xtr-serializer 6.0.0__py3-none-any.whl
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.
- xtr_serializer/.agents/skills/xtr-serializer/SKILL.md +244 -0
- xtr_serializer/.agents/skills/xtr-serializer/references/application.md +89 -0
- xtr_serializer/.agents/skills/xtr-serializer/references/context.md +59 -0
- xtr_serializer/.agents/skills/xtr-serializer/references/datetimes.md +41 -0
- xtr_serializer/.agents/skills/xtr-serializer/references/foreign-types.md +66 -0
- xtr_serializer/.agents/skills/xtr-serializer/references/hierarchies.md +77 -0
- xtr_serializer/.agents/skills/xtr-serializer/references/normalizers.md +79 -0
- xtr_serializer/__init__.py +110 -0
- xtr_serializer/_dispatch.py +102 -0
- xtr_serializer/_loc_path.py +20 -0
- xtr_serializer/_plain_data.py +17 -0
- xtr_serializer/_type_cache.py +147 -0
- xtr_serializer/_type_names.py +28 -0
- xtr_serializer/_written_types.py +64 -0
- xtr_serializer/bundle/__init__.py +19 -0
- xtr_serializer/bundle/_context_check.py +30 -0
- xtr_serializer/bundle/_serializer_factory.py +87 -0
- xtr_serializer/bundle/named_serializer_config.py +56 -0
- xtr_serializer/bundle/serializer_bundle.py +133 -0
- xtr_serializer/bundle/serializer_config.py +80 -0
- xtr_serializer/bundle/serializer_pass.py +262 -0
- xtr_serializer/command/__init__.py +7 -0
- xtr_serializer/command/_class_import.py +67 -0
- xtr_serializer/command/_schema_fields.py +177 -0
- xtr_serializer/command/debug_serializer_command.py +103 -0
- xtr_serializer/context/__init__.py +21 -0
- xtr_serializer/context/_context_builder_trait.py +107 -0
- xtr_serializer/context/context.py +18 -0
- xtr_serializer/context/context_builder_interface.py +22 -0
- xtr_serializer/context/encoder/__init__.py +7 -0
- xtr_serializer/context/encoder/json_encoder_context_builder.py +31 -0
- xtr_serializer/context/normalizer/__init__.py +7 -0
- xtr_serializer/context/normalizer/pydantic_normalizer_context_builder.py +127 -0
- xtr_serializer/context/serializer_context_builder.py +35 -0
- xtr_serializer/decorator/__init__.py +13 -0
- xtr_serializer/decorator/discriminator_map.py +111 -0
- xtr_serializer/decorator/discriminator_name.py +110 -0
- xtr_serializer/encoder/__init__.py +19 -0
- xtr_serializer/encoder/_encoder_chain.py +68 -0
- xtr_serializer/encoder/decoder_interface.py +43 -0
- xtr_serializer/encoder/encoder_interface.py +42 -0
- xtr_serializer/encoder/json_encoder.py +129 -0
- xtr_serializer/encoder/json_encoder_keys.py +20 -0
- xtr_serializer/exception/__init__.py +39 -0
- xtr_serializer/exception/circular_reference_error.py +20 -0
- xtr_serializer/exception/extra_attributes_error.py +54 -0
- xtr_serializer/exception/invalid_argument_error.py +19 -0
- xtr_serializer/exception/logic_error.py +19 -0
- xtr_serializer/exception/mapping_error.py +20 -0
- xtr_serializer/exception/not_encodable_value_error.py +17 -0
- xtr_serializer/exception/not_normalizable_value_error.py +61 -0
- xtr_serializer/exception/partial_denormalization_error.py +51 -0
- xtr_serializer/exception/serializer_error.py +60 -0
- xtr_serializer/exception/unexpected_value_error.py +20 -0
- xtr_serializer/exception/unsupported_format_error.py +30 -0
- xtr_serializer/mapping/__init__.py +11 -0
- xtr_serializer/mapping/_class_builds.py +28 -0
- xtr_serializer/mapping/_class_choice.py +121 -0
- xtr_serializer/mapping/_class_discriminator_registry.py +148 -0
- xtr_serializer/mapping/_declaration_sites.py +36 -0
- xtr_serializer/mapping/_deferred_refusals.py +89 -0
- xtr_serializer/mapping/_field_names.py +29 -0
- xtr_serializer/mapping/_polymorphic_schema.py +223 -0
- xtr_serializer/mapping/class_discriminator_mapping.py +52 -0
- xtr_serializer/normalized_value.py +26 -0
- xtr_serializer/normalizer/__init__.py +45 -0
- xtr_serializer/normalizer/_adapter_cache.py +193 -0
- xtr_serializer/normalizer/_built_in_words.py +74 -0
- xtr_serializer/normalizer/_context_options.py +111 -0
- xtr_serializer/normalizer/_cycles.py +17 -0
- xtr_serializer/normalizer/_first_difference.py +279 -0
- xtr_serializer/normalizer/_own_words.py +23 -0
- xtr_serializer/normalizer/_part_pairs.py +76 -0
- xtr_serializer/normalizer/_read_back.py +117 -0
- xtr_serializer/normalizer/_read_refusal.py +56 -0
- xtr_serializer/normalizer/_validation_refusals.py +221 -0
- xtr_serializer/normalizer/denormalizer_aware_interface.py +25 -0
- xtr_serializer/normalizer/denormalizer_interface.py +94 -0
- xtr_serializer/normalizer/normalizer_aware_interface.py +25 -0
- xtr_serializer/normalizer/normalizer_interface.py +76 -0
- xtr_serializer/normalizer/pydantic_normalizer.py +268 -0
- xtr_serializer/normalizer/pydantic_normalizer_keys.py +95 -0
- xtr_serializer/py.typed +0 -0
- xtr_serializer/recipe/__init__.py +16 -0
- xtr_serializer/recipe/manifest.toml +26 -0
- xtr_serializer/serializer.py +283 -0
- xtr_serializer/serializer_aware_interface.py +25 -0
- xtr_serializer/serializer_interface.py +99 -0
- xtr_serializer/supported_types.py +29 -0
- xtr_serializer/zoned_datetime.py +92 -0
- xtr_serializer-6.0.0.dist-info/METADATA +684 -0
- xtr_serializer-6.0.0.dist-info/RECORD +95 -0
- xtr_serializer-6.0.0.dist-info/WHEEL +4 -0
- xtr_serializer-6.0.0.dist-info/entry_points.txt +6 -0
- xtr_serializer-6.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-serializer
|
|
3
|
+
description: How to write values as JSON and read them back exactly with xtr-serializer — a Serializer over normalizers and encoders, declared types, class hierarchies tagged on the wire, zone-aware datetimes, and refusals that never quote the input. Use when code must put a dataclass or model on a wire or in storage and get the same value back, when a subclass in a field typed with its base loses its fields, when a tuple, Decimal, UUID, bytes or datetime changes across a round trip, when a datetime must keep its zone name, when a class another library owns must be serializable, when writing a normalizer or an encoder of your own, when adding SerializerBundle or named serializers to an application on xtr-dependency-injection, or when reading debug:serializer.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-serializer
|
|
7
|
+
|
|
8
|
+
A `Serializer` is a list of normalizers (object ⇄ plain data) and a list of encoders (plain data
|
|
9
|
+
⇄ text). pydantic does the conversion. What this package adds is the chain, the context, the
|
|
10
|
+
errors, the bundle — and one promise: a value is written, read back under the same options and
|
|
11
|
+
compared, so what comes back equals what went in, or the write is **refused** with the path to
|
|
12
|
+
the value that would have changed.
|
|
13
|
+
|
|
14
|
+
Everything is judged against the **declared** type, not the runtime one.
|
|
15
|
+
|
|
16
|
+
## Quick reference
|
|
17
|
+
|
|
18
|
+
- Build one: `serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])`. Priority is list
|
|
19
|
+
order; put a narrow normalizer before `PydanticNormalizer`, which handles nearly anything.
|
|
20
|
+
- Write and read: `serializer.serialize(value, "json")` → `str`;
|
|
21
|
+
`serializer.deserialize(text, Order, "json")` → `Order`. `deserialize` takes `str` or `bytes`.
|
|
22
|
+
- A bare `list`/`dict` declares nothing about its items: pass
|
|
23
|
+
`serialize(items, "json", type_=list[Order])` and `deserialize(text, list[Order], "json")`.
|
|
24
|
+
Without `type_`, a container is accepted only while everything in it is plain JSON already.
|
|
25
|
+
- Steps on their own: `normalize`, `denormalize`, `encode`, `decode`.
|
|
26
|
+
- Options go in a context mapping, built by `SerializerContextBuilder`,
|
|
27
|
+
`PydanticNormalizerContextBuilder`, `JsonEncoderContextBuilder`, or written by hand with the
|
|
28
|
+
public key constants (`STRICT`, `BY_ALIAS`, `ALLOW_EXTRA_ATTRIBUTES`, `JSON_INDENT`,
|
|
29
|
+
`COLLECT_DENORMALIZATION_ERRORS`, …) — [references/context.md](references/context.md).
|
|
30
|
+
- Hierarchies: `@discriminator_map()` on the base, `@discriminator_name("delivery")` on each
|
|
31
|
+
class that is a value of its own. Wire key `"$type"` —
|
|
32
|
+
[references/hierarchies.md](references/hierarchies.md).
|
|
33
|
+
- Zone-aware dates: annotate the field `ZonedDateTime`. A plain `datetime` field keeps the offset
|
|
34
|
+
only, and refuses a value in the hour the clocks repeat —
|
|
35
|
+
[references/datetimes.md](references/datetimes.md).
|
|
36
|
+
- A class you do not own: `Annotated[T, PlainValidator(read), PlainSerializer(write, return_type=str)]`
|
|
37
|
+
— **validator first**; [references/foreign-types.md](references/foreign-types.md).
|
|
38
|
+
- Your own normalizer or encoder:
|
|
39
|
+
[references/normalizers.md](references/normalizers.md).
|
|
40
|
+
- In an application, with xtr-dependency-injection installed beside the package, activate
|
|
41
|
+
`SerializerBundle` and inject `SerializerInterface`. Only `xtr_serializer.bundle` needs the
|
|
42
|
+
container; `xtr_serializer.command` needs xtr-console.
|
|
43
|
+
|
|
44
|
+
## Round-trip a value
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from datetime import datetime
|
|
49
|
+
from decimal import Decimal
|
|
50
|
+
from zoneinfo import ZoneInfo
|
|
51
|
+
|
|
52
|
+
from xtr_serializer import JsonEncoder, PydanticNormalizer, Serializer
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Order:
|
|
57
|
+
reference: str
|
|
58
|
+
total: Decimal
|
|
59
|
+
placed_at: datetime
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])
|
|
63
|
+
|
|
64
|
+
order = Order("A-17", Decimal("19.99"), datetime(2026, 3, 1, 9, 30, tzinfo=ZoneInfo("UTC")))
|
|
65
|
+
text = serializer.serialize(order, "json")
|
|
66
|
+
# '{"reference":"A-17","total":"19.99","placed_at":"2026-03-01T09:30:00Z"}'
|
|
67
|
+
|
|
68
|
+
assert serializer.deserialize(text, Order, "json") == order
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`Decimal`, `UUID`, `bytes` (base64url), `date`, `time`, `timedelta`, `Path`, enums, tuples, sets
|
|
72
|
+
and frozensets all come back by their declared type. A naive datetime in a plain `datetime` field
|
|
73
|
+
is refused — aware only.
|
|
74
|
+
|
|
75
|
+
## Carry a class hierarchy
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from dataclasses import dataclass, field
|
|
79
|
+
|
|
80
|
+
from xtr_serializer import discriminator_map, discriminator_name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@discriminator_map()
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Event:
|
|
86
|
+
name: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@discriminator_name("delivery")
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Delivery(Event):
|
|
92
|
+
recipient: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Mailbox:
|
|
97
|
+
latest: Event
|
|
98
|
+
history: list[Event] = field(default_factory=list[Event])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
mailbox = Mailbox(Delivery("sent", "ana@example.com"), [Delivery("sent", "bo@example.com")])
|
|
102
|
+
|
|
103
|
+
back = serializer.deserialize(serializer.serialize(mailbox, "json"), Mailbox, "json")
|
|
104
|
+
assert back == mailbox
|
|
105
|
+
assert type(back.latest) is Delivery
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`@discriminator_map` goes **after** `@dataclass`, and name every class before a type holding one
|
|
109
|
+
is used. The rest — the wire key, intermediate bases, what a forged tag does, a model holding its
|
|
110
|
+
own hierarchy — is in [references/hierarchies.md](references/hierarchies.md).
|
|
111
|
+
|
|
112
|
+
## Testing
|
|
113
|
+
|
|
114
|
+
- Use a real serializer; there is nothing to mock. `Serializer([PydanticNormalizer()],
|
|
115
|
+
[JsonEncoder()])` touches no I/O.
|
|
116
|
+
- Assert the round trip, and the exact type of every nested value — equality alone would pass for
|
|
117
|
+
a subclass written as its base:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
def test_a_delivery_in_an_event_field_comes_back_a_delivery() -> None:
|
|
121
|
+
serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])
|
|
122
|
+
mailbox = Mailbox(Delivery("sent", "ana@example.com"))
|
|
123
|
+
|
|
124
|
+
back = serializer.deserialize(serializer.serialize(mailbox, "json"), Mailbox, "json")
|
|
125
|
+
|
|
126
|
+
assert back == mailbox
|
|
127
|
+
assert type(back.latest) is Delivery
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- Assert refusals by type and path, never by the whole message:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
import pytest
|
|
134
|
+
from xtr_serializer import NotNormalizableValueError
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_a_forged_tag_is_refused() -> None:
|
|
138
|
+
with pytest.raises(NotNormalizableValueError) as raised:
|
|
139
|
+
serializer.deserialize('{"latest": {"$type": "nowhere"}}', Mailbox, "json")
|
|
140
|
+
|
|
141
|
+
assert raised.value.path == "latest"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- Name the hierarchy's classes at module level in the test module or a fixtures module, before
|
|
145
|
+
any type holding them is used. A name cannot be added once a schema covering the base is built,
|
|
146
|
+
and the registry lives for the process — so never register names inside a test body.
|
|
147
|
+
- With a kernel, `assert_zero_config(SerializerBundle)` is the bundle's own contract test, and
|
|
148
|
+
`boot_for_test(kernel, overrides={SerializerInterface: ...})` swaps the serializer out.
|
|
149
|
+
- Freeze a clock with `xtr-clock`'s `mock_time` when the value under test carries a date it
|
|
150
|
+
reads.
|
|
151
|
+
|
|
152
|
+
## Use in an application
|
|
153
|
+
|
|
154
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
155
|
+
`SerializerBundle`. There is no config file, environment or ignore line to write; the steps below
|
|
156
|
+
that it cannot make, it prints.
|
|
157
|
+
|
|
158
|
+
1. **Install** — `uv add xtr-serializer xtr-dependency-injection`: the bundle needs the container
|
|
159
|
+
installed beside it, never as an extra. Install xtr-console beside it for `debug:serializer`.
|
|
160
|
+
2. **Activate** — `SerializerBundle: {"all": True}` in `BUNDLES` in `<app>/config/bundles.py`,
|
|
161
|
+
imported from `xtr_serializer.bundle`.
|
|
162
|
+
3. **Brings along** — the console bundle when xtr-console is installed. The logging bundle, and
|
|
163
|
+
the clock bundle under it, join only when xtr-logging is installed beside them too:
|
|
164
|
+
xtr-console does not depend on it.
|
|
165
|
+
4. **Configure** — optional. With nothing configured there is one serializer writing JSON,
|
|
166
|
+
holding the pydantic normalizer, the JSON encoder and every normalizer and encoder **tagged**
|
|
167
|
+
for it — which a class subclassing one of the interfaces is, without being told, while a
|
|
168
|
+
service that only has their methods is not. A default context, named serializers and the tag
|
|
169
|
+
attributes are in [references/application.md](references/application.md).
|
|
170
|
+
5. **Environment** — nothing is read from the environment. A value given as `env(...)` in a
|
|
171
|
+
configured default context must be set at boot, which builds every serializer.
|
|
172
|
+
6. **Ignore** — nothing: the serializer writes no file into the project.
|
|
173
|
+
7. **Use** — inject the interface; the default serializer has no qualifier, a named one is
|
|
174
|
+
reached by its name:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from typing import Annotated
|
|
178
|
+
|
|
179
|
+
from xtr_dependency_injection import Target, as_service
|
|
180
|
+
from xtr_serializer import SerializerInterface
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@as_service
|
|
184
|
+
class Outbox:
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
serializer: SerializerInterface,
|
|
188
|
+
api: Annotated[SerializerInterface, Target("api")],
|
|
189
|
+
) -> None: ...
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
8. **Check** — `debug:bundles` shows `serializer` as `listed` and `active`.
|
|
193
|
+
`debug:serializer <module:Class>` shows the class's fields as it is read (`validation`) and as
|
|
194
|
+
it is written (`serialization`), and the classes of its hierarchy.
|
|
195
|
+
9. **Remove** — drop the `BUNDLES` entry (`uv run xtr-recipes recipes:remove xtr-serializer`),
|
|
196
|
+
delete `<app>/config/packages/serializer.py` if you wrote one, then `uv remove xtr-serializer`.
|
|
197
|
+
|
|
198
|
+
## Errors
|
|
199
|
+
|
|
200
|
+
Every error derives from `SerializerError`. None repeats a value it was handed: a message says
|
|
201
|
+
where the problem is and which types were involved.
|
|
202
|
+
|
|
203
|
+
| Error | Raised when |
|
|
204
|
+
| --- | --- |
|
|
205
|
+
| `NotNormalizableValueError` | A value has no exact plain form, or data does not fit the declared type. `path`, `expected_types`, `current_type`, `error_type` |
|
|
206
|
+
| `ExtraAttributesError` | Data carried fields its type does not declare, with `ALLOW_EXTRA_ATTRIBUTES` off. A `NotNormalizableValueError`, with `extra_attributes` |
|
|
207
|
+
| `PartialDenormalizationError` | `COLLECT_DENORMALIZATION_ERRORS` is on and one or more values did not fit: `errors`, `data` |
|
|
208
|
+
| `NotEncodableValueError` | Plain data has no form in the format, or the text is not valid in it |
|
|
209
|
+
| `UnsupportedFormatError` | Nothing writes or reads the format asked for. A `NotEncodableValueError`, with `format` |
|
|
210
|
+
| `CircularReferenceError` | A value contains itself. Also a `RuntimeError` |
|
|
211
|
+
| `MappingError` | A hierarchy registered in a way that cannot work — duplicate name, class in no hierarchy, key a field also uses. A `LogicError`, raised at registration |
|
|
212
|
+
| `LogicError` | The serializer was put together in a way no input could make right. Also a `RuntimeError` |
|
|
213
|
+
| `InvalidArgumentError` | An argument or a configuration cannot be used. Also a `ValueError` |
|
|
214
|
+
|
|
215
|
+
Raise these with their typed fields and **no `from` clause**: chaining an exception that carries
|
|
216
|
+
the value puts that value in the traceback.
|
|
217
|
+
|
|
218
|
+
## Do not
|
|
219
|
+
|
|
220
|
+
- Do not expect a bare `list` or `dict` of objects to be written. Pass `type_=list[Order]`, or it
|
|
221
|
+
is refused.
|
|
222
|
+
- Do not rely on a subclass surviving a field typed with its base without a hierarchy. Declare
|
|
223
|
+
`@discriminator_map()` and name the classes.
|
|
224
|
+
- Do not register a `@discriminator_name` lazily — in a function, a test body, or after a type
|
|
225
|
+
holding the class has been used. It raises `MappingError`.
|
|
226
|
+
- Do not use an import path, a class name or a module path as a wire tag. Only registered names
|
|
227
|
+
are read, and that is the point.
|
|
228
|
+
- Do not put `PlainSerializer` before `PlainValidator` in an `Annotated`. The validator throws
|
|
229
|
+
the serializer away.
|
|
230
|
+
- Do not store a `ZoneInfo` datetime in a plain `datetime` field and expect the zone back. Use
|
|
231
|
+
`ZonedDateTime`, or store UTC.
|
|
232
|
+
- Do not pass a generator or a one-shot iterator as `normalizers` or `encoders`: the serializer
|
|
233
|
+
ends up empty.
|
|
234
|
+
- Do not mutate the mapping a serializer was built with as its `default_context`, nor the context
|
|
235
|
+
a normalizer is handed — with no call context, every call gets the serializer's own.
|
|
236
|
+
- Do not put a value in an error message, and do not `raise ... from` an exception holding the
|
|
237
|
+
input.
|
|
238
|
+
- Do not expect a service that merely has a normalizer's methods to join a serializer: subclass
|
|
239
|
+
the interface, or tag it where you register it.
|
|
240
|
+
- Do not register `Serializer` yourself in a container, or override a built-in normalizer or
|
|
241
|
+
encoder: the bundle's tag attributes are lost. Configure through `SerializerConfig` and the
|
|
242
|
+
tags.
|
|
243
|
+
- Do not feed untrusted data of unbounded size to the decoder before capping the request:
|
|
244
|
+
crafted sets and integer-keyed dicts are quadratic.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# The bundle in an application
|
|
2
|
+
|
|
3
|
+
With xtr-dependency-injection installed beside the package, list the bundle:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
# app/config/bundles.py
|
|
7
|
+
from xtr_serializer.bundle import SerializerBundle
|
|
8
|
+
|
|
9
|
+
BUNDLES = {SerializerBundle: {"all": True}}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Every serializer is registered under `SerializerInterface`, `NormalizerInterface`,
|
|
13
|
+
`DenormalizerInterface`, `EncoderInterface` and `DecoderInterface` — the default one with no
|
|
14
|
+
qualifier, a named one under its name.
|
|
15
|
+
|
|
16
|
+
## Configure
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# app/config/packages/serializer.py
|
|
20
|
+
from xtr_dependency_injection import configure
|
|
21
|
+
from xtr_serializer import SKIP_NULL_VALUES, STRICT
|
|
22
|
+
from xtr_serializer.bundle import NamedSerializerConfig, SerializerConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@configure
|
|
26
|
+
def serializer() -> SerializerConfig:
|
|
27
|
+
return SerializerConfig(
|
|
28
|
+
default_context={STRICT: True},
|
|
29
|
+
named_serializers={"api": NamedSerializerConfig(default_context={SKIP_NULL_VALUES: True})},
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| `SerializerConfig` field | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `default_context` | Options every call to every serializer starts from; a call's context wins over them key by key |
|
|
36
|
+
| `named_serializers` | Serializers besides the default one, each registered under its name as the qualifier. `"default"` and `"*"` are the names tags use, so neither names one here |
|
|
37
|
+
|
|
38
|
+
| `NamedSerializerConfig` field | Meaning |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `default_context` | Options every call to it starts from, over the bundle's own key by key |
|
|
41
|
+
| `include_built_in_normalizers` | Whether the bundle's own pydantic normalizer joins it. `False` and the serializer has only what is tagged for it, and must be given one |
|
|
42
|
+
| `include_built_in_encoders` | The same for the JSON encoder |
|
|
43
|
+
|
|
44
|
+
## Tags
|
|
45
|
+
|
|
46
|
+
A service joins a serializer by `NORMALIZER_TAG` (`"serializer.normalizer"`) or `ENCODER_TAG`
|
|
47
|
+
(`"serializer.encoder"`), with three optional attributes:
|
|
48
|
+
|
|
49
|
+
| Attribute | Means |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `priority` | An `int`, higher tried first; without it, the definition's own priority, or 0 |
|
|
52
|
+
| `serializer` | The name it joins, or a sequence of names: `"default"` (the default when absent), a configured name, or `"*"` for every serializer |
|
|
53
|
+
| `built_in` | `True` for one a named serializer can leave out |
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from xtr_serializer.bundle import NORMALIZER_TAG
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_extension(self, config, services, builder) -> None:
|
|
60
|
+
_ = services.set(MoneyNormalizer).add_tag(NORMALIZER_TAG, priority=10, serializer="*")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- A class **subclassing** one of the interfaces carries the tag without being told, with no
|
|
64
|
+
attribute — which puts it in the default serializer at its definition's own priority. A class
|
|
65
|
+
that only has a normalizer's or an encoder's methods carries nothing: tag it where you register
|
|
66
|
+
it. A serializer holds exactly what is tagged for it.
|
|
67
|
+
- Such a class is tagged before its own `@autoconfigure_tag(...)` is read, so those attributes are
|
|
68
|
+
dropped. Tag it where you register it, or use `@as_tagged_item(priority=...)`.
|
|
69
|
+
- **Checked at build**: a tag that cannot be read, one naming a serializer that does not exist,
|
|
70
|
+
and a serializer left without a normalizer or an encoder all fail the build. Booting then builds
|
|
71
|
+
every serializer, so a service tagged as what it is not fails there rather than on the first
|
|
72
|
+
call.
|
|
73
|
+
- **Named copies**: each named serializer gets its own instance of every service it shares with
|
|
74
|
+
another, under the key `serializer.<name>[.<qualifier>]` — a service is handed the serializer
|
|
75
|
+
holding it, and one instance can only be handed one. A decorator wraps the default serializer's
|
|
76
|
+
instance only.
|
|
77
|
+
- Strings in a configured default context go through `%parameter%` resolution twice, so a literal
|
|
78
|
+
`%` is written `%%%%`, and `'%%kernel.environment%%'` arrives as `'dev'`. A string such as
|
|
79
|
+
`'10%%-20%%'` fails the boot with `ParameterNotFoundError` for the parameter `-20`.
|
|
80
|
+
- **Zero config**: one serializer writing JSON. Nothing is converted until something asks for one.
|
|
81
|
+
|
|
82
|
+
## debug:serializer
|
|
83
|
+
|
|
84
|
+
In debug mode, with a console bundle active, `debug:serializer <module:Class>` shows the fields of
|
|
85
|
+
a class as it is read (`validation`) and as it is written (`serialization`), and the classes of its
|
|
86
|
+
hierarchy. It takes a class: a parametrized generic alias (`Box[int]`, `list[int]`) and a TypeVar
|
|
87
|
+
are refused, while pydantic generic models (`Page[int]`) work. Running it on a class of a hierarchy
|
|
88
|
+
locks that hierarchy for the process. Its tables are pydantic's JSON schema, with that schema's
|
|
89
|
+
blind spots, and assume the default options rather than a configured default context.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Options: the context
|
|
2
|
+
|
|
3
|
+
Every call takes a context — a plain mapping each step reads its own keys from. A builder per
|
|
4
|
+
step sets them with typed methods, and each leaves the other steps' keys alone, so they chain by
|
|
5
|
+
handing one's `to_dict()` to the next.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
from xtr_serializer import (
|
|
14
|
+
JsonEncoder,
|
|
15
|
+
JsonEncoderContextBuilder,
|
|
16
|
+
PydanticNormalizer,
|
|
17
|
+
PydanticNormalizerContextBuilder,
|
|
18
|
+
Serializer,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Order:
|
|
24
|
+
reference: str
|
|
25
|
+
total: Decimal
|
|
26
|
+
placed_at: datetime
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])
|
|
30
|
+
order = Order("A-17", Decimal("19.99"), datetime(2026, 3, 1, 9, 30, tzinfo=ZoneInfo("UTC")))
|
|
31
|
+
|
|
32
|
+
context = (
|
|
33
|
+
PydanticNormalizerContextBuilder(JsonEncoderContextBuilder().with_indent(2).to_dict())
|
|
34
|
+
.with_ignored_attributes({"total"})
|
|
35
|
+
.to_dict()
|
|
36
|
+
)
|
|
37
|
+
# {'json_indent': 2, 'ignored_attributes': frozenset({'total'})}
|
|
38
|
+
|
|
39
|
+
serializer.serialize(order, "json", context)
|
|
40
|
+
# '{\n "reference": "A-17",\n "placed_at": "2026-03-01T09:30:00Z"\n}'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Builder | Sets |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `SerializerContextBuilder` | `with_collect_denormalization_errors` |
|
|
46
|
+
| `PydanticNormalizerContextBuilder` | `with_attributes`, `with_ignored_attributes`, `with_skip_null_values`, `with_skip_unset_values`, `with_skip_default_values`, `with_by_alias`, `with_strict`, `with_allow_extra_attributes`, `with_from_attributes`, `with_populate_by_name` |
|
|
47
|
+
| `JsonEncoderContextBuilder` | `with_indent` |
|
|
48
|
+
|
|
49
|
+
- Builders are immutable: each `with_*` returns a new one, so a builder holding an application's
|
|
50
|
+
defaults can be extended for a single call.
|
|
51
|
+
- Each key is a public constant with the same name in upper case — `STRICT`, `BY_ALIAS`,
|
|
52
|
+
`JSON_INDENT`, `COLLECT_DENORMALIZATION_ERRORS` — for a context written by hand.
|
|
53
|
+
- A serializer built with `default_context=` starts every call from those options, and a call's
|
|
54
|
+
context wins over them key by key. With no call context, every call is handed the serializer's
|
|
55
|
+
own mapping: copy it before changing it.
|
|
56
|
+
- The whole context also reaches pydantic as `info.context`, so an application's validators read
|
|
57
|
+
it.
|
|
58
|
+
- Leaving fields out (`with_attributes`, `with_ignored_attributes`, the `skip_*` options) is a
|
|
59
|
+
partial write: it is checked, but not read back, so the exactness promise does not apply to it.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Dates that keep their zone
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
from xtr_serializer import JsonEncoder, PydanticNormalizer, Serializer, ZonedDateTime
|
|
9
|
+
|
|
10
|
+
serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Reminder:
|
|
15
|
+
at: ZonedDateTime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
repeated = datetime(2026, 10, 25, 3, 30, tzinfo=ZoneInfo("Europe/Bucharest"), fold=1)
|
|
19
|
+
|
|
20
|
+
text = serializer.serialize(Reminder(repeated), "json")
|
|
21
|
+
# '{"at":"2026-10-25T03:30:00+02:00[Europe/Bucharest]"}'
|
|
22
|
+
|
|
23
|
+
read = serializer.deserialize(text, Reminder, "json")
|
|
24
|
+
assert read.at == repeated
|
|
25
|
+
assert (read.at.tzinfo, read.at.fold) == (ZoneInfo("Europe/Bucharest"), 1)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
One string, the form RFC 9557 gives: the ISO datetime with its offset, then the zone's name.
|
|
29
|
+
|
|
30
|
+
- The same value in a plain `datetime` field is refused when it is written, naming the field: in
|
|
31
|
+
the hour the clocks repeat the offset alone cannot tell the two 03:30s apart.
|
|
32
|
+
- `ZonedDateTime` refuses a naive datetime, a fixed offset and a zone with no name; reading
|
|
33
|
+
refuses text not in that form, an unknown zone, and an offset the zone does not have at that
|
|
34
|
+
time. Each unknown zone name is looked up on disk.
|
|
35
|
+
- A plain aware `datetime` reads back with pydantic's own `TzInfo`: equal, not the same tzinfo
|
|
36
|
+
object or class.
|
|
37
|
+
- `date`, `time`, `timedelta`, `Decimal`, `UUID`, `bytes` (base64url), `Path`, enums, tuples,
|
|
38
|
+
frozensets and sets all round-trip by their declared type, with no annotation.
|
|
39
|
+
- More than six fractional-second digits in a datetime are truncated.
|
|
40
|
+
- With pydantic before 2.14, a `NamedTuple`'s items are written by their runtime type, so a
|
|
41
|
+
`ZonedDateTime` inside one is refused.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Serialize a class you do not own
|
|
2
|
+
|
|
3
|
+
A class another library owns, which must stay free of the conversion engine, is made serializable
|
|
4
|
+
on the field rather than on the class, with pydantic's own annotations.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Annotated, TypeAlias
|
|
10
|
+
|
|
11
|
+
from pydantic import PlainSerializer, PlainValidator
|
|
12
|
+
|
|
13
|
+
from xtr_serializer import JsonEncoder, PydanticNormalizer, Serializer
|
|
14
|
+
|
|
15
|
+
serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Money:
|
|
19
|
+
"""An amount in a currency, owned elsewhere and knowing nothing of this package."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, amount: Decimal, currency: str) -> None:
|
|
22
|
+
self.amount = amount
|
|
23
|
+
self.currency = currency
|
|
24
|
+
|
|
25
|
+
def __eq__(self, other: object) -> bool:
|
|
26
|
+
if not isinstance(other, Money):
|
|
27
|
+
return NotImplemented
|
|
28
|
+
return (self.amount, self.currency) == (other.amount, other.currency)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_money(money: Money) -> str:
|
|
32
|
+
return f"{money.amount} {money.currency}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_money(value: object) -> Money:
|
|
36
|
+
if not isinstance(value, str):
|
|
37
|
+
raise ValueError("money is written as text")
|
|
38
|
+
amount, _, currency = value.partition(" ")
|
|
39
|
+
if len(currency) != 3 or not currency.isalpha():
|
|
40
|
+
raise ValueError("money is an amount, a space and a three-letter currency")
|
|
41
|
+
return Money(Decimal(amount), currency)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Priced: TypeAlias = Annotated[
|
|
45
|
+
Money, PlainValidator(_read_money), PlainSerializer(_write_money, return_type=str)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Invoice:
|
|
51
|
+
total: Priced
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
text = serializer.serialize(Invoice(Money(Decimal("12.50"), "EUR")), "json")
|
|
55
|
+
# '{"total":"12.50 EUR"}'
|
|
56
|
+
|
|
57
|
+
assert serializer.deserialize(text, Invoice, "json") == Invoice(Money(Decimal("12.50"), "EUR"))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **List `PlainValidator` before `PlainSerializer`.** The validator replaces the whole schema the
|
|
61
|
+
annotations before it built, the serializer among them; put it first and the `PlainSerializer`
|
|
62
|
+
after it is kept.
|
|
63
|
+
- The reader raises a plain `ValueError` with words that do not quote the value, and it comes back
|
|
64
|
+
as a refusal carrying the field's path.
|
|
65
|
+
- The annotation lives on the field, so the class itself stays free of the conversion engine — and
|
|
66
|
+
this works inside a hierarchy's values too.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Class hierarchies
|
|
2
|
+
|
|
3
|
+
A field typed with a base class holds a value of any subclass. Declare the hierarchy once on the
|
|
4
|
+
base, and name each class that is a value of its own.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from xtr_serializer import (
|
|
10
|
+
JsonEncoder,
|
|
11
|
+
NotNormalizableValueError,
|
|
12
|
+
PydanticNormalizer,
|
|
13
|
+
Serializer,
|
|
14
|
+
discriminator_map,
|
|
15
|
+
discriminator_name,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
serializer = Serializer([PydanticNormalizer()], [JsonEncoder()])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@discriminator_map()
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Event:
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@discriminator_name("delivery")
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Delivery(Event):
|
|
30
|
+
recipient: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Mailbox:
|
|
35
|
+
latest: Event
|
|
36
|
+
history: list[Event] = field(default_factory=list[Event])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
mailbox = Mailbox(Delivery("sent", "ana@example.com"), [Delivery("sent", "bo@example.com")])
|
|
40
|
+
|
|
41
|
+
text = serializer.serialize(mailbox, "json")
|
|
42
|
+
# '{"latest":{"$type":"delivery","name":"sent","recipient":"ana@example.com"},
|
|
43
|
+
# "history":[{"$type":"delivery","name":"sent","recipient":"bo@example.com"}]}'
|
|
44
|
+
|
|
45
|
+
back = serializer.deserialize(text, Mailbox, "json")
|
|
46
|
+
assert back == mailbox
|
|
47
|
+
assert type(back.latest) is Delivery
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A forged, unknown or missing tag is refused:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
try:
|
|
54
|
+
serializer.deserialize('{"latest": {"$type": "nowhere", "name": "sent"}}', Mailbox, "json")
|
|
55
|
+
except NotNormalizableValueError as refusal:
|
|
56
|
+
print(refusal)
|
|
57
|
+
# at 'latest': the data must be an object whose '$type' names a class the declared type
|
|
58
|
+
# takes (got 'dict')
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- The wire key is `"$type"` by default — no name a field can have. `@discriminator_map("kind")`
|
|
62
|
+
picks another, which must be no field's name or alias.
|
|
63
|
+
- A name is the data's word for the class: choose one that stays when the class moves or is
|
|
64
|
+
renamed. Only a named class is written; a value of an unnamed subclass is refused rather than
|
|
65
|
+
written as its base, so name even a subclass that adds no field.
|
|
66
|
+
- A tag is resolved among the registered subclasses **of the declared type only**, so data cannot
|
|
67
|
+
name a class of another hierarchy or one the field does not take.
|
|
68
|
+
- Dataclasses, pydantic dataclasses and models all work, as do intermediate bases: a field typed
|
|
69
|
+
with one takes the named classes under it. A generic class cannot be in a hierarchy.
|
|
70
|
+
- Apply `@discriminator_map` after `@dataclass`, and before `@discriminator_name` when the base
|
|
71
|
+
has a name of its own.
|
|
72
|
+
- Name every class of a hierarchy before a type holding one is used — and before a model holding
|
|
73
|
+
one is defined, as a model builds its schema then. Naming one later raises `MappingError`, and
|
|
74
|
+
hierarchy classes live for the whole process. A model holding its own hierarchy needs
|
|
75
|
+
`model_config = ConfigDict(defer_build=True)`.
|
|
76
|
+
- Reading a nested hierarchy value costs depth × size; the decoder caps nesting at about 198
|
|
77
|
+
levels.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Normalizers and encoders of your own
|
|
2
|
+
|
|
3
|
+
A normalizer handles the types it reports and leaves the rest; the serializer gives each value to
|
|
4
|
+
the first one that handles it, in the order the list has them. Put yours before the pydantic
|
|
5
|
+
normalizer, which handles nearly anything.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from xtr_serializer import (
|
|
11
|
+
JsonEncoder,
|
|
12
|
+
NotNormalizableValueError,
|
|
13
|
+
PydanticNormalizer,
|
|
14
|
+
Serializer,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Point:
|
|
20
|
+
x: int
|
|
21
|
+
y: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PointNormalizer:
|
|
25
|
+
"""Writes a point as a pair of numbers, and reads that pair back into a point."""
|
|
26
|
+
|
|
27
|
+
def normalize(self, data, format=None, context=None, *, type_=None):
|
|
28
|
+
if not isinstance(data, Point):
|
|
29
|
+
raise NotNormalizableValueError("only a point is written here")
|
|
30
|
+
return [data.x, data.y]
|
|
31
|
+
|
|
32
|
+
def supports_normalization(self, data, format=None, context=None, *, type_=None):
|
|
33
|
+
return isinstance(data, Point)
|
|
34
|
+
|
|
35
|
+
def denormalize(self, data, type_, format=None, context=None):
|
|
36
|
+
match data:
|
|
37
|
+
case [int() as x, int() as y] if type_ is Point:
|
|
38
|
+
return Point(x, y)
|
|
39
|
+
case _:
|
|
40
|
+
raise NotNormalizableValueError(
|
|
41
|
+
"a point is a pair of whole numbers",
|
|
42
|
+
expected_types=("Point",),
|
|
43
|
+
current_type=type(data).__name__,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def supports_denormalization(self, data, type_, format=None, context=None):
|
|
47
|
+
return type_ is Point
|
|
48
|
+
|
|
49
|
+
def get_supported_types(self, format):
|
|
50
|
+
return {Point: True}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
serializer = Serializer([PointNormalizer(), PydanticNormalizer()], [JsonEncoder()])
|
|
54
|
+
assert serializer.serialize(Point(3, 4), "json") == "[3,4]"
|
|
55
|
+
assert serializer.deserialize("[3,4]", Point, "json") == Point(3, 4)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Implement `NormalizerInterface`, `DenormalizerInterface` or both; an encoder implements
|
|
59
|
+
`EncoderInterface`, `DecoderInterface` or both, as `encode`, `supports_encoding`, `decode`,
|
|
60
|
+
`supports_decoding` and a format string of its own. They are `Protocol`s — subclassing is
|
|
61
|
+
optional in code, the methods are what counts, and `normalize` / `supports_normalization` must
|
|
62
|
+
accept the keyword-only `type_`.
|
|
63
|
+
- `get_supported_types(format)` is how a serializer skips you without asking: a key is a class
|
|
64
|
+
(standing for its subclasses), `"object"` (every class) or `"*"` (anything at all, type
|
|
65
|
+
expressions such as `int | None` included). Its value is `True` when the answer rests on the
|
|
66
|
+
type and the format alone — asked once per type and remembered — `False` when it rests on the
|
|
67
|
+
value or the context too, and `None` for a type you do not handle.
|
|
68
|
+
- A key naming the type itself counts first, so `None` there settles it; failing that, the first
|
|
69
|
+
class key covering the type, in the order reported, counts — so list narrower keys first. A
|
|
70
|
+
class key never covers `list[Point]` or `Point | None`; only `"*"` does.
|
|
71
|
+
- **Priority is list order**, both in code and, through the bundle, by the tag's `priority`
|
|
72
|
+
attribute, highest first.
|
|
73
|
+
- Take `SerializerAwareInterface`, `NormalizerAwareInterface` or `DenormalizerAwareInterface` and
|
|
74
|
+
the serializer hands itself over, for values nested inside yours.
|
|
75
|
+
- `Serializer` does not look inside a bare list or dict: it goes to a normalizer whole, so a
|
|
76
|
+
normalizer of your own is never handed a container's items.
|
|
77
|
+
- In a container, a class **subclassing** one of the interfaces is tagged without being told; a
|
|
78
|
+
class that only has their methods is not, and joins nothing until a tag puts it there — see
|
|
79
|
+
[application.md](application.md).
|