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."""