Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT

- The generated unstructure hooks now honor the converter's `dict_factory` (for example `Converter(dict_factory=OrderedDict)`), like `BaseConverter.unstructure_attrs_asdict` already did.
([#228](https://github.com/python-attrs/cattrs/issues/228))
- Fix the msgspec converter passing typed deques directly to the encoder, which cannot serialize them. Deques are now unstructured into lists, including when nested in attrs classes or dataclasses.
([#818](https://github.com/python-attrs/cattrs/issues/818) [#819](https://github.com/python-attrs/cattrs/pull/819))

Expand Down
35 changes: 26 additions & 9 deletions src/cattrs/gen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,22 +227,39 @@ def make_dict_unstructure_fn_from_attrs(

else:
# No default or no override.
invocation_lines.append(f"{kn!r}: {invoke},")
invocation_lines.append((kn, invoke))

# If the converter has a custom dict factory, the result must be built
# using it (like `BaseConverter.unstructure_attrs_asdict` does) instead
# of a plain dict literal.
use_dict_factory = converter._dict_factory is not dict
if use_dict_factory:
globs["__c_dict_factory"] = converter._dict_factory
internal_arg_parts["__c_dict_factory"] = converter._dict_factory

internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts])
if internal_arg_line:
internal_arg_line = f", {internal_arg_line}"
for k, v in internal_arg_parts.items():
globs[k] = v

total_lines = (
[f"def {fn_name}(instance{internal_arg_line}):"]
+ [" res = {"]
+ [f" {line}" for line in invocation_lines]
+ [" }"]
+ lines
+ [" return res"]
)
if use_dict_factory:
total_lines = (
[f"def {fn_name}(instance{internal_arg_line}):"]
+ [" res = __c_dict_factory()"]
+ [f" res[{kn!r}] = {invoke}" for kn, invoke in invocation_lines]
+ lines
+ [" return res"]
)
else:
total_lines = (
[f"def {fn_name}(instance{internal_arg_line}):"]
+ [" res = {"]
+ [f" {kn!r}: {invoke}," for kn, invoke in invocation_lines]
+ [" }"]
+ lines
+ [" return res"]
)
script = "\n".join(total_lines)
fname = generate_unique_filename(
cl, "unstructure", lines=total_lines if _cattrs_use_linecache else []
Expand Down
34 changes: 34 additions & 0 deletions tests/test_gen_dict.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for generated dict functions."""

from collections import OrderedDict
from dataclasses import dataclass
from math import ceil
from typing import Annotated, Dict, Literal, Type, Union
Expand Down Expand Up @@ -794,3 +795,36 @@ def test_fields_exception():
"""fields() raises on a non-attrs, non-dataclass class."""
with pytest.raises(Exception): # noqa: B017
fields(int)


def test_dict_factory_used_by_generated_hooks():
"""Generated unstructure hooks honor the converter's `dict_factory`.

Regression test for https://github.com/python-attrs/cattrs/issues/228
"""

@define
class Inner:
x: int

@define
class Outer:
inner: Inner
a: int = 1

converter = Converter(dict_factory=OrderedDict)

res = converter.unstructure(Outer(Inner(2)))
assert isinstance(res, OrderedDict)
assert isinstance(res["inner"], OrderedDict)
assert res == {"inner": {"x": 2}, "a": 1}

# Also through an explicitly generated hook.
hook = make_dict_unstructure_fn(Outer, converter)
assert isinstance(hook(Outer(Inner(2))), OrderedDict)

# And with a converter omitting defaults.
omitting = Converter(dict_factory=OrderedDict, omit_if_default=True)
res = omitting.unstructure(Outer(Inner(2)))
assert isinstance(res, OrderedDict)
assert res == {"inner": {"x": 2}}