Fields#
Fields are used to define the attributes of a Schema
. Oblate provides some built-in fields for
standard and commonly used data types and structures. These are available under the oblate.fields
package.
Defining fields#
Fields are typically defined inside a Schema
subclass as class attributes. Any class attribute
inside a Schema
which is a fields.Field
instance is considered a field of that schema.
Example:
class User(oblate.Schema):
id = fields.Integer()
username = fields.String()
Here, id
and username
are the fields of User
schema. While initializing the schema, id
will accept only integers (int
type) and username
will take strings (str
type).
There are also other fields provided by oblate.fields
package similar to fields.Integer
and fields.String
for various other data types and structures. To check out those, see the
API reference for fields.
Optional fields and defaults#
By default, every field is required as such when initializing a Schema
, if that field is not
present in the given data, an error will be raised.
This behaviour can be changed. Fields can be marked as optional by using the required
parameter:
class User(oblate.Schema):
id = fields.Integer()
username = fields.String()
is_employee = fields.Boolean(required=False)
Here, is_employee
is marked as optional and won’t be required while initializing the schema:
user = User({'id': 1, 'username': 'John'})
No errors will be raised by the above line. However, when accessing the field, a ValueError
will be raised as no value is available for this field:
print(user.is_employee) # FieldNotSet: Field 'is_employee' has no value set.
user.is_employee = True
print(user.is_employee) # Prints True
This behaviour is generally not ideal. Typically, one would expect a field to default to some
value instead of raising error on access. This is done using the default
parameter.
Example:
class User(oblate.Schema):
id = fields.Integer()
username = fields.String()
is_employee = fields.Boolean(default=False)
In this case, when initializing the user schema, if the is_employee
field is not given, it would
automatically get the value of False
. Example:
print(User({'id': 1, 'username': 'John'}).is_employee) # prints False
The default
parameter can take any value or a callable. If a callable is given, it will be called
during field processing with two parameters: the parent Field
and the SchemaContext
instance. The callable should return the value to set as default.
If a default
is provided, field is automatically marked as optional and required=False
is
not needed.
Noneable (nullable) fields#
By default, all fields only accept the relevant data types and do not support None
values however
this can be modified.
You can mark certain fields to accept a value of None
. This is achieved using the none
parameter:
class User(Schema):
username = fields.String()
email = fields.String(none=True)
In this case, the email
parameter will take either a string or a None
value:
User({'username': 'John', 'email': None}) # No error!
Strict Mode#
Fields for primitive data types such as fields.String
, fields.Integer
and
fields.Boolean
etc. have a strict
parameter.
When strict
is True (by default), the field will only accept value that is of same data type
as field e.g. fields.String`
will only accept string data type.
However, when strict is False
, the provided value is converted to the relevant data
type implicitly:
class User(Schema):
id = fields.Integer()
user = User({'id': '1'}) # ERROR: Must be of integer data type.
#### --- Without strict mode --- ###
class User(Schema):
id = fields.Integer(strict=False)
user = User({'id': '1'}) # No error!
print(type(user.id), user.id) # <class 'int'> 1
The given value however, must be convertable to the data type otherwise an error is raised.
For fields.Boolean
, with strict mode disabled, The given value is first type casted to
string and then compared against a set of values that correspond to either True or False:
class User(Schema):
is_employee = fields.Boolean(strict=False)
print(User({'is_employee': 'true'}).is_employee) # True
print(User({'is_employee': 'TRUE'}).is_employee) # True
print(User({'is_employee': 'yes'}).is_employee) # True
print(User({'is_employee': 1}).is_employee) # True
print(User({'is_employee': 'false'}).is_employee) # False
print(User({'is_employee': 'FALSE'}).is_employee) # False
print(User({'is_employee': 'false'}).is_employee) # False
print(User({'is_employee': 0}).is_employee) # False
print(User({'is_employee': 'not convertable value'}).is_employee) # ValidationError raised
It is also possible to provide the set of values that correspond to True/False:
class User(Schema):
is_employee = fields.Boolean(strict=False, true_values=['T', 'yeah'], false_values=['F', 'nope'])
print(User({'is_employee': 'yeah'}).is_employee) # True
print(User({'is_employee': 'nope'}).is_employee) # False
print(User({'is_employee': 'True'}).is_employee) # ValidationError raised
By default, true_values
and false_values
default to fields.Boolean.TRUE_VALUES
and
fields.Boolean.FALSE_VALUES
respectively.
Frozen Fields#
Frozen fields are, in other words, read only fields. These fields can only be set at initialization time and cannot be updated.
This is done by passing frozen
parameter. Whenever a field is attempted to be updated,
a FrozenError
is raised.
Example:
class User(oblate.Schema):
id = fields.Integer(frozen=True)
username = fields.String()
user = User({'id': 1, 'username': 'test'})
user.update({'id': 2}) # FrozenError: User.id field is frozen and cannot be updated.
user.id = 1 # FrozenError: User.id field is frozen and cannot be updated.
For marking schema (all fields) as frozen, see Frozen Schemas.
Extra Metadata#
Sometimes you want to attach some extra metadata to a field. This is typically done to use this metadata at another place in the user code to modify some behaviour. For example, attaching extra data for usage in modifying a validator’s behaviour.
Example of usage of extra metadata:
class RangeValidator(validate.Validator):
def __init__(self, lb: int, ub: int):
self.lb = lb
self.ub = ub
def validate(self, value, ctx):
if ctx.field.extras.get('range_validator_inclusive', False):
# include both bounds in validation
assert value >= lb and value <= ub
else:
assert value > lb and value < ub
ID_RANGE_VALIDATOR = RangeValidator(1000, 9999)
class Shelf(Schema):
id = fields.Integer(validators=[ID_RANGE_VALIDATOR])
class Book(Schema):
id = fields.Integer(extras={'range_validator_inclusive': True}, validators=[ID_RANGE_VALIDATOR])
In this case, range validation for Book.id
would include both lower and upper bound while it won’t
be the case for Shelf.id
.
Note that this “extra metadata” only exists to be used by the user and library will not perform any manipulation on this data.
Custom Fields#
Oblate provides commonly used data types support but also allows the creation of custom fields that have its own set of validation rules.
Fields are created by inheriting from the fields.Field
class which provides an interface
for other fields. The subclasses must implement the value_load()
and
fields.Field.value_dump()
methods.
Example:
class SumValues(fields.Field[list[int], int]):
def value_load(self, value: list[int], ctx: oblate.LoadContext) -> int:
if not isinstance(value, list):
raise ValueError('Value for this field must be a list of integers')
result = 0
for idx, num in enumerate(value):
if not isinstance(num, int):
raise ValueError(f'Non-integer value at index {idx}')
result += num
return result
def value_dump(self, value: int, ctx: oblate.DumpContext) -> int:
return value
The field above accepts a list of integers and returns their sum. The value_load
method takes
two parameters:
The value given by raw data
The
LoadContext
instance.
The raw value is any value (that could possibly be invalid too) that is provided by the data and
load context holds useful information about field deserialization context. The returned value by
value_load
method is the deserialized value that is assigned to field in the Schema
.
Example:
class Student(oblate.Schema):
name = fields.String()
test_score = SumValues()
std = Student({'name': 'John', 'test_score': [10, 9, 5, 6]})
print(std.test_score) # 30
The value_dump
method is called when a schema is being serialized to raw form. The value returned
by this method corresponds to the value of field in raw data. The parameters taken by this
method are:
The value that will be serialized (i.e the value returned by
value_load
)The
DumpContext
instance
Example:
std = Student({'name': 'John', 'test_score': [10, 9, 5, 6]})
print(std.dump()) {'name': 'John', 'test_score': 30}
Tip
fields.Field
is a typing generic and takes two type arguments. The first one is
the expected raw value type and the second one is the type of deserialized value.
Custom data keys#
A data key is the name of key in raw data that points to the value of a field in raw data. By default, the name of attribute that the field is bound to is used as data key for that field.
To provide a custom data key, the data_key
parameter can be used:
class User(oblate.Schema):
id = fields.Integer(data_key='userId')
user = User({'userId': 1234})
print(user.id) # 1234
print(user.dump()) {'userId': 1234}
If you want to separate the data keys for deserialization and serialization of data, the load_key
and dump_key
parameters can be used:
class User(oblate.Schema):
id = fields.Integer(load_key='userId', dump_key='user_id')
user = User({'userId': 1234})
print(user.id) # 1234
print(user.dump()) {'user_id': 1234}
All of these parameters default to the field name.
Nested schemas#
fields.Object
field allows nesting schemas inside other schemas. This field accepts raw data for
another schema and deserializes it to the Schema
object. You can nest schemas to as many levels
as you wish.
Example:
class Actor(oblate.Schema):
name = fields.String()
film_count = fields.Integer(default=0)
class Film(oblate.Schema):
name = fields.String()
rating = fields.Integer()
actor = fields.Object(Actor)
data = {
'name': 'A nice film',
'rating': 10,
'actor': {
'name': 'John',
'film_count': 13,
}
}
film = Film(data)
print(film.actor.name) # John
The actor
key in data
can also take a Actor
instance instead of raw data. If raw
data is given, it is automatically converted to Actor
instance. Similarly, if an instance
is given, it is returned as-is.
If an error occurs in a nested schema, the causative nested fields are indicated using indentation.
Example invalid data:
data = {
'name': 'A nice film',
'actor': {
'name': 0,
'film_count': 13,
}
}
Raised error:
oblate.exceptions.ValidationError:
│
│ 2 validation errors in schema 'Film'
│
└── In field actor:
│
└── In field name:
└── Value of this field must be a string
│
└── In field rating:
└── This field is required.
You can also pass init_kwargs
parameter to Object
to include any keyword
parameters that should be passed to schema while initializing it.
For example, in order to ignore unknown fields:
class Film(oblate.Schema):
name = fields.String()
rating = fields.Integer()
actor = fields.Object(Actor, init_kwargs=dict(ignore_extra=True))
data = {
'name': 'A nice film',
'rating': 10,
'actor': {
'name': 'John',
'film_count': 13,
'invalid_field': 'test',
}
}
film = Film(data) # No error, invalid fields ignored silently
Note
init_kwargs
are only used when the raw data is passed to object field. In a case,
where the schema instance is passed directly to object field, init_kwargs
is not
used as schema is already initialized.
For example, init_kwargs
would not be used in this case:
actor = Actor({'name': 'John', 'film_count': 13})
data = {
'name': 'A nice film',
'rating': 10,
'actor': actor,
}
Typing fields#
Some fields are inspired by some types provided by the typing
module from standard library
and work in a similar fashion.
Any#
The fields.Any
is a field that performs no validation on the given value and returns
it as-is. This is similar to typing.Any
.
Example:
class Model(oblate.Schema):
something = fields.Any()
Model({'something': 'any arbitrary type'})
Literal#
The fields.Literal
acts in a similar fashion to typing.Literal
. The field
only accepts the values provided during initialization as literals.
Example:
class User(oblate.Schema):
role = fields.Literal('owner', 'manager', 'employee')
User({'role': 'owner'}) # OK
User({'role': 'manager'}) # OK
User({'role': 'employee'}) # OK
User({'role': 'unknown'}) # ValidationError raised
Union#
The fields.Union
field accepts value of predefined types. This is similar to how
typing.Union
works.
Example:
class User(oblate.Schema):
phone_number = fields.Union(str, int)
User({'phone_number': '+16362326961'}) # OK
User({'phone_number': 6362326961}) # OK
User({'phone_number': False}) # ValidationError
Note
This field only performs simple isinstance()
check without any special
validation of its own.
Type Expression#
fields.TypeExpr
takes raw type expression and validates and deserializes the given value
using this expression.
For more information on the supported types and how this field works, see the Type Validation page in the guide.
Data Structures Fields#
Oblate also provides fields that accept various data structures. These fields also provide basic type validation to validate the data associated with the structure.
Note
For information on how type validation works along with the limitations, please see the Type Validation page.
Dict#
fields.Dict
field accepts dictionaries. This field can either take an arbitrary dictionary
with no data validation or can also perform type validation on dictionary.
Example:
class Model(oblate.Schema):
data = fields.Dict()
Model({'data': {'test': 'value'}}) # accepts any dictionary
It also supports type validation:
from typing import Any
class Model(oblate.Schema):
data = fields.Dict(str, Any)
Model({'data': {'test': 'value'}}) # OK
Model({'data': {'test': 1}}) # OK
Model({'data': {1: 'value'}}) # Error: Dict key at index 1: must be of type str
TypedDict#
fields.TypedDict
field accepts dictionaries which are validated using the typing.TypedDict
.
Example:
from typing import TypedDict, Required, NotRequired, Union
class ModelData(TypedDict):
id: Union[int, str]
name: str
rating: NotRequired[int]
class Model(oblate.Schema):
data = fields.TypedDict(ModelData)
Model({'data': {'id': '123', 'name': 'John'}}) # OK
Model({'data': {'id': 123, 'name': 'John', 'rating': 3}}) # OK
Errors are also handled properly in TypedDict errors:
Model({'data': {'id': 3.14}})
Outputs:
oblate.exceptions.ValidationError:
│
│ 1 validation error in schema 'Model'
│
└── In field data:
├── Validation failed for 'id': Must be one of types (int, str)
└── Key 'name' is required
Sequences#
Currently following sequence structures are available as fields:
All of these classes support type validation. If initialized without argument, these fields perform no type validation on elements of sequence otherwise each element’s type is validated according to given type expression.
Example:
class Model(oblate.Schema):
untyped_list = fields.List()
typed_list = fields.List(str)
typed_list_union = fields.List(typing.Union[str, int])
Here,
untyped_list
accepts any arbitrary list without any type validation on elements.typed_list
accepts list of string elements only.typed_list_union
accepts list with elements either being string or integer.