muotti 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.
- muotti-0.1.0/LICENSE.txt +21 -0
- muotti-0.1.0/PKG-INFO +246 -0
- muotti-0.1.0/README.md +230 -0
- muotti-0.1.0/muotti/__init__.py +31 -0
- muotti-0.1.0/muotti/_absence.py +42 -0
- muotti-0.1.0/muotti/_decision.py +60 -0
- muotti-0.1.0/muotti/_decorators.py +50 -0
- muotti-0.1.0/muotti/_mapper.py +146 -0
- muotti-0.1.0/muotti/_paths.py +47 -0
- muotti-0.1.0/muotti/_profiles.py +84 -0
- muotti-0.1.0/muotti/_protocol.py +66 -0
- muotti-0.1.0/muotti/_protocols/__init__.py +8 -0
- muotti-0.1.0/muotti/_protocols/_dataclass.py +48 -0
- muotti-0.1.0/muotti/_protocols/_mapping.py +53 -0
- muotti-0.1.0/muotti/_protocols/_plain.py +73 -0
- muotti-0.1.0/muotti/_protocols/_sequence.py +80 -0
- muotti-0.1.0/muotti/_protocols/_typed_dict.py +48 -0
- muotti-0.1.0/muotti/_runner.py +178 -0
- muotti-0.1.0/muotti/_sequence.py +51 -0
- muotti-0.1.0/muotti/_spec.py +55 -0
- muotti-0.1.0/muotti/_utils.py +51 -0
- muotti-0.1.0/muotti/_value.py +166 -0
- muotti-0.1.0/muotti/errors.py +74 -0
- muotti-0.1.0/muotti/py.typed +0 -0
- muotti-0.1.0/muotti/pydantic.py +74 -0
- muotti-0.1.0/pyproject.toml +119 -0
- muotti-0.1.0/pyproject.toml.orig +98 -0
muotti-0.1.0/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 The Bolinette Team
|
|
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.
|
muotti-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muotti
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Map objects onto other objects: protocols, profiles and a pluggable value converter.
|
|
5
|
+
Author: Pierre Chat
|
|
6
|
+
Author-email: Pierre Chat <pierrechat@outlook.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE.txt
|
|
9
|
+
Requires-Dist: escondite>=0.1.2
|
|
10
|
+
Requires-Dist: mirino>=0.1.0
|
|
11
|
+
Requires-Dist: peritype>=0.8.0
|
|
12
|
+
Requires-Dist: pydantic>=2.12.5 ; extra == 'pydantic'
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Provides-Extra: pydantic
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Muotti
|
|
18
|
+
|
|
19
|
+
Muotti maps an object onto another object.
|
|
20
|
+
It reads the fields of a source, converts them to the types the destination expects, and builds or updates that destination.
|
|
21
|
+
|
|
22
|
+
Muotti knows nothing about any particular kind of object, and is taught one shape at a time through object protocols.
|
|
23
|
+
Dataclasses, `TypedDict`s, mappings, sequences, plain classes and pydantic models are the ones that come with it.
|
|
24
|
+
Muotti is used by the [Bolinette project](https://github.com/bolinette) to build payloads, responses and configuration sections.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from muotti import DataclassProtocol, Mapper
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UserPayload:
|
|
33
|
+
name: str
|
|
34
|
+
age: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class User:
|
|
39
|
+
name: str
|
|
40
|
+
age: int
|
|
41
|
+
active: bool = True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
mapper = Mapper()
|
|
45
|
+
mapper.add_protocol(DataclassProtocol())
|
|
46
|
+
|
|
47
|
+
assert mapper.map(User, UserPayload("Bob", 42)) == User("Bob", 42, active=True)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```shell
|
|
53
|
+
$ pip install muotti # or use your preferred package manager
|
|
54
|
+
$ pip install muotti[pydantic] # with the pydantic protocol and value converter
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
Muotti requires Python 3.13 (or newer).
|
|
60
|
+
It depends on [peritype](https://pypi.org/project/peritype/) to read types at runtime, on [mirino](https://pypi.org/project/mirino/) for the paths it names fields with, and on [escondite](https://pypi.org/project/escondite/) for the decorators.
|
|
61
|
+
[pydantic](https://pypi.org/project/pydantic/) is only needed for the `pydantic` extra.
|
|
62
|
+
|
|
63
|
+
## Object protocols
|
|
64
|
+
|
|
65
|
+
An `ObjectProtocol` teaches Muotti one kind of object: which fields a type has, how to read one from an instance, and how to build the type from the values collected for it.
|
|
66
|
+
A mapper starts empty, and resolves the first protocol that matches a type, in decreasing `priority`.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from muotti import DataclassProtocol, Mapper, MappingProtocol
|
|
70
|
+
|
|
71
|
+
mapper = Mapper()
|
|
72
|
+
mapper.add_protocol(DataclassProtocol())
|
|
73
|
+
mapper.add_protocol(MappingProtocol())
|
|
74
|
+
|
|
75
|
+
assert mapper.map(dict[str, object], User, {"name": "Bob", "age": 42}).name == "Bob"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Some protocols know the fields of their type up front, such as the dataclass one.
|
|
79
|
+
Others are source-driven and take the keys they are given, which is how a `dict`, a `list` or a `set` destination is filled.
|
|
80
|
+
|
|
81
|
+
Writing a protocol is how a new kind of object is supported: subclass `ObjectProtocol`, implement `matches`, `fields`, `read_field` and `construct`, and give it a `priority` above the bundled ones if it must win over them.
|
|
82
|
+
|
|
83
|
+
## Mapping and merging
|
|
84
|
+
|
|
85
|
+
`map(dest_cls, src)` builds a new instance, and takes the source class from the source itself.
|
|
86
|
+
`map(src_cls, dest_cls, src)` is the form to use when the source cannot describe itself, a `dict` being the usual case.
|
|
87
|
+
`merge(src, dest)` maps into an instance that already exists, and leaves the fields absent from the source alone.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
user = mapper.map(User, UserPayload("Bob", 42))
|
|
91
|
+
mapper.merge(UserPayload("Alice", 30), user)
|
|
92
|
+
|
|
93
|
+
assert user == User("Alice", 30, active=True)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A field missing from the source is an error, unless the destination has a default for it or accepts `None`.
|
|
97
|
+
With `validate=True`, every field is tried and the failures are collected in a single `ValidationError` instead of raising on the first one.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from muotti.errors import ValidationError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
mapper.map(dict[str, object], User, {"name": "Bob"}, validate=True)
|
|
104
|
+
except ValidationError as err:
|
|
105
|
+
assert [str(e.dest) for e in err.errors] == ["User.age"]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Profiles
|
|
109
|
+
|
|
110
|
+
Field names do not always line up, and a `Profile` is where that is written down.
|
|
111
|
+
`register` opens a sequence between two types, and `for_attr` points at a destination field with a lambda.
|
|
112
|
+
That lambda receives an expression recorder, not an instance, so `lambda d: d.name` records the path to the field instead of reading anything.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from muotti import Mapper, Profile
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Account:
|
|
120
|
+
full_name: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AccountProfile(Profile):
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
super().__init__()
|
|
126
|
+
self.register(Account, User).for_attr(
|
|
127
|
+
lambda d: d.name,
|
|
128
|
+
lambda opt: opt.map_from(lambda s: s.full_name),
|
|
129
|
+
).for_attr(
|
|
130
|
+
lambda d: d.age,
|
|
131
|
+
lambda opt: opt.default(lambda: 0),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
mapper.load_profiles([AccountProfile()])
|
|
136
|
+
|
|
137
|
+
assert mapper.map(User, Account("Bob")) == User("Bob", 0, active=True)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Besides `map_from` and `default`, an option can `ignore` a field, mark it `read_only`, `allow_write` on a field the protocol reports as immutable, or force a type with `use_type`.
|
|
141
|
+
A sequence can also run a function `before_mapping` or `after_mapping`, and `include` another pair of types to inherit its overrides.
|
|
142
|
+
|
|
143
|
+
`assert_configuration_valid` walks every loaded sequence and raises a `MappingConfigurationError` listing each destination field that is required, has no default, and has nothing in the source to fill it.
|
|
144
|
+
It is meant to run once at startup, rather than to discover the problem on the first request.
|
|
145
|
+
|
|
146
|
+
## Value conversion
|
|
147
|
+
|
|
148
|
+
Everything that is not a nested object ends up at a value converter, and that converter is a protocol:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
class ValueConverter(Protocol):
|
|
152
|
+
def convert(self, value: Any, spec: FieldSpec, dest: ExpressionNode, src: ExpressionNode) -> Any: ...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A mapper uses `BasicValueConverter` unless it is given another one.
|
|
156
|
+
It passes a value through when it already has the target type, and otherwise applies a small set of conversions.
|
|
157
|
+
Numbers convert between themselves, `str` and `bytes` convert into each other, a string becomes a `UUID`, a `Decimal`, a `PurePath` or an `Enum` member, an ISO 8601 string becomes a `datetime`, a `date` or a `time`, and a number of seconds becomes a `timedelta`.
|
|
158
|
+
Lists, sets, tuples and dicts are converted element by element, and the members of a union are tried in order, the ones the value already matches first.
|
|
159
|
+
With `strict=True` nothing is converted, and only a value that already has the right type is accepted.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from datetime import date
|
|
163
|
+
from muotti import BasicValueConverter, Mapper
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class Event:
|
|
168
|
+
day: date
|
|
169
|
+
tags: set[str]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
mapper = Mapper(BasicValueConverter())
|
|
173
|
+
mapper.add_protocol(DataclassProtocol())
|
|
174
|
+
mapper.add_protocol(MappingProtocol())
|
|
175
|
+
|
|
176
|
+
event = mapper.map(dict[str, object], Event, {"day": "2026-09-18", "tags": ["a", "a", "b"]})
|
|
177
|
+
assert event == Event(date(2026, 9, 18), {"a", "b"})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The `pydantic` extra brings a second converter, which hands the value to a `TypeAdapter` and follows pydantic's own rules.
|
|
181
|
+
It is the one to use when those rules are what the rest of an application already expects.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from muotti import Mapper
|
|
185
|
+
from muotti.pydantic import PydanticProtocol, PydanticValueConverter
|
|
186
|
+
|
|
187
|
+
mapper = Mapper(PydanticValueConverter())
|
|
188
|
+
mapper.add_protocol(PydanticProtocol())
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The two converters do not accept exactly the same values, so a mapper that used to run on pydantic should be given `PydanticValueConverter` explicitly rather than left on the default.
|
|
192
|
+
|
|
193
|
+
## Discovery through a cache
|
|
194
|
+
|
|
195
|
+
`mapping` and `mapping_protocol` record a profile or a protocol class in an [escondite](https://pypi.org/project/escondite/) cache when the module is imported, and `load_from_cache` instantiates everything the cache holds.
|
|
196
|
+
An application collects its profiles this way instead of importing each one by hand.
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from escondite import Cache
|
|
200
|
+
from muotti import Mapper, Profile, mapping
|
|
201
|
+
|
|
202
|
+
cache = Cache()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mapping(cache=cache)
|
|
206
|
+
class AccountProfile(Profile): ...
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
mapper = Mapper()
|
|
210
|
+
mapper.load_from_cache(cache)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Both decorators take the bare and the parametrized form, and fall back to escondite's global cache when none is given.
|
|
214
|
+
Registering by hand with `add_protocol` and `load_profiles` works just as well, the cache is a convenience.
|
|
215
|
+
|
|
216
|
+
## Reference
|
|
217
|
+
|
|
218
|
+
### Bundled protocols
|
|
219
|
+
|
|
220
|
+
| Protocol | Destination | Priority |
|
|
221
|
+
| ----------------------------------- | --------------------------------------------- | -------- |
|
|
222
|
+
| `PydanticProtocol` | `pydantic.BaseModel`, in `muotti.pydantic` | 90 |
|
|
223
|
+
| `TypedDictProtocol` | `TypedDict`, with `NotRequired` and totality | 80 |
|
|
224
|
+
| `DataclassProtocol` | `@dataclass` types | 70 |
|
|
225
|
+
| `MappingProtocol` | `dict` and other mappings, source-driven | 20 |
|
|
226
|
+
| `SequenceProtocol` / `SetProtocol` | `list`, `tuple` / `set`, `frozenset` | 20 |
|
|
227
|
+
| `PlainObjectProtocol` | Any class with annotated attributes | 0 |
|
|
228
|
+
|
|
229
|
+
### Errors
|
|
230
|
+
|
|
231
|
+
All errors derive from `muotti.errors.MuottiError`, and a `MappingError` carries the `dest` and `src` paths the failure happened on.
|
|
232
|
+
|
|
233
|
+
| Error | Raised when |
|
|
234
|
+
| ---------------------------- | ------------------------------------------------------------------ |
|
|
235
|
+
| `SourceNotFoundError` | A required destination field has nothing in the source |
|
|
236
|
+
| `DestinationNotNullableError`| `None` reached a field that does not accept it |
|
|
237
|
+
| `ConversionError` | A value could not be converted to the type of its field |
|
|
238
|
+
| `InstantiationError` | The destination type rejected the values collected for it |
|
|
239
|
+
| `ImmutableFieldError` | A field cannot be written to |
|
|
240
|
+
| `NoProtocolError` | No `ObjectProtocol` matched the destination type |
|
|
241
|
+
| `ValidationError` | Raised with `validate=True`, and holds every error in `errors` |
|
|
242
|
+
| `MappingConfigurationError` | `assert_configuration_valid` found fields nothing can fill |
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
Muotti is released under the MIT license, see [LICENSE.txt](LICENSE.txt).
|
muotti-0.1.0/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Muotti
|
|
2
|
+
|
|
3
|
+
Muotti maps an object onto another object.
|
|
4
|
+
It reads the fields of a source, converts them to the types the destination expects, and builds or updates that destination.
|
|
5
|
+
|
|
6
|
+
Muotti knows nothing about any particular kind of object, and is taught one shape at a time through object protocols.
|
|
7
|
+
Dataclasses, `TypedDict`s, mappings, sequences, plain classes and pydantic models are the ones that come with it.
|
|
8
|
+
Muotti is used by the [Bolinette project](https://github.com/bolinette) to build payloads, responses and configuration sections.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from muotti import DataclassProtocol, Mapper
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class UserPayload:
|
|
17
|
+
name: str
|
|
18
|
+
age: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class User:
|
|
23
|
+
name: str
|
|
24
|
+
age: int
|
|
25
|
+
active: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
mapper = Mapper()
|
|
29
|
+
mapper.add_protocol(DataclassProtocol())
|
|
30
|
+
|
|
31
|
+
assert mapper.map(User, UserPayload("Bob", 42)) == User("Bob", 42, active=True)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```shell
|
|
37
|
+
$ pip install muotti # or use your preferred package manager
|
|
38
|
+
$ pip install muotti[pydantic] # with the pydantic protocol and value converter
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
Muotti requires Python 3.13 (or newer).
|
|
44
|
+
It depends on [peritype](https://pypi.org/project/peritype/) to read types at runtime, on [mirino](https://pypi.org/project/mirino/) for the paths it names fields with, and on [escondite](https://pypi.org/project/escondite/) for the decorators.
|
|
45
|
+
[pydantic](https://pypi.org/project/pydantic/) is only needed for the `pydantic` extra.
|
|
46
|
+
|
|
47
|
+
## Object protocols
|
|
48
|
+
|
|
49
|
+
An `ObjectProtocol` teaches Muotti one kind of object: which fields a type has, how to read one from an instance, and how to build the type from the values collected for it.
|
|
50
|
+
A mapper starts empty, and resolves the first protocol that matches a type, in decreasing `priority`.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from muotti import DataclassProtocol, Mapper, MappingProtocol
|
|
54
|
+
|
|
55
|
+
mapper = Mapper()
|
|
56
|
+
mapper.add_protocol(DataclassProtocol())
|
|
57
|
+
mapper.add_protocol(MappingProtocol())
|
|
58
|
+
|
|
59
|
+
assert mapper.map(dict[str, object], User, {"name": "Bob", "age": 42}).name == "Bob"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Some protocols know the fields of their type up front, such as the dataclass one.
|
|
63
|
+
Others are source-driven and take the keys they are given, which is how a `dict`, a `list` or a `set` destination is filled.
|
|
64
|
+
|
|
65
|
+
Writing a protocol is how a new kind of object is supported: subclass `ObjectProtocol`, implement `matches`, `fields`, `read_field` and `construct`, and give it a `priority` above the bundled ones if it must win over them.
|
|
66
|
+
|
|
67
|
+
## Mapping and merging
|
|
68
|
+
|
|
69
|
+
`map(dest_cls, src)` builds a new instance, and takes the source class from the source itself.
|
|
70
|
+
`map(src_cls, dest_cls, src)` is the form to use when the source cannot describe itself, a `dict` being the usual case.
|
|
71
|
+
`merge(src, dest)` maps into an instance that already exists, and leaves the fields absent from the source alone.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
user = mapper.map(User, UserPayload("Bob", 42))
|
|
75
|
+
mapper.merge(UserPayload("Alice", 30), user)
|
|
76
|
+
|
|
77
|
+
assert user == User("Alice", 30, active=True)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A field missing from the source is an error, unless the destination has a default for it or accepts `None`.
|
|
81
|
+
With `validate=True`, every field is tried and the failures are collected in a single `ValidationError` instead of raising on the first one.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from muotti.errors import ValidationError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
mapper.map(dict[str, object], User, {"name": "Bob"}, validate=True)
|
|
88
|
+
except ValidationError as err:
|
|
89
|
+
assert [str(e.dest) for e in err.errors] == ["User.age"]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Profiles
|
|
93
|
+
|
|
94
|
+
Field names do not always line up, and a `Profile` is where that is written down.
|
|
95
|
+
`register` opens a sequence between two types, and `for_attr` points at a destination field with a lambda.
|
|
96
|
+
That lambda receives an expression recorder, not an instance, so `lambda d: d.name` records the path to the field instead of reading anything.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from muotti import Mapper, Profile
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Account:
|
|
104
|
+
full_name: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AccountProfile(Profile):
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
super().__init__()
|
|
110
|
+
self.register(Account, User).for_attr(
|
|
111
|
+
lambda d: d.name,
|
|
112
|
+
lambda opt: opt.map_from(lambda s: s.full_name),
|
|
113
|
+
).for_attr(
|
|
114
|
+
lambda d: d.age,
|
|
115
|
+
lambda opt: opt.default(lambda: 0),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
mapper.load_profiles([AccountProfile()])
|
|
120
|
+
|
|
121
|
+
assert mapper.map(User, Account("Bob")) == User("Bob", 0, active=True)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Besides `map_from` and `default`, an option can `ignore` a field, mark it `read_only`, `allow_write` on a field the protocol reports as immutable, or force a type with `use_type`.
|
|
125
|
+
A sequence can also run a function `before_mapping` or `after_mapping`, and `include` another pair of types to inherit its overrides.
|
|
126
|
+
|
|
127
|
+
`assert_configuration_valid` walks every loaded sequence and raises a `MappingConfigurationError` listing each destination field that is required, has no default, and has nothing in the source to fill it.
|
|
128
|
+
It is meant to run once at startup, rather than to discover the problem on the first request.
|
|
129
|
+
|
|
130
|
+
## Value conversion
|
|
131
|
+
|
|
132
|
+
Everything that is not a nested object ends up at a value converter, and that converter is a protocol:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
class ValueConverter(Protocol):
|
|
136
|
+
def convert(self, value: Any, spec: FieldSpec, dest: ExpressionNode, src: ExpressionNode) -> Any: ...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A mapper uses `BasicValueConverter` unless it is given another one.
|
|
140
|
+
It passes a value through when it already has the target type, and otherwise applies a small set of conversions.
|
|
141
|
+
Numbers convert between themselves, `str` and `bytes` convert into each other, a string becomes a `UUID`, a `Decimal`, a `PurePath` or an `Enum` member, an ISO 8601 string becomes a `datetime`, a `date` or a `time`, and a number of seconds becomes a `timedelta`.
|
|
142
|
+
Lists, sets, tuples and dicts are converted element by element, and the members of a union are tried in order, the ones the value already matches first.
|
|
143
|
+
With `strict=True` nothing is converted, and only a value that already has the right type is accepted.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from datetime import date
|
|
147
|
+
from muotti import BasicValueConverter, Mapper
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class Event:
|
|
152
|
+
day: date
|
|
153
|
+
tags: set[str]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
mapper = Mapper(BasicValueConverter())
|
|
157
|
+
mapper.add_protocol(DataclassProtocol())
|
|
158
|
+
mapper.add_protocol(MappingProtocol())
|
|
159
|
+
|
|
160
|
+
event = mapper.map(dict[str, object], Event, {"day": "2026-09-18", "tags": ["a", "a", "b"]})
|
|
161
|
+
assert event == Event(date(2026, 9, 18), {"a", "b"})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The `pydantic` extra brings a second converter, which hands the value to a `TypeAdapter` and follows pydantic's own rules.
|
|
165
|
+
It is the one to use when those rules are what the rest of an application already expects.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from muotti import Mapper
|
|
169
|
+
from muotti.pydantic import PydanticProtocol, PydanticValueConverter
|
|
170
|
+
|
|
171
|
+
mapper = Mapper(PydanticValueConverter())
|
|
172
|
+
mapper.add_protocol(PydanticProtocol())
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The two converters do not accept exactly the same values, so a mapper that used to run on pydantic should be given `PydanticValueConverter` explicitly rather than left on the default.
|
|
176
|
+
|
|
177
|
+
## Discovery through a cache
|
|
178
|
+
|
|
179
|
+
`mapping` and `mapping_protocol` record a profile or a protocol class in an [escondite](https://pypi.org/project/escondite/) cache when the module is imported, and `load_from_cache` instantiates everything the cache holds.
|
|
180
|
+
An application collects its profiles this way instead of importing each one by hand.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from escondite import Cache
|
|
184
|
+
from muotti import Mapper, Profile, mapping
|
|
185
|
+
|
|
186
|
+
cache = Cache()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@mapping(cache=cache)
|
|
190
|
+
class AccountProfile(Profile): ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
mapper = Mapper()
|
|
194
|
+
mapper.load_from_cache(cache)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Both decorators take the bare and the parametrized form, and fall back to escondite's global cache when none is given.
|
|
198
|
+
Registering by hand with `add_protocol` and `load_profiles` works just as well, the cache is a convenience.
|
|
199
|
+
|
|
200
|
+
## Reference
|
|
201
|
+
|
|
202
|
+
### Bundled protocols
|
|
203
|
+
|
|
204
|
+
| Protocol | Destination | Priority |
|
|
205
|
+
| ----------------------------------- | --------------------------------------------- | -------- |
|
|
206
|
+
| `PydanticProtocol` | `pydantic.BaseModel`, in `muotti.pydantic` | 90 |
|
|
207
|
+
| `TypedDictProtocol` | `TypedDict`, with `NotRequired` and totality | 80 |
|
|
208
|
+
| `DataclassProtocol` | `@dataclass` types | 70 |
|
|
209
|
+
| `MappingProtocol` | `dict` and other mappings, source-driven | 20 |
|
|
210
|
+
| `SequenceProtocol` / `SetProtocol` | `list`, `tuple` / `set`, `frozenset` | 20 |
|
|
211
|
+
| `PlainObjectProtocol` | Any class with annotated attributes | 0 |
|
|
212
|
+
|
|
213
|
+
### Errors
|
|
214
|
+
|
|
215
|
+
All errors derive from `muotti.errors.MuottiError`, and a `MappingError` carries the `dest` and `src` paths the failure happened on.
|
|
216
|
+
|
|
217
|
+
| Error | Raised when |
|
|
218
|
+
| ---------------------------- | ------------------------------------------------------------------ |
|
|
219
|
+
| `SourceNotFoundError` | A required destination field has nothing in the source |
|
|
220
|
+
| `DestinationNotNullableError`| `None` reached a field that does not accept it |
|
|
221
|
+
| `ConversionError` | A value could not be converted to the type of its field |
|
|
222
|
+
| `InstantiationError` | The destination type rejected the values collected for it |
|
|
223
|
+
| `ImmutableFieldError` | A field cannot be written to |
|
|
224
|
+
| `NoProtocolError` | No `ObjectProtocol` matched the destination type |
|
|
225
|
+
| `ValidationError` | Raised with `validate=True`, and holds every error in `errors` |
|
|
226
|
+
| `MappingConfigurationError` | `assert_configuration_valid` found fields nothing can fill |
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
Muotti is released under the MIT license, see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from muotti._absence import (
|
|
2
|
+
ABSENT as ABSENT,
|
|
3
|
+
Generation as Generation,
|
|
4
|
+
MapMode as MapMode,
|
|
5
|
+
Maybe as Maybe,
|
|
6
|
+
is_present as is_present,
|
|
7
|
+
)
|
|
8
|
+
from muotti._spec import FieldOverride as FieldOverride, FieldSpec as FieldSpec, NO_OVERRIDE as NO_OVERRIDE
|
|
9
|
+
from muotti._utils import (
|
|
10
|
+
dict_value_type as dict_value_type,
|
|
11
|
+
element_type as element_type,
|
|
12
|
+
is_value_type as is_value_type,
|
|
13
|
+
main_class as main_class,
|
|
14
|
+
)
|
|
15
|
+
from muotti._protocol import ObjectProtocol as ObjectProtocol, ProtocolRegistry as ProtocolRegistry
|
|
16
|
+
from muotti._protocols import (
|
|
17
|
+
DataclassProtocol as DataclassProtocol,
|
|
18
|
+
MappingProtocol as MappingProtocol,
|
|
19
|
+
PlainObjectProtocol as PlainObjectProtocol,
|
|
20
|
+
SequenceProtocol as SequenceProtocol,
|
|
21
|
+
SetProtocol as SetProtocol,
|
|
22
|
+
TypedDictProtocol as TypedDictProtocol,
|
|
23
|
+
)
|
|
24
|
+
from muotti._value import BasicValueConverter as BasicValueConverter, ValueConverter as ValueConverter
|
|
25
|
+
from muotti._profiles import (
|
|
26
|
+
MappingOptions as MappingOptions,
|
|
27
|
+
Profile as Profile,
|
|
28
|
+
SequenceBuilder as SequenceBuilder,
|
|
29
|
+
)
|
|
30
|
+
from muotti._decorators import mapping as mapping, mapping_protocol as mapping_protocol
|
|
31
|
+
from muotti._mapper import Mapper as Mapper
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
from typing import Final, Self, TypeGuard, override
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class _Absent:
|
|
6
|
+
__slots__ = ()
|
|
7
|
+
_instance: "_Absent | None" = None
|
|
8
|
+
|
|
9
|
+
def __new__(cls) -> Self:
|
|
10
|
+
if cls._instance is None:
|
|
11
|
+
cls._instance = super().__new__(cls)
|
|
12
|
+
return cls._instance # pyright: ignore[reportReturnType]
|
|
13
|
+
|
|
14
|
+
@override
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return "ABSENT"
|
|
17
|
+
|
|
18
|
+
def __bool__(self) -> bool:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ABSENT: Final[_Absent] = _Absent()
|
|
23
|
+
|
|
24
|
+
type Maybe[T] = T | _Absent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_present[T](value: Maybe[T]) -> TypeGuard[T]:
|
|
28
|
+
return value is not ABSENT
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Generation(Enum):
|
|
32
|
+
NONE = auto()
|
|
33
|
+
|
|
34
|
+
CLIENT_DEFAULT = auto()
|
|
35
|
+
|
|
36
|
+
SERVER_GENERATED = auto()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MapMode(Enum):
|
|
40
|
+
CREATE = auto()
|
|
41
|
+
|
|
42
|
+
MERGE = auto()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum, auto
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from muotti._absence import MapMode, Maybe, is_present
|
|
6
|
+
from muotti._spec import FieldOverride, FieldSpec
|
|
7
|
+
from muotti.errors import DestinationNotNullableError, MappingError, SourceNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Action(Enum):
|
|
11
|
+
ASSIGN = auto()
|
|
12
|
+
SKIP = auto()
|
|
13
|
+
FAIL = auto()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Decision:
|
|
18
|
+
action: Action
|
|
19
|
+
value: Any = None
|
|
20
|
+
error: type[MappingError] = MappingError
|
|
21
|
+
reason: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decide(presence: Maybe[Any], spec: FieldSpec, mode: MapMode, override: FieldOverride) -> Decision:
|
|
25
|
+
if override.ignore:
|
|
26
|
+
return Decision(Action.SKIP)
|
|
27
|
+
|
|
28
|
+
writable = spec.writable or override.allow_write
|
|
29
|
+
if override.read_only:
|
|
30
|
+
writable = False
|
|
31
|
+
|
|
32
|
+
if not writable:
|
|
33
|
+
return Decision(Action.SKIP)
|
|
34
|
+
|
|
35
|
+
if is_present(presence):
|
|
36
|
+
if presence is None and not spec.nullable:
|
|
37
|
+
return Decision(
|
|
38
|
+
Action.FAIL,
|
|
39
|
+
error=DestinationNotNullableError,
|
|
40
|
+
reason="cannot bind a None value to a non-nullable field",
|
|
41
|
+
)
|
|
42
|
+
return Decision(Action.ASSIGN, value=presence)
|
|
43
|
+
|
|
44
|
+
if mode is MapMode.MERGE:
|
|
45
|
+
return Decision(Action.SKIP)
|
|
46
|
+
|
|
47
|
+
if override.default_factory is not None:
|
|
48
|
+
return Decision(Action.ASSIGN, value=override.default_factory())
|
|
49
|
+
|
|
50
|
+
if spec.self_populating:
|
|
51
|
+
return Decision(Action.SKIP)
|
|
52
|
+
|
|
53
|
+
if spec.nullable:
|
|
54
|
+
return Decision(Action.ASSIGN, value=None)
|
|
55
|
+
|
|
56
|
+
return Decision(
|
|
57
|
+
Action.FAIL,
|
|
58
|
+
error=SourceNotFoundError,
|
|
59
|
+
reason="source path not found and destination has no default",
|
|
60
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, overload
|
|
3
|
+
|
|
4
|
+
from escondite import Cache
|
|
5
|
+
|
|
6
|
+
from muotti._profiles import Profile
|
|
7
|
+
from muotti._protocol import ObjectProtocol
|
|
8
|
+
|
|
9
|
+
MAPPING_PROFILE_CACHE_KEY = "__muotti_profile__"
|
|
10
|
+
MAPPING_PROTOCOL_CACHE_KEY = "__muotti_protocol__"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@overload
|
|
14
|
+
def mapping[ProfileT: Profile](cls: type[ProfileT], /, *, cache: Cache | None = None) -> type[ProfileT]: ...
|
|
15
|
+
@overload
|
|
16
|
+
def mapping[ProfileT: Profile](*, cache: Cache | None = None) -> Callable[[type[ProfileT]], type[ProfileT]]: ...
|
|
17
|
+
def mapping(*args: Any, cache: Cache | None = None) -> Any:
|
|
18
|
+
def decorator(cls: type[Profile]) -> type[Profile]:
|
|
19
|
+
Cache.with_fallback(cache).add(MAPPING_PROFILE_CACHE_KEY, cls)
|
|
20
|
+
return cls
|
|
21
|
+
|
|
22
|
+
match args:
|
|
23
|
+
case ():
|
|
24
|
+
return decorator
|
|
25
|
+
case (cls,):
|
|
26
|
+
return decorator(cls)
|
|
27
|
+
case _:
|
|
28
|
+
raise TypeError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@overload
|
|
32
|
+
def mapping_protocol[ProtocolT: ObjectProtocol](
|
|
33
|
+
cls: type[ProtocolT], /, *, cache: Cache | None = None
|
|
34
|
+
) -> type[ProtocolT]: ...
|
|
35
|
+
@overload
|
|
36
|
+
def mapping_protocol[ProtocolT: ObjectProtocol](
|
|
37
|
+
*, cache: Cache | None = None
|
|
38
|
+
) -> Callable[[type[ProtocolT]], type[ProtocolT]]: ...
|
|
39
|
+
def mapping_protocol(*args: Any, cache: Cache | None = None) -> Any:
|
|
40
|
+
def decorator(cls: type[ObjectProtocol]) -> type[ObjectProtocol]:
|
|
41
|
+
Cache.with_fallback(cache).add(MAPPING_PROTOCOL_CACHE_KEY, cls)
|
|
42
|
+
return cls
|
|
43
|
+
|
|
44
|
+
match args:
|
|
45
|
+
case ():
|
|
46
|
+
return decorator
|
|
47
|
+
case (cls,):
|
|
48
|
+
return decorator(cls)
|
|
49
|
+
case _:
|
|
50
|
+
raise TypeError
|