pydantic-views 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,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,210 @@
1
+ Metadata-Version: 2.3
2
+ Name: pydantic-views
3
+ Version: 0.1.0
4
+ Summary: Views for Pydantic models
5
+ Home-page: https://pydantic-views.readthedocs.io/latest/
6
+ License: MIT
7
+ Keywords: view,pydantic,datamodel,model,REST API
8
+ Author: Alfred Santacatalina
9
+ Author-email: alfred.santacatalinagea@telefonica.com
10
+ Requires-Python: >=3.13,<4.0.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Pydantic
13
+ Classifier: Framework :: Pydantic :: 2
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: pydantic (>=2.10.6,<3.0.0)
18
+ Project-URL: Documentation, https://pydantic-views.readthedocs.io/latest/
19
+ Project-URL: Issues, https://github.com/alfred82santa/pydantic-views/issues
20
+ Project-URL: Repository, https://github.com/alfred82santa/pydantic-views.git
21
+ Description-Content-Type: text/x-rst
22
+
23
+
24
+ .. |docs| image:: https://readthedocs.org/projects/pydantic-views/badge/?version=latest
25
+ :alt: Documentation Status
26
+ :target: https://pydantic-views.readthedocs.io/latest/?badge=latest
27
+
28
+ .. |python-versions| image:: https://img.shields.io/pypi/pyversions/pydantic-views
29
+ :alt: PyPI - Python Version
30
+
31
+ .. |typed| image:: https://img.shields.io/pypi/types/pydantic-views
32
+ :alt: PyPI - Types
33
+
34
+ .. |license| image:: https://img.shields.io/pypi/l/pydantic-views
35
+ :alt: PyPI - License
36
+
37
+ .. |version| image:: https://img.shields.io/pypi/v/pydantic-views
38
+ :alt: PyPI - Version
39
+
40
+
41
+ |docs| |python-versions| |typed| |license| |version|
42
+
43
+ .. start-doc
44
+
45
+ ======================================
46
+ View for Pydantic models documentation
47
+ ======================================
48
+
49
+ This package provides a simple way to create `views` from `pydantic <https://docs.pydantic.dev/latest/>`_ models. A view is
50
+ another `pydantic <https://docs.pydantic.dev/latest/>`_ models with some of field of original model. So, for example,
51
+ read only fields does not appears on `Create` or `Update` views.
52
+
53
+ As rest service definition you could do:
54
+
55
+ .. code-block:: python
56
+
57
+ ExampleModelCreate = BuilderCreate().build_view(ExampleModel)
58
+ ExampleModelCreateResult = BuilderCreateResult().build_view(ExampleModel)
59
+ ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
60
+ ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
61
+
62
+ def create(input: ExampleModelCreate) -> ExampleModelCreateResult: ...
63
+ def load(model_id: str) -> ExampleModelLoad: ...
64
+ def update(model_id: str, input: ExampleModelUpdate) -> ExampleModelLoad: ...
65
+
66
+
67
+ --------
68
+ Features
69
+ --------
70
+
71
+ - Unlimited views per model.
72
+ - Create view for referenced inner models.
73
+ - It is possible to set a view manually.
74
+ - Tested code.
75
+ - Full typed.
76
+ - Opensource.
77
+
78
+
79
+ ------------
80
+ Installation
81
+ ------------
82
+
83
+ Using pip:
84
+
85
+ .. code-block:: bash
86
+
87
+ pip install pydantic-views
88
+
89
+ Using `poetry <https://python-poetry.org/>`_:
90
+
91
+ .. code-block:: bash
92
+
93
+ poetry add pydantic-views
94
+
95
+
96
+ ----------
97
+ How to use
98
+ ----------
99
+
100
+ When you define a pydantic model you must mark the access model for each field. It means
101
+ you should use our `annotations <https://pydantic-views.readthedocs.io/latest/api.html#field-annotations>`_ to define field typing.
102
+
103
+ .. code-block:: python
104
+
105
+ from typing import Annotated
106
+ from pydantic import BaseModel, gt
107
+ from pydantic_views import ReadOnly, ReadOnlyOnCreation, Hidden, AccessMode
108
+
109
+ class ExampleModel(BaseModel):
110
+
111
+ # No marked fields are treated like ReadAndWrite fields.
112
+ field_str: str
113
+
114
+ # Read only fields are removed on view for create and update views.
115
+ field_read_only_str: ReadOnly[str]
116
+
117
+ # Read only on creation fields are removed on view for create, update and load views.
118
+ # But it is shown on create result view.
119
+ field_api_secret: ReadOnlyOnCreation[str]
120
+
121
+ # It is possible to set more than one access mode and to use annotation standard pattern.
122
+ field_int: Annotated[int, AccessMode.READ_ONLY, AccessMode.WRITE_ONLY_ON_CREATION, gt(5)]
123
+
124
+ # Hidden field do not appears in any view.
125
+ field_hidden_int: Hidden[int]
126
+
127
+ # Computed fields only appears on reading views.
128
+ @computed_field
129
+ def field_computed_field(self) -> int:
130
+ return self.field_hidden_int * 5
131
+
132
+ So, in order to build a `Load` view it is so simple:
133
+
134
+ .. code-block:: python
135
+
136
+ from pydantic_views import BuilderLoad
137
+
138
+ ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
139
+
140
+ It is equivalent to:
141
+
142
+
143
+ .. code-block:: python
144
+
145
+ from pydantic import gt
146
+ from pydantic_views import View
147
+
148
+ class ExampleModelLoad(View[ExampleModel]):
149
+ field_str: str
150
+ field_int: Annotated[int, gt(5)]
151
+ field_computed_field: int
152
+
153
+ In same way to build a `Update` view you must do:
154
+
155
+ .. code-block:: python
156
+
157
+ from pydantic_views import BuilderUpdate
158
+
159
+ ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
160
+
161
+ It is equivalent to:
162
+
163
+ .. code-block:: python
164
+
165
+ from pydantic import PydanticUndefined
166
+ from pydantic_views import View
167
+
168
+ class ExampleModelUpdate(View[ExampleModel]):
169
+ field_str: str = Field(default_factory=lambda: PydanticUndefined)
170
+
171
+ As you can see, on `Update` view all fields has a default factory returning `PydanticUndefined`
172
+ in order to make them optionals. And when an update view is applied to a given model, the fields that are
173
+ not set (use default value) will not be applied to the model.
174
+
175
+ .. code-block:: python
176
+
177
+ original_model = ExampleModel(
178
+ field_str="anything"
179
+ field_read_only_str="anything"
180
+ field_api_secret="anything"
181
+ field_int=10
182
+ field_hidden_int=33
183
+ )
184
+
185
+ update = ExampleModelUpdate(field_str="new_data")
186
+
187
+ updated_model = update.view_apply_to(original_model)
188
+
189
+ assert isinstance(updated_model, ExampleModel)
190
+ assert updated_model.field_str == "new_data"
191
+
192
+
193
+ But if a field is not set on update view, the original value is kept.
194
+
195
+ .. code-block:: python
196
+
197
+ original_model = ExampleModel(
198
+ field_str="anything"
199
+ field_read_only_str="anything"
200
+ field_api_secret="anything"
201
+ field_int=10
202
+ field_hidden_int=33
203
+ )
204
+
205
+ update = ExampleModelUpdate()
206
+
207
+ updated_model = update.view_apply_to(original_model)
208
+
209
+ assert isinstance(updated_model, ExampleModel)
210
+ assert updated_model.field_str == "anything"
@@ -0,0 +1,188 @@
1
+
2
+ .. |docs| image:: https://readthedocs.org/projects/pydantic-views/badge/?version=latest
3
+ :alt: Documentation Status
4
+ :target: https://pydantic-views.readthedocs.io/latest/?badge=latest
5
+
6
+ .. |python-versions| image:: https://img.shields.io/pypi/pyversions/pydantic-views
7
+ :alt: PyPI - Python Version
8
+
9
+ .. |typed| image:: https://img.shields.io/pypi/types/pydantic-views
10
+ :alt: PyPI - Types
11
+
12
+ .. |license| image:: https://img.shields.io/pypi/l/pydantic-views
13
+ :alt: PyPI - License
14
+
15
+ .. |version| image:: https://img.shields.io/pypi/v/pydantic-views
16
+ :alt: PyPI - Version
17
+
18
+
19
+ |docs| |python-versions| |typed| |license| |version|
20
+
21
+ .. start-doc
22
+
23
+ ======================================
24
+ View for Pydantic models documentation
25
+ ======================================
26
+
27
+ This package provides a simple way to create `views` from `pydantic <https://docs.pydantic.dev/latest/>`_ models. A view is
28
+ another `pydantic <https://docs.pydantic.dev/latest/>`_ models with some of field of original model. So, for example,
29
+ read only fields does not appears on `Create` or `Update` views.
30
+
31
+ As rest service definition you could do:
32
+
33
+ .. code-block:: python
34
+
35
+ ExampleModelCreate = BuilderCreate().build_view(ExampleModel)
36
+ ExampleModelCreateResult = BuilderCreateResult().build_view(ExampleModel)
37
+ ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
38
+ ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
39
+
40
+ def create(input: ExampleModelCreate) -> ExampleModelCreateResult: ...
41
+ def load(model_id: str) -> ExampleModelLoad: ...
42
+ def update(model_id: str, input: ExampleModelUpdate) -> ExampleModelLoad: ...
43
+
44
+
45
+ --------
46
+ Features
47
+ --------
48
+
49
+ - Unlimited views per model.
50
+ - Create view for referenced inner models.
51
+ - It is possible to set a view manually.
52
+ - Tested code.
53
+ - Full typed.
54
+ - Opensource.
55
+
56
+
57
+ ------------
58
+ Installation
59
+ ------------
60
+
61
+ Using pip:
62
+
63
+ .. code-block:: bash
64
+
65
+ pip install pydantic-views
66
+
67
+ Using `poetry <https://python-poetry.org/>`_:
68
+
69
+ .. code-block:: bash
70
+
71
+ poetry add pydantic-views
72
+
73
+
74
+ ----------
75
+ How to use
76
+ ----------
77
+
78
+ When you define a pydantic model you must mark the access model for each field. It means
79
+ you should use our `annotations <https://pydantic-views.readthedocs.io/latest/api.html#field-annotations>`_ to define field typing.
80
+
81
+ .. code-block:: python
82
+
83
+ from typing import Annotated
84
+ from pydantic import BaseModel, gt
85
+ from pydantic_views import ReadOnly, ReadOnlyOnCreation, Hidden, AccessMode
86
+
87
+ class ExampleModel(BaseModel):
88
+
89
+ # No marked fields are treated like ReadAndWrite fields.
90
+ field_str: str
91
+
92
+ # Read only fields are removed on view for create and update views.
93
+ field_read_only_str: ReadOnly[str]
94
+
95
+ # Read only on creation fields are removed on view for create, update and load views.
96
+ # But it is shown on create result view.
97
+ field_api_secret: ReadOnlyOnCreation[str]
98
+
99
+ # It is possible to set more than one access mode and to use annotation standard pattern.
100
+ field_int: Annotated[int, AccessMode.READ_ONLY, AccessMode.WRITE_ONLY_ON_CREATION, gt(5)]
101
+
102
+ # Hidden field do not appears in any view.
103
+ field_hidden_int: Hidden[int]
104
+
105
+ # Computed fields only appears on reading views.
106
+ @computed_field
107
+ def field_computed_field(self) -> int:
108
+ return self.field_hidden_int * 5
109
+
110
+ So, in order to build a `Load` view it is so simple:
111
+
112
+ .. code-block:: python
113
+
114
+ from pydantic_views import BuilderLoad
115
+
116
+ ExampleModelLoad = BuilderLoad().build_view(ExampleModel)
117
+
118
+ It is equivalent to:
119
+
120
+
121
+ .. code-block:: python
122
+
123
+ from pydantic import gt
124
+ from pydantic_views import View
125
+
126
+ class ExampleModelLoad(View[ExampleModel]):
127
+ field_str: str
128
+ field_int: Annotated[int, gt(5)]
129
+ field_computed_field: int
130
+
131
+ In same way to build a `Update` view you must do:
132
+
133
+ .. code-block:: python
134
+
135
+ from pydantic_views import BuilderUpdate
136
+
137
+ ExampleModelUpdate = BuilderUpdate().build_view(ExampleModel)
138
+
139
+ It is equivalent to:
140
+
141
+ .. code-block:: python
142
+
143
+ from pydantic import PydanticUndefined
144
+ from pydantic_views import View
145
+
146
+ class ExampleModelUpdate(View[ExampleModel]):
147
+ field_str: str = Field(default_factory=lambda: PydanticUndefined)
148
+
149
+ As you can see, on `Update` view all fields has a default factory returning `PydanticUndefined`
150
+ in order to make them optionals. And when an update view is applied to a given model, the fields that are
151
+ not set (use default value) will not be applied to the model.
152
+
153
+ .. code-block:: python
154
+
155
+ original_model = ExampleModel(
156
+ field_str="anything"
157
+ field_read_only_str="anything"
158
+ field_api_secret="anything"
159
+ field_int=10
160
+ field_hidden_int=33
161
+ )
162
+
163
+ update = ExampleModelUpdate(field_str="new_data")
164
+
165
+ updated_model = update.view_apply_to(original_model)
166
+
167
+ assert isinstance(updated_model, ExampleModel)
168
+ assert updated_model.field_str == "new_data"
169
+
170
+
171
+ But if a field is not set on update view, the original value is kept.
172
+
173
+ .. code-block:: python
174
+
175
+ original_model = ExampleModel(
176
+ field_str="anything"
177
+ field_read_only_str="anything"
178
+ field_api_secret="anything"
179
+ field_int=10
180
+ field_hidden_int=33
181
+ )
182
+
183
+ update = ExampleModelUpdate()
184
+
185
+ updated_model = update.view_apply_to(original_model)
186
+
187
+ assert isinstance(updated_model, ExampleModel)
188
+ assert updated_model.field_str == "anything"
@@ -0,0 +1,117 @@
1
+ [project]
2
+ name = "pydantic-views"
3
+ version = "0.1.0"
4
+ description = "Views for Pydantic models"
5
+ authors = [
6
+ {name = "Alfred Santacatalina",email = "alfred.santacatalinagea@telefonica.com"}
7
+ ]
8
+ license = "MIT"
9
+ readme = "README.rst"
10
+ requires-python = ">=3.13,<4.0.0"
11
+ dependencies = ["pydantic (>=2.10.6,<3.0.0)"]
12
+ keywords = ["view", "pydantic", "datamodel", "model", "REST API"]
13
+
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: Pydantic",
17
+ "Framework :: Pydantic :: 2",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed"
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://pydantic-views.readthedocs.io/latest/"
25
+ Repository = "https://github.com/alfred82santa/pydantic-views.git"
26
+ Documentation = "https://pydantic-views.readthedocs.io/latest/"
27
+ Issues = "https://github.com/alfred82santa/pydantic-views/issues"
28
+
29
+ [build-system]
30
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
31
+ build-backend = "poetry.core.masonry.api"
32
+
33
+
34
+ [tool.poetry]
35
+ requires-poetry = ">=2.0"
36
+ packages = [{ from = "src", include = "pydantic_views" }]
37
+
38
+ [tool.poetry.group.dev.dependencies]
39
+ flake8 = "^7.1.2"
40
+ pytest-cov = "^6.0.0"
41
+ isort = "^5.13.2"
42
+ absolufy-imports = "^0.3.1"
43
+ ruff = "^0.9.9"
44
+ mypy = "^1.15.0"
45
+ pytest = "^8.3.5"
46
+ flake8-pyproject = "^1.2.3"
47
+ autoflake = "^2.3.1"
48
+
49
+
50
+ [tool.poetry.group.docs.dependencies]
51
+ sphinx = "^8.2.3"
52
+ autodoc-pydantic = "^2.2.0"
53
+
54
+ [tool.ruff]
55
+ exclude = [".venv/*"]
56
+
57
+ [tool.flake8]
58
+ exclude = [".venv/*"]
59
+ max-line-length = 120
60
+ extend-ignore = "E251"
61
+
62
+ [tool.isort]
63
+ profile = "black"
64
+ src_paths = ["src", "tests"]
65
+ skip_glob = [".venv/*"]
66
+ reverse_relative = true
67
+ split_on_trailing_comma = true
68
+ multi_line_output = 3
69
+ include_trailing_comma = true
70
+ force_grid_wrap = 0
71
+ use_parentheses = true
72
+ ensure_newline_before_comments = true
73
+
74
+ [tool.mypy]
75
+ files = ["src", "tests"]
76
+ exclude = [".venv/.*", "docs/source/.*"]
77
+ disable_error_code="valid-type,import-untyped"
78
+
79
+
80
+ [tool.pydantic-mypy]
81
+ init_forbid_extra = true
82
+ init_typed = true
83
+ warn_required_dynamic_aliases = true
84
+ warn_untyped_fields = true
85
+
86
+ [tool.coverage.run]
87
+ omit = [".venv/*"]
88
+ branch = true
89
+ relative_files = false
90
+
91
+ [tool.coverage.report]
92
+ # Regexes for lines to exclude from consideration
93
+ exclude_also = [
94
+ # Dont complain about missing debug-only code:
95
+ "def __repr__",
96
+ "if self\\.debug",
97
+
98
+ # Don't complain if tests don't hit defensive assertion code:
99
+ "raise AssertionError",
100
+ "raise NotImplementedError",
101
+
102
+ # Don't complain if non-runnable code isn't run:
103
+ "if 0:",
104
+ "if __name__ == .__main__.:",
105
+
106
+ # Don't complain about abstract methods, they aren't run:
107
+ "@(abc\\.)?abstractmethod",
108
+
109
+ # Don't complain type checking imports, they aren't run:
110
+ "if TYPE_CHECKING",
111
+
112
+ # Don't complain overloads, they aren't run:
113
+ "@overload"
114
+ ]
115
+
116
+ [tool.coverage.paths]
117
+ source = ["src/"]
@@ -0,0 +1,38 @@
1
+ from .annotations import (
2
+ AccessMode,
3
+ Hidden,
4
+ ReadAndWrite,
5
+ ReadOnly,
6
+ ReadOnlyOnCreation,
7
+ WriteOnly,
8
+ WriteOnlyOnCreation,
9
+ )
10
+ from .builder import (
11
+ Builder,
12
+ BuilderCreate,
13
+ BuilderCreateResult,
14
+ BuilderLoad,
15
+ BuilderUpdate,
16
+ ensure_model_views,
17
+ )
18
+ from .manager import Manager
19
+ from .view import RootView, View
20
+
21
+ __all__ = [
22
+ "AccessMode",
23
+ "ReadOnly",
24
+ "ReadAndWrite",
25
+ "ReadOnlyOnCreation",
26
+ "WriteOnly",
27
+ "WriteOnlyOnCreation",
28
+ "Hidden",
29
+ "Builder",
30
+ "BuilderCreate",
31
+ "BuilderCreateResult",
32
+ "BuilderUpdate",
33
+ "BuilderLoad",
34
+ "ensure_model_views",
35
+ "Manager",
36
+ "View",
37
+ "RootView",
38
+ ]
@@ -0,0 +1,47 @@
1
+ from enum import Enum, auto
2
+ from typing import Annotated, TypeAlias, TypeVar
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ class AccessMode(Enum):
8
+ """
9
+ Field access mode.
10
+ """
11
+
12
+ #: Read and write mark.
13
+ READ_AND_WRITE = auto()
14
+
15
+ #: Read only mark.
16
+ READ_ONLY = auto()
17
+
18
+ #: Write only mark.
19
+ WRITE_ONLY = auto()
20
+
21
+ #: Read only on creation mark.
22
+ READ_ONLY_ON_CREATION = auto()
23
+
24
+ #: Write only on creation mark.
25
+ WRITE_ONLY_ON_CREATION = auto()
26
+
27
+ #: Hidden mark.
28
+ HIDDEN = auto()
29
+
30
+
31
+ #: Read and write field annotation. Field could be read and written always.
32
+ ReadAndWrite: TypeAlias = Annotated[T, AccessMode.READ_AND_WRITE]
33
+
34
+ #: Read only field annotation. Field could be read always but never written.
35
+ ReadOnly = Annotated[T, AccessMode.READ_ONLY]
36
+
37
+ #: Write only field annotation. Field could be written always but never read.
38
+ WriteOnly = Annotated[T, AccessMode.WRITE_ONLY]
39
+
40
+ #: Read only on creation field annotation. Field could be read only after creation, and never again.
41
+ ReadOnlyOnCreation = Annotated[T, AccessMode.READ_ONLY_ON_CREATION]
42
+
43
+ #: Write only on creation field annotation. Field could be written only after creation, and never again.
44
+ WriteOnlyOnCreation = Annotated[T, AccessMode.WRITE_ONLY_ON_CREATION]
45
+
46
+ #: Hidden field annotation. Field could not be read or written.
47
+ Hidden = Annotated[T, AccessMode.HIDDEN]
@@ -0,0 +1,410 @@
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
19
+ from .manager import Manager
20
+ from .view import RootView, View
21
+
22
+
23
+ class Builder:
24
+ """
25
+ View builder. It create a view classes from models following given criterias.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ view_name: str,
31
+ access_modes: tuple[AccessMode, ...],
32
+ all_optional: bool = False,
33
+ all_nullable: bool = False,
34
+ hide_default_null: bool = False,
35
+ include_computed_fields: bool = False,
36
+ ) -> None:
37
+ """
38
+ :param view_name: View name.
39
+ :param access_modes: Access modes to filter for.
40
+ :param all_optional: Make all fields optionals. On updates it allows to send just fields you want to change.
41
+ :param all_nullable: Make all fields nulleables. On some kinds of updates it could meant set default value.
42
+ :param hide_default_null: Hide :obj:`None` as default value. It produces better examples.
43
+ :param include_computed_fields: Whether computed fields must be included on view or not.
44
+ """
45
+ self.view_name = view_name
46
+ self.access_modes = access_modes
47
+ self.all_optional = all_optional
48
+ self.all_nullable = all_nullable
49
+ self.hide_default_null = hide_default_null
50
+ self.include_computed_fields = include_computed_fields
51
+ self._views: dict[type[BaseModel], type[View[BaseModel]] | ForwardRef] = {}
52
+
53
+ def build_view[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
54
+ """
55
+ Builds a view from model if it does not exist, otherwise it return already created one.
56
+
57
+ :param model: Model class.
58
+ :returns: View of model class.
59
+ """
60
+ manager = ensure_model_views(model)
61
+ try:
62
+ result: type[View[T] | T] = manager[self.view_name]
63
+ except (KeyError, TypeError):
64
+ result = manager.build_view(self)
65
+
66
+ [
67
+ v.model_rebuild() # type: ignore
68
+ for v in self._views.values()
69
+ if not isinstance(v, ForwardRef)
70
+ ]
71
+
72
+ return result
73
+
74
+ def get_view_ref[T: BaseModel](
75
+ self, model: type[T]
76
+ ) -> type[View[T] | T] | ForwardRef:
77
+ """
78
+ Returns a view of model or a forward reference to it.
79
+
80
+ :param model: Model class.
81
+ :type model: type[T]
82
+ :returns: View of model class or reference to it.
83
+ :rtype: type[View[T] | T] | ForwardRef
84
+ """
85
+ try:
86
+ return cast(type[View[T]] | ForwardRef, self._views[model])
87
+ except KeyError:
88
+ pass
89
+
90
+ manager = ensure_model_views(model)
91
+
92
+ try:
93
+ view: type[View[T] | T] = manager[self.view_name]
94
+ except KeyError:
95
+ view = manager.build_view(self)
96
+
97
+ self._views[model] = cast(type[View[BaseModel]], view)
98
+
99
+ return view
100
+
101
+ def _filter_field(self, f_info: FieldInfo):
102
+ am = {m for m in f_info.metadata if isinstance(m, AccessMode)}
103
+ return len((am & set(self.access_modes))) == 0 and len(am) > 0
104
+
105
+ def _iter_fields[T: BaseModel](self, model: type[T]):
106
+ for f_name, f_info in model.model_fields.items():
107
+ if self._filter_field(f_info):
108
+ continue
109
+ yield f_name, f_info
110
+
111
+ def _filter_computed_field(self, f_info: ComputedFieldInfo) -> bool:
112
+ return False
113
+
114
+ def _iter_computed_fields[T: BaseModel](self, model: type[T]):
115
+ for f_name, cf_info in model.model_computed_fields.items():
116
+ if self._filter_computed_field(
117
+ cf_info
118
+ ): # [TODO] does it have sense? # pragma: no cover
119
+ continue
120
+ yield f_name, cf_info
121
+
122
+ def build_from_model[T: BaseModel](self, model: type[T]) -> type[View[T] | T]:
123
+ """
124
+ Builds a view from model
125
+
126
+ :param model: Model class.
127
+ :returns: View of model class.
128
+ """
129
+ from pydantic._internal._config import ConfigWrapper
130
+
131
+ view_name = model.__name__ + self.view_name[0].upper() + self.view_name[1:]
132
+ try:
133
+ view_cache = self._views[model]
134
+ if not isinstance(view_cache, ForwardRef):
135
+ return cast(type[View[T] | T], view_cache)
136
+ except KeyError:
137
+ self._views[model] = ForwardRef(view_name, module=model.__module__)
138
+
139
+ view: type[View[T] | T]
140
+
141
+ manager = ensure_model_views(model)
142
+
143
+ try:
144
+ view = manager[self.view_name]
145
+ self._views[model] = cast(type[View[BaseModel]], view)
146
+ except KeyError:
147
+ model_fields: dict[str, tuple[type[Any] | None, FieldInfo]] = {}
148
+ for f_name, f_info in self._iter_fields(model):
149
+ model_fields[f_name] = self._map_field_info(
150
+ f_info.annotation,
151
+ f_info,
152
+ ignore_nullable=issubclass(model, RootModel),
153
+ )
154
+
155
+ if self.include_computed_fields:
156
+ for f_name, cf_info in self._iter_computed_fields(model):
157
+ model_fields[f_name] = self._map_computed_field_info(cf_info)
158
+
159
+ base_view: type[View[T]] | type[RootView[T]]
160
+
161
+ if issubclass(model, RootModel):
162
+
163
+ class _RootView(RootView[T]):
164
+ model_config = deepcopy(model.model_config)
165
+
166
+ __model_class_root__ = ref(cast(type[T], model))
167
+
168
+ base_view = _RootView
169
+
170
+ else:
171
+
172
+ class _View(View[T]):
173
+ model_config = deepcopy(model.model_config)
174
+
175
+ __model_class_root__ = ref(cast(type[T], model))
176
+
177
+ _View.model_config["protected_namespaces"] = tuple(
178
+ {
179
+ *ConfigWrapper(model.model_config).protected_namespaces,
180
+ *ConfigWrapper(View.model_config).protected_namespaces,
181
+ }
182
+ )
183
+
184
+ base_view = _View
185
+
186
+ params: dict[str, Any] = {
187
+ "__module__": model.__module__,
188
+ "__base__": base_view,
189
+ "__doc__": (
190
+ f"View `{self.view_name}` "
191
+ f"of model :class:`~{model.__module__}.{model.__qualname__}`"
192
+ ),
193
+ **model_fields,
194
+ }
195
+
196
+ view = cast(
197
+ type[View[T]],
198
+ create_model(
199
+ view_name,
200
+ **params,
201
+ ),
202
+ )
203
+
204
+ self._views[model] = cast(type[View[BaseModel]], view)
205
+
206
+ return view
207
+
208
+ def _map_field_info(
209
+ self,
210
+ annotation: type[Any] | None,
211
+ f_info: FieldInfo,
212
+ *,
213
+ ignore_nullable: bool = False,
214
+ ) -> tuple[type[Any] | None, FieldInfo]:
215
+ f_info = FieldInfo.merge_field_infos(
216
+ f_info,
217
+ annotation=self._map_annotation(
218
+ f_info.annotation, ignore_nullable=ignore_nullable
219
+ ),
220
+ metadata=[m for m in f_info.metadata if not isinstance(m, AccessMode)],
221
+ )
222
+
223
+ if self.all_optional:
224
+ f_info = FieldInfo.merge_field_infos(
225
+ f_info,
226
+ default_factory=lambda: PydanticUndefined,
227
+ )
228
+ f_info.default = PydanticUndefined
229
+
230
+ if self.hide_default_null and f_info.default is None:
231
+ f_info = FieldInfo.merge_field_infos(
232
+ f_info,
233
+ default_factory=lambda: PydanticUndefined,
234
+ )
235
+ f_info.default = PydanticUndefined
236
+
237
+ return f_info.annotation, f_info
238
+
239
+ def _map_annotation(
240
+ self, annotation: type[Any] | None, *, ignore_nullable: bool = False
241
+ ) -> type[Any] | ForwardRef | None | UnionType:
242
+ def finish_annotation(a: type[Any] | None) -> type[Any] | None | UnionType:
243
+ if not ignore_nullable and self.all_nullable and a is not Ellipsis: # type: ignore
244
+ return Union[a, None] # type: ignore
245
+
246
+ return a
247
+
248
+ try:
249
+ if annotation and issubclass(annotation, BaseModel):
250
+ return finish_annotation(self.get_view_ref(annotation)) # type: ignore
251
+ except TypeError:
252
+ pass
253
+
254
+ origin = get_origin(annotation)
255
+ type_args = get_args(annotation)
256
+
257
+ if origin is None:
258
+ return finish_annotation(annotation)
259
+
260
+ if origin is not Union and not issubclass(origin, UnionType): # type: ignore
261
+ if issubclass(origin, Mapping):
262
+ return finish_annotation(
263
+ origin[ # type: ignore
264
+ self._map_annotation(type_args[0], ignore_nullable=True),
265
+ self._map_annotation(type_args[1]),
266
+ ]
267
+ )
268
+ elif issubclass(origin, Iterable):
269
+ return finish_annotation(
270
+ origin[ # type: ignore
271
+ *(
272
+ self._map_annotation(
273
+ t, ignore_nullable=issubclass(origin, (list, set))
274
+ )
275
+ for t in type_args
276
+ )
277
+ ]
278
+ )
279
+
280
+ return Union[ # type: ignore
281
+ *(
282
+ self._map_annotation(a)
283
+ for a in type_args
284
+ if a is not NoneType or not self.hide_default_null
285
+ )
286
+ ]
287
+
288
+ def _map_computed_field_info(
289
+ self, cf_info: ComputedFieldInfo
290
+ ) -> tuple[type[Any] | None, FieldInfo]:
291
+ return (
292
+ cf_info.return_type,
293
+ FieldInfo(
294
+ annotation=cf_info.return_type,
295
+ alias=cf_info.alias,
296
+ default=None,
297
+ alias_priority=cf_info.alias_priority,
298
+ serialization_alias=cf_info.title,
299
+ title=cf_info.title,
300
+ description=cf_info.title,
301
+ examples=cf_info.examples,
302
+ discriminator=cf_info.title,
303
+ deprecated=cf_info.title,
304
+ json_schema_extra=cf_info.json_schema_extra,
305
+ repr=cf_info.repr,
306
+ ),
307
+ )
308
+
309
+
310
+ def BuilderCreate(view_name: str = "Create") -> Builder:
311
+ """
312
+ Default builder for `Create` view. Views created by it keep fields with
313
+ :obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`,
314
+ :obj:`~pydantic_views.AccessMode.WRITE_ONLY` and :obj:`~pydantic_views.AccessMode.WRITE_ONLY_ON_CREATION`.
315
+ And hide default :obj:`None` value. It produces a better schema examples.
316
+
317
+ :param view_name: View name.
318
+ :returns: Builder configured for `Create` views.
319
+ """
320
+ return Builder(
321
+ view_name,
322
+ access_modes=(
323
+ AccessMode.READ_AND_WRITE,
324
+ AccessMode.WRITE_ONLY,
325
+ AccessMode.WRITE_ONLY_ON_CREATION,
326
+ ),
327
+ hide_default_null=True,
328
+ )
329
+
330
+
331
+ def BuilderCreateResult(view_name: str = "CreateResult") -> Builder:
332
+ """
333
+ Default builder for `CreateResult` view. Views created by it keep fields with
334
+ :obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`,
335
+ :obj:`~pydantic_views.AccessMode.READ_ONLY` and :obj:`~pydantic_views.AccessMode.READ_ONLY_ON_CREATION`.
336
+ And includes computed fields.
337
+
338
+ :param view_name: View name.
339
+ :returns: Builder configured for `CreateResult` views.
340
+ """
341
+ return Builder(
342
+ view_name,
343
+ access_modes=(
344
+ AccessMode.READ_AND_WRITE,
345
+ AccessMode.READ_ONLY,
346
+ AccessMode.READ_ONLY_ON_CREATION,
347
+ ),
348
+ include_computed_fields=True,
349
+ )
350
+
351
+
352
+ def BuilderUpdate(view_name: str = "Update") -> Builder:
353
+ """
354
+ Default builder for `Update` view. Views created by it keep fields with
355
+ :obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`
356
+ and :obj:`~pydantic_views.AccessMode.WRITE_ONLY`. And make all fields optional.
357
+
358
+ :param view_name: View name.
359
+ :returns: Builder configured for `Update` views.
360
+ """
361
+ return Builder(
362
+ view_name,
363
+ access_modes=(AccessMode.READ_AND_WRITE, AccessMode.WRITE_ONLY),
364
+ all_optional=True,
365
+ )
366
+
367
+
368
+ def BuilderLoad(view_name: str = "Load") -> Builder:
369
+ """
370
+ Default builder for `Load` view. Views created by it keep fields with
371
+ :obj:`access mode <pydantic_views.AccessMode>` :obj:`~pydantic_views.AccessMode.READ_AND_WRITE`
372
+ and :obj:`~pydantic_views.AccessMode.READ_ONLY`. And includes computed fields.
373
+
374
+ :param view_name: View name.
375
+ :returns: Builder configured for `Load` views.
376
+ """
377
+ return Builder(
378
+ view_name,
379
+ access_modes=(
380
+ AccessMode.READ_AND_WRITE,
381
+ AccessMode.READ_ONLY,
382
+ ),
383
+ include_computed_fields=True,
384
+ )
385
+
386
+
387
+ def ensure_model_views[T: BaseModel](model: type[T]):
388
+ """
389
+ Ensures model has a view manager and returns it.
390
+
391
+ :param model: Model class.
392
+ :returns: Views manager for model class.
393
+ """
394
+
395
+ try:
396
+ if (
397
+ manager := cast(Manager[T], getattr(model, "model_views"))
398
+ ) and manager.model == model:
399
+ return manager
400
+ except AttributeError:
401
+ pass
402
+
403
+ manager = Manager(model)
404
+ setattr(
405
+ model,
406
+ "model_views",
407
+ manager,
408
+ )
409
+
410
+ return manager
@@ -0,0 +1,64 @@
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
+ """
14
+ Views manager for a given model class.
15
+ """
16
+
17
+ __slots__ = ("_model", "_views")
18
+
19
+ def __init__(self, model: type[TModel]):
20
+ """ """
21
+ self._model = ref(model)
22
+ self._views: dict[str, type["View[TModel] | TModel"]] = {}
23
+
24
+ @property
25
+ def model(self) -> type[TModel]:
26
+ """
27
+ Associated model class.
28
+
29
+ :returns: Model class associated.
30
+ """
31
+ result = self._model()
32
+ if not result: # pragma: no cover
33
+ raise RuntimeError("Model class disappeared")
34
+ return result
35
+
36
+ def __getitem__(self, view_name: str) -> type["View[TModel] | TModel"]:
37
+ """
38
+ Get a model view.
39
+
40
+ :param view_name: Name of view to get.
41
+ :returns: View of model class.
42
+ """
43
+ return self._views[view_name]
44
+
45
+ def __setitem__(self, view_name: str, view: type["View[TModel] | TModel"]):
46
+ """
47
+ Set a model view.
48
+
49
+ :param view_name: Name of view to get.
50
+ :param view: View of model class.
51
+ """
52
+ self._views[view_name] = view
53
+
54
+ def build_view(self, builder: "Builder") -> type["View[TModel] | TModel"]:
55
+ """
56
+ Build view class for Manager's model.
57
+
58
+ :param builder: Builder to use to make the view of model.
59
+ :returns: View of model class.
60
+ """
61
+ view = builder.build_from_model(self.model)
62
+ self[builder.view_name] = view
63
+
64
+ return view
File without changes
@@ -0,0 +1,132 @@
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
+ """
10
+ View of model.
11
+ """
12
+
13
+ __model_class_root__: ClassVar[ReferenceType[type[BaseModel]]]
14
+
15
+ model_config = {
16
+ "protected_namespaces": (
17
+ "view_class_root",
18
+ "view_build_to",
19
+ "view_apply_to",
20
+ "view_build_from",
21
+ )
22
+ }
23
+
24
+ @classmethod
25
+ def view_class_root(cls) -> type[T]:
26
+ """
27
+ Returns the class object associated to the view.
28
+
29
+ :returns: Associated model class.
30
+ """
31
+
32
+ root = cls.__model_class_root__()
33
+
34
+ if root is None: # pragma: no cover
35
+ raise RuntimeError("Root model disappeared")
36
+
37
+ return cast(type[T], root)
38
+
39
+ @classmethod
40
+ def view_build_from(cls, model: T):
41
+ """
42
+ Build view from given model.
43
+
44
+ :param model: Model class to build view from.
45
+ :returns: View of model class.
46
+ """
47
+ return cls.model_validate(model.model_dump(exclude_unset=True, by_alias=True))
48
+
49
+ def view_build_to(self) -> T:
50
+ """
51
+ Build associated model from view data.
52
+
53
+ :returns: Associated model instance with view data.
54
+ """
55
+
56
+ return self.view_class_root().model_validate(
57
+ self.model_dump(exclude_unset=True, exclude_defaults=True, by_alias=True)
58
+ )
59
+
60
+ def view_apply_to(self, model: T) -> T:
61
+ """
62
+ Apply view data to associated model.
63
+
64
+ :param model: Model instance to use as base..
65
+ :returns: Associated model instance with view data merged to given model data.
66
+ """
67
+
68
+ return model_apply(model, self)
69
+
70
+
71
+ class RootView[R](View[RootModel[R]], RootModel[R]):
72
+ """
73
+ View for root models.
74
+ """
75
+
76
+
77
+ def model_apply[T: BaseModel](orig: T, view: View[T] | T) -> T:
78
+ """
79
+ Merge view or model into model
80
+ """
81
+
82
+ update_data: dict[str, Any] = {}
83
+
84
+ for field in view.model_fields_set:
85
+ value = _merge_values(
86
+ getattr(orig, field),
87
+ getattr(view, field),
88
+ )
89
+ update_data[field] = getattr(
90
+ orig.__pydantic_validator__.validate_assignment(orig, field, value), field
91
+ )
92
+ return orig.model_copy(
93
+ update=update_data,
94
+ deep=True,
95
+ )
96
+
97
+
98
+ @overload
99
+ def _merge_values[T: BaseModel](
100
+ orig_value: None, new_value: T
101
+ ) -> T | dict[str, Any]: ...
102
+
103
+
104
+ @overload
105
+ def _merge_values[T: BaseModel, A](orig_value: None, new_value: A) -> A: ...
106
+
107
+
108
+ @overload
109
+ def _merge_values[T: BaseModel, A](
110
+ orig_value: T, new_value: View[T]
111
+ ) -> T | dict[str, Any]: ...
112
+
113
+
114
+ def _merge_values[T: BaseModel, A](
115
+ orig_value: T | None, new_value: View[T] | A
116
+ ) -> T | A | dict[str, Any]:
117
+ if isinstance(new_value, BaseModel):
118
+ if isinstance(orig_value, BaseModel):
119
+ return cast(T, model_apply(orig_value, new_value))
120
+ if isinstance(new_value, View):
121
+ return cast(View[T], new_value).view_build_to()
122
+ return new_value.model_dump(
123
+ exclude_unset=True, exclude_defaults=True, by_alias=True
124
+ )
125
+ elif isinstance(new_value, Mapping) and isinstance(orig_value, Mapping):
126
+ data: dict[str, Any] = dict(cast(Mapping[str, Any], orig_value))
127
+ for k, v in cast(Mapping[str, Any], new_value).items():
128
+ data[k] = _merge_values(data.get(k), v)
129
+
130
+ return cast(T, cast(Mapping[str, Any], orig_value).__class__(**data))
131
+ else:
132
+ return cast(T, new_value)