Skip to content
Open
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

- 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+.
Expand Down
4 changes: 3 additions & 1 deletion src/cattrs/preconf/orjson.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/test_preconf_orjson_keys.py
Original file line number Diff line number Diff line change
@@ -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
}