Writing an adaptor¶
An adaptor turns one library's schema object into a language whose
members are that library's records, and there will be many of them, so
the package's own seven have no privileged path: each is an ordinary
registration under mathema's mathema.language_adaptors entry-point
group, found through the same registry an adaptor from your package is
found through, and passes the same conformance checks. The pages
beside this one describe the seven: dataclasses,
TypedDicts, pydantic,
JSON Schema, SQLAlchemy,
Django and text annotations.
The contract¶
An adaptor is a callable, adapt(obj), that returns a language for a
schema object of its own library and None for anything else, and it
decides that an object is not its own without importing its library: if the library was
never imported, nothing in the process can be one of its objects, so
sys.modules.get("mylib") is the whole test. It may refuse an object
that is its own but that it cannot read faithfully (a $ref it does
not follow, a regular expression with no validator to hold it to) by
raising with a message that says what to do instead.
Which adaptor is asked first¶
mathema asks the adaptors in an explicit order: a higher priority is
asked first, ties going to the entry-point name. The package uses three
bands, and an adaptor from another package marks itself with the same
ones, @priority(LIBRARY) from mathema_language.adaptor_priority:
| Priority | For | Here |
|---|---|---|
| 100 | an adaptor that recognises a library's own model classes | pydantic, SQLAlchemy, Django |
| 50 | a schema written as data | JSON Schema |
| 0 | a structural reading of any class of a standard shape, or of a plain annotation | dataclass, TypedDict, text |
So a class two adaptors would both accept goes to the more specific
one: a SQLAlchemy class that is also a dataclass (MappedAsDataclass)
is read by the SQLAlchemy adaptor, with its database constraints, rather
than by the dataclass adaptor, which would see only the annotations.
The public surface¶
What an adaptor needs is in mathema_language.schema: the neutral model
(RowSchema, Field, NeutralType, Constraints, BASES,
NO_DEFAULT), the Ecosystem protocol (name, accepts, to_model,
build_row, validate_row), two ecosystems to reuse (PlainEcosystem
for records as dicts, AttributeEcosystem for records as instances of
a class), and RowLanguage(schema, ecosystem, name), which does the
rest: generation per field, the hazards, outside, shrinking, and the
field bounds the derive lift reads. A library with a validator of its
own writes an ecosystem whose validate_row calls it, so membership is
the library's, the way the pydantic ecosystem calls model_validate.
An example¶
A small library that describes a record as a class with a SPEC
mapping of field names to types:
import sys
from mathema_language.adaptor_priority import LIBRARY, priority
from mathema_language.schema import (Constraints, Field, NeutralType,
AttributeEcosystem, RowLanguage, RowSchema)
class Spec:
"""The base class of the imaginary library's records."""
class Line(Spec):
SPEC = {"qty": (int, 1, 10), "price": (float, 0.0, None)}
def __init__(self, qty, price):
self.qty, self.price = qty, price
def __repr__(self):
return f"Line(qty={self.qty!r}, price={self.price!r})"
_BASES = {int: "int", float: "float", str: "string"}
@priority(LIBRARY)
def adapt(obj):
"""The record language of a `Spec` subclass, or None."""
base = getattr(sys.modules.get(__name__), "Spec", None)
if base is None or not (isinstance(obj, type) and issubclass(obj, base) and obj is not base):
return None
fields = tuple(Field(name, NeutralType(_BASES[kind]), constraints=Constraints(min=lo, max=hi))
for name, (kind, lo, hi) in obj.SPEC.items())
return RowLanguage(RowSchema(obj.__name__, fields), AttributeEcosystem(obj), obj.__name__)
It registers in the library's own pyproject.toml:
[project.entry-points."mathema.language_adaptors"]
speclib = "speclib.mathema:adapt"
Testing it¶
mathema_language.conformance holds the checks the package's own
adaptors pass. record_adaptor_problems(obj) lists every way the language
of obj falls short, and is empty when there is none: the registry
returns it through this adaptor, the language satisfies mathema's protocol, every
hazard and fifty random members are members by the ecosystem's own
validator, outside never draws a member and explains itself in the
path grammar, every shrink stays inside, and fields() has the shape
the derive lift reads. foreign_object_problems(adapt) checks the other half:
None for objects that are not the library's, with nothing imported
along the way. A test asserts that each list is empty.
In the library's own tests, with the adaptor installed, leave the
registry check on; here the adaptor is not installed, so it is off:
from mathema_language.conformance import foreign_object_problems, record_adaptor_problems
print(record_adaptor_problems(Line, adapt=adapt, registered=False))
print(foreign_object_problems(adapt))
[]
[]