Field Annotations¶
When using Typed Decoding structtype will ensure decoded
messages match the specified types. For example, to decode a list of integers
from JSON:
>>> from structtype import Struct
>>> class IntList(Struct):
... items: list[int]
>>> IntList.struct_validate_json(b'{"items": [1, 2, 3]}')
IntList(items=[1, 2, 3])
>>> IntList.struct_validate_json(b'{"items": [1, 2, "oops"]}')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `int`, got `str` - at `$.items[2]`
Often this is sufficient, but sometimes you also need to impose constraints on the values (rather than the types) found in the message.
Constraints and field metadata in structtype are specified by wrapping a
type with typing.Annotated, and adding a structtype.Field or
structtype.Constraint annotation. The Annotated wrapper attaches arbitrary
metadata to a type without changing its runtime behavior; structtype reads
the Field and Constraint instances it contains.
Note
from __future__ import annotations is fully supported — lazy string
annotations are resolved at class creation, so Field metadata,
Constraint constraints and Serializer objects work exactly as with
eager annotations. Forward references that can’t be resolved at class
creation (e.g. self-referential types) are left for the decode-time
resolver.
Constraint options (gt, ge, lt, le, multiple_of,
min_length, max_length, pattern, tz) restrict the values a
field may take, and are specified via structtype.Constraint subclasses.
Metadata options (alias, title, description,
examples, json_schema_extra) affect encoding names and generated
JSON Schemas, and are specified via structtype.Field.
For example, to constrain the list to positive integers (> 0), you’d make
use of the gt (greater-than) constraint:
>>> from typing import Annotated
>>> from structtype import NumericConstraint, Struct
>>> PositiveInt = Annotated[int, NumericConstraint(gt=0)]
>>> class PosList(Struct):
... items: list[PositiveInt]
>>> PosList.struct_validate_json(b'{"items": [1, 2, 3]}')
PosList(items=[1, 2, 3])
>>> PosList.struct_validate_json(b'{"items": [1, 2, -1]}')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `int` >= 1 - at `$.items[2]`
Constraints can be combined to enforce complex requirements. Here’s a more
complete example enforcing the following constraints on a User struct:
nameis astrwith1 <= length <= 32matching the regular expression"^[a-z_][a-z0-9_-]*$".groupsis asetof at most 16 strings, each with the same constraints asnameabove, defaulting to the emptyset.cpu_limitis afloatwith a value>= 0.1and<= 8, defaulting to 1.mem_limitis anintwith a value>= 256and<= 8192, defaulting to 1024.
>>> from typing import Annotated
>>> from structtype import Struct, Field, StrConstraint, NumericConstraint
>>> UnixName = Annotated[
... str, StrConstraint(min_length=1, max_length=32, pattern="^[a-z_][a-z0-9_-]*$")
... ]
>>> class User(Struct):
... name: UnixName
... groups: Annotated[set[UnixName], CollectionConstraint(max_length=16)] = set()
... cpu_limit: Annotated[float, NumericConstraint(ge=0.1, le=8)] = 1
... mem_limit: Annotated[int, NumericConstraint(ge=256, le=8192)] = 1024
As shown above, Annotated types can be applied inline, or used to create type
aliases and then reused elsewhere (as done with UnixName).
Annotated metadata can also be layered. A type alias may carry one kind of
annotation (e.g. a Constraint), and the alias can then be wrapped again with
another kind (e.g. a Field) where it’s used:
>>> from typing import Annotated
>>> from structtype import Struct, Field, Constraint
>>> def non_negative(value):
... if value < 0:
... raise ValueError("must be non-negative")
... return value
>>> NonNegativeInt = Annotated[int, Constraint(non_negative)]
>>> class S(Struct):
... xyz: Annotated[NonNegativeInt, Field(alias="z")]
>>> S.struct_validate({"z": 5}).xyz
5
>>> S.struct_validate({"z": -1})
Traceback (most recent call last):
...
structtype.ValidationError: must be non-negative - at `$.z`
At most one Field, one Serializer, and one Constraint may apply to a
single field, across all nesting levels. Using two of the same kind (e.g. two
Field wrappers) raises a TypeError.
A Serializer or Constraint must be attached to a concrete type inside
Annotated. Attaching one directly to a union or optional type (e.g.
Annotated[int | None, Constraint(...)]) raises a TypeError at class
creation for structtype.Struct, or at construction for
structtype.StructAdapter. To make such a field optional, union the
annotated member with None: Annotated[T, Constraint(...)] | None. A
None value bypasses the Serializer (load is not called).
The following constraints are supported:
Numeric Constraints¶
These constraints are valid on int or float types:
ge: The value must be greater than or equal toge.gt: The value must be greater thangt.le: The value must be less than or equal tole.lt: The value must be less thanlt.multiple_of: The value must be a multiple ofmultiple_of.
>>> from typing import Annotated
>>> from structtype import Struct, NumericConstraint
>>> class Value(Struct):
... val: Annotated[int, NumericConstraint(ge=0)]
>>> Value.struct_validate_json(b'{"val": -1}')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `int` >= 0 - at `$.val`
Warning
While multiple_of works on float types, we don’t recommend
specifying non-integral multiple_of constraints, as they may be
erroneously marked as invalid due to floating point precision issues. For
example, annotating a float type with multiple_of=10 is fine, but
multiple_of=0.1 may lead to issues. See this GitHub issue for more
details.
String Constraints¶
These constraints are valid on str types:
min_length: The minimum valid length, inclusive.max_length: The maximum valid length, inclusive.pattern: A regular expression pattern that the value must match. Note that patterns are treated as unanchored. This means that the pattern “es” matches not just “es” but also “expression”. If required, you must explicitly anchor the pattern by adding a “^” prefix and “$” suffix. For example, the pattern “^es$” only matches the string “es”
>>> from typing import Annotated
>>> from structtype import Struct, StrConstraint
>>> class UserName(Struct):
... name: Annotated[str, StrConstraint(pattern="^[a-z0-9_]*$")]
>>> UserName.struct_validate_json(
... b'{"name": "invalid username"}',
... )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `str` matching regex '^[a-z0-9_]*$' - at `$.name`
Datetime Constraints¶
These constraints are valid on datetime.datetime and datetime.time types:
tz: Whether the annotated type is required to be timezone-aware. Set toTrueto require timezone-aware values, orFalseto require timezone-naive values. The default isNone, which accepts either timezone-aware or timezone-naive values.
>>> from typing import Annotated
>>> from structtype import Struct, TimezoneConstraint
>>> from datetime import datetime
>>> class EventTZ(Struct):
... at: Annotated[datetime, TimezoneConstraint(tz=True)]
>>> EventTZ.struct_validate_json(
... b'{"at": "2022-04-02T18:18:10"}',
... ) # require timezone aware
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `datetime` with a timezone component - at `$.at`
>>> class EventNaive(Struct):
... at: Annotated[datetime, TimezoneConstraint(tz=False)]
>>> EventNaive.struct_validate_json(
... b'{"at": "2022-04-02T18:18:10-06:00"}',
... ) # require timezone naive
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `datetime` with no timezone component - at `$.at`
Bytes Constraints¶
These constraints are valid on bytes and bytearray types:
min_length: The minimum valid length, inclusive.max_length: The maximum valid length, inclusive.
>>> from typing import Annotated
>>> from structtype import Struct, BytesConstraint
>>> class Payload(Struct):
... data: Annotated[bytes, BytesConstraint(min_length=10)]
>>> Payload.struct_validate_json(
... b'{"data": "ZXhhbXBsZQ=="}',
... )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `bytes` of length >= 10 - at `$.data`
Sequence Constraints¶
These constraints are valid on list, tuple, set, and frozenset types:
min_length: The minimum valid length, inclusive.max_length: The maximum valid length, inclusive.
>>> from typing import Annotated
>>> from structtype import Struct, CollectionConstraint
>>> class SmallList(Struct):
... items: Annotated[list[int], CollectionConstraint(max_length=3)]
>>> SmallList.struct_validate_json(
... b'{"items": [1, 2, 3, 4]}',
... )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `array` of length <= 3 - at `$.items`
Mapping Constraints¶
These constraints are valid on dict types:
min_length: The minimum valid length, inclusive.max_length: The maximum valid length, inclusive.
>>> from typing import Annotated
>>> from structtype import Struct, CollectionConstraint
>>> class SmallDict(Struct):
... items: Annotated[dict[str, int], CollectionConstraint(max_length=3)]
>>> SmallDict.struct_validate_json(
... b'{"items": {"a": 1, "b": 2, "c": 3, "d": 4}}',
... )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
structtype.ValidationError: Expected `object` of length <= 3 - at `$.items`
Field Metadata¶
These Field options don’t constrain values — they attach metadata that
affects encoding names or generated JSON Schemas.
alias¶
Set an alternative name for the field, used when encoding/decoding messages.
The original name is still used in Python code. This is the field-level
alternative to the struct-level rename option; see Renaming Fields in the usage guide for the full comparison.
>>> from typing import Annotated
>>> from structtype import Struct, Field
>>> class Example(Struct):
... x: int
... y: Annotated[int, Field(alias="field_y")]
>>> Example(1, 2).struct_dump_json()
b'{"x":1,"field_y":2}'
>>> Example.struct_validate_json(b'{"x": 1, "field_y": 2}')
Example(x=1, y=2)
title and description¶
Human-readable metadata for the field, included in generated JSON Schemas:
>>> from typing import Annotated
>>> from structtype import Struct, Field
>>> class Product(Struct):
... name: Annotated[str, Field(
... title="Product Name",
... description="The display name of the product"
... )]
examples¶
Provide example values that appear in generated JSON Schemas:
>>> from typing import Annotated
>>> from structtype import Struct, Field
>>> class Product(Struct):
... name: Annotated[str, Field(examples=["Widget", "Gadget"])]
deprecated¶
Mark a field as deprecated in the generated JSON Schema:
>>> from typing import Annotated
>>> from structtype import Struct, Field
>>> class Product(Struct):
... sku: Annotated[str, Field(deprecated=True)]
json_schema_extra¶
Add arbitrary extra properties to the generated JSON Schema for a field:
>>> from typing import Annotated
>>> from structtype import Struct, Field
>>> class Product(Struct):
... sku: Annotated[str, Field(json_schema_extra={"format": "sku"})]
Unlike the other Field parameters, json_schema_extra is applied after
the type’s own schema is generated, so it takes precedence over generated
properties such as type and format (matching pydantic). This is useful
for a custom or natively supported type whose JSON representation is more
specific than the default. For example, a UUID rendered as a URN:
>>> import uuid
>>> class Event(Struct):
... id: Annotated[uuid.UUID, Field(
... json_schema_extra={"format": "urn-uuid"}
... )]
json_schema_extra is also what lets a custom type produce a JSON schema at
all — see JSON schema for string-encoded custom types.