User’s guide
Declaring models
The most basic model
The most basic model in Modelity is simply subclass of
modelity.base.Model with no fields declared:
from modelity.base import Model
class EmptyModel(Model):
pass
Such declared class has no practical use as an instance, but it is perfectly fine as a base class for other domain-specific models, especially as a place to put application-wide filtering hooks or validators.
Required fields
All fields declared with no extra type modifiers are required by Modelity. For example, this model has all fields required:
from modelity.base import Model
class User(Model):
name: str
email: str
age: int
All required fields must be provided by constructor when model instance is
created or otherwise the constructor will raise
modelity.exc.ParsingError exception:
>>> user = User()
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 3 parsing errors for type 'User':
age:
This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
email:
This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
name:
This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
And the model creation will succeed if all required fields are given:
>>> user = User(name="John Doe", email="jd@example.com", age=32)
>>> user
User(name='John Doe', email='jd@example.com', age=32)
The presence of required fields is additionally checked at the validation
phase. For example, model with age property missing will be invalid and
same error will be reported as for constructor, but just for age field
which we’ve just removed from the model:
>>> from modelity.helpers import validate # This helper is used to run validation on given model
>>> validate(user) # The user is valid
>>> del user.age # We've dropped `age` property...
>>> validate(user) # ...and the user is no longer valid:
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'User':
age:
This field is required [code=modelity.REQUIRED_MISSING]
Deferred fields
Added in version 0.35.0.
Modelity allows to declare fields as deferred which means that the field is
optional when model is being constructed, but must be provided before model is
validated. This allows to fill the model with data progressively with data, for
instance, coming from user prompts. Thanks to this mechanism you don’t have to
use intermediate data structures for storing such data – the model will handle
that for you. To declare fields as deferred use modelity.typing.Deferred
type modifier like in example below:
# Modelity provides an all-in-one import helper; all public names can be
# imported from `modelity.api`.
from modelity.api import (
Model,
Unset,
Deferred,
validate
)
class OrderItem(Model):
name: Deferred[str]
quantity: Deferred[int]
price: Deferred[float]
Creating such declared model now becomes possible without arguments and these
fields will be assigned with special modelity.unset.Unset sentinel:
>>> order = OrderItem()
>>> order
OrderItem(name=Unset, quantity=Unset, price=Unset)
Now the model can be fed with data in steps:
>>> order.name = prompt("Enter item name") # user answers: apple
>>> order
OrderItem(name='apple', quantity=Unset, price=Unset)
Now let’s try to validate the model. To do that we need
modelity.helpers.validate() helper function introduced earlier and call
it on our model:
>>> validate(order)
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 2 validation errors for model 'OrderItem':
price:
This field is required [code=modelity.REQUIRED_MISSING]
quantity:
This field is required [code=modelity.REQUIRED_MISSING]
Validation has failed with modelity.exc.ValidationError as there are
still 2 deferred fields missing in the model. To make validation pass the model
has to be filled in with missing data:
>>> order.quantity = prompt("Enter quantity") # user answers: 2
>>> order.price = prompt("Enter price") # user answers: 1.5
>>> validate(order) # will now pass
Validation stage will be described in more details in guide_validation section.
Optional fields
Modelity allows to declare optional fields using any of the following type modifiers:
Modifier |
Allows |
Can be left unset? |
|---|---|---|
Yes |
No |
|
Yes |
Yes |
|
No |
Yes |
Since Modelity treats None as a first-class value it cannot use it to
represent the unset state of a field. Instead, a dedicated
modelity.unset.Unset sentinel was created and therefore handling
optionality using standard typing.Optional alone may be insufficient
(see the table) as it might cause false positives during typical if
model.field is not None comparisons.
And now let’s take a tour around all modifiers that are used to declare fields as optional.
Using typing.Optional[T]
Allowed values:
objects of type
Tobjects of type
Uthat can be parsed asTNone
Use cases:
non-unsettable optionals; either
TorNone
Example:
from typing import Optional
from modelity.api import Model, validate
class OptionalExample(Model):
foo: Optional[int] = None # IMPORTANT: Optional[T] cannot be unset
>>> model = OptionalExample()
>>> validate(model) # OK
>>> model.foo is None # It was initialized with None
True
>>> model.foo = 123 # OK; 123 is an int
>>> model.foo
123
>>> model.foo = "456" # OK; "456" can be successfully converted to int
>>> model.foo
456
>>> model.foo = None # OK; can be set to None
>>> model.foo is None
True
>>> del model.foo
>>> model.foo # Now this is unset
Unset
>>> validate(model) # FAIL: Optional[T] cannot be unset
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'OptionalExample':
foo:
This field does not allow Unset; expected: Union[int, NoneType] [code=modelity.UNSET_NOT_ALLOWED, expected_type=Union[int, NoneType]]
Using modelity.typing.LooseOptional[T]
Allowed values:
objects of type
Tobjects of type
Uthat can be parsed asTNone
Use cases:
unsettable optionals;
T,NoneorUnset
Example:
from modelity.api import Model, LooseOptional, validate
class LooseOptionalExample(Model):
foo: LooseOptional[int]
>>> model = LooseOptionalExample()
>>> validate(model) # OK
>>> model.foo
Unset
>>> model.foo = 123 # OK
>>> model.foo
123
>>> model.foo = "456" # OK; "456" can be parsed as int
>>> model.foo
456
>>> model.foo = None # OK
>>> model.foo is None
True
>>> validate(model) # OK
>>> del model.foo
>>> model.foo
Unset
>>> validate(model) # OK
Using modelity.typing.StrictOptional[T]
Allowed values:
objects of type
Tobjects of type
Uthat can be parsed asT
Use cases:
for declaring fields that must either be set to
Tor not set at all
Example:
from modelity.api import (
Model, StrictOptional, model_postvalidator, validate, is_unset,
UserError
)
class Response(Model):
"""Strict optional example model.
This shows the practical use case; response object can either have
result or error, never both. A separate user-defined hook is used for
cross-field checks.
"""
result: StrictOptional[dict]
error: StrictOptional[str]
@model_postvalidator()
def _either_result_or_error(self):
if not is_unset(self.result) and not is_unset(self.error):
raise UserError("cannot pass both result and error in the response")
>>> model = Response()
>>> validate(model) # OK
>>> model.result = {"value": 123} # OK
>>> model.error = None # FAIL; StrictOptional[T] forbids None
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'Response':
error:
This field does not allow None; expected: Union[str, UnsetType] [code=modelity.NONE_NOT_ALLOWED, value_type=NoneType, expected_type=Union[str, UnsetType]]
>>> model.error # still unset
Unset
>>> model.error = "an error" # OK; field was set...
>>> validate(model) # FAIL: ...but cross field checks will now fail
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'Response':
(empty):
cannot pass both result and error in the response [code=modelity.USER_ERROR]
>>> del model.error
>>> validate(model) # OK; now only `result` is present
Default values
Using direct assignment
The most basic way of declaring default values for a model field is to declare that field and assign it to a value that will become its default value:
from modelity.api import Model
class DefaultExample(Model):
foo: int = 123
>>> model = DefaultExample() # OK; using 123 as default value for `foo`
>>> model
DefaultExample(foo=123)
>>> another_model = DefaultExample(foo=456) # OK; using 456 value for `foo`
>>> another_model
DefaultExample(foo=456)
Default values are no different than any other input values, so given the
example below if "789" is used as a default then it will be parsed into
integer number during model construction:
from modelity.api import Model
class DefaultExample(Model):
foo: int = "789"
>>> model = DefaultExample()
>>> model
DefaultExample(foo=789)
And if invalid value will be used as a default then creating model without args will fail:
from modelity.api import Model
class InvalidDefaultExample(Model):
foo: int = "not an integer"
>>> model = InvalidDefaultExample()
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'InvalidDefaultExample':
foo:
Not a valid int value [code=modelity.PARSE_ERROR, value_type=str, expected_type=int]
Important
Default values are currently not evaluated when declaring models, so pay attention to default values and their types when declaring those to avoid unexpected errors like the one above.
Mutable default values
In Modelity you can safely use mutable values as field’s defaults, as those are deep copied when model is created. For example, you can set empty list as default value for list field:
from modelity.api import Model
class MutableExample(Model):
foo: list[int] = []
And now, you don’t have to initialize foo with empty list when constructing
models:
>>> model = MutableExample()
>>> model.foo
[]
>>> model.foo.append(123) # Here we append first element
>>> model.foo
[123]
And the original default value is still an empty list:
>>> MutableExample.__model_fields__['foo'].field_info.default # This is how field metadata can be accessed (there is a dedicated section on this topic)
[]
Using field_info helper
Same functionality can be achieved when modelity.base.field_info() helper
is used in place of direct default value assignment. In fact, Modelity
automatically converts fixed default values to modelity.base.FieldInfo
objects unless one is set explicitly using said function. Here’s an example:
from modelity.api import Model, field_info
class DefaultExample(Model):
foo: int = field_info(default=123) # same as `foo: int = 123`
Such solution gives you the possibility of adding more metadata to field than just a default value. There will be more on this in the upcoming chapter.
Computed default values
Modelity also supports declaring default values by assigning factory function
instead of fixed static default. To do this you will also need
modelity.base.field_info() helper like in previous section. This is very
useful to get e.g. the current date and time when the model is created:
import datetime
from modelity.api import Model, field_info
class Order(Model):
items: list[str] = []
created: datetime.datetime = field_info(default_factory=utcnow) # `utcnow` is a no-argument function returning datetime
Now when the model is created, created field will be set to current date
and time in UTC:
>>> model = Order()
>>> model
Order(items=[], created=datetime.datetime(2026, 1, 1, 10, 10))
Attaching metadata to fields
Modelity supports attaching metadata to fields using
modelity.base.field_info() helper. It was already presented earlier and
used to declare default values, but let’s now show some more examples. Consider
this one:
from modelity.api import Model, field_info
class OrderItem(Model):
name: str = field_info(title="Item name", examples=["apple", "banana", "orange"])
quantity: int = field_info(title="Number of items")
price: float = field_info(title="The price of a single unit")
These metadata does not take place in model processing, but can be used as a
source of additional data for external tools, like documentation generators. To
access these metadata, you have to use
modelity.base.ModelMeta.__model_fields__ property:
>>> list(OrderItem.__model_fields__) # list of field names
['name', 'quantity', 'price']
>>> name = OrderItem.__model_fields__["name"] # get `name` field
>>> name.field_info.title
'Item name'
>>> name.field_info.examples
['apple', 'banana', 'orange']
Please proceed to modelity.base.field_info() for more information.
Using typing.Annotated and field-level constraints
Modelity comes with modelity.constraints module containing field-level
constraints that can be attached to fields using typing.Annotated type
modifier. Such declared fields are automatically verified when field is set, or
when model is validated. The latter is crucial for fields with mutable
containers, where modifying container’s content may invalidate the constraint.
Here’s an example:
from typing import Annotated
from modelity.api import Model, MinLen, Gt, Ge
class OrderItem(Model):
name: Annotated[str, MinLen(1)] # empty string is not allowed
quantity: Annotated[int, Gt(0)] # greater than, i.e. > 0
price: Annotated[float, Ge(0)] # greater than or equal to, i.e. >= 0
class Order(Model):
items: Annotated[list[OrderItem], MinLen(1)] # at least one order
Now let’s create first buggy OrderItem and see what’s happening:
>>> buggy = OrderItem(name="", quantity=-1, price=-1.5)
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 3 parsing errors for type 'OrderItem':
name:
Expected length >= 1 [code=modelity.INVALID_LENGTH, value_type=str, min_length=1]
price:
Value must be >= 0 [code=modelity.OUT_OF_RANGE, value_type=float, min_inclusive=0]
quantity:
Value must be > 0 [code=modelity.OUT_OF_RANGE, value_type=int, min_exclusive=0]
As you can see, the constraints are failing, as we’ve intentionally set incorrect values for fields. Let’s now create a valid model and try to set one of its field to an invalid value:
>>> apple = OrderItem(name="apple", quantity=1, price=1.5) # OK
>>> apple.name = "" # FAIL; empty string is not allowed
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'OrderItem':
name:
Expected length >= 1 [code=modelity.INVALID_LENGTH, value_type=str, min_length=1]
The constraints are automatically checked when fields are modified and
modelity.exc.ParsingError reported then gives an instant feedback.
However, if mutable field is modified (not overwritten) then the constrains
will not be re-evaluated:
>>> order = Order(items=[apple]) # OK; initialized with one element
>>> len(order.items)
1
>>> order.items.clear() # Remove all items from the list; NO ERROR!
>>> len(order.items)
0
Why there is no error despite the fact that now the minimum length constraint
is broken? Well, the field itself WAS NOT changed (it still points to the same
list object) therefore constraint check was not triggered. But the model is in
fact invalid now – we can check that using modelity.helpers.validate()
helper function:
>>> from modelity.helpers import validate
>>> validate(order)
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'Order':
items:
Expected length >= 1 [code=modelity.INVALID_LENGTH, min_length=1]
Validating model re-evaluates field-level constraints and that makes the thing working as a whole even for mutable fields. This is one of the reasons why Modelity have separated data processing into two distinct stages.
Using inheritance
In Modelity models are created by inheriting from modelity.base.Model
base class. But every single created model class can be a base class itself and
this can be used to create bases with common fields and/or user-defined hooks.
Consider following example:
from modelity.api import Model, field_postprocessor
class Base(Model):
id: int
@field_postprocessor()
def _strip_string_values(cls, value):
if isinstance(value, str):
return value.strip()
return value
class Author(Base):
first_name: str
last_name: str
class Book(Base):
title: str
author: Author
The example above introduced a modelity.hooks.field_postprocessor() hook
that runs when field is set and after successful type parsing. Since it was
declared without arguments, the hook will be called for every field and if
value is a string it will be stripped.
Note
Check guide_hooks for more information about user-defined hooks.
Since the hook was declared in base class, both Author and Book models
are now automatically using it, as well as the id field that was also
inherited:
>>> author = Author(id=1, first_name=" John", last_name="Doe ") # Leading and trailing spaces will be stripped
>>> book = Book(id=2, author=author, title=" Good Old Book ") # same here
>>> author
Author(id=1, first_name='John', last_name='Doe')
>>> book.title
'Good Old Book'
Having hooks that are performing common cleanup operations on data in one place is a recommended practice according to DRY principle and using inheritance to achieve that is one option.
Using mixins
A more elastic way of reusing common functionality is to use mixins instead or in addition to base classes. Mixins will let you wrap hooks in reusable and named classes that can later be injected to models without breaking or rebuilding entire inheritance tree. And, it is also quite easy to add more mixins if needed.
Important
Only hooks can currently be provided via mixins; fields must still rely on inheritance mechanism.
Let’s rewrite previous example and now let’s extract string stripping hook to a separate SupportsStripping mixin:
from modelity.api import Model, field_postprocessor
class Base(Model): # This is our base model
id: int
class SupportsStripping: # This is our mixin with string stripping hook
@field_postprocessor()
def _strip_string_values(cls, value):
if isinstance(value, str):
return value.strip()
return value
class Author(Base, SupportsStripping): # Use mixin
first_name: str
last_name: str
class Book(Base, SupportsStripping): # Same here
title: str
author: Author
And now, the models behave exactly the same as previously, but are now sharing string striping hook via mixin:
>>> author = Author(id=1, first_name=" John", last_name="Doe ") # Leading and trailing spaces will be stripped
>>> book = Book(id=2, author=author, title=" Good Old Book ") # same here
>>> author
Author(id=1, first_name='John', last_name='Doe')
>>> book.title
'Good Old Book'
Working with model objects
Constructing model objects
Let’s consider following model declaration:
from modelity.api import Model
class OrderItem(Model):
name: str # required
quantity: int = 1 # required, but with default value
price: float # required
To create instance of such model you have to pass field names and corresponding values as arguments for a built-in keyword-only constructor:
>>> apples = OrderItem(name="apple", price=1.5) # sets 'name' and 'apple', 'quantity' uses default value
>>> apples
OrderItem(name='apple', quantity=1, price=1.5)
>>> oranges = OrderItem(name="orange", quantity=2, price=2.5) # override default set for 'quantity'
>>> oranges
OrderItem(name='orange', quantity=2, price=2.5)
If you forget about required fields, Modelity will inform you with following error:
>>> empty = OrderItem() # 'name' and 'price' must be set
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 2 parsing errors for type 'OrderItem':
name:
This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
price:
This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
Customizing model construction
Overloading __init__ method is strongly discouraged in Modelity as it
may break the interface that is expected by Modelity internals. However, if you
really need to do so, here’s a safe boilerplate example:
from modelity.api import Model
class OrderItem(Model):
name: str
quantity: int
price: float
def __init__(self, **kwargs): # IMPORTANT!
if "quantity" not in kwargs: # before "real" init
kwargs["quantity"] = 1
super().__init__(**kwargs) # IMPORTANT!
if self.price < 0: # after "real" init
self.price = 0
In general, the rule of thumb is to keep it keyword-only and to always call base class constructor. Now let’s check how this works:
>>> bananas = OrderItem(name="banana", price=-2.0)
>>> bananas
OrderItem(name='banana', quantity=1, price=0.0)
A much better and recommended solution is to create per-model factory method that has its own, user-defined interface and is not part of Modelity internals. For example, you may want to create models using positional arguments. Here’s an example of such factory method:
from modelity.api import Model
class OrderItem(Model):
name: str
quantity: int
price: float
@classmethod
def create(cls, name: str, price: float, quantity: int=1) -> "OrderItem":
return cls(name=name, quantity=quantity, price=price)
Besides allowing to pass arguments in either keyword or positional way the custom method can also reorder arguments and set some defaults. Underneath the method is still calling built-in constructor. Here’s how this can be used:
>>> onions = OrderItem.create("onion", 0.75, quantity=3)
>>> onions
OrderItem(name='onion', quantity=3, price=0.75)
Unset fields
Modelity handles unset fields using special modelity.unset.Unset
sentinel, which is a singleton instance of modelity.unset.UnsetType
class. This special value is used by Modelity to explicitly represent fields
that were not set in the constructor or fields that were removed from the model
after it was created.
All non-required and unsettable fields are implicitly unset if no other default value was given:
from modelity.api import Model, StrictOptional
class Example(Model):
foo: StrictOptional[int]
bar: StrictOptional[str]
>>> model = Example() # No argument given
>>> model.foo
Unset
>>> model.foo
Unset
However, it is usually better to explicitly set those fields to Unset,
as it plays better with code linters and type checking tools (i.e. no warnings
about missing required params):
from modelity.api import Model, StrictOptional, Unset
class Example(Model):
foo: StrictOptional[int] = Unset # Same behavior as before, but explicit
bar: StrictOptional[str] = Unset
Modelity provides a modelity.unset.is_unset() helper that can be used to
check if the field is set or not. It is recommended to use this helper instead
of manually checking if model.field is not Unset as it performs type
narrowing, so LSPs will automatically know the remaining types:
>>> from modelity.api import is_unset
>>> model = Example()
>>> is_unset(model.foo)
True
>>> model.foo = 123
>>> is_unset(model.foo) # not unset; the LSP will know that `foo` is an integer
False
Inspecting models using repr function
All models provide default implementation of object.__repr__() magic
method that will print text representation of the model. The representation
includes model class name and its field names with current values. For example:
from modelity.api import Model, Unset, LooseOptional
class Dummy(Model):
foo: int
bar: Optional[str] = None
baz: LooseOptional[float] = Unset
>>> repr(Dummy(foo=123))
'Dummy(foo=123, bar=None, baz=Unset)'
>>> repr(Dummy(foo=123, baz=3.14))
'Dummy(foo=123, bar=None, baz=3.14)'
Comparing two model instances
Modelity provides built-in implementation of the object.__eq__() method
for checking if two Python objects are equal. Two model objects, a and
b, are equal if and only if all of these statements are true:
both objects are instances of same model type,
both objects have exactly same fields set,
all fields that are set are equal.
Some examples:
from modelity.api import Model, LooseOptional
class Foo(Model):
spam: LooseOptional[int]
class Bar(Model):
spam: LooseOptional[int]
>>> Foo() == Foo()
True
>>> Foo(spam=123) == Foo(spam=123)
True
>>> Foo() == Bar() # Two different types
False
>>> Foo(spam=123) == Foo() # Different amount of fields set
False
>>> Foo(spam=123) == Foo(spam=456) # Different field values
False
Checking if field is set
To check if a field is set, in operator can be used:
from modelity.api import Model
class Dummy(Model):
a: LooseOptional[int]
b: LooseOptional[int]
>>> foo = Dummy()
>>> "a" in foo
False
>>> foo.a = 123
>>> "a" in foo
True
>>> foo.a = None # IMPORTANT: None is a first class value!
>>> "a" in foo
True
>>> del foo.a
>>> "a" in foo
False
Important
In Modelity, unset fields are all fields that are set to
modelity.unset.Unset object, so assigning a field with Unset
manually is basically equivalent of using del operator:
>>> from modelity.unset import Unset
>>> bar = Dummy(a=123)
>>> "a" in bar
True
>>> bar.a = Unset
>>> "a" in bar
False
Iteration over set fields only
Modelity supplies models with object.__iter__() method implementation
that iterates through the model, in field-defined order, and yields names of
fields that are currently set:
from modelity.api import Model, LooseOptional
class IterExample(Model):
a: int
b: int
c: LooseOptional[int]
d: LooseOptional[int]
>>> one = IterExample(a=1, b=2)
>>> list(one)
['a', 'b']
>>> two = IterExample(a=1, b=2, c=3)
>>> list(two)
['a', 'b', 'c']
As a bonus, there also is a helper modelity.helpers.has_fields_set() that
can be used to check if any of model fields is set:
>>> from modelity.api import has_fields_set
>>> has_fields_set(one)
True
>>> has_fields_set(two)
True
>>> one.a = one.b = Unset # NOTE: Presence of required fields is double-checked during later validation
>>> has_fields_set(one)
False
Setting and getting attributes
Since models in Modelity are mutable by design, assigning value to a field of an existing model object invokes exactly the same value parsing logic as when model constructor is used. Here are some examples:
from modelity.api import Model, LooseOptional
class SetGetDelExample(Model):
foo: LooseOptional[int]
>>> model = SetGetDelExample() # OK; no required fields
>>> model.foo # getting attribute; it is Unset now
Unset
>>> model.foo = 123 # setting attribute; parsing logic is executed
>>> model.foo
123
>>> model.foo = "spam" # FAIL; neither int, nor it can be parsed as int
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'SetGetDelExample':
foo:
Not a valid int value [code=modelity.PARSE_ERROR, value_type=str, expected_type=int]
>>> model.foo # old value remains
123
Deleting attributes
Attributes can be deleted from model object, but this is just a syntactic sugar
over assignment of an attribute with modelity.unset.Unset value. Any
model field can be deleted from the model after successful construction. This
is safe for as long as the model is later (re-)validated with
modelity.helpers.validate() function. For example:
from modelity.api import Model, validate
class Dummy(Model):
foo: int
>>> dummy = Dummy(foo=123)
>>> dummy.foo
123
>>> del dummy.foo
>>> dummy.foo
Unset
>>> validate(dummy) # FAIL; we've just deleted required field
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'Dummy':
foo:
This field is required [code=modelity.REQUIRED_MISSING]
>>> dummy.foo = 456
>>> dummy.foo
456
>>> validate(dummy) # OK
Applying visitors to models
One of Modelity design decisions was to use visitor pattern to separate model structure from algorithms operating on that structure.
Modelity provides few built-in visitor implementations (for validation and
serialization) available in modelity.visitors module, an ABC
modelity.base.ModelVisitor for creating custom ones from a scratch,
and a modelity.base.Model.accept() method for applying visitors on
models.
There is also a modelity.helpers module containing helpers for hiding
boilerplate code (e.g. modelity.helpers.dump() for serialization or
modelity.helpers.validate() for validation) but you still can instantiate
and run visitors manually whenever needed.
Here’s a simple example:
from modelity.api import Model, Loc
class OrderItem(Model):
name: str
quantity: int
price: float
class Order(Model):
id: int
items: list[OrderItem]
Now let’s create some order object:
>>> apple = OrderItem(name="apple", quantity=1, price=2.5)
>>> banana = OrderItem(name="banana", quantity=2, price=1.5)
>>> orange = OrderItem(name="orange", quantity=4, price=0.75)
>>> order = Order(id=1, items=[apple, banana, orange])
And finally, let’s dump it to dict using modelity.visitors.DumpVisitor
visitor:
>>> from modelity.api import DumpVisitor
>>> out = {}
>>> visitor = DumpVisitor(out)
>>> order.accept(visitor, Loc())
>>> out
{'id': 1, 'items': [{'name': 'apple', 'quantity': 1, 'price': 2.5}, {'name': 'banana', 'quantity': 2, 'price': 1.5}, {'name': 'orange', 'quantity': 4, 'price': 0.75}]}
Data processing pipeline
Input data parsing
Data parsing happens when a new model instance is created, when fields in existing models are modified, or when mutable containers are modified. This stage is executed on a field-level basis and its role is to ensure that data stored in the model respects model field types, or to reject the input data completely if it does not.
Failed parsing is signalled with modelity.exc.ParsingError exception
that is raised with all parsing errors collected for all fields.
Preprocessing chain
Preprocessing is an optional first step of data parsing that happens for an
individual fields if those have user-defined preprocessors assigned via
dedicated modelity.hooks.field_preprocessor() decorator. This decorator
can be declared for all fields, or for a given subset of fields depending on
whether or not and which field names are given during declaration.
Preprocessors can report errors either by raising
modelity.exc.UserError, or by manually creating
modelity.error.Error object and adding it to the errors list (see
hook modelity.hooks.field_preprocessor() for more details on this). If
any of preprocessors report one or more errors, the parsing as a whole will
fail.
Preprocessors are used to clean up and normalize the data before it is passed further to the type parsing step. The very common use case is to restrict input data just to a subset of input types (i.e. only JSON-compatible ones) or to perform actions like string stripping to remove whitespace characters.
The data flow for preprocessing chain looks as follows:
(raw input) ->
[1st preprocessor -> [2nd preprocessor -> ... -> N-th preprocessor ->]]
(preprocessed output)
Here’s an example:
from modelity.exc import UserError
from modelity.base import Model
from modelity.hooks import field_preprocessor
class JsonRestrictingModel(Model):
"""Base class for models allowing only JSON-compatible input values."""
@field_preprocessor() # Will run for all fields
def _restrict_input(cls, value):
if value is not None and not isinstance(value, (int, float, str, bool, list, dict)):
raise UserError("non JSON-compatible value") # Will terminate parsing
return value
class OrderItem(JsonRestrictingModel):
name: str
quantity: int
price: float
@field_preprocessor("name", "quantity", "price") # Fields for which the hook will run
def _strip_strings(cls, value):
if isinstance(value, str):
return value.strip() # call strip() only if input value is a string
return value
>>> apples = OrderItem(name=" apple ", quantity=" 2 ", price=" 3.25 ") # This would fail without '_strip_strings' preprocessor
>>> apples
OrderItem(name='apple', quantity=2, price=3.25)
>>> apples.name = object()
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'OrderItem':
name:
non JSON-compatible value [code=modelity.USER_ERROR, value_type=object]
Type parsing
Type parsing runs in a field-level scope, individually for each field, on the preprocessed data delivered by the last preprocessor set for a field being currently parsed, or for raw input data if no preprocessors were found.
Parsing step runs the core logic of Modelity built-in parsing system and
ensures that all fields of a model have the right value (i.e. instance of type
set for that field), or otherwise tries to parse input as the type field
expects. Finally, if input value is neither instance of the right type, nor it
can be parsed as one, parsing step fails and modelity.error.Error is
reported with precise cause and modelity.exc.ParsingError is raised.
The data flow for type parsing looks as follows:
(preprocessed output) -> type parser -> (parsed output)
Although we’ve already seen that in action, let’s have some more examples as a recap:
from modelity.base import Model
class OrderItem(Model):
name: str
quantity: int
price: float
class Order(Model):
items: list[OrderItem]
Now, let’s take a look at the following example:
>>> apples = OrderItem(name="apple", quantity=3, price=1.5) # The right types
>>> apples
OrderItem(name='apple', quantity=3, price=1.5)
>>> oranges = OrderItem(name="orange", quantity="3", price="1.5") # Not the right types, but successfully parsed
>>> oranges
OrderItem(name='orange', quantity=3, price=1.5)
>>> incorrect = OrderItem(name="incorrect", quantity="three", price="one and the half") # FAIL: Cannot parse
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 2 parsing errors for type 'OrderItem':
price:
Not a valid float value [code=modelity.PARSE_ERROR, value_type=str, expected_type=float]
quantity:
Not a valid int value [code=modelity.PARSE_ERROR, value_type=str, expected_type=int]
First object, apples, was created using the exact value types model
expects. Second object, oranges, was also created successfully, but
original input given as string was parsed to the right type. Third example,
incorrect, has failed as Modelity could not parse given strings as integer
and float numbers.
Same logic happens when existing models are modified:
>>> apples.quantity = "4" # OK; this can be parsed
>>> apples.quantity
4
>>> oranges.price = 1 # OK; int can be parsed as float
>>> oranges.price
1.0
>>> oranges.quantity = "four" # FAIL; cannot parse `four` as int
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'OrderItem':
quantity:
Not a valid int value [code=modelity.PARSE_ERROR, value_type=str, expected_type=int]
>>> oranges.quantity # The old value remains intact
3
Now let’s take a look at few more examples showing how parsing of the container types work. Parsing containers involves both parsing container itself, and each individual item:
OK; all elements already have the right type
>>> first = Order(items=[apples, oranges]) >>> first.items[0] is apples True >>> first.items[1] is oranges True
OK;
dictcan be parsed as OrderItem if all required fields are present and all fields have the right values:>>> second = Order(items=[{"name": "strawberry", "quantity": "7", "price": "3.5"}]) >>> second.items[0] OrderItem(name='strawberry', quantity=7, price=3.5)
FAIL; cannot parse
intvalue as alistcontainer>>> fail1 = Order(items=123) Traceback (most recent call last): ... modelity.exc.ParsingError: Found 1 parsing error for type 'Order': items: Not a valid value; expected: list[OrderItem] [code=modelity.INVALID_TYPE, value_type=int, expected_types=[list[OrderItem]], allowed_types=[Sequence], forbidden_types=[str, bytes]]
FAIL; cannot parse
intas OrderItem>>> fail2 = Order(items=[apples, oranges, 123]) # FAIL; cannot parse 123 as OrderItem Traceback (most recent call last): ... modelity.exc.ParsingError: Found 1 parsing error for type 'Order': items.2: Not a valid value; expected: OrderItem [code=modelity.INVALID_TYPE, value_type=int, expected_types=[OrderItem], allowed_types=[Mapping]]
FAIL; empty dict does not contain all fields required by OrderItem model:
>>> fail3 = Order(items=[apples, oranges, {}]) # FAIL; the dict does not have all required fields set Traceback (most recent call last): ... modelity.exc.ParsingError: Found 3 parsing errors for type 'Order': items.2.name: This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType] items.2.price: This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType] items.2.quantity: This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
Postprocessing chain
Postprocessing is an optional last step of data parsing that happens for an
individual fields if those have user-defined postprocessors assigned via
dedicated modelity.hooks.field_postprocessor() decorator. This decorator
can be declared for all fields, or for a given subset of fields depending on
whether or not and which field names are given during declaration.
Postprocessors can report errors either by raising
modelity.exc.UserError, or by manually creating
modelity.error.Error object and adding it to the errors list (see
hook modelity.hooks.field_postprocessor() for more details on this). If
any of postprocessors report one or more errors, the parsing as a whole will
fail.
Postprocessors receive data from type parser and can assume that input value for the first preprocessor already has the right type (there is no need to check that). The role of postprocessors is to perform field-level validation, data normalization that does not affect field’s type, or both. The value returned by the last postprocessor in the chain will be used as field’s value.
The data flow for postprocessing chain looks as follows:
(parsed output) ->
[1st postprocessor -> [2nd postprocessor -> ... -> N-th postprocessor ->]]
(field value)
Important
There is no more type checking after preprocessing step, so preprocessors can potentially alter field type without being noticed. This is fine for as long as the new type is compatible with field’s type (e.g. is a subclass), but SHOULD BE avoided for incompatible types as those will break the contract enforced by the model.
Here’s a practical example of using postprocessing hook:
import math
from modelity.base import Model
from modelity.hooks import field_postprocessor
class Vec2D(Model):
"""Model representing a 2-dimensional vector."""
x: float
y: float
def length(self):
"""Compute vector's length."""
return math.sqrt(self.x**2 + self.y**2)
def normalized(self) -> "Vec2D":
"""Compute normalized version of this vector."""
l = self.length()
return Vec2D(x=self.x/l, y=self.y/l)
class Object2D(Model):
"""Model representing 2-dimensional object."""
pos: Vec2D
dir: Vec2D
speed: float
@field_postprocessor("dir") # Applied only to `dir` vector
def _normalize_direction(cls, value: Vec2D):
return value.normalized() # `value` is guaranteed to be Vec2D
>>> p = Vec2D(x=1, y=3) # position vector
>>> d = Vec2D(x=5, y=5) # direction vector
>>> obj = Object2D(pos=p, dir=d, speed=0.75) # here the postprocessor will be used on `d`
>>> obj.pos is p # This one was not modified by postprocessor
True
>>> obj.dir is not d # This one was normalized by postprocessor
True
>>> obj.dir
Vec2D(x=0.7071067811865475, y=0.7071067811865475)
Adding missing or derived data to models
Using after_field_set hook
Modelity provides modelity.hooks.after_field_set() hook that can be used
to wrap a function to be executed when a field is set to a valid value. The
hook can be triggered by any model field (if declared without args) or with any
of the given fields (otherwise).
Here’s an example:
import datetime
from modelity.base import Model
from modelity.loc import Loc
from modelity.typing import Deferred
from modelity.hooks import after_field_set
class FileInfo(Model):
path: str
size: int
created: datetime.datetime
modified: Deferred[datetime.datetime] # Make it deferred, so it will not be required during construction
@after_field_set("path", "size", "created")
def _reset_modified(self, loc: Loc, value: datetime.datetime):
if loc[-1] == "created":
self.modified = value # Modified shouldn't precede `created`
else:
self.modified = datetime.datetime.now() # Update modification time when `path` or `size` is changed
>>> foo = FileInfo(path="/foo.txt", size=1024, created=datetime.datetime.now()) # Will also set `modified`
>>> foo.created == foo.modified
True
And same will happen if created is later modified:
>>> foo.created = datetime.datetime(1999, 1, 1, 10, 10)
>>> foo.modified
datetime.datetime(1999, 1, 1, 10, 10)
And if parsing of created field fails, the hook will not be executed:
>>> foo.created = "invalid"
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 1 parsing error for type 'FileInfo':
created:
Not a valid datetime format; expected one of: YYYY-MM-DDThh:mm:ssZZZZ, YYYY-MM-DDThh:mm:ss.ffffffZZZZ, YYYY-MM-DDThh:mm:ss, YYYY-MM-DDThh:mm:ss.ffffff, YYYY-MM-DD hh:mm:ss ZZZZ, YYYY-MM-DD hh:mm:ss.ffffff ZZZZ, YYYY-MM-DD hh:mm:ss, YYYY-MM-DD hh:mm:ss.ffffff, YYYYMMDDThhmmssZZZZ, YYYYMMDDThhmmss.ffffffZZZZ, YYYYMMDDThhmmss, YYYYMMDDThhmmss.ffffff, YYYYMMDDhhmmssZZZZ, YYYYMMDDhhmmss.ffffffZZZZ, YYYYMMDDhhmmss, YYYYMMDDhhmmss.ffffff [code=modelity.INVALID_DATETIME_FORMAT, value_type=str, expected_formats=['YYYY-MM-DDThh:mm:ssZZZZ', 'YYYY-MM-DDThh:mm:ss.ffffffZZZZ', 'YYYY-MM-DDThh:mm:ss', 'YYYY-MM-DDThh:mm:ss.ffffff', 'YYYY-MM-DD hh:mm:ss ZZZZ', 'YYYY-MM-DD hh:mm:ss.ffffff ZZZZ', 'YYYY-MM-DD hh:mm:ss', 'YYYY-MM-DD hh:mm:ss.ffffff', 'YYYYMMDDThhmmssZZZZ', 'YYYYMMDDThhmmss.ffffffZZZZ', 'YYYYMMDDThhmmss', 'YYYYMMDDThhmmss.ffffff', 'YYYYMMDDhhmmssZZZZ', 'YYYYMMDDhhmmss.ffffffZZZZ', 'YYYYMMDDhhmmss', 'YYYYMMDDhhmmss.ffffff']]
>>> foo.modified == foo.created
True
>>> foo.modified
datetime.datetime(1999, 1, 1, 10, 10)
And also, when any of the fields is changed, model will automatically update
modified time:
>>> foo.modified == foo.created
True
>>> foo.path = "/bar.txt" # Will trigger the hook and update modification time
>>> foo.modified == foo.created
False
You can of course add some additional conditions, like setting the value only if it is unset.
Important
This hook must be properly configured to avoid recursion errors. In the
example above, modified field was intentionally ignored as changing it
would also cause the hook to fire, and that would cause modified to be
altered… causing infinite recursion error.
Using model_fixup hook
The modelity.hooks.after_field_set() hook shown earlier will not be
called when modifying mutable fields in-place. To overcome this obstacle, a
dedicated modelity.hooks.model_fixup() hook is also provided. It operates
at the model level and can only be run if modelity.helpers.fixup() helper
is explicitly called on a model.
Here’s an example:
from modelity.base import Model
from modelity.hooks import model_fixup
from modelity.helpers import fixup
class OrderItem(Model):
name: str
quantity: int
price: float
class Order(Model):
items: list[OrderItem] = []
total: float = 0.0
@model_fixup()
def _adjust_total(self):
self.total = sum(x.quantity * x.price for x in self.items)
>>> apples = OrderItem(name="apple", quantity=2, price=1.5)
>>> oranges = OrderItem(name="orange", quantity=3, price=2.0)
>>> order = Order()
>>> order.total
0.0
>>> order.items.append(apples)
>>> order.items.append(oranges)
>>> order.total # No change
0.0
>>> fixup(order) # Here the `_adjust_total` will be called
>>> order.total
9.0
The mechanism of model fixing up is backed up with a dedicated visitor (see
modelity.visitors.FixupVisitor) and that brings the possibility
of running all model fixup hooks for the entire model tree using just a single
call to modelity.helpers.fixup() function for the root model only. For
example, let’s now create a collection of orders:
from modelity.base import Model
class UserOrders(Model):
login: str
orders: list[Order] = []
total: float = 0.0
@model_fixup()
def _adjust_user_total(self):
self.total = sum(x.total for x in self.orders)
>>> apples = OrderItem(name="apple", quantity=2, price=1.5)
>>> oranges = OrderItem(name="orange", quantity=3, price=2.0)
>>> order = Order()
>>> order.items.append(apples)
>>> order.items.append(oranges)
>>> user_orders = UserOrders(login="john.doe")
>>> user_orders.orders.append(order)
>>> fixup(user_orders) # Will run both `_adjust_total` for Order model, and `_adjust_user_total` for UserOrders model
>>> user_orders.total
9.0
Validating models
Modelity does not validate models automatically, but instead lets you decide if
and when the models will be validated. Recommended way of validating models is
to use modelity.helpers.validate() helper. Alternatively, if you need more
fine-tuned customization, you can subclass
modelity.visitors.ValidationVisitor built-in default validation visitor
(the one used by helper) or even to create your own validation visitor from
scratch (for advanced users).
Errors reported during validation step are all collected and raised as a single
modelity.exc.ValidationError exception. This is different from
modelity.exc.ParsingError used during parsing, so you can
differentiate errors raised at parsing step from the errors raised during
validation. This may be useful especially when loading complete models from
untrusted external data.
Unlike parsing step, where single error is causing instant full stop for a
processed field, all validators are always allowed to execute even if there
already are errors reported (with exception of
modelity.hooks.model_prevalidator() that can be used to skip remaining
validators for a model it was declared in).
Note
Both exceptions inherit from same modelity.exc.ModelError, so you
can catch that if you don’t care at what phase the exception was raised.
Built-in validation, like input data parsing, is divided into user-pluggable steps that will be explained in details in the next sections.
Important
Modelity assumes that validators have no side effects. In other words, validators must not modify model being validated in any way. Models are read only from the validators point of view. Any modifications must be done before validation either with manual field setting, or by using fixup hooks (see Adding missing or derived data to models for more details). The only dynamic data allowed to be modified (if really needed) is user-defined context object passed to validators. See Validating with user-defined context for more details.
Model prevalidation chain
Model-level prevalidators can be declared using
modelity.hooks.model_prevalidator() hook. Model prevalidators are
executed before any other validators (even built-in ones like check of required
fields presence) and can be used to perform cross-field validation with
optional skipping of other validators (including built-in validators) for the
current model and all its nested models (if it has any). There can be multiple
model prevalidators defined and in such case all are executed in their
declaration order.
Here’s a practical example of using model prevalidator to conditionally skip validation of blog post if it is a draft:
from typing import Literal
from modelity.api import Model, UserError, Deferred, model_prevalidator, validate
class BlogPost(Model):
title: Deferred[str] # We will be filling these fields progressively
content: Deferred[str]
tags: list[str] = []
status: Deferred[Literal["draft", "published"]] = "draft" # By default it is a draft
@model_prevalidator()
def _check_draft_status(self, ctx):
if self.status == "draft":
# In draft mode, skip all other validations
return True
# Otherwise, proceed with normal validation
return
>>> post = BlogPost(title="A story to tell") # Just a title, no content yet
>>> post.status
'draft'
>>> validate(post) # OK; validation skipped, as `post` is a draft
>>> post.status = "published" # Let's now try to publish our post
>>> validate(post) # FAIL; content is still missing
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'BlogPost':
content:
This field is required [code=modelity.REQUIRED_MISSING]
>>> post.content = "A long, long time ago..."
>>> validate(post) # OK
This example demonstrates how model prevalidator can be used to implement conditional validation logic, such as allowing incomplete data in draft mode while enforcing strict validation for published content.
Built-in validation
When model prevalidators are done, then built-in validation runs on a per-field
basis. Built-in validation ensures that all required fields are present (both
construction-time required, and validation-time required), prevents
non-unsettable fields (like typing.Optional) from being left unset, and
re-runs field-level constraints declared using typing.Annotated type
wrapper with constraints from modelity.constraints module.
Here’s a brief example:
from typing import Annotated, Optional
from modelity.api import Model, Deferred, MinLen, validate
class DummyModel(Model):
foo: int # construction-time required
bar: Deferred[int] # validation-time required
baz: Optional[str] = None # equivalent of Union[str, None]; `Unset` is not a valid value
spam: Annotated[list, MinLen(1)] = [1] # can be modified after creation, so it must be re-checked
>>> model = DummyModel(foo=123) # OK; all required fields set
>>> model
DummyModel(foo=123, bar=Unset, baz=None, spam=[1])
>>> validate(model) # FAIL; deferred field `bar` is missing
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'DummyModel':
bar:
This field is required [code=modelity.REQUIRED_MISSING]
>>> model.bar = 456
>>> validate(model) # OK
>>> del model.baz # make `baz` Unset
>>> validate(model) # FAIL; `baz` cannot be unset
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'DummyModel':
baz:
This field does not allow Unset; expected: Union[str, NoneType] [code=modelity.UNSET_NOT_ALLOWED, expected_type=Union[str, NoneType]]
>>> model.baz = "baz" # Set a value to `baz` field
>>> validate(model) # OK
>>> model.spam.clear() # Clear mutable field
>>> model.spam
[]
>>> validate(model) # FAIL; MinLen(1) constraint failed
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'DummyModel':
spam:
Expected length >= 1 [code=modelity.INVALID_LENGTH, min_length=1]
Field validation chain
Field-level validation chain can be configured for each field individually and
is executed only if the field has value set. To declare field-level
validator, you have to use modelity.hooks.field_validator() hook. Field
validators are best way to implement cross-field validation logic, where
validated field depends on one or more other fields. For example:
from modelity.api import Model, field_validator
class RegistrationForm(Model):
username: str
password: str
repeated_password: str
@field_validator("repeated_password")
def _check_if_same_as_password(self, value):
if value != self.password:
raise UserError("passwords do not match")
>>> from modelity.api import validate
>>> form = RegistrationForm(username="john.doe", password="p@ssw0rd", repeated_password="passw0rd")
>>> validate(form) # FAIL; passwords don't match
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'RegistrationForm':
repeated_password:
passwords do not match [code=modelity.USER_ERROR]
>>> form.repeated_password = "p@ssw0rd"
>>> validate(form) # OK
Location-based validation chain
Location-based validation kind of extends field-level validation explained in
previous section. It allows to validate based on location patterns, not just
field names. Thanks to this you can declare validator that belongs to the model
while reaching into nested structures. To declare location-based validation,
modelity.hooks.location_validator() hook must be used.
Location validators use period-separated paths relative to the model where validator was declared. Each path element can either be a field name, a key in a mapping, and index in sequence, or one of the following wildcards:
*to match any number of path segments,?to match exactly one location segment.
For example:
from modelity.api import Model, UserError, location_validator
class Address(Model):
street: str
city: str
zip_code: str
class Person(Model):
name: str
home_address: Address
work_address: Address
@location_validator("?.zip_code")
def _validate_zip_code(self, value):
if not value.isdigit() or len(value) != 5:
raise UserError("invalid zip code")
>>> from modelity.api import validate
>>> person = Person(
... name="John",
... home_address={"street": "123 Main St", "city": "Anytown", "zip_code": "12345"},
... work_address={"street": "456 Office Rd", "city": "Anytown", "zip_code": "abcde"}
... )
>>> validate(person) # FAIL; invalid zip code in work_address
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'Person':
work_address.zip_code:
invalid zip code [code=modelity.USER_ERROR]
>>> person.work_address.zip_code = "67890"
>>> validate(person) # OK
Important
Location validators are evaluated at runtime, so the cost of using those
may be higher than other validators. It is usually better to use other
validators (f.e. modelity.hooks.field_validator()) if same results
can be achieved.
Model postvalidation chain
Model-wide postvalidation is the final step of validation for a given model
instance. To declare postvalidator for a given model,
modelity.hooks.model_postvalidator() hook must be used. It is recommended
to use postvalidators as a default choice to attach model-wide validation to
models, unless you need validation skipping functionality.
Here’s an example:
from modelity.api import Model, UserError, model_postvalidator
class Person(Model):
name: str
age: int
@model_postvalidator()
def validate_age(self):
if self.age < 0:
raise UserError("Age cannot be negative")
>>> from modelity.api import validate
>>> person = Person(name="John", age=-5)
>>> validate(person)
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'Person':
(empty):
Age cannot be negative [code=modelity.USER_ERROR]
>>> person.age = 30
>>> validate(person) # OK
Advanced validation
Validating with user-defined context
Modelity allows you to pass context object of your choice to validation chain and access it from inside your validators. Thanks to this it is possible to choose different validation strategies depending on the use case. For example, you can use this feature to completely disable validation for models fetched from a trusted source.
Since you need to call modelity.helpers.validate() at some point from
your application’s code, you are also capable of passing your own context
object via ctx argument and access it from validators using the same
argument name. For Modelity, the context is completely transparent and it is
simply passed to all validators “as is” without any modifications.
For example:
from typing import Optional
from modelity.api import Model, model_prevalidator, field_validator, UserError
class User(Model):
name: str
@model_prevalidator()
def _prevalidate(self, ctx: Optional[dict]):
if ctx and ctx.get('trusted_source'):
return True # Skip other validators for this model if this is a trusted source
@field_validator("name")
def _validate_name(self, value):
if len(value) < 3:
raise UserError("Name must be at least 3 characters")
>>> from modelity.api import validate
>>> user = User(name="Jo")
>>> validate(user)
Traceback (most recent call last):
...
modelity.exc.ValidationError: ...
>>> user = User(name="Jo")
>>> validate(user, ctx={'trusted_source': True})
>>> user.name
'Jo'
You can also create a mixin or a base class for an easier reuse of such mechanism for any model:
from typing import Optional
from modelity.api import Model, model_prevalidator, field_validator, UserError
class TrustedSkips: # Mixin declaration
@model_prevalidator()
def _prevalidate(self, ctx: Optional[dict]):
if ctx and ctx.get('trusted_source'):
return True # Skip other validators for this model if this is a trusted source
class User(Model, TrustedSkips): # Mixin use
name: str
@field_validator("name")
def _validate_name(self, value):
if len(value) < 3:
raise UserError("Name must be at least 3 characters")
>>> from modelity.api import validate
>>> user = User(name="")
>>> validate(user) # FAIL; validation runs normally
Traceback (most recent call last):
...
modelity.exc.ValidationError: Found 1 validation error for model 'User':
name:
Name must be at least 3 characters [code=modelity.USER_ERROR]
>>> ctx = {"trusted_source": True}
>>> validate(user, ctx=ctx) # PASS; validation skipped
All validation hooks have access to context object via ctx argument. See
modelity.hooks for more information.
Accessing entire model tree during validation
Each validator can access root model via dedicated root argument. The root
model is the one for which modelity.helpers.validate() was originally
called and is passed to all validators, including ones in nested models. This
gives the possibility to access any part of the model tree from any validator
and it allows to perform complex cross-field, or cross-model verifications at
the cost of making nested models aware of where those are used in.
Here’s an example:
from modelity.api import Model, UserError, field_validator, ValidationError
class Address(Model):
city: str
postal_code: str
@field_validator("postal_code")
def _validate_postal_code(self, root: Model, value: str):
# Access root model to validate based on other fields
if isinstance(root, User) and root.country == "US":
if not value.isdigit() or len(value) != 5:
raise UserError("US postal code must be 5 digits")
class User(Model):
name: str
country: str
address: Address
>>> user = User(name="John", country="US", address=Address(city="NYC", postal_code="1000X"))
>>> from modelity.api import validate
>>> validate(user)
Traceback (most recent call last):
...
modelity.exc.ValidationError: ...
>>> user = User(name="John", country="US", address=Address(city="NYC", postal_code="10001"))
>>> validate(user)
>>> user.address.postal_code
'10001'
Tip
Similar behavior can be achieved from the root model when
modelity.hooks.location_validator() is used. However, the cost of
using location validators is higher as those are dynamically matched to
model locations during validation and cannot be precomputed.
Registering custom types
Introducing type handlers
Modelity implements parsing mechanism via so called type handlers. These
are subclasses of modelity.base.TypeHandler abstract base class that
need to implement two mandatory methods:
parse(with type parsing logic),and
accept(with visitor accepting logic).
This mechanism is used by Modelity during compilation of type annotations which
is done shortly after model declaration ends (not in runtime!). Modelity does
that in recursive way. For example, for list[int] type following actions
take place to build needed type handler:
Find type handler for
listtype.Find type handler for
inttype.Build
list[int]type handler from handlers found in previous steps.Use created type handler for field annotated with
list[int].
If needed type could not be found, modelity.exc.UnsupportedTypeError
exception is raised, preventing the model type from being successfully created
and giving instant feedback for the user.
First custom type handler
Let’s assume we have following type defined:
import dataclasses
@dataclasses.dataclass
class Vec2D:
x: float
y: float
Let’s now try to use it as a type of Modelity model:
>>> from modelity.api import Model
>>> class Object(Model):
... position: Vec2D
... direction: Vec2D
Traceback (most recent call last):
...
modelity.exc.UnsupportedTypeError: unsupported type used: <class 'Vec2D'>
As you can see, Modelity cannot handle our new type out of the box and it
reports modelity.exc.UnsupportedTypeError exception when model is
declared. To fix that, we need to create type handler for our Vec2D type
introduced earlier. This is a simplified version of how such type handler could
look like:
from typing import Any
from modelity.api import TypeHandler, Loc, Error, ModelVisitor, ErrorFactory, Unset, UnsetType
class Vec2DTypeHandler(TypeHandler):
def parse(self, errors: list[Error], loc: Loc, value: Any) -> Any | UnsetType:
if isinstance(value, Vec2D):
return value # No conversion needed
if not isinstance(value, tuple) or len(value) != 2:
errors.append(ErrorFactory.invalid_type(loc, value, [Vec2D]))
return Unset
return Vec2D(*value) # Convert a 2-dimensional tuple
def accept(self, visitor: ModelVisitor, loc: Loc, value: Any):
visitor.visit_any(loc, value) # Calls most generic visitor method
Now, the newly created type handler must be registered in Modelity. To do that
you need to use modelity.base.register_type_handler_factory() function:
>>> from modelity.api import register_type_handler_factory
>>> register_type_handler_factory(Vec2D, lambda typ, **opts: Vec2DTypeHandler())
And now we can successfully declare our model:
from modelity.api import Model
class Object(Model):
position: Vec2D
direction: Vec2D
>>> car = Object(position=(0, 0), direction=(0, 1))
>>> car
Object(position=Vec2D(x=0, y=0), direction=Vec2D(x=0, y=1))
And since now Modelity knows the new type, it also implicitly knows how to handle it when used with other known types, especially containers:
from modelity.api import Model
class ObjectCollection(Model):
objects: list[Object]
>>> collection = ObjectCollection(objects=[])
>>> collection.objects.append(car) # OK
>>> collection.objects.append({"position": (2, 3), "direction": (4, 5)}) # OK; parsing needed
>>> collection.objects
[Object(position=Vec2D(x=0, y=0), direction=Vec2D(x=0, y=1)), Object(position=Vec2D(x=2, y=3), direction=Vec2D(x=4, y=5))]
Using built-in type handler from custom type handler
Let’s once again recall Vec2D introduced earlier:
import dataclasses
@dataclasses.dataclass
class Vec2D:
x: float
y: float
It has x and y float fields, but our previous type handler was not
implemented with float parsing in mind, so following will be parsed
successfully, but the type will not be coerced:
>>> obj = Object(position=("2", "3"), direction=("4", "5"))
>>> obj.position
Vec2D(x='2', y='3')
>>> obj.direction
Vec2D(x='4', y='5')
To fix that, we need to use type handler for float type while parsing our own Vec2D type. The following full example shows how this can be accomplished:
from typing import Any
from modelity.api import (
TypeHandler, Loc, Error, ModelVisitor, ErrorFactory, Unset, UnsetType,
register_type_handler_factory, create_type_handler
)
class Vec2DTypeHandler(TypeHandler):
"""Type handler for ``Vec2D`` type."""
def __init__(self):
self._float_type_handler = create_type_handler(float) # Obtain type handler for `float` type
def parse(self, errors: list[Error], loc: Loc, value: Any) -> Any | UnsetType:
if isinstance(value, Vec2D):
return value # No conversion needed
if not isinstance(value, tuple) or len(value) != 2:
errors.append(ErrorFactory.invalid_type(loc, value, [Vec2D]))
return Unset
x = self._float_type_handler.parse(errors, loc + Loc('x'), value[0]) # Invoke Modelity built-in float conversion
y = self._float_type_handler.parse(errors, loc + Loc('y'), value[1]) # Same here
return Vec2D(x, y) # Use converted values
def accept(self, visitor: ModelVisitor, loc: Loc, value: Any):
visitor.visit_any(loc, value) # Calls most generic visitor method
# Overwrite previous type handler for `Vec2D`
register_type_handler_factory(Vec2D, lambda typ, **opts: Vec2DTypeHandler())
# Now declare model that uses `Vec2D`
class Object(Model):
position: Vec2D
direction: Vec2D
And now let’s see this in action:
>>> obj = Object(position=("2", "3"), direction=("4", "5"))
>>> obj.position # This is now converted to float
Vec2D(x=2.0, y=3.0)
>>> obj.direction
Vec2D(x=4.0, y=5.0)
As you can see, both the input tuple and coordinates are now converted. Moreover, since we’ve reused float parser, we have also implicitly implemented error handling if coordinates are incorrect:
>>> obj.position = ("ka", "boom")
Traceback (most recent call last):
...
modelity.exc.ParsingError: Found 2 parsing errors for type 'Object':
position.x:
Not a valid float value [code=modelity.PARSE_ERROR, value_type=str, expected_type=float]
position.y:
Not a valid float value [code=modelity.PARSE_ERROR, value_type=str, expected_type=float]
Hook inheritance
Declaring base model with common hooks
When using hooks you will declare hook directly in the model class for most cases. However, sometimes same hook needs to be provided for other models as well. Consider this example:
from modelity.base import Model
from modelity.hooks import field_preprocessor
class First(Model):
foo: str
@field_preprocessor("foo")
def _strip_string(value):
if isinstance(value, str):
return value.strip()
return value
class Second(Model):
bar: str
@field_preprocessor("bar")
def _strip_string(value): # duplicated!
if isinstance(value, str):
return value.strip()
return value
We have two models that need some preprocessing logic for string inputs to get rid of white characters. The example from above duplicates same functionality, but works just fine from the model user’s PoV:
>>> first = First(foo=" 123")
>>> second = Second(bar="456 ")
>>> first.foo
'123'
>>> second.bar
'456'
However, this is not an elegant solution, as the example break DRY principle. Let’s modify it a bit and extract logic to a separate helper function:
from modelity.base import Model
from modelity.hooks import field_preprocessor
def _strip_string(value):
if isinstance(value, str):
return value.strip()
return value
class First(Model):
foo: str
@field_preprocessor("foo")
def _strip_string(value):
return _strip_string(value)
class Second(Model):
bar: str
@field_preprocessor("bar")
def _strip_string(value): # duplicated!
return _strip_string(value)
Now it is slightly better, and the functionality is still the same:
>>> first = First(foo=" 123")
>>> second = Second(bar="456 ")
>>> first.foo
'123'
>>> second.bar
'456'
However, we still need to remember to add 3 additional lines to each single model that will need such stripping functionality. The best solution for that is to create a common base class and declare stripping hook inside base class. After further refactoring, the code looks as follows:
from modelity.base import Model
from modelity.hooks import field_preprocessor
class Base(Model):
@field_preprocessor() # use this hook for any field...
def _strip_string(value): # ...especially if we don't use it for any particular field
if isinstance(value, str):
return value.strip()
return value
class First(Base):
foo: str
class Second(Base):
bar: str
class Third(Base): # Just one more class to automatically reuse stripping logic
baz: str
And let’s check this in practice once again:
>>> first = First(foo=" 123")
>>> second = Second(bar="456 ")
>>> third = Third(baz=" 789 ")
>>> first.foo, second.bar, third.baz
('123', '456', '789')
Using mixin classes with hooks
Added in version 0.24.0.
Sometimes having all the hooks in a single common base class, or even several separate base classes, is too rigid and forces you to inherit from base class even if the model is logically not a subclass of such base. To resolve such issues, Modelity now provides an easy way to declare hooks inside non-model mixin classes that can then be mixed in to any model you want.
Let’s slightly extend an example from above to see how to use mixins:
from modelity.api import Model, Deferred, field_preprocessor
class StringStrippingMixin: # this is a mixin; we don't inherit from model
@field_preprocessor() # use this hook for any field...
def _strip_string(value): # ...especially if we don't use it for any particular field
if isinstance(value, str):
return value.strip()
return value
class Base(Model, StringStrippingMixin): # base class for First and Second; both will use the mixin
pass
class First(Base):
foo: Deferred[str]
class Second(Base):
bar: Deferred[str]
class Third(Model): # Here we don't use our mixin...
baz: Deferred[str]
class Fourth(Third, StringStrippingMixin): # ...and here we do
spam: Deferred[str]
And the final check looks as follows:
>>> first = First(foo=" 123")
>>> second = Second(bar="456 ")
>>> third = Third(baz=" 789 ")
>>> fourth = Fourth(spam=' spam ')
>>> first.foo, second.bar, third.baz, fourth.spam
('123', '456', ' 789 ', 'spam')