diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..1858b37b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Ensure the orjson converter stringifies mapping keys after applying their unstructure hooks, including union and `Any` keys. + - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. ([#238](https://github.com/python-attrs/cattrs/issues/238)) - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+. diff --git a/src/cattrs/preconf/orjson.py b/src/cattrs/preconf/orjson.py index 27b0bc4a..3901b6ba 100644 --- a/src/cattrs/preconf/orjson.py +++ b/src/cattrs/preconf/orjson.py @@ -80,7 +80,9 @@ def key_handler(v): kh = converter.get_unstructure_hook(args[0]) if kh != identity: - key_handler = kh + + def key_handler(v): + return str(kh(v)) return converter.gen_unstructure_mapping( cl, unstructure_to=unstructure_to, key_handler=key_handler diff --git a/tests/test_preconf_orjson_keys.py b/tests/test_preconf_orjson_keys.py new file mode 100644 index 00000000..9ca769fc --- /dev/null +++ b/tests/test_preconf_orjson_keys.py @@ -0,0 +1,60 @@ +"""Mapping keys remain strings after their configured unstructure hooks.""" + +from base64 import b85encode +from enum import Enum +from typing import Any, Union + +import pytest +from attrs import define + +orjson = pytest.importorskip("orjson") +make_converter = pytest.importorskip("cattrs.preconf.orjson").make_converter + + +@pytest.mark.parametrize("detailed_validation", [True, False]) +@pytest.mark.parametrize("key_type", [Union[str, int], str | int, Any, int]) +def test_mapping_keys_are_stringified(key_type, detailed_validation): + converter = make_converter(detailed_validation=detailed_validation) + data = {1: 7} if key_type is int else {1: 7, "second": 9} + expected = {str(key): value for key, value in data.items()} + target = dict[key_type, int] + assert converter.unstructure(data, unstructure_as=target) == expected + assert orjson.loads(converter.dumps(data, unstructure_as=target)) == expected + + +def test_union_mapping_in_attrs_field(): + @define + class Record: + entries: dict[Union[str, int], int] + + converter = make_converter() + assert orjson.loads(converter.dumps(Record({1: 7}))) == {"entries": {"1": 7}} + + +def test_string_enum_keys_keep_values(): + class Key(str, Enum): + FIRST = "first" + + converter = make_converter() + assert orjson.loads( + converter.dumps({Key.FIRST: 7}, unstructure_as=dict[Key, int]) + ) == {"first": 7} + + +def test_bytes_keys_keep_base85_hook(): + converter = make_converter() + assert orjson.loads( + converter.dumps({b"key": 7}, unstructure_as=dict[bytes, int]) + ) == {b85encode(b"key").decode("utf8"): 7} + + +@pytest.mark.parametrize("hook_result", [42, "custom"]) +def test_custom_key_hook_is_applied_before_stringification(hook_result): + class Key: + pass + + converter = make_converter() + converter.register_unstructure_hook(Key, lambda _: hook_result) + assert orjson.loads(converter.dumps({Key(): 7}, unstructure_as=dict[Key, int])) == { + str(hook_result): 7 + }