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.
- didactic_pydantic-0.1.0/.gitignore +48 -0
- didactic_pydantic-0.1.0/PKG-INFO +88 -0
- didactic_pydantic-0.1.0/README.md +71 -0
- didactic_pydantic-0.1.0/pyproject.toml +33 -0
- didactic_pydantic-0.1.0/src/didactic/pydantic/__init__.py +54 -0
- didactic_pydantic-0.1.0/src/didactic/pydantic/_adapter.py +308 -0
- didactic_pydantic-0.1.0/src/didactic/pydantic/_reverse.py +241 -0
- didactic_pydantic-0.1.0/src/didactic/pydantic/py.typed +0 -0
|
@@ -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
|