attrs integration#

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

python -m pip install "pintext[attrs]"

pintext.attrs.field() mirrors attrs.field() and adds a units argument, which attaches units to a field:

>>> import attrs, pint, pintext
>>> from pintext.attrs import field
>>> ureg = pintext.get_unit_registry()
>>> @attrs.define
... class MyClass:
...     value = field(units=ureg.km)
>>> MyClass(1.0)
MyClass(value=1.0 km)

Note

If units is unset, pintext.attrs.field() behaves exactly like attrs.field().

Unitless values are automatically wrapped. If a Pint quantity is passed, its units are checked; when they are compatible in the sense of Pintext, the value is assigned unchanged:

>>> MyClass(1.0 * ureg.m)
MyClass(value=1.0 m)

Incompatible units make the built-in validator raise a UnitsError:

>>> MyClass(1.0 * ureg.s)
Traceback (most recent call last):
    ...
pintext.exceptions.UnitsError: Cannot convert from 'second' to 'kilometer': incompatible units 'second' used to set field 'value' (allowed: 'kilometer').

By default, conversion and validation also apply on assignment:

>>> o = MyClass(1.0)
>>> o
MyClass(value=1.0 km)
>>> o.value = 1.0 * ureg.s
Traceback (most recent call last):
    ...
pintext.exceptions.UnitsError: Cannot convert from 'second' to 'kilometer': incompatible units 'second' used to set field 'value' (allowed: 'kilometer').
>>> o.value = 1.0 * ureg.m
>>> o
MyClass(value=1.0 m)
>>> o.value = 1.0
>>> o
MyClass(value=1.0 km)

Note

To opt out, pass attrs.setters.NO_OP:

>>> @attrs.define
... class AnotherClass:
...     value = field(units=ureg.km, on_setattr=attrs.setters.NO_OP)
>>> o = AnotherClass(1.0)
>>> o
AnotherClass(value=1.0 km)
>>> o.value = 1.0
>>> o
AnotherClass(value=1.0)

Passing on_setattr=None is not equivalent: None means “defer to the class-level setting”, and attrs.define() installs a convert-and-validate pipeline of its own, so conversion still happens.

>>> @attrs.define
... class AnotherClass:
...     value = field(units=ureg.km, on_setattr=None)
>>> o = AnotherClass(1.0)
>>> o.value = 1.0
>>> o
AnotherClass(value=1.0 km)

None is however what you need for frozen classes, which reject any on_setattr at all:

>>> @attrs.frozen
... class AnotherClass:
...     value = field(units=ureg.m)
Traceback (most recent call last):
    ...
ValueError: Frozen classes can't use on_setattr.
>>> @attrs.frozen
... class AnotherClass:
...     value = field(units=ureg.m, on_setattr=None)

Fields with units also get a repr suited to displaying quantities. The original one is restored by passing repr=True:

>>> @attrs.define
... class AnotherClass:
...     value = field(units=ureg.km, repr=True)
>>> AnotherClass(1.0)
AnotherClass(value=<Quantity(1.0, 'kilometer')>)

Unit contexts#

The units argument accepts a UnitGenerator, so a field can read its units from a unit context. Units are then resolved at instantiation time:

>>> uctx = pintext.UnitContext({"length": ureg.m})
>>> @attrs.define
... class MyClass:
...     value = field(units=uctx.deferred("length"))
>>> MyClass(1.0)
MyClass(value=1.0 m)
>>> with uctx.override(length="km"):
...     MyClass(1.0)
MyClass(value=1.0 km)

Validators and converters#

Under the hood, field() composes a converter (ensure_units() in deferred mode) and a validator (has_compatible_units()). Both can be used directly to customize field behaviour further. See pintext.attrs for details.