Usage#

Attaching units to attributes#

Pinttrs’s main functionality is to provide support natural unit support to attrs classes. Units must be specified explicitly, i.e. as Unit instances created by a unit registry. Therefore, the first thing you need to do is to create a Pint unit registry:

>>> import pint
>>> ureg = pint.UnitRegistry()

Note

Although Pinttrs offers a default unit registry (see get_unit_registry()), we deliberately made the choice to not support automatic string interpretation. The reason is that automatically interpreting units using the built-in unit registry is a potential source of trouble for users who would also manipulate units created with a different registry.

It should however be noted that with the application registry, Pint makes using a shared registry much safer. We might support automatic string interpretation using the default registry in a future release.

Pinttrs defines a pinttrs.field() function similar to attrs.field(), which basically calls the latter after defining some metadata. The units argument is the main difference and allows for the attachment of units to a field:

>>> import attrs, pinttrs
>>> @attrs.define
... class MyClass:
...     field = pinttrs.field(units=ureg.km)
>>> MyClass(1.0)
MyClass(field=1.0 km)

Note

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

Unitless values are automatically wrapped in Pint units. If a Pint quantity is passed as an attribute value, its units will be checked. If they prove to be compatible in the sense of Pinttrs, the value will be assigned to the attribute without modification:

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

If units are incompatible, the built-in validator will fail and raise a UnitsError:

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

By default, the created attribute also applies conversion and validation upon setting:

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

Note

The original behaviour can be restored by setting on_setattr to None:

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

This is sometimes required, typically if the class is frozen:

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

By default, the created attribute is assigned a repr value well-suited for displaying units.

Note

The original repr can be restored by passing repr=True:

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

Validators and converters#

Under the hood, Pinttrs’s attribute conversion system leverages simple validators and converters which can be used manually to further customise the behaviour of attributes. See relevant API sections for further information: Converters [pinttrs.converters], Validators [pinttrs.validators].

Unit generators#

Pinttrs provides facilities to dynamically vary default units applied when passing a unitless value to a field to which units are attached. The central component of this workflow is the UnitGenerator class. This small class stores Pint units and returns them when called:

>>> ugen = pinttrs.UnitGenerator(ureg.m)
>>> ugen()
<Unit('meter')>

Stored units can then be dynamically modified:

>>> ugen.units = ureg.s
>>> ugen()
<Unit('second')>

The pinttrs.field() function’s units parameter also accepts unit generators. When this happens, the stored generator is evaluated each time units are requested, e.g. by a converter or a validator:

>>> ugen = pinttrs.UnitGenerator(ureg.m)
>>> @attrs.define
... class MyClass:
...     field = pinttrs.field(units=ugen)
>>> MyClass(1.0)
MyClass(field=1.0 m)

Note

Under the hood, units attached to attributes with pinttrs.field() are always stored as unit generators.

Temporary override#

The UnitGenerator.override() context manager can also be used to modify stored units temporarily:

>>> ugen.units = ureg.m
>>> with ugen.override(ureg.s):
...     ugen()
<Unit('second')>
>>> ugen()
<Unit('meter')>

Override values can be specified using strings, which are interpreted based on the registry associated to the currently stored units:

>>> with ugen.override("m"):
...     ugen()
<Unit('meter')>

Override can be used to vary dynamically default units attached to an attribute:

>>> ugen = pinttrs.UnitGenerator(ureg.m)
>>> @attrs.define
... class MyClass:
...     field = pinttrs.field(units=ugen)
>>> MyClass(1.0)
MyClass(field=1.0 m)
>>> with ugen.override(ureg.s):
...     MyClass(1.0)
MyClass(field=1.0 s)

Composed unit generators#

Unit generators can be composed to construct composed dynamic units. To that end, the UnitGenerator constructor accepts a callable, which can be a regular function, a callable class or even a lambda (even another generator can be used, but this is of limited utility). For instance:

>>> ugen_length = pinttrs.UnitGenerator(ureg.m)
>>> ugen_time = pinttrs.UnitGenerator(ureg.s)
>>> ugen_speed = pinttrs.UnitGenerator(lambda: ugen_length() / ugen_time())
>>> ugen_speed()
<Unit('meter / second')>

Overrides will then propagate to the composed generator:

>>> with ugen_length.override("km"), ugen_time.override("hour"):
...     ugen_speed()
<Unit('kilometer / hour')>

Unit contexts#

Unit contexts, implemented by the UnitContext class, provide a simple interface to manage a structured collection of unit generators. Their primary application is to vary the interpretation of units applied to scalar values assigned to unit-attached fields.

Let’s first define a unit context. UnitContext encapsulates a dictionary of UnitGenerator values. The simplest definition uses string-keyed dictionaries:

>>> uctx = pinttrs.UnitContext({"length": pinttrs.UnitGenerator(ureg.m)})

Additional units can be registered after context object creation using the register() method:

>>> uctx.register("time", pinttrs.UnitGenerator(ureg.s))
>>> uctx.get_all()
{'length': <Unit('meter')>, 'time': <Unit('second')>}

The unit context can be queried for units using the get() method:

>>> uctx.get("length")
<Unit('meter')>

Note

The get() and register() methods are aliased with square brackets:

>>> uctx["time"] = ureg.ms
>>> uctx["time"]
<Unit('millisecond')>
>>> uctx["time"] = pinttrs.UnitGenerator(ureg.s)
>>> uctx["time"]
<Unit('second')>

It is also possible to access the underlying generator with the deferred() method:

>>> uctx.deferred("length")
UnitGenerator(units=<Unit('meter')>)

The returned unit generator can be used to attach units to an attribute:

>>> @attrs.define
... class MyClass:
...     field = pinttrs.field(units=uctx.deferred("length"))
>>> MyClass(1.0)
MyClass(field=1.0 m)

When initialising a context or registering additional units to it, units can be directly passed and will be turned into generators automatically:

>>> uctx = pinttrs.UnitContext({"length": ureg.m})
>>> uctx.deferred("length")
UnitGenerator(units=<Unit('meter')>)
>>> uctx.register("time", ureg.s)
>>> uctx.deferred("time")
UnitGenerator(units=<Unit('second')>)

Temporary override#

The override() context manager provides a convenient way to override one or several of the registered units with a dictionary:

>>> with uctx.override({"length": ureg.mile, "time": ureg.hour}):
...     ureg.Quantity(1.0, "km/hour").to(uctx.get("length") / uctx.get("time"))
<Quantity(0.621371192, 'mile / hour')>

The override() method also offers a keyword argument interface, usable when keys are strings or when a key converter handling strings is defined (see Non-string context keys):

>>> with uctx.override(length=ureg.mile, time=ureg.hour):
...     ureg.Quantity(1.0, "km/hour").to(uctx.get("length") / uctx.get("time"))
<Quantity(0.621371192, 'mile / hour')>

Just like UnitGenerator, UnitContext can be overridden using string-based unit specifications:

>>> with uctx.override(length="mile", time="hour"):
...     ureg.Quantity(1.0, "km/hour").to(uctx.get("length") / uctx.get("time"))
<Quantity(0.621371192, 'mile / hour')>

Non-string context keys#

Sometimes, it is desirable to not use strings as context registry keys. A typical replacement can be an enumeration, e.g. with string values:

>>> import enum
>>> class PhysicalQuantity(enum.Enum):
...     LENGTH = "length"
...     SPEED = "speed"
...     TIME = "time"

Using a string-valued enumeration is of particular interest, because the enum’s constructor will act like a converter:

>>> PhysicalQuantity(PhysicalQuantity.LENGTH)
<PhysicalQuantity.LENGTH: 'length'>
>>> PhysicalQuantity("length")
<PhysicalQuantity.LENGTH: 'length'>

In order to preserve optimal convenience, UnitContext offers the possibility to declare a key converter. In our example, we would like to still be able to access units and generators using strings (this would also make the keyword argument of override() still usable). Our enumeration’s constructor performs this string-to-enum conversion, so we can declare it as the key converter:

>>> uctx = pinttrs.UnitContext(key_converter=PhysicalQuantity)

We can then use strings or enum members indifferently to access context contents:

>>> uctx.register(PhysicalQuantity.LENGTH, ureg.m)
>>> uctx.register("time", ureg.s)
>>> uctx.deferred(PhysicalQuantity.TIME)
UnitGenerator(units=<Unit('second')>)
>>> uctx.register(PhysicalQuantity.SPEED, pinttrs.UnitGenerator(
...     lambda: uctx.get(PhysicalQuantity.LENGTH) /
...             uctx.get(PhysicalQuantity.TIME)
... ))
>>> with uctx.override(length=ureg.km, time=ureg.hour):
...    uctx.get("speed")
<Unit('kilometer / hour')>

Specifying units with strings#

UnitContext can interpret string values to Pint units and construct generators from them. The unit registry used is set by the ureg constructor argument. If it is unset, the unit registry returned by get_unit_registry() will be used for interpretation. Example:

>>> uctx = pinttrs.UnitContext({"length": "m", "time": "s"}, interpret_str=True)
>>> uctx.get_all()
{'length': <Unit('meter')>, 'time': <Unit('second')>}

Warning

Interpreting units based on Pinttrs’s default registry can have unintended consequences. Be careful when using this feature!

>>> uctx.get("length") / ureg.m
Traceback (most recent call last):
    ...
ValueError: Cannot operate with Unit and Unit of different registries.

Interpreting units in dicts#

Pinttrs ships a helper function pinttrs.interpret_units() which can be used to interpret units in a dictionary with string-valued keys:

>>> pinttrs.interpret_units({"field": 1.0, "field_units": "m"}, ureg)
{'field': <Quantity(1.0, 'meter')>}

This is useful to e.g. initialise objects using simple JSON fragments. Example:

>>> from pinttrs import interpret_units
>>> ugen = pinttrs.UnitGenerator(ureg.m)
>>> @attrs.define
... class MyClass:
...     field = pinttrs.field(units=ugen)
>>> MyClass(**interpret_units({"field": 1.0, "field_units": "m"}, ureg))
MyClass(field=1.0 m)
>>> MyClass(**interpret_units({"field": 1.0, "field_units": "s"}, ureg))
Traceback (most recent call last):
    ...
pinttr.exceptions.UnitsError: Cannot convert from 'second' to 'meter': incompatible units 'second' used to set field 'field' (allowed: 'meter').

Note

The same unit registry must be used to define field units and interpret dictionaries.

If the magnitude entry is already a Pint quantity, conversion to passed units will be performed (and will fail if incompatible units are detected):

>>> pinttrs.interpret_units({"field": 1.0 * ureg.m, "field_units": "km"}, ureg)
{'field': <Quantity(0.001, 'kilometer')>}
>>> pinttrs.interpret_units({"field": 1.0 * ureg.s, "field_units": "m"}, ureg)
Traceback (most recent call last):
    ...
pint.errors.DimensionalityError: Cannot convert from 'second' ([time]) to 'meter' ([length])