Customization guide¶
Basics¶
It can be useful to know how it all works before writing your custom extensions.
Once a Model subclass is declared and created in the code, it automatically generates
a msgspec.Struct type based on the field data in the Fields
class. Basically, it goes across all the fields there and gathers name, field’s __base_type__ and default values
and defines a structure type from them. The structure itself goes to the model’s __base_type__ class attribute. The code
also creates JSON and Msgpack converters for this structure type and stores them at __decode__ and __decode_msgpack__
class attributes.
Once a model instance receives data in either one of its decode()
or get_struct() methods, it uses the model’s
structure class stored at __base_type__ to validate and load data to a struct object.
The important note here is that python types, model fields and constraints are evaluated on model class initialization and that python types (annotations) you bind to your fields should be compatible with msgspec type specification.
Fields¶
Note
Before writing custom stuff make sure there’s no default one for your task. See fields - model field types for detail on standard field types.
To write a custom field you should import the Field class and inherit from it.
You have to set __base_type__ to a base Python type to be bound to this field and __ui_type__ with a string which
should be displayed as type in the field UI schema.
from uuid import UUID
from kaiju_models import Field
class UUIDField(Field[UUID])
__base_type__ = UUID
__ui_type__ = 'uuid'
Suppose you want to add some custom field settings (attributes) and also display them in the UI schema. The best way is to create a dataclass from your field and also create a get_schema method returning this attribute value as well.
from dataclasses import dataclass
@dataclass(slots=True)
class UUIDField(Field[UUID])
__base_type__ = UUID
__ui_type__ = 'uuid'
version: int = 4
"""UUID version number."""
def get_schema(self):
return {**super().get_schema(), "version": self.version}
In case you wanted to validate the UUID against the version on the client side, you should probably move this attribute from the schema root too the validation section instead:
# better
@dataclass(slots=True)
class UUIDField(Field[UUID])
__base_type__ = UUID
__ui_type__ = 'uuid'
version: int = 4
"""UUID version number."""
def _validation_schema(self):
return {"version": self.version}
Post-validation¶
Suppose you also want to check for a proper UUID version on backend. There’s a problem! Standard type conversion doesn’t care for the UUID version and it won’t raise any error!!! There’s a way to counter that by creating a custom post-validation method for the field by declaring a validate function which accepts an attribute value, so the final example would be like this.
from kaiju_models import ModelValidationError, ErrorData, ErrorCode
@dataclass(slots=True)
class UUIDField(Field[UUID])
__base_type__ = UUID
__ui_type__ = 'uuid'
version: int = 4
"""UUID version number."""
def validate(self, value: UUID, /) -> UUID:
if value.version != self.version:
raise ModelValidationError(
'Invalid UUID version',
data=ErrorData(code=ErrorCode.INVALID_FORMAT))
return value
def _validation_schema(self):
return {"version": self.version}
Note that a validate function receives a value after it has been decoded, so you can be sure that it will be of a proper type (or None if the value is allowed to be optional).
The validate function must also return a value of the same type after it has been validated or raise a
ModelValidationError.
You can use validation functions to normalize values too.
from kaiju_models import StringField
class CapitalizedString(StringField[str])
__base_type__ = str
__ui_type__ = 'string'
def validate(self, value: str, /) -> str:
return value.capitalize()
Although custom validator may be tempting, keep in mind that they may slow down data conversion. Try to stick to standard constraints and types whenever possible.
More complex types¶
Models API uses msgspec types for data conversion and validation
with a variety of supported types. Sometimes a more complex type behavior than just a simple type hint is required.
For example, take a look at the standard ListField source code.
# part of source code skipped
class ListField(Field[list]):
__base_type__ = list
def __init__(self, model: Field, *args, min_length: int = None, max_length: int = None, **kws: Any) -> None:
self.model = model
self.min_length = min_length
self.max_length = max_length
super().__init__(*args, default=[], **kws)
def _get_annotation(self) -> type:
return Annotated[self.__base_type__[self.model.__annotation__], self._get_annotation_meta()]
def _get_annotation_meta(self):
return Meta(
min_length=self.min_length,
max_length=self.max_length,
title=self.title,
description=self.description)
As you can see, ListField uses msgspec meta
to define length constraints in a customized _get_annotation_meta() method. But that’s not all.
It also uses custom _get_annotation() to create an annotation for not only a list but for a list of items of certain type depending on which model was passed to the list. On practice the result would look like this:
class Item(Model['Item.Fields']):
class Fields(Model.Fields):
id: str = StringField()
quantity: int = IntegerField()
class Cart(Model['Cart.Fields']):
class Fields(Model.Fields):
items: list[Item] = ListField(Item(), min_length=1, max_length=10)
The API will automatically create an annotation and constraint for a list of items and add it to the structure.