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.
Files changed (95) hide show
  1. xtr_serializer/.agents/skills/xtr-serializer/SKILL.md +244 -0
  2. xtr_serializer/.agents/skills/xtr-serializer/references/application.md +89 -0
  3. xtr_serializer/.agents/skills/xtr-serializer/references/context.md +59 -0
  4. xtr_serializer/.agents/skills/xtr-serializer/references/datetimes.md +41 -0
  5. xtr_serializer/.agents/skills/xtr-serializer/references/foreign-types.md +66 -0
  6. xtr_serializer/.agents/skills/xtr-serializer/references/hierarchies.md +77 -0
  7. xtr_serializer/.agents/skills/xtr-serializer/references/normalizers.md +79 -0
  8. xtr_serializer/__init__.py +110 -0
  9. xtr_serializer/_dispatch.py +102 -0
  10. xtr_serializer/_loc_path.py +20 -0
  11. xtr_serializer/_plain_data.py +17 -0
  12. xtr_serializer/_type_cache.py +147 -0
  13. xtr_serializer/_type_names.py +28 -0
  14. xtr_serializer/_written_types.py +64 -0
  15. xtr_serializer/bundle/__init__.py +19 -0
  16. xtr_serializer/bundle/_context_check.py +30 -0
  17. xtr_serializer/bundle/_serializer_factory.py +87 -0
  18. xtr_serializer/bundle/named_serializer_config.py +56 -0
  19. xtr_serializer/bundle/serializer_bundle.py +133 -0
  20. xtr_serializer/bundle/serializer_config.py +80 -0
  21. xtr_serializer/bundle/serializer_pass.py +262 -0
  22. xtr_serializer/command/__init__.py +7 -0
  23. xtr_serializer/command/_class_import.py +67 -0
  24. xtr_serializer/command/_schema_fields.py +177 -0
  25. xtr_serializer/command/debug_serializer_command.py +103 -0
  26. xtr_serializer/context/__init__.py +21 -0
  27. xtr_serializer/context/_context_builder_trait.py +107 -0
  28. xtr_serializer/context/context.py +18 -0
  29. xtr_serializer/context/context_builder_interface.py +22 -0
  30. xtr_serializer/context/encoder/__init__.py +7 -0
  31. xtr_serializer/context/encoder/json_encoder_context_builder.py +31 -0
  32. xtr_serializer/context/normalizer/__init__.py +7 -0
  33. xtr_serializer/context/normalizer/pydantic_normalizer_context_builder.py +127 -0
  34. xtr_serializer/context/serializer_context_builder.py +35 -0
  35. xtr_serializer/decorator/__init__.py +13 -0
  36. xtr_serializer/decorator/discriminator_map.py +111 -0
  37. xtr_serializer/decorator/discriminator_name.py +110 -0
  38. xtr_serializer/encoder/__init__.py +19 -0
  39. xtr_serializer/encoder/_encoder_chain.py +68 -0
  40. xtr_serializer/encoder/decoder_interface.py +43 -0
  41. xtr_serializer/encoder/encoder_interface.py +42 -0
  42. xtr_serializer/encoder/json_encoder.py +129 -0
  43. xtr_serializer/encoder/json_encoder_keys.py +20 -0
  44. xtr_serializer/exception/__init__.py +39 -0
  45. xtr_serializer/exception/circular_reference_error.py +20 -0
  46. xtr_serializer/exception/extra_attributes_error.py +54 -0
  47. xtr_serializer/exception/invalid_argument_error.py +19 -0
  48. xtr_serializer/exception/logic_error.py +19 -0
  49. xtr_serializer/exception/mapping_error.py +20 -0
  50. xtr_serializer/exception/not_encodable_value_error.py +17 -0
  51. xtr_serializer/exception/not_normalizable_value_error.py +61 -0
  52. xtr_serializer/exception/partial_denormalization_error.py +51 -0
  53. xtr_serializer/exception/serializer_error.py +60 -0
  54. xtr_serializer/exception/unexpected_value_error.py +20 -0
  55. xtr_serializer/exception/unsupported_format_error.py +30 -0
  56. xtr_serializer/mapping/__init__.py +11 -0
  57. xtr_serializer/mapping/_class_builds.py +28 -0
  58. xtr_serializer/mapping/_class_choice.py +121 -0
  59. xtr_serializer/mapping/_class_discriminator_registry.py +148 -0
  60. xtr_serializer/mapping/_declaration_sites.py +36 -0
  61. xtr_serializer/mapping/_deferred_refusals.py +89 -0
  62. xtr_serializer/mapping/_field_names.py +29 -0
  63. xtr_serializer/mapping/_polymorphic_schema.py +223 -0
  64. xtr_serializer/mapping/class_discriminator_mapping.py +52 -0
  65. xtr_serializer/normalized_value.py +26 -0
  66. xtr_serializer/normalizer/__init__.py +45 -0
  67. xtr_serializer/normalizer/_adapter_cache.py +193 -0
  68. xtr_serializer/normalizer/_built_in_words.py +74 -0
  69. xtr_serializer/normalizer/_context_options.py +111 -0
  70. xtr_serializer/normalizer/_cycles.py +17 -0
  71. xtr_serializer/normalizer/_first_difference.py +279 -0
  72. xtr_serializer/normalizer/_own_words.py +23 -0
  73. xtr_serializer/normalizer/_part_pairs.py +76 -0
  74. xtr_serializer/normalizer/_read_back.py +117 -0
  75. xtr_serializer/normalizer/_read_refusal.py +56 -0
  76. xtr_serializer/normalizer/_validation_refusals.py +221 -0
  77. xtr_serializer/normalizer/denormalizer_aware_interface.py +25 -0
  78. xtr_serializer/normalizer/denormalizer_interface.py +94 -0
  79. xtr_serializer/normalizer/normalizer_aware_interface.py +25 -0
  80. xtr_serializer/normalizer/normalizer_interface.py +76 -0
  81. xtr_serializer/normalizer/pydantic_normalizer.py +268 -0
  82. xtr_serializer/normalizer/pydantic_normalizer_keys.py +95 -0
  83. xtr_serializer/py.typed +0 -0
  84. xtr_serializer/recipe/__init__.py +16 -0
  85. xtr_serializer/recipe/manifest.toml +26 -0
  86. xtr_serializer/serializer.py +283 -0
  87. xtr_serializer/serializer_aware_interface.py +25 -0
  88. xtr_serializer/serializer_interface.py +99 -0
  89. xtr_serializer/supported_types.py +29 -0
  90. xtr_serializer/zoned_datetime.py +92 -0
  91. xtr_serializer-6.0.0.dist-info/METADATA +684 -0
  92. xtr_serializer-6.0.0.dist-info/RECORD +95 -0
  93. xtr_serializer-6.0.0.dist-info/WHEEL +4 -0
  94. xtr_serializer-6.0.0.dist-info/entry_points.txt +6 -0
  95. 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).