Scalar data exchange

unitx.serialization.to_dict() returns a versioned record made of dictionaries, lists, strings, null and an integer format version. from_dict() restores it. Use Python's JSON functions, or another transport for the same data structure:

>>> import json
>>> from decimal import Decimal
>>> from fractions import Fraction
>>> from unitx import AffineQuantity, AffineUnit, Quantity, Unit
>>> from unitx.serialization import from_dict, to_dict
>>> original = Quantity(Fraction(1, 3), 'cm')
>>> text = json.dumps(to_dict(original), allow_nan=False)
>>> restored = from_dict(json.loads(text))
>>> type(restored.value) is Fraction
True
>>> restored.value, restored.unit
(Fraction(1, 3), Unit('cm'))
>>> restored == original
True

The module supports Unit, AffineUnit, LogUnit, scalar Quantity, AffineQuantity and LogQuantity. Import the two functions from unitx.serialization; they are not package-root exports. No optional dependency is required.

Stored numbers

Magnitudes must be exact instances of Python int, float, Fraction or Decimal. Booleans, complex numbers, numeric subclasses, custom magnitudes, NumPy scalars and arrays are rejected with TypeError. A round trip preserves the stored magnitude's type and value without converting its unit.

Type tag value field Meaning
int A canonical decimal integer string Retains integers beyond the exact range of a JSON consumer's floating-point numbers
fraction Two integer strings, [numerator, denominator] Exact rational, with positive denominator
float float.hex() text Exact finite binary value, including subnormals and signed zero; inf, -inf and nan are also supported
decimal Decimal text Retains sign, coefficient, trailing zeros and exponent; infinities and NaNs are supported

Float NaNs preserve their category; NaN payload bits and sign are not part of the format. Decimal NaN payloads and signaling status are retained. Export and import do not round a Decimal magnitude in the active context. Invalid Decimal strings are rejected even when the caller disables InvalidOperation traps, without setting flags in the caller's context.

All numerical data except the format version use strings. Consequently strict JSON encoding with allow_nan=False works even for nonfinite magnitudes. Python's normal integer-string conversion and Decimal representation limits still apply.

Units and points

The format preserves the formal product, including prefixes and rational exponents. Equal physical scale does not merge distinct units such as Hz and Bq, and km/m retains its nontrivial dimensionless scale. Every Unit record stores its exact scale separately from its formal product, including when that scale is one:

>>> from unitx import pi
>>> from unitx.unit import Unit
>>> scaled = Unit('m', scale=pi ** Fraction(1, 3))
>>> rebuilt = from_dict(json.loads(json.dumps(to_dict(scaled))))
>>> type(rebuilt) is Unit and rebuilt.scale == scaled.scale
True
>>> rebuilt**3 == scaled**3
True

An AffineUnit stores its linear physical origin and difference unit. A LogUnit stores a positive physical reference, its defining reading at, logarithmic difference unit and convention. Logarithmic exchange retains this anchor without evaluating a zero-reading reference. Registered coordinates also retain their canonical symbol and stable definition ID; anonymous coordinates remain anonymous. Points retain their concrete AffineQuantity or LogQuantity family:

>>> point = AffineQuantity(Decimal('20.00'), 'degC')
>>> rebuilt = from_dict(json.loads(json.dumps(to_dict(point))))
>>> type(rebuilt) is AffineQuantity and rebuilt.unit == point.unit
True
>>> rebuilt.value.as_tuple() == point.value.as_tuple()
True
>>> epoch = AffineUnit(Quantity(946684800, 's'), delta_unit='s')
>>> custom = AffineQuantity(Fraction(1, 2), epoch)
>>> from_dict(to_dict(custom)).unit == epoch
True

This preserves the existing model's information. It does not add clock identity, timezone or source labels to time points. Point arithmetic and the existing hashability rules are unchanged.

Registry requirements

Import resolves spellings against the default environment, or the explicit Registry supplied as from_dict(record, registry=receiver). Both formal definition IDs and physical metadata must match. Share catalogue definitions with Registry([imperial.ft, si.s]).

Custom definitions follow these sharing rules:

Scope Application responsibility
Within one process Include the existing unit objects in each receiving Registry; this shares their formal identities.
Across processes Put reusable definitions in a module-level UnitCatalog in an application-controlled module. Import compatible versions of that catalogue in each process and include the required objects in the receiving Registry. Catalogue persistence locations are derived automatically.
Definitions without a published catalogue location Their UUID handles refer to live definitions in the originating process; they do not provide cross-process reconstruction. This includes units kept only in ordinary blocks or local classes.

Repeating .define() creates a new identity, even with identical spelling, dimension and scale. It does not recreate a saved definition. JSON carries definition references and validation data, not an installable definition bundle. Decoding never imports catalogue modules, constructs missing definitions, registers spellings or replaces definitions. Unknown spellings raise SerializationError with the original UnitSymbolError retained as __cause__. Neither function changes the input object or record. Applications that mutate a receiving Registry must also follow its synchronization contract throughout decoding.

A linear unit record contains its canonical symbols, stable definition IDs and its combined dimension, SI Factor and logarithmic degree. Import checks these against the resolved unit. A registered coordinate must also match its saved affine origin or logarithmic reference anchor, formal reference and difference units, and logarithmic kind. Conflicts raise SerializationError rather than silently changing the result.

These checks describe the resulting unit, not a complete registry snapshot. They do not compare names, aliases, prefix policies or each individual unit's conversion metadata when changes cancel within a product. The receiving application remains responsible for compatible unit definitions. Factor checks compare exact formal state: a changed symbolic approximation or a different unresolved-integer decomposition can cause rejection even if physical values are approximately equal. No floating-point tolerance is used to infer equality.

For catalogue quantities, select the required definitions explicitly:

>>> from unitx import Registry
>>> from unitx.imperial import imperial
>>> from unitx.si import si
>>> receiver = Registry([imperial.ft, si.s])
>>> original = 3 * imperial.parse('ft/s')
>>> from_dict(to_dict(original), registry=receiver) == original
True

The receiver must have compatible bindings for every term. A product containing two independent definitions with the same spelling cannot be represented by one receiving spelling environment. Pickle can preserve such a product by ID.

Version 2 record format

Only the outermost object has "version": 2; every object record has a type. For example, an integer length is represented as:

{
  "version": 2,
  "type": "quantity",
  "value": {"type": "int", "value": "3"},
  "unit": {
    "type": "unit",
    "expression": "m",
    "scale": [],
    "identity": [["", "m", {"module": "unitx.unitregistry", "catalogue": "", "symbol": "m"}, "1", "1"]],
    "dimension": [["0", "1"], ["1", "1"], ["0", "1"], ["0", "1"], ["0", "1"], ["0", "1"], ["0", "1"]],
    "factor": [],
    "log_degree": ["0", "1"]
  }
}

The object records have these exact fields:

type Fields in addition to type
quantity, affine_quantity, log_quantity value scalar record and matching linear/affine/logarithmic unit record
unit expression, scale, identity, dimension, factor, log_degree
affine_unit origin linear Quantity record, delta_unit linear unit record, kind null, symbol canonical registered string or null, definition_id stable ID or null
log_unit reference linear Quantity record, at scalar record, delta_unit linear unit record, kind power/amplitude, symbol canonical registered string or null, definition_id stable ID or null

identity is a sorted list of [prefix_symbol, unprefixed_symbol, definition_id, numerator, denominator] terms; the last two strings encode the nonzero exponent. Definition IDs are structured records: {"module": ..., "catalogue": ..., "symbol": ...} for published definitions, or {"uuid": "<integer>"} for process-local definitions. UUIDs remain typed handles internally; the integer string is their transport form. No module/class/symbol information is encoded in a string or split during lookup. Built-in root definitions use module unitx.unitregistry and an empty catalogue.

dimension lists seven rational pairs in T, L, M, I, Theta, N, J order. log_degree is another rational pair in the existing version 2 wire format. It is derived from Dimension at encoding and folded back into the complete Dimension on validation; it is not a separate runtime state or public attribute.

Factor component lists preserve the formal integer/symbolic bases and rational exponents. The empty list represents one. Each component has an exponent pair and one of these base descriptions:

Component type Other fields
prime value: prime integer string
symbolic symbol: literal label, value: positive finite float.hex() approximation
unfactored value: unresolved integer string, limit: stored factorization-limit integer string

Symbolic labels are reconstructed as SymbolicConstant objects, never evaluated as code. Their mathematical construction requirements remain the caller's responsibility. Unresolved bases keep the existing incomplete-factorization limitations and emit IncompleteFactorWarning on reconstruction.

Unknown versions, tags, missing or extra fields and invalid nested records are rejected. The version is independent of the package release number; future incompatible record changes require a new format version. This is a data format, not a Python class/subclass reconstruction protocol. No eval, dynamic class imports, JSON encoder subclasses, file I/O or configurable codec registry are involved. Applications parsing external input should apply their own size and resource limits.

Python pickle is a separate mechanism with its own reconstruction rules. Current unitx pickles retain definition IDs, but pickle payloads produced by older unitx formats are not supported. Use this versioned JSON format for data exchange.

Migrating version 1 records

The writer emits version 2 and the reader requires it. Version 1 identified units by symbols, so it cannot prove that same-named private definitions share identity. Reading it raises VersionError. Decode old records with unitx 0.13.3 to recover their measurements. Rebuild them using explicitly verified current definitions, then encode with the current implementation. Do not merely change the version number: linear terms need stable IDs and coordinate records need a definition_id field.

For private definitions, place reusable declarations in a module-level UnitCatalog and import that catalogue in the receiving process. Independently repeating .define() creates a new UUID and does not recreate a saved definition. There is no automatic registration or migration of untrusted definitions.

Errors

Import the error classes from unitx.exceptions. The public boundaries are:

Operation and failure Exception
to_dict() receives an unsupported object or magnitude TypeError
from_dict() receives a non-dictionary top-level input TypeError
A record specifies an unsupported integer format version VersionError
A record has no version or the version is not an integer SerializationError
Invalid fields, missing definitions, conflicting unit meanings or failed reconstruction SerializationError

VersionError and SerializationError both inherit UnitxError and ValueError. They are independent classes: VersionError is available for explicit version checks elsewhere in unitx as well. It currently describes the record format version, not the installed package version. Catch the two classes separately for different recovery actions, or catch UnitxError for both:

>>> from unitx.exceptions import SerializationError, UnitxError, VersionError
>>> try:
...     from_dict({'version': 1})
... except VersionError:
...     print('Unsupported record version')
Unsupported record version
>>> try:
...     from_dict({'version': 2, 'type': 'unknown'})
... except SerializationError:
...     print('Invalid record')
Invalid record

Underlying numeric, parsing and coordinate exceptions, including UnitSymbolError, UnitSyntaxError and DimensionError, are retained as error.__cause__ when wrapped. Explicit record-validation errors use SerializationError directly. Exceptions outside the expected validation families, such as RuntimeError, are not wrapped.

The return type of from_dict() is a union of the supported object classes; use isinstance when code requires a specific class. Its scalar magnitude type is the supported union, since arbitrary input data cannot statically determine the original numerical type.

Numerical encodings follow the Python documentation for float hexadecimal conversion, Decimal construction and JSON data exchange.

Documentation index · Scalar behavior · Custom units