From e0f873f1a03bb5a13a426c22acc4ae067dc30431 Mon Sep 17 00:00:00 2001 From: Aschenbath Date: Wed, 4 Nov 2026 07:49:58 +0000 Subject: [PATCH] Honor dict_factory in generated unstructure hooks --- HISTORY.md | 2 ++ src/cattrs/gen/__init__.py | 35 ++++++++++++++++++++++++++--------- tests/test_gen_dict.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 942616e3..5b91f4bc 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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)) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 79d318e1..b6a6b49b 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -227,7 +227,15 @@ 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: @@ -235,14 +243,23 @@ def make_dict_unstructure_fn_from_attrs( 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 [] diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index e7e1c438..e6168696 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -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 @@ -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}}