1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391
|
# Strategies
_cattrs_ ships with a number of _strategies_ for customizing un/structuring behavior.
Strategies are prepackaged, high-level patterns for quickly and easily applying complex customizations to a converter.
## Tagged Unions Strategy
_Found at {py:func}`cattrs.strategies.configure_tagged_union`._
The _tagged union_ strategy allows for un/structuring a union of classes by including an additional field (the _tag_) in the unstructured representation.
Each tag value is associated with a member of the union.
```{doctest} tagged_unions
>>> from cattrs.strategies import configure_tagged_union
>>> from cattrs import Converter
>>> converter = Converter()
>>> @define
... class A:
... a: int
>>> @define
... class B:
... b: str
>>> configure_tagged_union(A | B, converter)
>>> converter.unstructure(A(1), unstructure_as=A | B)
{'a': 1, '_type': 'A'}
>>> converter.structure({'a': 1, '_type': 'A'}, A | B)
A(a=1)
```
By default, the tag field name is `_type` and the tag value is the class name of the union member.
Both the field name and value can be overriden.
The `tag_generator` parameter is a one-argument callable that will be called with every member of the union to generate a mapping of tag values to union members.
Here are some common `tag_generator` uses:
| Tag info available in | Recommended `tag_generator` |
| ----------------------------- | ------------------------------------------------------- |
| Name of the class | Use the default, or `lambda cl: cl.__name__` |
| A class variable (`classvar`) | `lambda cl: cl.classvar` |
| A dictionary (`mydict`) | `mydict.get` or `mydict.__getitem__` |
| An enum of possible values | Build a dictionary of classes to enum values and use it |
The union members aren't required to be attrs classes or dataclasses, although those work automatically.
They may be anything that cattrs can un/structure from/to a dictionary, for example a type with registered custom hooks.
A default member can be specified to be used if the tag is missing or is unknown.
This is useful for evolving APIs in a backwards-compatible way; an endpoint taking class `A` can be changed to take `A | B` with `A` as the default (for old clients which do not send the tag).
This strategy only applies in the context of the union; the normal un/structuring hooks are left untouched.
This also means union members can be reused in multiple unions easily.
```{doctest} tagged_unions
# Unstructuring as a union.
>>> converter.unstructure(A(1), unstructure_as=A | B)
{'a': 1, '_type': 'A'}
# Unstructuring as just an `A`.
>>> converter.unstructure(A(1))
{'a': 1}
```
```{versionchanged} 25.1.0
The strategy can also be called with a type alias of a union.
```
### Real-life Case Study
The Apple App Store supports [server callbacks](https://developer.apple.com/documentation/appstoreservernotifications), by which Apple sends a JSON payload to a URL of your choice.
The payload can be interpreted as about a dozen different messages, based on the value of the `notificationType` field.
To keep the example simple we define two classes, one for the `REFUND` event and one for everything else.
```{testcode} apple
@define
class Refund:
originalTransactionId: str
@define
class OtherAppleNotification:
notificationType: str
AppleNotification = Refund | OtherAppleNotification
```
Next, we use the _tagged unions_ strategy to prepare our converter.
The tag value for the `Refund` event is `REFUND`, and we can let the `OtherAppleNotification` class handle all the other cases.
The `tag_generator` parameter is a callable, so we can give it the `get` method of a dictionary.
```{doctest} apple
>>> from cattrs.strategies import configure_tagged_union
>>> c = Converter()
>>> configure_tagged_union(
... AppleNotification,
... c,
... tag_name="notificationType",
... tag_generator={Refund: "REFUND"}.get,
... default=OtherAppleNotification
... )
```
The converter is now ready to start structuring Apple notifications.
```{doctest} apple
>>> payload = {"notificationType": "REFUND", "originalTransactionId": "1"}
>>> notification = c.structure(payload, AppleNotification)
>>> match notification:
... case Refund(txn_id):
... print(f"Refund for {txn_id}!")
... case OtherAppleNotification(not_type):
... print("Can't handle this yet")
Refund for 1!
```
```{versionadded} 23.1.0
```
## Include Subclasses Strategy
_Found at {py:func}`cattrs.strategies.include_subclasses`._
The _include subclass_ strategy allows the un/structuring of a base class to an instance of itself or one of its descendants.
Conceptually with this strategy, each time an un/structure operation for the base class is asked, `cattrs` machinery replaces that operation as if the union of the base class and its descendants had been asked instead.
```{doctest} include_subclass
>>> from attrs import define
>>> from cattrs.strategies import include_subclasses
>>> from cattrs import Converter
>>> @define
... class Parent:
... a: int
>>> @define
... class Child(Parent):
... b: str
>>> converter = Converter()
>>> include_subclasses(Parent, converter)
>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
{'a': 1, 'b': 'foo'}
>>> converter.structure({'a': 1, 'b': 'foo'}, Parent)
Child(a=1, b='foo')
```
In the example above, we asked to unstructure then structure a `Child` instance as the `Parent` class and in both cases we correctly obtained back the unstructured and structured versions of the `Child` instance.
If we did not apply the `include_subclasses` strategy, this is what we would have obtained:
```python
>>> converter_no_subclasses = Converter()
>>> converter_no_subclasses.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
{'a': 1}
>>> converter_no_subclasses.structure({'a': 1, 'b': 'foo'}, Parent)
Parent(a=1)
```
Without the application of the strategy, in both unstructure and structure operations, we received a `Parent` instance.
```{note}
The handling of subclasses is an opt-in feature for two main reasons:
- Performance. While small and probably negligible in most cases the subclass handling incurs more function calls and has a performance impact.
- Customization. The specific handling of subclasses can be different from one situation to the other. In particular there is not apparent universal good defaults for disambiguating the union type. Consequently the decision is left to the user.
```
```{warning}
To work properly, all subclasses must be defined when the `include_subclasses` strategy is applied to a `converter`. If subclasses types are defined later, for instance in the context of a plug-in mechanism using inheritance, then those late defined subclasses will not be part of the subclasses union type and will not be un/structured as expected.
```
### Customization
In the example shown in the previous section, the default options for `include_subclasses` work well because the `Child` class has an attribute that do not exist in the `Parent` class (the `b` attribute).
The automatic union type disambiguation function which is based on finding unique fields for each type of the union works as intended.
Sometimes, more disambiguation customization is required.
For instance, the unstructuring operation would have failed if `Child` did not have an extra attribute or if a sibling of `Child` had also a `b` attribute.
For those cases, a callable of 2 positional arguments (a union type and a converter) defining a [tagged union strategy](strategies.md#tagged-unions-strategy) can be passed to the `include_subclasses` strategy.
{py:func}`configure_tagged_union()<cattrs.strategies.configure_tagged_union>` can be used as-is, but if you want to change its defaults, the [partial](https://docs.python.org/3/library/functools.html#functools.partial) function from the `functools` module in the standard library can come in handy.
```python
>>> from functools import partial
>>> from attrs import define
>>> from cattrs.strategies import include_subclasses, configure_tagged_union
>>> from cattrs import Converter
>>> @define
... class Parent:
... a: int
>>> @define
... class Child1(Parent):
... b: str
>>> @define
... class Child2(Parent):
... b: int
>>> converter = Converter()
>>> union_strategy = partial(configure_tagged_union, tag_name="type_name")
>>> include_subclasses(Parent, converter, union_strategy=union_strategy)
>>> converter.unstructure(Child1(a=1, b="foo"), unstructure_as=Parent)
{'a': 1, 'b': 'foo', 'type_name': 'Child1'}
>>> converter.structure({'a': 1, 'b': 1, 'type_name': 'Child2'}, Parent)
Child2(a=1, b=1)
```
Other customizations available see are (see {py:func}`include_subclasses()<cattrs.strategies.include_subclasses>`):
- The exact list of subclasses that should participate to the union with the `subclasses` argument.
- Attribute overrides that permit the customization of attributes un/structuring like renaming an attribute.
Here is an example involving both customizations:
```python
>>> from attrs import define
>>> from cattrs.strategies import include_subclasses
>>> from cattrs import Converter, override
>>> @define
... class Parent:
... a: int
>>> @define
... class Child(Parent):
... b: str
>>> converter = Converter()
>>> include_subclasses(
... Parent,
... converter,
... subclasses=(Parent, Child),
... overrides={"b": override(rename="c")}
... )
>>> converter.unstructure(Child(a=1, b="foo"), unstructure_as=Parent)
{'a': 1, 'c': 'foo'}
>>> converter.structure({'a': 1, 'c': 'foo'}, Parent)
Child(a=1, b='foo')
```
```{versionadded} 23.1.0
```
## Using Class-Specific Structure and Unstructure Methods
_Found at {py:func}`cattrs.strategies.use_class_methods`._
This strategy allows for un/structuring logic on the models themselves.
It can be applied for both structuring and unstructuring (also simultaneously).
If a class requires special handling for (un)structuring, you can add a dedicated (un)structuring
method:
```{doctest} class_methods
>>> from attrs import define
>>> from cattrs import Converter
>>> from cattrs.strategies import use_class_methods
>>> @define
... class MyClass:
... a: int
...
... @classmethod
... def _structure(cls, data: dict):
... return cls(data["b"] + 1) # expecting "b", not "a"
...
... def _unstructure(self):
... return {"c": self.a - 1} # unstructuring as "c", not "a"
>>> converter = Converter()
>>> use_class_methods(converter, "_structure", "_unstructure")
>>> print(converter.structure({"b": 42}, MyClass))
MyClass(a=43)
>>> print(converter.unstructure(MyClass(42)))
{'c': 41}
```
Any class without a `_structure` or `_unstructure` method will use the default strategy for structuring or unstructuring, respectively.
Feel free to use other names.
The stategy can be applied multiple times (with different method names).
If you want to (un)structured nested objects, just append a converter parameter to your (un)structuring methods and you will receive the converter there:
```{doctest} class_methods
>>> @define
... class Nested:
... m: MyClass
...
... @classmethod
... def _structure(cls, data: dict, conv):
... return cls(conv.structure(data["n"], MyClass))
...
... def _unstructure(self, conv):
... return {"n": conv.unstructure(self.m)}
>>> print(converter.structure({"n": {"b": 42}}, Nested))
Nested(m=MyClass(a=43))
>>> print(converter.unstructure(Nested(MyClass(42))))
{'n': {'c': 41}}
```
```{versionadded} 23.2.0
```
## Union Passthrough
_Found at {py:func}`cattrs.strategies.configure_union_passthrough`._
The _union passthrough_ strategy enables a {py:class}`Converter <cattrs.BaseConverter>` to structure unions and subunions of given types.
A very common use case for _cattrs_ is processing data created by other serialization libraries, such as _JSON_ or _msgpack_.
These libraries are able to directly produce values of unions inherent to the format.
For example, every JSON library can differentiate between numbers, booleans, strings and null values since these values are represented differently in the wire format.
This strategy enables _cattrs_ to offload the creation of these values to an underlying library and just validate the final value.
So, _cattrs_ preconfigured JSON converters can handle the following type:
- `bool | int | float | str | None`
Continuing the JSON example, this strategy also enables structuring subsets of unions of these values.
Accordingly, here are some examples of subset unions that are also supported:
- `bool | int`
- `int | str`
- `int | float | str`
The strategy also supports types including one or more [Literals](https://mypy.readthedocs.io/en/stable/literal_types.html#literal-types) of supported types. For example:
- `Literal["admin", "user"] | int`
- `Literal[True] | str | int | float`
The strategy also supports [NewTypes](https://mypy.readthedocs.io/en/stable/more_types.html#newtypes) of these types. For example:
```python
>>> from typing import NewType
>>> UserId = NewType("UserId", int)
>>> converter.loads("12", UserId)
12
```
Unions containing unsupported types can be handled if at least one union type is supported by the strategy; the supported union types will be checked before the rest (referred to as the _spillover_) is handed over to the converter again.
For example, if `A` and `B` are arbitrary _attrs_ classes, the union `Literal[10] | A | B` cannot be handled directly by a JSON converter.
However, the strategy will check if the value being structured matches `Literal[10]` (because this type _is_ supported) and, if not, will pass it back to the converter to be structured as `A | B` (where a different strategy can handle it).
The strategy is designed to run in _O(1)_ at structure time; it doesn't depend on the size of the union and the ordering of union members.
This strategy has been preapplied to the following preconfigured converters:
- {py:class}`BsonConverter <cattrs.preconf.bson.BsonConverter>`
- {py:class}`Cbor2Converter <cattrs.preconf.cbor2.Cbor2Converter>`
- {py:class}`JsonConverter <cattrs.preconf.json.JsonConverter>`
- {py:class}`MsgpackConverter <cattrs.preconf.msgpack.MsgpackConverter>`
- {py:class}`MsgspecJsonConverter <cattrs.preconf.msgspec.MsgspecJsonConverter>`
- {py:class}`OrjsonConverter <cattrs.preconf.orjson.OrjsonConverter>`
- {py:class}`PyyamlConverter <cattrs.preconf.pyyaml.PyyamlConverter>`
- {py:class}`TomlkitConverter <cattrs.preconf.tomlkit.TomlkitConverter>`
- {py:class}`UjsonConverter <cattrs.preconf.ujson.UjsonConverter>`
```{versionadded} 23.2.0
```
|