diff --git a/.coveragerc b/.coveragerc
index 2c3a1e3..35dc139 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,10 +1,12 @@
[run]
include=*raspador*
-omit=tasks.py
+omit=tasks.py,*ordereddict*
[report]
exclude_lines =
raise NotImplementedError
- if __name__ == '__main__':
\ No newline at end of file
+ if __name__ == '__main__':
+
+ except ImportError:
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c44372b..3c7cc13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ MANIFEST
# Virtualenvs
env*
+.tox
# Distribute
build
diff --git a/.travis.yml b/.travis.yml
index 37fa2d9..a8b8bf9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -6,7 +6,6 @@ python:
- "3.3"
- "pypy"
install:
- - "pip install -r requirements_dev.txt --use-mirrors"
# For Python 2.6 support
- "pip install ordereddict --use-mirrors"
- "pip install coveralls"
diff --git a/README.rst b/README.rst
index f0e1282..46bfbe7 100644
--- a/README.rst
+++ b/README.rst
@@ -15,90 +15,99 @@ raspador
:target: https://crate.io/packages/raspador/
-Biblioteca para extração de dados em documentos semi-estruturados.
+Library to extract data from semi-structured text documents.
-A definição dos extratores é feita através de classes como modelos, de forma
-semelhante ao ORM do Django. Cada extrator procura por um padrão especificado
-por expressão regular, e a conversão para tipos primitidos é feita
-automaticamente a partir dos grupos capturados.
+It's best suited for data-processing in files that do not have a formal
+structure and are in plain text (or that are easy to convert). Structured files
+like XML, CSV and HTML doesn't fit a good use case for raspador, and have
+excellent alternatives to get data extracted, like lxml_, html5lib_,
+BeautifulSoup_, and PyQuery_.
+The extractors are defined through classes as models, something similar to the
+Django ORM. Each field searches for a pattern specified by the regular
+expression, and captured groups are converted automatically to primitives.
-O analisador é implementado como um gerador, onde cada item encontrado pode ser
-consumido antes do final da análise, caracterizando uma pipeline.
+The parser is implemented as a generator, where each item found can be consumed
+before the end of the analysis, featuring a pipeline.
+The analysis is forward-only, which makes it extremely quick, and thus any
+iterator that returns a string can be analyzed, including infinite streams.
-A análise é foward-only, o que o torna extremamente rápido, e deste modo
-qualquer iterador que retorne uma string pode ser analisado, incluindo streams
-infinitos.
+.. _lxml: http://lxml.de
+.. _html5lib: https://github.com/html5lib/html5lib-python
+.. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/
+.. _PyQuery: https://github.com/gawel/pyquery/
-Com uma base sólida e enxuta, é fácil construir seus próprios extratores.
+Install
+=======
-Além da utilidade da ferramenta, o raspador é um exemplo prático e simples da
-utilização de conceitos e recursos como iteradores, geradores, meta-programação
-e property-descriptors.
+raspador works on CPython 2.6+, CPython 3.2+ and PyPy. To install it, use::
+ pip install raspador
-Compatibilidade e dependências
-===============================
+or easy install::
-O raspador é compatível com Python 2.6, 2.7, 3.2, 3.3 e pypy.
+ easy_install raspador
-Desenvolvimento realizado em Python 2.7.5 e Python 3.2.3.
-Não há dependências externas.
+From source
+-----------
-.. note:: Python 2.6
+Download and install from source::
- Em Python 2.6, a biblioteca `ordereddict
- `_ é necessária.
+ git clone https://github.com/fgmacedo/raspador.git
+ cd raspador
+ python setup.py install
- Você pode instalar com pip::
- pip install ordereddict
+Dependencies
+------------
-Testes
-======
+There are no external dependencies.
-Os testes dependem de algumas bibliotecas externas:
-
-.. code-block:: text
+.. note:: Python 2.6
- coverage==3.6
- nose==1.3.0
- flake8==2.0
- invoke==0.5.0
+ With Python 2.6, you must install `ordereddict
+ `_.
+ You can install it with pip::
-Você pode executar os testes com ``nosetests``:
+ pip install ordereddict
-.. code-block:: bash
+Tests
+======
- $ nosetests
+To automate tests with all supported Python versions at once, we use `tox
+`_.
-E adicionalmente, verificar a compatibilidade com o PEP8:
+Run all tests with:
.. code-block:: bash
- $ flake8 raspador testes
+ $ tox
-Ou por conveniência, executar os dois em sequência com invoke:
+Tests depend on several third party libraries, but these are installed by tox
+on each Python's virtualenv:
-.. code-block:: bash
+.. code-block:: text
- $ invoke test
+ nose==1.3.0
+ coverage==3.6
+ flake8==2.0
-Exemplos
+Examples
========
-Extrator de dados em logs
--------------------------
+Extract data from logs
+----------------------
.. code-block:: python
+ from __future__ import print_function
import json
- from raspador import Analizador, CampoString
+ from raspador import Parser, StringField
out = """
PART:/dev/sda1 UUID:423k34-3423lk423-sdfsd-43 TYPE:ext4
@@ -107,22 +116,23 @@ Extrator de dados em logs
"""
- class AnalizadorDeLog(Analizador):
- inicio = r'^PART.*'
- fim = r'^PART.*'
- PART = CampoString(r'PART:([^\s]+)')
- UUID = CampoString(r'UUID:([^\s]+)')
- TYPE = CampoString(r'TYPE:([^\s]+)')
+ class LogParser(Parser):
+ begin = r'^PART.*'
+ end = r'^PART.*'
+ PART = StringField(r'PART:([^\s]+)')
+ UUID = StringField(r'UUID:([^\s]+)')
+ TYPE = StringField(r'TYPE:([^\s]+)')
- a = AnalizadorDeLog()
+ a = LogParser()
- # res é um gerador
- res = a.analizar(linha for linha in out.splitlines())
+ # res is a generator
+ res = a.parse(iter(out.splitlines()))
- print (json.dumps(list(res), indent=2))
+ out_as_json = json.dumps(list(res), indent=2)
+ print (out_as_json)
- # Saída:
+ # Output:
"""
[
{
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 26d4fd4..c3b8b0a 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,17 +1,17 @@
-Documentação do raspador
-========================
+.. _topics-index:
+
+================================
+Raspador |version| documentation
+================================
-Conteúdo:
-.. toctree::
- :maxdepth: 2
- raspador
+.. toctree::
+ :hidden:
+ intro/overview
+ intro/install
+ intro/tutorial
-Índices e tabelas
-==================
-* :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search`
+Looking for specific information? Try the :ref:`genindex` or :ref:`modindex`.
\ No newline at end of file
diff --git a/docs/source/intro/install.rst b/docs/source/intro/install.rst
new file mode 100644
index 0000000..dd78073
--- /dev/null
+++ b/docs/source/intro/install.rst
@@ -0,0 +1,28 @@
+
+*******
+Install
+*******
+
+
+Package managers
+================
+
+You can install using pip or easy_install.
+
+PIP::
+
+ pip install raspador
+
+Easy install::
+
+ easy_install raspador
+
+
+From source
+===========
+
+Download and install from source::
+
+ git clone https://github.com/fgmacedo/raspador.git
+ cd raspador
+ python setup.py install
\ No newline at end of file
diff --git a/docs/source/raspador.rst b/docs/source/raspador.rst
index 8985de3..ff9b919 100644
--- a/docs/source/raspador.rst
+++ b/docs/source/raspador.rst
@@ -1,4 +1,5 @@
+========
raspador
========
@@ -6,25 +7,25 @@ O módulo raspador fornece estrutura genérica para extração de dados a partir
arquivos texto semi-estruturados.
-Analizador
+Parser
----------
-.. automodule:: raspador.analizador
+.. automodule:: raspador.parser
:members:
Campos
------
-.. automodule:: raspador.campos
+.. automodule:: raspador.fields
:members:
:undoc-members:
-Coleções
---------
+Item
+----
-.. automodule:: raspador.colecoes
+.. automodule:: raspador.item
:members:
:undoc-members:
diff --git a/raspador/__init__.py b/raspador/__init__.py
index 1db55cc..0d1d845 100644
--- a/raspador/__init__.py
+++ b/raspador/__init__.py
@@ -1,9 +1,10 @@
# flake8: noqa
-from .analizador import Analizador, Dicionario
-from .campos import CampoBase, CampoString, CampoNumerico, \
- CampoInteiro, CampoData, CampoDataHora, CampoBooleano
+from .parser import Parser
+from .item import Dictionary
+from .fields import BaseField, StringField, FloatField, BRFloatField, \
+ IntegerField, DateField, DateTimeField, BooleanField
-from .decoradores import ProxyDeCampo, ProxyConcatenaAteRE
+from .decorators import FieldProxy, UnionUntilRegexProxy
from .cache import Cache
diff --git a/raspador/analizador.py b/raspador/analizador.py
deleted file mode 100644
index b481a0a..0000000
--- a/raspador/analizador.py
+++ /dev/null
@@ -1,172 +0,0 @@
-#coding: utf-8
-import re
-import weakref
-
-from .cache import Cache
-from .colecoes import Dicionario
-import collections
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class AuxiliarDeAnalizador(object):
- """
- Classe auxiliar para definir comportamentos que serão adicionados
- em todos os analizadores através de herança múltipla.
- Padrão mix-in.
- """
- def __init__(self):
- self.tem_busca_inicio = hasattr(self, '_inicio')
- self.tem_busca_fim = hasattr(self, '_fim')
- self.inicio_encontrado = not self.tem_busca_inicio
- self.cache = Cache(self.qtd_linhas_cache + 1)
- self.valor_padrao = None
- self._atribuir_analizador_nos_campos()
-
- def _atribuir_analizador_nos_campos(self):
- """
- Atribui uma referência fraca do analizador para seus campos.
- Não foi utilizada referência forte para não gerar dependência ciclica,
- impedindo a liberação de memória do analizador.
- """
- ref = weakref.ref(self)
- for item in list(self._campos.values()):
- if hasattr(item, 'atribuir_analizador'):
- item.atribuir_analizador(ref)
-
- def analizar(self, arquivo, codificacao='latin1'):
- for item in self.analizar_arquivo(arquivo, codificacao):
- yield item
-
- @property
- def tem_retorno(self):
- return hasattr(self, 'retorno') and self.retorno is not None
-
- def converter_linha(self, linha, codificacao):
- logger.debug('converter_linha (%s): %s', codificacao, linha)
- if codificacao == 'utf-8':
- return linha
- try:
- return linha.decode(codificacao).encode('utf-8')
- except:
- return linha
-
- def analizar_arquivo(self, arquivo, codificacao='latin1'):
- try:
- while True:
- linha = next(arquivo)
- linha = self.converter_linha(linha, codificacao)
- res = self.analizar_linha(linha)
- if res:
- yield res
- except StopIteration:
- res = self.finalizar()
- if res:
- yield res
-
- def analizar_linha(self, linha):
- self.cache.adicionar(linha)
-
- if self.tem_busca_inicio and not self.inicio_encontrado:
- self.inicio_encontrado = bool(self._inicio.match(linha))
-
- if self.inicio_encontrado:
- if not self.tem_retorno:
- self.retorno = Dicionario()
- if self.tem_busca_fim:
- self.inicio_encontrado = not bool(self._fim.match(linha))
-
- for linha in self.cache.consumir():
- for nome, campo in list(self._campos.items()):
- if nome in self.retorno and \
- hasattr(campo, 'lista') and not campo.lista:
- continue
- valor = campo.analizar_linha(linha)
- if valor is not None:
- self.atribuir_valor_ao_retorno(nome, valor)
- if self.retornar_ao_obter_valor:
- return self.finalizar_retorno()
-
- if not self.inicio_encontrado:
- return self.finalizar_retorno()
-
- def finalizar(self):
- if not self.tem_retorno:
- return None
- if self.retornar_ao_obter_valor:
- return None
- return self.finalizar_retorno()
-
- def finalizar_retorno(self):
- for nome, campo in list(self._campos.items()):
- if not nome in self.retorno:
- valor = None
- if hasattr(campo, 'finalizar') and \
- isinstance(campo.finalizar, collections.Callable):
- valor = campo.finalizar()
- if valor is None:
- valor = campo.valor_padrao
- if valor is not None:
- self.atribuir_valor_ao_retorno(nome, valor)
-
- self.processar_retorno()
- res = self.retorno
- self.retorno = None
- return res
-
- def atribuir_valor_ao_retorno(self, nome, valor):
- if isinstance(valor, list) and not nome in self.retorno:
- self.retorno[nome] = valor
- elif isinstance(valor, list) and hasattr(self.retorno[nome], 'extend'):
- self.retorno[nome].extend(valor)
- else:
- self.retorno[nome] = valor
-
- def processar_retorno(self):
- "Permite modificações finais ao objeto sendo retornado"
- pass
-
-
-class MetaclasseDeAnalizador(type):
- """
- Metaclasse responsável por descobrir os coletores de informações associados
- à um parser.
- """
- def __new__(self, name, bases, attrs):
- if object in bases:
- bases = tuple([c for c in bases if c != object])
-
- return type.__new__(self, name, bases + (AuxiliarDeAnalizador,), attrs)
-
- def __init__(cls, name, bases, attrs):
- super(MetaclasseDeAnalizador, cls).__init__(name, bases, attrs)
-
- cls._campos = dict((k, v) for k, v in list(attrs.items())
- if hasattr(v, 'analizar_linha')
- and not isinstance(v, type))
-
- cls.adicionar_atributo_re(cls, attrs, 'inicio')
- cls.adicionar_atributo_re(cls, attrs, 'fim')
-
- if not hasattr(cls, 'qtd_linhas_cache'):
- cls.qtd_linhas_cache = 0
-
- if not hasattr(cls, 'retornar_ao_obter_valor'):
- cls.retornar_ao_obter_valor = False
-
- for nome, atributo in list(cls._campos.items()):
- if hasattr(atributo, 'anexar_na_classe'):
- atributo.anexar_na_classe(cls, nome, cls._campos)
-
- def adicionar_atributo_re(self, cls, atributos, nome):
- if nome in atributos:
- expressao = atributos[nome]
- setattr(cls, '_' + nome, re.compile(expressao))
-
- @classmethod
- def __prepare__(self, name, bases):
- return Dicionario()
-
-
-Analizador = MetaclasseDeAnalizador('Analizador', (object,), {})
diff --git a/raspador/cache.py b/raspador/cache.py
index f6ff336..044e6f6 100644
--- a/raspador/cache.py
+++ b/raspador/cache.py
@@ -1,23 +1,24 @@
#coding: utf-8
+from collections import deque
class Cache(object):
- def __init__(self, tamanho=0):
- self.tamanho = tamanho
- self.lista = []
+ def __init__(self, max_length=0):
+ self.max_length = max_length
+ self.items = deque()
def __len__(self):
- return len(self.lista)
+ return len(self.items)
- def adicionar(self, item):
- self.lista.append(item)
- if self.tamanho:
- while len(self.lista) > self.tamanho:
- self.lista.pop(0)
+ def append(self, item):
+ self.items.append(item)
+ if self.max_length:
+ while len(self.items) > self.max_length:
+ self.items.popleft()
def itens(self):
- return self.lista
+ return self.items
- def consumir(self):
- while self.lista:
- yield self.lista.pop(0)
+ def consume(self):
+ while self.items:
+ yield self.items.popleft()
diff --git a/raspador/campos.py b/raspador/campos.py
deleted file mode 100644
index a5f29a9..0000000
--- a/raspador/campos.py
+++ /dev/null
@@ -1,266 +0,0 @@
-#coding: utf-8
-
-"""
-Os campos são simples extratores de dados baseados em expressões regulares.
-
-Ao confrontar uma linha recebida para análise com sua expressão regular, o
-campo verifica se há grupos de dados capturados, e então pode realizar algum
-processamento e validações nestes dados. Se o campo considerar os dados
-válidos, retorna o(s) dado(s). """
-
-import re
-from datetime import datetime
-import collections
-
-
-class CampoBase(object):
- """
- Contém lógica de processamento para extrair dados através de expressões
- regulares, além de prover métodos utilitários que podem ser sobrescritos
- para customizações no tratamento dos dados.
-
- O comportamento do Campo pode ser ajustado através de diversos parâmetros:
-
- mascara
-
- O requisito mínimo para um campo é uma máscara em expressão regular,
- onde deve-se especificar um grupo para captura::
-
- >>> s = "02/01/2013 10:21:51 COO:022734"
- >>> campo = CampoBase(mascara=r'COO:(\d+)')
- >>> campo.analizar_linha(s)
- '022734'
-
- O parâmetro mascara é o único posicional, e deste modo, seu nome pode
- ser omitido::
-
- >>> s = "02/01/2013 10:21:51 COO:022734"
- >>> campo = CampoBase(r'COO:(\d+)')
- >>> campo.analizar_linha(s)
- '022734'
-
-
- ao_atribuir
-
- Recebe um callback para tratar o valor antes de ser retornado pelo
- campo.
-
- >>> s = "02/01/2013 10:21:51 COO:022734"
- >>> def dobro(valor):
- ... return int(valor) * 2
- ...
- >>> campo = CampoBase(r'COO:(\d+)', ao_atribuir=dobro)
- >>> campo.analizar_linha(s) # 45468 = 2 x 22734
- 45468
-
- grupos
-
- Permite escolher quais grupos capturados o campo deve processar como
- dados de entrada, utilizado para expressões regulares que utilizam
- grupos para correspondência da expressão regular, mas que apenas parte
- destes grupos possui informação útil.
-
- Pode-se informar um número inteiro, que será o índice do grupo,
- inicando em 0::
-
- >>> s = "Contador de Reduções Z: 1246"
- >>> campo = CampoBase(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \
- grupos=1, ao_atribuir=int)
- >>> campo.analizar_linha(s)
- 1246
-
- Ou uma lista de inteiros::
-
- >>> s = "Data do movimento: 02/01/2013 10:21:51"
- >>> c = CampoBase(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\
- grupos=[1, 2, 3])
- >>> c.analizar_linha(s)
- ['02', '01', '2013']
-
-
- valor_padrao
-
- Valor que será utilizado no :py:class:`~raspador.analizador.Analizador`
- , quando o campo não retornar valor após a análise das
- linhas recebidas.
-
-
- lista
-
- Quando especificado, retorna o valor como uma lista::
-
- >>> s = "02/01/2013 10:21:51 COO:022734"
- >>> campo = CampoBase(r'COO:(\d+)', lista=True)
- >>> campo.analizar_linha(s)
- ['022734']
-
- Por convenção, quando um campo retorna uma lista, o
- :py:class:`~raspador.analizador.Analizador` acumula os valores
- retornados pelo campo.
- """
- def __init__(self, mascara=None, **kwargs):
- class_unique_name = self.__class__.__name__ + str(id(self))
- if not hasattr(self, 'nome'):
- self.nome = kwargs.get('nome', class_unique_name)
-
- self.valor_padrao = kwargs.get('valor_padrao')
- self.lista = kwargs.get('lista', False)
- self.mascara = mascara
- self.ao_atribuir = kwargs.get('ao_atribuir')
- self.grupos = kwargs.get('grupos', [])
-
- if self.ao_atribuir and \
- not isinstance(self.ao_atribuir, collections.Callable):
- raise TypeError('O callback ao_atribuir não é uma função.')
-
- if not hasattr(self.grupos, '__iter__'):
- self.grupos = (self.grupos,)
-
- self._iniciar()
-
- @property
- def _metodo_busca(self):
- return self.mascara.findall
-
- def _iniciar(self):
- "Ponto para inicialização especial nas classes descendentes"
- pass
-
- def atribuir_analizador(self, analizador):
- """
- Recebe uma referência fraca de
- :py:class:`~raspador.analizador.Analizador`
- """
- self.analizador = analizador
-
- def _resultado_valido(self, valor):
- return bool(valor)
-
- def _converter(self, valor):
- if self.grupos:
- if len(valor) == 1: # não é desejado uma tupla,
- valor = valor[0] # se houver apenas um item
- tamanho = len(valor)
- valor = [valor[i] for i in self.grupos if i < tamanho]
-
- if len(valor) == 1: # não é desejado uma tupla,
- valor = valor[0] # se houver apenas um item
- return valor
-
- def _para_python(self, valor):
- """
- Converte o valor recebido palo parser para o tipo de dado
- nativo do python
- """
- return valor
-
- @property
- def mascara(self):
- return self._mascara
-
- @mascara.setter
- def mascara(self, valor):
- self._mascara = re.compile(valor) if valor else None
-
- def anexar_na_classe(self, cls, nome, informacoes):
- self.cls = cls
- self.nome = nome
- self.informacoes = informacoes
-
- def analizar_linha(self, linha):
- if self.mascara:
- valor = self._metodo_busca(linha)
- if self._resultado_valido(valor):
- valor = self._converter(valor)
- valor = self._para_python(valor)
- if self.ao_atribuir:
- valor = self.ao_atribuir(valor)
- if valor is not None and self.lista \
- and not isinstance(valor, list):
- valor = [valor]
- return valor
-
-
-class CampoString(CampoBase):
- def _para_python(self, valor):
- return str(valor).strip()
-
-
-class CampoNumerico(CampoBase):
- def _para_python(self, valor):
- valor = valor.replace('.', '')
- valor = valor.replace(',', '.')
- return float(valor)
-
-
-class CampoInteiro(CampoBase):
- def _para_python(self, valor):
- return int(valor)
-
-
-class CampoBooleano(CampoBase):
- """
- Retorna verdadeiro se a Regex bater com uma linha completa, e
- se ao menos algum valor for capturado.
- """
- def _iniciar(self):
- self.valor_padrao = False
-
- @property
- def _metodo_busca(self):
- return self.mascara.match
-
- def _converter(self, valor):
- res = valor.groups() if valor else False
- return super(CampoBooleano, self)._converter(res)
-
- def _resultado_valido(self, valor):
- return valor and (valor.groups())
-
- def _para_python(self, valor):
- return bool(valor)
-
-
-class CampoData(CampoBase):
- """
- Campo que mantém dados no formato de data,
- representado em Python por datetine.date.
-
- Formato:
- Veja http://docs.python.org/library/datetime.html para detalhes.
- """
-
- def __init__(self, mascara=None, **kwargs):
- """
- formato='%d/%m/%Y'
- """
- super(CampoData, self).__init__(mascara=mascara, **kwargs)
- self.formato = kwargs.get('formato', '%d/%m/%Y')
-
- def _para_python(self, valor):
- return datetime.strptime(valor, self.formato).date()
-
-
-class CampoDataHora(CampoBase):
- """
- Campo que mantém dados no formato de data/hora,
- representado em Python por datetine.datetime.
-
- Formato:
- Veja http://docs.python.org/library/datetime.html para detalhes.
- """
-
- def __init__(self, mascara=None, **kwargs):
- """
- formato='%d/%m/%Y %H:%M:%S'
- """
- super(CampoDataHora, self).__init__(mascara=mascara, **kwargs)
- self.formato = kwargs.get('formato', '%d/%m/%Y %H:%M:%S')
-
- def _para_python(self, valor):
- return datetime.strptime(valor, self.formato)
-
-
-if __name__ == '__main__':
- import doctest
- doctest.testmod()
diff --git a/raspador/colecoes.py b/raspador/colecoes.py
deleted file mode 100644
index d2015fe..0000000
--- a/raspador/colecoes.py
+++ /dev/null
@@ -1,19 +0,0 @@
-#coding: utf-8
-try:
- from collections import OrderedDict
-except:
- # Python 2.6 alternative
- from ordereddict import OrderedDict
-
-
-class Dicionario(OrderedDict):
- """
- Dicionário especializado que permite acesso de chaves como propriedades.
- Adicionalmente, se uma chave já foi atribuída, transforma o valor em uma
- lista e acumula os valores.
- """
- def __getattr__(self, nome):
- if nome in self:
- return self[nome]
- raise AttributeError("%r sem atributo %r" %
- (type(self).__name__, nome))
diff --git a/raspador/decoradores.py b/raspador/decoradores.py
deleted file mode 100644
index e45e836..0000000
--- a/raspador/decoradores.py
+++ /dev/null
@@ -1,32 +0,0 @@
-#coding: utf-8
-import re
-
-
-class ProxyDeCampo(object):
- def __init__(self, campo):
- self.campo = campo
-
- def __getattr__(self, atributo):
- return getattr(self.campo, atributo)
-
-
-class ProxyConcatenaAteRE(ProxyDeCampo):
- """
- Proxy que faz cache de linhas recebidas. Quando recebe uma linha para
- análise, envia a linha ao cache, até encontrar um match da linha recebida
- com a expressão regular de término, e então envia o acumulado das linhas
- recebidas para o decorado.
- """
- def __init__(self, campo, uniao, re_fim):
- super(ProxyConcatenaAteRE, self).__init__(campo)
- self.cache = []
- self.uniao = uniao
- self.re_fim = re.compile(re_fim)
-
- def analizar_linha(self, linha):
- linha = linha.rstrip()
- self.cache.append(linha)
- if self.re_fim.match(linha):
- acumulado = self.uniao(self.cache)
- self.cache = []
- return self.campo.analizar_linha(acumulado)
diff --git a/raspador/decorators.py b/raspador/decorators.py
new file mode 100644
index 0000000..77cb7a8
--- /dev/null
+++ b/raspador/decorators.py
@@ -0,0 +1,31 @@
+#coding: utf-8
+import re
+
+
+class FieldProxy(object):
+ def __init__(self, field):
+ self.field = field
+
+ def __getattr__(self, attr):
+ return getattr(self.field, attr)
+
+
+class UnionUntilRegexProxy(FieldProxy):
+ """
+ Does cache of blocks until the provided regex returns a match, then uses
+ the ``union_method`` to join blocks that are sent to the decorated field.
+ """
+ def __init__(self, field, union_method, search_regex):
+ super(UnionUntilRegexProxy, self).__init__(field)
+ self.cache = []
+ self.union_method = union_method
+ self.search_regex = re.compile(search_regex, re.UNICODE)
+
+ def parse_block(self, block):
+ if hasattr(block, 'rstrip'):
+ block = block.rstrip()
+ self.cache.append(block)
+ if self.search_regex.match(block):
+ blocks = self.union_method(self.cache)
+ self.cache = []
+ return self.field.parse_block(blocks)
diff --git a/raspador/fields.py b/raspador/fields.py
new file mode 100644
index 0000000..99784ec
--- /dev/null
+++ b/raspador/fields.py
@@ -0,0 +1,269 @@
+#coding: utf-8
+
+"""
+
+Fields define how and what data will be extracted. The parser does not expect
+the fields explicitly inherit from :py:class:`~raspador.fields.BaseField`, the
+minimum expected is that a field has at least a method `parse_block`.
+
+The fields in this file are based on regular expressions and provide conversion
+for primitive types in Python.
+"""
+
+import re
+from datetime import datetime
+import collections
+
+
+class BaseField(object):
+ """
+ Contains processing logic to extract data using regular expressions, and
+ provide utility methods that can be overridden for custom data processing.
+
+
+ Default behavior can be adjusted by parameters:
+
+ search
+
+ Regular expression that must specify a group of capture. Use
+ parentheses for capturing::
+
+ >>> s = "02/01/2013 10:21:51 COO:022734"
+ >>> field = BaseField(search=r'COO:(\d+)')
+ >>> field.parse_block(s)
+ '022734'
+
+ The `search` parameter is the only by position and hence its name can
+ be omitted::
+
+ >>> s = "02/01/2013 10:21:51 COO:022734"
+ >>> field = BaseField(r'COO:(\d+)')
+ >>> field.parse_block(s)
+ '022734'
+
+
+ input_processor
+
+ Receives a function to handle the captured value before being returned
+ by the field.
+
+ >>> s = "02/01/2013 10:21:51 COO:022734"
+ >>> def double(value):
+ ... return int(value) * 2
+ ...
+ >>> field = BaseField(r'COO:(\d+)', input_processor=double)
+ >>> field.parse_block(s) # 45468 = 2 x 22734
+ 45468
+
+ groups
+
+ Specify which numbered capturing groups do you want do process in.
+
+
+ You can enter a integer number, as the group index::
+
+ >>> s = "Contador de Reduções Z: 1246"
+ >>> field = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \
+ groups=1, input_processor=int)
+ >>> field.parse_block(s)
+ 1246
+
+ Or a list of integers::
+
+ >>> s = "Data do movimento: 02/01/2013 10:21:51"
+ >>> c = BaseField(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\
+ groups=[1, 2, 3])
+ >>> c.parse_block(s)
+ ['02', '01', '2013']
+
+ .. note::
+
+ If you do not need the group to capture its match, you can optimize
+ the regular expression putting an `?:` after the opening
+ parenthesis::
+
+ >>> s = "Contador de Reduções Z: 1246"
+ >>> field = BaseField(r'Contador de Reduç(?:ão|ões) Z:\s*(\d+)')
+ >>> field.parse_block(s)
+ '1246'
+
+ default
+
+ If assigned, the :py:class:`~raspador.parser.Parser` will query this
+ default if no value was returned by the field.
+
+ is_list
+
+ When specified, returns the value as a list::
+
+ >>> s = "02/01/2013 10:21:51 COO:022734"
+ >>> field = BaseField(r'COO:(\d+)', is_list=True)
+ >>> field.parse_block(s)
+ ['022734']
+
+ By convention, when a field returns a list, the
+ :py:class:`~raspador.parser.Parser` accumulates values
+ returned by the field.
+
+ """
+ def __init__(self, search=None, default=None, is_list=False,
+ input_processor=None, groups=[]):
+ self.search = search
+ self.default = default
+ self.is_list = is_list
+ self.input_processor = input_processor
+ self.groups = groups
+
+ if self.input_processor and \
+ not isinstance(self.input_processor, collections.Callable):
+ raise TypeError('input_processor is not callable.')
+
+ if not hasattr(self.groups, '__iter__'):
+ self.groups = (self.groups,)
+
+ self._setup()
+
+ @property
+ def _search_method(self):
+ return self.search.findall
+
+ def _setup(self):
+ "Hook to special setup required on child classes"
+ pass
+
+ def assign_parser(self, parser):
+ """
+ Receives a weak reference of
+ :py:class:`~raspador.parser.Parser`
+ """
+ self.parser = parser
+
+ def _is_valid_result(self, value):
+ return bool(value)
+
+ def _process_value(self, value):
+ if self.groups:
+ if len(value) == 1: # take first, if only one item
+ value = value[0]
+ length = len(value)
+ value = [value[i] for i in self.groups if i < length]
+
+ if len(value) == 1: # take first, if only one item
+ value = value[0]
+ return value
+
+ def to_python(self, value):
+ """
+ Converts parsed data to native python type.
+ """
+ return value
+
+ @property
+ def search(self):
+ return self._search
+
+ @search.setter
+ def search(self, value):
+ self._search = re.compile(value, re.UNICODE) if value else None
+
+ def assign_class(self, cls, name):
+ self.cls = cls
+
+ def parse_block(self, block):
+ if self.search:
+ value = self._search_method(block)
+ if self._is_valid_result(value):
+ value = self._process_value(value)
+ value = self.to_python(value)
+ if self.input_processor:
+ value = self.input_processor(value)
+ if value is not None and self.is_list \
+ and not isinstance(value, list):
+ value = [value]
+ return value
+
+
+class StringField(BaseField):
+ def to_python(self, value):
+ return str(value).strip()
+
+
+class FloatField(BaseField):
+ "Removes thousand separator and converts to float."
+ def to_python(self, value):
+ value = value.replace(',', '')
+ return float(value)
+
+
+class BRFloatField(BaseField):
+ "Removes thousand separator and converts to float (Brazilian format)"
+ def to_python(self, value):
+ value = value.replace('.', '')
+ value = value.replace(',', '.')
+ return float(value)
+
+
+class IntegerField(BaseField):
+ def to_python(self, value):
+ return int(value)
+
+
+class BooleanField(BaseField):
+ """
+ Returns true if the block is matched by Regex, and is at least some value
+ is captured.
+ """
+ def _setup(self):
+ self.default = False
+
+ @property
+ def _search_method(self):
+ return self.search.match
+
+ def _process_value(self, value):
+ res = value.groups() if value else False
+ return super(BooleanField, self)._process_value(res)
+
+ def _is_valid_result(self, value):
+ return value and (value.groups())
+
+ def to_python(self, value):
+ return bool(value)
+
+
+class DateField(BaseField):
+ """
+
+ Field that holds data in date format, represented in Python by
+ datetine.date.
+
+ http://docs.python.org/library/datetime.html
+ """
+
+ default_format_string = '%d/%m/%Y'
+ convertion_function = lambda self, date: datetime.date(date)
+
+ def __init__(self, search=None, formato=None, **kwargs):
+ self.formato = formato if formato else self.default_format_string
+ super(DateField, self).__init__(search=search, **kwargs)
+
+ def to_python(self, value):
+ date_value = datetime.strptime(value, self.formato)
+ return self.convertion_function(date_value)
+
+
+class DateTimeField(DateField):
+ """
+ Field that holds data in hour/date format, represented in Python by
+ datetine.datetime.
+
+ http://docs.python.org/library/datetime.html
+ """
+
+ default_format_string = '%d/%m/%Y %H:%M:%S'
+ convertion_function = lambda self, date: date
+
+
+if __name__ == '__main__':
+ import doctest
+ doctest.testmod()
diff --git a/raspador/item.py b/raspador/item.py
new file mode 100644
index 0000000..94b0387
--- /dev/null
+++ b/raspador/item.py
@@ -0,0 +1,17 @@
+#coding: utf-8
+try:
+ from collections import OrderedDict
+except ImportError:
+ # Python 2.6 alternative
+ from ordereddict import OrderedDict
+
+
+class Dictionary(OrderedDict):
+ """
+ Dictionary that exposes keys as properties for easy read access.
+ """
+ def __getattr__(self, name):
+ if name in self:
+ return self[name]
+ raise AttributeError("%s without attr '%s'" %
+ (type(self).__name__, name))
diff --git a/raspador/parser.py b/raspador/parser.py
new file mode 100644
index 0000000..5d2b796
--- /dev/null
+++ b/raspador/parser.py
@@ -0,0 +1,156 @@
+#coding: utf-8
+# from __future__ import unicode_literals
+import re
+import weakref
+import collections
+import logging
+
+from .cache import Cache
+from .item import Dictionary
+
+logger = logging.getLogger(__name__)
+
+
+class ParserMixin(object):
+ """
+ A mixin that holds all base parser implementation.
+ """
+
+ number_of_blocks_in_cache = 0
+ default_item_class = Dictionary
+ yield_item_to_each_field_value_found = False
+ begin = None
+ end = None
+
+ def __init__(self):
+ self.begin_found = not self.has_search_begin
+ self.cache = Cache(self.number_of_blocks_in_cache + 1)
+ self._assign_parser_to_fields()
+
+ def _assign_parser_to_fields(self):
+ """
+ Assigns an weak parser reference to fields.
+ """
+ ref = weakref.ref(self)
+ for item in list(self.fields.values()):
+ if hasattr(item, 'assign_parser'):
+ item.assign_parser(ref)
+
+ def parse(self, iterator):
+ for item in self.parse_iterator(iterator):
+ yield item
+
+ @property
+ def has_item(self):
+ return hasattr(self, 'item') and self.item is not None
+
+ def parse_iterator(self, iterator):
+ try:
+ while True:
+ block = next(iterator)
+ res = self.parse_block(block)
+ if res:
+ yield res
+ except StopIteration:
+ res = self.finalize()
+ if res:
+ yield res
+
+ def parse_block(self, block):
+ logger.debug('parse_block: %r:%s', type(block), block)
+ self.cache.append(block)
+
+ if self.has_search_begin and not self.begin_found:
+ self.begin_found = bool(self._begin.match(block))
+
+ if self.begin_found:
+ logger.debug('init found: %r', self.begin_found)
+ if not self.has_item:
+ self.item = self.default_item_class()
+ if self.has_search_end:
+ self.begin_found = not bool(self._end.match(block))
+
+ for block in self.cache.consume():
+ for name, field in list(self.fields.items()):
+ if name in self.item and \
+ hasattr(field, 'is_list') and not field.is_list:
+ continue
+ value = field.parse_block(block)
+ if value is not None:
+ self.assign_value_into_item(name, value)
+ if self.yield_item_to_each_field_value_found:
+ return self.finalize_item()
+
+ if not self.begin_found:
+ return self.finalize_item()
+
+ def finalize(self):
+ if not self.has_item:
+ return None
+ if self.yield_item_to_each_field_value_found:
+ return None
+ return self.finalize_item()
+
+ def finalize_item(self):
+ for name, field in list(self.fields.items()):
+ if not name in self.item:
+ value = None
+ if hasattr(field, 'finalize') and \
+ isinstance(field.finalize, collections.Callable):
+ value = field.finalize()
+ if value is None:
+ value = field.default
+ if value is not None:
+ self.assign_value_into_item(name, value)
+
+ res = self.process_item(self.item)
+ self.item = None
+ return res
+
+ def assign_value_into_item(self, name, value):
+ if isinstance(value, list) and not name in self.item:
+ self.item[name] = value
+ elif isinstance(value, list) and hasattr(self.item[name], 'extend'):
+ self.item[name].extend(value)
+ else:
+ self.item[name] = value
+
+ def process_item(self, item):
+ "Allows final modifications at the object being returned"
+ return item
+
+
+class ParserMetaclass(type):
+ """
+ Collect data-extractors into a field collection and injects ParserMixin.
+ """
+ def __new__(self, name, bases, attrs):
+ if object in bases:
+ bases = tuple([c for c in bases if c != object])
+
+ return type.__new__(self, name, bases + (ParserMixin,), attrs)
+
+ def __init__(cls, name, bases, attrs):
+ super(ParserMetaclass, cls).__init__(name, bases, attrs)
+
+ cls.fields = dict((k, v) for k, v in list(attrs.items())
+ if hasattr(v, 'parse_block')
+ and not isinstance(v, type))
+
+ cls.add_regex_attr(cls, attrs, 'begin')
+ cls.add_regex_attr(cls, attrs, 'end')
+
+ for name, attr in list(cls.fields.items()):
+ if hasattr(attr, 'assign_class'):
+ attr.assign_class(cls, name)
+
+ def add_regex_attr(self, cls, attrs, name):
+ has_attr = name in attrs
+ setattr(cls, 'has_search_'+name, has_attr)
+ if has_attr:
+ regex = attrs[name]
+ logger.error('add_regex_attr %s: %s', name, regex)
+ setattr(cls, '_' + name, re.compile(regex, re.UNICODE))
+
+
+Parser = ParserMetaclass('Parser', (object,), {})
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 4e0608f..5427ac8 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,4 +1 @@
-coverage==3.6
-nose==1.3.0
-flake8==2.0
-invoke==0.5.0
\ No newline at end of file
+tox==1.6.1
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index 6ce8081..c757787 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,3 +3,4 @@ where=tests
exe=1
with-coverage=1
with-doctest=1
+cover-erase=1
\ No newline at end of file
diff --git a/setup.py b/setup.py
index d3c23a9..81db305 100644
--- a/setup.py
+++ b/setup.py
@@ -10,11 +10,11 @@
name='raspador',
author='Fernando Macedo',
author_email='fgmacedo@gmail.com',
- description='Biblioteca para extração de dados em documentos',
+ description='Library to extract data from semi-structured text documents',
long_description=long_description,
license='MIT',
url="http://github.org/fgmacedo/raspador",
- version='0.1.3',
+ version='0.2.0',
packages=['raspador'],
classifiers=[
'Development Status :: 3 - Alpha',
diff --git a/tasks.py b/tasks.py
deleted file mode 100644
index a496e6f..0000000
--- a/tasks.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from invoke import run, task
-
-
-@task
-def clean():
- run('git clean -Xfd')
-
-
-@task
-def test():
- commands = ['nosetests', 'flake8']
- for c in commands:
- run('{} raspador tests'.format(c))
diff --git a/tests/arquivos/cupom.txt b/tests/files/cupom.txt
similarity index 100%
rename from tests/arquivos/cupom.txt
rename to tests/files/cupom.txt
diff --git a/tests/arquivos/cupom_cancelado.txt b/tests/files/cupom_cancelado.txt
similarity index 100%
rename from tests/arquivos/cupom_cancelado.txt
rename to tests/files/cupom_cancelado.txt
diff --git a/tests/arquivos/reducaoz.txt b/tests/files/reducaoz.txt
similarity index 100%
rename from tests/arquivos/reducaoz.txt
rename to tests/files/reducaoz.txt
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..d571b47
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,40 @@
+#coding: utf-8
+import unittest
+from raspador.cache import Cache
+
+
+class Test_Cache(unittest.TestCase):
+ def setUp(self):
+ self.cache = Cache(3)
+
+ def test_should_keep_last_items(self):
+ self.cache.append(1)
+ self.cache.append(2)
+ self.cache.append(3)
+ self.cache.append(4)
+ self.assertEqual(list(self.cache.itens()), [2, 3, 4])
+
+ def test_should_consume_cache(self):
+ self.cache.append(1)
+ self.cache.append(2)
+ self.cache.append(3)
+ self.cache.append(4)
+ self.assertEqual(list(self.cache.consume()), [2, 3, 4])
+ self.cache.append(5)
+ self.assertEqual(list(self.cache.consume()), [5])
+ self.cache.append(6)
+ self.cache.append(7)
+ self.assertEqual(list(self.cache.consume()), [6, 7])
+
+ def test_should_return_empty_if_empty(self):
+ valor = list(self.cache.consume())
+ self.assertEqual(valor, [])
+
+ def test_should_return_cache_length(self):
+ self.cache.append(1)
+ self.cache.append(2)
+ self.assertEqual(len(self.cache), 2)
+ self.cache.append(3)
+ self.cache.append(4)
+ self.cache.append(5)
+ self.assertEqual(len(self.cache), 3)
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
new file mode 100644
index 0000000..86d913c
--- /dev/null
+++ b/tests/test_decorators.py
@@ -0,0 +1,95 @@
+#coding: utf-8
+import unittest
+
+from raspador import FieldProxy, UnionUntilRegexProxy
+
+
+class CampoFake(object):
+ def __init__(self, default=None, is_list=False, retornar=False):
+ self.default = default
+ self.is_list = is_list
+ self.lines = []
+ self.retornar = retornar
+
+ def parse_block(self, linha):
+ self.lines.append(linha)
+ if self.retornar:
+ return linha
+
+ def assign_class(self, cls, nome, informacoes):
+ self.classe = cls
+ self.nome = nome
+ self.informacoes = informacoes
+
+
+class TesteDeProxyChamandoMetodosSemIntervencao(unittest.TestCase):
+ def teste_decorador_deve_retornar_default(self):
+ mock = CampoFake(default=1234)
+ p = FieldProxy(mock)
+ self.assertEqual(
+ p.default,
+ 1234
+ )
+
+ def teste_decorador_deve_retornar_valor_lista(self):
+ mock = CampoFake(is_list=True)
+ p = FieldProxy(mock)
+ self.assertEqual(
+ p.is_list,
+ True
+ )
+
+ def teste_decorador_deve_passar_linhas(self):
+ mock = CampoFake()
+ p = FieldProxy(mock)
+ p.parse_block('teste1')
+ p.parse_block('teste2')
+ self.assertEqual(
+ mock.lines,
+ ['teste1', 'teste2']
+ )
+
+
+class TesteDeDecoradorConcatenaAteRE(unittest.TestCase):
+
+ def teste_deve_chamar_decorado_acumulando_linhas(self):
+ mock = CampoFake()
+ p = UnionUntilRegexProxy(mock, ' '.join, 'l4|l6')
+ p.parse_block('l1')
+ p.parse_block('l2')
+ p.parse_block('l3')
+ p.parse_block('l4')
+ p.parse_block('l5')
+ p.parse_block('l6')
+ self.assertEqual(
+ [
+ 'l1 l2 l3 l4',
+ 'l5 l6',
+ ],
+ mock.lines
+ )
+
+ def teste_deve_chamar_decorado_retornando_primeiro_valor(self):
+ mock = CampoFake(retornar=True)
+ p = UnionUntilRegexProxy(mock, ' '.join, 'l\d')
+ p.parse_block('l1')
+ p.parse_block('l2')
+ p.parse_block('l3')
+ p.parse_block('l4')
+ p.parse_block('l5')
+ p.parse_block('l6')
+ self.assertEqual(
+ [
+ 'l1',
+ 'l2',
+ 'l3',
+ 'l4',
+ 'l5',
+ 'l6',
+ ],
+ mock.lines
+ )
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_fields.py b/tests/test_fields.py
new file mode 100644
index 0000000..df7deb7
--- /dev/null
+++ b/tests/test_fields.py
@@ -0,0 +1,147 @@
+#coding: utf-8
+
+import unittest
+from datetime import date, datetime
+
+from raspador.fields import BaseField, StringField, FloatField, BRFloatField, \
+ IntegerField, DateField, DateTimeField, BooleanField
+
+
+class TestBaseField(unittest.TestCase):
+
+ def test_should_retornar_valor_no_analizar(self):
+ s = "02/01/2013 10:21:51 COO:022734"
+ campo = BaseField(r'COO:(\d+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, '022734')
+
+ def test_should_retornar_none_sem_search(self):
+ s = "02/01/2013 10:21:51 COO:022734"
+ campo = BaseField()
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, None)
+
+ def test_should_aceitar_callback(self):
+ s = "02/01/2013 10:21:51 COO:022734"
+
+ def dobro(valor):
+ return int(valor) * 2
+
+ campo = BaseField(r'COO:(\d+)', input_processor=dobro)
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 45468) # 45468 = 2 x 22734
+
+ def test_should_recusar_callback_invalido(self):
+ self.assertRaises(
+ TypeError,
+ lambda: BaseField(r'COO:(\d+)', input_processor='pegadinha')
+ )
+
+ def test_should_utilizar_grupo_quando_informado(self):
+ s = "Contador de Reduções Z: 1246"
+ campo = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', groups=1,
+ input_processor=int)
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 1246)
+
+
+class TestIntegerField(unittest.TestCase):
+ def test_should_obter_valor(self):
+ s = "02/01/2013 10:21:51 COO:022734"
+ campo = IntegerField(r'COO:(\d+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 22734)
+
+
+class TestFloatField(unittest.TestCase):
+ def test_should_obter_valor(self):
+ s = "VENDA BRUTA DIÁRIA: 793.00"
+ campo = FloatField(r'VENDA BRUTA DIÁRIA:\s+(\d+\.\d+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 793.0)
+
+ def test_should_obter_valor_com_separador_de_milhar(self):
+ s = "VENDA BRUTA DIÁRIA: 10,036.70"
+ campo = FloatField(r'VENDA BRUTA DIÁRIA:\s+([\d,]+.\d+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 10036.7)
+
+
+class TestBRFloatField(unittest.TestCase):
+ def test_should_obter_valor(self):
+ s = "VENDA BRUTA DIÁRIA: 793,00"
+ campo = BRFloatField(r'VENDA BRUTA DIÁRIA:\s+(\d+,\d+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 793.0)
+
+ def test_should_obter_valor_com_separador_de_milhar(self):
+ s = "VENDA BRUTA DIÁRIA: 10.036,70"
+ campo = BRFloatField(r'VENDA BRUTA DIÁRIA:\s+([\d.]+,\d+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 10036.7)
+
+
+class TestStringField(unittest.TestCase):
+ def test_should_obter_valor(self):
+ s = "1 Dinheiro 0,00"
+ campo = StringField(r'\d+\s+(\w[^\d]+)')
+ valor = campo.parse_block(s)
+ self.assertEqual(valor, 'Dinheiro')
+
+
+class TestBooleanField(unittest.TestCase):
+ s = " CANCELAMENTO "
+
+ def test_should_obter_valor_verdadeiro_se_bater_e_capturar(self):
+ campo = BooleanField(r'^\s+(CANCELAMENTO)\s+$')
+ valor = campo.parse_block(self.s)
+ self.assertEqual(valor, True)
+
+ def test_should_retornar_falso_ao_finalizar_quando_regex_nao_bate(self):
+ campo = BooleanField(r'^\s+HAH\s+$')
+ valor = campo.parse_block(self.s)
+ self.assertEqual(valor, None)
+ valor = campo.default
+ self.assertEqual(valor, False)
+
+
+class TestDateField(unittest.TestCase):
+ def test_should_obter_valor(self):
+ s = "02/01/2013 10:21:51 COO:022734"
+ campo = DateField(r'^(\d+/\d+/\d+)')
+ valor = campo.parse_block(s)
+ data_esperada = date(2013, 1, 2)
+ self.assertEqual(valor, data_esperada)
+
+ def test_should_obter_respeitando_formato(self):
+ s = "2013-01-02T10:21:51 COO:022734"
+ campo = DateField(r'^(\d+-\d+-\d+)', formato='%Y-%m-%d')
+ valor = campo.parse_block(s)
+ data_esperada = date(2013, 1, 2)
+ self.assertEqual(valor, data_esperada)
+
+
+class TestDateTimeField(unittest.TestCase):
+ def test_should_obter_valor(self):
+ s = "02/01/2013 10:21:51 COO:022734"
+ campo = DateTimeField(r'^(\d+/\d+/\d+ \d+:\d+:\d+)')
+ valor = campo.parse_block(s)
+ data_esperada = datetime(2013, 1, 2, 10, 21, 51)
+ self.assertEqual(valor, data_esperada)
+
+ def test_should_obter_respeitando_formato(self):
+ s = "2013-01-02T10:21:51 COO:022734"
+ campo = DateTimeField(r'^(\d+-\d+-\d+T\d+:\d+:\d+)',
+ formato='%Y-%m-%dT%H:%M:%S')
+ valor = campo.parse_block(s)
+ data_esperada = datetime(2013, 1, 2, 10, 21, 51)
+ self.assertEqual(valor, data_esperada)
+
+if __name__ == '__main__':
+ import logging
+ logging.basicConfig(
+ # filename='test_parser.log',
+ level=logging.DEBUG,
+ format='%(asctime)-15s %(message)s'
+ )
+ unittest.main()
diff --git a/tests/teste_analizador.py b/tests/test_parser.py
similarity index 61%
rename from tests/teste_analizador.py
rename to tests/test_parser.py
index f723270..65e5932 100644
--- a/tests/teste_analizador.py
+++ b/tests/test_parser.py
@@ -1,19 +1,74 @@
#coding: utf-8
+from __future__ import unicode_literals
+import os
+import sys
import unittest
+import codecs
import re
-from .teste_uteis import full_path, assertDicionario
-from raspador.analizador import Analizador, Dicionario
-from raspador.campos import CampoBase, CampoNumerico, \
- CampoInteiro, CampoBooleano
+sys.path.append('../')
-class CampoItem(CampoBase):
- def _iniciar(self):
- self.mascara = (r"(\d+)\s(\d+)\s+([\w.#\s/()]+)\s+(\d+)(\w+)"
+from raspador.parser import Parser, Dictionary
+from raspador.fields import BaseField, IntegerField, BooleanField
+from raspador.fields import BRFloatField as FloatField
+
+
+full_path = lambda x: os.path.join(os.path.dirname(__file__), x)
+
+
+class DictDiffer(object):
+ """
+ Calculate the difference between two dictionaries as:
+ (1) items added
+ (2) items removed
+ (3) keys same in both but changed values
+ (4) keys same in both and unchanged values
+ """
+ def __init__(self, current_dict, past_dict):
+ self.current_dict, self.past_dict = current_dict, past_dict
+ self.current_keys, self.past_keys = [
+ set(d.keys()) for d in (current_dict, past_dict)
+ ]
+ self.intersect = self.current_keys.intersection(self.past_keys)
+
+ def added(self):
+ return self.current_keys - self.intersect
+
+ def removed(self):
+ return self.past_keys - self.intersect
+
+ def changed(self):
+ return set(o for o in self.intersect
+ if self.past_dict[o] != self.current_dict[o])
+
+ def unchanged(self):
+ return set(o for o in self.intersect
+ if self.past_dict[o] == self.current_dict[o])
+
+
+def assertDictionary(self, a, b, mensagem=''):
+ d = DictDiffer(a, b)
+
+ def diff(msg, fn):
+ q = getattr(d, fn)()
+ if mensagem:
+ msg = mensagem + '. ' + msg
+ m = 'chaves %s: %r, esperado:%r != encontrado:%r' % \
+ (msg, q, a, b,) if q else ''
+ self.assertFalse(q, m)
+
+ diff('adicionadas', 'added')
+ diff('removidas', 'removed')
+ diff('alteradas', 'changed')
+
+
+class CampoItem(BaseField):
+ def _setup(self):
+ self.search = (r"(\d+)\s(\d+)\s+([\w.#\s/()]+)\s+(\d+)(\w+)"
"\s+X\s+(\d+,\d+)\s+(\w+)\s+(\d+,\d+)")
- def _para_python(self, r):
- return Dicionario(
+ def to_python(self, r):
+ return Dictionary(
Item=int(r[0]),
Codigo=r[1],
Descricao=r[2],
@@ -25,43 +80,43 @@ def _para_python(self, r):
)
-class ExtratorDeDados(Analizador):
- inicio = r'^\s+CUPOM FISCAL\s+$'
- fim = r'^FAB:.*BR$'
- qtd_linhas_cache = 1
- COO = CampoInteiro(r'COO:\s?(\d+)')
- Cancelado = CampoBooleano(r'^\s+(CANCELAMENTO)\s+$')
- Total = CampoNumerico(r'^TOTAL R\$\s+(\d+,\d+)')
- Itens = CampoItem(lista=True)
+class ExtratorDeDados(Parser):
+ begin = r'^\s+CUPOM FISCAL\s+$'
+ end = r'^FAB:.*BR$'
+ number_of_blocks_in_cache = 1
+ COO = IntegerField(r'COO:\s?(\d+)')
+ Cancelado = BooleanField(r'^\s+(CANCELAMENTO)\s+$')
+ Total = FloatField(r'^TOTAL R\$\s+(\d+,\d+)')
+ Itens = CampoItem(is_list=True)
-class TotalizadoresNaoFiscais(Analizador):
- class CampoNF(CampoBase):
- def _iniciar(self):
- self.mascara = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)'
+class TotalizadoresNaoFiscais(Parser):
+ class CampoNF(BaseField):
+ def _setup(self):
+ self.search = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)'
- def _para_python(self, v):
- return Dicionario(
+ def to_python(self, v):
+ return Dictionary(
N=int(v[0]),
Operacao=v[1].strip(),
CON=int(v[2]),
ValorAcumulado=float(re.sub('[,.]', '.', v[3])),
)
- inicio = r'^\s+TOTALIZADORES NÃO FISCAIS\s+$'
- fim = r'^[\s-]*$'
- Totalizador = CampoNF(lista=True)
+ begin = r'^\s+TOTALIZADORES NÃO FISCAIS\s+$'
+ end = r'^[\s-]*$'
+ Totalizador = CampoNF(is_list=True)
- def processar_retorno(self):
- self.retorno = self.retorno.Totalizador
+ def process_item(self, item):
+ return item.Totalizador
-class AnalizadorDeReducaoZ(Analizador):
- inicio = r'^\s+REDUÇÃO Z\s+$'
- fim = r'^FAB:.*BR$'
- qtd_linhas_cache = 1
- COO = CampoInteiro(r'COO:\s*(\d+)')
- CRZ = CampoInteiro(r'Contador de Redução Z:\s*(\d+)')
+class ParserDeReducaoZ(Parser):
+ begin = r'^\s+REDUÇÃO Z\s+$'
+ end = r'^FAB:.*BR$'
+ number_of_blocks_in_cache = 1
+ COO = IntegerField(r'COO:\s*(\d+)')
+ CRZ = IntegerField(r'Contador de Redução Z:\s*(\d+)')
Totalizadores = TotalizadoresNaoFiscais()
@@ -71,11 +126,11 @@ class BaseParaTestesComApiDeArquivo(unittest.TestCase):
cache_itens = None
def setUp(self):
- self.analizador = self.criar_analizador()
+ self.parser = self.criar_analizador()
self.arquivo = self.obter_arquivo()
- # verificando se analizador foi criado
- self.assertTrue(hasattr(self.analizador, 'analizar'))
+ # verificando se parser foi criado
+ self.assertTrue(hasattr(self.parser, 'parse'))
if self.cache_itens:
self.itens = self.cache_itens
@@ -103,18 +158,21 @@ def obter_arquivo(self):
raise NotImplementedError('Return an file-like object')
def analizar(self):
- return list(self.analizador.analizar(
- self.arquivo,
- codificacao=self.codificacao_arquivo) or [])
+ return list(self.parser.parse(self.arquivo) or [])
+
+ @classmethod
+ def open_file(cls, filename):
+ return codecs.open(full_path(filename),
+ encoding=cls.codificacao_arquivo)
- assertDicionario = assertDicionario
+ assertDictionary = assertDictionary
class TesteDeExtrairDadosDeCupom(BaseParaTestesComApiDeArquivo):
codificacao_arquivo = 'utf-8'
def obter_arquivo(self):
- return open(full_path('arquivos/cupom.txt'))
+ return self.open_file('files/cupom.txt')
def criar_analizador(self):
return ExtratorDeDados()
@@ -235,13 +293,13 @@ def teste_deve_emitir_dicionario_com_valores(self):
class TesteExtrairDadosDeCupomCancelado(BaseParaTestesComApiDeArquivo):
def obter_arquivo(self):
- return open(full_path('arquivos/cupom.txt'))
+ return self.open_file('files/cupom.txt')
def criar_analizador(self):
- class ExtratorDeDados(Analizador):
- inicio = r'^\s+CUPOM FISCAL\s+$'
- fim = r'^FAB:.*BR$'
- Total = CampoNumerico(r'^TOTAL R\$\s+(\d+,\d+)')
+ class ExtratorDeDados(Parser):
+ begin = r'^\s+CUPOM FISCAL\s+$'
+ end = r'^FAB:.*BR$'
+ Total = FloatField(r'^TOTAL R\$\s+(\d+,\d+)')
return ExtratorDeDados()
@@ -249,15 +307,15 @@ def teste_deve_retornar_valores(self):
self.assertEqual(len(self.itens), 1)
-class TesteExtrairDadosComAnalizadoresAlinhados(BaseParaTestesComApiDeArquivo):
+class TesteExtrairDadosComParseresAlinhados(BaseParaTestesComApiDeArquivo):
codificacao_arquivo = 'utf-8'
def obter_arquivo(self):
"sobrescrever retornando arquivo"
- return open(full_path('arquivos/reducaoz.txt'))
+ return self.open_file('files/reducaoz.txt')
def criar_analizador(self):
- return AnalizadorDeReducaoZ()
+ return ParserDeReducaoZ()
def teste_deve_retornar_dados(self):
reducao = [
@@ -284,4 +342,13 @@ def teste_deve_retornar_dados(self):
]
}
]
- self.assertDicionario(reducao[0], self.itens[0])
+ self.assertDictionary(reducao[0], self.itens[0])
+
+if __name__ == '__main__':
+ import logging
+ logging.basicConfig(
+ # filename='test_parser.log',
+ level=logging.DEBUG,
+ format='%(asctime)-15s %(message)s'
+ )
+ unittest.main()
diff --git a/tests/teste_cache.py b/tests/teste_cache.py
deleted file mode 100644
index 587ca00..0000000
--- a/tests/teste_cache.py
+++ /dev/null
@@ -1,40 +0,0 @@
-#coding: utf-8
-import unittest
-from raspador.cache import Cache
-
-
-class Test_Cache(unittest.TestCase):
- def setUp(self):
- self.cache = Cache(3)
-
- def test_deve_manter_ultimos_itens_em_cache(self):
- self.cache.adicionar(1)
- self.cache.adicionar(2)
- self.cache.adicionar(3)
- self.cache.adicionar(4)
- self.assertEqual(self.cache.itens(), [2, 3, 4])
-
- def test_deve_consumir_cache(self):
- self.cache.adicionar(1)
- self.cache.adicionar(2)
- self.cache.adicionar(3)
- self.cache.adicionar(4)
- self.assertEqual(list(self.cache.consumir()), [2, 3, 4])
- self.cache.adicionar(5)
- self.assertEqual(list(self.cache.consumir()), [5])
- self.cache.adicionar(6)
- self.cache.adicionar(7)
- self.assertEqual(list(self.cache.consumir()), [6, 7])
-
- def test_deve_retornar_vazio_se_nao_tem_cache(self):
- valor = list(self.cache.consumir())
- self.assertEqual(valor, [])
-
- def test_deve_retornar_tamanho_da_lista(self):
- self.cache.adicionar(1)
- self.cache.adicionar(2)
- self.assertEqual(len(self.cache), 2)
- self.cache.adicionar(3)
- self.cache.adicionar(4)
- self.cache.adicionar(5)
- self.assertEqual(len(self.cache), 3)
diff --git a/tests/teste_campos.py b/tests/teste_campos.py
deleted file mode 100644
index 8d1fa5e..0000000
--- a/tests/teste_campos.py
+++ /dev/null
@@ -1,124 +0,0 @@
-#coding: utf-8
-
-import unittest
-from datetime import date, datetime
-
-from raspador.campos import CampoBase, CampoString, CampoNumerico, \
- CampoInteiro, CampoData, CampoDataHora, CampoBooleano
-
-
-class TesteDeCampoBase(unittest.TestCase):
-
- def teste_deve_retornar_valor_no_analizar(self):
- s = "02/01/2013 10:21:51 COO:022734"
- campo = CampoBase(r'COO:(\d+)', nome='COO')
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, '022734')
-
- def teste_deve_retornar_none_sem_mascara(self):
- s = "02/01/2013 10:21:51 COO:022734"
- campo = CampoBase()
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, None)
-
- def teste_deve_aceitar_callback(self):
- s = "02/01/2013 10:21:51 COO:022734"
-
- def dobro(valor):
- return int(valor) * 2
-
- campo = CampoBase(r'COO:(\d+)', nome='COO', ao_atribuir=dobro)
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, 45468) # 45468 = 2 x 22734
-
- def teste_deve_recusar_callback_invalido(self):
- self.assertRaises(
- TypeError,
- lambda: CampoBase(r'COO:(\d+)', ao_atribuir='pegadinha')
- )
-
- def teste_deve_utilizar_grupo_quando_informado(self):
- s = "Contador de Reduções Z: 1246"
- campo = CampoBase(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', grupos=1,
- ao_atribuir=int)
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, 1246)
-
-
-class TesteDeCampoInteiro(unittest.TestCase):
- def teste_deve_obter_valor(self):
- s = "02/01/2013 10:21:51 COO:022734"
- campo = CampoInteiro(r'COO:(\d+)', nome='COO')
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, 22734)
-
-
-class TesteDeCampoNumerico(unittest.TestCase):
- def teste_deve_obter_valor(self):
- s = "VENDA BRUTA DIÁRIA: 793,00"
- campo = CampoNumerico(r'VENDA BRUTA DIÁRIA:\s+(\d+,\d+)')
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, 793.0)
-
- def teste_deve_obter_valor_com_separador_de_milhar(self):
- s = "VENDA BRUTA DIÁRIA: 10.036,70"
- campo = CampoNumerico(r'VENDA BRUTA DIÁRIA:\s+([\d.]+,\d+)')
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, 10036.7)
-
-
-class TesteDeCampoString(unittest.TestCase):
- def teste_deve_obter_valor(self):
- s = "1 Dinheiro 0,00"
- campo = CampoString(r'\d+\s+(\w[^\d]+)', nome='Meio')
- valor = campo.analizar_linha(s)
- self.assertEqual(valor, 'Dinheiro')
-
-
-class TesteDeCampoBooleano(unittest.TestCase):
- s = " CANCELAMENTO "
-
- def teste_deve_obter_valor_verdadeiro_se_bater_e_capturar(self):
- campo = CampoBooleano(r'^\s+(CANCELAMENTO)\s+$', nome='Cancelado')
- valor = campo.analizar_linha(self.s)
- self.assertEqual(valor, True)
-
- def teste_deve_retornar_falso_ao_finalizar_quando_regex_nao_bate(self):
- campo = CampoBooleano(r'^\s+HAH\s+$', nome='Cancelado')
- valor = campo.analizar_linha(self.s)
- self.assertEqual(valor, None)
- valor = campo.valor_padrao
- self.assertEqual(valor, False)
-
-
-class TesteDeCampoData(unittest.TestCase):
- def teste_deve_obter_valor(self):
- s = "02/01/2013 10:21:51 COO:022734"
- campo = CampoData(r'^(\d+/\d+/\d+)', nome='Data')
- valor = campo.analizar_linha(s)
- data_esperada = date(2013, 1, 2)
- self.assertEqual(valor, data_esperada)
-
- def teste_deve_obter_respeitando_formato(self):
- s = "2013-01-02T10:21:51 COO:022734"
- campo = CampoData(r'^(\d+-\d+-\d+)', nome='Data', formato='%Y-%m-%d')
- valor = campo.analizar_linha(s)
- data_esperada = date(2013, 1, 2)
- self.assertEqual(valor, data_esperada)
-
-
-class TesteDeCampoDataHora(unittest.TestCase):
- def teste_deve_obter_valor(self):
- s = "02/01/2013 10:21:51 COO:022734"
- campo = CampoDataHora(r'^(\d+/\d+/\d+ \d+:\d+:\d+)')
- valor = campo.analizar_linha(s)
- data_esperada = datetime(2013, 1, 2, 10, 21, 51)
- self.assertEqual(valor, data_esperada)
-
- def teste_deve_obter_respeitando_formato(self):
- s = "2013-01-02T10:21:51 COO:022734"
- campo = CampoDataHora(r'^(\d+-\d+-\d+T\d+:\d+:\d+)',
- formato='%Y-%m-%dT%H:%M:%S')
- valor = campo.analizar_linha(s)
- data_esperada = datetime(2013, 1, 2, 10, 21, 51)
- self.assertEqual(valor, data_esperada)
diff --git a/tests/teste_decoradores.py b/tests/teste_decoradores.py
deleted file mode 100644
index 5e07ed0..0000000
--- a/tests/teste_decoradores.py
+++ /dev/null
@@ -1,95 +0,0 @@
-#coding: utf-8
-import unittest
-
-from raspador import ProxyDeCampo, ProxyConcatenaAteRE
-
-
-class CampoFake(object):
- def __init__(self, valor_padrao=None, lista=False, retornar=False):
- self.valor_padrao = valor_padrao
- self.lista = lista
- self.linhas = []
- self.retornar = retornar
-
- def analizar_linha(self, linha):
- self.linhas.append(linha)
- if self.retornar:
- return linha
-
- def anexar_na_classe(self, cls, nome, informacoes):
- self.classe = cls
- self.nome = nome
- self.informacoes = informacoes
-
-
-class TesteDeProxyChamandoMetodosSemIntervencao(unittest.TestCase):
- def teste_decorador_deve_retornar_valor_padrao(self):
- mock = CampoFake(valor_padrao=1234)
- p = ProxyDeCampo(mock)
- self.assertEqual(
- p.valor_padrao,
- 1234
- )
-
- def teste_decorador_deve_retornar_valor_lista(self):
- mock = CampoFake(lista=True)
- p = ProxyDeCampo(mock)
- self.assertEqual(
- p.lista,
- True
- )
-
- def teste_decorador_deve_passar_linhas(self):
- mock = CampoFake()
- p = ProxyDeCampo(mock)
- p.analizar_linha('teste1')
- p.analizar_linha('teste2')
- self.assertEqual(
- mock.linhas,
- ['teste1', 'teste2']
- )
-
-
-class TesteDeDecoradorConcatenaAteRE(unittest.TestCase):
-
- def teste_deve_chamar_decorado_acumulando_linhas(self):
- mock = CampoFake()
- p = ProxyConcatenaAteRE(mock, ' '.join, 'l4|l6')
- p.analizar_linha('l1')
- p.analizar_linha('l2')
- p.analizar_linha('l3')
- p.analizar_linha('l4')
- p.analizar_linha('l5')
- p.analizar_linha('l6')
- self.assertEqual(
- [
- 'l1 l2 l3 l4',
- 'l5 l6',
- ],
- mock.linhas
- )
-
- def teste_deve_chamar_decorado_retornando_primeiro_valor(self):
- mock = CampoFake(retornar=True)
- p = ProxyConcatenaAteRE(mock, ' '.join, 'l\d')
- p.analizar_linha('l1')
- p.analizar_linha('l2')
- p.analizar_linha('l3')
- p.analizar_linha('l4')
- p.analizar_linha('l5')
- p.analizar_linha('l6')
- self.assertEqual(
- [
- 'l1',
- 'l2',
- 'l3',
- 'l4',
- 'l5',
- 'l6',
- ],
- mock.linhas
- )
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/teste_uteis.py b/tests/teste_uteis.py
deleted file mode 100644
index 9c1385b..0000000
--- a/tests/teste_uteis.py
+++ /dev/null
@@ -1,55 +0,0 @@
-#coding: utf-8
-import os
-import sys
-
-full_path = lambda x: os.path.join(os.path.dirname(__file__), x)
-
-
-def incluir_diretorio_raiz():
- sys.path.append(os.path.realpath('../'))
-
-
-class DictDiffer(object):
- """
- Calculate the difference between two dictionaries as:
- (1) items added
- (2) items removed
- (3) keys same in both but changed values
- (4) keys same in both and unchanged values
- """
- def __init__(self, current_dict, past_dict):
- self.current_dict, self.past_dict = current_dict, past_dict
- self.current_keys, self.past_keys = [
- set(d.keys()) for d in (current_dict, past_dict)
- ]
- self.intersect = self.current_keys.intersection(self.past_keys)
-
- def added(self):
- return self.current_keys - self.intersect
-
- def removed(self):
- return self.past_keys - self.intersect
-
- def changed(self):
- return set(o for o in self.intersect
- if self.past_dict[o] != self.current_dict[o])
-
- def unchanged(self):
- return set(o for o in self.intersect
- if self.past_dict[o] == self.current_dict[o])
-
-
-def assertDicionario(self, a, b, mensagem=''):
- d = DictDiffer(a, b)
-
- def diff(msg, fn):
- q = getattr(d, fn)()
- if mensagem:
- msg = mensagem + '. ' + msg
- m = 'chaves %s: %r, esperado:%r != encontrado:%r' % \
- (msg, q, a, b,) if q else ''
- self.assertFalse(q, m)
-
- diff('adicionadas', 'added')
- diff('removidas', 'removed')
- diff('alteradas', 'changed')
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..521fb6b
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,19 @@
+[tox]
+envlist = py26,py27,py32,py33
+
+[testenv]
+usedevelop=True
+deps=
+ nose
+ coverage
+ flake8
+commands=
+ nosetests
+ flake8 raspador
+
+[testenv:py26]
+deps =
+ ordereddict
+ {[testenv]deps}
+commands=
+ nosetests