Building Isolated UnitSystems¶
ucon's UnitSystem value type bundles a basis, the unit and dimension
registries, the conversion and basis graphs, and a canonical BaseUnits
mapping into a single passable value. This guide covers when and how to
construct an isolated UnitSystem — one that doesn't share state with the
global default.
For the rationale and equality semantics, see the UnitSystem Value Type architecture page.
Three Construction Modes¶
There are three ways to obtain a UnitSystem:
from ucon import UnitSystem, BaseUnits, active_system, units
# 1. Capture the active system
default = active_system()
# 2. Derive with a different BaseUnits mapping
imperial = default.with_base_units(units.imperial)
# 3. Construct directly to replace specific registries
isolated = UnitSystem(
basis=default.basis,
units=default.units,
dimensions=default.dimensions,
base_units=default.base_units,
conversion_graph=my_isolated_graph, # the one field you actually want different
basis_graph=default.basis_graph,
contexts={},
constants=default.constants,
)
Most code uses mode 1 or 2. Mode 3 is for the case the rest of this guide covers — when you need a different world in one specific axis.
When You Need Isolation¶
The four scenarios that justify direct construction:
1. Test fixtures¶
Library tests that register custom units mutate the global conversion graph.
Run two such tests in the same process and the second test sees the first
test's units. An isolated UnitSystem gives each test its own
ConversionGraph:
import pytest
from ucon import UnitSystem, active_system, use
from ucon.graph import ConversionGraph
@pytest.fixture
def isolated_system():
parent = active_system()
return UnitSystem(
basis=parent.basis,
units=parent.units,
dimensions=parent.dimensions,
base_units=parent.base_units,
conversion_graph=ConversionGraph(), # empty per-test graph
basis_graph=parent.basis_graph,
contexts={},
constants=parent.constants,
)
def test_custom_unit(isolated_system):
with use(isolated_system):
# Register and convert in isolation
...
# Outside the with-block: globals are untouched
2. Multi-tenant services¶
A web service that registers customer-specific units per request must not let
those registrations leak into other customers' computations. Per-request
isolation matches the UnitSystem-per-request pattern naturally:
def handle_request(request):
tenant_system = build_tenant_system(request.tenant_id)
with use(tenant_system):
return run_pipeline(request)
3. Reproducible pipelines¶
A scientific analysis that pins exactly one basis, one conversion graph, and
one constants set produces byte-identical outputs every run — even if other
parts of the same process register units later. Construct the UnitSystem
once at startup, persist it (or rebuild from the same TOML package), and pin
it across the run.
4. Concurrent bases¶
A simulation in CGS-Gaussian and a downstream SI consumer in the same process
can each pin their own UnitSystem. The ContextVar underlying use(...)
makes this work correctly across asyncio tasks and threads.
Step-by-Step: An Isolated UnitSystem for Aerospace¶
This worked example builds a fully self-contained UnitSystem that registers
aerospace units in its own conversion graph, leaving the global graph
untouched.
Step 1: Capture the parent¶
parent shares its units and dimensions registries with the active system
(by reference). That's fine — we only want to isolate the conversion graph.
Step 2: Start a fresh conversion graph¶
.copy() gives us a graph with every default edge already present (so
meter → foot still works) but distinct from the global default. Edges added
to aero_graph won't appear in get_default_graph().
Step 3: Register aerospace units on the isolated graph¶
from ucon import Dimension
from ucon.core import Scale, Unit
from ucon.maps import LinearMap
from ucon import units
slug = Unit("slug", "slug", Dimension.mass, aliases=("slug",))
nmi = Unit("nautical_mile", "nmi", Dimension.length, aliases=("NM", "nmi"))
kilogram = Scale.kilo * units.gram
aero_graph.register_unit(slug)
aero_graph.register_unit(nmi)
aero_graph.add_edge(slug, kilogram, LinearMap(14.5939))
aero_graph.add_edge(nmi, units.meter, LinearMap(1852))
Step 4: Wrap it in a UnitSystem¶
aero_system = UnitSystem(
basis=parent.basis,
units=parent.units,
dimensions=parent.dimensions,
base_units=parent.base_units,
conversion_graph=aero_graph,
basis_graph=parent.basis_graph,
contexts=parent.contexts,
constants=parent.constants,
)
Step 5: Activate with use(...)¶
from ucon import use
with use(aero_system):
mass_kg = slug(1).to(kilogram)
print(mass_kg) # <14.5939 kg>
Outside the with-block, the global graph is unchanged — slug is not
registered, nautical_mile is not registered, and the user-facing entry
points (Number.to, enforce_dimensions, parse, parse_unit,
parse_dimension, ucon.basis.ops.*) see only what the global registries
expose.
Threading system= Explicitly¶
use(...) sets a ContextVar. Every entry point that reads the active
system also accepts system= as an explicit override. Use it when you want a
call-site decision rather than a contextmanager:
Entry points that accept system=:
| Function | Module |
|---|---|
Number.to(target, *, system=None) |
ucon.core |
enforce_dimensions(*, system=None) |
ucon.checking |
parse(s, *, system=None) |
ucon.parsing |
parse_unit(s, *, system=None) |
ucon.resolver |
parse_dimension(s, *, system=None) |
ucon.parsing |
get_unit_by_name(n, *, system=None) |
ucon.resolver |
multiply_via(*, system=None) |
ucon.basis.ops |
divide_via(*, system=None) |
ucon.basis.ops |
unify(*, system=None) |
ucon.basis.ops |
Explicit system= wins over the active use(...) context. Useful for tests
where you want to assert "this call must use that system" without nesting.
Equality and Reuse¶
Two captures of active_system() compare equal even though they are
distinct objects:
Systems remain equal until you mutate one of the shared registries. If your
pipeline relies on __hash__ (e.g., as a dict key), pin one system at
startup rather than re-capturing per call.
A directly-constructed UnitSystem with an isolated graph compares unequal
to the active system:
That's by design: a different conversion graph means a different world.
Replacing Legacy using_* Stacks¶
Prior to v2.0, isolating "the world" required multiple nested context managers:
# Legacy — three nested contexts (removed in v2.0)
with using_basis(my_basis), using_conversion_graph(my_graph), using_context(my_ctx):
...
v2.0 collapses that to one:
# v2.0 — one UnitSystem
my_system = UnitSystem(
basis=my_basis,
conversion_graph=my_graph,
contexts={"my_ctx": my_ctx},
# other fields from parent system
...
)
with use(my_system):
...
use(...) sets a single ContextVar that carries the entire UnitSystem.
The legacy using_basis, using_basis_graph, and related per-field context
managers have been removed in v2.0.
Testing Checklist¶
When you write a test that builds an isolated UnitSystem, verify:
- [ ] Global graph membership is unchanged after the test
- [ ] Activating the system inside
use(...)is what enables your custom unit - [ ]
system=kwarg matches the contextmanager
Further Reading¶
- UnitSystem Value Type — the rationale
- Custom Units & Graphs — graph-only isolation
- Particle Physics walkthrough — end-to-end example