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.
@@ -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