From f0c5a80d2f5b1329eff182c89936f72f24470fd1 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:32:07 -0400 Subject: [PATCH] Sort extra keys when formatting ForbiddenExtraKeysError `ForbiddenExtraKeysError.extra_fields` is a `set`, and `format_exception` in src/cattrs/v.py joined it directly, so `transform_error` put the extra keys in whatever order that set happened to iterate in. String hashing is randomized per process, so the message text changed from run to run once more than one extra key was present, which breaks anything that compares or snapshots the output of `transform_error`. `ForbiddenExtraKeysError.__str__` in src/cattrs/errors.py already sorts the same set, so the two renderings of one error disagreed as well. Sort the keys in `format_exception` too, and add a regression test with five extra keys, since the existing test only used one and could not observe the ordering. --- HISTORY.md | 5 +++++ src/cattrs/v.py | 2 +- tests/test_v.py | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 9ac64a6f..cafbeec2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -11,6 +11,11 @@ The third number is for emergencies when we need to start branches for older rel Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md). +## NEXT (UNRELEASED) + +- Fix {func}`transform_error ` listing the extra keys of a `ForbiddenExtraKeysError` in set iteration order, which made the message differ between runs; the keys are now sorted, like the error's own `__str__` already sorts them. + ([#776](https://github.com/python-attrs/cattrs/pull/776)) + ## 26.2.0 (2026-09-08) - Fix the `msgpack` and `cbor2` converters unstructuring naive datetimes as local time, which made the serialized value depend on the timezone of the machine doing the unstructuring; naive datetimes are now assumed to be UTC, matching what the structure hooks already read back. diff --git a/src/cattrs/v.py b/src/cattrs/v.py index 134c990f..78361aab 100644 --- a/src/cattrs/v.py +++ b/src/cattrs/v.py @@ -41,7 +41,7 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str: tn = type.__name__ if hasattr(type, "__name__") else repr(type) res = f"invalid value for type, expected {tn}" elif isinstance(exc, ForbiddenExtraKeysError): - res = f"extra fields found ({', '.join(exc.extra_fields)})" + res = f"extra fields found ({', '.join(sorted(exc.extra_fields))})" elif isinstance(exc, AttributeError) and exc.args[0].endswith( "object has no attribute 'items'" ): diff --git a/tests/test_v.py b/tests/test_v.py index 513027c6..4456b60e 100644 --- a/tests/test_v.py +++ b/tests/test_v.py @@ -106,6 +106,27 @@ class C: ] +def test_extra_keys_are_sorted(c: Converter) -> None: + """Extra keys are reported in a stable order. + + `ForbiddenExtraKeysError.extra_fields` is a set, so the message used to + depend on the iteration order of that set, which differs between processes. + """ + + @define + class C: + a: int + + c.register_structure_hook( + C, make_dict_structure_fn(C, c, _cattrs_forbid_extra_keys=True) + ) + + with raises(Exception) as exc_info: + c.structure({"a": 1, "e": 2, "c": 3, "b": 4, "d": 5}, C) + + assert transform_error(exc_info.value) == ["extra fields found (b, c, d, e) @ $"] + + def test_untyped_class_errors(c: Converter) -> None: """Errors on untyped attrs classes transform correctly."""