Serializando objetos en MONGODB Java (POJO) usando Codecs (II)

En el articulo anterior, les traté de mostrar como guardar y recuperar documentos en MongoDB … sin mucho éxito.

El problema es que el Java POJO utiliza enumeraciones y la base de datos no puede por si sólo encargarse de su manejo.

Después de leer otra vez la guía de Jackson con las anotaciones ‘@JsonDeserialize’ ‘@JsonSerialize’ y MongoDB con Java Codecs entendí que esta es la estrategia:

  • En Jackson, debemos guardar y leer las enumeraciones como cadena de caracteres. De hecho mi definición contiene una representación mas amigable que la que estaba usando. Esto lo voy a usar más tarde para un servicio web que estoy escribiendo y no tiene nada que ver con MongoDB 🙂
  • En MongoDB registramos un Codec que se encarga de leer la enumeración desde una cadena de caracteres y la escribe de vuelta como una cadena de caracteres

Todo se ve más claro en código, así que allí vamos. Lo primero es anotar nuestro POJO con Jackson (sólo muestro el atributo StatusEnumType):

public class ItemType {
    @JsonDeserialize(using = StatusEnumTypeDeserializer.class)
    @JsonSerialize(using = StatusEnumTypeSerializer.class)
    @XmlElement(required = true)
    @XmlSchemaType(name = "token")
    protected StatusEnumType status;

La anotación registra el atributo con las clases que van a leer y a escribir la enumeración de forma que pueda ser entendida por nuestro ObjectMapper en Jackson.

La clase que codifica:

package com.kodegeek.cvebrowser.persistence.serializers;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.kodegeek.cvebrowser.entity.SimplePhaseEnumType;

import java.io.IOException;

public class SimplePhaseEnumTypeSerializer extends JsonSerializer {
    @Override
    public void serialize(SimplePhaseEnumType value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (! StringChecker.isMissing(value.value())) {
            gen.writeString(value.value());
        } else {
            gen.writeNull();
        }
    }
}

Y la que decodifica:

package com.kodegeek.cvebrowser.persistence.serializers;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.kodegeek.cvebrowser.entity.SimplePhaseEnumType;

import java.io.IOException;

public class SimplePhaseEnumTypeDeserializer extends JsonDeserializer {
    @Override
    public SimplePhaseEnumType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        SimplePhaseEnumType value = null;
        try {
            value = SimplePhaseEnumType.fromValue(p.getValueAsString());
        } catch (IllegalArgumentException eaexp) {
            eaexp.printStackTrace();
        }
        return value;
    }
}

No hay que hacer nada más para que Jackson funcione. En cuanto a MongoDB, debemos escribir una clase que implemente “Codec”. Esta clase se encarga de codificar y descodificar nuestra enumeración:

package com.kodegeek.cvebrowser.persistence.serializers;

import com.kodegeek.cvebrowser.entity.SimplePhaseEnumType;
import org.bson.BsonReader;
import org.bson.BsonWriter;
import org.bson.codecs.Codec;
import org.bson.codecs.DecoderContext;
import org.bson.codecs.EncoderContext;

public class SimplePhaseEnumTypeCodec implements Codec{
    @Override
    public SimplePhaseEnumType decode(BsonReader reader, DecoderContext decoderContext) {
        return SimplePhaseEnumType.fromValue(reader.readString());
    }

    @Override
    public void encode(BsonWriter writer, SimplePhaseEnumType value, EncoderContext encoderContext) {
        writer.writeString(value.value());
    }

    @Override
    public Class getEncoderClass() {
        return SimplePhaseEnumType.class;
    }
}

El paso final es registrar nuestros codecs con el registro, de manera que estos sean llamados cuando procesemos este tipo de datos:

    /**
     * MongoDB could not make this any simpler ;-)
     * @return a Codec registry
     */
    public static CodecRegistry getCodecRegistry() {
        final CodecRegistry defaultCodecRegistry = MongoClient.getDefaultCodecRegistry();
        final CodecProvider pojoCodecProvider = PojoCodecProvider.builder().register(packages).build();
        final CodecRegistry cvePojoCodecRegistry = CodecRegistries.fromProviders(pojoCodecProvider);
        // Aqui e stan los nuevos codecs, el del ejemplo es "SimplePhaseEnumTypeCodec"
        final CodecRegistry customEnumCodecs = CodecRegistries.fromCodecs(
                new SimplePhaseEnumTypeCodec(),
                new StatusEnumTypeCodec(),
                new TypeEnumTypeCodec()
        );
        return CodecRegistries.fromRegistries(defaultCodecRegistry, customEnumCodecs, cvePojoCodecRegistry);
    }

Serializando objetos en MONGODB Java (POJO) usando Codecs (I)

Estos días he estado trabajando con Java JPA para un proyecto de la oficina; También he estado trabajando con MongoDB y objetos con soporte para JAXP para CVEBrowser.

No es tan fácil como parece :-). En el caso de Mongo no tiene sentido pensar en JPA (aunque hay soporte para esto), así que comencé a jugar con el API del manejado de conexiones.

La razón es que cuando tenemos un esquema previo, por ejemplo una definición que viene de XSD, no hay mucha elección en cuanto a como se ven los atributos.

Veamos por ejemplo una clase que resulta de convertir la definición de una entrada de XSD CVE (https://cve.mitre.org/schema/cve/cve_1.0.xsd).

//
// This file was generated by the JavaTM Architecture for XML Binding(JAXB) Reference Implementation, v2.2.8-b130911.1802 
// See http://java.sun.com/xml/jaxb 
// Any modifications to this file will be lost upon recompilation of the source schema. 
// Generated on: 2018.02.17 at 08:52:30 PM EST 
//


package com.kodegeek.cvebrowser.entity;

import javax.xml.bind.annotation.XmlEnum;
import javax.xml.bind.annotation.XmlEnumValue;
import javax.xml.bind.annotation.XmlType;


/**
 * Java class for simplePhaseEnumType.
 * 
 * The following schema fragment specifies the expected content contained within this class.
 * 
 * 
 * <simpleType name="simplePhaseEnumType">
 *   <restriction base="{http://www.w3.org/2001/XMLSchema}token">
 *     <enumeration value="Proposed"/>
 *     <enumeration value="Interim"/>
 *     <enumeration value="Modified"/>
 *     <enumeration value="Assigned"/>
 *   </restriction>
 * </simpleType>
 * 
 * 
 */
@XmlType(name = "simplePhaseEnumType")
@XmlEnum
public enum SimplePhaseEnumType {

    @XmlEnumValue("Proposed")
    PROPOSED("Proposed"),
    @XmlEnumValue("Interim")
    INTERIM("Interim"),
    @XmlEnumValue("Modified")
    MODIFIED("Modified"),
    @XmlEnumValue("Assigned")
    ASSIGNED("Assigned");
    private final String value;

    SimplePhaseEnumType(String v) {
        value = v;
    }

    public String value() {
        return value;
    }

    public static SimplePhaseEnumType fromValue(String v) {
        for (SimplePhaseEnumType c: SimplePhaseEnumType.values()) {
            if (c.value.equals(v)) {
                return c;
            }
        }
        throw new IllegalArgumentException(v);
    }

}

Ahora mi problema es que puedo guardar los datos (aparentemente) pero no puedo recuperarlos debido a que el codec no puede transformar la enumeración (la cual por definición no tiene constructores públicos).

Según MongoDB (PojoQuickTour,java), debería ser tan fácil como usar una base de datos con el Codec apropiado:


private final static String [] packages = {  "com.kodegeek.cvebrowser.entity" };
// ...

public static CodecRegistry getCodecRegistry() {
        final CodecRegistry defaultCodecRegistry = MongoClient.getDefaultCodecRegistry();
        final CodecProvider pojoCodecProvider = PojoCodecProvider.builder().register(packages).build();
        final CodecRegistry cvePojoCodecRegistry = CodecRegistries.fromProviders(pojoCodecProvider);
        return CodecRegistries.fromRegistries(defaultCodecRegistry, cvePojoCodecRegistry);
    }

// Later on, different place we get a database with a POJO codec
// ...
mongoClient.getDatabase("cvebrowser").withCodecRegistry(getCodecRegistry())


Sin embargo si trato de hacer una búsqueda usando el codec (Joses-iMac:CVEBrowser josevnz$ gradle test):

org.bson.codecs.configuration.CodecConfigurationException: Failed to decode 'phase'. Failed to decode 'value'. Cannot find a public constructor for 'SimplePhaseEnumType'.
	at org.bson.codecs.pojo.PojoCodecImpl.decodePropertyModel(PojoCodecImpl.java:192)
	at org.bson.codecs.pojo.PojoCodecImpl.decodeProperties(PojoCodecImpl.java:168)
	at org.bson.codecs.pojo.PojoCodecImpl.decode(PojoCodecImpl.java:122)
	at org.bson.codecs.pojo.PojoCodecImpl.decode(PojoCodecImpl.java:126)
	at com.mongodb.operation.CommandResultArrayCodec.decode(CommandResultArrayCodec.java:52)
	at com.mongodb.operation.CommandResultDocumentCodec.readValue(CommandResultDocumentCodec.java:60)
	at org.bson.codecs.BsonDocumentCodec.decode(BsonDocumentCodec.java:84)
	at org.bson.codecs.BsonDocumentCodec.decode(BsonDocumentCodec.java:41)
	at org.bson.codecs.configuration.LazyCodec.decode(LazyCodec.java:47)
	at org.bson.codecs.BsonDocumentCodec.readValue(BsonDocumentCodec.java:101)
	at com.mongodb.operation.CommandResultDocumentCodec.readValue(CommandResultDocumentCodec.java:63)
	at org.bson.codecs.BsonDocumentCodec.decode(BsonDocumentCodec.java:84)
	at org.bson.codecs.BsonDocumentCodec.decode(BsonDocumentCodec.java:41)
	at com.mongodb.connection.ReplyMessage.(ReplyMessage.java:51)
	at com.mongodb.connection.InternalStreamConnection.receiveCommandMessageResponse(InternalStreamConnection.java:301)
	at com.mongodb.connection.InternalStreamConnection.sendAndReceive(InternalStreamConnection.java:255)
	at com.mongodb.connection.UsageTrackingInternalConnection.sendAndReceive(UsageTrackingInternalConnection.java:98)
	at com.mongodb.connection.DefaultConnectionPool$PooledConnection.sendAndReceive(DefaultConnectionPool.java:441)
	at com.mongodb.connection.CommandProtocolImpl.execute(CommandProtocolImpl.java:80)
	at com.mongodb.connection.DefaultServer$DefaultServerProtocolExecutor.execute(DefaultServer.java:189)
	at com.mongodb.connection.DefaultServerConnection.executeProtocol(DefaultServerConnection.java:264)
	at com.mongodb.connection.DefaultServerConnection.command(DefaultServerConnection.java:126)
	at com.mongodb.connection.DefaultServerConnection.command(DefaultServerConnection.java:118)
	at com.mongodb.operation.CommandOperationHelper.executeWrappedCommandProtocol(CommandOperationHelper.java:226)
	at com.mongodb.operation.CommandOperationHelper.executeWrappedCommandProtocol(CommandOperationHelper.java:217)
	at com.mongodb.operation.CommandOperationHelper.executeWrappedCommandProtocol(CommandOperationHelper.java:120)
	at com.mongodb.operation.FindOperation$1.call(FindOperation.java:717)
	at com.mongodb.operation.FindOperation$1.call(FindOperation.java:711)
	at com.mongodb.operation.OperationHelper.withConnectionSource(OperationHelper.java:471)
	at com.mongodb.operation.OperationHelper.withConnection(OperationHelper.java:415)
	at com.mongodb.operation.FindOperation.execute(FindOperation.java:711)
	at com.mongodb.operation.FindOperation.execute(FindOperation.java:83)
	at com.mongodb.Mongo$3.execute(Mongo.java:826)
	at com.mongodb.MongoIterableImpl.execute(MongoIterableImpl.java:130)
	at com.mongodb.MongoIterableImpl.iterator(MongoIterableImpl.java:77)
	at com.mongodb.MongoIterableImpl.forEach(MongoIterableImpl.java:100)
	at com.kodegeek.cvebrowser.persistence.TestCVEMongoPojoManager.testPrintIssues(TestCVEMongoPojoManager.java:47)

“Failed to decode ‘phase’. Failed to decode ‘value’. Cannot find a public constructor for ‘SimplePhaseEnumType”. Estoy aprendiendo como enseñarle a MongoDB como guardar y leer estos valores sin que le de un infarto.

(Por cierto, estoy preguntando en StackOverFlow. Vamos a ver que tan complicado es).

Mis primeros pasos con MongoDB

Para ser honesto yo ya había instalado MongoDB hace años, pero no es sino hasta ahora que me ha tocado un proyecto en el cual creo que le puedo sacar mucho provecho. En vista de esto, me senté a jugar un poco con la herramienta.

La Internet cuenta con muchos lugares de donde podemos bajar juegos de datos, en este caso me decidí bajarme la lista de los nombres de bebes más populares en el estado de Nueva York entre los años 2011 y 2014, en formato JSON.

{
  "meta" : {
    "view" : {
      "id" : "25th-nujf",
      "name" : "Most Popular Baby Names by Sex and Mother's Ethnic Group, New York City",
      "attribution" : "Department of Health and Mental Hygiene (DOHMH)",
      "averageRating" : 0,
      "category" : "Health",
      "createdAt" : 1382724894,
      "description" : "The most popular baby names by sex and mother's ethnicity in New York City.",
      "displayType" : "table",
      "downloadCount" : 4328,
      "hideFromCatalog" : false,
      "hideFromDataJson" : false,
      "indexUpdatedAt" : 1465427458,
      "newBackend" : false,
...
  },
  "data" : [ [ 1, "EB6FAA1B-EE35-4D55-B07B-8E663565CCDF", 1, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "GERALDINE", "13", "75" ]
, [ 2, "2DBBA431-D26F-40A1-9375-AF7C16FF2987", 2, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "GIA", "21", "67" ]
, [ 3, "54318692-0577-4B21-80C8-9CAEFCEDA8BA", 3, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "GIANNA", "49", "42" ]
, [ 4, "17C1236A-5778-412D-8DC9-94EBC01BB9A1", 4, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "GISELLE", "38", "51" ]
, [ 5, "F53CF696-A3F4-4EC3-8DFD-10C0A111B2D8", 5, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "GRACE", "36", "53" ]
, [ 6, "6615893F-39B8-440C-98D3-5A37CCF1C44B", 6, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "GUADALUPE", "26", "62" ]
, [ 7, "CC9BE461-34B8-4BD7-BEF2-BDB23CA1ADC6", 7, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "HAILEY", "126", "8" ]
, [ 8, "4EA2FFD4-1B1D-4859-A5C2-045949E3FD36", 8, 1386853125, "399231", 1386853125, "399231", "{\n}", "2011", "FEMALE", "HISPANIC", "HALEY", "14", "74" ]
]}

Antes de importar el archivo en Mongo, hay que darle un masaje a los datos, para ignorar la metadata y para separar cada entrada en el archivo para que luzca como un documento individual, sobre el cual podremos hacer agregación.

Primero les muestro como arrancar MongoDB, importar los datos (no muestro como bajarse el archivo pero es trivial) y luego corremos unos cuantos consultas:

#!/bin/bash
export PATH=$PATH:/Users/josevnz/mongo/mongodb-osx-x86_64-3.2.10/bin
mongod --fork --logpath /Users/josevnz/mongo/mongod.log --noauth --rest --pidfilepath /Users/josevnz/mongo/mongod.pid --dbpath /Users/josevnz/mongo/data

El programa para masajear los datos, en Python:

#!/usr/bin/env python
# josevn at kodegeek.com
import json
import sys
from pprint import pprint
if len(sys.argv) < 2:
  raise ValueError("Missing JSON file...")

'''
// From this (array of arrays):
{
    "data": [
        [
            1,
            "EB6FAA1B-EE35-4D55-B07B-8E663565CCDF",
            1,
            1386853125,
            "399231",
            1386853125,
            "399231",
            "{\n}",
            "2011",
            "FEMALE",
            "HISPANIC",
            "GERALDINE",
            "13",
            "75"
        ],
        [
          ...
        ]
    ],
    "metadata" : {
...
    }
}
 // To this individual maps, no metadata and no extra attributes:
      {
       "key": 1,
       "id": "EB6FAA1B-EE35-4D55-B07B-8E663565CCDF",
       "year": "2011",
       "gender": "FEMALE",
       "ethnicity": "HISPANIC",
       "name": "GERALDINE",
       "cnt": 13
      },
      {
       ...
      }
'''
with open(sys.argv[1]) as data_file:
  data = json.load(data_file)
  for item in data["data"]:
    newitem = {}
    newitem['key'] = item[0]
    newitem['id'] = item[1]
    newitem['year'] = int(item[8])
    newitem['gender'] = item[9]
    newitem['ethnicity'] = item[10]
    newitem['name'] = item[11]
    newitem['cnt'] = int(item[12])
    print(json.dumps(newitem))

Luego importamos los datos y nos conectamos listos para correr un par de consultas:

./jsonMap.py ~/Downloads/NY.babynames.json > ~/Downloads/NY.babynames.pretty.json
mongoimport --db ny --collection babynames --file ~/Downloads/NY.babynames.pretty.json --upsert
mongo localhost:27017/ny

Me encanta que no tuve que crear una base de datos, o una tabla en SQL :-). El documento se describe sólo y ahora estoy listo para hacer consultas:

// ¿Cuantos bebes hispanos entre el 2011 y el 2014?
> db.babynames.find({ "ethnicity": "HISPANIC"}).count()
4254
// Muestre los total agrupando por raza, sexo y año de mayor  a menor
> db.babynames.aggregate([ { $group: { _id: { ethnicity: "$ethnicity", gender: "$gender", year: "$year" }, total: { $sum: "$cnt"} } }, {$sort: {total:-1}} ])
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "MALE", "year" : 2011 }, "total" : 56236 }
{ "_id" : { "ethnicity" : "WHITE NON HISPANIC", "gender" : "MALE", "year" : 2011 }, "total" : 54392 }
{ "_id" : { "ethnicity" : "WHITE NON HISPANIC", "gender" : "FEMALE", "year" : 2011 }, "total" : 45120 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "FEMALE", "year" : 2011 }, "total" : 41532 }
{ "_id" : { "ethnicity" : "BLACK NON HISPANIC", "gender" : "MALE", "year" : 2011 }, "total" : 24540 }
{ "_id" : { "ethnicity" : "ASIAN AND PACIFIC ISLANDER", "gender" : "MALE", "year" : 2011 }, "total" : 18580 }
{ "_id" : { "ethnicity" : "BLACK NON HISPANIC", "gender" : "FEMALE", "year" : 2011 }, "total" : 17624 }
{ "_id" : { "ethnicity" : "WHITE NON HISPANIC", "gender" : "MALE", "year" : 2014 }, "total" : 14831 }
{ "_id" : { "ethnicity" : "WHITE NON HISPANIC", "gender" : "MALE", "year" : 2013 }, "total" : 14537 }
{ "_id" : { "ethnicity" : "WHITE NON HISP", "gender" : "MALE", "year" : 2012 }, "total" : 14273 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "MALE", "year" : 2012 }, "total" : 13809 }
{ "_id" : { "ethnicity" : "ASIAN AND PACIFIC ISLANDER", "gender" : "FEMALE", "year" : 2011 }, "total" : 13672 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "MALE", "year" : 2013 }, "total" : 13312 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "MALE", "year" : 2014 }, "total" : 13126 }
{ "_id" : { "ethnicity" : "WHITE NON HISPANIC", "gender" : "FEMALE", "year" : 2014 }, "total" : 12884 }
{ "_id" : { "ethnicity" : "WHITE NON HISP", "gender" : "FEMALE", "year" : 2012 }, "total" : 12402 }
{ "_id" : { "ethnicity" : "WHITE NON HISPANIC", "gender" : "FEMALE", "year" : 2013 }, "total" : 12303 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "FEMALE", "year" : 2013 }, "total" : 9755 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "FEMALE", "year" : 2012 }, "total" : 9738 }
{ "_id" : { "ethnicity" : "HISPANIC", "gender" : "FEMALE", "year" : 2014 }, "total" : 9729 }
Type "it" for more
> it
{ "_id" : { "ethnicity" : "BLACK NON HISP", "gender" : "MALE", "year" : 2012 }, "total" : 5965 }
{ "_id" : { "ethnicity" : "ASIAN AND PACI", "gender" : "MALE", "year" : 2012 }, "total" : 5962 }
{ "_id" : { "ethnicity" : "BLACK NON HISPANIC", "gender" : "MALE", "year" : 2013 }, "total" : 5866 }
{ "_id" : { "ethnicity" : "BLACK NON HISPANIC", "gender" : "MALE", "year" : 2014 }, "total" : 5702 }
{ "_id" : { "ethnicity" : "ASIAN AND PACIFIC ISLANDER", "gender" : "MALE", "year" : 2014 }, "total" : 5636 }
{ "_id" : { "ethnicity" : "ASIAN AND PACIFIC ISLANDER", "gender" : "MALE", "year" : 2013 }, "total" : 5281 }
{ "_id" : { "ethnicity" : "ASIAN AND PACI", "gender" : "FEMALE", "year" : 2012 }, "total" : 4338 }
{ "_id" : { "ethnicity" : "BLACK NON HISPANIC", "gender" : "FEMALE", "year" : 2013 }, "total" : 4278 }
{ "_id" : { "ethnicity" : "BLACK NON HISPANIC", "gender" : "FEMALE", "year" : 2014 }, "total" : 4255 }
{ "_id" : { "ethnicity" : "BLACK NON HISP", "gender" : "FEMALE", "year" : 2012 }, "total" : 4243 }
{ "_id" : { "ethnicity" : "ASIAN AND PACIFIC ISLANDER", "gender" : "FEMALE", "year" : 2014 }, "total" : 4198 }
{ "_id" : { "ethnicity" : "ASIAN AND PACIFIC ISLANDER", "gender" : "FEMALE", "year" : 2013 }, "total" : 4012 }

Estoy emocionado con la herramienta, pienso usarla para ver los datos en mi trabajo de manera distinta.

Trucos con ‘static’ en Java

Les voy a mostrar un pequeño truco en Java. ¿Cual es la salida del siguiente código?

package com.kodegeek;


public class StaticWeird {

    public static int count = 0;

    public static void print() {
        System.out.println(++count);
    }

    /**
     * What is the output?
     * @param args
     */
    public static void main(String [] args) {
        StaticWeird weird = new StaticWeird();
        weird.print();
        weird = null; // Magic hat trick
        weird.print(); // Expecting an NPE :-)?
    }


}

No es “NullPointerException”. Java sabe que la referencia al método es estática (static) y utiliza esa en vez de una referencia a la instancia de la clase.

¡Java tramposo! 🙂

Decoración de funciones en Python usando functools

La decoración de funciones, usando functools, en Python nos permite eliminar código repetitivo que debemos escribir una y otra vez, y también nos permite modificar funciones existentes con unas pocas lineas de código. Como ejemplo, veamos la tubería que escribí hace tiempo atrás:

#!/usr/bin/env python3
import sys, re

def grep(expression):
    while True:
        text = (yield)
        if re.search(expression, text):
            print(text)

def cat(file, target):
    next(target)
    with open(file, "r") as fh:
        for line in fh.readlines():
            target.send(line.strip())

def main(args):
    cat(args[0], grep(args[1]))

if __name__ == "__main__":
    main(sys.argv[1:])

En la linea 11 pueden ver que hay que preparar el otro lado de la tubería antes de enviar datos, lo cual es un fastidio; Usando ‘functools’ podemos crear una anotación a la medida, la cual llamaremos ‘pipe’ (lineas 6-12):

#!/usr/bin/env python3
# Shows how to use a function decorator to create an annotation
# josevnz@kodegeek.com
import sys, re, functools

def pipe(function):
    @functools.wraps(function)
    def wrapper(*args, **kwargs):
        target = function(*args, **kwargs)
        next(target) # Saves us the trouble of calling next on the pipe
        return target
    return wrapper

@pipe
def grep(expression):
    while True:
        text = (yield)
        if re.search(expression, text):
            print(text)

def cat(file, target):
    with open(file, "r") as fh:
        for line in fh.readlines():
            target.send(line.strip())

def main(args):
    cat(args[0], grep(args[1]))

if __name__ == "__main__":
    main(sys.argv[1:])

En la línea 14 agregamos la nueva anotación y note como eliminamos el ‘next(target)’ completamente de la función cat(file, target). No paree gran cosa, pero es una línea menos de código que poner en cada llamada.

¿Pero se pueden hacer otras cosas? Se me ocurren decoraciones como ‘count’ o ‘sorted’ para funciones que trabajan con listas, estoy aprendiendo como usar esta nueva herramienta, ya les diré como me va.

Usando ‘data pipes y coroutines’ en Python

El concepto es similar a usar ‘pipes’ en UNIX. Por ejemplo, en UNIX podemos combinar varias herramientas para filtrar los contenidos en un archivo de texto. ¿Que hace el siguiente comando?

# Contar cuantos 'root' hay en el archivo de passwords del servidor Linux
cat /etc/password| egrep -i root

En Python 3 podemos hacer algo como lo siguiente:

#!/usr/bin/env python3
# pipeline.py /etc/passwd root
import sys, re

def grep(expression):
    while True:
        text = (yield) # Espere por el texto enviados por la otra co-rutina
        if re.search(expression, text):
            print(text)

def cat(file, target):
    next(target) # Initialize el otro lado de la tuberia
    with open(file, "r") as fh:
        for line in fh.readlines():
            target.send(line.strip()) # envie la linea a la co-rutina

def main(args):
    cat(args[0], grep(args[1])) # La 'tuberia' se hace de afuera hacia adentro

if __name__ == "__main__":
    main(sys.argv[1:])

Otro ejemplo, vamos a modificar el programa que lee los datos del archivo de datos de DolarToday para filtrar por fecha de inicio, final, valores máximos y mínimos. Note como el uso de los filtros es opcional y si no se aplican entonces retornan todos los datos (para mantener el ejemplo sencillo, removí el código que convierte los datos al formato binario):

#!/usr/bin/env python3
# Simple program to save the 'Dolartoday' extra official dollar rates from CSV to a custom format
# Revisited to use data pipes, couroutines
# @author Jose Vicente Nunez, josevnz@kodegeek.com
import os, sys, datetime, re, gzip, struct
from optparse import OptionParser, OptionValueError
from datetime import datetime

class DollarToday:
    
    __slot__ = ("__date", "__value")
    
    def __init__(self, ddate, value):
        if isinstance(ddate, datetime):
            self.__date = ddate
        else:
            self.__date = datetime.strptime(ddate, "%m-%d-%Y")
        self.__value = float(value)
        assert self.__value > 0.0, "{} for date {} is invalid!".format(value, self.__date)
        
    @property
    def date(self):
        return self.__date
    
    @date.setter
    def date(self, date):
        assert isinstance(date, datetime), "Invalid date {}".format(date)
        self.__date = date
    
    @property
    def value(self):
        return self.__value
    
    def __str__(self):
        return "DollarToday[date={}, value={}]".format(self.__date, self.__value)
    
    def __hash__(self):
        return str(id(self))
            
class DollarCollection(dict):
    
    def values(self):
        for dateId in sorted(self.keys()):
            yield self[dateId]
    
    def items(self):
        for dateId in self.keys():
            yield (dateId, self[dateId])
    
    def __iter__(self):
        for dateId in sorted(super().keys()):
            yield dateId

    def __str__(self):
        return "Records={},\n{}".format(len(self), ",\n".join([str(date) for date in self.values()]))

# Pipe: Read from binary file
def readBinary(file, target):
    next(target)
    FILE_MAGIC = b"DLR\x00"
    FILE_VERSION = b"\x00\x01"
    dollarStruct = struct.Struct(" FILE_VERSION:
          raise "Unsupported file version: {}, expected {}".format(version, FILE_VERSION)
        while True:
          data = fh.read(dollarStruct.size)
          if len(data) == 0:
           break
          numbers = dollarStruct.unpack(data)
          target.send(DollarToday(datetime.fromordinal(numbers[0]), numbers[1]))
    except (Exception) as err:
            raise

# Pipe: Filter elements >= minimum
def min(min, target):
    next(target)
    while True:
        dollar = (yield)
        if dollar.value >= min:
            target.send(dollar)

# Pipe: Filter elements < = max
def max(max, target):
    next(target)
    while True:
        dollar = (yield)
        if dollar.value <= max:
            target.send(dollar)

# Pipe: filter from date
def from_date(fromd, target):
        next(target)
        while True:
            dollar = (yield)
            if dollar.date >= fromd:
                target.send(dollar)

# Pipe: filter from date
def to_date(tod, target):
        next(target)
        while True:
            dollar = (yield)
            if dollar.date < = tod:
                target.send(dollar)

def adder(collection):
    while True:
        dollar = (yield)
        collection[dollar.date] = dollar

def main(options):

    dc = DollarCollection()
    
    pipeline = adder(dc) # Destination of the pipe
    # Add pipes (if needed)
    if options.min != 99999999:
        pipeline = min(options.min, pipeline)
    
    if options.max != -1:
        pipeline = max(options.min, pipeline)

    if options.fromd is not None:
        pipeline = from_date(datetime.strptime(options.fromd, "%Y-%m-%d"), pipeline)
        
    if options.tod is not None:
        pipeline = to_date(datetime.strptime(options.tod, "%Y-%m-%d"), pipeline)

    readBinary(options.report, pipeline) # Start of the pipe that reads the file contents
 
    print("{}".format(dc))

if __name__ == "__main__":
    
    usagetext = """
%prog --report binary.file [--from_date YYYYMMDD] [--to YYYYMMDD] [--min amount] [--max amount]
"""

    op = OptionParser(usage=usagetext)
    op.add_option(
                  "-p", "--report",
                  action="store",
                  dest="report",
                  help="Read the contents from the binary storage and generate a report.")
    op.add_option(
                  "-f", "--from",
                  action="store",
                  dest="fromd",
                  help="Optional filter. Start date yyyy-mm-dd")
    op.add_option(
                  "-t", "--to",
                  action="store",
                  dest="tod",
                  help="Optional filter. End date yyyy-mm-dd")
    op.add_option(
                  "-m", "--min",
                  action="store",
                  dest="min",
                  default=99999999,
                  type="float",
                  help="Optional filter. Minimal amount")
    op.add_option(
                  "-M", "--max",
                  action="store",
                  dest="max",
                  default=-1,
                  type="float",
                  help="Optional filter. Maximum amount")
    
    (options, values) = op.parse_args()
    main(options)

Una salida de ejemplo:

DolarTodayReader.py --report /Users/josevnz/Documents/dolartoday.jose --min 1050 --from 2016-02-23 --to 2016-02-24
Records=2,
DollarToday[date=2016-02-23 00:00:00, value=1060.0],
DollarToday[date=2016-02-24 00:00:00, value=1071.19]

Para cerrar, les recomiendo el siguiente tutorial: http://www.dabeaz.com/coroutines/index.html. La sintaxis es de Python 2.

‘Contexts’ en Python

En Python, un contexto (context) es una clase que implementa los métodos ‘__enter__’ y ‘__exit__’ los cuales son llamados si la clase en llamada con la palabra reservada ‘with’. Por ejemplo, los descriptores de archivo (file handle) en Python se pueden llamar con un contexto, ahorrando llamar ‘finally’ para cerrar archivos, sin importar si hay un error.

Les traigo de vuelta el programa de ‘DolarToday’ el cual escribe datos en un archivo binario, pero ahora utilizando contextos:

#!/usr/bin/env python3
# Simple program to save the 'Dolartoday' extra official dollar rates from CSV to a custom format
# Revisited to use 'contexts' to avoid using finally on exception handling
# @author Jose Vicente Nunez, josevnz@kodegeek.com
import os, sys, datetime, re, gzip, struct
from optparse import OptionParser, OptionValueError
from datetime import datetime

class DollarToday:
    
    def __init__(self, ddate, value):
        if isinstance(ddate, datetime):
            self.__date = ddate
        else:
            self.__date = datetime.strptime(ddate, "%m-%d-%Y")
        self.__value = float(value)
        assert self.__value > 0.0, "{} for date {} is invalid!".format(value, self.__date)
        
    @property
    def date(self):
        return self.__date
    
    @date.setter
    def date(self, date):
        assert isinstance(date, datetime), "Invalid date {}".format(date)
        self.__date = date
    
    @property
    def value(self):
        return self.__value
    
    def __str__(self):
        return "DollarToday[date={}, value={}]".format(self.__date, self.__value)
    
    def __hash__(self):
        return str(id(self))
            
class DollarCollection(dict):
    
    # https://en.wikipedia.org/wiki/List_of_file_signatures
    __FILE_MAGIC = b"DLR\x00"
    __FILE_VERSION = b"\x00\x01"
    __dollarStruct = struct.Struct(" self.__FILE_VERSION:
                    raise "Unsupported file version: {}, expected {}".format(version, self.__FILE_VERSION)
                self.clear()
                while True:
                    data = fh.read(self.__dollarStruct.size)
                    if len(data) == 0:
                        break
                    numbers = self.__dollarStruct.unpack(data)
                    #if verbose:
                    #    print("{}".format(",".join([str(x) for x in numbers])))
                    dolar = DollarToday(
                                    datetime.fromordinal(numbers[0]),
                                    numbers[1]
                                    )
                    self[dolar.date] = dolar
        except (Exception) as err:
            raise
                
    def readCsv(self, file):
        tempMap = {}
        try:
            with open(file, "r", encoding="UTF-8") as fh:
                for line in fh.readlines():
                    # 6-23-2010       9.92
                    (date, value) = line.strip().split("\t")
                    if re.match("Fecha", date):
                        continue
                    try:
                        dolVsBol = DollarToday(date, value)
                        tempMap[dolVsBol.date] = dolVsBol
                    except (Exception) as err:
                        print(err)
            self.clear()
            self.update(tempMap)
        except (Exception) as err:
            raise

    def __str__(self):
        return "Records={},\n{}".format(len(self), ",\n".join([str(date) for date in self.values()]))

def main(options):

    verbose = options.verbose
    dc = DollarCollection()
    if options.report == None:
        dc.readCsv(options.read)
        dc.save(options.write)
    else:
        dc.readBinary(options.report, verbose)
    if verbose:
        print("{}".format(dc))

if __name__ == "__main__":
    
    usagetext = """
%prog --read csv.file --write binary.file

Or:

%prog --report binary.file

"""

    op = OptionParser(usage=usagetext)
    op.add_option(
                  "-r", "--read",
                  action="store", 
                  dest="read", 
                  help="Full path to CSV file with date,value pairs per line")
    op.add_option(
                  "-w", "--write", 
                  action="store", 
                  dest="write", 
                  help="Full path to destination file in binary format")
    op.add_option(
                  "-p", "--report", 
                  action="store", 
                  dest="report",
                  help="Read the contents of the binary storage and generate a report. Incompatible with --read and --write")
    op.add_option(
                  "-v", "--verbose", 
                  action="store_true", 
                  default=False, 
                  dest="verbose", 
                  help="Enable verbose mode")
    
    (options, values) = op.parse_args()

    main(options)

Functors en Python

Un ‘functor’ es u objeto el cual puede ser llamado como si fuera una función. En Python simplemente hay que implementar el método ‘__call__’. En este ejemplo, vamos a tomar a los candidatos presidenciales del articulo anterior y vamos a escribir una pequeña clase (llamada SortKey) la cual nos va a permitir ordenar por cualquier attributo o combinación de attributos (en nuestro ejemplo vamos a ordendar por sexo y luego por edad):

#!/usr/bin/env python3
# A little fun with the candidates for the US presidency for 2016 election year
# Revisited versions with functors, slots, abstract classes
# @author josevnz@kodegeek.com
#
import abc

class SortKey:
    
    def __init__(self, *attributes):
        self.attributes = attributes
    def __call__(self, instance):
        return [getattr(instance, attribute) for attribute in self.attributes]

class Candidate(metaclass=abc.ABCMeta):
    
    __slots__ = ("__name", "__sex", "__age")

    @abc.abstractmethod    
    def __init__(self, name, sex="F", age=21):
        self.__name = name
        self.__sex = "F" if sex not in ["M", "F"] else sex
        self.__age = 21 if not (21 < = age <= 120) else age
    
    @property
    def name(self):
        return self.__name
    
    def get_party(self):
        raise NotImplemented()
    
    party = abc.abstractproperty(get_party)
    
    @property
    def sex(self):
        return self.__sex
    
    @property
    def age(self):
        return self.__age
    
    def __hash__(self):
        return hash(id(self))
    
    def __str__(self):
        return "{}=[name={}, sex={}, age={}]".format(self.__class__.__name__,
                                                            self.__name,
                                                            self.__sex,
                                                            self.__age
                                                            )
    
    def __repr__(self):
        return "{}({}{}{}{})".format(
                                 self.__class__.__name__, 
                                 self.__name, 
                                 self.__sex, 
                                 self.__age
                                )
    
    @abc.abstractmethod
    def __bool__(self):
        raise NotImplemented()
    
    def __lt__(self, other):
        return self.__age < other.__age
    
    def __gt__(self, other):
        return self.__age > other.__age
    
    def __eq__(self, other):
        return (
                self.__age == other.__age and 
                self.__name == other.__name and
                self.__sex == other.__sex
                )

class Republican(Candidate):
    
    def __init__(self, name, sex="F", age=21):
        return super().__init__(name, sex, age)
    
    party = "Replublican Party (GOP)"
    
    def __bool__(self):
        return False
            
class Democrat(Candidate):
    
    def __init__(self, name, sex="F", age=21):
        return super().__init__(name, sex, age)
    
    party = "Democratic Party"
    
    def __bool__(self):
        return True
    
if __name__ == "__main__":
        candidates = []
        candidates.append(Democrat("Hillary Clinton", "F", 68))
        candidates.append(Republican("Donald Trump", "M", 70))
        candidates.append(Democrat("Bernie Sanders", "M", 75))
        candidates.append(Republican("Marco Rubio", "M", 45))
        candidates.sort(key=SortKey("sex", "age"), reverse=False)
        for candidate in candidates:
            isDemocrat = "yes" if candidate else "no"
            print("Candidate: {}, democrat? {}".format(candidate, isDemocrat))        

Y la salida (fíjense que definimos el orden en la linea 103, ademas de que ordenamos la lista primero para después utilizarla):

Candidate: Democrat=[name=Hillary Clinton, sex=F, age=68], democrat? yes
Candidate: Republican=[name=Marco Rubio, sex=M, age=45], democrat? no
Candidate: Republican=[name=Donald Trump, sex=M, age=70], democrat? no
Candidate: Democrat=[name=Bernie Sanders, sex=M, age=75], democrat? yes

Slots, Abstracts classes en Python 3

¿Se acuerdan del ejemplo anterior usando classes en Python? En este caso hice un par de cambios para aprender más sobre clases abstractas y slots (una manera de ahorrar memoria en Python cuando creamos objetos).

En este caso:

  • Uso un ‘slot’ para guardar sólo 3 atributos que son definidos en el constructor
    Declaro la propiedad ‘party’ como abstracta ya que quiero que el que extienda las clases se vea forzado a implementar un método adecuado que me diga de que partido viene
    También obligo al usuario a declarar el método ‘__bool__’ el cual utilizo para saber si es Democrata u otra clase (si, el ejemplo es forzado :-))
#!/usr/bin/env python3
# A little fun with the candidates for the US presidency for 2016 election year
# Revisited versions with slots, abstract classes
# @author josevnz@kodegeek.com
#
import abc

class Candidate(metaclass=abc.ABCMeta):
    
    __slots__ = ("__name", "__sex", "__age")

    @abc.abstractmethod    
    def __init__(self, name, sex="F", age=21):
        self.__name = name
        self.__sex = "F" if sex not in ["M", "F"] else sex
        self.__age = 21 if not (21 < = age <= 120) else age
    
    @property
    def name(self):
        return self.__name
    
    def get_party(self):
        raise NotImplemented()
    
    party = abc.abstractproperty(get_party)
    
    @property
    def sex(self):
        return self.__sex
    
    @property
    def age(self):
        return self.__age
    
    def __hash__(self):
        return hash(id(self))
    
    def __str__(self):
        return "{}=[name={}, sex={}, age={}]".format(self.__class__.__name__,
                                                            self.__name,
                                                            self.__sex,
                                                            self.__age
                                                            )
    
    def __repr__(self):
        return "{}({}{}{}{})".format(
                                 self.__class__.__name__, 
                                 self.__name, 
                                 self.__sex, 
                                 self.__age
                                )
    
    @abc.abstractmethod
    def __bool__(self):
        raise NotImplemented()
    
    def __lt__(self, other):
        return self.__age < other.__age 

     def __gt__(self, other): 
        return self.__age > other.__age
    
    def __eq__(self, other):
        return (
                self.__age == other.__age and 
                self.__name == other.__name and
                self.__sex == other.__sex
                )

class Republican(Candidate):
    
    def __init__(self, name, sex="F", age=21):
        return super().__init__(name, sex, age)
    
    party = "Republican Party (GOP)"
    
    def __bool__(self):
        return False
            
class Democrat(Candidate):
    
    def __init__(self, name, sex="F", age=21):
        return super().__init__(name, sex, age)
    
    party = "Democratic Party"
    
    def __bool__(self):
        return True
    
if __name__ == "__main__":
        candidates = []
        candidates.append(Democrat("Hillary Clinton", "F", 68))
        candidates.append(Republican("Donald Trump", "M", 70))
        candidates.append(Democrat("Bernie Sanders", "M", 75))
        candidates.append(Republican("Marco Rubio", "M", 45))
        for candidate in sorted(candidates, reverse=True):
            isDemocrat = "yes" if candidate else "no"
            print("Candidate: {}, democrat? {}".format(candidate, isDemocrat))