Skip to content

Commit 1ac3ce8

Browse files
authored
Introduce tomllib (#715)
* Introduce tomllib * Skip coverage
1 parent 4c20ab7 commit 1ac3ce8

File tree

7 files changed

+200
-2
lines changed

7 files changed

+200
-2
lines changed

HISTORY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1313

1414
## 25.4.0 (UNRELEASED)
1515

16+
- Add the {mod}`tomllib <cattrs.preconf.tomllib>` preconf converter.
17+
See [here](https://catt.rs/en/latest/preconf.html#tomllib) for details.
18+
([#716](https://github.com/python-attrs/cattrs/pull/716))
1619
- Fix structuring of nested generic classes with stringified annotations.
1720
([#688](https://github.com/python-attrs/cattrs/pull/688))
1821
- Python 3.9 is no longer supported, as it is end-of-life. Use previous versions on this Python version.

docs/cattrs.preconf.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ cattrs.preconf.tomlkit module
7373
:undoc-members:
7474
:show-inheritance:
7575

76+
cattrs.preconf.tomllib module
77+
-----------------------------
78+
79+
.. automodule:: cattrs.preconf.tomllib
80+
:members:
81+
:undoc-members:
82+
:show-inheritance:
83+
7684
cattrs.preconf.ujson module
7785
---------------------------
7886

docs/preconf.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ _msgspec_ doesn't support PyPy.
132132
133133
```
134134

135+
136+
## _tomllib_
137+
138+
Found at {mod}`cattrs.preconf.tomllib`.
139+
140+
Bytes are serialized as base 85 strings. Sets are serialized as lists, and deserialized back into sets.
141+
Tuples are serialized as lists, and deserialized back into tuples.
142+
_tomllib_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization.
143+
144+
Writing is supported via the [_tomli-w_](https://pypi.org/project/tomli-w/) library; this needs to be installed separately (or depend on the `cattrs[tomllib]` extra).
145+
146+
On Python 3.10, the [_tomli_](https://pypi.org/project/tomli/) library is required.
147+
148+
```{versionadded} NEXT
149+
150+
```
151+
152+
135153
## _ujson_
136154

137155
Found at {mod}`cattrs.preconf.ujson`.
@@ -208,3 +226,4 @@ Tuples are serialized as lists, and deserialized back into tuples.
208226
_tomlkit_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization.
209227
[`date`](https://docs.python.org/3/library/datetime.html#datetime.date) and [`datetime`](https://docs.python.org/3/library/datetime.html#datetime.datetime) objects are passed through to be unstructured by _tomlkit_ itself.
210228

229+

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ bson = [
9595
msgspec = [
9696
"msgspec>=0.19.0; implementation_name == \"cpython\"",
9797
]
98+
tomllib = [
99+
"tomli>=1.1.0; python_version < '3.11'",
100+
"tomli-w>=1.1.0",
101+
]
98102

99103
[tool.pytest.ini_options]
100104
addopts = "-l --benchmark-sort=fullname --benchmark-warmup=true --benchmark-warmup-iterations=5 --benchmark-group-by=fullname"

src/cattrs/preconf/tomllib.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Preconfigured converters for tomllib."""
2+
3+
from base64 import b85decode, b85encode
4+
from collections.abc import Set
5+
from datetime import date, datetime
6+
from enum import Enum
7+
from operator import attrgetter
8+
from typing import Any, TypeVar, Union
9+
10+
try:
11+
from tomllib import loads
12+
except ImportError:
13+
from tomli import loads
14+
15+
try:
16+
from tomli_w import dumps
17+
except ImportError: # pragma: nocover
18+
dumps = None
19+
20+
from .._compat import is_mapping, is_subclass
21+
from ..converters import BaseConverter, Converter
22+
from ..fns import identity
23+
from ..strategies import configure_union_passthrough
24+
from . import validate_datetime, wrap
25+
26+
__all__ = ["TomllibConverter", "configure_converter", "make_converter"]
27+
28+
T = TypeVar("T")
29+
_enum_value_getter = attrgetter("_value_")
30+
31+
32+
class TomllibConverter(Converter):
33+
"""A converter subclass specialized for tomllib."""
34+
35+
if dumps is not None:
36+
37+
def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str:
38+
return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs)
39+
40+
def loads(self, data: str, cl: type[T], **kwargs: Any) -> T:
41+
return self.structure(loads(data, **kwargs), cl)
42+
43+
44+
def configure_converter(converter: BaseConverter):
45+
"""
46+
Configure the converter for use with the tomllib library.
47+
48+
* bytes are serialized as base85 strings
49+
* sets are serialized as lists
50+
* tuples are serializas as lists
51+
* mapping keys are coerced into strings when unstructuring
52+
* dates and datetimes are left for tomllib to handle
53+
"""
54+
converter.register_structure_hook(bytes, lambda v, _: b85decode(v))
55+
converter.register_unstructure_hook(
56+
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
57+
)
58+
59+
@converter.register_unstructure_hook_factory(is_mapping)
60+
def gen_unstructure_mapping(cl: Any, unstructure_to=None):
61+
key_handler = str
62+
args = getattr(cl, "__args__", None)
63+
if args:
64+
if is_subclass(args[0], str):
65+
key_handler = _enum_value_getter if is_subclass(args[0], Enum) else None
66+
elif is_subclass(args[0], bytes):
67+
68+
def key_handler(k: bytes):
69+
return b85encode(k).decode("utf8")
70+
71+
return converter.gen_unstructure_mapping(
72+
cl, unstructure_to=unstructure_to, key_handler=key_handler
73+
)
74+
75+
converter.register_unstructure_hook(datetime, identity)
76+
converter.register_structure_hook(datetime, validate_datetime)
77+
converter.register_unstructure_hook(date, identity)
78+
converter.register_structure_hook(
79+
date, lambda v, _: v if isinstance(v, date) else date.fromisoformat(v)
80+
)
81+
configure_union_passthrough(Union[str, int, float, bool], converter)
82+
83+
84+
@wrap(TomllibConverter)
85+
def make_converter(*args: Any, **kwargs: Any) -> TomllibConverter:
86+
kwargs["unstruct_collection_overrides"] = {
87+
Set: list,
88+
tuple: list,
89+
**kwargs.get("unstruct_collection_overrides", {}),
90+
}
91+
res = TomllibConverter(*args, **kwargs)
92+
configure_converter(res)
93+
94+
return res

tests/test_preconf.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from cattrs.preconf.msgpack import make_converter as msgpack_make_converter
5151
from cattrs.preconf.pyyaml import make_converter as pyyaml_make_converter
5252
from cattrs.preconf.tomlkit import make_converter as tomlkit_make_converter
53+
from cattrs.preconf.tomllib import make_converter as tomllib_make_converter
5354
from cattrs.preconf.ujson import make_converter as ujson_make_converter
5455

5556
NO_MSGSPEC: Final = python_implementation() == "PyPy"
@@ -127,6 +128,10 @@ def everythings(
127128
allow_datetime_microseconds=True,
128129
key_blacklist_characters=[],
129130
):
131+
"""
132+
Args:
133+
min_key_length: The minimum key length for keys in dicts.
134+
"""
130135
key_text = text(
131136
characters(
132137
blacklist_categories=("Cs",) if allow_null_bytes_in_keys else ("Cs", "Cc"),
@@ -784,6 +789,51 @@ class A:
784789
assert converter.loads(data, A) == A(date(2023, 1, 1))
785790

786791

792+
@given(
793+
everythings(
794+
allow_null_bytes_in_keys=False,
795+
key_blacklist_characters=['"', "\\"],
796+
allow_control_characters_in_values=False,
797+
),
798+
booleans(),
799+
)
800+
def test_tomllib_converter(everything: Everything, detailed_validation: bool):
801+
802+
converter = tomllib_make_converter(detailed_validation=detailed_validation)
803+
raw = converter.dumps(everything)
804+
805+
assert converter.loads(raw, Everything) == everything
806+
807+
808+
@given(
809+
everythings(
810+
allow_null_bytes_in_keys=False,
811+
key_blacklist_characters=['"', "\\"],
812+
allow_control_characters_in_values=False,
813+
),
814+
booleans(),
815+
)
816+
def test_tomllib_converter_dumps(everything: Everything, detailed_validation: bool):
817+
converter = tomllib_make_converter(detailed_validation=detailed_validation)
818+
raw = converter.dumps(everything)
819+
assert converter.loads(raw, Everything) == everything
820+
821+
822+
@given(
823+
everythings(
824+
allow_null_bytes_in_keys=False,
825+
key_blacklist_characters=['"', "\\"],
826+
allow_control_characters_in_values=False,
827+
)
828+
)
829+
def test_tomllib_converter_unstruct_collection_overrides(everything: Everything):
830+
converter = tomllib_make_converter(unstruct_collection_overrides={Set: sorted})
831+
raw = converter.unstructure(everything)
832+
assert raw["a_set"] == sorted(raw["a_set"])
833+
assert raw["a_mutable_set"] == sorted(raw["a_mutable_set"])
834+
assert raw["a_frozenset"] == sorted(raw["a_frozenset"])
835+
836+
787837
@given(everythings(min_int=-9223372036854775808, max_int=18446744073709551615))
788838
def test_cbor2(everything: Everything):
789839
from cbor2 import dumps as cbor2_dumps
@@ -951,3 +1001,8 @@ def test_literal_dicts_msgspec():
9511001
from cattrs.preconf.msgspec import make_converter as msgspec_make_converter
9521002

9531003
test_literal_dicts(msgspec_make_converter)
1004+
1005+
1006+
def test_literal_dicts_tomllib():
1007+
"""Dicts with keys that aren't subclasses of `type` work."""
1008+
test_literal_dicts(tomllib_make_converter)

uv.lock

Lines changed: 17 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)