From eae28744564f9c173be06e8dadf70eed3b07d121 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 14 Sep 2013 23:58:32 -0300 Subject: [PATCH 01/17] Analizador becomes Parser --- README.rst | 6 +++--- docs/source/index.rst | 22 +++++++++++----------- docs/source/intro/install.rst | 28 ++++++++++++++++++++++++++++ docs/source/raspador.rst | 3 ++- raspador/__init__.py | 3 ++- raspador/analizador.py | 25 ++++++++++++------------- raspador/campos.py | 6 +++--- tests/teste_analizador.py | 14 +++++++------- 8 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 docs/source/intro/install.rst diff --git a/README.rst b/README.rst index 206f5b6..a98ce4b 100644 --- a/README.rst +++ b/README.rst @@ -98,7 +98,7 @@ Extrator de dados em logs .. code-block:: python import json - from raspador import Analizador, CampoString + from raspador import Parser, CampoString out = """ PART:/dev/sda1 UUID:423k34-3423lk423-sdfsd-43 TYPE:ext4 @@ -107,7 +107,7 @@ Extrator de dados em logs """ - class AnalizadorDeLog(Analizador): + class ParserDeLog(Parser): inicio = r'^PART.*' fim = r'^PART.*' PART = CampoString(r'PART:([^\s]+)') @@ -115,7 +115,7 @@ Extrator de dados em logs TYPE = CampoString(r'TYPE:([^\s]+)') - a = AnalizadorDeLog() + a = ParserDeLog() # res é um gerador res = a.analizar(linha for linha in out.splitlines()) 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..426b312 100644 --- a/docs/source/raspador.rst +++ b/docs/source/raspador.rst @@ -1,4 +1,5 @@ +======== raspador ======== @@ -6,7 +7,7 @@ O módulo raspador fornece estrutura genérica para extração de dados a partir arquivos texto semi-estruturados. -Analizador +Parser ---------- .. automodule:: raspador.analizador diff --git a/raspador/__init__.py b/raspador/__init__.py index 1db55cc..8f1d048 100644 --- a/raspador/__init__.py +++ b/raspador/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa -from .analizador import Analizador, Dicionario +from .analizador import Parser +from .colecoes import Dicionario from .campos import CampoBase, CampoString, CampoNumerico, \ CampoInteiro, CampoData, CampoDataHora, CampoBooleano diff --git a/raspador/analizador.py b/raspador/analizador.py index b481a0a..1046132 100644 --- a/raspador/analizador.py +++ b/raspador/analizador.py @@ -10,18 +10,21 @@ logger = logging.getLogger(__name__) -class AuxiliarDeAnalizador(object): +class ParserMixin(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. """ + + qtd_linhas_cache = 0 + default_item_class = Dicionario + 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): @@ -73,7 +76,7 @@ def analizar_linha(self, linha): if self.inicio_encontrado: if not self.tem_retorno: - self.retorno = Dicionario() + self.retorno = self.default_item_class() if self.tem_busca_fim: self.inicio_encontrado = not bool(self._fim.match(linha)) @@ -128,19 +131,18 @@ def processar_retorno(self): pass -class MetaclasseDeAnalizador(type): +class ParserMetaclass(type): """ - Metaclasse responsável por descobrir os coletores de informações associados - à um parser. + Collect data-extractors into a field collection. """ 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) + return type.__new__(self, name, bases + (ParserMixin,), attrs) def __init__(cls, name, bases, attrs): - super(MetaclasseDeAnalizador, cls).__init__(name, bases, attrs) + super(ParserMetaclass, cls).__init__(name, bases, attrs) cls._campos = dict((k, v) for k, v in list(attrs.items()) if hasattr(v, 'analizar_linha') @@ -149,9 +151,6 @@ def __init__(cls, name, bases, attrs): 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 @@ -166,7 +165,7 @@ def adicionar_atributo_re(self, cls, atributos, nome): @classmethod def __prepare__(self, name, bases): - return Dicionario() + return self.default_item_class() -Analizador = MetaclasseDeAnalizador('Analizador', (object,), {}) +Parser = ParserMetaclass('Parser', (object,), {}) diff --git a/raspador/campos.py b/raspador/campos.py index a5f29a9..248de35 100644 --- a/raspador/campos.py +++ b/raspador/campos.py @@ -80,7 +80,7 @@ class CampoBase(object): valor_padrao - Valor que será utilizado no :py:class:`~raspador.analizador.Analizador` + Valor que será utilizado no :py:class:`~raspador.analizador.Parser` , quando o campo não retornar valor após a análise das linhas recebidas. @@ -95,7 +95,7 @@ class CampoBase(object): ['022734'] Por convenção, quando um campo retorna uma lista, o - :py:class:`~raspador.analizador.Analizador` acumula os valores + :py:class:`~raspador.analizador.Parser` acumula os valores retornados pelo campo. """ def __init__(self, mascara=None, **kwargs): @@ -129,7 +129,7 @@ def _iniciar(self): def atribuir_analizador(self, analizador): """ Recebe uma referência fraca de - :py:class:`~raspador.analizador.Analizador` + :py:class:`~raspador.analizador.Parser` """ self.analizador = analizador diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index f723270..da20116 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -2,7 +2,7 @@ import unittest import re from .teste_uteis import full_path, assertDicionario -from raspador.analizador import Analizador, Dicionario +from raspador.analizador import Parser, Dicionario from raspador.campos import CampoBase, CampoNumerico, \ CampoInteiro, CampoBooleano @@ -25,7 +25,7 @@ def _para_python(self, r): ) -class ExtratorDeDados(Analizador): +class ExtratorDeDados(Parser): inicio = r'^\s+CUPOM FISCAL\s+$' fim = r'^FAB:.*BR$' qtd_linhas_cache = 1 @@ -35,7 +35,7 @@ class ExtratorDeDados(Analizador): Itens = CampoItem(lista=True) -class TotalizadoresNaoFiscais(Analizador): +class TotalizadoresNaoFiscais(Parser): class CampoNF(CampoBase): def _iniciar(self): self.mascara = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)' @@ -56,7 +56,7 @@ def processar_retorno(self): self.retorno = self.retorno.Totalizador -class AnalizadorDeReducaoZ(Analizador): +class ParserDeReducaoZ(Parser): inicio = r'^\s+REDUÇÃO Z\s+$' fim = r'^FAB:.*BR$' qtd_linhas_cache = 1 @@ -238,7 +238,7 @@ def obter_arquivo(self): return open(full_path('arquivos/cupom.txt')) def criar_analizador(self): - class ExtratorDeDados(Analizador): + class ExtratorDeDados(Parser): inicio = r'^\s+CUPOM FISCAL\s+$' fim = r'^FAB:.*BR$' Total = CampoNumerico(r'^TOTAL R\$\s+(\d+,\d+)') @@ -249,7 +249,7 @@ 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): @@ -257,7 +257,7 @@ def obter_arquivo(self): return open(full_path('arquivos/reducaoz.txt')) def criar_analizador(self): - return AnalizadorDeReducaoZ() + return ParserDeReducaoZ() def teste_deve_retornar_dados(self): reducao = [ From c905911064fb0e158d84277c6c94d633adf28923 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 15 Sep 2013 23:00:47 -0300 Subject: [PATCH 02/17] classes renamed to en --- README.rst | 8 ++--- raspador/__init__.py | 4 +-- raspador/analizador.py | 6 ++-- raspador/campos.py | 62 +++++++++++++++++--------------------- setup.py | 13 ++++++++ tests/teste_analizador.py | 24 +++++++-------- tests/teste_campos.py | 50 +++++++++++++++--------------- tests/teste_decoradores.py | 10 +++--- 8 files changed, 92 insertions(+), 85 deletions(-) diff --git a/README.rst b/README.rst index a98ce4b..95667c9 100644 --- a/README.rst +++ b/README.rst @@ -98,7 +98,7 @@ Extrator de dados em logs .. code-block:: python import json - from raspador import Parser, CampoString + from raspador import Parser, StringField out = """ PART:/dev/sda1 UUID:423k34-3423lk423-sdfsd-43 TYPE:ext4 @@ -110,9 +110,9 @@ Extrator de dados em logs class ParserDeLog(Parser): inicio = r'^PART.*' fim = r'^PART.*' - PART = CampoString(r'PART:([^\s]+)') - UUID = CampoString(r'UUID:([^\s]+)') - TYPE = CampoString(r'TYPE:([^\s]+)') + PART = StringField(r'PART:([^\s]+)') + UUID = StringField(r'UUID:([^\s]+)') + TYPE = StringField(r'TYPE:([^\s]+)') a = ParserDeLog() diff --git a/raspador/__init__.py b/raspador/__init__.py index 8f1d048..7dc27a1 100644 --- a/raspador/__init__.py +++ b/raspador/__init__.py @@ -2,8 +2,8 @@ from .analizador import Parser from .colecoes import Dicionario -from .campos import CampoBase, CampoString, CampoNumerico, \ - CampoInteiro, CampoData, CampoDataHora, CampoBooleano +from .campos import BaseField, StringField, FloatField, \ + IntegerField, DateField, DateTimeField, BooleanField from .decoradores import ProxyDeCampo, ProxyConcatenaAteRE diff --git a/raspador/analizador.py b/raspador/analizador.py index 1046132..678301f 100644 --- a/raspador/analizador.py +++ b/raspador/analizador.py @@ -109,7 +109,7 @@ def finalizar_retorno(self): isinstance(campo.finalizar, collections.Callable): valor = campo.finalizar() if valor is None: - valor = campo.valor_padrao + valor = campo.default if valor is not None: self.atribuir_valor_ao_retorno(nome, valor) @@ -133,7 +133,7 @@ def processar_retorno(self): class ParserMetaclass(type): """ - Collect data-extractors into a field collection. + Collect data-extractors into a field collection and injects ParserMixin. """ def __new__(self, name, bases, attrs): if object in bases: @@ -156,7 +156,7 @@ def __init__(cls, name, bases, attrs): for nome, atributo in list(cls._campos.items()): if hasattr(atributo, 'anexar_na_classe'): - atributo.anexar_na_classe(cls, nome, cls._campos) + atributo.anexar_na_classe(cls, nome) def adicionar_atributo_re(self, cls, atributos, nome): if nome in atributos: diff --git a/raspador/campos.py b/raspador/campos.py index 248de35..b943dbd 100644 --- a/raspador/campos.py +++ b/raspador/campos.py @@ -13,7 +13,7 @@ import collections -class CampoBase(object): +class BaseField(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 @@ -27,7 +27,7 @@ class CampoBase(object): onde deve-se especificar um grupo para captura:: >>> s = "02/01/2013 10:21:51 COO:022734" - >>> campo = CampoBase(mascara=r'COO:(\d+)') + >>> campo = BaseField(mascara=r'COO:(\d+)') >>> campo.analizar_linha(s) '022734' @@ -35,7 +35,7 @@ class CampoBase(object): ser omitido:: >>> s = "02/01/2013 10:21:51 COO:022734" - >>> campo = CampoBase(r'COO:(\d+)') + >>> campo = BaseField(r'COO:(\d+)') >>> campo.analizar_linha(s) '022734' @@ -49,7 +49,7 @@ class CampoBase(object): >>> def dobro(valor): ... return int(valor) * 2 ... - >>> campo = CampoBase(r'COO:(\d+)', ao_atribuir=dobro) + >>> campo = BaseField(r'COO:(\d+)', ao_atribuir=dobro) >>> campo.analizar_linha(s) # 45468 = 2 x 22734 45468 @@ -64,7 +64,7 @@ class CampoBase(object): inicando em 0:: >>> s = "Contador de Reduções Z: 1246" - >>> campo = CampoBase(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \ + >>> campo = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \ grupos=1, ao_atribuir=int) >>> campo.analizar_linha(s) 1246 @@ -72,13 +72,13 @@ class CampoBase(object): Ou uma lista de inteiros:: >>> s = "Data do movimento: 02/01/2013 10:21:51" - >>> c = CampoBase(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\ + >>> c = BaseField(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\ grupos=[1, 2, 3]) >>> c.analizar_linha(s) ['02', '01', '2013'] - valor_padrao + default Valor que será utilizado no :py:class:`~raspador.analizador.Parser` , quando o campo não retornar valor após a análise das @@ -90,7 +90,7 @@ class CampoBase(object): 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 = BaseField(r'COO:(\d+)', lista=True) >>> campo.analizar_linha(s) ['022734'] @@ -99,11 +99,7 @@ class CampoBase(object): 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.default = kwargs.get('default') self.lista = kwargs.get('lista', False) self.mascara = mascara self.ao_atribuir = kwargs.get('ao_atribuir') @@ -147,7 +143,7 @@ def _converter(self, valor): valor = valor[0] # se houver apenas um item return valor - def _para_python(self, valor): + def to_python(self, valor): """ Converte o valor recebido palo parser para o tipo de dado nativo do python @@ -162,17 +158,15 @@ def mascara(self): def mascara(self, valor): self._mascara = re.compile(valor) if valor else None - def anexar_na_classe(self, cls, nome, informacoes): + def anexar_na_classe(self, cls, name): 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) + valor = self.to_python(valor) if self.ao_atribuir: valor = self.ao_atribuir(valor) if valor is not None and self.lista \ @@ -181,30 +175,30 @@ def analizar_linha(self, linha): return valor -class CampoString(CampoBase): - def _para_python(self, valor): +class StringField(BaseField): + def to_python(self, valor): return str(valor).strip() -class CampoNumerico(CampoBase): - def _para_python(self, valor): +class FloatField(BaseField): + def to_python(self, valor): valor = valor.replace('.', '') valor = valor.replace(',', '.') return float(valor) -class CampoInteiro(CampoBase): - def _para_python(self, valor): +class IntegerField(BaseField): + def to_python(self, valor): return int(valor) -class CampoBooleano(CampoBase): +class BooleanField(BaseField): """ 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 + self.default = False @property def _metodo_busca(self): @@ -212,16 +206,16 @@ def _metodo_busca(self): def _converter(self, valor): res = valor.groups() if valor else False - return super(CampoBooleano, self)._converter(res) + return super(BooleanField, self)._converter(res) def _resultado_valido(self, valor): return valor and (valor.groups()) - def _para_python(self, valor): + def to_python(self, valor): return bool(valor) -class CampoData(CampoBase): +class DateField(BaseField): """ Campo que mantém dados no formato de data, representado em Python por datetine.date. @@ -234,14 +228,14 @@ def __init__(self, mascara=None, **kwargs): """ formato='%d/%m/%Y' """ - super(CampoData, self).__init__(mascara=mascara, **kwargs) + super(DateField, self).__init__(mascara=mascara, **kwargs) self.formato = kwargs.get('formato', '%d/%m/%Y') - def _para_python(self, valor): + def to_python(self, valor): return datetime.strptime(valor, self.formato).date() -class CampoDataHora(CampoBase): +class DateTimeField(BaseField): """ Campo que mantém dados no formato de data/hora, representado em Python por datetine.datetime. @@ -254,10 +248,10 @@ def __init__(self, mascara=None, **kwargs): """ formato='%d/%m/%Y %H:%M:%S' """ - super(CampoDataHora, self).__init__(mascara=mascara, **kwargs) + super(DateTimeField, self).__init__(mascara=mascara, **kwargs) self.formato = kwargs.get('formato', '%d/%m/%Y %H:%M:%S') - def _para_python(self, valor): + def to_python(self, valor): return datetime.strptime(valor, self.formato) diff --git a/setup.py b/setup.py index 9087194..d3c23a9 100644 --- a/setup.py +++ b/setup.py @@ -16,4 +16,17 @@ url="http://github.org/fgmacedo/raspador", version='0.1.3', packages=['raspador'], + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: Implementation :: CPython', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], ) diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index da20116..d821630 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -3,16 +3,16 @@ import re from .teste_uteis import full_path, assertDicionario from raspador.analizador import Parser, Dicionario -from raspador.campos import CampoBase, CampoNumerico, \ - CampoInteiro, CampoBooleano +from raspador.campos import BaseField, FloatField, \ + IntegerField, BooleanField -class CampoItem(CampoBase): +class CampoItem(BaseField): def _iniciar(self): self.mascara = (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): + def to_python(self, r): return Dicionario( Item=int(r[0]), Codigo=r[1], @@ -29,18 +29,18 @@ class ExtratorDeDados(Parser): 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+)') + COO = IntegerField(r'COO:\s?(\d+)') + Cancelado = BooleanField(r'^\s+(CANCELAMENTO)\s+$') + Total = FloatField(r'^TOTAL R\$\s+(\d+,\d+)') Itens = CampoItem(lista=True) class TotalizadoresNaoFiscais(Parser): - class CampoNF(CampoBase): + class CampoNF(BaseField): def _iniciar(self): self.mascara = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)' - def _para_python(self, v): + def to_python(self, v): return Dicionario( N=int(v[0]), Operacao=v[1].strip(), @@ -60,8 +60,8 @@ class ParserDeReducaoZ(Parser): 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+)') + COO = IntegerField(r'COO:\s*(\d+)') + CRZ = IntegerField(r'Contador de Redução Z:\s*(\d+)') Totalizadores = TotalizadoresNaoFiscais() @@ -241,7 +241,7 @@ def criar_analizador(self): class ExtratorDeDados(Parser): inicio = r'^\s+CUPOM FISCAL\s+$' fim = r'^FAB:.*BR$' - Total = CampoNumerico(r'^TOTAL R\$\s+(\d+,\d+)') + Total = FloatField(r'^TOTAL R\$\s+(\d+,\d+)') return ExtratorDeDados() diff --git a/tests/teste_campos.py b/tests/teste_campos.py index 8d1fa5e..a7e1703 100644 --- a/tests/teste_campos.py +++ b/tests/teste_campos.py @@ -3,21 +3,21 @@ import unittest from datetime import date, datetime -from raspador.campos import CampoBase, CampoString, CampoNumerico, \ - CampoInteiro, CampoData, CampoDataHora, CampoBooleano +from raspador.campos import BaseField, StringField, FloatField, \ + IntegerField, DateField, DateTimeField, BooleanField -class TesteDeCampoBase(unittest.TestCase): +class TesteDeBaseField(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') + campo = BaseField(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() + campo = BaseField() valor = campo.analizar_linha(s) self.assertEqual(valor, None) @@ -27,97 +27,97 @@ def teste_deve_aceitar_callback(self): def dobro(valor): return int(valor) * 2 - campo = CampoBase(r'COO:(\d+)', nome='COO', ao_atribuir=dobro) + campo = BaseField(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') + lambda: BaseField(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, + campo = BaseField(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): +class TesteDeIntegerField(unittest.TestCase): def teste_deve_obter_valor(self): s = "02/01/2013 10:21:51 COO:022734" - campo = CampoInteiro(r'COO:(\d+)', nome='COO') + campo = IntegerField(r'COO:(\d+)', nome='COO') valor = campo.analizar_linha(s) self.assertEqual(valor, 22734) -class TesteDeCampoNumerico(unittest.TestCase): +class TesteDeFloatField(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+)') + campo = FloatField(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+)') + campo = FloatField(r'VENDA BRUTA DIÁRIA:\s+([\d.]+,\d+)') valor = campo.analizar_linha(s) self.assertEqual(valor, 10036.7) -class TesteDeCampoString(unittest.TestCase): +class TesteDeStringField(unittest.TestCase): def teste_deve_obter_valor(self): s = "1 Dinheiro 0,00" - campo = CampoString(r'\d+\s+(\w[^\d]+)', nome='Meio') + campo = StringField(r'\d+\s+(\w[^\d]+)', nome='Meio') valor = campo.analizar_linha(s) self.assertEqual(valor, 'Dinheiro') -class TesteDeCampoBooleano(unittest.TestCase): +class TesteDeBooleanField(unittest.TestCase): s = " CANCELAMENTO " def teste_deve_obter_valor_verdadeiro_se_bater_e_capturar(self): - campo = CampoBooleano(r'^\s+(CANCELAMENTO)\s+$', nome='Cancelado') + campo = BooleanField(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') + campo = BooleanField(r'^\s+HAH\s+$', nome='Cancelado') valor = campo.analizar_linha(self.s) self.assertEqual(valor, None) - valor = campo.valor_padrao + valor = campo.default self.assertEqual(valor, False) -class TesteDeCampoData(unittest.TestCase): +class TesteDeDateField(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') + campo = DateField(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') + campo = DateField(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): +class TesteDeDateTimeField(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+)') + campo = DateTimeField(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+)', + campo = DateTimeField(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) diff --git a/tests/teste_decoradores.py b/tests/teste_decoradores.py index 5e07ed0..a26e2ca 100644 --- a/tests/teste_decoradores.py +++ b/tests/teste_decoradores.py @@ -5,8 +5,8 @@ class CampoFake(object): - def __init__(self, valor_padrao=None, lista=False, retornar=False): - self.valor_padrao = valor_padrao + def __init__(self, default=None, lista=False, retornar=False): + self.default = default self.lista = lista self.linhas = [] self.retornar = retornar @@ -23,11 +23,11 @@ def anexar_na_classe(self, cls, nome, informacoes): class TesteDeProxyChamandoMetodosSemIntervencao(unittest.TestCase): - def teste_decorador_deve_retornar_valor_padrao(self): - mock = CampoFake(valor_padrao=1234) + def teste_decorador_deve_retornar_default(self): + mock = CampoFake(default=1234) p = ProxyDeCampo(mock) self.assertEqual( - p.valor_padrao, + p.default, 1234 ) From f0694100229233f4c38a611a7014e59eb738f2b5 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 16 Sep 2013 18:40:40 -0300 Subject: [PATCH 03/17] simplified setup when testing with files --- raspador/analizador.py | 7 ++-- tests/teste_analizador.py | 72 +++++++++++++++++++++++++++++++++++++-- tests/teste_uteis.py | 55 ------------------------------ 3 files changed, 72 insertions(+), 62 deletions(-) delete mode 100644 tests/teste_uteis.py diff --git a/raspador/analizador.py b/raspador/analizador.py index 678301f..09e62ac 100644 --- a/raspador/analizador.py +++ b/raspador/analizador.py @@ -59,7 +59,7 @@ def analizar_arquivo(self, arquivo, codificacao='latin1'): try: while True: linha = next(arquivo) - linha = self.converter_linha(linha, codificacao) + # linha = self.converter_linha(linha, codificacao) res = self.analizar_linha(linha) if res: yield res @@ -69,6 +69,7 @@ def analizar_arquivo(self, arquivo, codificacao='latin1'): yield res def analizar_linha(self, linha): + logger.debug('analizar_linha: %s', linha) self.cache.adicionar(linha) if self.tem_busca_inicio and not self.inicio_encontrado: @@ -163,9 +164,5 @@ def adicionar_atributo_re(self, cls, atributos, nome): expressao = atributos[nome] setattr(cls, '_' + nome, re.compile(expressao)) - @classmethod - def __prepare__(self, name, bases): - return self.default_item_class() - Parser = ParserMetaclass('Parser', (object,), {}) diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index d821630..a4ff7c7 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -1,12 +1,66 @@ #coding: utf-8 +import os +import sys import unittest +import codecs import re -from .teste_uteis import full_path, assertDicionario + +sys.path.append('../') + from raspador.analizador import Parser, Dicionario from raspador.campos import BaseField, FloatField, \ IntegerField, BooleanField +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 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') + + class CampoItem(BaseField): def _iniciar(self): self.mascara = (r"(\d+)\s(\d+)\s+([\w.#\s/()]+)\s+(\d+)(\w+)" @@ -107,6 +161,10 @@ def analizar(self): self.arquivo, codificacao=self.codificacao_arquivo) or []) + @classmethod + def open_file(cls, filename): + return codecs.open(full_path(filename), encoding=cls.codificacao_arquivo) + assertDicionario = assertDicionario @@ -254,7 +312,8 @@ class TesteExtrairDadosComParseresAlinhados(BaseParaTestesComApiDeArquivo): def obter_arquivo(self): "sobrescrever retornando arquivo" - return open(full_path('arquivos/reducaoz.txt')) + return self.open_file('arquivos/reducaoz.txt') + # return open(full_path('arquivos/reducaoz.txt'), encoding=self.codificacao_arquivo) def criar_analizador(self): return ParserDeReducaoZ() @@ -285,3 +344,12 @@ def teste_deve_retornar_dados(self): } ] self.assertDicionario(reducao[0], self.itens[0]) + +if __name__ == '__main__': + import logging + logging.basicConfig( + filename='example.log', + level=logging.DEBUG, + format='%(asctime)-15s %(message)s' + ) + unittest.main() \ No newline at end of file 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') From 398830bb26743832c65e3f1141bddd1f3ca5e5b2 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 20 Sep 2013 00:01:05 -0300 Subject: [PATCH 04/17] Fixing tests; translations --- .coveragerc | 2 +- .gitignore | 1 + README.rst | 56 +++++++++++++++------------------------ raspador/analizador.py | 5 ++-- raspador/campos.py | 2 +- requirements_dev.txt | 5 +--- setup.py | 2 +- tasks.py | 13 --------- tests/teste_analizador.py | 1 + tox.ini | 19 +++++++++++++ 10 files changed, 50 insertions(+), 56 deletions(-) delete mode 100644 tasks.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc index 2c3a1e3..98dea62 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] include=*raspador* -omit=tasks.py +omit=tasks.py,*ordereddict* [report] exclude_lines = 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/README.rst b/README.rst index 95667c9..943feec 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ raspador ======== -.. image:: https://api.travis-ci.org/fgmacedo/raspador.png +.. image:: https://api.travis-ci.org/fgmacedo/raspador.png?branch=master :target: https://travis-ci.org/fgmacedo/raspador .. image:: https://coveralls.io/repos/fgmacedo/raspador/badge.png @@ -39,61 +39,49 @@ utilização de conceitos e recursos como iteradores, geradores, meta-programaç e property-descriptors. -Compatibilidade e dependências -=============================== +Compatibility and dependencies +============================== -O raspador é compatível com Python 2.6, 2.7, 3.2, 3.3 e pypy. +raspador runs on Python 2.6+, 3.2+ and pypy. -Desenvolvimento realizado em Python 2.7.5 e Python 3.2.3. - -Não há dependências externas. +There are no external dependencies. .. note:: Python 2.6 - Em Python 2.6, a biblioteca `ordereddict - `_ é necessária. + With Python 2.6, you must install `ordereddict + `_. - Você pode instalar com pip:: + You can install it with pip:: pip install ordereddict -Testes +Tests ====== -Os testes dependem de algumas bibliotecas externas: +To automate tests with all supported Python versions at once, we use `tox +`_. -.. code-block:: text - - coverage==3.6 - nose==1.3.0 - flake8==2.0 - invoke==0.5.0 - - -Você pode executar os testes com ``nosetests``: +Run all tests with: .. code-block:: bash - $ nosetests + $ tox -E adicionalmente, verificar a compatibilidade com o PEP8: +Tests depends on several third party libraries, but these are installed by tox +on each Python's virtualenv: -.. code-block:: bash - - $ flake8 raspador testes - -Ou por conveniência, executar os dois em sequência com invoke: - -.. 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 diff --git a/raspador/analizador.py b/raspador/analizador.py index 09e62ac..d17575b 100644 --- a/raspador/analizador.py +++ b/raspador/analizador.py @@ -69,13 +69,14 @@ def analizar_arquivo(self, arquivo, codificacao='latin1'): yield res def analizar_linha(self, linha): - logger.debug('analizar_linha: %s', linha) + logger.debug('analizar_linha: %s:%s', type(linha), 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: + logger.debug('init found: %r', self.inicio_encontrado) if not self.tem_retorno: self.retorno = self.default_item_class() if self.tem_busca_fim: @@ -162,7 +163,7 @@ def __init__(cls, name, bases, attrs): def adicionar_atributo_re(self, cls, atributos, nome): if nome in atributos: expressao = atributos[nome] - setattr(cls, '_' + nome, re.compile(expressao)) + setattr(cls, '_' + nome, re.compile(expressao, re.UNICODE)) Parser = ParserMetaclass('Parser', (object,), {}) diff --git a/raspador/campos.py b/raspador/campos.py index b943dbd..cfc6234 100644 --- a/raspador/campos.py +++ b/raspador/campos.py @@ -156,7 +156,7 @@ def mascara(self): @mascara.setter def mascara(self, valor): - self._mascara = re.compile(valor) if valor else None + self._mascara = re.compile(valor, re.UNICODE) if valor else None def anexar_na_classe(self, cls, name): self.cls = cls 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.py b/setup.py index d3c23a9..92616d1 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ 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 documents', long_description=long_description, license='MIT', url="http://github.org/fgmacedo/raspador", 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/teste_analizador.py b/tests/teste_analizador.py index a4ff7c7..945674c 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -1,4 +1,5 @@ #coding: utf-8 +from __future__ import unicode_literals import os import sys import unittest 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 From c426da99666b27d6a821d02646d73eeaed478fc8 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 20 Sep 2013 00:20:30 -0300 Subject: [PATCH 05/17] cleaning code; improved tests coverage --- README.rst | 14 ++++++++------ raspador/analizador.py | 10 ---------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/README.rst b/README.rst index 943feec..e72d6ec 100644 --- a/README.rst +++ b/README.rst @@ -85,6 +85,7 @@ Extract data from logs .. code-block:: python + from __future__ import print_function import json from raspador import Parser, StringField @@ -95,7 +96,7 @@ Extract data from logs """ - class ParserDeLog(Parser): + class LogParser(Parser): inicio = r'^PART.*' fim = r'^PART.*' PART = StringField(r'PART:([^\s]+)') @@ -103,14 +104,15 @@ Extract data from logs TYPE = StringField(r'TYPE:([^\s]+)') - a = ParserDeLog() + a = LogParser() - # res é um gerador - res = a.analizar(linha for linha in out.splitlines()) + # res is a generator + res = a.analizar(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/raspador/analizador.py b/raspador/analizador.py index d17575b..11331dc 100644 --- a/raspador/analizador.py +++ b/raspador/analizador.py @@ -46,20 +46,10 @@ def analizar(self, arquivo, codificacao='latin1'): 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 From 6cf4795091df10aabd62576c5e6c5ac521c1719d Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 20 Sep 2013 00:44:08 -0300 Subject: [PATCH 06/17] travis already has nosetests installed by default --- .travis.yml | 1 - 1 file changed, 1 deletion(-) 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" From 07b8a433d09805cfd91009777ed3124198598d0f Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 22 Sep 2013 12:22:41 -0300 Subject: [PATCH 07/17] cache using optimized deque.popleft over list.pop(0) --- raspador/cache.py | 7 ++++--- tests/teste_cache.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/raspador/cache.py b/raspador/cache.py index f6ff336..bb7b590 100644 --- a/raspador/cache.py +++ b/raspador/cache.py @@ -1,10 +1,11 @@ #coding: utf-8 +from collections import deque class Cache(object): def __init__(self, tamanho=0): self.tamanho = tamanho - self.lista = [] + self.lista = deque() def __len__(self): return len(self.lista) @@ -13,11 +14,11 @@ def adicionar(self, item): self.lista.append(item) if self.tamanho: while len(self.lista) > self.tamanho: - self.lista.pop(0) + self.lista.popleft() def itens(self): return self.lista def consumir(self): while self.lista: - yield self.lista.pop(0) + yield self.lista.popleft() diff --git a/tests/teste_cache.py b/tests/teste_cache.py index 587ca00..bf4e449 100644 --- a/tests/teste_cache.py +++ b/tests/teste_cache.py @@ -12,7 +12,7 @@ def test_deve_manter_ultimos_itens_em_cache(self): self.cache.adicionar(2) self.cache.adicionar(3) self.cache.adicionar(4) - self.assertEqual(self.cache.itens(), [2, 3, 4]) + self.assertEqual(list(self.cache.itens()), [2, 3, 4]) def test_deve_consumir_cache(self): self.cache.adicionar(1) From d3cec9b92272a0fd905a5ac0545baf3404937cad Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 22 Sep 2013 12:48:12 -0300 Subject: [PATCH 08/17] files and classes renamed to en --- docs/source/raspador.rst | 10 +++++----- raspador/__init__.py | 8 ++++---- raspador/{decoradores.py => decorators.py} | 0 raspador/{campos.py => fields.py} | 12 ++++++------ raspador/{colecoes.py => item.py} | 2 +- raspador/{analizador.py => parser.py} | 12 ++++++------ tests/teste_analizador.py | 22 +++++++++++----------- tests/teste_campos.py | 2 +- 8 files changed, 34 insertions(+), 34 deletions(-) rename raspador/{decoradores.py => decorators.py} (100%) rename raspador/{campos.py => fields.py} (92%) rename raspador/{colecoes.py => item.py} (91%) rename raspador/{analizador.py => parser.py} (93%) diff --git a/docs/source/raspador.rst b/docs/source/raspador.rst index 426b312..ff9b919 100644 --- a/docs/source/raspador.rst +++ b/docs/source/raspador.rst @@ -10,22 +10,22 @@ arquivos texto semi-estruturados. 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 7dc27a1..3f1ea81 100644 --- a/raspador/__init__.py +++ b/raspador/__init__.py @@ -1,10 +1,10 @@ # flake8: noqa -from .analizador import Parser -from .colecoes import Dicionario -from .campos import BaseField, StringField, FloatField, \ +from .parser import Parser +from .item import Dictionary +from .fields import BaseField, StringField, FloatField, \ IntegerField, DateField, DateTimeField, BooleanField -from .decoradores import ProxyDeCampo, ProxyConcatenaAteRE +from .decorators import ProxyDeCampo, ProxyConcatenaAteRE from .cache import Cache diff --git a/raspador/decoradores.py b/raspador/decorators.py similarity index 100% rename from raspador/decoradores.py rename to raspador/decorators.py diff --git a/raspador/campos.py b/raspador/fields.py similarity index 92% rename from raspador/campos.py rename to raspador/fields.py index cfc6234..0e2fe00 100644 --- a/raspador/campos.py +++ b/raspador/fields.py @@ -1,7 +1,7 @@ #coding: utf-8 """ -Os campos são simples extratores de dados baseados em expressões regulares. +Os fields 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 @@ -80,7 +80,7 @@ class BaseField(object): default - Valor que será utilizado no :py:class:`~raspador.analizador.Parser` + Valor que será utilizado no :py:class:`~raspador.parser.Parser` , quando o campo não retornar valor após a análise das linhas recebidas. @@ -95,7 +95,7 @@ class BaseField(object): ['022734'] Por convenção, quando um campo retorna uma lista, o - :py:class:`~raspador.analizador.Parser` acumula os valores + :py:class:`~raspador.parser.Parser` acumula os valores retornados pelo campo. """ def __init__(self, mascara=None, **kwargs): @@ -122,12 +122,12 @@ def _iniciar(self): "Ponto para inicialização especial nas classes descendentes" pass - def atribuir_analizador(self, analizador): + def atribuir_analizador(self, parser): """ Recebe uma referência fraca de - :py:class:`~raspador.analizador.Parser` + :py:class:`~raspador.parser.Parser` """ - self.analizador = analizador + self.parser = parser def _resultado_valido(self, valor): return bool(valor) diff --git a/raspador/colecoes.py b/raspador/item.py similarity index 91% rename from raspador/colecoes.py rename to raspador/item.py index d2015fe..447d57c 100644 --- a/raspador/colecoes.py +++ b/raspador/item.py @@ -6,7 +6,7 @@ from ordereddict import OrderedDict -class Dicionario(OrderedDict): +class Dictionary(OrderedDict): """ Dicionário especializado que permite acesso de chaves como propriedades. Adicionalmente, se uma chave já foi atribuída, transforma o valor em uma diff --git a/raspador/analizador.py b/raspador/parser.py similarity index 93% rename from raspador/analizador.py rename to raspador/parser.py index 11331dc..0761033 100644 --- a/raspador/analizador.py +++ b/raspador/parser.py @@ -1,12 +1,12 @@ #coding: utf-8 import re import weakref - -from .cache import Cache -from .colecoes import Dicionario import collections import logging +from .cache import Cache +from .item import Dictionary + logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class ParserMixin(object): """ qtd_linhas_cache = 0 - default_item_class = Dicionario + default_item_class = Dictionary def __init__(self): self.tem_busca_inicio = hasattr(self, '_inicio') @@ -29,9 +29,9 @@ def __init__(self): def _atribuir_analizador_nos_campos(self): """ - Atribui uma referência fraca do analizador para seus campos. + Atribui uma referência fraca do parser para seus fields. Não foi utilizada referência forte para não gerar dependência ciclica, - impedindo a liberação de memória do analizador. + impedindo a liberação de memória do parser. """ ref = weakref.ref(self) for item in list(self._campos.values()): diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index 945674c..b3df587 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -8,8 +8,8 @@ sys.path.append('../') -from raspador.analizador import Parser, Dicionario -from raspador.campos import BaseField, FloatField, \ +from raspador.parser import Parser, Dictionary +from raspador.fields import BaseField, FloatField, \ IntegerField, BooleanField @@ -46,7 +46,7 @@ def unchanged(self): if self.past_dict[o] == self.current_dict[o]) -def assertDicionario(self, a, b, mensagem=''): +def assertDictionary(self, a, b, mensagem=''): d = DictDiffer(a, b) def diff(msg, fn): @@ -68,7 +68,7 @@ def _iniciar(self): "\s+X\s+(\d+,\d+)\s+(\w+)\s+(\d+,\d+)") def to_python(self, r): - return Dicionario( + return Dictionary( Item=int(r[0]), Codigo=r[1], Descricao=r[2], @@ -96,7 +96,7 @@ def _iniciar(self): self.mascara = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)' def to_python(self, v): - return Dicionario( + return Dictionary( N=int(v[0]), Operacao=v[1].strip(), CON=int(v[2]), @@ -126,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, 'analizar')) if self.cache_itens: self.itens = self.cache_itens @@ -158,7 +158,7 @@ def obter_arquivo(self): raise NotImplementedError('Return an file-like object') def analizar(self): - return list(self.analizador.analizar( + return list(self.parser.analizar( self.arquivo, codificacao=self.codificacao_arquivo) or []) @@ -166,7 +166,7 @@ def analizar(self): def open_file(cls, filename): return codecs.open(full_path(filename), encoding=cls.codificacao_arquivo) - assertDicionario = assertDicionario + assertDictionary = assertDictionary class TesteDeExtrairDadosDeCupom(BaseParaTestesComApiDeArquivo): @@ -344,7 +344,7 @@ 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 diff --git a/tests/teste_campos.py b/tests/teste_campos.py index a7e1703..e139009 100644 --- a/tests/teste_campos.py +++ b/tests/teste_campos.py @@ -3,7 +3,7 @@ import unittest from datetime import date, datetime -from raspador.campos import BaseField, StringField, FloatField, \ +from raspador.fields import BaseField, StringField, FloatField, \ IntegerField, DateField, DateTimeField, BooleanField From d9eaac24ae2db2ec1db417e91592787bdb9ac486 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 22 Sep 2013 22:23:27 -0300 Subject: [PATCH 09/17] translations --- raspador/decorators.py | 4 ++-- raspador/fields.py | 16 +++++++-------- raspador/parser.py | 42 ++++++++++++++++++-------------------- tests/teste_analizador.py | 10 ++++----- tests/teste_campos.py | 28 ++++++++++++------------- tests/teste_decoradores.py | 30 +++++++++++++-------------- 6 files changed, 63 insertions(+), 67 deletions(-) diff --git a/raspador/decorators.py b/raspador/decorators.py index e45e836..ca39d5a 100644 --- a/raspador/decorators.py +++ b/raspador/decorators.py @@ -23,10 +23,10 @@ def __init__(self, campo, uniao, re_fim): self.uniao = uniao self.re_fim = re.compile(re_fim) - def analizar_linha(self, linha): + def parse_block(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) + return self.campo.parse_block(acumulado) diff --git a/raspador/fields.py b/raspador/fields.py index 0e2fe00..b1ab12c 100644 --- a/raspador/fields.py +++ b/raspador/fields.py @@ -28,7 +28,7 @@ class BaseField(object): >>> s = "02/01/2013 10:21:51 COO:022734" >>> campo = BaseField(mascara=r'COO:(\d+)') - >>> campo.analizar_linha(s) + >>> campo.parse_block(s) '022734' O parâmetro mascara é o único posicional, e deste modo, seu nome pode @@ -36,7 +36,7 @@ class BaseField(object): >>> s = "02/01/2013 10:21:51 COO:022734" >>> campo = BaseField(r'COO:(\d+)') - >>> campo.analizar_linha(s) + >>> campo.parse_block(s) '022734' @@ -50,7 +50,7 @@ class BaseField(object): ... return int(valor) * 2 ... >>> campo = BaseField(r'COO:(\d+)', ao_atribuir=dobro) - >>> campo.analizar_linha(s) # 45468 = 2 x 22734 + >>> campo.parse_block(s) # 45468 = 2 x 22734 45468 grupos @@ -66,7 +66,7 @@ class BaseField(object): >>> s = "Contador de Reduções Z: 1246" >>> campo = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \ grupos=1, ao_atribuir=int) - >>> campo.analizar_linha(s) + >>> campo.parse_block(s) 1246 Ou uma lista de inteiros:: @@ -74,7 +74,7 @@ class BaseField(object): >>> s = "Data do movimento: 02/01/2013 10:21:51" >>> c = BaseField(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\ grupos=[1, 2, 3]) - >>> c.analizar_linha(s) + >>> c.parse_block(s) ['02', '01', '2013'] @@ -91,7 +91,7 @@ class BaseField(object): >>> s = "02/01/2013 10:21:51 COO:022734" >>> campo = BaseField(r'COO:(\d+)', lista=True) - >>> campo.analizar_linha(s) + >>> campo.parse_block(s) ['022734'] Por convenção, quando um campo retorna uma lista, o @@ -122,7 +122,7 @@ def _iniciar(self): "Ponto para inicialização especial nas classes descendentes" pass - def atribuir_analizador(self, parser): + def assign_parser(self, parser): """ Recebe uma referência fraca de :py:class:`~raspador.parser.Parser` @@ -161,7 +161,7 @@ def mascara(self, valor): def anexar_na_classe(self, cls, name): self.cls = cls - def analizar_linha(self, linha): + def parse_block(self, linha): if self.mascara: valor = self._metodo_busca(linha) if self._resultado_valido(valor): diff --git a/raspador/parser.py b/raspador/parser.py index 0761033..77c23a6 100644 --- a/raspador/parser.py +++ b/raspador/parser.py @@ -17,40 +17,38 @@ class ParserMixin(object): Padrão mix-in. """ - qtd_linhas_cache = 0 + number_of_blocks_in_cache = 0 default_item_class = Dictionary 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._atribuir_analizador_nos_campos() + self.cache = Cache(self.number_of_blocks_in_cache + 1) + self._assign_parser_on_fields() - def _atribuir_analizador_nos_campos(self): + def _assign_parser_on_fields(self): """ - Atribui uma referência fraca do parser para seus fields. - Não foi utilizada referência forte para não gerar dependência ciclica, - impedindo a liberação de memória do parser. + Assigns an weak parser reference to fields. """ ref = weakref.ref(self) for item in list(self._campos.values()): - if hasattr(item, 'atribuir_analizador'): - item.atribuir_analizador(ref) + if hasattr(item, 'assign_parser'): + item.assign_parser(ref) - def analizar(self, arquivo, codificacao='latin1'): - for item in self.analizar_arquivo(arquivo, codificacao): + def parse(self, iterator): + for item in self.parse_iterator(iterator): yield item @property def tem_retorno(self): return hasattr(self, 'retorno') and self.retorno is not None - def analizar_arquivo(self, arquivo, codificacao='latin1'): + def parse_iterator(self, iterator): try: while True: - linha = next(arquivo) - res = self.analizar_linha(linha) + block = next(iterator) + res = self.parse_block(block) if res: yield res except StopIteration: @@ -58,26 +56,26 @@ def analizar_arquivo(self, arquivo, codificacao='latin1'): if res: yield res - def analizar_linha(self, linha): - logger.debug('analizar_linha: %s:%s', type(linha), linha) - self.cache.adicionar(linha) + def parse_block(self, block): + logger.debug('parse_block: %s:%s', type(block), block) + self.cache.adicionar(block) if self.tem_busca_inicio and not self.inicio_encontrado: - self.inicio_encontrado = bool(self._inicio.match(linha)) + self.inicio_encontrado = bool(self._inicio.match(block)) if self.inicio_encontrado: logger.debug('init found: %r', self.inicio_encontrado) if not self.tem_retorno: self.retorno = self.default_item_class() if self.tem_busca_fim: - self.inicio_encontrado = not bool(self._fim.match(linha)) + self.inicio_encontrado = not bool(self._fim.match(block)) - for linha in self.cache.consumir(): + for block 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) + valor = campo.parse_block(block) if valor is not None: self.atribuir_valor_ao_retorno(nome, valor) if self.retornar_ao_obter_valor: @@ -137,7 +135,7 @@ def __init__(cls, name, bases, attrs): super(ParserMetaclass, cls).__init__(name, bases, attrs) cls._campos = dict((k, v) for k, v in list(attrs.items()) - if hasattr(v, 'analizar_linha') + if hasattr(v, 'parse_block') and not isinstance(v, type)) cls.adicionar_atributo_re(cls, attrs, 'inicio') diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index b3df587..5c4b415 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -83,7 +83,7 @@ def to_python(self, r): class ExtratorDeDados(Parser): inicio = r'^\s+CUPOM FISCAL\s+$' fim = r'^FAB:.*BR$' - qtd_linhas_cache = 1 + 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+)') @@ -114,7 +114,7 @@ def processar_retorno(self): class ParserDeReducaoZ(Parser): inicio = r'^\s+REDUÇÃO Z\s+$' fim = r'^FAB:.*BR$' - qtd_linhas_cache = 1 + number_of_blocks_in_cache = 1 COO = IntegerField(r'COO:\s*(\d+)') CRZ = IntegerField(r'Contador de Redução Z:\s*(\d+)') Totalizadores = TotalizadoresNaoFiscais() @@ -130,7 +130,7 @@ def setUp(self): self.arquivo = self.obter_arquivo() # verificando se parser foi criado - self.assertTrue(hasattr(self.parser, 'analizar')) + self.assertTrue(hasattr(self.parser, 'parse')) if self.cache_itens: self.itens = self.cache_itens @@ -158,9 +158,7 @@ def obter_arquivo(self): raise NotImplementedError('Return an file-like object') def analizar(self): - return list(self.parser.analizar( - self.arquivo, - codificacao=self.codificacao_arquivo) or []) + return list(self.parser.parse(self.arquivo) or []) @classmethod def open_file(cls, filename): diff --git a/tests/teste_campos.py b/tests/teste_campos.py index e139009..d04e8c4 100644 --- a/tests/teste_campos.py +++ b/tests/teste_campos.py @@ -12,13 +12,13 @@ class TesteDeBaseField(unittest.TestCase): def teste_deve_retornar_valor_no_analizar(self): s = "02/01/2013 10:21:51 COO:022734" campo = BaseField(r'COO:(\d+)', nome='COO') - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, '022734') def teste_deve_retornar_none_sem_mascara(self): s = "02/01/2013 10:21:51 COO:022734" campo = BaseField() - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, None) def teste_deve_aceitar_callback(self): @@ -28,7 +28,7 @@ def dobro(valor): return int(valor) * 2 campo = BaseField(r'COO:(\d+)', nome='COO', ao_atribuir=dobro) - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, 45468) # 45468 = 2 x 22734 def teste_deve_recusar_callback_invalido(self): @@ -41,7 +41,7 @@ def teste_deve_utilizar_grupo_quando_informado(self): s = "Contador de Reduções Z: 1246" campo = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', grupos=1, ao_atribuir=int) - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, 1246) @@ -49,7 +49,7 @@ class TesteDeIntegerField(unittest.TestCase): def teste_deve_obter_valor(self): s = "02/01/2013 10:21:51 COO:022734" campo = IntegerField(r'COO:(\d+)', nome='COO') - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, 22734) @@ -57,13 +57,13 @@ class TesteDeFloatField(unittest.TestCase): def teste_deve_obter_valor(self): s = "VENDA BRUTA DIÁRIA: 793,00" campo = FloatField(r'VENDA BRUTA DIÁRIA:\s+(\d+,\d+)') - valor = campo.analizar_linha(s) + valor = campo.parse_block(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 = FloatField(r'VENDA BRUTA DIÁRIA:\s+([\d.]+,\d+)') - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, 10036.7) @@ -71,7 +71,7 @@ class TesteDeStringField(unittest.TestCase): def teste_deve_obter_valor(self): s = "1 Dinheiro 0,00" campo = StringField(r'\d+\s+(\w[^\d]+)', nome='Meio') - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) self.assertEqual(valor, 'Dinheiro') @@ -80,12 +80,12 @@ class TesteDeBooleanField(unittest.TestCase): def teste_deve_obter_valor_verdadeiro_se_bater_e_capturar(self): campo = BooleanField(r'^\s+(CANCELAMENTO)\s+$', nome='Cancelado') - valor = campo.analizar_linha(self.s) + valor = campo.parse_block(self.s) self.assertEqual(valor, True) def teste_deve_retornar_falso_ao_finalizar_quando_regex_nao_bate(self): campo = BooleanField(r'^\s+HAH\s+$', nome='Cancelado') - valor = campo.analizar_linha(self.s) + valor = campo.parse_block(self.s) self.assertEqual(valor, None) valor = campo.default self.assertEqual(valor, False) @@ -95,14 +95,14 @@ class TesteDeDateField(unittest.TestCase): def teste_deve_obter_valor(self): s = "02/01/2013 10:21:51 COO:022734" campo = DateField(r'^(\d+/\d+/\d+)', nome='Data') - valor = campo.analizar_linha(s) + valor = campo.parse_block(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 = DateField(r'^(\d+-\d+-\d+)', nome='Data', formato='%Y-%m-%d') - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) data_esperada = date(2013, 1, 2) self.assertEqual(valor, data_esperada) @@ -111,7 +111,7 @@ class TesteDeDateTimeField(unittest.TestCase): def teste_deve_obter_valor(self): s = "02/01/2013 10:21:51 COO:022734" campo = DateTimeField(r'^(\d+/\d+/\d+ \d+:\d+:\d+)') - valor = campo.analizar_linha(s) + valor = campo.parse_block(s) data_esperada = datetime(2013, 1, 2, 10, 21, 51) self.assertEqual(valor, data_esperada) @@ -119,6 +119,6 @@ def teste_deve_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.analizar_linha(s) + valor = campo.parse_block(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 index a26e2ca..910179b 100644 --- a/tests/teste_decoradores.py +++ b/tests/teste_decoradores.py @@ -11,7 +11,7 @@ def __init__(self, default=None, lista=False, retornar=False): self.linhas = [] self.retornar = retornar - def analizar_linha(self, linha): + def parse_block(self, linha): self.linhas.append(linha) if self.retornar: return linha @@ -42,8 +42,8 @@ def teste_decorador_deve_retornar_valor_lista(self): def teste_decorador_deve_passar_linhas(self): mock = CampoFake() p = ProxyDeCampo(mock) - p.analizar_linha('teste1') - p.analizar_linha('teste2') + p.parse_block('teste1') + p.parse_block('teste2') self.assertEqual( mock.linhas, ['teste1', 'teste2'] @@ -55,12 +55,12 @@ 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') + 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', @@ -72,12 +72,12 @@ def teste_deve_chamar_decorado_acumulando_linhas(self): 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') + 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', From d62b0be16a7d11e7966a01bddaf8f2e537373b08 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Thu, 26 Sep 2013 00:26:12 -0300 Subject: [PATCH 10/17] more translations --- README.rst | 4 +- raspador/parser.py | 94 +++++++++++++++++++-------------------- tests/teste_analizador.py | 19 ++++---- 3 files changed, 58 insertions(+), 59 deletions(-) diff --git a/README.rst b/README.rst index e72d6ec..41a0f34 100644 --- a/README.rst +++ b/README.rst @@ -97,8 +97,8 @@ Extract data from logs class LogParser(Parser): - inicio = r'^PART.*' - fim = r'^PART.*' + begin = r'^PART.*' + end = r'^PART.*' PART = StringField(r'PART:([^\s]+)') UUID = StringField(r'UUID:([^\s]+)') TYPE = StringField(r'TYPE:([^\s]+)') diff --git a/raspador/parser.py b/raspador/parser.py index 77c23a6..64390eb 100644 --- a/raspador/parser.py +++ b/raspador/parser.py @@ -12,18 +12,16 @@ class ParserMixin(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. + A mixin that holds all base parser implementation. """ number_of_blocks_in_cache = 0 default_item_class = Dictionary 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.has_search_begin = hasattr(self, '_begin') + self.has_search_end = hasattr(self, '_end') + self.begin_found = not self.has_search_begin self.cache = Cache(self.number_of_blocks_in_cache + 1) self._assign_parser_on_fields() @@ -32,7 +30,7 @@ def _assign_parser_on_fields(self): Assigns an weak parser reference to fields. """ ref = weakref.ref(self) - for item in list(self._campos.values()): + for item in list(self.fields.values()): if hasattr(item, 'assign_parser'): item.assign_parser(ref) @@ -60,28 +58,28 @@ def parse_block(self, block): logger.debug('parse_block: %s:%s', type(block), block) self.cache.adicionar(block) - if self.tem_busca_inicio and not self.inicio_encontrado: - self.inicio_encontrado = bool(self._inicio.match(block)) + if self.has_search_begin and not self.begin_found: + self.begin_found = bool(self._begin.match(block)) - if self.inicio_encontrado: - logger.debug('init found: %r', self.inicio_encontrado) + if self.begin_found: + logger.debug('init found: %r', self.begin_found) if not self.tem_retorno: self.retorno = self.default_item_class() - if self.tem_busca_fim: - self.inicio_encontrado = not bool(self._fim.match(block)) + if self.has_search_end: + self.begin_found = not bool(self._end.match(block)) for block 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: + for name, field in list(self.fields.items()): + if name in self.retorno and \ + hasattr(field, 'lista') and not field.lista: continue - valor = campo.parse_block(block) - if valor is not None: - self.atribuir_valor_ao_retorno(nome, valor) + value = field.parse_block(block) + if value is not None: + self.atribuir_valor_ao_retorno(name, value) if self.retornar_ao_obter_valor: return self.finalizar_retorno() - if not self.inicio_encontrado: + if not self.begin_found: return self.finalizar_retorno() def finalizar(self): @@ -92,32 +90,32 @@ def finalizar(self): 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.default - if valor is not None: - self.atribuir_valor_ao_retorno(nome, valor) + for name, field in list(self.fields.items()): + if not name in self.retorno: + value = None + if hasattr(field, 'finalizar') and \ + isinstance(field.finalizar, collections.Callable): + value = field.finalizar() + if value is None: + value = field.default + if value is not None: + self.atribuir_valor_ao_retorno(name, value) 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) + def atribuir_valor_ao_retorno(self, name, value): + if isinstance(value, list) and not name in self.retorno: + self.retorno[name] = value + elif isinstance(value, list) and hasattr(self.retorno[name], 'extend'): + self.retorno[name].extend(value) else: - self.retorno[nome] = valor + self.retorno[name] = value def processar_retorno(self): - "Permite modificações finais ao objeto sendo retornado" + "Allows final modifications at the object being returned" pass @@ -134,24 +132,24 @@ def __new__(self, name, bases, attrs): def __init__(cls, name, bases, attrs): super(ParserMetaclass, cls).__init__(name, bases, attrs) - cls._campos = dict((k, v) for k, v in list(attrs.items()) - if hasattr(v, 'parse_block') - and not isinstance(v, type)) + cls.fields = dict((k, v) for k, v in list(attrs.items()) + if hasattr(v, 'parse_block') + and not isinstance(v, type)) - cls.adicionar_atributo_re(cls, attrs, 'inicio') - cls.adicionar_atributo_re(cls, attrs, 'fim') + cls.adicionar_atributo_re(cls, attrs, 'begin') + cls.adicionar_atributo_re(cls, attrs, 'end') if not hasattr(cls, 'retornar_ao_obter_valor'): cls.retornar_ao_obter_valor = False - for nome, atributo in list(cls._campos.items()): + for name, atributo in list(cls.fields.items()): if hasattr(atributo, 'anexar_na_classe'): - atributo.anexar_na_classe(cls, nome) + atributo.anexar_na_classe(cls, name) - def adicionar_atributo_re(self, cls, atributos, nome): - if nome in atributos: - expressao = atributos[nome] - setattr(cls, '_' + nome, re.compile(expressao, re.UNICODE)) + def adicionar_atributo_re(self, cls, atributos, name): + if name in atributos: + expressao = atributos[name] + setattr(cls, '_' + name, re.compile(expressao, re.UNICODE)) Parser = ParserMetaclass('Parser', (object,), {}) diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index 5c4b415..4ebf355 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -81,8 +81,8 @@ def to_python(self, r): class ExtratorDeDados(Parser): - inicio = r'^\s+CUPOM FISCAL\s+$' - fim = r'^FAB:.*BR$' + 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+$') @@ -103,8 +103,8 @@ def to_python(self, v): ValorAcumulado=float(re.sub('[,.]', '.', v[3])), ) - inicio = r'^\s+TOTALIZADORES NÃO FISCAIS\s+$' - fim = r'^[\s-]*$' + begin = r'^\s+TOTALIZADORES NÃO FISCAIS\s+$' + end = r'^[\s-]*$' Totalizador = CampoNF(lista=True) def processar_retorno(self): @@ -112,8 +112,8 @@ def processar_retorno(self): class ParserDeReducaoZ(Parser): - inicio = r'^\s+REDUÇÃO Z\s+$' - fim = r'^FAB:.*BR$' + 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+)') @@ -162,7 +162,8 @@ def analizar(self): @classmethod def open_file(cls, filename): - return codecs.open(full_path(filename), encoding=cls.codificacao_arquivo) + return codecs.open(full_path(filename), + encoding=cls.codificacao_arquivo) assertDictionary = assertDictionary @@ -296,8 +297,8 @@ def obter_arquivo(self): def criar_analizador(self): class ExtratorDeDados(Parser): - inicio = r'^\s+CUPOM FISCAL\s+$' - fim = r'^FAB:.*BR$' + begin = r'^\s+CUPOM FISCAL\s+$' + end = r'^FAB:.*BR$' Total = FloatField(r'^TOTAL R\$\s+(\d+,\d+)') return ExtratorDeDados() From e743713bd3b92cf8d44f5e2aa8c6e14bb0acb6be Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 28 Sep 2013 00:28:54 -0300 Subject: [PATCH 11/17] parser translation completed --- raspador/cache.py | 26 +++++----- raspador/fields.py | 10 ++-- raspador/parser.py | 102 +++++++++++++++++++------------------ tests/teste_analizador.py | 17 +++---- tests/teste_cache.py | 42 +++++++-------- tests/teste_decoradores.py | 10 ++-- 6 files changed, 104 insertions(+), 103 deletions(-) diff --git a/raspador/cache.py b/raspador/cache.py index bb7b590..6f2107e 100644 --- a/raspador/cache.py +++ b/raspador/cache.py @@ -3,22 +3,22 @@ class Cache(object): - def __init__(self, tamanho=0): - self.tamanho = tamanho - self.lista = deque() + def __init__(self, length=0): + self.length = 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.popleft() + def append(self, item): + self.items.append(item) + if self.length: + while len(self.items) > self.length: + self.items.popleft() def itens(self): - return self.lista + return self.items - def consumir(self): - while self.lista: - yield self.lista.popleft() + def consume(self): + while self.items: + yield self.items.popleft() diff --git a/raspador/fields.py b/raspador/fields.py index b1ab12c..1eb3a4f 100644 --- a/raspador/fields.py +++ b/raspador/fields.py @@ -85,12 +85,12 @@ class BaseField(object): linhas recebidas. - lista + is_list Quando especificado, retorna o valor como uma lista:: >>> s = "02/01/2013 10:21:51 COO:022734" - >>> campo = BaseField(r'COO:(\d+)', lista=True) + >>> campo = BaseField(r'COO:(\d+)', is_list=True) >>> campo.parse_block(s) ['022734'] @@ -100,7 +100,7 @@ class BaseField(object): """ def __init__(self, mascara=None, **kwargs): self.default = kwargs.get('default') - self.lista = kwargs.get('lista', False) + self.is_list = kwargs.get('is_list', False) self.mascara = mascara self.ao_atribuir = kwargs.get('ao_atribuir') self.grupos = kwargs.get('grupos', []) @@ -158,7 +158,7 @@ def mascara(self): def mascara(self, valor): self._mascara = re.compile(valor, re.UNICODE) if valor else None - def anexar_na_classe(self, cls, name): + def assign_class(self, cls, name): self.cls = cls def parse_block(self, linha): @@ -169,7 +169,7 @@ def parse_block(self, linha): valor = self.to_python(valor) if self.ao_atribuir: valor = self.ao_atribuir(valor) - if valor is not None and self.lista \ + if valor is not None and self.is_list \ and not isinstance(valor, list): valor = [valor] return valor diff --git a/raspador/parser.py b/raspador/parser.py index 64390eb..74aaced 100644 --- a/raspador/parser.py +++ b/raspador/parser.py @@ -1,4 +1,5 @@ #coding: utf-8 +# from __future__ import unicode_literals import re import weakref import collections @@ -17,15 +18,16 @@ class ParserMixin(object): 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.has_search_begin = hasattr(self, '_begin') - self.has_search_end = hasattr(self, '_end') self.begin_found = not self.has_search_begin self.cache = Cache(self.number_of_blocks_in_cache + 1) - self._assign_parser_on_fields() + self._assign_parser_to_fields() - def _assign_parser_on_fields(self): + def _assign_parser_to_fields(self): """ Assigns an weak parser reference to fields. """ @@ -39,8 +41,8 @@ def parse(self, iterator): yield item @property - def tem_retorno(self): - return hasattr(self, 'retorno') and self.retorno is not None + def has_item(self): + return hasattr(self, 'item') and self.item is not None def parse_iterator(self, iterator): try: @@ -50,71 +52,71 @@ def parse_iterator(self, iterator): if res: yield res except StopIteration: - res = self.finalizar() + res = self.finalize() if res: yield res def parse_block(self, block): - logger.debug('parse_block: %s:%s', type(block), block) - self.cache.adicionar(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.tem_retorno: - self.retorno = self.default_item_class() + 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.consumir(): + for block in self.cache.consume(): for name, field in list(self.fields.items()): - if name in self.retorno and \ - hasattr(field, 'lista') and not field.lista: + 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.atribuir_valor_ao_retorno(name, value) - if self.retornar_ao_obter_valor: - return self.finalizar_retorno() + 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.finalizar_retorno() + return self.finalize_item() - def finalizar(self): - if not self.tem_retorno: + def finalize(self): + if not self.has_item: return None - if self.retornar_ao_obter_valor: + if self.yield_item_to_each_field_value_found: return None - return self.finalizar_retorno() + return self.finalize_item() - def finalizar_retorno(self): + def finalize_item(self): for name, field in list(self.fields.items()): - if not name in self.retorno: + if not name in self.item: value = None - if hasattr(field, 'finalizar') and \ - isinstance(field.finalizar, collections.Callable): - value = field.finalizar() + 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.atribuir_valor_ao_retorno(name, value) + self.assign_value_into_item(name, value) - self.processar_retorno() - res = self.retorno - self.retorno = None + self.process_item() + res = self.item + self.item = None return res - def atribuir_valor_ao_retorno(self, name, value): - if isinstance(value, list) and not name in self.retorno: - self.retorno[name] = value - elif isinstance(value, list) and hasattr(self.retorno[name], 'extend'): - self.retorno[name].extend(value) + 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.retorno[name] = value + self.item[name] = value - def processar_retorno(self): + def process_item(self): "Allows final modifications at the object being returned" pass @@ -136,20 +138,20 @@ def __init__(cls, name, bases, attrs): if hasattr(v, 'parse_block') and not isinstance(v, type)) - cls.adicionar_atributo_re(cls, attrs, 'begin') - cls.adicionar_atributo_re(cls, attrs, 'end') + cls.add_regex_attr(cls, attrs, 'begin') + cls.add_regex_attr(cls, attrs, 'end') - if not hasattr(cls, 'retornar_ao_obter_valor'): - cls.retornar_ao_obter_valor = False + for name, attr in list(cls.fields.items()): + if hasattr(attr, 'assign_class'): + attr.assign_class(cls, name) - for name, atributo in list(cls.fields.items()): - if hasattr(atributo, 'anexar_na_classe'): - atributo.anexar_na_classe(cls, name) - - def adicionar_atributo_re(self, cls, atributos, name): - if name in atributos: - expressao = atributos[name] - setattr(cls, '_' + name, re.compile(expressao, re.UNICODE)) + 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/tests/teste_analizador.py b/tests/teste_analizador.py index 4ebf355..5ef3579 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -87,7 +87,7 @@ class ExtratorDeDados(Parser): COO = IntegerField(r'COO:\s?(\d+)') Cancelado = BooleanField(r'^\s+(CANCELAMENTO)\s+$') Total = FloatField(r'^TOTAL R\$\s+(\d+,\d+)') - Itens = CampoItem(lista=True) + Itens = CampoItem(is_list=True) class TotalizadoresNaoFiscais(Parser): @@ -105,10 +105,10 @@ def to_python(self, v): begin = r'^\s+TOTALIZADORES NÃO FISCAIS\s+$' end = r'^[\s-]*$' - Totalizador = CampoNF(lista=True) + Totalizador = CampoNF(is_list=True) - def processar_retorno(self): - self.retorno = self.retorno.Totalizador + def process_item(self): + self.item = self.item.Totalizador class ParserDeReducaoZ(Parser): @@ -172,7 +172,7 @@ class TesteDeExtrairDadosDeCupom(BaseParaTestesComApiDeArquivo): codificacao_arquivo = 'utf-8' def obter_arquivo(self): - return open(full_path('arquivos/cupom.txt')) + return self.open_file('arquivos/cupom.txt') def criar_analizador(self): return ExtratorDeDados() @@ -293,7 +293,7 @@ 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('arquivos/cupom.txt') def criar_analizador(self): class ExtratorDeDados(Parser): @@ -313,7 +313,6 @@ class TesteExtrairDadosComParseresAlinhados(BaseParaTestesComApiDeArquivo): def obter_arquivo(self): "sobrescrever retornando arquivo" return self.open_file('arquivos/reducaoz.txt') - # return open(full_path('arquivos/reducaoz.txt'), encoding=self.codificacao_arquivo) def criar_analizador(self): return ParserDeReducaoZ() @@ -348,8 +347,8 @@ def teste_deve_retornar_dados(self): if __name__ == '__main__': import logging logging.basicConfig( - filename='example.log', + # filename='test_parser.log', level=logging.DEBUG, format='%(asctime)-15s %(message)s' ) - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/teste_cache.py b/tests/teste_cache.py index bf4e449..b52f32e 100644 --- a/tests/teste_cache.py +++ b/tests/teste_cache.py @@ -8,33 +8,33 @@ 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.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_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_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_deve_retornar_vazio_se_nao_tem_cache(self): - valor = list(self.cache.consumir()) + valor = list(self.cache.consume()) self.assertEqual(valor, []) def test_deve_retornar_tamanho_da_lista(self): - self.cache.adicionar(1) - self.cache.adicionar(2) + self.cache.append(1) + self.cache.append(2) self.assertEqual(len(self.cache), 2) - self.cache.adicionar(3) - self.cache.adicionar(4) - self.cache.adicionar(5) + self.cache.append(3) + self.cache.append(4) + self.cache.append(5) self.assertEqual(len(self.cache), 3) diff --git a/tests/teste_decoradores.py b/tests/teste_decoradores.py index 910179b..433c660 100644 --- a/tests/teste_decoradores.py +++ b/tests/teste_decoradores.py @@ -5,9 +5,9 @@ class CampoFake(object): - def __init__(self, default=None, lista=False, retornar=False): + def __init__(self, default=None, is_list=False, retornar=False): self.default = default - self.lista = lista + self.is_list = is_list self.linhas = [] self.retornar = retornar @@ -16,7 +16,7 @@ def parse_block(self, linha): if self.retornar: return linha - def anexar_na_classe(self, cls, nome, informacoes): + def assign_class(self, cls, nome, informacoes): self.classe = cls self.nome = nome self.informacoes = informacoes @@ -32,10 +32,10 @@ def teste_decorador_deve_retornar_default(self): ) def teste_decorador_deve_retornar_valor_lista(self): - mock = CampoFake(lista=True) + mock = CampoFake(is_list=True) p = ProxyDeCampo(mock) self.assertEqual( - p.lista, + p.is_list, True ) From 4d7dcac44ee46545c2ebb4d1daf4ee5b291e9956 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 28 Sep 2013 18:50:23 -0300 Subject: [PATCH 12/17] cache and proxy translation --- .coveragerc | 4 +++- README.rst | 2 +- raspador/__init__.py | 2 +- raspador/cache.py | 8 ++++---- raspador/decorators.py | 39 +++++++++++++++++++------------------- raspador/item.py | 16 +++++++--------- setup.cfg | 1 + tests/teste_cache.py | 8 ++++---- tests/teste_decoradores.py | 12 ++++++------ 9 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.coveragerc b/.coveragerc index 98dea62..35dc139 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,4 +7,6 @@ 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/README.rst b/README.rst index 41a0f34..d532644 100644 --- a/README.rst +++ b/README.rst @@ -107,7 +107,7 @@ Extract data from logs a = LogParser() # res is a generator - res = a.analizar(iter(out.splitlines())) + res = a.parse(iter(out.splitlines())) out_as_json = json.dumps(list(res), indent=2) print (out_as_json) diff --git a/raspador/__init__.py b/raspador/__init__.py index 3f1ea81..3abdb44 100644 --- a/raspador/__init__.py +++ b/raspador/__init__.py @@ -5,6 +5,6 @@ from .fields import BaseField, StringField, FloatField, \ IntegerField, DateField, DateTimeField, BooleanField -from .decorators import ProxyDeCampo, ProxyConcatenaAteRE +from .decorators import FieldProxy, UnionUntilRegexProxy from .cache import Cache diff --git a/raspador/cache.py b/raspador/cache.py index 6f2107e..044e6f6 100644 --- a/raspador/cache.py +++ b/raspador/cache.py @@ -3,8 +3,8 @@ class Cache(object): - def __init__(self, length=0): - self.length = length + def __init__(self, max_length=0): + self.max_length = max_length self.items = deque() def __len__(self): @@ -12,8 +12,8 @@ def __len__(self): def append(self, item): self.items.append(item) - if self.length: - while len(self.items) > self.length: + if self.max_length: + while len(self.items) > self.max_length: self.items.popleft() def itens(self): diff --git a/raspador/decorators.py b/raspador/decorators.py index ca39d5a..77cb7a8 100644 --- a/raspador/decorators.py +++ b/raspador/decorators.py @@ -2,31 +2,30 @@ import re -class ProxyDeCampo(object): - def __init__(self, campo): - self.campo = campo +class FieldProxy(object): + def __init__(self, field): + self.field = field - def __getattr__(self, atributo): - return getattr(self.campo, atributo) + def __getattr__(self, attr): + return getattr(self.field, attr) -class ProxyConcatenaAteRE(ProxyDeCampo): +class UnionUntilRegexProxy(FieldProxy): """ - 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. + 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, campo, uniao, re_fim): - super(ProxyConcatenaAteRE, self).__init__(campo) + def __init__(self, field, union_method, search_regex): + super(UnionUntilRegexProxy, self).__init__(field) self.cache = [] - self.uniao = uniao - self.re_fim = re.compile(re_fim) + self.union_method = union_method + self.search_regex = re.compile(search_regex, re.UNICODE) - def parse_block(self, linha): - linha = linha.rstrip() - self.cache.append(linha) - if self.re_fim.match(linha): - acumulado = self.uniao(self.cache) + 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.campo.parse_block(acumulado) + return self.field.parse_block(blocks) diff --git a/raspador/item.py b/raspador/item.py index 447d57c..94b0387 100644 --- a/raspador/item.py +++ b/raspador/item.py @@ -1,19 +1,17 @@ #coding: utf-8 try: from collections import OrderedDict -except: +except ImportError: # Python 2.6 alternative from ordereddict import OrderedDict class Dictionary(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. + Dictionary that exposes keys as properties for easy read access. """ - def __getattr__(self, nome): - if nome in self: - return self[nome] - raise AttributeError("%r sem atributo %r" % - (type(self).__name__, nome)) + def __getattr__(self, name): + if name in self: + return self[name] + raise AttributeError("%s without attr '%s'" % + (type(self).__name__, name)) 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/tests/teste_cache.py b/tests/teste_cache.py index b52f32e..d571b47 100644 --- a/tests/teste_cache.py +++ b/tests/teste_cache.py @@ -7,14 +7,14 @@ class Test_Cache(unittest.TestCase): def setUp(self): self.cache = Cache(3) - def test_deve_manter_ultimos_itens_em_cache(self): + 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_deve_consume_cache(self): + def test_should_consume_cache(self): self.cache.append(1) self.cache.append(2) self.cache.append(3) @@ -26,11 +26,11 @@ def test_deve_consume_cache(self): self.cache.append(7) self.assertEqual(list(self.cache.consume()), [6, 7]) - def test_deve_retornar_vazio_se_nao_tem_cache(self): + def test_should_return_empty_if_empty(self): valor = list(self.cache.consume()) self.assertEqual(valor, []) - def test_deve_retornar_tamanho_da_lista(self): + def test_should_return_cache_length(self): self.cache.append(1) self.cache.append(2) self.assertEqual(len(self.cache), 2) diff --git a/tests/teste_decoradores.py b/tests/teste_decoradores.py index 433c660..d0ece9b 100644 --- a/tests/teste_decoradores.py +++ b/tests/teste_decoradores.py @@ -1,7 +1,7 @@ #coding: utf-8 import unittest -from raspador import ProxyDeCampo, ProxyConcatenaAteRE +from raspador import FieldProxy, UnionUntilRegexProxy class CampoFake(object): @@ -25,7 +25,7 @@ def assign_class(self, cls, nome, informacoes): class TesteDeProxyChamandoMetodosSemIntervencao(unittest.TestCase): def teste_decorador_deve_retornar_default(self): mock = CampoFake(default=1234) - p = ProxyDeCampo(mock) + p = FieldProxy(mock) self.assertEqual( p.default, 1234 @@ -33,7 +33,7 @@ def teste_decorador_deve_retornar_default(self): def teste_decorador_deve_retornar_valor_lista(self): mock = CampoFake(is_list=True) - p = ProxyDeCampo(mock) + p = FieldProxy(mock) self.assertEqual( p.is_list, True @@ -41,7 +41,7 @@ def teste_decorador_deve_retornar_valor_lista(self): def teste_decorador_deve_passar_linhas(self): mock = CampoFake() - p = ProxyDeCampo(mock) + p = FieldProxy(mock) p.parse_block('teste1') p.parse_block('teste2') self.assertEqual( @@ -54,7 +54,7 @@ class TesteDeDecoradorConcatenaAteRE(unittest.TestCase): def teste_deve_chamar_decorado_acumulando_linhas(self): mock = CampoFake() - p = ProxyConcatenaAteRE(mock, ' '.join, 'l4|l6') + p = UnionUntilRegexProxy(mock, ' '.join, 'l4|l6') p.parse_block('l1') p.parse_block('l2') p.parse_block('l3') @@ -71,7 +71,7 @@ def teste_deve_chamar_decorado_acumulando_linhas(self): def teste_deve_chamar_decorado_retornando_primeiro_valor(self): mock = CampoFake(retornar=True) - p = ProxyConcatenaAteRE(mock, ' '.join, 'l\d') + p = UnionUntilRegexProxy(mock, ' '.join, 'l\d') p.parse_block('l1') p.parse_block('l2') p.parse_block('l3') From 2e03172140f97ad2f8ef89e1541faf63e27573e5 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 28 Sep 2013 20:04:42 -0300 Subject: [PATCH 13/17] partial fields translation --- README.rst | 2 +- raspador/__init__.py | 2 +- raspador/fields.py | 212 +++++++++++++++++++------------------- tests/teste_analizador.py | 12 +-- tests/teste_campos.py | 86 +++++++++------- 5 files changed, 165 insertions(+), 149 deletions(-) diff --git a/README.rst b/README.rst index d532644..2f14904 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ Biblioteca para extração de dados em documentos semi-estruturados. 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. +automaticamente a partir dos groups capturados. O analisador é implementado como um gerador, onde cada item encontrado pode ser diff --git a/raspador/__init__.py b/raspador/__init__.py index 3abdb44..0d1d845 100644 --- a/raspador/__init__.py +++ b/raspador/__init__.py @@ -2,7 +2,7 @@ from .parser import Parser from .item import Dictionary -from .fields import BaseField, StringField, FloatField, \ +from .fields import BaseField, StringField, FloatField, BRFloatField, \ IntegerField, DateField, DateTimeField, BooleanField from .decorators import FieldProxy, UnionUntilRegexProxy diff --git a/raspador/fields.py b/raspador/fields.py index 1eb3a4f..54bdf2d 100644 --- a/raspador/fields.py +++ b/raspador/fields.py @@ -3,8 +3,8 @@ """ Os fields 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 +Ao confrontar uma block recebida para análise com sua expressão regular, o +campo verifica se há groups 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). """ @@ -21,17 +21,17 @@ class BaseField(object): O comportamento do Campo pode ser ajustado através de diversos parâmetros: - mascara + search 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 = BaseField(mascara=r'COO:(\d+)') + >>> campo = BaseField(search=r'COO:(\d+)') >>> campo.parse_block(s) '022734' - O parâmetro mascara é o único posicional, e deste modo, seu nome pode + O parâmetro search é o único posicional, e deste modo, seu nome pode ser omitido:: >>> s = "02/01/2013 10:21:51 COO:022734" @@ -40,32 +40,32 @@ class BaseField(object): '022734' - ao_atribuir + out_processor - Recebe um callback para tratar o valor antes de ser retornado pelo + Recebe um callback para tratar o value antes de ser retornado pelo campo. >>> s = "02/01/2013 10:21:51 COO:022734" - >>> def dobro(valor): - ... return int(valor) * 2 + >>> def dobro(value): + ... return int(value) * 2 ... - >>> campo = BaseField(r'COO:(\d+)', ao_atribuir=dobro) + >>> campo = BaseField(r'COO:(\d+)', out_processor=dobro) >>> campo.parse_block(s) # 45468 = 2 x 22734 45468 - grupos + groups - Permite escolher quais grupos capturados o campo deve processar como + Permite escolher quais groups 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. + groups para correspondência da expressão regular, mas que apenas parte + destes groups 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 = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \ - grupos=1, ao_atribuir=int) + groups=1, out_processor=int) >>> campo.parse_block(s) 1246 @@ -73,7 +73,7 @@ class BaseField(object): >>> s = "Data do movimento: 02/01/2013 10:21:51" >>> c = BaseField(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\ - grupos=[1, 2, 3]) + groups=[1, 2, 3]) >>> c.parse_block(s) ['02', '01', '2013'] @@ -81,13 +81,13 @@ class BaseField(object): default Valor que será utilizado no :py:class:`~raspador.parser.Parser` - , quando o campo não retornar valor após a análise das + , quando o campo não retornar value após a análise das linhas recebidas. is_list - Quando especificado, retorna o valor como uma lista:: + Quando especificado, retorna o value como uma lista:: >>> s = "02/01/2013 10:21:51 COO:022734" >>> campo = BaseField(r'COO:(\d+)', is_list=True) @@ -98,121 +98,129 @@ class BaseField(object): :py:class:`~raspador.parser.Parser` acumula os valores retornados pelo campo. """ - def __init__(self, mascara=None, **kwargs): - self.default = kwargs.get('default') - self.is_list = kwargs.get('is_list', False) - self.mascara = mascara - self.ao_atribuir = kwargs.get('ao_atribuir') - self.grupos = kwargs.get('grupos', []) + def __init__(self, search=None, default=None, is_list=False, + out_processor=None, groups=[]): + self.search = search + self.default = default + self.is_list = is_list + self.out_processor = out_processor + self.groups = groups - if self.ao_atribuir and \ - not isinstance(self.ao_atribuir, collections.Callable): - raise TypeError('O callback ao_atribuir não é uma função.') + if self.out_processor and \ + not isinstance(self.out_processor, collections.Callable): + raise TypeError('out_processor is not callable.') - if not hasattr(self.grupos, '__iter__'): - self.grupos = (self.grupos,) + if not hasattr(self.groups, '__iter__'): + self.groups = (self.groups,) - self._iniciar() + self._setup() @property - def _metodo_busca(self): - return self.mascara.findall + def _search_method(self): + return self.search.findall - def _iniciar(self): - "Ponto para inicialização especial nas classes descendentes" + def _setup(self): + "Hook to special setup required on child classes" pass def assign_parser(self, parser): """ - Recebe uma referência fraca de + Receives a weak reference of :py:class:`~raspador.parser.Parser` """ self.parser = parser - def _resultado_valido(self, valor): - return bool(valor) + def _is_valid_result(self, value): + return bool(value) - 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] + 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(valor) == 1: # não é desejado uma tupla, - valor = valor[0] # se houver apenas um item - return valor + if len(value) == 1: # take first, if only one item + value = value[0] + return value - def to_python(self, valor): + def to_python(self, value): """ - Converte o valor recebido palo parser para o tipo de dado - nativo do python + Converts parsed data to native python type. """ - return valor + return value @property - def mascara(self): - return self._mascara + def search(self): + return self._search - @mascara.setter - def mascara(self, valor): - self._mascara = re.compile(valor, re.UNICODE) if valor else None + @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, linha): - if self.mascara: - valor = self._metodo_busca(linha) - if self._resultado_valido(valor): - valor = self._converter(valor) - valor = self.to_python(valor) - if self.ao_atribuir: - valor = self.ao_atribuir(valor) - if valor is not None and self.is_list \ - and not isinstance(valor, list): - valor = [valor] - return valor + 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.out_processor: + value = self.out_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, valor): - return str(valor).strip() + def to_python(self, value): + return str(value).strip() class FloatField(BaseField): - def to_python(self, valor): - valor = valor.replace('.', '') - valor = valor.replace(',', '.') - return float(valor) + "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, valor): - return int(valor) + def to_python(self, value): + return int(value) class BooleanField(BaseField): """ - Retorna verdadeiro se a Regex bater com uma linha completa, e - se ao menos algum valor for capturado. + Retorna verdadeiro se a Regex bater com uma block completa, e + se ao menos algum value for capturado. """ - def _iniciar(self): + def _setup(self): self.default = False @property - def _metodo_busca(self): - return self.mascara.match + def _search_method(self): + return self.search.match - def _converter(self, valor): - res = valor.groups() if valor else False - return super(BooleanField, self)._converter(res) + def _process_value(self, value): + res = value.groups() if value else False + return super(BooleanField, self)._process_value(res) - def _resultado_valido(self, valor): - return valor and (valor.groups()) + def _is_valid_result(self, value): + return value and (value.groups()) - def to_python(self, valor): - return bool(valor) + def to_python(self, value): + return bool(value) class DateField(BaseField): @@ -224,18 +232,19 @@ class DateField(BaseField): Veja http://docs.python.org/library/datetime.html para detalhes. """ - def __init__(self, mascara=None, **kwargs): - """ - formato='%d/%m/%Y' - """ - super(DateField, self).__init__(mascara=mascara, **kwargs) - self.formato = kwargs.get('formato', '%d/%m/%Y') + default_format_string = '%d/%m/%Y' + convertion_function = lambda self, date: datetime.date(date) - def to_python(self, valor): - return datetime.strptime(valor, self.formato).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(BaseField): + +class DateTimeField(DateField): """ Campo que mantém dados no formato de data/hora, representado em Python por datetine.datetime. @@ -244,15 +253,8 @@ class DateTimeField(BaseField): Veja http://docs.python.org/library/datetime.html para detalhes. """ - def __init__(self, mascara=None, **kwargs): - """ - formato='%d/%m/%Y %H:%M:%S' - """ - super(DateTimeField, self).__init__(mascara=mascara, **kwargs) - self.formato = kwargs.get('formato', '%d/%m/%Y %H:%M:%S') - - def to_python(self, valor): - return datetime.strptime(valor, self.formato) + default_format_string = '%d/%m/%Y %H:%M:%S' + convertion_function = lambda self, date: date if __name__ == '__main__': diff --git a/tests/teste_analizador.py b/tests/teste_analizador.py index 5ef3579..2357fd3 100644 --- a/tests/teste_analizador.py +++ b/tests/teste_analizador.py @@ -9,8 +9,8 @@ sys.path.append('../') from raspador.parser import Parser, Dictionary -from raspador.fields import BaseField, FloatField, \ - IntegerField, BooleanField +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) @@ -63,8 +63,8 @@ def diff(msg, fn): class CampoItem(BaseField): - def _iniciar(self): - self.mascara = (r"(\d+)\s(\d+)\s+([\w.#\s/()]+)\s+(\d+)(\w+)" + 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 to_python(self, r): @@ -92,8 +92,8 @@ class ExtratorDeDados(Parser): class TotalizadoresNaoFiscais(Parser): class CampoNF(BaseField): - def _iniciar(self): - self.mascara = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)' + def _setup(self): + self.search = r'(\d+)\s+([\w\s]+)\s+(\d+)\s+(\d+,\d+)' def to_python(self, v): return Dictionary( diff --git a/tests/teste_campos.py b/tests/teste_campos.py index d04e8c4..d256a8c 100644 --- a/tests/teste_campos.py +++ b/tests/teste_campos.py @@ -3,119 +3,133 @@ import unittest from datetime import date, datetime -from raspador.fields import BaseField, StringField, FloatField, \ +from raspador.fields import BaseField, StringField, FloatField, BRFloatField, \ IntegerField, DateField, DateTimeField, BooleanField -class TesteDeBaseField(unittest.TestCase): +class TestBaseField(unittest.TestCase): - def teste_deve_retornar_valor_no_analizar(self): + def test_should_retornar_valor_no_analizar(self): s = "02/01/2013 10:21:51 COO:022734" - campo = BaseField(r'COO:(\d+)', nome='COO') + campo = BaseField(r'COO:(\d+)') valor = campo.parse_block(s) self.assertEqual(valor, '022734') - def teste_deve_retornar_none_sem_mascara(self): + 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 teste_deve_aceitar_callback(self): + 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+)', nome='COO', ao_atribuir=dobro) + campo = BaseField(r'COO:(\d+)', out_processor=dobro) valor = campo.parse_block(s) self.assertEqual(valor, 45468) # 45468 = 2 x 22734 - def teste_deve_recusar_callback_invalido(self): + def test_should_recusar_callback_invalido(self): self.assertRaises( TypeError, - lambda: BaseField(r'COO:(\d+)', ao_atribuir='pegadinha') + lambda: BaseField(r'COO:(\d+)', out_processor='pegadinha') ) - def teste_deve_utilizar_grupo_quando_informado(self): + 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+)', grupos=1, - ao_atribuir=int) + campo = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', groups=1, + out_processor=int) valor = campo.parse_block(s) self.assertEqual(valor, 1246) -class TesteDeIntegerField(unittest.TestCase): - def teste_deve_obter_valor(self): +class TestIntegerField(unittest.TestCase): + def test_should_obter_valor(self): s = "02/01/2013 10:21:51 COO:022734" - campo = IntegerField(r'COO:(\d+)', nome='COO') + campo = IntegerField(r'COO:(\d+)') valor = campo.parse_block(s) self.assertEqual(valor, 22734) -class TesteDeFloatField(unittest.TestCase): - def teste_deve_obter_valor(self): +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 = FloatField(r'VENDA BRUTA DIÁRIA:\s+(\d+,\d+)') + campo = BRFloatField(r'VENDA BRUTA DIÁRIA:\s+(\d+,\d+)') valor = campo.parse_block(s) self.assertEqual(valor, 793.0) - def teste_deve_obter_valor_com_separador_de_milhar(self): + 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+)') + campo = BRFloatField(r'VENDA BRUTA DIÁRIA:\s+([\d.]+,\d+)') valor = campo.parse_block(s) self.assertEqual(valor, 10036.7) -class TesteDeStringField(unittest.TestCase): - def teste_deve_obter_valor(self): +class TestStringField(unittest.TestCase): + def test_should_obter_valor(self): s = "1 Dinheiro 0,00" - campo = StringField(r'\d+\s+(\w[^\d]+)', nome='Meio') + campo = StringField(r'\d+\s+(\w[^\d]+)') valor = campo.parse_block(s) self.assertEqual(valor, 'Dinheiro') -class TesteDeBooleanField(unittest.TestCase): +class TestBooleanField(unittest.TestCase): s = " CANCELAMENTO " - def teste_deve_obter_valor_verdadeiro_se_bater_e_capturar(self): - campo = BooleanField(r'^\s+(CANCELAMENTO)\s+$', nome='Cancelado') + 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 teste_deve_retornar_falso_ao_finalizar_quando_regex_nao_bate(self): - campo = BooleanField(r'^\s+HAH\s+$', nome='Cancelado') + 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 TesteDeDateField(unittest.TestCase): - def teste_deve_obter_valor(self): +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+)', nome='Data') + campo = DateField(r'^(\d+/\d+/\d+)') valor = campo.parse_block(s) data_esperada = date(2013, 1, 2) self.assertEqual(valor, data_esperada) - def teste_deve_obter_respeitando_formato(self): + def test_should_obter_respeitando_formato(self): s = "2013-01-02T10:21:51 COO:022734" - campo = DateField(r'^(\d+-\d+-\d+)', nome='Data', formato='%Y-%m-%d') + 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 TesteDeDateTimeField(unittest.TestCase): - def teste_deve_obter_valor(self): +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 teste_deve_obter_respeitando_formato(self): + 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') From c227e430a6c5caf7797d4b53d9e993417bfd02fb Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 28 Sep 2013 20:13:03 -0300 Subject: [PATCH 14/17] test files renamed --- tests/{teste_cache.py => test_cache.py} | 0 tests/{teste_decoradores.py => test_decorators.py} | 0 tests/{teste_campos.py => test_fields.py} | 0 tests/{teste_analizador.py => test_parser.py} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/{teste_cache.py => test_cache.py} (100%) rename tests/{teste_decoradores.py => test_decorators.py} (100%) rename tests/{teste_campos.py => test_fields.py} (100%) rename tests/{teste_analizador.py => test_parser.py} (100%) diff --git a/tests/teste_cache.py b/tests/test_cache.py similarity index 100% rename from tests/teste_cache.py rename to tests/test_cache.py diff --git a/tests/teste_decoradores.py b/tests/test_decorators.py similarity index 100% rename from tests/teste_decoradores.py rename to tests/test_decorators.py diff --git a/tests/teste_campos.py b/tests/test_fields.py similarity index 100% rename from tests/teste_campos.py rename to tests/test_fields.py diff --git a/tests/teste_analizador.py b/tests/test_parser.py similarity index 100% rename from tests/teste_analizador.py rename to tests/test_parser.py From f1767c9779a5690d1338ffbd926210d2fb9a0cdf Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 28 Sep 2013 20:14:02 -0300 Subject: [PATCH 15/17] tests fixtures renamed --- tests/{arquivos => files}/cupom.txt | 0 tests/{arquivos => files}/cupom_cancelado.txt | 0 tests/{arquivos => files}/reducaoz.txt | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/{arquivos => files}/cupom.txt (100%) rename tests/{arquivos => files}/cupom_cancelado.txt (100%) rename tests/{arquivos => files}/reducaoz.txt (100%) 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 From b762d2efe00b964615624529c57564577af6a08a Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 30 Sep 2013 00:25:30 -0300 Subject: [PATCH 16/17] process_item should return item --- raspador/parser.py | 7 +++---- tests/test_fields.py | 9 +++++++++ tests/test_parser.py | 10 +++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/raspador/parser.py b/raspador/parser.py index 74aaced..5d2b796 100644 --- a/raspador/parser.py +++ b/raspador/parser.py @@ -103,8 +103,7 @@ def finalize_item(self): if value is not None: self.assign_value_into_item(name, value) - self.process_item() - res = self.item + res = self.process_item(self.item) self.item = None return res @@ -116,9 +115,9 @@ def assign_value_into_item(self, name, value): else: self.item[name] = value - def process_item(self): + def process_item(self, item): "Allows final modifications at the object being returned" - pass + return item class ParserMetaclass(type): diff --git a/tests/test_fields.py b/tests/test_fields.py index d256a8c..c7c0f62 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -136,3 +136,12 @@ def test_should_obter_respeitando_formato(self): 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/test_parser.py b/tests/test_parser.py index 2357fd3..65e5932 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -107,8 +107,8 @@ def to_python(self, v): end = r'^[\s-]*$' Totalizador = CampoNF(is_list=True) - def process_item(self): - self.item = self.item.Totalizador + def process_item(self, item): + return item.Totalizador class ParserDeReducaoZ(Parser): @@ -172,7 +172,7 @@ class TesteDeExtrairDadosDeCupom(BaseParaTestesComApiDeArquivo): codificacao_arquivo = 'utf-8' def obter_arquivo(self): - return self.open_file('arquivos/cupom.txt') + return self.open_file('files/cupom.txt') def criar_analizador(self): return ExtratorDeDados() @@ -293,7 +293,7 @@ def teste_deve_emitir_dicionario_com_valores(self): class TesteExtrairDadosDeCupomCancelado(BaseParaTestesComApiDeArquivo): def obter_arquivo(self): - return self.open_file('arquivos/cupom.txt') + return self.open_file('files/cupom.txt') def criar_analizador(self): class ExtratorDeDados(Parser): @@ -312,7 +312,7 @@ class TesteExtrairDadosComParseresAlinhados(BaseParaTestesComApiDeArquivo): def obter_arquivo(self): "sobrescrever retornando arquivo" - return self.open_file('arquivos/reducaoz.txt') + return self.open_file('files/reducaoz.txt') def criar_analizador(self): return ParserDeReducaoZ() From 39469e16f9aefccb3893e05a31cbb2ad9f9edb34 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 1 Oct 2013 00:07:59 -0300 Subject: [PATCH 17/17] fields translation --- raspador/fields.py | 127 +++++++++++++++++++++------------------ setup.py | 2 +- tests/test_decorators.py | 10 +-- tests/test_fields.py | 6 +- 4 files changed, 76 insertions(+), 69 deletions(-) diff --git a/raspador/fields.py b/raspador/fields.py index 54bdf2d..99784ec 100644 --- a/raspador/fields.py +++ b/raspador/fields.py @@ -1,12 +1,14 @@ #coding: utf-8 """ -Os fields são simples extratores de dados baseados em expressões regulares. -Ao confrontar uma block recebida para análise com sua expressão regular, o -campo verifica se há groups 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). """ +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 @@ -15,61 +17,58 @@ class BaseField(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. + Contains processing logic to extract data using regular expressions, and + provide utility methods that can be overridden for custom data processing. + - O comportamento do Campo pode ser ajustado através de diversos parâmetros: + Default behavior can be adjusted by parameters: search - O requisito mínimo para um campo é uma máscara em expressão regular, - onde deve-se especificar um grupo para captura:: + Regular expression that must specify a group of capture. Use + parentheses for capturing:: >>> s = "02/01/2013 10:21:51 COO:022734" - >>> campo = BaseField(search=r'COO:(\d+)') - >>> campo.parse_block(s) + >>> field = BaseField(search=r'COO:(\d+)') + >>> field.parse_block(s) '022734' - O parâmetro search é o único posicional, e deste modo, seu nome pode - ser omitido:: + The `search` parameter is the only by position and hence its name can + be omitted:: >>> s = "02/01/2013 10:21:51 COO:022734" - >>> campo = BaseField(r'COO:(\d+)') - >>> campo.parse_block(s) + >>> field = BaseField(r'COO:(\d+)') + >>> field.parse_block(s) '022734' - out_processor + input_processor - Recebe um callback para tratar o value antes de ser retornado pelo - campo. + Receives a function to handle the captured value before being returned + by the field. >>> s = "02/01/2013 10:21:51 COO:022734" - >>> def dobro(value): + >>> def double(value): ... return int(value) * 2 ... - >>> campo = BaseField(r'COO:(\d+)', out_processor=dobro) - >>> campo.parse_block(s) # 45468 = 2 x 22734 + >>> field = BaseField(r'COO:(\d+)', input_processor=double) + >>> field.parse_block(s) # 45468 = 2 x 22734 45468 groups - Permite escolher quais groups capturados o campo deve processar como - dados de entrada, utilizado para expressões regulares que utilizam - groups para correspondência da expressão regular, mas que apenas parte - destes groups possui informação útil. + Specify which numbered capturing groups do you want do process in. - Pode-se informar um número inteiro, que será o índice do grupo, - inicando em 0:: + + You can enter a integer number, as the group index:: >>> s = "Contador de Reduções Z: 1246" - >>> campo = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \ - groups=1, out_processor=int) - >>> campo.parse_block(s) + >>> field = BaseField(r'Contador de Reduç(ão|ões) Z:\s*(\d+)', \ + groups=1, input_processor=int) + >>> field.parse_block(s) 1246 - Ou uma lista de inteiros:: + Or a list of integers:: >>> s = "Data do movimento: 02/01/2013 10:21:51" >>> c = BaseField(r'^Data .*(movimento|cupom): (\d+)/(\d+)/(\d+)',\ @@ -77,38 +76,47 @@ class BaseField(object): >>> c.parse_block(s) ['02', '01', '2013'] + .. note:: - default + If you do not need the group to capture its match, you can optimize + the regular expression putting an `?:` after the opening + parenthesis:: - Valor que será utilizado no :py:class:`~raspador.parser.Parser` - , quando o campo não retornar value após a análise das - linhas recebidas. + >>> 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 - Quando especificado, retorna o value como uma lista:: + When specified, returns the value as a list:: >>> s = "02/01/2013 10:21:51 COO:022734" - >>> campo = BaseField(r'COO:(\d+)', is_list=True) - >>> campo.parse_block(s) + >>> field = BaseField(r'COO:(\d+)', is_list=True) + >>> field.parse_block(s) ['022734'] - Por convenção, quando um campo retorna uma lista, o - :py:class:`~raspador.parser.Parser` acumula os valores - retornados pelo campo. + 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, - out_processor=None, groups=[]): + input_processor=None, groups=[]): self.search = search self.default = default self.is_list = is_list - self.out_processor = out_processor + self.input_processor = input_processor self.groups = groups - if self.out_processor and \ - not isinstance(self.out_processor, collections.Callable): - raise TypeError('out_processor is not callable.') + 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,) @@ -167,8 +175,8 @@ def parse_block(self, block): if self._is_valid_result(value): value = self._process_value(value) value = self.to_python(value) - if self.out_processor: - value = self.out_processor(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] @@ -202,8 +210,8 @@ def to_python(self, value): class BooleanField(BaseField): """ - Retorna verdadeiro se a Regex bater com uma block completa, e - se ao menos algum value for capturado. + Returns true if the block is matched by Regex, and is at least some value + is captured. """ def _setup(self): self.default = False @@ -225,11 +233,11 @@ def to_python(self, value): class DateField(BaseField): """ - 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. + 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' @@ -246,11 +254,10 @@ def to_python(self, value): class DateTimeField(DateField): """ - Campo que mantém dados no formato de data/hora, - representado em Python por datetine.datetime. + Field that holds data in hour/date format, represented in Python by + datetine.datetime. - Formato: - Veja http://docs.python.org/library/datetime.html para detalhes. + http://docs.python.org/library/datetime.html """ default_format_string = '%d/%m/%Y %H:%M:%S' diff --git a/setup.py b/setup.py index 92616d1..927a7c1 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ 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/tests/test_decorators.py b/tests/test_decorators.py index d0ece9b..86d913c 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -8,11 +8,11 @@ class CampoFake(object): def __init__(self, default=None, is_list=False, retornar=False): self.default = default self.is_list = is_list - self.linhas = [] + self.lines = [] self.retornar = retornar def parse_block(self, linha): - self.linhas.append(linha) + self.lines.append(linha) if self.retornar: return linha @@ -45,7 +45,7 @@ def teste_decorador_deve_passar_linhas(self): p.parse_block('teste1') p.parse_block('teste2') self.assertEqual( - mock.linhas, + mock.lines, ['teste1', 'teste2'] ) @@ -66,7 +66,7 @@ def teste_deve_chamar_decorado_acumulando_linhas(self): 'l1 l2 l3 l4', 'l5 l6', ], - mock.linhas + mock.lines ) def teste_deve_chamar_decorado_retornando_primeiro_valor(self): @@ -87,7 +87,7 @@ def teste_deve_chamar_decorado_retornando_primeiro_valor(self): 'l5', 'l6', ], - mock.linhas + mock.lines ) diff --git a/tests/test_fields.py b/tests/test_fields.py index c7c0f62..df7deb7 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -27,20 +27,20 @@ def test_should_aceitar_callback(self): def dobro(valor): return int(valor) * 2 - campo = BaseField(r'COO:(\d+)', out_processor=dobro) + 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+)', out_processor='pegadinha') + 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, - out_processor=int) + input_processor=int) valor = campo.parse_block(s) self.assertEqual(valor, 1246)