Escribir subclases de propiedades

La clase Property se ha diseñado para que se pueda crear una subclase a partir de ella. Sin embargo, normalmente es más fácil crear una subclase de una subclase Property.

Todos los atributos especiales Property, incluso los que se consideran "públicos", tienen nombres que empiezan por un guion bajo. Esto se debe a que StructuredProperty usa el espacio de nombres de atributo sin guion bajo para hacer referencia a nombres Property anidados, lo cual es esencial para especificar consultas en subpropiedades.

La clase Property y sus subclases predefinidas permiten crear subclases mediante APIs de validación y conversión componibles (o apilables). Para ello, necesitamos definir algunos términos:

  • Un valor de usuario es un valor que se define y al que se accede mediante el código de la aplicación usando atributos estándar de la entidad.
  • Un valor base es un valor que se serializaría en y se deserializaría desde Datastore.

Una subclase Property que implemente una transformación específica entre valores de usuario y valores serializables debe implementar dos métodos: _to_base_type() y _from_base_type(). No deben llamar a su método super(). Esto es lo que se conoce como APIs componibles (o apilables).

La API admite clases de acumulación con conversiones de base de usuarios cada vez más sofisticadas: la conversión de usuario a base pasa de más sofisticada a menos sofisticada, mientras que la conversión de base a usuario pasa de menos sofisticada a más sofisticada. Por ejemplo, consulta la relación entre BlobProperty, TextProperty y StringProperty. Por ejemplo, TextProperty hereda de BlobProperty. Su código es bastante sencillo porque hereda la mayor parte del comportamiento que necesita.

Además de _to_base_type() y _from_base_type(), el método _validate() también es una API componible.

La API de validación distingue entre los valores de usuario lax y strict. El conjunto de valores laxos es un superconjunto del conjunto de valores estrictos. El método _validate() toma un valor laxo y, si es necesario, lo convierte en un valor estricto. Esto significa que, al definir el valor de la propiedad, se aceptan valores laxos, mientras que, al obtener el valor de la propiedad, solo se devuelven valores estrictos. Si no es necesario realizar ninguna conversión, _validate() puede devolver None. Si el argumento está fuera del conjunto de valores laxos aceptados, _validate() debería generar una excepción, preferiblemente TypeError o datastore_errors.BadValueError.

_validate(), _to_base_type() y _from_base_type() no tienen que gestionar lo siguiente:

  • None: no se llamará con None (y, si devuelve None, significa que el valor no necesita conversión).
  • Valores repetidos: la infraestructura se encarga de llamar a _from_base_type() o _to_base_type() por cada elemento de la lista de un valor repetido.
  • Distinguir los valores de usuario de los valores base: la infraestructura se encarga de esto llamando a las APIs componibles.
  • Comparaciones: las operaciones de comparación llaman a _to_base_type() en su operando.
  • Distinguir entre los valores de usuario y los valores base: la infraestructura garantiza que se llamará a _from_base_type() con un valor base (sin envolver) y que se llamará a _to_base_type() con un valor de usuario.

Por ejemplo, supongamos que necesitas almacenar números enteros muy largos. El estándar IntegerProperty solo admite números enteros de 64 bits (con signo). Es posible que tu propiedad almacene un número entero más largo como una cadena. Sería conveniente que la clase de propiedad gestionara la conversión. Una aplicación que use tu clase de propiedad podría tener un aspecto similar al siguiente:

from datetime import date

import my_models
...
class MyModel(ndb.Model):
    name = ndb.StringProperty()
    abc = LongIntegerProperty(default=0)
    xyz = LongIntegerProperty(repeated=True)
...
# Create an entity and write it to the Datastore.
entity = my_models.MyModel(name='booh', xyz=[10**100, 6**666])
assert entity.abc == 0
key = entity.put()
...
# Read an entity back from the Datastore and update it.
entity = key.get()
entity.abc += 1
entity.xyz.append(entity.abc//3)
entity.put()
...
# Query for a MyModel entity whose xyz contains 6**666.
# (NOTE: using ordering operations don't work, but == does.)
results = my_models.MyModel.query(
    my_models.MyModel.xyz == 6**666).fetch(10)

Parece sencillo y directo. También muestra el uso de algunas opciones de propiedad estándar (predeterminada, repetida). Como autor de LongIntegerProperty, te alegrará saber que no tienes que escribir ningún código repetitivo para que funcionen. Es más fácil definir una subclase de otra propiedad. Por ejemplo:

class LongIntegerProperty(ndb.StringProperty):
    def _validate(self, value):
        if not isinstance(value, (int, long)):
            raise TypeError('expected an integer, got %s' % repr(value))

    def _to_base_type(self, value):
        return str(value)  # Doesn't matter if it's an int or a long

    def _from_base_type(self, value):
        return long(value)  # Always return a long

Cuando asignas un valor de propiedad a una entidad, por ejemplo, ent.abc = 42, se llama a tu método _validate() y, si no genera una excepción, el valor se almacena en la entidad. Cuando escribes la entidad en Datastore, se llama al método _to_base_type(), que convierte el valor en una cadena. A continuación, la clase base serializa ese valor, StringProperty. La cadena de eventos inversa se produce cuando la entidad se lee de nuevo desde Datastore. Las clases StringProperty y Property se encargan de otros detalles, como serializar y deserializar la cadena, definir el valor predeterminado y gestionar los valores de propiedad repetidos.

En este ejemplo, admitir desigualdades (es decir, consultas que usan <, <=, >, >=) requiere más trabajo. En el siguiente ejemplo de implementación se impone un tamaño máximo de número entero y se almacenan los valores como cadenas de longitud fija:

class BoundedLongIntegerProperty(ndb.StringProperty):
    def __init__(self, bits, **kwds):
        assert isinstance(bits, int)
        assert bits > 0 and bits % 4 == 0  # Make it simple to use hex
        super(BoundedLongIntegerProperty, self).__init__(**kwds)
        self._bits = bits

    def _validate(self, value):
        assert -(2 ** (self._bits - 1)) <= value < 2 ** (self._bits - 1)

    def _to_base_type(self, value):
        # convert from signed -> unsigned
        if value < 0:
            value += 2 ** self._bits
        assert 0 <= value < 2 ** self._bits
        # Return number as a zero-padded hex string with correct number of
        # digits:
        return '%0*x' % (self._bits // 4, value)

    def _from_base_type(self, value):
        value = int(value, 16)
        if value >= 2 ** (self._bits - 1):
            value -= 2 ** self._bits
        return value

Se puede usar de la misma forma que LongIntegerProperty excepto que debes pasar el número de bits al constructor de la propiedad, por ejemplo, BoundedLongIntegerProperty(1024).

Puedes crear subclases de otros tipos de propiedad de forma similar.

Este enfoque también funciona para almacenar datos estructurados. Supongamos que tienes una clase de Python FuzzyDate que representa un periodo. Utiliza los campos first y last para almacenar el inicio y el final del periodo:

from datetime import date

...
class FuzzyDate(object):
    def __init__(self, first, last=None):
        assert isinstance(first, date)
        assert last is None or isinstance(last, date)
        self.first = first
        self.last = last or first

Puedes crear un FuzzyDateProperty que derive de StructuredProperty. Lamentablemente, esta última no funciona con las clases de Python antiguas, sino que necesita una subclase Model. Por lo tanto, define una subclase de Model como representación intermedia.

class FuzzyDateModel(ndb.Model):
    first = ndb.DateProperty()
    last = ndb.DateProperty()

A continuación, crea una subclase de StructuredProperty que codifica el argumento modelclass como FuzzyDateModel y define los métodos _to_base_type() y _from_base_type() para convertir entre FuzzyDate y FuzzyDateModel:

class FuzzyDateProperty(ndb.StructuredProperty):
    def __init__(self, **kwds):
        super(FuzzyDateProperty, self).__init__(FuzzyDateModel, **kwds)

    def _validate(self, value):
        assert isinstance(value, FuzzyDate)

    def _to_base_type(self, value):
        return FuzzyDateModel(first=value.first, last=value.last)

    def _from_base_type(self, value):
        return FuzzyDate(value.first, value.last)

Una aplicación podría usar esta clase de la siguiente manera:

class HistoricPerson(ndb.Model):
    name = ndb.StringProperty()
    birth = FuzzyDateProperty()
    death = FuzzyDateProperty()
    # Parallel lists:
    event_dates = FuzzyDateProperty(repeated=True)
    event_names = ndb.StringProperty(repeated=True)
...
columbus = my_models.HistoricPerson(
    name='Christopher Columbus',
    birth=my_models.FuzzyDate(date(1451, 8, 22), date(1451, 10, 31)),
    death=my_models.FuzzyDate(date(1506, 5, 20)),
    event_dates=[my_models.FuzzyDate(
        date(1492, 1, 1), date(1492, 12, 31))],
    event_names=['Discovery of America'])
columbus.put()

# Query for historic people born no later than 1451.
results = my_models.HistoricPerson.query(
    my_models.HistoricPerson.birth.last <= date(1451, 12, 31)).fetch()

Supongamos que quiere aceptar objetos date sin formato además de objetos FuzzyDate como valores de FuzzyDateProperty. Para ello, modifica el método _validate() de la siguiente manera:

def _validate(self, value):
    if isinstance(value, date):
        return FuzzyDate(value)  # Must return the converted value!
    # Otherwise, return None and leave validation to the base class

En su lugar, puedes crear una subclase de FuzzyDateProperty de la siguiente manera (suponiendo que FuzzyDateProperty._validate() es como se muestra arriba).

class MaybeFuzzyDateProperty(FuzzyDateProperty):
    def _validate(self, value):
        if isinstance(value, date):
            return FuzzyDate(value)  # Must return the converted value!
        # Otherwise, return None and leave validation to the base class

Cuando asignas un valor a un campo MaybeFuzzyDateProperty, se invocan MaybeFuzzyDateProperty._validate() y FuzzyDateProperty._validate(), en ese orden. Lo mismo ocurre con _to_base_type() y _from_base_type(): los métodos de la superclase y la subclase se combinan implícitamente. No uses super para controlar el comportamiento heredado en este caso. En estos tres métodos, la interacción es sutil y super no hace lo que quieres.