Skip to content

Commit a13fa2e

Browse files
committed
tagged unions: leave tag key unless forbid_extra_keys
1 parent 898e59c commit a13fa2e

4 files changed

Lines changed: 70 additions & 21 deletions

File tree

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ can now be used as decorators and have gained new features.
4848
([#463](https://github.com/python-attrs/cattrs/pull/463))
4949
- `cattrs.gen` generators now attach metadata to the generated functions, making them introspectable.
5050
([#472](https://github.com/python-attrs/cattrs/pull/472))
51+
- The [tagged union strategy](https://catt.rs/en/stable/strategies.html#tagged-unions-strategy) now leaves the tags in the payload unless `forbid_extra_keys` is set.
52+
([#533](https://github.com/python-attrs/cattrs/issues/533) [#534](https://github.com/python-attrs/cattrs/pull/534))
5153
- More robust support for `Annotated` and `NotRequired` in TypedDicts.
5254
([#450](https://github.com/python-attrs/cattrs/pull/450))
5355
- `typing_extensions.Literal` is now automatically structured, just like `typing.Literal`.

docs/strategies.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ The payload can be interpreted as about a dozen different messages, based on the
7474

7575
To keep the example simple we define two classes, one for the `REFUND` event and one for everything else.
7676

77-
```python
77+
```{testcode} apple
7878
7979
@define
8080
class Refund:
@@ -92,7 +92,9 @@ Next, we use the _tagged unions_ strategy to prepare our converter.
9292
The tag value for the `Refund` event is `REFUND`, and we can let the `OtherAppleNotification` class handle all the other cases.
9393
The `tag_generator` parameter is a callable, so we can give it the `get` method of a dictionary.
9494

95-
```python
95+
```{doctest} apple
96+
97+
>>> from cattrs.strategies import configure_tagged_union
9698
9799
>>> c = Converter()
98100
>>> configure_tagged_union(
@@ -107,7 +109,7 @@ The `tag_generator` parameter is a callable, so we can give it the `get` method
107109

108110
The converter is now ready to start structuring Apple notifications.
109111

110-
```python
112+
```{doctest} apple
111113
112114
>>> payload = {"notificationType": "REFUND", "originalTransactionId": "1"}
113115
>>> notification = c.structure(payload, AppleNotification)
@@ -117,7 +119,7 @@ The converter is now ready to start structuring Apple notifications.
117119
... print(f"Refund for {txn_id}!")
118120
... case OtherAppleNotification(not_type):
119121
... print("Can't handle this yet")
120-
122+
Refund for 1!
121123
```
122124

123125
```{versionadded} 23.1.0

src/cattrs/strategies/_unions.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -84,27 +84,50 @@ def unstructure_tagged_union(
8484
return res
8585

8686
if default is NOTHING:
87+
if getattr(converter, "forbid_extra_keys", False):
8788

88-
def structure_tagged_union(
89-
val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name
90-
) -> union:
91-
val = val.copy()
92-
return _tag_to_cl[val.pop(_tag_name)](val)
89+
def structure_tagged_union(
90+
val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name
91+
) -> union:
92+
val = val.copy()
93+
return _tag_to_cl[val.pop(_tag_name)](val)
94+
95+
else:
96+
97+
def structure_tagged_union(
98+
val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name
99+
) -> union:
100+
return _tag_to_cl[val[_tag_name]](val)
93101

94102
else:
103+
if getattr(converter, "forbid_extra_keys", False):
104+
105+
def structure_tagged_union(
106+
val: dict,
107+
_,
108+
_tag_to_hook=tag_to_hook,
109+
_tag_name=tag_name,
110+
_dh=default_handler,
111+
_default=default,
112+
) -> union:
113+
if _tag_name in val:
114+
val = val.copy()
115+
return _tag_to_hook[val.pop(_tag_name)](val)
116+
return _dh(val, _default)
95117

96-
def structure_tagged_union(
97-
val: dict,
98-
_,
99-
_tag_to_hook=tag_to_hook,
100-
_tag_name=tag_name,
101-
_dh=default_handler,
102-
_default=default,
103-
) -> union:
104-
if _tag_name in val:
105-
val = val.copy()
106-
return _tag_to_hook[val.pop(_tag_name)](val)
107-
return _dh(val, _default)
118+
else:
119+
120+
def structure_tagged_union(
121+
val: dict,
122+
_,
123+
_tag_to_hook=tag_to_hook,
124+
_tag_name=tag_name,
125+
_dh=default_handler,
126+
_default=default,
127+
) -> union:
128+
if _tag_name in val:
129+
return _tag_to_hook[val[_tag_name]](val)
130+
return _dh(val, _default)
108131

109132
converter.register_unstructure_hook(union, unstructure_tagged_union)
110133
converter.register_structure_hook(union, structure_tagged_union)

tests/strategies/test_tagged_unions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ def test_default_member(converter: BaseConverter) -> None:
9797
assert converter.structure({"_type": "B", "a": 1}, union) == B("1")
9898

9999

100+
def test_default_member_with_tag(converter: BaseConverter) -> None:
101+
"""Members can access the tags, if not `forbid_extra_keys`."""
102+
103+
@define
104+
class C:
105+
_type: str = ""
106+
107+
union = Union[A, B, C]
108+
configure_tagged_union(union, converter, default=C)
109+
assert converter.unstructure(A(1), union) == {"_type": "A", "a": 1}
110+
assert converter.unstructure(B("1"), union) == {"_type": "B", "a": "1"}
111+
112+
# No tag, so should structure as C.
113+
assert converter.structure({"a": 1}, union) == C()
114+
# Wrong tag, so should again structure as C.
115+
assert converter.structure({"_type": "D", "a": 1}, union) == C("D")
116+
117+
assert converter.structure({"_type": "A", "a": 1}, union) == A(1)
118+
assert converter.structure({"_type": "B", "a": 1}, union) == B("1")
119+
assert converter.structure({"_type": "C", "a": 1}, union) == C("C")
120+
121+
100122
def test_default_member_validation(converter: BaseConverter) -> None:
101123
"""Default members are structured properly.."""
102124
union = Union[A, B]

0 commit comments

Comments
 (0)