#!/usr/bin/env python # coding: utf-8 # ## Ejemplo de uso de pyparsing # # Vamos a utilizar [pyparsing](http://pyparsing.wikispaces.com/home) para # procesar un fichero csv. Para hacer las cosas # más interesantes, supondremos que el formato # de los ficheros es muy laxo; por ejemplo, podemos # encontrarnos con lo siguiente: # # - Los campos de texto pueden venir entrecomillados o no # # - Los campos de tipo fecha pueden venir en dos formatos: YYYY-MM-DD o DD/MM/YYYY # # - Los campos de tipo booleano pueden venir con los valores ``1``, ``S``, ``Y`` o ``True`` para el valor lógico verdadero, y ``0``, ``N``, ``F`` y ``False`` para el valor lógico falso # # - Los números decimales pueden venir con una coma o con un punto como separador decimal. # # - La primera línea contiene los nombres de los campos, el resto los datos; en los dos casos se separa cada valor por el caracter ``;`` # # ### Ejemplo de datos a procesar # # Algo como esto: # # Comentario;Activo;Fecha;Importe # Texto sin comillas porque yo lo valgo;Y;2017-08-08;4292.00 # "Ahora si que pongo comillas";F;25/8/2014;3200.00 # Ya ves, todo vale;True;1/1/1970;4532,02 # # # ### Una gramatica para estos ficheros # # La gramática podría ser algo así: # # root -> header + lines # header -> \.+ # ignorar la linea # lines -> line+ # line -> text + sep + date + sep + bool + sep + cost # text -> '"' + literal + '"' | literal # date -> year + '-' + month + '-' + day | day + '/' + month + '/' + year # cost -> \d+[\.|,]\d{2} # bool -> '1' | 'S' | 'Y' | 'T' | 'True' | '0' | 'N' | 'F' | 'False' # year -> \d{4} # month -> 1|2|3|4|5|6|7|8|9|10|11|12 # day -> \d{1,2} # # ### Implementación con pyparsing # Las gramáticas pueden ser un poco intimidantes la primera vez que las ves. Lo bueno de pyparsing es que nos permite testear y modificar las distintas partes de la gramática como piezas sueltas. Así podemos crear el parser poco a poco, ensamblando las distinas piezas, con la confianza de que estas funcionan. # ### Parseando fechas # Por ejemplo, para las fechas, que pueden venir en dos formatos, tenemos el siguiente fragmento de la gramática (pasado a la sintaxis de pyparsing, y por tanto cambiando el orden, la regla inicial sería la última): # # In[2]: from pyparsing import Literal, Regex, oneOf, StringEnd, Group, ParseException dash = Literal('-') slash = Literal('/') year = Regex('\d{4}') month = Regex('\d{1,2}') day = Regex('\d{1,2}') date = year + dash + month + dash + day ^ day + slash + month + slash + year # pyparsong sobrecarga el operador ^ para indicar alternancia # La clase ``Literal`` sirve para indicar una expresión o token literal, que queremos detectar durante la fase de # parseo. Así, definimos ``dash`` y ``slash`` para detectar los literales ``-`` y ``/``. La clase ``Regex`` nos permite definir tokens usando expresiones regulares. Estos objetos, así como el resto de los que veremos, derivan de la clase ``ParserElement`` de pyparsing, que sobrecarga varios operadores para poder expresar las reglas de la gramática. Así, la regla: # # date -> year + '-' + month + '-' + day | day + '/' + month + '/' + year # # Se puede expresar en Python con los operadores ``+`` y ``^``: # # date = year + dash + month + dash + day ^ day + slash + month + slash + year # # Aparte de cambios como el uso del operador ``^``, o la definición de los literales ``dash`` y ``slash``, podemos ver que la gramática se mapea de forma casi directa a expresiones Python. # # Veamos que tal funciona este mini-parser: # In[3]: try: date.parseString('hola') except ParseException: pass # Ok, no es una fecha print(date.parseString('25/8/2016')) print(date.parseString('2017-12-08')) # pyparsing define su propia clase de excepciones para errores de Parseo, ``parseException``. Si nuestro parser es muy complicado puede ser interesante usar esta misma excepción para indicar nuestros propios errores. Por ejemplo, ahora mismo aceptamos para el día cualquier combinación de dos dígitos, e igualmente para el mes: # In[4]: print(date.parseString('99/88/2016')) # opps, esto no debería valer, pero vale # Más adelante veremos que podemos tratar estos casos y elevar errores explicativos que provoquen el fallo del parser. # Por ahora, poco más de lo que podríamos hacer simplemente con expresiones regulares. # # Podemos realizar una pequeña mejora. Observemos un detalle de los separadores usados en el formato de fechas, definidos como ``dash`` y ``slash``; en realidad, sus valores no nos interesan. Podemos calcular el valor de la fecha sin necesidad de saber que caracteres se usaron como separador. Estos elementos son necesarios para el parser, pero no tienen más utilidad. # # Existe una clase en pyparsing llamada ``Suppress`` que funciona exactamente igual que ``Literal``, pero que retira el token, de forma que nos evitamos procesarlo. Cambiemos la gramática para redefinir ``dash`` y ``slash`` usando ``Suppress`` en vez de ``Literal``: # In[5]: from pyparsing import Suppress dash = Suppress('-') slash = Suppress('/') year = Regex('\d{4}') month = Regex('\d{1,2}') day = Regex('\d{1,2}') date = year + dash + month + dash + day ^ day + slash + month + slash + year # In[6]: print(date.parseString('23/9/2016')) # bien, el separador desaparece # No está mal, pero la mejora realmente interesante sería que nos devolviera algo más elaborado, un objeto de tipo fecha, objetos de tipo ``datetime.datetime``, por ejemplo. Vamos a ello. Para eso, necesitamos usar las **reglas de parseo**. # # ### Reglas de parseo # # Podemos asociar acciones a las reglas de parseo (en este daso, ``date``) para que se ejecuten cada vez qe se active la regla. # # Vamos a asociar una función que no haga nada, solo imprimir un valor para ver que, efectivamente, se ejecuta cuando la regla de parseo se activa. Usaremos el método ``setParseAction``: # In[7]: def very_simple_action(): print('OK, se ha ejecutado la acción') date.setParseAction(very_simple_action) print(date.parseString('25/8/2016')) print(date.parseString('2017-12-08')) # De la documentación de pyparsing, podemos obtener más información sobre como definir y usar estas acciones: # # Podemos definir uno o varias acciones a realizar cuando se produce una coincidencia que activa la regla del parser. Estas acciones pueden ser cualquier objeto de tipo *callable* de python; es decir, funciones, métodos u objetos instanciados de clases que definan el método mágico ``__call__``. # # Las acciones pueden aceptar desde cero hasta tres argumentos, es decir que, dependiendo de como definamos la acción ``fn``, esta será llamada como ``fn()``, ``fn(toks)``, ``fn(loc, toks)`` o ``fn(s, loc, toks)``. El significado de estos parámetros es el siguiente: # # - ``s``: es la string original que activó el patrón de la regla # # - ``loc``: es la localización, dentro del texto, de la substring ``s`` (Útil para generar mensajes de error) # # - ``toks``: Una lista de los tokens encontrados, empaquetados en forma de objeto de tipo ``ParseResults`` # # Si la función quiere modificar los tokens, debe devolver un nuevo valor como resultado de la función, con lo que la lista de tokens devueltos reemplazaría a la original. Si no queremos realizar ningún cambio, la función no debe # retornar ningún valor. # # Definamos una acción, solo para ver que estos parámetros se pasan efectivamente: # In[8]: def I_just_wanna_see(s, loc, tokens): print('s:', s) print('loc:', loc) print('tokens:', tokens) print() date.setParseAction(I_just_wanna_see) print(date.parseString('25/8/2016')) # **Nota**: Podemos asignar varias acciones usando el método ``addParseAction``. en ese caso, las acciones se ejecutan de forma anidada, siendo la primera en ejecutarse la primera en añadirse. Cada acción recibe como entrada el resultado de la anterior y pasa su resultado a la siguiente. Veamos un ejemplo: # In[9]: token = Literal('hola') token.addParseAction(lambda tokens: 'ei' + tokens[0] + 'ai') token.addParseAction(lambda tokens: tokens[0].upper()) print(token.parseString('hola')) # Si cambiamos el orden en que se añaden las acciones, el resultado puede diferir, lógicamente: # In[10]: token = Literal('hola') token.addParseAction(lambda tokens: tokens[0].upper()) token.addParseAction(lambda tokens: 'ei' + tokens[0] + 'ai') print(token.parseString('hola')) # Con esto ya podemos definir una acción que nos devuelva un objeto ``date``. Usaremos una acción con un solo parámetro, ``tokens``, la lista de los tokens detectados, ya que no necesitamos los otros parámetros. # # Como devolvemos un valor, el parser sustituirá la lista de tokens detectados por ese nuevo valor. # In[11]: import datetime def get_as_date(tokens): first_element = tokens[0] if len(first_element) == 4: # Formato YYYY-MM-DD d = datetime.date( int(tokens[0]), # Year int(tokens[1]), # Month int(tokens[2]), # Day ) else: d = datetime.date( int(tokens[2]), # Year int(tokens[1]), # Month int(tokens[0]), # Day ) return d date.setParseAction(get_as_date) print(date.parseString('25/8/2016')) print(date.parseString('2017-12-08')) # ### Vamos a parsear valores lógicos # Podemos hacer algo similar con los objetos booleanos. Usaremos una de las funciones auxiliares de # pyparsing, ``oneOf``, que nos permite definir de forma rápida un conjunto de literales # alternativos. Además se asegura de que siempre intentará capturar el literal más grande, en caso de # que haya conflicto entre alguno de ellos; por ejemplo, entre ``<`` y ``<=`` primero intentará # encontrar una correspondencia con el más largo, ``<=``, y si no la encuentra lo intentará con ``<``. # # In[12]: from pyparsing import oneOf boolean = oneOf('1 S Y T True 0 N F False') print(boolean.parseString('True')) # Con un poco de mágia en forma de acción asociada a la regla obtendremos valores booleanos de Python. LA API # de pyparsing es fluida, por lo que podemos definir la regla y asociar la acción en una sola línea: # In[13]: def get_as_bool(tokens): return tokens[0] in ('1', 'S', 'Y', 'T', 'True') boolean = oneOf('1 S Y T True 0 N F False').setParseAction(get_as_bool) # simple tests assert boolean.parseString('1').pop() is True assert boolean.parseString('S').pop() is True assert boolean.parseString('Y').pop() is True assert boolean.parseString('T').pop() is True assert boolean.parseString('True').pop() is True assert boolean.parseString('0').pop() is False assert boolean.parseString('N').pop() is False assert boolean.parseString('F').pop() is False assert boolean.parseString('False').pop() is False # ### Parsear los importes # # Nos queda el problema de los importes, que pueden usar como separador decimal la coma, al estilo # español, o el punto, al estilo internacional, y las cadenas de textos, que pueden venir # limitadas por comillas o no. Los dos casos son fáciles de tratar: # In[14]: from pyparsing import nums, Word cost = Word(nums) + oneOf('. ,') + Regex('\d\d') cost.parseString('3819.24') # La clase ``Word`` nos permite definir una palabra, pasandole uno o dos parámetros, que son vocabularios. Los vocabularios se pueden indicar con una string de símbolos, como ``'aeiou'``. ``nums`` es solo una constante definida en pyparsing que vale ``'0123456789'``. Podiamos haber usado una expresión regular, pero ``Word`` es bastante interesante. # # Si a ``Word`` se le pasa un solo vocabulario, define una palabra como una secuencia de n caracteres tomados de los símbolos definidos en el vocabulario. # # Si se le pasan dos vocabularios, define una palabra como una secuencia donde el primer # caracter debe pertenecer al primer vocabulario y el resto, si los hubiera, al segundo. Por ejemplo, podemos definir un parser para los nombres válidos de Python, que permiten el uso de carateres alfanuméricos y el carácter subrayado, pero no no se permiten que empiece por un dígito: # In[15]: from pyparsing import alphas, nums, alphanums assert alphas == 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' assert alphanums == alphas + nums var_name = Word(alphas + '_', alphanums + '_') var_name.parseString('a') var_name.parseString('a1') var_name.parseString('alp_ha') try: var_name.parseString('1uno') except ParseException: # Oops, no se permite el caracter '_' pass # Definamos otra patrón, incluyendo el símbolo dolar como caracter válido al principio, PERL-style: # In[16]: var_name_plus = Word(alphas + '$', alphanums + '_') assert var_name_plus.parseString('a').pop() == 'a' assert var_name_plus.parseString('$_alpha').pop() == '$_alpha' assert var_name_plus.parseString('$Alpha_plus').pop() == '$Alpha_plus' # Hecha esta disgresión, volvamos al problema de tratar los importes. Añadamos # una acción para obtener un número de tipo Decimal: # In[17]: from decimal import Decimal def get_as_decimal(tokens): int_part, dec_part = tokens return Decimal('{}.{}'.format(int_part, dec_part)) cost = Word(nums) + Suppress(oneOf('. ,')) + Regex('\d\d') cost.setParseAction(get_as_decimal) cost.parseString('484432,23') num_esp = cost.parseString('484432,23').pop() num_int = cost.parseString('484432.23').pop() assert num_esp == num_int == Decimal('484432.23') print(cost.parseString('3.14')) # ### Cadenas de texto con o sin delimitador # # Para poder procesar las cadenas de texto, ignorando si procede las comillas delimitadoras opcionales, podemos hacer: # In[18]: quote = Suppress('"') content = Regex("[^\";]+") # Cualquier secuencia de caracteres, excepto ; y " text = quote + content + quote ^ content assert text.parseString('Texto sin comillas').pop() == 'Texto sin comillas' assert text.parseString('"Texto con comillas"').pop() == 'Texto con comillas' # Bueno, ha sido un viaje un poco largo, con paradas en algunos puntos interesantes, pero ya podemos escribir la gramática completa, junto al parser y las acciones aplicadas: # In[19]: from pyparsing import OneOrMore # Funciones de conversion def get_as_decimal(s, lok, tokens): int_part, _sep, dec_part = tokens return Decimal('{}.{}'.format(int_part, dec_part)) def get_as_date(s, loc, tokens): a, b, c = tokens if len(a) == 4: # Formato YYYY-MM-DD return datetime.date(int(a), int(b), int(c)) else: # Formato DD/MM/YYYY return datetime.date(int(c), int(b), int(a)) def get_as_bool(s, loc, tokens): return tokens[0] in ('1', 'S', 'Y', 'T', 'True') sep = Suppress(';') quote = Suppress('"') # Texto content = Regex("[^\";]+") # Cualquier secuencia de caracteres, excepto ; y " text = quote + content + quote ^ content text.setParseAction(lambda tokens: tokens[0].strip()) boolean = oneOf('1 S Y T True 0 N F False') # Valores lógicos boolean.setParseAction(get_as_bool) dash = Suppress('-') # Fechas slash = Suppress('/') year = Regex('\d{4}') month = Regex('\d{1,2}') day = Regex('\d{1,2}') date = year + dash + month + dash + day ^ day + slash + month + slash + year date.setParseAction(get_as_date) cost = Word(nums) + oneOf('. ,') + Regex('\d\d') # Importes cost.setParseAction(get_as_decimal) line = Group(text + sep + boolean + sep + date + sep + cost) # One Line lines = OneOrMore(line) # Lines lines.setParseAction(lambda tokens: list(tokens)) header = Suppress(Regex('.+')) # Header parser = header + lines + StringEnd() # First rule # - La clase ``OneOrMore`` nos permite implementar las reglas de *una secuencia de uno o más elementos repetidos*, como su mismo nombre indica. Pyparsing define muchos más clases de este tipo, como ``ZeroOrMore``, ``Optional``, (Uno o cero), ``OnlyOne``... # # - La clase ``Group`` nos permite agrupar varios tokens en un solo resultado, normalmente porque vamos a tratarlos todos juntos. # # - La clase ``StringEnd`` nos permite indicar que el parser, al consumir este token, debería de haber terminado, es decir, que todo el texto a parserar debe consumirse íntegramente. # # Vamos a hacer unas pruebas parseando líneas individuales: # In[20]: print(line.parseString('Texto sin comillas porque yo lo valgo;Y;2017-08-08;4292.00')) # In[21]: print(line.parseString('"Ahora si que pongo comillas";F;25/8/2014;3200.00')) # In[22]: print(line.parseString('Ya ves, todo vale;True;1/1/1970;4532,02')) print(header.parseString('Comentario;Activo;Fecha;Importe')) # Ignoramos la cabecera # Y la prueba de fuego, un fichero completo: # In[23]: source = '''Comentario;Activo;Fecha;Importe Texto sin comillas porque yo lo valgo;Y;2017-08-08;4292.00 "Ahora si que pongo comillas";F;25/8/2014;3200.00 Ya ves, todo vale;True;1/1/1970;4532,02 ''' g = parser.parseString(source) for item in g: print(item) # Unsado el método ``parseFile`` podemos procesar un fichero, si especificamos el nombre, un fichero abierto, o cualquier objeto que implemente una interfaz similar a ``File``: # In[24]: with open('ejemplo.csv', 'r') as stream: g = parser.parseFile(stream) for item in g: print(item) # ## Ventajas de Pyparsing # - **Robusto y sencillo de usar**. Pyparsing lleva más de una década de desarrollo, y se basa en el uso de gramáticas para la definición formal de lenguajes. El paso de la gramática a código Python es casi directo. # # # - **Desarrollo incremental, facilmente testeable**. El parser final se puede ir construyendo paso a paso. # # - No se ve en los ejemplo, pero podemos añadir **validaciones y mensajes de error explicativos** que simplifican la resolución de problemas --incluyendo, por ejemplo, número de línea y posición del error. Podemos asignar nombres a los resultados de los tokens para que seán más sencillos de referencias, y muchas otras funcionalidades que no hemos podido ver aquí. # # - **Flexible**, comparado con un parser hecho a mano o en base a un montón de espresiones regulares. A modo de ejemplo, véanse los **ejercicios para el lector**. # # ### Ejercicios para el lector # # - Añadir otro formato válido para las fechas, por ejemplo, ``10/abr/2017`` (10 puntos) # # - Permitir que la última columna, el importe, acepte también un valor entero, es decir, sin parte decimal (20 puntos) # # - Permitir que los textos puedan venir sin comillas, con comillas simples o con comillas dobles (20 puntos) # # - El jefe ha modificado el formato, el fichero tiene ahora una última ĺinea donde va el total acumulado de todos los importes previos, algo así: # # Comentario;Activo;Fecha;Importe # Texto sin comillas porque yo lo valgo;Y;2017-08-08;4292.00 # "Ahora si que pongo comillas";F;25/8/2014;3200.00 # Ya ves, todo vale;True;1/1/1970;4532,02 # 12024.02 # # El parser debe adaptarse a este cambio, y comprobar que la suma de los importes # coincide con el dato final. Si, ya sé que, técnicamente, esto ha dejado de ser # un CSV. Estas cosas pasan. (100 puntos y una gran satisfacción personal) # ### Mas información # # - La página web de pyparsing: # # - Parsing In Python: Tools And Libraries # In[ ]: