yaddd-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.
- yaddd_pydantic-0.1.0/.gitignore +165 -0
- yaddd_pydantic-0.1.0/LICENSE +0 -0
- yaddd_pydantic-0.1.0/PKG-INFO +32 -0
- yaddd_pydantic-0.1.0/README.md +10 -0
- yaddd_pydantic-0.1.0/pyproject.toml +39 -0
- yaddd_pydantic-0.1.0/src/yaddd_pydantic/__init__.py +6 -0
- yaddd_pydantic-0.1.0/src/yaddd_pydantic/py.typed +0 -0
- yaddd_pydantic-0.1.0/src/yaddd_pydantic/value_objects.py +90 -0
- yaddd_pydantic-0.1.0/tests/test_pydantic_vo.py +116 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
110
|
+
.pdm.toml
|
|
111
|
+
.pdm-python
|
|
112
|
+
.pdm-build/
|
|
113
|
+
|
|
114
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
115
|
+
__pypackages__/
|
|
116
|
+
|
|
117
|
+
# Celery stuff
|
|
118
|
+
celerybeat-schedule
|
|
119
|
+
celerybeat.pid
|
|
120
|
+
|
|
121
|
+
# SageMath parsed files
|
|
122
|
+
*.sage.py
|
|
123
|
+
|
|
124
|
+
# Environments
|
|
125
|
+
.env
|
|
126
|
+
.venv
|
|
127
|
+
env/
|
|
128
|
+
venv/
|
|
129
|
+
ENV/
|
|
130
|
+
env.bak/
|
|
131
|
+
venv.bak/
|
|
132
|
+
|
|
133
|
+
# Spyder project settings
|
|
134
|
+
.spyderproject
|
|
135
|
+
.spyproject
|
|
136
|
+
|
|
137
|
+
# Rope project settings
|
|
138
|
+
.ropeproject
|
|
139
|
+
|
|
140
|
+
# mkdocs documentation
|
|
141
|
+
/site
|
|
142
|
+
|
|
143
|
+
# mypy
|
|
144
|
+
.mypy_cache/
|
|
145
|
+
.dmypy.json
|
|
146
|
+
dmypy.json
|
|
147
|
+
|
|
148
|
+
# Pyre type checker
|
|
149
|
+
.pyre/
|
|
150
|
+
|
|
151
|
+
# pytype static type analyzer
|
|
152
|
+
.pytype/
|
|
153
|
+
|
|
154
|
+
# Cython debug symbols
|
|
155
|
+
cython_debug/
|
|
156
|
+
|
|
157
|
+
# IDE
|
|
158
|
+
.idea
|
|
159
|
+
.vscode/!launch.json
|
|
160
|
+
.vscode/!tasks.json
|
|
161
|
+
|
|
162
|
+
# OS
|
|
163
|
+
.DS_Store
|
|
164
|
+
|
|
165
|
+
*_old/
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yaddd-pydantic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pydantic v2 integration for yaddd: PydanticVO value objects as first-class model fields
|
|
5
|
+
Project-URL: Homepage, https://github.com/netstuff/yaddd
|
|
6
|
+
Author-email: Yuri Andreev <andreev.jr@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: pydantic>=2.10
|
|
20
|
+
Requires-Dist: yaddd>=0.1
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# yaddd-pydantic
|
|
24
|
+
Pydantic v2 integration for [yaddd](https://github.com/pyDDD/yaddd).
|
|
25
|
+
|
|
26
|
+
`PydanticVO[V]` makes yaddd value objects first-class field types in pydantic
|
|
27
|
+
models: pydantic constraints double as VO validation, models accept both raw
|
|
28
|
+
values and VO instances, dumps produce primitives, and JSON Schema shows the
|
|
29
|
+
primitive shape. `SensitiveValueObject` masking is preserved.
|
|
30
|
+
|
|
31
|
+
Wiring is explicit: subclass `PydanticVO` in your domain, no auto-discovery.
|
|
32
|
+
Conformance is proven by `VoSerializationContract` from `yaddd.testing`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# yaddd-pydantic
|
|
2
|
+
Pydantic v2 integration for [yaddd](https://github.com/pyDDD/yaddd).
|
|
3
|
+
|
|
4
|
+
`PydanticVO[V]` makes yaddd value objects first-class field types in pydantic
|
|
5
|
+
models: pydantic constraints double as VO validation, models accept both raw
|
|
6
|
+
values and VO instances, dumps produce primitives, and JSON Schema shows the
|
|
7
|
+
primitive shape. `SensitiveValueObject` masking is preserved.
|
|
8
|
+
|
|
9
|
+
Wiring is explicit: subclass `PydanticVO` in your domain, no auto-discovery.
|
|
10
|
+
Conformance is proven by `VoSerializationContract` from `yaddd.testing`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yaddd-pydantic"
|
|
3
|
+
description = "Pydantic v2 integration for yaddd: PydanticVO value objects as first-class model fields"
|
|
4
|
+
authors = [
|
|
5
|
+
{name = "Yuri Andreev", email = "andreev.jr@gmail.com"},
|
|
6
|
+
]
|
|
7
|
+
license = 'MIT'
|
|
8
|
+
license-files = ['LICENSE']
|
|
9
|
+
classifiers = [
|
|
10
|
+
'Development Status :: 4 - Beta',
|
|
11
|
+
'Programming Language :: Python',
|
|
12
|
+
'Programming Language :: Python :: 3.12',
|
|
13
|
+
'Programming Language :: Python :: 3.13',
|
|
14
|
+
'Programming Language :: Python :: 3.14',
|
|
15
|
+
'Intended Audience :: Developers',
|
|
16
|
+
'Operating System :: OS Independent',
|
|
17
|
+
'Topic :: Software Development :: Libraries :: Python Modules',
|
|
18
|
+
'Topic :: Internet',
|
|
19
|
+
]
|
|
20
|
+
requires-python = '>=3.12'
|
|
21
|
+
dependencies = [
|
|
22
|
+
"yaddd>=0.1",
|
|
23
|
+
"pydantic>=2.10",
|
|
24
|
+
]
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
readme = 'README.md'
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = 'https://github.com/netstuff/yaddd'
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
yaddd = { workspace = true }
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/yaddd_pydantic"]
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Pydantic v2 value objects: VOs as first-class field types in pydantic models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar, Self, cast
|
|
4
|
+
|
|
5
|
+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler, TypeAdapter
|
|
6
|
+
from pydantic.json_schema import JsonSchemaValue
|
|
7
|
+
from pydantic_core import CoreSchema, core_schema
|
|
8
|
+
from yaddd.domain.value_object import ValueObject
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["PydanticVO"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PydanticVO[V](ValueObject[V]):
|
|
15
|
+
"""``ValueObject`` validated and serialized by pydantic v2.
|
|
16
|
+
|
|
17
|
+
Subclass and declare ``pydantic_type`` — the type pydantic uses for
|
|
18
|
+
validation and JSON Schema::
|
|
19
|
+
|
|
20
|
+
class PositiveTotal(PydanticVO[int]):
|
|
21
|
+
pydantic_type = Annotated[int, Field(gt=0)]
|
|
22
|
+
|
|
23
|
+
Behavior:
|
|
24
|
+
|
|
25
|
+
- ``validate`` runs the ``TypeAdapter(pydantic_type)`` chain, so pydantic
|
|
26
|
+
constraints double as VO validation — even outside any model;
|
|
27
|
+
- a VO field in a ``BaseModel`` accepts both a raw value (validated into
|
|
28
|
+
a VO instance) and an existing VO instance (kept as is);
|
|
29
|
+
- serialization produces the primitive: ``model_dump()`` and
|
|
30
|
+
``model_dump_json()`` contain ``vo.value``, never a VO repr;
|
|
31
|
+
- JSON Schema (OpenAPI) shows the ``pydantic_type`` shape.
|
|
32
|
+
|
|
33
|
+
Combine with ``SensitiveValueObject`` to keep masking::
|
|
34
|
+
|
|
35
|
+
class Password(SensitiveValueObject[str], PydanticVO[str]):
|
|
36
|
+
pydantic_type = Annotated[str, Field(min_length=8)]
|
|
37
|
+
|
|
38
|
+
Masking is preserved: the model's ``repr`` uses the VO's masked
|
|
39
|
+
``__repr__`` and ``str`` still raises ``SensitiveValueAccessError``.
|
|
40
|
+
A pydantic dump is an explicit serialization act, so ``model_dump()`` /
|
|
41
|
+
``model_dump_json()`` contain the raw value — treat dumped output as
|
|
42
|
+
sensitive and keep it out of logs.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
pydantic_type: ClassVar[Any]
|
|
46
|
+
|
|
47
|
+
_adapter_instance: ClassVar[TypeAdapter[Any] | None] = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate(cls, value: V) -> V:
|
|
51
|
+
"""Validate the raw value through the ``pydantic_type`` chain."""
|
|
52
|
+
return cast("V", cls._type_adapter().validate_python(value))
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
|
|
56
|
+
"""Accept raw values and VO instances; serialize to the primitive."""
|
|
57
|
+
return core_schema.no_info_plain_validator_function(
|
|
58
|
+
cls._accept,
|
|
59
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
60
|
+
_serialize,
|
|
61
|
+
return_schema=cls._type_adapter().core_schema,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def __get_pydantic_json_schema__(cls, schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
|
|
67
|
+
"""JSON Schema comes from ``pydantic_type``: the primitive shape."""
|
|
68
|
+
json_schema = cls._type_adapter().json_schema()
|
|
69
|
+
json_schema["title"] = cls.__name__
|
|
70
|
+
return json_schema
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _type_adapter(cls) -> TypeAdapter[Any]:
|
|
74
|
+
"""Lazily built and cached per concrete VO class."""
|
|
75
|
+
adapter = cls.__dict__.get("_adapter_instance")
|
|
76
|
+
if adapter is None:
|
|
77
|
+
adapter = TypeAdapter(cls.pydantic_type)
|
|
78
|
+
cls._adapter_instance = adapter
|
|
79
|
+
return cast("TypeAdapter[Any]", adapter)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def _accept(cls, value: Any) -> Self:
|
|
83
|
+
"""Pass VO instances through; validate raw values into VOs."""
|
|
84
|
+
if isinstance(value, cls):
|
|
85
|
+
return value
|
|
86
|
+
return cls(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _serialize(vo: ValueObject[Any]) -> Any:
|
|
90
|
+
return vo.value
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
|
7
|
+
|
|
8
|
+
from yaddd.domain import SensitiveValueObject, ValueObject
|
|
9
|
+
from yaddd.testing import VoSerializationContract
|
|
10
|
+
from yaddd_pydantic import PydanticVO
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PositiveTotal(PydanticVO[int]):
|
|
14
|
+
pydantic_type = Annotated[int, Field(gt=0)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Secret(SensitiveValueObject[str], PydanticVO[str]):
|
|
18
|
+
pydantic_type = Annotated[str, Field(min_length=8)]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OrderModel(BaseModel):
|
|
22
|
+
total: PositiveTotal
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Credentials(BaseModel):
|
|
26
|
+
password: Secret
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_raw_input_becomes_vo_instance():
|
|
30
|
+
model = OrderModel(total=5)
|
|
31
|
+
|
|
32
|
+
assert isinstance(model.total, PositiveTotal)
|
|
33
|
+
assert model.total.value == 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_vo_instance_input_is_preserved():
|
|
37
|
+
total = PositiveTotal(10)
|
|
38
|
+
|
|
39
|
+
assert OrderModel(total=total).total is total
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_pydantic_constraint_violation_raises():
|
|
43
|
+
with pytest.raises(ValidationError):
|
|
44
|
+
OrderModel(total=-1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_vo_construction_outside_model_uses_pydantic_validation():
|
|
48
|
+
with pytest.raises(ValidationError):
|
|
49
|
+
PositiveTotal(0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_model_dump_contains_primitive():
|
|
53
|
+
dumped = OrderModel(total=5).model_dump()
|
|
54
|
+
|
|
55
|
+
assert dumped["total"] == 5
|
|
56
|
+
assert not isinstance(dumped["total"], ValueObject)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_model_dump_json_contains_primitive():
|
|
60
|
+
assert json.loads(OrderModel(total=5).model_dump_json())["total"] == 5
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_json_schema_shows_primitive_shape():
|
|
64
|
+
schema = OrderModel.model_json_schema()
|
|
65
|
+
|
|
66
|
+
assert schema["properties"]["total"]["type"] == "integer"
|
|
67
|
+
assert schema["properties"]["total"]["exclusiveMinimum"] == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_sensitive_vo_repr_is_masked():
|
|
71
|
+
model = Credentials(password="super-secret-1")
|
|
72
|
+
|
|
73
|
+
assert "super-secret-1" not in repr(model)
|
|
74
|
+
assert "MASKED" in repr(model)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_sensitive_vo_explicit_dump_contains_raw_value():
|
|
78
|
+
assert Credentials(password="super-secret-1").model_dump()["password"] == "super-secret-1"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_sensitive_vo_constraint_violation_raises():
|
|
82
|
+
with pytest.raises(ValidationError):
|
|
83
|
+
Credentials(password="short")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestPydanticVoSerialization(VoSerializationContract):
|
|
87
|
+
"""The contract suite proves serialization conformance via pydantic hooks."""
|
|
88
|
+
|
|
89
|
+
@pytest.fixture
|
|
90
|
+
def vo(self) -> ValueObject[Any]:
|
|
91
|
+
return PositiveTotal(5)
|
|
92
|
+
|
|
93
|
+
@pytest.fixture
|
|
94
|
+
def invalid_raw(self) -> Any:
|
|
95
|
+
return -1
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def dump(self) -> Callable[[ValueObject[Any]], Any]:
|
|
99
|
+
adapter: TypeAdapter[PositiveTotal] = TypeAdapter(PositiveTotal)
|
|
100
|
+
return adapter.dump_python
|
|
101
|
+
|
|
102
|
+
@pytest.fixture
|
|
103
|
+
def load(self) -> Callable[[Any], ValueObject[Any]]:
|
|
104
|
+
adapter: TypeAdapter[PositiveTotal] = TypeAdapter(PositiveTotal)
|
|
105
|
+
return adapter.validate_python
|
|
106
|
+
|
|
107
|
+
@pytest.fixture
|
|
108
|
+
def sensitive_vo(self) -> ValueObject[Any]:
|
|
109
|
+
return Secret("super-secret-1")
|
|
110
|
+
|
|
111
|
+
@pytest.fixture
|
|
112
|
+
def container_repr(self) -> Callable[[ValueObject[Any]], str]:
|
|
113
|
+
def make_repr(vo: ValueObject[Any]) -> str:
|
|
114
|
+
return repr(Credentials(password=vo))
|
|
115
|
+
|
|
116
|
+
return make_repr
|