The Materia System¶
A Materia is the plugin that connects transmuters to a concrete backend. It is the layer that makes "one object, not two" possible: it knows how to turn a backend record (a provided instance) into a validated transmuter and how to keep the two in sync. This page is the design; to use a materia, start at Usage.
Core vocabulary¶
| Term | Meaning |
|---|---|
| Transmuter | The Pydantic object you work with. Validates, and wraps a provided instance. |
| Provider | The backend object class you bind a transmuter class to (e.g. the AuthorModel ORM class). |
| Provided | A backend object instance of that provider class — the record a transmuter instance wraps, reachable via __transmuter_provided__. |
| Materia | The plugin binding transmuters to a backend (SqlalchemyMateria, NoOpMateria, …). |
| Transmuter Context | A session-scoped cache mapping a provided instance → its transmuter. |
The split is by level: provider is the class, provided is the instance. So the correspondence is class-to-class and instance-to-instance — a transmuter class is bound to a provider (class), and each transmuter instance wraps one provided (instance) of it.
Binding with bless()¶
bless() registers a two-way mapping between a transmuter class and a provider (class). After
blessing, the materia can construct a transmuter from any provided instance it sees — for example,
every ORM row a Session returns:
materia = SqlalchemyMateria()
@materia.bless(AuthorModel) # Author transmuter <-> AuthorModel provider
class Author(BaseTransmuter):
...
The registry¶
The mapping lives on the materia instance, in a formulars registry — a bidirectional dict
(BidirectonDict) keyed by transmuter class with provider class as the value. It is two-way because
both directions are needed at runtime:
- transmuter → provider drives validation and querying: when you build
Author["name"]or validate a transmuter, the materia looks upAuthor's provider class to resolve columns and expressions.materia[Author]returns that provider class (andAuthor in materiatests membership). - provider → transmuter drives blessing on the way out: when a
Sessionhands back anAuthorModelrow, the materia usesformulars.reverseto find the matching transmuter class and wrap the row.
materia[Author] # → AuthorModel (transmuter → provider)
materia.formulars.reverse[AuthorModel] # → Author (provider → reverse lookup)
Author in materia # → True
Because the registry is per-instance, the same transmuter class can be blessed by more than one
materia — e.g. a RedisMateria and a SqlalchemyMateria each holding their own mapping for
Author. Which one is in effect is decided by activation (below), not by the class.
The default NoOpMateria needs no blessing at all — its bless() is a no-op and it is active
automatically, which is why transmuters behave like plain Pydantic models out of the box.
Activating a materia¶
A materia only takes effect while it is the active one. Activation is a context manager backed by
a ContextVar, so it is scoped, thread/async-safe, and nestable:
materia = SqlalchemyMateria()
with materia: # activate for this block
author = session.get_one(Author, 1)
Author["name"] == "Ada" # resolves against `materia`'s registry
# outside the block, the previous materia (NoOpMateria by default) is active again
with materia: activates the instance directly and raises if it is already active. To nest or
re-enter, call it — with materia(): yields a shallow copy that shares the same formulars registry
but gets its own activation token:
with outer_materia:
...
with inner_materia(): # nested activation — note the ()
... # inner_materia is active here
... # back to outer_materia
Switching materia at runtime¶
Because activation is just a context stack, you can switch backends mid-flow — the canonical example
being a cache → database read: try a fast cache materia first, fall back to the database, then
populate the cache. (Pseudo-code; RedisMateria is work in progress.)
db = SqlalchemyMateria()
cache = RedisMateria()
@cache.bless("author") # same transmuter, two materias
@db.bless(AuthorModel)
class Author(BaseTransmuter):
id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
name: str
def read_author(author_id: int) -> Author | None:
with cache: # 1. try the cache backend
hit = redis.get(Author, author_id) # pseudo: read-through cache
if hit is not None:
return hit
with db, Session(engine) as session: # 2. miss → read the database
author = session.get_one(Author, author_id)
with cache: # 3. populate the cache for next time
redis.set(author)
return author
Each with block decides which registry — and which expression compiler, identity rules, and
loading semantics — is in effect for the code inside it. The transmuter class is the same throughout;
only the active materia changes.
Design philosophy: respect the backend¶
Arcanus's guiding rule is that it never fights the target backend's session, atom, and transaction semantics — it cooperates with them:
- Defer, don't pre-load. Relationships are marked deferred during validation and only loaded on access, so Arcanus honors the backend's own lazy-loading strategy instead of forcing eager I/O.
- Don't duplicate the identity map. Within a session, a given provided instance always resolves to the same transmuter. Arcanus leans on the backend's identity map rather than inventing a second one.
- Sync at the backend's boundaries. Server-generated values land after
flush/commit; Arcanus syncs them onto the transmuter automatically after each flush (revalidate()is the explicit full re-sync). Transactions remain the backend's job.
A new backend is "correct" precisely when it upholds these guarantees.
Implementing a new Materia¶
Building a backend means upholding the lifecycle guarantees — the strong/weak reference design in Lifecycle & Async → the reference architecture. There are three required components.
1. Provider mixin¶
Provider objects must carry the weak back-reference to their transmuter. Mix in
TransmuterProxiedMixin, which supplies _transmuter_proxy and the transmuter_proxy weakref
property:
from arcanus.base import TransmuterProxiedMixin
class YourProviderBase(TransmuterProxiedMixin):
"""Base class for your backend's provider objects."""
# provides: _transmuter_proxy + transmuter_proxy (weakref getter/setter)
2. Session / context manager¶
Own a WeakKeyDictionary[provided, transmuter] and activate it as the validation context for the
session's lifetime, so validation resolves providers to the same transmuter:
from weakref import WeakKeyDictionary
from arcanus.base import validation_context
class YourSession:
def __init__(self) -> None:
self._transmuter_context = WeakKeyDictionary()
def __enter__(self):
self._cm = validation_context(self._transmuter_context)
self._cm.__enter__()
return self
def __exit__(self, *exc):
self._cm.__exit__(*exc)
When you add a transmuter, store its provider in the context (strongly, as the dict value) and
cascade to already-proxied related providers so they resolve to the same transmuters:
def add(self, transmuter):
provided = transmuter.__transmuter_provided__
self._identity_map_add(provided) # backend owns it strongly
self._transmuter_context[provided] = transmuter
for related in self._cascade(provided):
if (t := getattr(related, "transmuter_proxy", None)) is not None:
self._transmuter_context[related] = t
3. Registration¶
Implement bless() to record the transmuter-class ↔ provider-class mapping in formulars:
from arcanus.materia.base import BaseMateria
class YourMateria(BaseMateria):
def bless(self, provider_cls):
def decorator(transmuter_cls):
self.formulars[transmuter_cls] = provider_cls
return transmuter_cls
return decorator
Memory-safety checklist
- Provided → Transmuter is a weakref (prevents the strong cycle).
- The context is a
WeakKeyDictionary(weak keys, strong values) — entries auto-clear when a provided instance is collected. - The session holds provided instances strongly; the transmuter holds its provided instance
strongly via
__transmuter_provided__. - There is no other strong reference from the provided instance back to the transmuter.
Testing a new materia¶
Three checks catch the common mistakes:
- Identity / no duplicates — build many related objects in a loop, then verify each provider resolves to a single transmuter (not a fresh one per access).
- Circular references — set up a bidirectional relationship and assert
child.parent is parent(same instance, via the context cache). - No leaks — hold a
weakrefto a transmuter, close the session,gc.collect(), and assert the weakref is dead — proving the context released it with its provider.