didactic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. didactic/_self_describing.py +206 -0
  2. didactic/api.py +126 -0
  3. didactic/axioms/__init__.py +11 -0
  4. didactic/axioms/_axiom_enforcement.py +236 -0
  5. didactic/axioms/_axioms.py +137 -0
  6. didactic/cli/__init__.py +7 -0
  7. didactic/cli/_cli.py +241 -0
  8. didactic/codegen/__init__.py +54 -0
  9. didactic/codegen/_emitter.py +286 -0
  10. didactic/codegen/_json_schema.py +215 -0
  11. didactic/codegen/_write.py +128 -0
  12. didactic/codegen/io.py +278 -0
  13. didactic/codegen/source.py +198 -0
  14. didactic/fields/__init__.py +28 -0
  15. didactic/fields/_computed.py +149 -0
  16. didactic/fields/_derived.py +142 -0
  17. didactic/fields/_fields.py +543 -0
  18. didactic/fields/_refs.py +246 -0
  19. didactic/fields/_unions.py +255 -0
  20. didactic/fields/_validators.py +164 -0
  21. didactic/lenses/__init__.py +16 -0
  22. didactic/lenses/_dependent_lens.py +315 -0
  23. didactic/lenses/_lens.py +444 -0
  24. didactic/lenses/_testing.py +302 -0
  25. didactic/migrations/__init__.py +38 -0
  26. didactic/migrations/_diff.py +128 -0
  27. didactic/migrations/_fingerprint.py +220 -0
  28. didactic/migrations/_migrations.py +499 -0
  29. didactic/migrations/_synthesis.py +138 -0
  30. didactic/models/__init__.py +19 -0
  31. didactic/models/_config.py +104 -0
  32. didactic/models/_meta.py +469 -0
  33. didactic/models/_model.py +790 -0
  34. didactic/models/_root.py +124 -0
  35. didactic/models/_storage.py +151 -0
  36. didactic/py.typed +0 -0
  37. didactic/theory/__init__.py +8 -0
  38. didactic/theory/_theory.py +477 -0
  39. didactic/types/__init__.py +16 -0
  40. didactic/types/_types.py +866 -0
  41. didactic/types/_types_lib.py +147 -0
  42. didactic/types/_typing.py +136 -0
  43. didactic/vcs/__init__.py +10 -0
  44. didactic/vcs/_backref.py +232 -0
  45. didactic/vcs/_repo.py +328 -0
  46. didactic-0.1.0.dist-info/METADATA +68 -0
  47. didactic-0.1.0.dist-info/RECORD +49 -0
  48. didactic-0.1.0.dist-info/WHEEL +4 -0
  49. didactic-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,206 @@
1
+ """Self-describing JSON via fingerprint URIs.
2
+
3
+ A panproto-native form of self-describing data: an emitted JSON
4
+ payload carries a ``$schema`` URI of the form
5
+ ``didactic://v1/<structural-fingerprint>``. A consumer that holds a
6
+ registry of known Theories (by fingerprint) can validate an unknown
7
+ payload by looking the URI up.
8
+
9
+ This is something Pydantic structurally cannot do, because Pydantic
10
+ has no content-addressed schema identity. didactic's structural
11
+ fingerprint already gives every Model a stable address; this module
12
+ just plumbs it through to JSON.
13
+
14
+ Examples
15
+ --------
16
+ >>> import didactic.api as dx
17
+ >>>
18
+ >>> class User(dx.Model):
19
+ ... id: str
20
+ ... email: str
21
+ >>>
22
+ >>> u = User(id="u1", email="ada@example.org")
23
+ >>> payload = dx.embed_schema_uri(u)
24
+ >>> payload["$schema"].startswith("didactic://v1/")
25
+ True
26
+
27
+ See Also
28
+ --------
29
+ didactic.migrations._fingerprint.structural_fingerprint : the underlying address.
30
+ """
31
+
32
+ # Cross-translation between ``FieldValue`` and ``JsonValue`` shapes
33
+ # in the schema-URI registry layer.
34
+ # Tracked in panproto/didactic#1.
35
+ # pyright: reportArgumentType=false, reportReturnType=false
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import TYPE_CHECKING
40
+
41
+ if TYPE_CHECKING:
42
+ from didactic.models._model import Model
43
+ from didactic.types._typing import JsonObject
44
+
45
+ #: URI scheme prefix didactic uses for self-describing payloads.
46
+ URI_PREFIX = "didactic://v1/"
47
+
48
+
49
+ def schema_uri(cls: type[Model]) -> str:
50
+ """Return the canonical schema URI for a Model class.
51
+
52
+ Parameters
53
+ ----------
54
+ cls
55
+ A [Model][didactic.api.Model] subclass.
56
+
57
+ Returns
58
+ -------
59
+ str
60
+ ``"didactic://v1/<fingerprint>"`` where ``<fingerprint>`` is
61
+ the Model's structural fingerprint.
62
+ """
63
+ from didactic.migrations._fingerprint import structural_fingerprint # noqa: PLC0415
64
+ from didactic.theory._theory import build_theory_spec # noqa: PLC0415
65
+
66
+ return f"{URI_PREFIX}{structural_fingerprint(build_theory_spec(cls))}"
67
+
68
+
69
+ def embed_schema_uri(instance: Model) -> JsonObject:
70
+ """Return ``instance.model_dump()`` with a ``$schema`` URI prepended.
71
+
72
+ Parameters
73
+ ----------
74
+ instance
75
+ A Model instance.
76
+
77
+ Returns
78
+ -------
79
+ dict
80
+ The dump dict, with ``"$schema"`` set to the Model's
81
+ canonical URI as the first key.
82
+
83
+ Notes
84
+ -----
85
+ A consumer that knows how to resolve ``didactic://v1/<fp>`` URIs
86
+ can fetch the Theory by fingerprint and validate the payload
87
+ without knowing the original Python class.
88
+ """
89
+ payload = instance.model_dump()
90
+ return {"$schema": schema_uri(type(instance)), **payload}
91
+
92
+
93
+ class FingerprintRegistry:
94
+ """An in-memory mapping of structural fingerprint to Model class.
95
+
96
+ Use as the lookup side of a self-describing JSON pipeline:
97
+ register every Model your application understands, then
98
+ [validate_with_uri_lookup][didactic.api.validate_with_uri_lookup]
99
+ can resolve an unknown payload's ``$schema`` URI back to a class.
100
+
101
+ Examples
102
+ --------
103
+ >>> import didactic.api as dx
104
+ >>> class User(dx.Model):
105
+ ... id: str
106
+ >>>
107
+ >>> reg = dx.FingerprintRegistry()
108
+ >>> reg.register(User)
109
+ >>>
110
+ >>> u = User(id="u1")
111
+ >>> payload = dx.embed_schema_uri(u)
112
+ >>> back = dx.validate_with_uri_lookup(payload, reg)
113
+ >>> back == u
114
+ True
115
+ """
116
+
117
+ __slots__ = ("_by_fingerprint",)
118
+
119
+ def __init__(self) -> None:
120
+ self._by_fingerprint: dict[str, type[Model]] = {}
121
+
122
+ def register[M: Model](self, cls: type[M]) -> type[M]:
123
+ """Register ``cls`` under its structural fingerprint."""
124
+ from didactic.migrations._fingerprint import ( # noqa: PLC0415
125
+ structural_fingerprint,
126
+ )
127
+ from didactic.theory._theory import build_theory_spec # noqa: PLC0415
128
+
129
+ fp = structural_fingerprint(build_theory_spec(cls))
130
+ self._by_fingerprint[fp] = cls
131
+ return cls
132
+
133
+ def lookup(self, uri: str) -> type[Model] | None:
134
+ """Resolve a ``didactic://v1/<fp>`` URI to a registered class."""
135
+ if not uri.startswith(URI_PREFIX):
136
+ return None
137
+ fp = uri[len(URI_PREFIX) :]
138
+ return self._by_fingerprint.get(fp)
139
+
140
+ def __contains__(self, cls_or_uri: object) -> bool:
141
+ if isinstance(cls_or_uri, str):
142
+ return self.lookup(cls_or_uri) is not None
143
+ if isinstance(cls_or_uri, type):
144
+ from didactic.migrations._fingerprint import ( # noqa: PLC0415
145
+ structural_fingerprint,
146
+ )
147
+ from didactic.theory._theory import build_theory_spec # noqa: PLC0415
148
+
149
+ return (
150
+ structural_fingerprint(build_theory_spec(cls_or_uri))
151
+ in self._by_fingerprint
152
+ )
153
+ return False
154
+
155
+ def __len__(self) -> int:
156
+ return len(self._by_fingerprint)
157
+
158
+
159
+ def validate_with_uri_lookup(
160
+ payload: JsonObject,
161
+ registry: FingerprintRegistry,
162
+ ) -> Model:
163
+ """Validate ``payload`` against the Model named by its ``$schema`` URI.
164
+
165
+ Parameters
166
+ ----------
167
+ payload
168
+ A dict that includes a ``$schema`` key.
169
+ registry
170
+ A [FingerprintRegistry][didactic.api.FingerprintRegistry] mapping
171
+ URIs to Model classes.
172
+
173
+ Returns
174
+ -------
175
+ Model
176
+ A validated Model instance whose class came from the
177
+ registry.
178
+
179
+ Raises
180
+ ------
181
+ LookupError
182
+ If the URI is not registered.
183
+ KeyError
184
+ If the payload has no ``$schema`` key.
185
+ """
186
+ if "$schema" not in payload:
187
+ msg = "validate_with_uri_lookup: payload has no $schema key"
188
+ raise KeyError(msg)
189
+
190
+ uri = payload["$schema"]
191
+ cls = registry.lookup(uri)
192
+ if cls is None:
193
+ msg = f"validate_with_uri_lookup: no registered model for URI {uri!r}"
194
+ raise LookupError(msg)
195
+
196
+ body = {k: v for k, v in payload.items() if k != "$schema"}
197
+ return cls.model_validate(body)
198
+
199
+
200
+ __all__ = [
201
+ "URI_PREFIX",
202
+ "FingerprintRegistry",
203
+ "embed_schema_uri",
204
+ "schema_uri",
205
+ "validate_with_uri_lookup",
206
+ ]
didactic/api.py ADDED
@@ -0,0 +1,126 @@
1
+ """Aggregator module for the didactic API.
2
+
3
+ `didactic` lets you author class-based, declarative data models the way
4
+ [Pydantic][pydantic] does, while the underlying values are
5
+ [panproto][panproto] [Theory][panproto.Theory] and
6
+ [Schema][panproto.Schema] instances rather than ad hoc Python objects with
7
+ bolt-on validation. The selling point is everything you get *for free*
8
+ once your data is panproto-native: lenses, dependent optics, theory
9
+ colimits, schema migrations as data, vertex-level VCS, and cross-language
10
+ semantic export.
11
+
12
+ [pydantic]: https://docs.pydantic.dev/
13
+ [panproto]: https://github.com/panproto/panproto
14
+
15
+ Notes
16
+ -----
17
+ The conventional alias for this module is ``dx``::
18
+
19
+ import didactic.api as dx
20
+
21
+
22
+ class User(dx.Model):
23
+ id: str
24
+ email: str
25
+
26
+ The ``didactic`` namespace itself is a PEP 420 implicit namespace
27
+ package; the four distributions (``didactic`` / ``didactic-pydantic`` /
28
+ ``didactic-settings`` / ``didactic-fastapi``) each contribute a
29
+ sub-package (``didactic.api``, ``didactic.pydantic``,
30
+ ``didactic.settings``, ``didactic.fastapi``) without an
31
+ ``__init__.py`` at the namespace root. This lets static type checkers
32
+ resolve cross-distribution imports without a ``pkgutil`` workaround.
33
+ """
34
+
35
+ from didactic import codegen
36
+ from didactic._self_describing import (
37
+ FingerprintRegistry,
38
+ embed_schema_uri,
39
+ schema_uri,
40
+ validate_with_uri_lookup,
41
+ )
42
+ from didactic.axioms._axioms import Axiom, axiom
43
+ from didactic.fields._computed import computed
44
+ from didactic.fields._derived import derived
45
+ from didactic.fields._fields import Field, FieldSpec, field
46
+ from didactic.fields._refs import Backref, Embed, Ref
47
+ from didactic.fields._unions import TaggedUnion
48
+ from didactic.fields._validators import (
49
+ ValidationError,
50
+ ValidationErrorEntry,
51
+ validates,
52
+ )
53
+ from didactic.lenses import _testing as testing
54
+ from didactic.lenses._dependent_lens import DependentLens
55
+ from didactic.lenses._lens import Iso, Lens, Mapping, lens
56
+ from didactic.migrations._diff import classify_change, diff, is_breaking_change
57
+ from didactic.migrations._migrations import (
58
+ load_registry,
59
+ migrate,
60
+ register_migration,
61
+ save_registry,
62
+ )
63
+ from didactic.migrations._synthesis import SynthesisResult, synthesise_migration
64
+ from didactic.models._config import DEFAULT_CONFIG, ExtraPolicy, ModelConfig
65
+ from didactic.models._model import BaseModel, Model
66
+ from didactic.models._root import RootModel, TypeAdapter
67
+ from didactic.types import _types_lib as types
68
+ from didactic.vcs._backref import ModelPool, resolve_backrefs
69
+ from didactic.vcs._repo import Repository
70
+
71
+ __version__ = "0.1.0"
72
+
73
+ #: Conventional namespace for lens utilities (`dx.lens.identity(...)`,
74
+ #: `dx.lens.Lens`, etc.). The ``lens`` name doubles as a decorator
75
+ #: (``@dx.lens(A, B)``) and as a module-style namespace. The attributes
76
+ #: are bound by ``_LensNamespace.__init__`` in :mod:`didactic.lenses._lens`.
77
+
78
+
79
+ __all__ = [
80
+ "DEFAULT_CONFIG",
81
+ "Axiom",
82
+ "Backref",
83
+ "BaseModel",
84
+ "DependentLens",
85
+ "Embed",
86
+ "ExtraPolicy",
87
+ "Field",
88
+ "FieldSpec",
89
+ "FingerprintRegistry",
90
+ "Iso",
91
+ "Lens",
92
+ "Mapping",
93
+ "Model",
94
+ "ModelConfig",
95
+ "ModelPool",
96
+ "Ref",
97
+ "Repository",
98
+ "RootModel",
99
+ "SynthesisResult",
100
+ "TaggedUnion",
101
+ "TypeAdapter",
102
+ "ValidationError",
103
+ "ValidationErrorEntry",
104
+ "__version__",
105
+ "axiom",
106
+ "classify_change",
107
+ "codegen",
108
+ "computed",
109
+ "derived",
110
+ "diff",
111
+ "embed_schema_uri",
112
+ "field",
113
+ "is_breaking_change",
114
+ "lens",
115
+ "load_registry",
116
+ "migrate",
117
+ "register_migration",
118
+ "resolve_backrefs",
119
+ "save_registry",
120
+ "schema_uri",
121
+ "synthesise_migration",
122
+ "testing",
123
+ "types",
124
+ "validate_with_uri_lookup",
125
+ "validates",
126
+ ]
@@ -0,0 +1,11 @@
1
+ """Class-level axioms and their construction-time enforcement."""
2
+
3
+ from didactic.axioms._axiom_enforcement import check_class_axioms
4
+ from didactic.axioms._axioms import Axiom, axiom, collect_class_axioms
5
+
6
+ __all__ = [
7
+ "Axiom",
8
+ "axiom",
9
+ "check_class_axioms",
10
+ "collect_class_axioms",
11
+ ]
@@ -0,0 +1,236 @@
1
+ """Axiom enforcement: parse axiom strings into predicates run at construction.
2
+
3
+ Each ``__axioms__`` entry is parsed into a panproto ``Expr`` AST via
4
+ ``panproto.parse_expr``, then evaluated against a Model instance's
5
+ field values during construction. Failures raise
6
+ [didactic.api.ValidationError][didactic.api.ValidationError] with the axiom's
7
+ message.
8
+
9
+ The evaluator walks the panproto Expr ``to_dict()`` form, which is a
10
+ tagged union of ``{"Builtin": ...}``, ``{"Var": ...}``, ``{"Lit": ...}``,
11
+ and a small set of other variants.
12
+
13
+ See Also
14
+ --------
15
+ didactic.axioms._axioms : the Axiom record type and ``__axioms__`` collection.
16
+ panproto.parse_expr : the underlying parser.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING, cast
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Sequence
25
+
26
+ from didactic.axioms._axioms import Axiom
27
+ from didactic.types._typing import FieldValue, JsonValue
28
+
29
+
30
+ # A panproto Expr ``to_dict()`` AST node is one of any JsonValue-shaped
31
+ # payload — at the leaves of dispatch we narrow with ``match`` /
32
+ # ``isinstance``. Using ``JsonValue`` (rather than the narrower
33
+ # "single-key dict envelope" shape) lets list-positional accesses like
34
+ # ``args[0]`` inside ``Builtin`` typecheck without a per-element
35
+ # ``isinstance`` chain.
36
+ type ExprNode = JsonValue
37
+ # The evaluator returns a Python value whose runtime type depends on
38
+ # the expression: a literal evaluates to a scalar, a variable lookup
39
+ # returns whatever ``env`` holds, an arithmetic op returns the result
40
+ # of Python's operator protocol over those leaves. The union is
41
+ # constructed from the documented ``FieldValue`` set (everything that
42
+ # can sit in an environment binding); callers narrow per use site
43
+ # (e.g. the predicate wrapper ``_predicate`` casts the result to
44
+ # ``bool``).
45
+ type EvalResult = FieldValue
46
+
47
+
48
+ def parse_axiom_predicate(axiom: Axiom): # type: ignore[no-untyped-def]
49
+ """Parse an axiom expression into a callable predicate.
50
+
51
+ Parameters
52
+ ----------
53
+ axiom
54
+ An [Axiom][didactic.api.Axiom] instance whose ``expr`` is the
55
+ Haskell-style surface syntax accepted by
56
+ ``panproto.parse_expr``.
57
+
58
+ Returns
59
+ -------
60
+ Callable[[dict[str, FieldValue]], bool]
61
+ A predicate that takes a ``{field_name: value}`` environment
62
+ and returns whether the axiom holds.
63
+
64
+ Raises
65
+ ------
66
+ panproto.ExprError
67
+ If the axiom cannot be parsed.
68
+
69
+ Notes
70
+ -----
71
+ The parser is invoked once per ``Axiom``; cache the result if you
72
+ plan to evaluate the same axiom many times.
73
+ """
74
+ import panproto # noqa: PLC0415
75
+
76
+ expr = panproto.parse_expr(axiom.expr)
77
+ # ``Expr.to_dict()`` returns ``dict[str, object]`` (the panproto
78
+ # binding doesn't declare a tighter type because the leaf values
79
+ # are dynamic). Cast to the ``JsonValue`` shape ``_evaluate``
80
+ # expects; the runtime contract guarantees JSON-shaped leaves.
81
+ ast = cast("JsonValue", expr.to_dict())
82
+
83
+ def _predicate(env: dict[str, FieldValue]) -> bool:
84
+ return bool(_evaluate(ast, env))
85
+
86
+ return _predicate
87
+
88
+
89
+ def _evaluate(node: ExprNode, env: dict[str, FieldValue]) -> EvalResult:
90
+ """Walk a panproto Expr ``to_dict`` AST and evaluate against ``env``.
91
+
92
+ The function handles the common subset that shows up in axiom
93
+ expressions: variable lookup, literals, comparison operators,
94
+ boolean connectives, and arithmetic. Unrecognised node shapes
95
+ raise ``NotImplementedError``.
96
+
97
+ Pattern-matched on the panproto Expr shape: each variant is
98
+ a single-key dict whose value has a known structure. The match
99
+ statements narrow the dynamic ``ExprNode`` shape into the typed
100
+ sub-shapes the helpers consume.
101
+ """
102
+ match node:
103
+ case {"Var": str(name)}:
104
+ if name not in env:
105
+ msg = f"axiom references unbound variable {name!r}"
106
+ raise NameError(msg)
107
+ return env[name]
108
+ case {"Lit": dict(lit)}:
109
+ return _evaluate_literal(lit)
110
+ case {"Builtin": [str(op), list(args)]}:
111
+ return _evaluate_builtin(op, args, env)
112
+ case {"App": [dict(func), arg]}:
113
+ # general App: rare in axioms; treat as a builtin family if
114
+ # head is a Var pointing at a known function name
115
+ if "Var" in func and isinstance(func["Var"], str):
116
+ return _evaluate_builtin(func["Var"], [arg], env)
117
+ msg = f"App nodes with non-Var heads are not supported: {func!r}"
118
+ raise NotImplementedError(msg)
119
+ case _:
120
+ msg = f"unsupported Expr node shape: {node!r}"
121
+ raise NotImplementedError(msg)
122
+
123
+
124
+ def _evaluate_literal(lit: ExprNode) -> EvalResult:
125
+ """Evaluate a panproto literal node."""
126
+ match lit:
127
+ case {"Int": int(v) | str(v)}:
128
+ return int(v)
129
+ case {"Float": float(v) | int(v) | str(v)}:
130
+ return float(v)
131
+ case {"Str": str(v)}:
132
+ return v
133
+ case {"Bool": bool(v)}:
134
+ return v
135
+ case {"Char": str(v)}:
136
+ return v
137
+ case _:
138
+ msg = f"unsupported Literal: {lit!r}"
139
+ raise NotImplementedError(msg)
140
+
141
+
142
+ def _evaluate_builtin(
143
+ op: str,
144
+ args: Sequence[ExprNode],
145
+ env: dict[str, FieldValue],
146
+ ) -> EvalResult:
147
+ """Evaluate a panproto builtin operator.
148
+
149
+ All comparison and arithmetic operators rely on Python's
150
+ duck-typed operator protocols; the values come from
151
+ ``_evaluate``'s ``EvalResult`` (an alias for ``object``) and the
152
+ runtime contract is that an axiom expression's leaf values are
153
+ comparable with the relevant operators. ``# type: ignore`` markers
154
+ sit at each operator site because pyright cannot prove that
155
+ arbitrary ``object`` values support ``<``/``+``/etc.
156
+ """
157
+ # comparison and equality (panproto uses Gte/Lte/Neq variants)
158
+ if op == "Eq":
159
+ a, b = (_evaluate(args[0], env), _evaluate(args[1], env))
160
+ return a == b
161
+ if op in ("Ne", "Neq"):
162
+ return _evaluate(args[0], env) != _evaluate(args[1], env)
163
+ if op == "Lt":
164
+ return _evaluate(args[0], env) < _evaluate(args[1], env) # type: ignore[operator]
165
+ if op in ("Le", "Lte"):
166
+ return _evaluate(args[0], env) <= _evaluate(args[1], env) # type: ignore[operator]
167
+ if op == "Gt":
168
+ return _evaluate(args[0], env) > _evaluate(args[1], env) # type: ignore[operator]
169
+ if op in ("Ge", "Gte"):
170
+ return _evaluate(args[0], env) >= _evaluate(args[1], env) # type: ignore[operator]
171
+
172
+ # boolean connectives
173
+ if op == "And":
174
+ return all(bool(_evaluate(a, env)) for a in args)
175
+ if op == "Or":
176
+ return any(bool(_evaluate(a, env)) for a in args)
177
+ if op == "Not":
178
+ return not bool(_evaluate(args[0], env))
179
+
180
+ # arithmetic
181
+ if op == "Add":
182
+ return _evaluate(args[0], env) + _evaluate(args[1], env) # type: ignore[operator]
183
+ if op == "Sub":
184
+ return _evaluate(args[0], env) - _evaluate(args[1], env) # type: ignore[operator]
185
+ if op == "Mul":
186
+ return _evaluate(args[0], env) * _evaluate(args[1], env) # type: ignore[operator]
187
+ if op == "Div":
188
+ return _evaluate(args[0], env) / _evaluate(args[1], env) # type: ignore[operator]
189
+ if op == "Mod":
190
+ return _evaluate(args[0], env) % _evaluate(args[1], env) # type: ignore[operator]
191
+ if op == "Neg":
192
+ return -_evaluate(args[0], env) # type: ignore[operator]
193
+
194
+ # length-style helpers (axioms about collection sizes are common)
195
+ if op == "Len":
196
+ return len(_evaluate(args[0], env)) # type: ignore[arg-type]
197
+
198
+ msg = f"axiom evaluator does not yet implement Builtin {op!r}"
199
+ raise NotImplementedError(msg)
200
+
201
+
202
+ def check_class_axioms(cls: type, env: dict[str, FieldValue]) -> list[str]:
203
+ """Run every collected axiom on ``cls`` against ``env``.
204
+
205
+ Parameters
206
+ ----------
207
+ cls
208
+ A [Model][didactic.api.Model] subclass with ``__class_axioms__``.
209
+ env
210
+ The field-name to value mapping.
211
+
212
+ Returns
213
+ -------
214
+ list of str
215
+ Failure messages, one per axiom that does not hold. Empty
216
+ when every axiom holds.
217
+ """
218
+ failures: list[str] = []
219
+ axioms: tuple[Axiom, ...] = getattr(cls, "__class_axioms__", ())
220
+ for ax in axioms:
221
+ try:
222
+ predicate = parse_axiom_predicate(ax)
223
+ ok = predicate(env)
224
+ except (NameError, NotImplementedError) as exc:
225
+ failures.append(f"axiom {ax.expr!r}: cannot evaluate ({exc})")
226
+ continue
227
+ if not ok:
228
+ msg = ax.message or f"axiom failed: {ax.expr}"
229
+ failures.append(msg)
230
+ return failures
231
+
232
+
233
+ __all__ = [
234
+ "check_class_axioms",
235
+ "parse_axiom_predicate",
236
+ ]