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.
- didactic/_self_describing.py +206 -0
- didactic/api.py +126 -0
- didactic/axioms/__init__.py +11 -0
- didactic/axioms/_axiom_enforcement.py +236 -0
- didactic/axioms/_axioms.py +137 -0
- didactic/cli/__init__.py +7 -0
- didactic/cli/_cli.py +241 -0
- didactic/codegen/__init__.py +54 -0
- didactic/codegen/_emitter.py +286 -0
- didactic/codegen/_json_schema.py +215 -0
- didactic/codegen/_write.py +128 -0
- didactic/codegen/io.py +278 -0
- didactic/codegen/source.py +198 -0
- didactic/fields/__init__.py +28 -0
- didactic/fields/_computed.py +149 -0
- didactic/fields/_derived.py +142 -0
- didactic/fields/_fields.py +543 -0
- didactic/fields/_refs.py +246 -0
- didactic/fields/_unions.py +255 -0
- didactic/fields/_validators.py +164 -0
- didactic/lenses/__init__.py +16 -0
- didactic/lenses/_dependent_lens.py +315 -0
- didactic/lenses/_lens.py +444 -0
- didactic/lenses/_testing.py +302 -0
- didactic/migrations/__init__.py +38 -0
- didactic/migrations/_diff.py +128 -0
- didactic/migrations/_fingerprint.py +220 -0
- didactic/migrations/_migrations.py +499 -0
- didactic/migrations/_synthesis.py +138 -0
- didactic/models/__init__.py +19 -0
- didactic/models/_config.py +104 -0
- didactic/models/_meta.py +469 -0
- didactic/models/_model.py +790 -0
- didactic/models/_root.py +124 -0
- didactic/models/_storage.py +151 -0
- didactic/py.typed +0 -0
- didactic/theory/__init__.py +8 -0
- didactic/theory/_theory.py +477 -0
- didactic/types/__init__.py +16 -0
- didactic/types/_types.py +866 -0
- didactic/types/_types_lib.py +147 -0
- didactic/types/_typing.py +136 -0
- didactic/vcs/__init__.py +10 -0
- didactic/vcs/_backref.py +232 -0
- didactic/vcs/_repo.py +328 -0
- didactic-0.1.0.dist-info/METADATA +68 -0
- didactic-0.1.0.dist-info/RECORD +49 -0
- didactic-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|