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