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

- The standard library JSON and ujson converters now support `bytes_encoding="base64"` in `make_converter` and `configure_converter`, while retaining Base85 as the default.
([#552](https://github.com/python-attrs/cattrs/issues/552))
- Fix the msgspec converter passing typed deques directly to the encoder, which cannot serialize them. Deques are now unstructured into lists, including when nested in attrs classes or dataclasses.
([#818](https://github.com/python-attrs/cattrs/issues/818) [#819](https://github.com/python-attrs/cattrs/pull/819))

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ _cattrs_ comes with pre-configured converters for a number of serialization libr

For details, see the [cattrs.preconf package](https://catt.rs/en/stable/preconf.html).

The standard library JSON and UltraJSON converters support optional Base64 encoding for bytes with `make_converter(bytes_encoding="base64")`.


## Design Decisions

Expand Down
25 changes: 25 additions & 0 deletions docs/preconf.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ Found at {mod}`cattrs.preconf.json`.

Bytes are serialized as base 85 strings. Counters are serialized as dictionaries. Sets are serialized as lists, and deserialized back into sets. `datetime` s and `date` s are serialized as ISO 8601 strings.

Pass `bytes_encoding="base64"` to {func}`cattrs.preconf.json.make_converter` or
{func}`cattrs.preconf.json.configure_converter` to use standard Base64 instead.
Use the same encoding when serializing and deserializing.

```{doctest}
>>> from cattrs.preconf.json import make_converter
>>> converter = make_converter(bytes_encoding="base64")
>>> converter.dumps(b"hello")
'"aGVsbG8="'
>>> converter.loads('"aGVsbG8="', bytes)
b'hello'
```

```{versionadded} NEXT
The `bytes_encoding` option accepts `"base85"` (the default) and `"base64"`.
```


## _orjson_

Expand Down Expand Up @@ -156,6 +173,14 @@ Found at {mod}`cattrs.preconf.ujson`.

Bytes are serialized as base 85 strings. Sets are serialized as lists, and deserialized back into sets. `datetime` s and `date` s are serialized as ISO 8601 strings.

Pass `bytes_encoding="base64"` to {func}`cattrs.preconf.ujson.make_converter` or
{func}`cattrs.preconf.ujson.configure_converter` to use standard Base64 instead,
as with the standard library JSON converter. Use the same encoding when serializing and deserializing.

```{versionadded} NEXT
The `bytes_encoding` option accepts `"base85"` (the default) and `"base64"`.
```

_ujson_ doesn't support integers less than -9223372036854775808, and greater than 9223372036854775807, nor does it support `float('inf')`.


Expand Down
88 changes: 67 additions & 21 deletions src/cattrs/preconf/json.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
"""Preconfigured converters for the stdlib json."""

from base64 import b85decode, b85encode
from collections.abc import Set
from base64 import b64decode, b64encode, b85decode, b85encode
from collections.abc import Callable, Mapping, Set
from datetime import date, datetime
from json import dumps, loads
from typing import Any, TypeVar, Union
from typing import Any, Literal, TypeVar, Union

from typing_extensions import TypeForm

from .._compat import Counter
from ..converters import BaseConverter, Converter
from ..fns import identity
from ..converters import BaseConverter, Converter, UnstructureStrategy
from ..dispatch import HookFactory, StructureHook, UnstructureHook
from ..fns import identity, raise_error
from ..gen._consts import AttributeOverride
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap
from . import is_primitive_enum, literals_with_enums_unstructure_factory

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

Expand All @@ -34,11 +36,13 @@ def loads(self, data: Union[bytes, str], cl: TypeForm[T], **kwargs: Any) -> T:
return self.structure(loads(data, **kwargs), cl)


def configure_converter(converter: BaseConverter) -> None:
def configure_converter(
converter: BaseConverter, *, bytes_encoding: Literal["base85", "base64"] = "base85"
) -> None:
"""
Configure the converter for use with the stdlib json module.

* bytes are serialized as base85 strings
* bytes are serialized as base85 strings, or base64 if requested
* datetimes are serialized as ISO 8601
* counters are serialized as dicts
* sets are serialized as lists
Expand All @@ -48,11 +52,20 @@ def configure_converter(converter: BaseConverter) -> None:

.. versionchanged:: 24.2.0
Enums are left to the library to unstructure, speeding them up.

.. versionchanged:: NEXT
*bytes_encoding* selects base85 (the default) or base64 for bytes.
"""
converter.register_unstructure_hook(
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
)
converter.register_structure_hook(bytes, lambda v, _: b85decode(v))
encode: Callable[[bytes], bytes]
decode: Callable[[Union[str, bytes]], bytes]
if bytes_encoding == "base85":
encode, decode = b85encode, b85decode
elif bytes_encoding == "base64":
encode, decode = b64encode, b64decode
else:
raise ValueError("bytes_encoding must be 'base85' or 'base64'")
converter.register_unstructure_hook(bytes, lambda v: encode(v).decode("ascii"))
converter.register_structure_hook(bytes, lambda v, _: decode(v))
converter.register_unstructure_hook(datetime, lambda v: v.isoformat())
converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v))
converter.register_unstructure_hook(date, lambda v: v.isoformat())
Expand All @@ -64,14 +77,47 @@ def configure_converter(converter: BaseConverter) -> None:
configure_union_passthrough(Union[str, bool, int, float, None], converter)


@wrap(JsonConverter)
def make_converter(*args: Any, **kwargs: Any) -> JsonConverter:
kwargs["unstruct_collection_overrides"] = {
Set: list,
Counter: dict,
**kwargs.get("unstruct_collection_overrides", {}),
}
res = JsonConverter(*args, **kwargs)
configure_converter(res)
def make_converter(
dict_factory: Callable[[], Any] = dict,
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
omit_if_default: bool = False,
forbid_extra_keys: bool = False,
type_overrides: Mapping[type, AttributeOverride] = {},
unstruct_collection_overrides: Mapping[type, UnstructureHook] = {},
prefer_attrib_converters: bool = False,
detailed_validation: bool = True,
unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity,
structure_fallback_factory: HookFactory[StructureHook] = lambda t: raise_error(
None, t
),
use_alias: bool = False,
*,
bytes_encoding: Literal["base85", "base64"] = "base85",
) -> JsonConverter:
"""Create a JSON converter, optionally using base64 for bytes.

All other parameters are passed to :class:`cattrs.Converter`.

.. versionchanged:: NEXT
*bytes_encoding* selects base85 (the default) or base64 for bytes.
"""
res = JsonConverter(
dict_factory=dict_factory,
unstruct_strat=unstruct_strat,
omit_if_default=omit_if_default,
forbid_extra_keys=forbid_extra_keys,
type_overrides=type_overrides,
unstruct_collection_overrides={
Set: list,
Counter: dict,
**unstruct_collection_overrides,
},
prefer_attrib_converters=prefer_attrib_converters,
detailed_validation=detailed_validation,
unstructure_fallback_factory=unstructure_fallback_factory,
structure_fallback_factory=structure_fallback_factory,
use_alias=use_alias,
)
configure_converter(res, bytes_encoding=bytes_encoding)

return res
83 changes: 63 additions & 20 deletions src/cattrs/preconf/ujson.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""Preconfigured converters for ujson."""

from base64 import b85decode, b85encode
from collections.abc import Set
from base64 import b64decode, b64encode, b85decode, b85encode
from collections.abc import Callable, Mapping, Set
from datetime import date, datetime
from typing import Any, AnyStr, TypeVar, Union
from typing import Any, AnyStr, Literal, TypeVar, Union

from typing_extensions import TypeForm
from ujson import dumps, loads

from ..converters import BaseConverter, Converter
from ..fns import identity
from ..converters import BaseConverter, Converter, UnstructureStrategy
from ..dispatch import HookFactory, StructureHook, UnstructureHook
from ..fns import identity, raise_error
from ..gen._consts import AttributeOverride
from ..literals import is_literal_containing_enums
from ..strategies import configure_union_passthrough
from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap
from . import is_primitive_enum, literals_with_enums_unstructure_factory

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

Expand All @@ -33,22 +35,33 @@ def loads(self, data: AnyStr, cl: TypeForm[T], **kwargs: Any) -> T:
return self.structure(loads(data, **kwargs), cl)


def configure_converter(converter: BaseConverter):
def configure_converter(
converter: BaseConverter, *, bytes_encoding: Literal["base85", "base64"] = "base85"
) -> None:
"""
Configure the converter for use with the ujson library.

* bytes are serialized as base64 strings
* bytes are serialized as base85 strings, or base64 if requested
* datetimes are serialized as ISO 8601
* sets are serialized as lists
* string and int enums are passed through when unstructuring

.. versionchanged:: 24.2.0
Enums are left to the library to unstructure, speeding them up.

.. versionchanged:: NEXT
*bytes_encoding* selects base85 (the default) or base64 for bytes.
"""
converter.register_unstructure_hook(
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
)
converter.register_structure_hook(bytes, lambda v, _: b85decode(v))
encode: Callable[[bytes], bytes]
decode: Callable[[Union[str, bytes]], bytes]
if bytes_encoding == "base85":
encode, decode = b85encode, b85decode
elif bytes_encoding == "base64":
encode, decode = b64encode, b64decode
else:
raise ValueError("bytes_encoding must be 'base85' or 'base64'")
converter.register_unstructure_hook(bytes, lambda v: encode(v).decode("ascii"))
converter.register_structure_hook(bytes, lambda v, _: decode(v))

converter.register_unstructure_hook(datetime, lambda v: v.isoformat())
converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v))
Expand All @@ -61,13 +74,43 @@ def configure_converter(converter: BaseConverter):
configure_union_passthrough(Union[str, bool, int, float, None], converter)


@wrap(UjsonConverter)
def make_converter(*args: Any, **kwargs: Any) -> UjsonConverter:
kwargs["unstruct_collection_overrides"] = {
Set: list,
**kwargs.get("unstruct_collection_overrides", {}),
}
res = UjsonConverter(*args, **kwargs)
configure_converter(res)
def make_converter(
dict_factory: Callable[[], Any] = dict,
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
omit_if_default: bool = False,
forbid_extra_keys: bool = False,
type_overrides: Mapping[type, AttributeOverride] = {},
unstruct_collection_overrides: Mapping[type, UnstructureHook] = {},
prefer_attrib_converters: bool = False,
detailed_validation: bool = True,
unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity,
structure_fallback_factory: HookFactory[StructureHook] = lambda t: raise_error(
None, t
),
use_alias: bool = False,
*,
bytes_encoding: Literal["base85", "base64"] = "base85",
) -> UjsonConverter:
"""Create a ujson converter, optionally using base64 for bytes.

All other parameters are passed to :class:`cattrs.Converter`.

.. versionchanged:: NEXT
*bytes_encoding* selects base85 (the default) or base64 for bytes.
"""
res = UjsonConverter(
dict_factory=dict_factory,
unstruct_strat=unstruct_strat,
omit_if_default=omit_if_default,
forbid_extra_keys=forbid_extra_keys,
type_overrides=type_overrides,
unstruct_collection_overrides={Set: list, **unstruct_collection_overrides},
prefer_attrib_converters=prefer_attrib_converters,
detailed_validation=detailed_validation,
unstructure_fallback_factory=unstructure_fallback_factory,
structure_fallback_factory=structure_fallback_factory,
use_alias=use_alias,
)
configure_converter(res, bytes_encoding=bytes_encoding)

return res
Loading