pydantic integration#

Important

Pydantic integration is experiment: breaking changes might happen.

This extension requires that the pydantic (v2) package is installed. If not done externally in your project, it can be requested with the pydantic extra:

python -m pip install "pintext[pydantic]"

Pintext exposes three names for pydantic: Units, the annotation attaching units to a field; Quantity, which accepts any quantity; and quantity(), a concise shorthand.

Declaring units#

Units is used as Annotated metadata:

>>> from typing import Annotated
>>> import pint, pintext
>>> from pydantic import BaseModel, ValidationError
>>> from pintext.pydantic import Units
>>> ureg = pintext.get_unit_registry()
>>> class Sphere(BaseModel):
...     radius: Annotated[pint.Quantity, Units(ureg.m)]
>>> Sphere(radius=1.0).radius
<Quantity(1.0, 'meter')>

quantity() is a shorthand for the same thing:

>>> from pintext.pydantic import quantity
>>> class Sphere(BaseModel):
...     radius: quantity(ureg.m)
>>> Sphere(radius=1.0).radius
<Quantity(1.0, 'meter')>

Warning

quantity(...) is a function call, which static type checkers reject inside an annotation. Use the Units spelling wherever static checking matters; quantity() is a convenience for interactive use.

Units may also be given as a string, interpreted against the registry returned by get_unit_registry():

>>> class Sphere(BaseModel):
...     radius: Annotated[pint.Quantity, Units("m")]
>>> Sphere(radius=1.0).radius
<Quantity(1.0, 'meter')>

Quantities with compatible units pass through unchanged; convert=True forces conversion to the declared units:

>>> Sphere(radius=1.0 * ureg.km).radius
<Quantity(1.0, 'kilometer')>
>>> class ConvertingSphere(BaseModel):
...     radius: Annotated[pint.Quantity, Units(ureg.m, convert=True)]
>>> ConvertingSphere(radius=1.0 * ureg.km).radius
<Quantity(1000.0, 'meter')>

Incompatible units are reported as a regular ValidationError, so they compose with pydantic’s error collection:

>>> try:
...     Sphere(radius=1.0 * ureg.s)
... except ValidationError as e:
...     print(type(e).__name__)
ValidationError

Note

UnitsError derives from TypeError, which pydantic does not collect into a ValidationError. The integration therefore re-raises it as a ValueError, keeping the original exception as the __cause__.

Dictionary input#

Serialized values (e.g. mappings, unit-carrying strings and xarray.DataArray objects) are passed through to_quantity() first, which is what makes loading from JSON or YAML work:

>>> Sphere(radius={"value": 2.0, "units": "km"}).radius
<Quantity(2.0, 'kilometer')>
>>> Sphere(radius="2 km").radius
<Quantity(2, 'kilometer')>

Plain magnitudes are not: they receive the field’s default units instead of being made dimensionless.

Serialization produces the same form, so models round-trip:

>>> dumped = Sphere(radius=1.0 * ureg.km).model_dump_json()
>>> dumped
'{"radius":{"value":1.0,"units":"kilometer"}}'
>>> Sphere.model_validate_json(dumped).radius
<Quantity(1.0, 'kilometer')>

A JSON schema is produced as well:

>>> Sphere.model_json_schema()["properties"]["radius"]["required"]
['value', 'units']

Unit contexts#

The units argument accepts a UnitGenerator, which defers evaluation to validation time. Combined with UnitContext.deferred(), this makes a whole model tree read its unitless inputs according to the active context:

>>> uctx = pintext.UnitContext({"length": ureg.m})
>>> class Sphere(BaseModel):
...     radius: Annotated[pint.Quantity, Units(uctx.deferred("length"))]
>>> Sphere(radius=1.0).radius
<Quantity(1.0, 'meter')>
>>> with uctx.override(length="km"):
...     Sphere(radius=1.0).radius
<Quantity(1.0, 'kilometer')>

Accepting any quantity#

When a field should hold a quantity but has no declared units, use Quantity. No default units are applied and no compatibility check is performed, but mappings are still interpreted:

>>> from pintext.pydantic import Quantity
>>> class Measurement(BaseModel):
...     value: Quantity
>>> Measurement(value={"value": 5.0, "units": "s"}).value
<Quantity(5.0, 'second')>

A value that cannot be interpreted as a quantity is rejected:

>>> try:
...     Measurement(value=1.0)
... except ValidationError as e:
...     print(type(e).__name__)
ValidationError

Note

Because the annotation supplies its own core schema, models holding quantities need no arbitrary_types_allowed configuration.