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.