didactic-pydantic 0.1.0__tar.gz

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.
@@ -0,0 +1,48 @@
1
+ # dev-only working notes, design drafts, scratch
2
+ notes/
3
+
4
+ # python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+ .eggs/
14
+ *.egg
15
+
16
+ # virtualenvs
17
+ .venv/
18
+ venv/
19
+ env/
20
+
21
+ # uv
22
+ .uv/
23
+
24
+ # testing / coverage
25
+ .pytest_cache/
26
+ .coverage
27
+ .coverage.*
28
+ htmlcov/
29
+ .tox/
30
+ .nox/
31
+
32
+ # type-checkers / linters
33
+ .mypy_cache/
34
+ .ruff_cache/
35
+ .pyright/
36
+
37
+ # mkdocs build output
38
+ site/
39
+
40
+ # editors
41
+ .vscode/
42
+ .idea/
43
+ *.swp
44
+ *.swo
45
+
46
+ # os
47
+ .DS_Store
48
+ Thumbs.db
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: didactic-pydantic
3
+ Version: 0.1.0
4
+ Summary: Pydantic interop for didactic — adapters for incremental migration.
5
+ Author-email: Aaron Steven White <aaronstevenwhite@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: didactic
15
+ Requires-Dist: pydantic>=2.10
16
+ Description-Content-Type: text/markdown
17
+
18
+ # didactic-pydantic
19
+
20
+ Bidirectional adapter between `pydantic.BaseModel` and `dx.Model`.
21
+ Contributes `didactic.pydantic` to the namespace package.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ pip install didactic-pydantic
27
+ ```
28
+
29
+ The package depends on `didactic` and `pydantic>=2.10`.
30
+
31
+ ## from_pydantic
32
+
33
+ Convert a `pydantic.BaseModel` subclass into a `dx.Model` subclass:
34
+
35
+ ```python
36
+ from pydantic import BaseModel, Field
37
+ from didactic.pydantic import from_pydantic
38
+
39
+
40
+ class PydUser(BaseModel):
41
+ id: str
42
+ email: str = Field(description="primary contact")
43
+
44
+
45
+ User = from_pydantic(PydUser)
46
+ ```
47
+
48
+ Field annotations, defaults, factories, aliases, descriptions,
49
+ examples, and the `deprecated` flag carry across.
50
+ `Annotated[T, ...]` constraint metadata flows through unchanged, so
51
+ `annotated-types` primitives (`Ge`, `Le`, ...) continue to produce
52
+ axioms on the didactic side.
53
+
54
+ Custom Pydantic validators (`@field_validator`, `@model_validator`),
55
+ `@computed_field`, and discriminated unions are not translated; the
56
+ [Pydantic interop guide](https://panproto.dev/didactic/guide/pydantic/)
57
+ lists the didactic-side replacements.
58
+
59
+ ## to_pydantic
60
+
61
+ The inverse direction:
62
+
63
+ ```python
64
+ import didactic.api as dx
65
+ from didactic.pydantic import to_pydantic
66
+
67
+
68
+ class User(dx.Model):
69
+ id: str
70
+ email: str = dx.field(description="primary contact")
71
+
72
+
73
+ PydUser = to_pydantic(User)
74
+ ```
75
+
76
+ Use `to_pydantic` to expose a `dx.Model` to FastAPI, OpenAPI
77
+ generators, or any other Pydantic-shaped tool. The conversion is
78
+ cached, so repeated calls with the same input return the same
79
+ Pydantic class.
80
+
81
+ ## Documentation
82
+
83
+ See [Guides > Pydantic interop](https://panproto.dev/didactic/guide/pydantic/)
84
+ for the full feature matrix and round-trip behaviour.
85
+
86
+ ## License
87
+
88
+ MIT.
@@ -0,0 +1,71 @@
1
+ # didactic-pydantic
2
+
3
+ Bidirectional adapter between `pydantic.BaseModel` and `dx.Model`.
4
+ Contributes `didactic.pydantic` to the namespace package.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pip install didactic-pydantic
10
+ ```
11
+
12
+ The package depends on `didactic` and `pydantic>=2.10`.
13
+
14
+ ## from_pydantic
15
+
16
+ Convert a `pydantic.BaseModel` subclass into a `dx.Model` subclass:
17
+
18
+ ```python
19
+ from pydantic import BaseModel, Field
20
+ from didactic.pydantic import from_pydantic
21
+
22
+
23
+ class PydUser(BaseModel):
24
+ id: str
25
+ email: str = Field(description="primary contact")
26
+
27
+
28
+ User = from_pydantic(PydUser)
29
+ ```
30
+
31
+ Field annotations, defaults, factories, aliases, descriptions,
32
+ examples, and the `deprecated` flag carry across.
33
+ `Annotated[T, ...]` constraint metadata flows through unchanged, so
34
+ `annotated-types` primitives (`Ge`, `Le`, ...) continue to produce
35
+ axioms on the didactic side.
36
+
37
+ Custom Pydantic validators (`@field_validator`, `@model_validator`),
38
+ `@computed_field`, and discriminated unions are not translated; the
39
+ [Pydantic interop guide](https://panproto.dev/didactic/guide/pydantic/)
40
+ lists the didactic-side replacements.
41
+
42
+ ## to_pydantic
43
+
44
+ The inverse direction:
45
+
46
+ ```python
47
+ import didactic.api as dx
48
+ from didactic.pydantic import to_pydantic
49
+
50
+
51
+ class User(dx.Model):
52
+ id: str
53
+ email: str = dx.field(description="primary contact")
54
+
55
+
56
+ PydUser = to_pydantic(User)
57
+ ```
58
+
59
+ Use `to_pydantic` to expose a `dx.Model` to FastAPI, OpenAPI
60
+ generators, or any other Pydantic-shaped tool. The conversion is
61
+ cached, so repeated calls with the same input return the same
62
+ Pydantic class.
63
+
64
+ ## Documentation
65
+
66
+ See [Guides > Pydantic interop](https://panproto.dev/didactic/guide/pydantic/)
67
+ for the full feature matrix and round-trip behaviour.
68
+
69
+ ## License
70
+
71
+ MIT.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "didactic-pydantic"
7
+ version = "0.1.0"
8
+ description = "Pydantic interop for didactic — adapters for incremental migration."
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Aaron Steven White", email = "aaronstevenwhite@gmail.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "didactic",
25
+ "pydantic>=2.10",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["src/didactic/pydantic"]
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ only-include = ["src/didactic/pydantic"]
33
+ sources = ["src"]
@@ -0,0 +1,54 @@
1
+ """didactic-pydantic: bidirectional adapter for Pydantic interop.
2
+
3
+ Two complementary adapters:
4
+
5
+ [from_pydantic][didactic.pydantic.from_pydantic]
6
+ ``BaseModel -> dx.Model``. For incremental adoption: convert one
7
+ Pydantic model at a time without rewriting field declarations by
8
+ hand.
9
+ [to_pydantic][didactic.pydantic.to_pydantic]
10
+ ``dx.Model -> BaseModel``. For interop with FastAPI, OpenAPI
11
+ generators, and any Pydantic-shaped consumer that wants the
12
+ didactic model exposed as a ``BaseModel``.
13
+
14
+ Notes
15
+ -----
16
+ The adapter is **structural**: it inspects a Pydantic v2
17
+ ``BaseModel``'s ``model_fields`` and constructs an equivalent
18
+ [didactic.api.Model][didactic.api.Model] subclass. Per-field metadata that
19
+ maps cleanly (default, default_factory, alias, description, examples,
20
+ deprecated) is preserved. ``Annotated[...]`` constraint metadata
21
+ flows through unchanged, so ``annotated-types`` primitives like
22
+ ``Ge``/``Le`` continue to produce axioms on the didactic side.
23
+
24
+ Custom Pydantic validators (``@field_validator``, ``@model_validator``)
25
+ are **not** translated; they live on the Pydantic side. If you need
26
+ similar behaviour on the didactic side, port them to
27
+ [didactic.api.validates][didactic.api.validates] manually.
28
+
29
+ Examples
30
+ --------
31
+ >>> from pydantic import BaseModel, Field
32
+ >>> from didactic.pydantic import from_pydantic
33
+ >>>
34
+ >>> class PydUser(BaseModel):
35
+ ... id: str
36
+ ... email: str = Field(description="primary contact")
37
+ ... display_name: str = ""
38
+ >>>
39
+ >>> User = from_pydantic(PydUser) # User is a dx.Model subclass
40
+ >>> u = User(id="u1", email="a@b.c")
41
+ >>> u.email
42
+ 'a@b.c'
43
+ """
44
+
45
+ from didactic.pydantic._adapter import from_pydantic
46
+ from didactic.pydantic._reverse import to_pydantic
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "__version__",
52
+ "from_pydantic",
53
+ "to_pydantic",
54
+ ]
@@ -0,0 +1,308 @@
1
+ """Pydantic v2 to didactic Model adapter.
2
+
3
+ The single user-facing entry point is [from_pydantic][didactic.pydantic.from_pydantic].
4
+ It walks a Pydantic ``BaseModel`` subclass's ``model_fields`` and produces
5
+ an equivalent [didactic.api.Model][didactic.api.Model] subclass.
6
+
7
+ Notes
8
+ -----
9
+ Mapping table (Pydantic FieldInfo on the left, didactic.field on the right)::
10
+
11
+ annotation -> class annotation
12
+ default (PydanticUndefined) -> MISSING
13
+ default_factory -> default_factory
14
+ alias / validation_alias -> alias
15
+ description -> description
16
+ examples -> examples
17
+ metadata (Annotated) -> passes through verbatim
18
+ deprecated -> deprecated
19
+ init_var / repr (Pydantic) -> ignored (no didactic equivalent)
20
+ json_schema_extra -> extras["json_schema_extra"]
21
+ frozen -> ignored (didactic Models are always frozen)
22
+
23
+ Pydantic features explicitly **not** translated:
24
+
25
+ - ``@field_validator`` / ``@model_validator``: keep on the Pydantic
26
+ side or re-implement with [didactic.api.validates][didactic.api.validates].
27
+ - ``@computed_field``: re-author with [didactic.api.computed][didactic.api.computed].
28
+ - Discriminated unions: re-author with
29
+ [didactic.api.TaggedUnion][didactic.api.TaggedUnion].
30
+
31
+ See Also
32
+ --------
33
+ didactic.Model : the base class produced by from_pydantic.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import inspect
39
+ from typing import TYPE_CHECKING, Annotated, cast
40
+
41
+ from pydantic_core import PydanticUndefined
42
+
43
+ import didactic.api as dx
44
+ from didactic.fields._fields import MISSING
45
+ from didactic.models._meta import ModelMeta
46
+ from pydantic import BaseModel
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Callable, Mapping
50
+
51
+ from didactic.types._typing import FieldValue, Opaque
52
+ from pydantic.fields import FieldInfo
53
+
54
+
55
+ def from_pydantic(
56
+ pyd_cls: type,
57
+ *,
58
+ name: str | None = None,
59
+ ) -> type[dx.Model]:
60
+ """Derive a [Model][didactic.api.Model] subclass from a Pydantic ``BaseModel``.
61
+
62
+ Parameters
63
+ ----------
64
+ pyd_cls
65
+ The Pydantic ``BaseModel`` subclass to translate.
66
+ name
67
+ Optional name for the new didactic class. Defaults to
68
+ ``pyd_cls.__name__``.
69
+
70
+ Returns
71
+ -------
72
+ type
73
+ A new [didactic.api.Model][didactic.api.Model] subclass with one field per
74
+ ``pyd_cls.model_fields`` entry.
75
+
76
+ Raises
77
+ ------
78
+ TypeError
79
+ If ``pyd_cls`` is not a Pydantic v2 ``BaseModel`` subclass.
80
+
81
+ Notes
82
+ -----
83
+ The new class lives in the same module as ``pyd_cls`` (its
84
+ ``__module__`` is set accordingly) so that any forward references
85
+ inside its annotations resolve against the same globals.
86
+
87
+ Examples
88
+ --------
89
+ >>> from pydantic import BaseModel, Field
90
+ >>> class PydUser(BaseModel):
91
+ ... id: str
92
+ ... email: str = Field(description="primary contact")
93
+ >>> User = from_pydantic(PydUser)
94
+ >>> issubclass(User, dx.Model)
95
+ True
96
+ >>> u = User(id="u1", email="a@b.c")
97
+ >>> u.email
98
+ 'a@b.c'
99
+ """
100
+ if not issubclass(pyd_cls, BaseModel):
101
+ msg = (
102
+ f"from_pydantic requires a Pydantic v2 BaseModel subclass; got {pyd_cls!r}"
103
+ )
104
+ raise TypeError(msg)
105
+
106
+ target_name = name or pyd_cls.__name__
107
+ annotations: dict[str, type] = {}
108
+ namespace: dict[str, Opaque] = {}
109
+
110
+ for fname, info in pyd_cls.model_fields.items():
111
+ annotation = _resolve_annotation(info)
112
+ annotations[fname] = annotation
113
+
114
+ # we emit a dx.field(...) whenever there's any Pydantic-side metadata
115
+ # to carry; default, factory, alias, description, examples, deprecated,
116
+ # or json_schema_extra. Required fields with no metadata don't need a
117
+ # didactic Field descriptor.
118
+ if _has_metadata(info, PydanticUndefined):
119
+ namespace[fname] = _to_dx_field(info, PydanticUndefined)
120
+
121
+ namespace["__annotations__"] = annotations
122
+ if pyd_cls.__doc__:
123
+ namespace["__doc__"] = pyd_cls.__doc__
124
+ namespace["__module__"] = pyd_cls.__module__
125
+
126
+ cls = ModelMeta(target_name, (dx.Model,), namespace)
127
+ return cast("type[dx.Model]", cls)
128
+
129
+
130
+ def _resolve_annotation(info: FieldInfo) -> type:
131
+ """Reconstruct the original ``Annotated[...]`` annotation for a Pydantic field.
132
+
133
+ Pydantic stores constraint metadata on ``info.metadata`` separately from
134
+ the base annotation. The didactic metaclass expects these on the
135
+ annotation itself (as ``Annotated[T, ...]``) so we splice them back in.
136
+
137
+ Parameters
138
+ ----------
139
+ info
140
+ The Pydantic ``FieldInfo`` for one field.
141
+
142
+ Returns
143
+ -------
144
+ type
145
+ Either the bare type or an ``Annotated[T, *metadata]`` form.
146
+ """
147
+ base = info.annotation
148
+ if base is None:
149
+ msg = "Pydantic FieldInfo has no annotation; cannot translate."
150
+ raise TypeError(msg)
151
+ metadata = tuple(info.metadata or ())
152
+ if not metadata:
153
+ return base
154
+ return Annotated[base, *metadata] # type: ignore[valid-type]
155
+
156
+
157
+ def _is_required(info: FieldInfo, undefined: Opaque) -> bool:
158
+ """Whether the Pydantic field has no default and no factory."""
159
+ has_default = info.default is not undefined
160
+ has_factory = info.default_factory is not None
161
+ return not (has_default or has_factory)
162
+
163
+
164
+ def _has_metadata(info: FieldInfo, undefined: Opaque) -> bool:
165
+ """Whether the FieldInfo carries any metadata worth materialising as a Field.
166
+
167
+ Returns
168
+ -------
169
+ bool
170
+ ``True`` if any of: a default, a default_factory, an alias, a
171
+ description, examples, the deprecated flag, or json_schema_extra
172
+ are set.
173
+ """
174
+ return (
175
+ info.default is not undefined
176
+ or info.default_factory is not None
177
+ or info.alias is not None
178
+ or info.validation_alias is not None
179
+ or info.description is not None
180
+ or bool(info.examples)
181
+ or bool(info.deprecated)
182
+ or info.json_schema_extra is not None
183
+ )
184
+
185
+
186
+ def _to_dx_field(info: FieldInfo, undefined: Opaque) -> dx.Field:
187
+ """Translate one ``FieldInfo`` into a [didactic.api.field][didactic.api.field] call.
188
+
189
+ Parameters
190
+ ----------
191
+ info
192
+ The Pydantic ``FieldInfo``.
193
+ undefined
194
+ Pydantic's ``PydanticUndefined`` sentinel; passed in so we don't
195
+ need to re-import it per call.
196
+
197
+ Returns
198
+ -------
199
+ Field
200
+ The didactic Field descriptor.
201
+ """
202
+ # Build a Field directly so each attribute is typed precisely; the
203
+ # `field()` overloads are tuned for human-written class bodies, not
204
+ # for kwargs-spreading from a heterogeneous source dict.
205
+ extras: Mapping[str, Opaque] | None = None
206
+ if info.json_schema_extra is not None and not callable(info.json_schema_extra):
207
+ extras = {"json_schema_extra": dict(info.json_schema_extra)}
208
+
209
+ raw_alias = info.alias if info.alias is not None else info.validation_alias
210
+ alias = raw_alias if isinstance(raw_alias, str) else None
211
+
212
+ examples: tuple[FieldValue, ...] = ()
213
+ if info.examples:
214
+ examples = tuple(_coerce_example(e) for e in info.examples)
215
+
216
+ return dx.Field(
217
+ default=info.default if info.default is not undefined else MISSING,
218
+ default_factory=_coerce_factory(info.default_factory),
219
+ alias=alias,
220
+ description=info.description,
221
+ examples=examples,
222
+ deprecated=bool(info.deprecated),
223
+ extras=extras,
224
+ )
225
+
226
+
227
+ def _coerce_example(value: object) -> FieldValue:
228
+ """Narrow an arbitrary Pydantic example value to ``FieldValue``.
229
+
230
+ Pydantic stores examples as ``list[Any]``; didactic's ``examples``
231
+ tuple is typed as ``tuple[FieldValue, ...]``. We accept the value
232
+ structurally and let the metaclass / validation surface any real
233
+ mismatch at class-construction time.
234
+ """
235
+ # FieldValue is a recursive union covering all JSON-shaped scalars,
236
+ # tuples, frozensets, dicts, and Models. A runtime isinstance check
237
+ # against the union would be expensive and brittle; the contract
238
+ # here is "Pydantic gave us a value the user wrote as an example,
239
+ # so we trust it as a FieldValue".
240
+ if isinstance(value, (str, int, float, bool, bytes, type(None))):
241
+ return value
242
+ if isinstance(value, (tuple, list)):
243
+ seq = cast("tuple[FieldValue, ...] | list[FieldValue]", value)
244
+ return tuple(_coerce_example(v) for v in seq)
245
+ if isinstance(value, dict):
246
+ items = cast("dict[str, FieldValue]", value)
247
+ return {str(k): _coerce_example(v) for k, v in items.items()}
248
+ if isinstance(value, frozenset):
249
+ members = cast("frozenset[FieldValue]", value)
250
+ return frozenset(_coerce_example(v) for v in members)
251
+ msg = f"Unsupported example value of type {type(value).__name__}: {value!r}"
252
+ raise TypeError(msg)
253
+
254
+
255
+ def _coerce_factory(
256
+ factory: object,
257
+ ) -> Callable[[], FieldValue] | None:
258
+ """Validate that a Pydantic ``default_factory`` is the zero-arg form.
259
+
260
+ Pydantic v2 supports a one-arg ``default_factory(validated_data)`` form
261
+ that didactic does not model; we only forward the zero-arg case. The
262
+ returned object is the same callable, narrowed by an arity check so
263
+ the static type ``Callable[[], FieldValue]`` is honest.
264
+ """
265
+ if factory is None:
266
+ return None
267
+ if not callable(factory):
268
+ msg = f"default_factory must be callable, got {factory!r}"
269
+ raise TypeError(msg)
270
+ arity = _zero_arg_arity(factory)
271
+ if not arity:
272
+ msg = (
273
+ "from_pydantic only supports zero-argument default_factory; "
274
+ f"got a callable that requires arguments: {factory!r}"
275
+ )
276
+ raise TypeError(msg)
277
+ # Pydantic types default_factory loosely (it returns ``Any`` and may
278
+ # also accept a one-arg ``validated_data`` form). The arity check
279
+ # above establishes the zero-arg contract didactic requires; we use
280
+ # ``cast`` (the standard typed-Python narrowing primitive) to expose
281
+ # the original callable under didactic's narrower signature without
282
+ # wrapping, so identity is preserved (tests compare with ``is``).
283
+ return cast("Callable[[], FieldValue]", factory)
284
+
285
+
286
+ def _zero_arg_arity(func: Callable[..., object]) -> bool:
287
+ """Return ``True`` if ``func`` can be called with zero positional args."""
288
+ try:
289
+ sig = inspect.signature(func)
290
+ except TypeError, ValueError:
291
+ # Built-ins like ``tuple`` may not expose a signature; assume OK.
292
+ return True
293
+ for param in sig.parameters.values():
294
+ if param.kind in (
295
+ inspect.Parameter.VAR_POSITIONAL,
296
+ inspect.Parameter.VAR_KEYWORD,
297
+ ):
298
+ continue
299
+ if param.default is inspect.Parameter.empty and param.kind in (
300
+ inspect.Parameter.POSITIONAL_ONLY,
301
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
302
+ inspect.Parameter.KEYWORD_ONLY,
303
+ ):
304
+ return False
305
+ return True
306
+
307
+
308
+ __all__ = ["_is_required", "from_pydantic"]
@@ -0,0 +1,241 @@
1
+ """didactic Model to Pydantic v2 BaseModel adapter.
2
+
3
+ The single user-facing entry point is
4
+ [to_pydantic][didactic.pydantic.to_pydantic]. It walks a didactic
5
+ [Model][didactic.api.Model] subclass's ``__field_specs__`` and produces an
6
+ equivalent Pydantic ``BaseModel`` subclass, suitable for use with
7
+ FastAPI, OpenAPI generators, and any other Pydantic-shaped consumer.
8
+
9
+ Notes
10
+ -----
11
+ Mapping table (didactic FieldSpec on the left, Pydantic FieldInfo on the right)::
12
+
13
+ annotation -> field annotation
14
+ default (MISSING) -> PydanticUndefined (required)
15
+ default -> default
16
+ default_factory -> default_factory
17
+ alias -> alias / validation_alias / serialization_alias
18
+ description -> description
19
+ examples -> examples
20
+ deprecated -> deprecated
21
+ axioms (Annotated) -> passes through verbatim on the annotation
22
+ extras -> json_schema_extra
23
+ converter -> ignored (Pydantic doesn't have a direct equivalent)
24
+ nominal -> ignored (Pydantic has no vertex-identity concept)
25
+ usage_mode -> ignored
26
+
27
+ didactic concepts that have no clean Pydantic equivalent are dropped
28
+ silently:
29
+
30
+ - Computed fields ([didactic.api.computed][didactic.api.computed]) are dropped.
31
+ Re-author with ``@computed_field`` on the Pydantic side if you need
32
+ them.
33
+ - Tagged unions ([didactic.api.TaggedUnion][didactic.api.TaggedUnion]) are
34
+ dropped. Re-author with Pydantic's discriminated unions.
35
+ - Validators ([didactic.api.validates][didactic.api.validates]) are dropped.
36
+ Re-author with ``@field_validator`` / ``@model_validator``.
37
+
38
+ See Also
39
+ --------
40
+ didactic.pydantic.from_pydantic : the inverse direction.
41
+ didactic.Model : the input class.
42
+ """
43
+
44
+ # Local PEP 695 type alias inside a function body and a heterogeneous
45
+ # kwargs dict for the dynamic-pydantic-Field construction.
46
+ # Tracked in panproto/didactic#1.
47
+ # pyright: reportArgumentType=false, reportGeneralTypeIssues=false
48
+
49
+ from __future__ import annotations
50
+
51
+ from typing import TYPE_CHECKING, Annotated, cast
52
+
53
+ import didactic.api as dx
54
+ from didactic.fields._fields import MISSING
55
+ from pydantic import BaseModel, Field, create_model
56
+
57
+ if TYPE_CHECKING:
58
+ from collections.abc import Callable
59
+
60
+ from didactic.fields._fields import FieldSpec
61
+ from didactic.types._typing import FieldValue
62
+ from pydantic.fields import FieldInfo
63
+
64
+
65
+ def to_pydantic(
66
+ dx_cls: type[dx.Model],
67
+ *,
68
+ name: str | None = None,
69
+ ) -> type[BaseModel]:
70
+ """Derive a Pydantic ``BaseModel`` subclass from a didactic Model.
71
+
72
+ Parameters
73
+ ----------
74
+ dx_cls
75
+ The [didactic.api.Model][didactic.api.Model] subclass to translate.
76
+ name
77
+ Optional name for the new Pydantic class. Defaults to
78
+ ``dx_cls.__name__``.
79
+
80
+ Returns
81
+ -------
82
+ type
83
+ A new Pydantic ``BaseModel`` subclass with one field per
84
+ ``dx_cls.__field_specs__`` entry.
85
+
86
+ Raises
87
+ ------
88
+ TypeError
89
+ If ``dx_cls`` is not a [didactic.api.Model][didactic.api.Model] subclass.
90
+
91
+ Notes
92
+ -----
93
+ The new class lives in the same module as ``dx_cls`` so that any
94
+ forward references inside its annotations resolve against the same
95
+ globals. Computed fields and tagged-union variants are skipped;
96
+ only ``readwrite`` fields are translated.
97
+
98
+ Examples
99
+ --------
100
+ >>> import didactic.api as dx
101
+ >>> from didactic.pydantic import to_pydantic
102
+ >>>
103
+ >>> class User(dx.Model):
104
+ ... id: str
105
+ ... email: str = dx.field(description="primary contact")
106
+ >>>
107
+ >>> PydUser = to_pydantic(User)
108
+ >>> issubclass(PydUser, BaseModel)
109
+ True
110
+ >>> u = PydUser(id="u1", email="a@b.c")
111
+ >>> u.email
112
+ 'a@b.c'
113
+ """
114
+ # static type already says ``type[dx.Model]``; the runtime check
115
+ # catches users who bypass type-checking and hand in a non-Model.
116
+ if not (isinstance(dx_cls, type) and issubclass(dx_cls, dx.Model)): # pyright: ignore[reportUnnecessaryIsInstance]
117
+ msg = f"to_pydantic requires a didactic.Model subclass; got {dx_cls!r}"
118
+ raise TypeError(msg)
119
+
120
+ target_name = name or dx_cls.__name__
121
+ fields: dict[str, tuple[type, FieldInfo]] = {}
122
+
123
+ for fname, spec in dx_cls.__field_specs__.items():
124
+ if spec.usage_mode != "readwrite":
125
+ # computed and materialised fields don't translate cleanly;
126
+ # they would need re-authoring as @computed_field on the
127
+ # Pydantic side
128
+ continue
129
+
130
+ annotation = _annotation_with_axioms(spec)
131
+ field_info = _to_pydantic_field(spec)
132
+ fields[fname] = (annotation, field_info)
133
+
134
+ # ``create_model``'s overload signature treats every keyword as a
135
+ # candidate for one of its named parameters (``__config__``,
136
+ # ``__validators__``, …) before falling through to the
137
+ # ``**field_definitions`` catch-all. Splatting an arbitrary
138
+ # ``fields`` dict therefore looks ill-typed to pyright even though
139
+ # the runtime contract accepts it (it is the documented pydantic
140
+ # idiom for dynamic model creation). The cast widens the call
141
+ # site to ``Callable[..., type[BaseModel]]`` so the splat checks.
142
+ creator = cast("Callable[..., type[BaseModel]]", create_model)
143
+ return creator(
144
+ target_name,
145
+ __base__=BaseModel,
146
+ __module__=dx_cls.__module__,
147
+ __doc__=dx_cls.__doc__,
148
+ **fields,
149
+ )
150
+
151
+
152
+ def _annotation_with_axioms(spec: FieldSpec) -> type:
153
+ """Reconstruct an ``Annotated[T, ...]`` annotation including axiom metadata.
154
+
155
+ Parameters
156
+ ----------
157
+ spec
158
+ A didactic FieldSpec.
159
+
160
+ Returns
161
+ -------
162
+ type
163
+ Either the bare annotation, or ``Annotated[T, *axiom_metadata]``
164
+ when the spec carries any ``annotated-types`` constraints in
165
+ its ``extras["annotated_metadata"]`` list.
166
+
167
+ Notes
168
+ -----
169
+ didactic stores ``Annotated`` metadata it does not recognise in
170
+ ``spec.extras``; ``annotated-types`` primitives like ``Ge``/``Le``
171
+ are recognised and live on ``spec.axioms`` as their string-form
172
+ Expr equivalents. To round-trip through Pydantic we prefer the
173
+ original metadata where it survived, otherwise we just send the
174
+ bare annotation: Pydantic doesn't speak panproto-Expr predicates.
175
+ """
176
+ metadata = spec.extras.get("annotated_metadata", ())
177
+ if metadata:
178
+ return Annotated[spec.annotation, *metadata] # type: ignore[valid-type]
179
+ return spec.annotation
180
+
181
+
182
+ def _to_pydantic_field(spec: FieldSpec) -> FieldInfo:
183
+ """Translate one ``FieldSpec`` into a ``pydantic.Field(...)`` call.
184
+
185
+ Parameters
186
+ ----------
187
+ spec
188
+ The didactic FieldSpec.
189
+
190
+ Returns
191
+ -------
192
+ FieldInfo
193
+ A Pydantic ``FieldInfo`` produced by ``pydantic.Field(...)``.
194
+ """
195
+ # Pydantic's ``Field`` accepts a heterogeneous mix of native types
196
+ # (str, bool, FieldValue defaults, callables, dicts) for its many
197
+ # named keywords. The kwarg dict's value type is therefore the
198
+ # union of all those, expressed as ``FieldValue`` plus
199
+ # ``Callable``/``dict`` and explicitly admitted at each assignment
200
+ # site below.
201
+ type _FieldKwargValue = (
202
+ FieldValue | Callable[[], FieldValue] | dict[str, FieldValue]
203
+ )
204
+ kwargs: dict[str, _FieldKwargValue] = {}
205
+
206
+ if spec.default is not MISSING:
207
+ kwargs["default"] = cast("FieldValue", spec.default)
208
+ if spec.default_factory is not None:
209
+ kwargs["default_factory"] = spec.default_factory
210
+
211
+ if spec.alias is not None:
212
+ kwargs["alias"] = spec.alias
213
+
214
+ if spec.description is not None:
215
+ kwargs["description"] = spec.description
216
+ if spec.examples:
217
+ kwargs["examples"] = list(spec.examples)
218
+ if spec.deprecated:
219
+ kwargs["deprecated"] = True
220
+
221
+ # surface anything else through json_schema_extra; this round-trips
222
+ # to from_pydantic via the same key
223
+ extras = {
224
+ k: cast("FieldValue", v)
225
+ for k, v in spec.extras.items()
226
+ if k != "annotated_metadata"
227
+ }
228
+ if extras:
229
+ kwargs["json_schema_extra"] = extras
230
+
231
+ # Same dynamic-kwargs pattern as ``create_model``: ``Field``'s
232
+ # overloads enumerate named parameters (``alias``, ``description``,
233
+ # …) and pyright matches each kwarg against the most specific
234
+ # overload first, so a splatted ``dict[str, FieldValue]`` doesn't
235
+ # fit any overload. The cast widens to a permissive shape that
236
+ # matches Pydantic's runtime contract (returns a ``FieldInfo``).
237
+ field_factory = cast("Callable[..., FieldInfo]", Field)
238
+ return field_factory(**kwargs)
239
+
240
+
241
+ __all__ = ["to_pydantic"]
File without changes