Skip to content
Merged
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
3 changes: 3 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## 25.4.0 (UNRELEASED)

- Add the {mod}`tomllib <cattrs.preconf.tomllib>` preconf converter.
See [here](https://catt.rs/en/latest/preconf.html#tomllib) for details.
([#716](https://github.com/python-attrs/cattrs/pull/716))
- Fix structuring of nested generic classes with stringified annotations.
([#688](https://github.com/python-attrs/cattrs/pull/688))
- Python 3.9 is no longer supported, as it is end-of-life. Use previous versions on this Python version.
Expand Down
8 changes: 8 additions & 0 deletions docs/cattrs.preconf.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ cattrs.preconf.tomlkit module
:undoc-members:
:show-inheritance:

cattrs.preconf.tomllib module
-----------------------------

.. automodule:: cattrs.preconf.tomllib
:members:
:undoc-members:
:show-inheritance:

cattrs.preconf.ujson module
---------------------------

Expand Down
19 changes: 19 additions & 0 deletions docs/preconf.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ _msgspec_ doesn't support PyPy.

```


## _tomllib_

Found at {mod}`cattrs.preconf.tomllib`.

Bytes are serialized as base 85 strings. Sets are serialized as lists, and deserialized back into sets.
Tuples are serialized as lists, and deserialized back into tuples.
_tomllib_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization.

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

On Python 3.10, the [_tomli_](https://pypi.org/project/tomli/) library is required.

```{versionadded} NEXT

```


## _ujson_

Found at {mod}`cattrs.preconf.ujson`.
Expand Down Expand Up @@ -208,3 +226,4 @@ Tuples are serialized as lists, and deserialized back into tuples.
_tomlkit_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization.
[`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.


4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ bson = [
msgspec = [
"msgspec>=0.19.0; implementation_name == \"cpython\"",
]
tomllib = [
"tomli>=1.1.0; python_version < '3.11'",
"tomli-w>=1.1.0",
]

[tool.pytest.ini_options]
addopts = "-l --benchmark-sort=fullname --benchmark-warmup=true --benchmark-warmup-iterations=5 --benchmark-group-by=fullname"
Expand Down
94 changes: 94 additions & 0 deletions src/cattrs/preconf/tomllib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Preconfigured converters for tomllib."""

from base64 import b85decode, b85encode
from collections.abc import Set
from datetime import date, datetime
from enum import Enum
from operator import attrgetter
from typing import Any, TypeVar, Union

try:
from tomllib import loads
except ImportError:
from tomli import loads

try:
from tomli_w import dumps
except ImportError: # pragma: nocover
dumps = None

from .._compat import is_mapping, is_subclass
from ..converters import BaseConverter, Converter
from ..fns import identity
from ..strategies import configure_union_passthrough
from . import validate_datetime, wrap

__all__ = ["TomllibConverter", "configure_converter", "make_converter"]

T = TypeVar("T")
_enum_value_getter = attrgetter("_value_")


class TomllibConverter(Converter):
"""A converter subclass specialized for tomllib."""

if dumps is not None:

def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str:
return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs)

def loads(self, data: str, cl: type[T], **kwargs: Any) -> T:
return self.structure(loads(data, **kwargs), cl)


def configure_converter(converter: BaseConverter):
"""
Configure the converter for use with the tomllib library.

* bytes are serialized as base85 strings
* sets are serialized as lists
* tuples are serializas as lists
* mapping keys are coerced into strings when unstructuring
* dates and datetimes are left for tomllib to handle
"""
converter.register_structure_hook(bytes, lambda v, _: b85decode(v))
converter.register_unstructure_hook(
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
)

@converter.register_unstructure_hook_factory(is_mapping)
def gen_unstructure_mapping(cl: Any, unstructure_to=None):
key_handler = str
args = getattr(cl, "__args__", None)
if args:
if is_subclass(args[0], str):
key_handler = _enum_value_getter if is_subclass(args[0], Enum) else None
elif is_subclass(args[0], bytes):

def key_handler(k: bytes):
return b85encode(k).decode("utf8")

return converter.gen_unstructure_mapping(
cl, unstructure_to=unstructure_to, key_handler=key_handler
)

converter.register_unstructure_hook(datetime, identity)
converter.register_structure_hook(datetime, validate_datetime)
converter.register_unstructure_hook(date, identity)
converter.register_structure_hook(
date, lambda v, _: v if isinstance(v, date) else date.fromisoformat(v)
)
configure_union_passthrough(Union[str, int, float, bool], converter)


@wrap(TomllibConverter)
def make_converter(*args: Any, **kwargs: Any) -> TomllibConverter:
kwargs["unstruct_collection_overrides"] = {
Set: list,
tuple: list,
**kwargs.get("unstruct_collection_overrides", {}),
}
res = TomllibConverter(*args, **kwargs)
configure_converter(res)

return res
55 changes: 55 additions & 0 deletions tests/test_preconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from cattrs.preconf.msgpack import make_converter as msgpack_make_converter
from cattrs.preconf.pyyaml import make_converter as pyyaml_make_converter
from cattrs.preconf.tomlkit import make_converter as tomlkit_make_converter
from cattrs.preconf.tomllib import make_converter as tomllib_make_converter
from cattrs.preconf.ujson import make_converter as ujson_make_converter

NO_MSGSPEC: Final = python_implementation() == "PyPy"
Expand Down Expand Up @@ -127,6 +128,10 @@ def everythings(
allow_datetime_microseconds=True,
key_blacklist_characters=[],
):
"""
Args:
min_key_length: The minimum key length for keys in dicts.
"""
key_text = text(
characters(
blacklist_categories=("Cs",) if allow_null_bytes_in_keys else ("Cs", "Cc"),
Expand Down Expand Up @@ -784,6 +789,51 @@ class A:
assert converter.loads(data, A) == A(date(2023, 1, 1))


@given(
everythings(
allow_null_bytes_in_keys=False,
key_blacklist_characters=['"', "\\"],
allow_control_characters_in_values=False,
),
booleans(),
)
def test_tomllib_converter(everything: Everything, detailed_validation: bool):

converter = tomllib_make_converter(detailed_validation=detailed_validation)
raw = converter.dumps(everything)

assert converter.loads(raw, Everything) == everything


@given(
everythings(
allow_null_bytes_in_keys=False,
key_blacklist_characters=['"', "\\"],
allow_control_characters_in_values=False,
),
booleans(),
)
def test_tomllib_converter_dumps(everything: Everything, detailed_validation: bool):
converter = tomllib_make_converter(detailed_validation=detailed_validation)
raw = converter.dumps(everything)
assert converter.loads(raw, Everything) == everything


@given(
everythings(
allow_null_bytes_in_keys=False,
key_blacklist_characters=['"', "\\"],
allow_control_characters_in_values=False,
)
)
def test_tomllib_converter_unstruct_collection_overrides(everything: Everything):
converter = tomllib_make_converter(unstruct_collection_overrides={Set: sorted})
raw = converter.unstructure(everything)
assert raw["a_set"] == sorted(raw["a_set"])
assert raw["a_mutable_set"] == sorted(raw["a_mutable_set"])
assert raw["a_frozenset"] == sorted(raw["a_frozenset"])


@given(everythings(min_int=-9223372036854775808, max_int=18446744073709551615))
def test_cbor2(everything: Everything):
from cbor2 import dumps as cbor2_dumps
Expand Down Expand Up @@ -951,3 +1001,8 @@ def test_literal_dicts_msgspec():
from cattrs.preconf.msgspec import make_converter as msgspec_make_converter

test_literal_dicts(msgspec_make_converter)


def test_literal_dicts_tomllib():
"""Dicts with keys that aren't subclasses of `type` work."""
test_literal_dicts(tomllib_make_converter)
19 changes: 17 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading