Porting from Pinttrs#

Pintext is the continuation of Pinttrs, rescoped around unit contexts. The attrs integration is now optional and lives in a dedicated namespace.

Warning

There is no compatibility shim. The pinttr and pinttrs import namespaces no longer exist, and nothing is deprecated-but-working: removed names are simply gone. Porting is a one-time, mechanical edit; this page lists every change you need to make.

Installation and imports#

The distribution is renamed, and attrs is no longer a hard dependency, it moved behind an extra:

- pip install pinttrs
+ pip install "pintext[attrs]"

If you only use unit contexts and converters, plain pip install pintext is enough: the core depends on Pint alone.

Both former import namespaces collapse into one:

- import pinttr     # the "classic" namespace
- import pinttrs    # the "modern" namespace
+ import pintext

Symbol map#

Everything that survived, and where it went:

Pinttrs

Pintext

pinttrs.UnitContext

pintext.UnitContext

pinttrs.UnitGenerator

pintext.UnitGenerator

pinttrs.get_unit_registry

pintext.get_unit_registry()

pinttrs.set_unit_registry

pintext.set_unit_registry()

pinttrs.converters.ensure_units

pintext.ensure_units()

pinttrs.converters.to_quantity

pintext.to_quantity()

pinttrs.util.units_compatible

pintext.units_compatible()

pinttrs.exceptions.UnitsError

pintext.UnitsError

pinttrs.field

pintext.attrs.field()

pinttrs.validators.has_compatible_units

pintext.attrs.has_compatible_units()

pinttr._metadata.MetadataKey

pintext.attrs.MetadataKey (now public)

to_quantity, ensure_units, units_compatible and UnitsError are now reachable from the top level, so most imports get shorter:

- from pinttrs.converters import ensure_units, to_quantity
- from pinttrs.util import units_compatible
+ from pintext import ensure_units, to_quantity, units_compatible

Removed names#

Removed

Replacement

pinttr.attrib(), pinttr.ib()

pintext.attrs.field() (keyword-only)

pinttrs.interpret_units()

pintext.to_quantity() (see Dictionary interpretation)

pinttrs.converters.to_units(units)

pintext.ensure_units(default_units=units) (deferred mode)

pinttrs.util.ensure_units()

pintext.ensure_units() (alias is gone, function is not)

pinttrs.util.always_iterable()

more_itertools.always_iterable()

The field factory#

attrib()/ib() and field() collapse into a single keyword-only pintext.attrs.field(). If you used the classic spelling, positional arguments have to become keywords:

- import attr, pinttr
-
- @attr.s
- class MyClass:
-     value = pinttr.ib(0.0, units=ureg.m)
+ import attrs
+ from pintext.attrs import field
+
+ @attrs.define
+ class MyClass:
+     value = field(default=0.0, units=ureg.m)

The cmp and type parameters of attrib() are not carried over; they were already absent from field() and deprecated in attrs itself.

Dictionary interpretation#

This is the one change that is not a rename. interpret_units() read a flat dictionary and paired each x_units key with its x sibling:

# Pinttrs
interpret_units({"radius": 1.0, "radius_units": "m"}, ureg)
# {'radius': <Quantity(1.0, 'meter')>}

Pintext drops thsi mechanism entirely: to_quantity() works on a single quantity at a time, which carries its magnitude and units together:

>>> import pintext
>>> ureg = pintext.get_unit_registry()
>>> pintext.to_quantity({"radius": 1.0, "radius_units": "m"})
Traceback (most recent call last):
    ...
ValueError: Supplied value has no magnitude
>>> pintext.to_quantity({"value": 1.0, "units": "m"})
<Quantity(1.0, 'meter')>

So the recommended port is to reshape the serialized data, nesting each quantity instead of spreading it across sibling keys:

  {
-   "radius": 1.0,
-   "radius_units": "m",
-   "duration": 30.0,
-   "duration_units": "s"
+   "radius": {"value": 1.0, "units": "m"},
+   "duration": {"value": 30.0, "units": "s"}
  }

and to apply to_quantity() per value:

>>> data = {
...     "radius": {"value": 1.0, "units": "m"},
...     "duration": {"value": 30.0, "units": "s"},
... }
>>> {k: pintext.to_quantity(v) for k, v in data.items()}
{'radius': <Quantity(1.0, 'meter')>, 'duration': <Quantity(30.0, 'second')>}

The nested form is self-contained: a value carries its own units, so it survives being moved, nested or passed around on its own, and it is what the pydantic integration emits when serializing.

Keeping legacy documents readable#

If you cannot reshape your data (e.g. stored documents, a published format), drop this adapter into your own code. It reproduces what interpret_units() did:

>>> import pint
>>>
>>> def interpret_units(d, ureg=None):
...     """Local replacement for the removed pinttrs.interpret_units()."""
...     if ureg is None:
...         ureg = pintext.get_unit_registry()
...     result = dict(d)
...     for units_key in [k for k in d if k.endswith("_units")]:
...         magnitude_key = units_key[: -len("_units")]
...         if magnitude_key not in result:
...             continue
...         units = result.pop(units_key)
...         magnitude = result[magnitude_key]
...         if isinstance(magnitude, pint.Quantity):
...             magnitude = magnitude.m_as(units)
...         result[magnitude_key] = ureg.Quantity(magnitude, units)
...     return result

It behaves as before, including converting an already-dimensional magnitude:

>>> interpret_units({"radius": 1.0, "radius_units": "m"})
{'radius': <Quantity(1.0, 'meter')>}
>>> interpret_units({"radius": 1.0 * ureg.m, "radius_units": "km"})
{'radius': <Quantity(0.001, 'kilometer')>}

and leaving a _units key with no matching magnitude alone:

>>> interpret_units({"radius_units": "m"})
{'radius_units': 'm'}

Treat it as a migration aid, not a destination: it is 15 lines you now own, and the nested form above is the supported path.

Other behaviour changes#

to_quantity() converts everything Pint can convert. In Pinttrs, anything that was not a mapping or a DataArray passed through unchanged. Pintext hands those values to Pint, which parses unit-carrying strings and makes unitless values dimensionless:

>>> pintext.to_quantity("2 m")
<Quantity(2, 'meter')>
>>> pintext.to_quantity(42.0)
<Quantity(42.0, 'dimensionless')>

Values Pint cannot interpret still pass through; strict=True raises a ValueError instead. Code that applied to_quantity() indiscriminately and relied on plain magnitudes surviving untouched must guard the call. This is what pintext.pydantic.Units does, so that a plain magnitude still receives the field’s default units rather than becoming dimensionless.

The core classes are no longer attrs classes. UnitContext and UnitGenerator are dataclasses, which is what lets the core depend on Pint alone. Their constructors, attributes and methods are unchanged, but attrs introspection helpers no longer apply:

- attrs.fields(UnitContext)      # raises NotAnAttrsClassError
- attrs.evolve(generator, units=ureg.km)
+ dataclasses.fields(UnitContext)
+ dataclasses.replace(generator, units=ureg.km)

A shared unit check is now public. pintext.check_units() holds the comparison that has_compatible_units used to own, in a form that does not depend on an attrs attribute object. Use it if you validate units by hand:

>>> pintext.check_units(1.0 * ureg.km, ureg.m)
>>> pintext.check_units(1.0 * ureg.s, ureg.m, name="radius")
Traceback (most recent call last):
    ...
pintext.exceptions.UnitsError: Cannot convert from 'second' to 'meter': incompatible units 'second' used to set field 'radius' (allowed: 'meter').

Python 3.8 and 3.9 are no longer supported. Pintext requires Python 3.10 or later.

Versioning switched from CalVer to SemVer. Pinttrs used YY.MINOR.MICRO; Pintext starts at 0.1.0 and follows Semantic Versioning. A version comparison that assumed the year prefix will read Pintext versions as older: pin on the distribution name instead.

What is new#

Porting is a good moment to pick these up:

  • A pydantic integration, with the same deferred-units behaviour as the attrs one, plus JSON round-trips and JSON schema.

  • pintext.check_units(), described above.

  • A py.typed marker, so type checkers now see Pintext’s annotations.