Usage

Schemas

Schemas are defined by subclassing Schema and defining fields as class properties.

import datetime
from ciri import fields, Schema, ValidationError


class User(Schema):
    name = fields.String(required=True)
    type_ = fields.String(name='type', required=True)
    created = fields.Date()


user = User(name='Eric', type_='dev', created=datetime.date(2018, 1, 1))
user.serialize()

# {'name': Eric', 'type': 'dev', 'created': '2018-01-01'}

Subclassing

Schemas can be subclassed as you would normal objects.

class Person(Schema):
    name = fields.String()
    age = fields.Integer()


class Parent(Person):
    # inherits name, age
    children = fields.List(Person())


class GrandParent(Parent):
    # inherits name, age, children
    grandchildren = fields.List(Person())

Subclasses can override existing fields or remove them altogether:

class Contact(Schema)
    id = fields.Integer(required=True)
    name = fields.String(required=True)
    mobile = fields.String()
    emergency_contact_id = fields.Integer()

class EmergencyContact(Schema)
    mobile = fields.String(required=True)
    emergency_contact_id = None

Behavior

All schemas have a SchemaOptions object which sets the default behavior. You can configure your own defaults by setting the __schema_options__ property in your schema definition.

Note

Schema options are not inherited by subclasses by design. This allows schema composition to be defined by the instanced schema and field behavior. For this reason, it’s recommended to create your own subclass of Schema if you want to modify all schemas behavior. Alternatively, you can override the options as needed as shown below.

from ciri import fields, Schema, SchemaOptions


class Person(Schema):

    __schema_options__ = SchemaOptions(allow_none=True)

    name = fields.String()

person = Person().serialize({})  # {'name': None}

Validation

Validate data using the validate() method:

class Person(Schema):
    name = fields.String(required=True)

person = Person().serialize({})  # raises ValidationError

Raw validation errors are stored on the _raw_errors property. The schema error handler will also compile error output to be used in your application and can be accessed through the errors property. Check out the Errors section for more details on what to do with validation errors.

Serialization

Data is serialized using the serialize() method:

class Person(Schema):
    name = fields.String(required=True)

person = Person().serialize({'name': 'Harry'})  # {'name': 'Harry'}

You can also serialize a schema instance by passing no data to the serailize method:

person = Person(name='Harry').serialize()  # {'name': Harry}

By default, validate() is called serialization. You can skip validation (if you have already validated elsewhere for example) by passing skip_validation to the serialize method:

person = Person(name=123).serialize(skip_validation=True)  # {'name': 123}

All serialization requires validation to correctly serialize, but if you are confident the data being serialized is already valid, you can save time by skipping validation. This is useful if you are serializing database output or other known values.

Deserialization

Data is deserialized using the deserialize() method. It behaves the same way as the serialization method and has the same validation caveats. Check out the Serialization section for more info.

class Person(Schema):
    name = fields.String(required=True)

person = Person().deserialize({'name': 'Harry'})
person.name  # Harry

Encoding

Data is encoded using the (you guessed it) encode() method. By default, the validate() and serialize() methods will be called and the resulting serialized data will be passed to the encoder. You can skip validation and serialization using the skip_validation and skip_serialization keyword args.

The default encoder class is JSONEncoder but can be set in the schema options.

class Person(Schema):
    name = fields.String()
    active = fields.Boolean(default=False)

person = Person(name=Harry).encode()  # '{"name": "Harry", "active": false}'

Errors

Things go wrong. It’s important to know why they went wrong. When a field is invalid, a FieldValidationError is raised. The offending FieldError passed is then set on on the schemas _raw_errors dict under the key the field was defined as on the schema (not the serialized output name). More often than not, you won’t need to care about the _raw_errors but it can be useful for testing and debugging.

By contrast, the errors property is very useful for error reporting. It holds the formatted error output. The default error handler outputs errors that are structured like so:

{'field_key': {'msg': 'Error Description'}}

So in effect:

from ciri import fields, Schema, ValidationError


class Person(Schema):

    name = fields.String(required=True)
    age = fields.Integer(required=True)
    born = fields.Date(required=True)

try:
    person = Person(name=Harry, age="42").serialize()
except ValidationError:
    person.errors  # {'age': {'msg': 'Field is not a valid Integer'}, 'born': {'msg': 'Required Field'}}

Nested Errors

Sequence and Mapping fields such as List and Schema can contain multiple errors. The default error handler will nest these errors under the errors key.

Here is an example of nested errors:

class Person(Schema):

    name = fields.String(required=True)
    age = fields.Integer(required=True)
    born = fields.Date(required=True)


class Sibling(Person):

    mother = fields.Schema(Person, required=True)
    siblings = fields.List(Person(), required=True)


mother = Person(name='Karen', age="73", born='1937-06-17')
brother = Person(name='Joe', age=45, born='1965-10-11')
child = Sibling(name='Harry', age=42, mother=mother, siblings=[brother, 'sue'])
try:
    child.serialize()
except Exception as e:
    print(child.errors)

# {'born': {'msg': 'Required Field'},
#  'siblings': {'msg': 'Invalid Item(s)', 'errors': {'1': {'msg': 'Field is not a valid Mapping'}}},
#  'mother': {'msg': 'Invalid Schema', 'errors': {'age': {'msg': 'Field is not a valid Integer'}}}}

Note

Sequence fields will use the sequence index (coerced with str) as the error key.

Composition

Schemas can be composed using the __schema_include__ attribute. You can alternatively provide the compose attribute of the schema Meta class, which sets the schema include property for you.

Normal mixins work as well, and so does a dict of fields.

class A(Schema):
    a = fields.String()

class B(Schema):
    b = fields.String()

class C(Schema):
    c = fields.String()

class AB(A, B):
    pass

class ABC(Schema):

    class Meta:
       compose = [AB, C]

class ABCD(ABC):

    __schema_include__ = [{'d': fields.String()}]

Schema composition can be very handy when you have many overlapping fields between schemas, but not all fields apply. For example, let’s define some schemas for some imaginary API responses:

Polymorphic Schemas

Polymorphic schemas let you define a set of derived schema variations. In other words, you can define a base schema that can be mapped to another schema using an identifier that resides on the schema itself.

Let’s examine a small but practical use case for using polymorphism; versioning. Take this basic user schema as an example:

from ciri import PolySchema


class AppUser(PolySchema):

    username = fields.String(required=True)
    email = fields.String(required=True)
    version = fields.String(default='v1')

    __poly_on__ = version  # define the field which will determine the subclass mapping

The __poly_on__ attribute defines which schema field will be used to map subclasses. Notice that we’ve also got the username and email fields. We can now use the AppUser schema to define further variations.

class AppUserV1(AppUser):

    __poly_id__ = 'v1'  # define the polymorphic identifier

    # add additional fields
    first_name = fields.String(required=True)
    last_name = fields.String()
    twitter_url = fields.String(allow_none=True)


class AppUserV2(AppUserV1):

    __poly_id__ = 'v2'  # define the polymorphic identifier

    # add another field
    roles = fields.List(fields.String())

    # modify another field
    last_name = fields.String(required=True)

Above we’ve used the __poly_id__ attribute to define a v1 and v2 schema mapping. Notice how you can continue to subclass the original PolySchema. Now let’s see what makes these mappings so handy.

user_data = {
    'username': 'magic_is_cool',
    'email': 'hpotter@hogwarts.example.com',
    'first_name': 'Harry',
    'last_name': 'Potter',
    'roles': ['student', 'wizard']
}

v1_user = AppUser(version='v1', **user_data)
# <AppUser object at 0x7f4441245e48>

v2_user = AppUser(version='v2', **user_data)
# <AppUser object at 0x7fd127dfae48>

v1_user_again = AppUser.polymorph(version='v1', **user_data)
# <AppUserV1 object at 0x7f64597520b8>

v2_user_again = AppUser().deserialize(v2_user.serialize())
# <AppUserV2 object at 0x7fd127dfaf28>

Notice that each of these interactions utilize the same base polymorphic schema class, but will return different schema instances depending on the usage. This is important to know when performing checks like isinstance, but for most other intents and purposes, they can be considered functionally equivalent.

Here’s a bit of code that may make that more clear:

print(isinstance(v1_user, AppUserV1))
# False

print(isinstance(v1_user_again, AppUserV1))
# True

print(v1_user.serialize() == v1_user_again.serialize())
# True

Fields

Field Type Reference

Class Python Type Notes
String str, unicode Returns the unicode type in python 2.x
Integer int  
Float float  
Dict dict  
Schema dict  
UUID str  
Date str ISO-8601 Date String
DateTime str ISO-8601 Date + Time String