pydantic-views 0.1.0b0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alfred Santacatalina Gea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: pydantic-views
3
+ Version: 0.1.0b0
4
+ Summary: View for Pydantic models
5
+ License: LGPL-3.0-or-later
6
+ Author: Alfred Santacatalina
7
+ Author-email: alfred.santacatalinagea@telefonica.com
8
+ Requires-Python: >=3.13,<4.0.0
9
+ Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Dist: pydantic (>=2.10.6,<3.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Views for pydantic models
16
+
17
+ TBD
18
+
@@ -0,0 +1,3 @@
1
+ # Views for pydantic models
2
+
3
+ TBD
@@ -0,0 +1,102 @@
1
+ [project]
2
+ name = "pydantic-views"
3
+ version = "0.1.0b0"
4
+ description = "View for Pydantic models"
5
+ authors = [
6
+ {name = "Alfred Santacatalina",email = "alfred.santacatalinagea@telefonica.com"}
7
+ ]
8
+ license = "LGPL-3.0-or-later"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13,<4.0.0"
11
+ dependencies = ["pydantic (>=2.10.6,<3.0.0)"]
12
+
13
+
14
+ [build-system]
15
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
16
+ build-backend = "poetry.core.masonry.api"
17
+
18
+
19
+ [tool.poetry]
20
+ requires-poetry = ">=2.0"
21
+ packages = [{ from = "src", include = "pydantic_views" }]
22
+
23
+ [tool.poetry.group.dev.dependencies]
24
+ flake8 = "^7.1.2"
25
+ pytest-cov = "^6.0.0"
26
+ isort = "^5.13.2"
27
+ absolufy-imports = "^0.3.1"
28
+ ruff = "^0.9.9"
29
+ mypy = "^1.15.0"
30
+ pytest = "^8.3.5"
31
+ flake8-pyproject = "^1.2.3"
32
+ autoflake = "^2.3.1"
33
+
34
+
35
+ [tool.poetry.group.docs.dependencies]
36
+ sphinx = "^8.2.3"
37
+ autodoc-pydantic = "^2.2.0"
38
+
39
+ [tool.ruff]
40
+ exclude = [".venv/*"]
41
+
42
+ [tool.flake8]
43
+ exclude = [".venv/*"]
44
+ max-line-length = 120
45
+ extend-ignore = "E251"
46
+
47
+ [tool.isort]
48
+ profile = "black"
49
+ src_paths = ["src", "tests"]
50
+ skip_glob = [".venv/*"]
51
+ reverse_relative = true
52
+ split_on_trailing_comma = true
53
+ multi_line_output = 3
54
+ include_trailing_comma = true
55
+ force_grid_wrap = 0
56
+ use_parentheses = true
57
+ ensure_newline_before_comments = true
58
+
59
+ [tool.mypy]
60
+ files = ["src", "tests"]
61
+ exclude = [".venv/.*", "docs/source/.*"]
62
+ disable_error_code="valid-type,import-untyped"
63
+
64
+
65
+ [tool.pydantic-mypy]
66
+ init_forbid_extra = true
67
+ init_typed = true
68
+ warn_required_dynamic_aliases = true
69
+ warn_untyped_fields = true
70
+
71
+ [tool.coverage.run]
72
+ omit = [".venv/*"]
73
+ branch = true
74
+ relative_files = false
75
+
76
+ [tool.coverage.report]
77
+ # Regexes for lines to exclude from consideration
78
+ exclude_also = [
79
+ # Dont complain about missing debug-only code:
80
+ "def __repr__",
81
+ "if self\\.debug",
82
+
83
+ # Don't complain if tests don't hit defensive assertion code:
84
+ "raise AssertionError",
85
+ "raise NotImplementedError",
86
+
87
+ # Don't complain if non-runnable code isn't run:
88
+ "if 0:",
89
+ "if __name__ == .__main__.:",
90
+
91
+ # Don't complain about abstract methods, they aren't run:
92
+ "@(abc\\.)?abstractmethod",
93
+
94
+ # Don't complain type checking imports, they aren't run:
95
+ "if TYPE_CHECKING",
96
+
97
+ # Don't complain overloads, they aren't run:
98
+ "@overload"
99
+ ]
100
+
101
+ [tool.coverage.paths]
102
+ source = ["src/"]
File without changes
@@ -0,0 +1,40 @@
1
+ from enum import Enum, auto
2
+ from typing import Annotated, TypeVar
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ class AccessMode(Enum):
8
+ READ_AND_WRITE = auto()
9
+ READ_ONLY = auto()
10
+ WRITE_ONLY = auto()
11
+ READ_ONLY_ON_CREATION = auto()
12
+ WRITE_ONLY_ON_CREATION = auto()
13
+ HIDDEN = auto()
14
+
15
+
16
+ class FieldAccess:
17
+ __slots__ = ("_mode",)
18
+
19
+ def __init__(self, mode: AccessMode = AccessMode.READ_AND_WRITE):
20
+ self._mode = mode
21
+
22
+ @property
23
+ def mode(self) -> AccessMode:
24
+ return self._mode
25
+
26
+
27
+ RW = FieldAccess(AccessMode.READ_AND_WRITE)
28
+ RO = FieldAccess(AccessMode.READ_ONLY)
29
+ WO = FieldAccess(AccessMode.WRITE_ONLY)
30
+ ROOC = FieldAccess(AccessMode.READ_ONLY_ON_CREATION)
31
+ WOOC = FieldAccess(AccessMode.WRITE_ONLY_ON_CREATION)
32
+ HIDDEN = FieldAccess(AccessMode.HIDDEN)
33
+
34
+
35
+ ReadAndWrite = Annotated[T, RW]
36
+ ReadOnly = Annotated[T, RO]
37
+ WriteOnly = Annotated[T, WO]
38
+ ReadOnlyOnCreation = Annotated[T, ROOC]
39
+ WriteOnlyOnCreation = Annotated[T, WOOC]
40
+ Hidden = Annotated[T, HIDDEN]
@@ -0,0 +1,336 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from copy import deepcopy
3
+ from types import NoneType, UnionType
4
+ from typing import Union # type: ignore[deprecated]
5
+ from typing import (
6
+ Any,
7
+ ForwardRef,
8
+ cast,
9
+ get_args,
10
+ get_origin,
11
+ )
12
+ from weakref import ref
13
+
14
+ from pydantic import BaseModel, RootModel, create_model
15
+ from pydantic.fields import ComputedFieldInfo, FieldInfo
16
+ from pydantic_core import PydanticUndefined
17
+
18
+ from .annotations import AccessMode, FieldAccess
19
+ from .manager import Manager
20
+ from .view import RootView, View
21
+
22
+
23
+ class Builder:
24
+ def __init__(
25
+ self,
26
+ suffix: str,
27
+ access_modes: tuple[AccessMode, ...],
28
+ all_optional: bool = False,
29
+ all_nullable: bool = False,
30
+ hide_default_null: bool = False,
31
+ include_computed_fields: bool = False,
32
+ ) -> None:
33
+ self.suffix = suffix
34
+ self.access_modes = access_modes
35
+ self.all_optional = all_optional
36
+ self.all_nullable = all_nullable
37
+ self.hide_default_null = hide_default_null
38
+ self.include_computed_fields = include_computed_fields
39
+ self._views: dict[type[BaseModel], type[View[BaseModel]] | ForwardRef] = {}
40
+
41
+ def build_view[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
42
+ manager = ensure_model_views(model)
43
+ try:
44
+ result: type[View[T] | T] = manager[self.suffix]
45
+ except (KeyError, TypeError):
46
+ result = manager.build_view(self)
47
+
48
+ [
49
+ v.model_rebuild() # type: ignore
50
+ for v in self._views.values()
51
+ if not isinstance(v, ForwardRef)
52
+ ]
53
+
54
+ return result
55
+
56
+ def get_view_ref[T: BaseModel](
57
+ self, model: type[T]
58
+ ) -> type[View[T] | T] | ForwardRef:
59
+ try:
60
+ return cast(type[View[T]] | ForwardRef, self._views[model])
61
+ except KeyError:
62
+ pass
63
+
64
+ manager = ensure_model_views(model)
65
+
66
+ try:
67
+ view: type[View[T] | T] = manager[self.suffix]
68
+ except KeyError:
69
+ view = manager.build_view(self)
70
+
71
+ self._views[model] = cast(type[View[BaseModel]], view)
72
+
73
+ return view
74
+
75
+ def _filter_field(self, f_info: FieldInfo):
76
+ am = {m.mode for m in f_info.metadata if isinstance(m, FieldAccess)}
77
+ return len((am & set(self.access_modes))) == 0 and len(am) > 0
78
+
79
+ def _iter_fields[T: BaseModel](self, model: type[T]):
80
+ for f_name, f_info in model.model_fields.items():
81
+ if self._filter_field(f_info):
82
+ continue
83
+ yield f_name, f_info
84
+
85
+ def _filter_computed_field(self, f_info: ComputedFieldInfo) -> bool:
86
+ return False
87
+
88
+ def _iter_computed_fields[T: BaseModel](self, model: type[T]):
89
+ for f_name, cf_info in model.model_computed_fields.items():
90
+ if self._filter_computed_field(
91
+ cf_info
92
+ ): # [TODO] does it have sense? # pragma: no cover
93
+ continue
94
+ yield f_name, cf_info
95
+
96
+ def build_from_model[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
97
+ from pydantic._internal._config import ConfigWrapper
98
+
99
+ view_name = model.__name__ + self.suffix[0].upper() + self.suffix[1:]
100
+ try:
101
+ view_cache = self._views[model]
102
+ if not isinstance(view_cache, ForwardRef):
103
+ return cast(type[View[T] | T], view_cache)
104
+ except KeyError:
105
+ self._views[model] = ForwardRef(view_name, module=model.__module__)
106
+
107
+ view: type[View[T] | T]
108
+
109
+ manager = ensure_model_views(model)
110
+
111
+ try:
112
+ view = manager[self.suffix]
113
+ self._views[model] = cast(type[View[BaseModel]], view)
114
+ except KeyError:
115
+ model_fields: dict[str, tuple[type[Any] | None, FieldInfo]] = {}
116
+ for f_name, f_info in self._iter_fields(model):
117
+ model_fields[f_name] = self._map_field_info(
118
+ f_info.annotation,
119
+ f_info,
120
+ ignore_nullable=issubclass(model, RootModel),
121
+ )
122
+
123
+ if self.include_computed_fields:
124
+ for f_name, cf_info in self._iter_computed_fields(model):
125
+ model_fields[f_name] = self._map_computed_field_info(cf_info)
126
+
127
+ base_view: type[View[T]] | type[RootView[T]]
128
+
129
+ if issubclass(model, RootModel):
130
+
131
+ class _RootView(RootView[T]):
132
+ model_config = deepcopy(model.model_config)
133
+
134
+ __model_class_root__ = ref(cast(type[T], model))
135
+
136
+ base_view = _RootView
137
+
138
+ else:
139
+
140
+ class _View(View[T]):
141
+ model_config = deepcopy(model.model_config)
142
+
143
+ __model_class_root__ = ref(cast(type[T], model))
144
+
145
+ _View.model_config["protected_namespaces"] = tuple(
146
+ {
147
+ *ConfigWrapper(_View.model_config).protected_namespaces,
148
+ *ConfigWrapper(View.model_config).protected_namespaces,
149
+ }
150
+ )
151
+
152
+ base_view = _View
153
+
154
+ params: dict[str, Any] = {
155
+ "__module__": model.__module__,
156
+ "__base__": base_view,
157
+ "__doc__": (
158
+ f"View `{self.suffix}` "
159
+ f"of model :class:`~{model.__module__}.{model.__qualname__}`"
160
+ ),
161
+ **model_fields,
162
+ }
163
+
164
+ view = cast(
165
+ type[View[T]],
166
+ create_model(
167
+ view_name,
168
+ **params,
169
+ ),
170
+ )
171
+
172
+ self._views[model] = cast(type[View[BaseModel]], view)
173
+
174
+ return view
175
+
176
+ def _map_field_info(
177
+ self,
178
+ annotation: type[Any] | None,
179
+ f_info: FieldInfo,
180
+ *,
181
+ ignore_nullable: bool = False,
182
+ ) -> tuple[type[Any] | None, FieldInfo]:
183
+ f_info = FieldInfo.merge_field_infos(
184
+ f_info,
185
+ annotation=self._map_annotation(
186
+ f_info.annotation, ignore_nullable=ignore_nullable
187
+ ),
188
+ )
189
+
190
+ if self.all_optional:
191
+ f_info = FieldInfo.merge_field_infos(
192
+ f_info,
193
+ default_factory=lambda: PydanticUndefined,
194
+ )
195
+ f_info.default = PydanticUndefined
196
+
197
+ if self.hide_default_null and f_info.default is None:
198
+ f_info = FieldInfo.merge_field_infos(
199
+ f_info,
200
+ default_factory=lambda: PydanticUndefined,
201
+ )
202
+ f_info.default = PydanticUndefined
203
+
204
+ return f_info.annotation, f_info
205
+
206
+ def _map_annotation(
207
+ self, annotation: type[Any] | None, *, ignore_nullable: bool = False
208
+ ) -> type[Any] | ForwardRef | None | UnionType:
209
+ def finish_annotation(a: type[Any] | None) -> type[Any] | None | UnionType:
210
+ if not ignore_nullable and self.all_nullable and a is not Ellipsis: # type: ignore
211
+ return Union[a, None] # type: ignore
212
+
213
+ return a
214
+
215
+ try:
216
+ if annotation and issubclass(annotation, BaseModel):
217
+ return finish_annotation(self.get_view_ref(annotation)) # type: ignore
218
+ except TypeError:
219
+ pass
220
+
221
+ origin = get_origin(annotation)
222
+ type_args = get_args(annotation)
223
+
224
+ if origin is None:
225
+ return finish_annotation(annotation)
226
+
227
+ if origin is not Union and not issubclass(origin, UnionType): # type: ignore
228
+ if issubclass(origin, Mapping):
229
+ return finish_annotation(
230
+ origin[ # type: ignore
231
+ self._map_annotation(type_args[0], ignore_nullable=True),
232
+ self._map_annotation(type_args[1]),
233
+ ]
234
+ )
235
+ elif issubclass(origin, Iterable):
236
+ return finish_annotation(
237
+ origin[ # type: ignore
238
+ *(
239
+ self._map_annotation(
240
+ t, ignore_nullable=issubclass(origin, (list, set))
241
+ )
242
+ for t in type_args
243
+ )
244
+ ]
245
+ )
246
+
247
+ return Union[ # type: ignore
248
+ *(
249
+ self._map_annotation(a)
250
+ for a in type_args
251
+ if a is not NoneType or not self.hide_default_null
252
+ )
253
+ ]
254
+
255
+ def _map_computed_field_info(
256
+ self, cf_info: ComputedFieldInfo
257
+ ) -> tuple[type[Any] | None, FieldInfo]:
258
+ return (
259
+ cf_info.return_type,
260
+ FieldInfo(
261
+ annotation=cf_info.return_type,
262
+ alias=cf_info.alias,
263
+ default=None,
264
+ alias_priority=cf_info.alias_priority,
265
+ serialization_alias=cf_info.title,
266
+ title=cf_info.title,
267
+ description=cf_info.title,
268
+ examples=cf_info.examples,
269
+ discriminator=cf_info.title,
270
+ deprecated=cf_info.title,
271
+ json_schema_extra=cf_info.json_schema_extra,
272
+ repr=cf_info.repr,
273
+ ),
274
+ )
275
+
276
+
277
+ def BuilderCreate(suffix: str = "Create") -> Builder:
278
+ return Builder(
279
+ suffix,
280
+ access_modes=(
281
+ AccessMode.READ_AND_WRITE,
282
+ AccessMode.WRITE_ONLY,
283
+ AccessMode.WRITE_ONLY_ON_CREATION,
284
+ ),
285
+ hide_default_null=True,
286
+ )
287
+
288
+
289
+ def BuilderCreateResult(suffix: str = "CreateResult") -> Builder:
290
+ return Builder(
291
+ suffix,
292
+ access_modes=(
293
+ AccessMode.READ_AND_WRITE,
294
+ AccessMode.READ_ONLY,
295
+ AccessMode.READ_ONLY_ON_CREATION,
296
+ ),
297
+ include_computed_fields=True,
298
+ )
299
+
300
+
301
+ def BuilderUpdate(suffix: str = "Update") -> Builder:
302
+ return Builder(
303
+ suffix,
304
+ access_modes=(AccessMode.READ_AND_WRITE, AccessMode.WRITE_ONLY),
305
+ all_optional=True,
306
+ )
307
+
308
+
309
+ def BuilderLoad(suffix: str = "Load") -> Builder:
310
+ return Builder(
311
+ suffix,
312
+ access_modes=(
313
+ AccessMode.READ_AND_WRITE,
314
+ AccessMode.READ_ONLY,
315
+ ),
316
+ include_computed_fields=True,
317
+ )
318
+
319
+
320
+ def ensure_model_views[T: BaseModel](model: type[T]):
321
+ try:
322
+ if (
323
+ manager := cast(Manager[T], getattr(model, "model_views"))
324
+ ) and manager.model == model:
325
+ return manager
326
+ except AttributeError:
327
+ pass
328
+
329
+ manager = Manager(model)
330
+ setattr(
331
+ model,
332
+ "model_views",
333
+ manager,
334
+ )
335
+
336
+ return manager
@@ -0,0 +1,36 @@
1
+ from typing import TYPE_CHECKING
2
+ from weakref import ref
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from .view import View
7
+
8
+ if TYPE_CHECKING:
9
+ from .builder import Builder
10
+
11
+
12
+ class Manager[TModel: BaseModel]:
13
+ __slots__ = ("_model", "_views")
14
+
15
+ def __init__(self, model: type[TModel]):
16
+ self._model = ref(model)
17
+ self._views: dict[str, type["View[TModel] | TModel"]] = {}
18
+
19
+ @property
20
+ def model(self) -> type[TModel]:
21
+ result = self._model()
22
+ if not result: # pragma: no cover
23
+ raise RuntimeError("Model class disappeared")
24
+ return result
25
+
26
+ def __getitem__(self, view_name: str) -> type["View[TModel] | TModel"]:
27
+ return self._views[view_name]
28
+
29
+ def __setitem__(self, view_name: str, view: type["View[TModel] | TModel"]):
30
+ self._views[view_name] = view
31
+
32
+ def build_view(self, builder: "Builder") -> type["View[TModel] | TModel"]:
33
+ view = builder.build_from_model(self.model)
34
+ self[builder.suffix] = view
35
+
36
+ return view
File without changes
@@ -0,0 +1,92 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any, ClassVar, cast, overload
3
+ from weakref import ReferenceType
4
+
5
+ from pydantic import BaseModel, RootModel
6
+
7
+
8
+ class View[T: BaseModel](BaseModel):
9
+ __model_class_root__: ClassVar[ReferenceType[type[BaseModel]]]
10
+
11
+ model_config = {
12
+ "protected_namespaces": ("view_class_root", "view_build", "view_apply")
13
+ }
14
+
15
+ @classmethod
16
+ def view_class_root(cls) -> type[T]:
17
+ root = cls.__model_class_root__()
18
+
19
+ if root is None: # pragma: no cover
20
+ raise RuntimeError("Root model disappeared")
21
+
22
+ return cast(type[T], root)
23
+
24
+ @classmethod
25
+ def view_build_from(cls, model: T):
26
+ return cls.model_validate(model.model_dump(exclude_unset=True, by_alias=True))
27
+
28
+ def view_build_to(self) -> T:
29
+ return self.view_class_root().model_validate(
30
+ self.model_dump(exclude_unset=True, exclude_defaults=True, by_alias=True)
31
+ )
32
+
33
+ def view_apply_to(self, model: T) -> T:
34
+ return model_apply(model, self)
35
+
36
+
37
+ class RootView[R](View[RootModel[R]], RootModel[R]):
38
+ pass
39
+
40
+
41
+ def model_apply[T: BaseModel](orig: T, view: View[T] | T) -> T:
42
+ update_data: dict[str, Any] = {}
43
+
44
+ for field in view.model_fields_set:
45
+ value = _merge_values(
46
+ getattr(orig, field),
47
+ getattr(view, field),
48
+ )
49
+ update_data[field] = getattr(
50
+ orig.__pydantic_validator__.validate_assignment(orig, field, value), field
51
+ )
52
+ return orig.model_copy(
53
+ update=update_data,
54
+ deep=True,
55
+ )
56
+
57
+
58
+ @overload
59
+ def _merge_values[T: BaseModel](
60
+ orig_value: None, new_value: T
61
+ ) -> T | dict[str, Any]: ...
62
+
63
+
64
+ @overload
65
+ def _merge_values[T: BaseModel, A](orig_value: None, new_value: A) -> A: ...
66
+
67
+
68
+ @overload
69
+ def _merge_values[T: BaseModel, A](
70
+ orig_value: T, new_value: View[T]
71
+ ) -> T | dict[str, Any]: ...
72
+
73
+
74
+ def _merge_values[T: BaseModel, A](
75
+ orig_value: T | None, new_value: View[T] | A
76
+ ) -> T | A | dict[str, Any]:
77
+ if isinstance(new_value, BaseModel):
78
+ if isinstance(orig_value, BaseModel):
79
+ return cast(T, model_apply(orig_value, new_value))
80
+ if isinstance(new_value, View):
81
+ return cast(View[T], new_value).view_build_to()
82
+ return new_value.model_dump(
83
+ exclude_unset=True, exclude_defaults=True, by_alias=True
84
+ )
85
+ elif isinstance(new_value, Mapping) and isinstance(orig_value, Mapping):
86
+ data: dict[str, Any] = dict(cast(Mapping[str, Any], orig_value))
87
+ for k, v in cast(Mapping[str, Any], new_value).items():
88
+ data[k] = _merge_values(data.get(k), v)
89
+
90
+ return cast(T, cast(Mapping[str, Any], orig_value).__class__(**data))
91
+ else:
92
+ return cast(T, new_value)