From 53280aeabadf2eed7bfa43a199310d9a057315f4 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Sun, 4 May 2025 15:40:55 +0300 Subject: [PATCH 01/16] Fix lint errors --- tests/test_generics.py | 2 +- tests/typed.py | 2 +- tests/typeddicts.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_generics.py b/tests/test_generics.py index 466c4134..374d2e32 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -161,7 +161,7 @@ class TClass2(Generic[T]): def test_raises_if_no_generic_params_supplied( - converter: Union[Converter, BaseConverter] + converter: Union[Converter, BaseConverter], ): data = TClass(1, "a") diff --git a/tests/typed.py b/tests/typed.py index 5ff4ea6f..96533fd9 100644 --- a/tests/typed.py +++ b/tests/typed.py @@ -275,7 +275,7 @@ def key(t): def _create_hyp_class_and_strat( - attrs_and_strategy: list[tuple[_CountingAttr, SearchStrategy[PosArg]]] + attrs_and_strategy: list[tuple[_CountingAttr, SearchStrategy[PosArg]]], ) -> SearchStrategy[tuple[type, SearchStrategy[PosArgs], SearchStrategy[KwArgs]]]: def key(t): return (t[0].default is not NOTHING, t[0].kw_only) diff --git a/tests/typeddicts.py b/tests/typeddicts.py index ba488010..36a87510 100644 --- a/tests/typeddicts.py +++ b/tests/typeddicts.py @@ -202,7 +202,7 @@ def simple_typeddicts_with_extra_keys( # The normal attributes are 2 characters or less. extra_keys = draw(sets(text(ascii_lowercase, min_size=3, max_size=3))) - success.update({k: 1 for k in extra_keys}) + success.update(dict.fromkeys(extra_keys, 1)) return cls, success, extra_keys From 9342b7d4b74eb0948eb78d7f9fc3cfd1f6c06e2f Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Sun, 4 May 2025 15:41:30 +0300 Subject: [PATCH 02/16] Enable partial tests for dev --- Makefile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 545b70d9..98299272 100644 --- a/Makefile +++ b/Makefile @@ -22,13 +22,13 @@ for line in sys.stdin: endef export PRINT_HELP_PYSCRIPT BROWSER := python -c "$$BROWSER_PYSCRIPT" +TESTS ?= tests help: @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts - clean-build: ## remove build artifacts rm -fr build/ rm -fr dist/ @@ -51,9 +51,8 @@ lint: ## check style with ruff and black pdm run ruff check src/ tests bench pdm run black --check src tests docs/conf.py -test: ## run tests quickly with the default Python - pdm run pytest -x --ff -n auto tests - +test: ## run tests quickly with the default Python; pass TESTS= for specific path + pdm run pytest -x --ff $(if $(filter $(TESTS),tests),-n auto ,)$(TESTS) test-all: ## run tests on every Python version with tox tox From 52517828c6f5498821e504d68456653b3fd3942a Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Tue, 6 May 2025 18:50:51 +0300 Subject: [PATCH 03/16] Move hook-related types from cattrs.dispatch to cattrs.types to avoid circular import from cattrs.fns (type-annotated function will be added there) --- src/cattrs/cols.py | 2 +- src/cattrs/converters.py | 18 +++++++++--------- src/cattrs/dispatch.py | 12 +----------- src/cattrs/gen/__init__.py | 3 +-- src/cattrs/gen/_shared.py | 2 +- src/cattrs/preconf/__init__.py | 3 ++- src/cattrs/preconf/bson.py | 2 +- src/cattrs/typealiases.py | 2 +- src/cattrs/types.py | 20 ++++++++++++++++++++ 9 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index 0b578eb0..3eb6b9a8 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -28,7 +28,6 @@ is_subclass, ) from ._compat import is_mutable_set as is_set -from .dispatch import StructureHook, UnstructureHook from .errors import IterableValidationError, IterableValidationNote from .fns import identity from .gen import ( @@ -41,6 +40,7 @@ mapping_unstructure_factory, ) from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory +from .types import StructureHook, UnstructureHook if TYPE_CHECKING: from .converters import BaseConverter diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index d5ec1250..65b59913 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -63,15 +63,7 @@ namedtuple_unstructure_factory, ) from .disambiguators import create_default_dis_func, is_supported_union -from .dispatch import ( - HookFactory, - MultiStrategyDispatch, - StructuredValue, - StructureHook, - TargetType, - UnstructuredValue, - UnstructureHook, -) +from .dispatch import MultiStrategyDispatch from .errors import ( IterableValidationError, IterableValidationNote, @@ -90,6 +82,14 @@ from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn from .literals import is_literal_containing_enums +from .types import ( + HookFactory, + StructuredValue, + StructureHook, + TargetType, + UnstructuredValue, + UnstructureHook, +) from .typealiases import ( get_type_alias_base, is_type_alias, diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index f98dc51d..8f1db604 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -5,22 +5,12 @@ from attrs import Factory, define -from ._compat import TypeAlias from .fns import Predicate +from .types import Hook, HookFactory, TargetType if TYPE_CHECKING: from .converters import BaseConverter -TargetType: TypeAlias = Any -UnstructuredValue: TypeAlias = Any -StructuredValue: TypeAlias = Any - -StructureHook: TypeAlias = Callable[[UnstructuredValue, TargetType], StructuredValue] -UnstructureHook: TypeAlias = Callable[[StructuredValue], UnstructuredValue] - -Hook = TypeVar("Hook", StructureHook, UnstructureHook) -HookFactory: TypeAlias = Callable[[TargetType], Hook] - @define class _DispatchNotFound: diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 3afa3b9b..d1a61dd6 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -19,7 +19,6 @@ is_generic, ) from .._generics import deep_copy_with -from ..dispatch import UnstructureHook from ..errors import ( AttributeValidationNote, ClassValidationError, @@ -29,7 +28,7 @@ StructureHandlerNotFoundError, ) from ..fns import identity -from ..types import SimpleStructureHook +from ..types import SimpleStructureHook, UnstructureHook from ._consts import AttributeOverride, already_generating, neutral from ._generics import generate_mapping from ._lc import generate_unique_filename diff --git a/src/cattrs/gen/_shared.py b/src/cattrs/gen/_shared.py index 904c7744..fd553ceb 100644 --- a/src/cattrs/gen/_shared.py +++ b/src/cattrs/gen/_shared.py @@ -5,9 +5,9 @@ from attrs import NOTHING, Attribute, Factory from .._compat import is_bare_final -from ..dispatch import StructureHook from ..errors import StructureHandlerNotFoundError from ..fns import raise_error +from ..types import StructureHook if TYPE_CHECKING: from ..converters import BaseConverter diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index 1b12ef93..c51c91f5 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -4,8 +4,9 @@ from typing import Any, Callable, TypeVar, get_args from .._compat import is_subclass -from ..converters import Converter, UnstructureHook +from ..converters import Converter from ..fns import identity +from ..types import UnstructureHook if sys.version_info[:2] < (3, 10): from typing_extensions import ParamSpec diff --git a/src/cattrs/preconf/bson.py b/src/cattrs/preconf/bson.py index 49574893..bdbe46d5 100644 --- a/src/cattrs/preconf/bson.py +++ b/src/cattrs/preconf/bson.py @@ -10,10 +10,10 @@ from .._compat import is_mapping, is_subclass from ..cols import mapping_structure_factory from ..converters import BaseConverter, Converter -from ..dispatch import StructureHook from ..fns import identity from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough +from ..types import StructureHook from . import ( is_primitive_enum, literals_with_enums_unstructure_factory, diff --git a/src/cattrs/typealiases.py b/src/cattrs/typealiases.py index d3a20c48..d902de16 100644 --- a/src/cattrs/typealiases.py +++ b/src/cattrs/typealiases.py @@ -7,8 +7,8 @@ from ._compat import is_generic from ._generics import deep_copy_with -from .dispatch import StructureHook from .gen._generics import generate_mapping +from .types import StructureHook if TYPE_CHECKING: from .converters import BaseConverter diff --git a/src/cattrs/types.py b/src/cattrs/types.py index a864cb90..52094f18 100644 --- a/src/cattrs/types.py +++ b/src/cattrs/types.py @@ -1,10 +1,30 @@ from typing import Protocol, TypeVar __all__ = ["SimpleStructureHook"] +__all__ = [ + "Hook", + "HookFactory", + "SimpleStructureHook", + "StructuredValue", + "StructureHook", + "TargetType", + "UnstructuredValue", + "UnstructureHook", +] In = TypeVar("In") T = TypeVar("T") +TargetType: TypeAlias = Any +UnstructuredValue: TypeAlias = Any +StructuredValue: TypeAlias = Any + +StructureHook: TypeAlias = Callable[[UnstructuredValue, TargetType], StructuredValue] +UnstructureHook: TypeAlias = Callable[[StructuredValue], UnstructuredValue] + +Hook = TypeVar("Hook", StructureHook, UnstructureHook) +HookFactory: TypeAlias = Callable[[TargetType], Hook] + class SimpleStructureHook(Protocol[In, T]): """A structure hook with an optional (ignored) second argument.""" From 3de6772f9fde1423c8a95e287d6e9c5a44802233 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Tue, 6 May 2025 18:52:10 +0300 Subject: [PATCH 04/16] Add utility function --- src/cattrs/fns.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cattrs/fns.py b/src/cattrs/fns.py index 984c05eb..3dbdc410 100644 --- a/src/cattrs/fns.py +++ b/src/cattrs/fns.py @@ -1,9 +1,11 @@ """Useful internal functions.""" +from functools import wraps from typing import Any, Callable, NoReturn, TypeVar from ._compat import TypeAlias from .errors import StructureHandlerNotFoundError +from .types import StructuredValue, StructureHook, TargetType, UnstructuredValue T = TypeVar("T") @@ -16,6 +18,14 @@ def identity(obj: T) -> T: return obj +def bypass(target: type, structure_hook: StructureHook) -> StructureHook: + """Bypass structure hook when given object of target type.""" + @wraps(structure_hook) + def wrapper(obj: UnstructuredValue, cl: TargetType) -> StructuredValue: + return obj if type(obj) is target else structure_hook(obj, cl) + return wrapper + + def raise_error(_, cl: Any) -> NoReturn: """At the bottom of the condition stack, we explode if we can't handle it.""" msg = f"Unsupported type: {cl!r}. Register a structure hook for it." From 4ca75faa5f0c33bd36ad50f912da1338e65e2c7b Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Tue, 6 May 2025 18:53:27 +0300 Subject: [PATCH 05/16] Add leftovers from previous commits --- src/cattrs/preconf/msgspec.py | 4 ++-- src/cattrs/types.py | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index 6274a32b..4c5428d2 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -17,11 +17,11 @@ from .._compat import fields, get_args, get_origin, is_bare, is_mapping, is_sequence from ..cols import is_namedtuple from ..converters import BaseConverter, Converter -from ..dispatch import UnstructureHook from ..fns import identity from ..gen import make_hetero_tuple_unstructure_fn from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough +from ..types import UnstructureHook from . import literals_with_enums_unstructure_factory, wrap T = TypeVar("T") @@ -109,7 +109,7 @@ def configure_passthroughs(converter: Converter) -> None: ) converter.register_unstructure_hook_factory( is_dataclass, - partial(msgspec_attrs_unstructure_factory, msgspec_skips_private=False), + partial(msgspec_attrs_unstructure_factory, msgspec_skips_private=True), ) converter.register_unstructure_hook_factory( is_namedtuple, namedtuple_unstructure_factory diff --git a/src/cattrs/types.py b/src/cattrs/types.py index 52094f18..8657a87e 100644 --- a/src/cattrs/types.py +++ b/src/cattrs/types.py @@ -1,6 +1,6 @@ -from typing import Protocol, TypeVar +from collections.abc import Callable +from typing import Any, Protocol, TypeAlias, TypeVar -__all__ = ["SimpleStructureHook"] __all__ = [ "Hook", "HookFactory", @@ -8,6 +8,7 @@ "StructuredValue", "StructureHook", "TargetType", + "Unavailable", "UnstructuredValue", "UnstructureHook", ] @@ -30,3 +31,7 @@ class SimpleStructureHook(Protocol[In, T]): """A structure hook with an optional (ignored) second argument.""" def __call__(self, _: In, /, cl=...) -> T: ... + + +class Unavailable: + """Placeholder class to substitute missing converter class on import.""" From ab8c910c5d32d544b72c298e460c9f1ab6dfe2ad Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Tue, 6 May 2025 18:55:05 +0300 Subject: [PATCH 06/16] Expose typed helper to check preconfigured converter type without unnecessary imports --- src/cattrs/preconf/__init__.py | 1 + src/cattrs/preconf/_all.py | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/cattrs/preconf/_all.py diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index c51c91f5..f93691a3 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Any, Callable, TypeVar, get_args +from ._all import ConverterFormat, PreconfiguredConverter, has_type, is_preconfigured from .._compat import is_subclass from ..converters import Converter from ..fns import identity diff --git a/src/cattrs/preconf/_all.py b/src/cattrs/preconf/_all.py new file mode 100644 index 00000000..38bedeeb --- /dev/null +++ b/src/cattrs/preconf/_all.py @@ -0,0 +1,153 @@ +from collections.abc import Sequence +from typing import Literal, TypeAlias, TYPE_CHECKING, TypeIs, Union, get_args, overload + +from ..converters import Converter +from ..types import Unavailable + +if TYPE_CHECKING: + try: + from cattrs.preconf.bson import BsonConverter + except ModuleNotFoundError: + BsonConverter = Unavailable + + try: + from cattrs.preconf.cbor2 import Cbor2Converter + except ModuleNotFoundError: + Cbor2Converter = Unavailable + + from cattrs.preconf.json import JsonConverter + + try: + from cattrs.preconf.msgpack import MsgpackConverter + except ModuleNotFoundError: + MsgpackConverter = Unavailable + + try: + from cattrs.preconf.msgspec import MsgspecJsonConverter + except ModuleNotFoundError: + MsgspecJsonConverter = Unavailable + + try: + from cattrs.preconf.orjson import OrjsonConverter + except ModuleNotFoundError: + OrjsonConverter = Unavailable + + try: + from cattrs.preconf.pyyaml import PyyamlConverter + except ModuleNotFoundError: + PyyamlConverter = Unavailable + + try: + from cattrs.preconf.tomlkit import TomlkitConverter + except ModuleNotFoundError: + TomlkitConverter = Unavailable + + try: + from cattrs.preconf.ujson import UjsonConverter + except ModuleNotFoundError: + UjsonConverter = Unavailable + + PreconfiguredConverter: TypeAlias = Union[ + BsonConverter, + Cbor2Converter, + JsonConverter, + MsgpackConverter, + MsgspecJsonConverter, + OrjsonConverter, + PyyamlConverter, + TomlkitConverter, + UjsonConverter, + ] + +else: + PreconfiguredConverter: TypeAlias = Converter + +ConverterFormat: TypeAlias = Literal[ + "bson", "cbor2", "json", "msgpack", "msgspec", "orjson", "pyyaml", "tomlkit", + "ujson", +] + +C: TypeAlias = Converter | Unavailable + + +@overload +def has_type(converter: C, fmt: Literal["bson"]) -> TypeIs["BsonConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["cbor2"]) -> TypeIs["Cbor2Converter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["json"]) -> TypeIs["JsonConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["msgpack"]) -> TypeIs["MsgpackConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["msgspec"]) -> TypeIs["MsgspecJsonConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["orjson"]) -> TypeIs["OrjsonConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["pyyaml"]) -> TypeIs["PyyamlConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["tomlkit"]) -> TypeIs["TomlkitConverter"]: + ... +@overload +def has_type(converter: C, fmt: Literal["ujson"]) -> TypeIs["UjsonConverter"]: + ... +def has_type(converter: C, fmt: ConverterFormat | str | Sequence[ConverterFormat]) -> bool: + if isinstance(fmt, str): + fmt = (fmt,) + + if "bson" in fmt and converter.__class__.__name__ == "BsonConverter": + from .bson import BsonConverter + + return isinstance(converter, BsonConverter) + + if "cbor2" in fmt and converter.__class__.__name__ == "Cbor2Converter": + from .cbor2 import Cbor2Converter + + return isinstance(converter, Cbor2Converter) + + if "json" in fmt and converter.__class__.__name__ == "JsonConverter": + from .json import JsonConverter + + return isinstance(converter, JsonConverter) + + if "msgpack" in fmt and converter.__class__.__name__ == "MsgpackConverter": + from .msgpack import MsgpackConverter + + return isinstance(converter, MsgpackConverter) + + if "msgspec" in fmt and converter.__class__.__name__ == "MsgspecJsonConverter": + from .msgspec import MsgspecJsonConverter + + return isinstance(converter, MsgspecJsonConverter) + + if "orjson" in fmt and converter.__class__.__name__ == "OrjsonConverter": + from .orjson import OrjsonConverter + + return isinstance(converter, OrjsonConverter) + + if "pyyaml" in fmt and converter.__class__.__name__ == "PyyamlConverter": + from .pyyaml import PyyamlConverter + + return isinstance(converter, PyyamlConverter) + + if "tomlkit" in fmt and converter.__class__.__name__ == "TomlkitConverter": + from .tomlkit import TomlkitConverter + + return isinstance(converter, TomlkitConverter) + + if "ujson" in fmt and converter.__class__.__name__ == "UjsonConverter": + from .ujson import UjsonConverter + + return isinstance(converter, UjsonConverter) + + return False + + +def is_preconfigured(converter: Converter) -> bool: + return any(has_type(converter, fmt) for fmt in get_args(ConverterFormat)) From 1809cbaf3d59540a0a390b7feca5d2db111348de Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Tue, 6 May 2025 21:11:17 +0300 Subject: [PATCH 07/16] Fix debugging adjustment --- src/cattrs/preconf/msgspec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index 4c5428d2..8ab7e754 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -109,7 +109,7 @@ def configure_passthroughs(converter: Converter) -> None: ) converter.register_unstructure_hook_factory( is_dataclass, - partial(msgspec_attrs_unstructure_factory, msgspec_skips_private=True), + partial(msgspec_attrs_unstructure_factory, msgspec_skips_private=False), ) converter.register_unstructure_hook_factory( is_namedtuple, namedtuple_unstructure_factory From 2e472c0dfd6e0a4ec03239edc5106d31f1795fe1 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 10:16:35 +0300 Subject: [PATCH 08/16] Rename has_type to has_format --- src/cattrs/preconf/__init__.py | 2 +- src/cattrs/preconf/_all.py | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index f93691a3..e03d8b97 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Callable, TypeVar, get_args -from ._all import ConverterFormat, PreconfiguredConverter, has_type, is_preconfigured +from ._all import ConverterFormat, PreconfiguredConverter, has_format, is_preconfigured from .._compat import is_subclass from ..converters import Converter from ..fns import identity diff --git a/src/cattrs/preconf/_all.py b/src/cattrs/preconf/_all.py index 38bedeeb..0cde187a 100644 --- a/src/cattrs/preconf/_all.py +++ b/src/cattrs/preconf/_all.py @@ -71,33 +71,33 @@ @overload -def has_type(converter: C, fmt: Literal["bson"]) -> TypeIs["BsonConverter"]: +def has_format(converter: C, fmt: Literal["bson"]) -> TypeIs["BsonConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["cbor2"]) -> TypeIs["Cbor2Converter"]: +def has_format(converter: C, fmt: Literal["cbor2"]) -> TypeIs["Cbor2Converter"]: ... @overload -def has_type(converter: C, fmt: Literal["json"]) -> TypeIs["JsonConverter"]: +def has_format(converter: C, fmt: Literal["json"]) -> TypeIs["JsonConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["msgpack"]) -> TypeIs["MsgpackConverter"]: +def has_format(converter: C, fmt: Literal["msgpack"]) -> TypeIs["MsgpackConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["msgspec"]) -> TypeIs["MsgspecJsonConverter"]: +def has_format(converter: C, fmt: Literal["msgspec"]) -> TypeIs["MsgspecJsonConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["orjson"]) -> TypeIs["OrjsonConverter"]: +def has_format(converter: C, fmt: Literal["orjson"]) -> TypeIs["OrjsonConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["pyyaml"]) -> TypeIs["PyyamlConverter"]: +def has_format(converter: C, fmt: Literal["pyyaml"]) -> TypeIs["PyyamlConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["tomlkit"]) -> TypeIs["TomlkitConverter"]: +def has_format(converter: C, fmt: Literal["tomlkit"]) -> TypeIs["TomlkitConverter"]: ... @overload -def has_type(converter: C, fmt: Literal["ujson"]) -> TypeIs["UjsonConverter"]: +def has_format(converter: C, fmt: Literal["ujson"]) -> TypeIs["UjsonConverter"]: ... -def has_type(converter: C, fmt: ConverterFormat | str | Sequence[ConverterFormat]) -> bool: +def has_format(converter: C, fmt: ConverterFormat | str | Sequence[ConverterFormat]) -> bool: if isinstance(fmt, str): fmt = (fmt,) @@ -150,4 +150,4 @@ def has_type(converter: C, fmt: ConverterFormat | str | Sequence[ConverterFormat def is_preconfigured(converter: Converter) -> bool: - return any(has_type(converter, fmt) for fmt in get_args(ConverterFormat)) + return any(has_format(converter, fmt) for fmt in get_args(ConverterFormat)) From f11a94a0ee777124ca5aae06ac89471aa7276898 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 10:27:48 +0300 Subject: [PATCH 09/16] Sort Ruff exceptions --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb165065..c6963088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,15 +139,15 @@ select = [ "I", # isort ] ignore = [ + "B006", # mutable argument defaults + "DTZ001", # datetimes in tests + "DTZ006", # datetimes in tests "E501", # line length is handled by black + "PGH003", # leave my type: ignores alone "RUF001", # leave my smart characters alone "S101", # assert "S307", # hands off my eval "SIM300", # Yoda rocks in asserts - "PGH003", # leave my type: ignores alone - "B006", # mutable argument defaults - "DTZ001", # datetimes in tests - "DTZ006", # datetimes in tests "UP006", # We support old typing constructs at runtime "UP035", # We support old typing constructs at runtime ] From 30938b1f687fc9618569da2948e45a6afbca9aea Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 10:54:55 +0300 Subject: [PATCH 10/16] Fix lint errors --- pyproject.toml | 23 ++++++++++++----------- src/cattrs/converters.py | 12 ++++++------ src/cattrs/dispatch.py | 2 +- src/cattrs/preconf/__init__.py | 4 +++- src/cattrs/preconf/_all.py | 20 ++++++++------------ src/cattrs/types.py | 4 ++-- 6 files changed, 32 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6963088..a7bed01e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,17 +139,18 @@ select = [ "I", # isort ] ignore = [ - "B006", # mutable argument defaults - "DTZ001", # datetimes in tests - "DTZ006", # datetimes in tests - "E501", # line length is handled by black - "PGH003", # leave my type: ignores alone - "RUF001", # leave my smart characters alone - "S101", # assert - "S307", # hands off my eval - "SIM300", # Yoda rocks in asserts - "UP006", # We support old typing constructs at runtime - "UP035", # We support old typing constructs at runtime + "B006", # mutable argument defaults + "DTZ001", # datetimes in tests + "DTZ006", # datetimes in tests + "E501", # line length is handled by black + "PGH003", # leave my type: ignores alone + "PLC0414", # redundant import aliases indicate exported names + "RUF001", # leave my smart characters alone + "S101", # assert + "S307", # hands off my eval + "SIM300", # Yoda rocks in asserts + "UP006", # We support old typing constructs at runtime + "UP035", # We support old typing constructs at runtime ] [tool.ruff.lint.pyupgrade] diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 11e40e78..8d94764c 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -83,20 +83,20 @@ from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn from .literals import is_literal_containing_enums +from .typealiases import ( + get_type_alias_base, + is_type_alias, + type_alias_structure_factory, +) from .types import ( HookFactory, + SimpleStructureHook, StructuredValue, StructureHook, TargetType, UnstructuredValue, UnstructureHook, ) -from .typealiases import ( - get_type_alias_base, - is_type_alias, - type_alias_structure_factory, -) -from .types import SimpleStructureHook __all__ = ["BaseConverter", "Converter", "GenConverter", "UnstructureStrategy"] diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index 8f1db604..3e47d9be 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import lru_cache, singledispatch -from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal from attrs import Factory, define diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index e03d8b97..d01c37dd 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -3,11 +3,13 @@ from enum import Enum from typing import Any, Callable, TypeVar, get_args -from ._all import ConverterFormat, PreconfiguredConverter, has_format, is_preconfigured from .._compat import is_subclass from ..converters import Converter from ..fns import identity from ..types import UnstructureHook +from ._all import ConverterFormat as ConverterFormat +from ._all import PreconfiguredConverter as PreconfiguredConverter +from ._all import has_format as has_format if sys.version_info[:2] < (3, 10): from typing_extensions import ParamSpec diff --git a/src/cattrs/preconf/_all.py b/src/cattrs/preconf/_all.py index 0cde187a..85cfac2c 100644 --- a/src/cattrs/preconf/_all.py +++ b/src/cattrs/preconf/_all.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Literal, TypeAlias, TYPE_CHECKING, TypeIs, Union, get_args, overload +from typing import TYPE_CHECKING, Literal, TypeAlias, TypeIs, Union, overload from ..converters import Converter from ..types import Unavailable @@ -14,34 +14,34 @@ from cattrs.preconf.cbor2 import Cbor2Converter except ModuleNotFoundError: Cbor2Converter = Unavailable - + from cattrs.preconf.json import JsonConverter - + try: from cattrs.preconf.msgpack import MsgpackConverter except ModuleNotFoundError: MsgpackConverter = Unavailable - + try: from cattrs.preconf.msgspec import MsgspecJsonConverter except ModuleNotFoundError: MsgspecJsonConverter = Unavailable - + try: from cattrs.preconf.orjson import OrjsonConverter except ModuleNotFoundError: OrjsonConverter = Unavailable - + try: from cattrs.preconf.pyyaml import PyyamlConverter except ModuleNotFoundError: PyyamlConverter = Unavailable - + try: from cattrs.preconf.tomlkit import TomlkitConverter except ModuleNotFoundError: TomlkitConverter = Unavailable - + try: from cattrs.preconf.ujson import UjsonConverter except ModuleNotFoundError: @@ -147,7 +147,3 @@ def has_format(converter: C, fmt: ConverterFormat | str | Sequence[ConverterForm return isinstance(converter, UjsonConverter) return False - - -def is_preconfigured(converter: Converter) -> bool: - return any(has_format(converter, fmt) for fmt in get_args(ConverterFormat)) diff --git a/src/cattrs/types.py b/src/cattrs/types.py index 8657a87e..361c8432 100644 --- a/src/cattrs/types.py +++ b/src/cattrs/types.py @@ -5,12 +5,12 @@ "Hook", "HookFactory", "SimpleStructureHook", - "StructuredValue", "StructureHook", + "StructuredValue", "TargetType", "Unavailable", - "UnstructuredValue", "UnstructureHook", + "UnstructuredValue", ] In = TypeVar("In") From 2fa9ea424e55bc4413f3c7b30b8dbf57503ee826 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 10:56:01 +0300 Subject: [PATCH 11/16] Add new strategy register_extra_types --- src/cattrs/strategies/__init__.py | 2 + .../strategies/_extra_types/__init__.py | 43 +++++ .../strategies/_extra_types/_builtins.py | 57 ++++++ src/cattrs/strategies/_extra_types/_uuid.py | 34 ++++ .../strategies/_extra_types/_zoneinfo.py | 18 ++ tests/strategies/test_extra_types.py | 170 ++++++++++++++++++ 6 files changed, 324 insertions(+) create mode 100644 src/cattrs/strategies/_extra_types/__init__.py create mode 100644 src/cattrs/strategies/_extra_types/_builtins.py create mode 100644 src/cattrs/strategies/_extra_types/_uuid.py create mode 100644 src/cattrs/strategies/_extra_types/_zoneinfo.py create mode 100644 tests/strategies/test_extra_types.py diff --git a/src/cattrs/strategies/__init__.py b/src/cattrs/strategies/__init__.py index 9caf0732..a95a2396 100644 --- a/src/cattrs/strategies/__init__.py +++ b/src/cattrs/strategies/__init__.py @@ -1,6 +1,7 @@ """High level strategies for converters.""" from ._class_methods import use_class_methods +from ._extra_types import register_extra_types from ._subclasses import include_subclasses from ._unions import configure_tagged_union, configure_union_passthrough @@ -8,5 +9,6 @@ "configure_tagged_union", "configure_union_passthrough", "include_subclasses", + "register_extra_types", "use_class_methods", ] diff --git a/src/cattrs/strategies/_extra_types/__init__.py b/src/cattrs/strategies/_extra_types/__init__.py new file mode 100644 index 00000000..b8a23612 --- /dev/null +++ b/src/cattrs/strategies/_extra_types/__init__.py @@ -0,0 +1,43 @@ +from functools import cache +from importlib import import_module +from types import ModuleType +from typing import NoReturn + +from ...converters import Converter +from ...fns import bypass + + +def register_extra_types(converter: Converter, *classes: type) -> None: + """ + TODO: Add docs + """ + for cl in classes: + if not isinstance(cl, type): + raise TypeError("Type required instead of object") + + struct_hook = get_module(cl).gen_structure_hook(cl, converter) + if struct_hook is None: + raise_unsupported(cl) + converter.register_structure_hook(cl, bypass(cl, struct_hook)) + + unstruct_hook = get_module(cl).gen_unstructure_hook(cl, converter) + if unstruct_hook is None: + raise_unsupported(cl) + converter.register_unstructure_hook(cl, unstruct_hook) + + +@cache +def get_module(cl: type) -> ModuleType: + modname = getattr(cl, "__module__", "builtins") + try: + return import_module(f"cattrs.strategies._extra_types._{modname}") + except ModuleNotFoundError: + raise_unsupported(cl) + + +def raise_unexpected_structure(target: type, cl: type) -> NoReturn: + raise TypeError(f"Unable to structure registered extra type {target} from {cl}") + + +def raise_unsupported(cl: type) -> NoReturn: + raise ValueError(f"Type {cl} is not supported by register_extra_types strategy") diff --git a/src/cattrs/strategies/_extra_types/_builtins.py b/src/cattrs/strategies/_extra_types/_builtins.py new file mode 100644 index 00000000..5625bae9 --- /dev/null +++ b/src/cattrs/strategies/_extra_types/_builtins.py @@ -0,0 +1,57 @@ +from collections.abc import Sequence +from functools import cache, partial +from numbers import Real + +from ...converters import Converter +from ...preconf import has_format +from ...types import StructureHook, UnstructureHook +from . import raise_unexpected_structure + +MISSING_SPECIAL_FLOATS = ("msgspec", "orjson") + +SPECIAL = (float("inf"), float("-inf"), float("nan")) +SPECIAL_STR = ("inf", "+inf", "-inf", "infinity", "+infinity", "-infinity", "nan") + + +@cache +def gen_structure_hook(cl: type, _) -> StructureHook | None: + if cl is complex: + return structure_complex + return None + + +@cache +def gen_unstructure_hook(cl: type, converter: Converter) -> UnstructureHook | None: + if cl is complex: + if has_format(converter, MISSING_SPECIAL_FLOATS): + return partial(unstructure_complex, special_as_string=True) + return unstructure_complex + return None + + +def structure_complex(obj: object, _) -> complex: + if ( + isinstance(obj, Sequence) + and len(obj) == 2 + and all(isinstance(x, (Real, str)) for x in obj) + ): + try: + # for all converters, string inf and nan are allowed + obj = [ + float(x) if (isinstance(x, str) and x.lower() in SPECIAL_STR) else x + for x in obj + ] + return complex(*obj) + except ValueError: + pass # to error + raise_unexpected_structure(complex, type(obj)) # noqa: RET503 # NoReturn not handled by Ruff + + +def unstructure_complex( + value: complex, + special_as_string: bool = False, +) -> list[float | str]: + return [ + str(x) if (x in SPECIAL and special_as_string) else x + for x in [value.real, value.imag] + ] diff --git a/src/cattrs/strategies/_extra_types/_uuid.py b/src/cattrs/strategies/_extra_types/_uuid.py new file mode 100644 index 00000000..37035e6d --- /dev/null +++ b/src/cattrs/strategies/_extra_types/_uuid.py @@ -0,0 +1,34 @@ +from functools import cache +from uuid import UUID + +from ...converters import Converter +from ...fns import identity +from ...preconf import has_format +from ...types import StructureHook, UnstructureHook +from . import raise_unexpected_structure + +SUPPORTS_UUID = ('bson', 'cbor', 'msgspec', 'orjson') + + +@cache +def gen_structure_hook(cl: type, _) -> StructureHook | None: + if issubclass(cl, UUID): + return structure_uuid + return None + + +@cache +def gen_unstructure_hook(cl: type, converter: Converter) -> UnstructureHook | None: + if issubclass(cl, UUID): + return identity if has_format(converter, SUPPORTS_UUID) else lambda v: str(v) + return None + + +def structure_uuid(value: bytes | int | str, _) -> UUID: + if isinstance(value, bytes): + return UUID(bytes=value) + if isinstance(value, int): + return UUID(int=value) + if isinstance(value, str): + return UUID(value) + raise_unexpected_structure(UUID, type(value)) # noqa: RET503 # NoReturn not handled by Ruff diff --git a/src/cattrs/strategies/_extra_types/_zoneinfo.py b/src/cattrs/strategies/_extra_types/_zoneinfo.py new file mode 100644 index 00000000..0ca00939 --- /dev/null +++ b/src/cattrs/strategies/_extra_types/_zoneinfo.py @@ -0,0 +1,18 @@ +from functools import cache +from zoneinfo import ZoneInfo + +from ...types import StructureHook, UnstructureHook + + +@cache +def gen_structure_hook(cl: type, _) -> StructureHook | None: + if issubclass(cl, ZoneInfo): + return lambda v, _: ZoneInfo(v) + return None + + +@cache +def gen_unstructure_hook(cl: type, _) -> UnstructureHook | None: + if issubclass(cl, ZoneInfo): + return lambda v: str(v) + return None diff --git a/tests/strategies/test_extra_types.py b/tests/strategies/test_extra_types.py new file mode 100644 index 00000000..ca591c69 --- /dev/null +++ b/tests/strategies/test_extra_types.py @@ -0,0 +1,170 @@ +import uuid +import zoneinfo +from functools import partial +from platform import python_implementation + +from attrs import define, fields +from bson import UuidRepresentation +from hypothesis import given +from hypothesis.strategies import ( + DrawFn, + builds, + complex_numbers, + composite, + timezone_keys, + uuids, +) +from pytest import fixture, mark, skip + +from cattrs import Converter +from cattrs.preconf import has_format +from cattrs.strategies import register_extra_types + +# converters + +# isort: off +from cattrs.preconf import bson +from cattrs.preconf import cbor2 +from cattrs.preconf import json +from cattrs.preconf import msgpack +from cattrs.preconf import pyyaml +from cattrs.preconf import tomlkit +from cattrs.preconf import ujson + +if python_implementation() != "PyPy": + from cattrs.preconf import msgspec + from cattrs.preconf import orjson +else: + msgspec = 'msgspec' + orjson = 'orjson' + +PRECONF_MODULES = [bson, cbor2, json, msgpack, msgspec, orjson, pyyaml, tomlkit, ujson] +# isort: on + + +@define +class Extras: + complex: complex + uuid: uuid.UUID + zoneinfo: zoneinfo.ZoneInfo + + +EXTRA_TYPES = {attr.name: attr.type for attr in fields(Extras)} + +@composite +def extras(draw: DrawFn): + return Extras( + complex=draw(complex_numbers(allow_infinity=True, allow_nan=False)), + uuid=draw(uuids(allow_nil=True)), + zoneinfo=draw(builds(zoneinfo.ZoneInfo, timezone_keys())), + ) + +# converters + +@fixture(scope="session") +def raw_converter(converter_cls) -> Converter: + """Raw BaseConverter and Converter.""" + conv = converter_cls() + register_extra_types(conv, *EXTRA_TYPES.values()) + return conv + +@fixture(scope="session", params=PRECONF_MODULES) +def preconf_converter(request) -> Converter: + """All preconfigured converters.""" + if isinstance(request.param, str): + skip(f'Converter "{request.param}" is unavailable for current implementation') + + conv = request.param.make_converter() + register_extra_types(conv, *EXTRA_TYPES.values()) + return conv + +@fixture(scope="session", params=[None, *PRECONF_MODULES]) +def any_converter(request) -> Converter: + """Global converter and all preconfigured converters.""" + if isinstance(request.param, str): + skip(f'Converter "{request.param}" is unavailable for current implementation') + + conv = request.param.make_converter() if request.param else Converter() + register_extra_types(conv, *EXTRA_TYPES.values()) + return conv + +# common tests + +@given(extras()) +def test_restructure_attrs(any_converter, item: Extras): + """Extra types as attributes can be unstructured and restructured.""" + assert any_converter.structure(any_converter.unstructure(item), Extras) == item + +@given(extras()) +def test_restructure_values(any_converter, item: Extras): + """Extra types as standalone values can be unstructured and restructured.""" + for attr, cl in EXTRA_TYPES.items(): + value = getattr(item, attr) + assert any_converter.structure(any_converter.unstructure(value), cl) == value + +@given(extras()) +def test_restructure_optional(any_converter, item: Extras): + """Extra types as optional standalone values can be structured.""" + for attr, cl in EXTRA_TYPES.items(): + value = getattr(item, attr) + assert any_converter.structure(None, cl | None) is None + assert any_converter.structure( + any_converter.unstructure(value), cl | None + ) == value + +@given(extras()) +def test_dumpload_attrs(preconf_converter, item: Extras): + """Extra types as attributes can be dumped/loaded by preconfigured converters.""" + if has_format(preconf_converter, "bson"): + # BsonConverter requires explicit UUID representation + codec_options = bson.DEFAULT_CODEC_OPTIONS.with_options( + uuid_representation=UuidRepresentation.STANDARD, + ) + dumps = partial(preconf_converter.dumps, codec_options=codec_options) + loads = partial(preconf_converter.loads, codec_options=codec_options) + elif has_format(preconf_converter, "msgspec"): + # MsgspecJsonConverter can be used with dumps/loads factories for extra types + dumps = preconf_converter.get_dumps_hook(Extras) + loads = lambda v, cl: preconf_converter.get_loads_hook(cl)(v) # noqa: E731 # lambda is fine here + else: + dumps = preconf_converter.dumps + loads = preconf_converter.loads + # test + assert loads(dumps(item), Extras) == item + +# builtins.complex + +@mark.parametrize("unstructured,structured", [([1.0, 0.0], complex(1, 0))]) +def test_specific_complex(raw_converter, unstructured, structured) -> None: + """Raw converter structures complex.""" + assert raw_converter.structure(unstructured, complex) == structured + +# uuid.UUID + +UUID_NIL = uuid.UUID(bytes=b"\x00" * 16) + +@mark.parametrize( + "value", + ( + UUID_NIL, # passthrough + b"\x00" * 16, + 0, + "00000000000000000000000000000000", + "00000000-0000-0000-0000-000000000000", + "{00000000000000000000000000000000}", + "{00000000-0000-0000-0000-000000000000}", + "urn:uuid:00000000000000000000-000000000000", + "urn:uuid:00000000-0000-0000-0000-000000000000", + ), +) +def test_specific_uuid(raw_converter, value) -> None: + """Raw converter structures from all formats supported by uuid.UUID.""" + assert raw_converter.structure(value, uuid.UUID) == UUID_NIL + +# zoneinfo.ZoneInfo + +@mark.parametrize("value", ("EET", "Europe/Kiev")) +def test_specific_zoneinfo(raw_converter, value) -> None: + """Raw converter structures zoneinfo.ZoneInfo.""" + assert raw_converter.structure(value, zoneinfo.ZoneInfo) \ + == zoneinfo.ZoneInfo(value) From 076a76458b33446c7b916f99993112ed935aa884 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 11:01:32 +0300 Subject: [PATCH 12/16] Fix formatting --- src/cattrs/fns.py | 2 + src/cattrs/preconf/_all.py | 58 ++++++++++--------- .../strategies/_extra_types/_builtins.py | 5 +- src/cattrs/strategies/_extra_types/_uuid.py | 4 +- tests/strategies/test_extra_types.py | 34 ++++++++--- 5 files changed, 61 insertions(+), 42 deletions(-) diff --git a/src/cattrs/fns.py b/src/cattrs/fns.py index 3dbdc410..a625fd75 100644 --- a/src/cattrs/fns.py +++ b/src/cattrs/fns.py @@ -20,9 +20,11 @@ def identity(obj: T) -> T: def bypass(target: type, structure_hook: StructureHook) -> StructureHook: """Bypass structure hook when given object of target type.""" + @wraps(structure_hook) def wrapper(obj: UnstructuredValue, cl: TargetType) -> StructuredValue: return obj if type(obj) is target else structure_hook(obj, cl) + return wrapper diff --git a/src/cattrs/preconf/_all.py b/src/cattrs/preconf/_all.py index 85cfac2c..a5d26b79 100644 --- a/src/cattrs/preconf/_all.py +++ b/src/cattrs/preconf/_all.py @@ -63,7 +63,14 @@ PreconfiguredConverter: TypeAlias = Converter ConverterFormat: TypeAlias = Literal[ - "bson", "cbor2", "json", "msgpack", "msgspec", "orjson", "pyyaml", "tomlkit", + "bson", + "cbor2", + "json", + "msgpack", + "msgspec", + "orjson", + "pyyaml", + "tomlkit", "ujson", ] @@ -71,33 +78,28 @@ @overload -def has_format(converter: C, fmt: Literal["bson"]) -> TypeIs["BsonConverter"]: - ... +def has_format(converter: C, fmt: Literal["bson"]) -> TypeIs["BsonConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["cbor2"]) -> TypeIs["Cbor2Converter"]: - ... +def has_format(converter: C, fmt: Literal["cbor2"]) -> TypeIs["Cbor2Converter"]: ... @overload -def has_format(converter: C, fmt: Literal["json"]) -> TypeIs["JsonConverter"]: - ... +def has_format(converter: C, fmt: Literal["json"]) -> TypeIs["JsonConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["msgpack"]) -> TypeIs["MsgpackConverter"]: - ... +def has_format(converter: C, fmt: Literal["msgpack"]) -> TypeIs["MsgpackConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["msgspec"]) -> TypeIs["MsgspecJsonConverter"]: - ... +def has_format( + converter: C, fmt: Literal["msgspec"] +) -> TypeIs["MsgspecJsonConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["orjson"]) -> TypeIs["OrjsonConverter"]: - ... +def has_format(converter: C, fmt: Literal["orjson"]) -> TypeIs["OrjsonConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["pyyaml"]) -> TypeIs["PyyamlConverter"]: - ... +def has_format(converter: C, fmt: Literal["pyyaml"]) -> TypeIs["PyyamlConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["tomlkit"]) -> TypeIs["TomlkitConverter"]: - ... +def has_format(converter: C, fmt: Literal["tomlkit"]) -> TypeIs["TomlkitConverter"]: ... @overload -def has_format(converter: C, fmt: Literal["ujson"]) -> TypeIs["UjsonConverter"]: - ... -def has_format(converter: C, fmt: ConverterFormat | str | Sequence[ConverterFormat]) -> bool: +def has_format(converter: C, fmt: Literal["ujson"]) -> TypeIs["UjsonConverter"]: ... +def has_format( + converter: C, fmt: ConverterFormat | str | Sequence[ConverterFormat] +) -> bool: if isinstance(fmt, str): fmt = (fmt,) @@ -106,42 +108,42 @@ def has_format(converter: C, fmt: ConverterFormat | str | Sequence[ConverterForm return isinstance(converter, BsonConverter) - if "cbor2" in fmt and converter.__class__.__name__ == "Cbor2Converter": + if "cbor2" in fmt and converter.__class__.__name__ == "Cbor2Converter": from .cbor2 import Cbor2Converter return isinstance(converter, Cbor2Converter) - if "json" in fmt and converter.__class__.__name__ == "JsonConverter": + if "json" in fmt and converter.__class__.__name__ == "JsonConverter": from .json import JsonConverter return isinstance(converter, JsonConverter) - if "msgpack" in fmt and converter.__class__.__name__ == "MsgpackConverter": + if "msgpack" in fmt and converter.__class__.__name__ == "MsgpackConverter": from .msgpack import MsgpackConverter return isinstance(converter, MsgpackConverter) - if "msgspec" in fmt and converter.__class__.__name__ == "MsgspecJsonConverter": + if "msgspec" in fmt and converter.__class__.__name__ == "MsgspecJsonConverter": from .msgspec import MsgspecJsonConverter return isinstance(converter, MsgspecJsonConverter) - if "orjson" in fmt and converter.__class__.__name__ == "OrjsonConverter": + if "orjson" in fmt and converter.__class__.__name__ == "OrjsonConverter": from .orjson import OrjsonConverter return isinstance(converter, OrjsonConverter) - if "pyyaml" in fmt and converter.__class__.__name__ == "PyyamlConverter": + if "pyyaml" in fmt and converter.__class__.__name__ == "PyyamlConverter": from .pyyaml import PyyamlConverter return isinstance(converter, PyyamlConverter) - if "tomlkit" in fmt and converter.__class__.__name__ == "TomlkitConverter": + if "tomlkit" in fmt and converter.__class__.__name__ == "TomlkitConverter": from .tomlkit import TomlkitConverter return isinstance(converter, TomlkitConverter) - if "ujson" in fmt and converter.__class__.__name__ == "UjsonConverter": + if "ujson" in fmt and converter.__class__.__name__ == "UjsonConverter": from .ujson import UjsonConverter return isinstance(converter, UjsonConverter) diff --git a/src/cattrs/strategies/_extra_types/_builtins.py b/src/cattrs/strategies/_extra_types/_builtins.py index 5625bae9..007133d7 100644 --- a/src/cattrs/strategies/_extra_types/_builtins.py +++ b/src/cattrs/strategies/_extra_types/_builtins.py @@ -44,12 +44,11 @@ def structure_complex(obj: object, _) -> complex: return complex(*obj) except ValueError: pass # to error - raise_unexpected_structure(complex, type(obj)) # noqa: RET503 # NoReturn not handled by Ruff + raise_unexpected_structure(complex, type(obj)) # noqa: RET503 # NoReturn def unstructure_complex( - value: complex, - special_as_string: bool = False, + value: complex, special_as_string: bool = False ) -> list[float | str]: return [ str(x) if (x in SPECIAL and special_as_string) else x diff --git a/src/cattrs/strategies/_extra_types/_uuid.py b/src/cattrs/strategies/_extra_types/_uuid.py index 37035e6d..7c4337dc 100644 --- a/src/cattrs/strategies/_extra_types/_uuid.py +++ b/src/cattrs/strategies/_extra_types/_uuid.py @@ -7,7 +7,7 @@ from ...types import StructureHook, UnstructureHook from . import raise_unexpected_structure -SUPPORTS_UUID = ('bson', 'cbor', 'msgspec', 'orjson') +SUPPORTS_UUID = ("bson", "cbor", "msgspec", "orjson") @cache @@ -31,4 +31,4 @@ def structure_uuid(value: bytes | int | str, _) -> UUID: return UUID(int=value) if isinstance(value, str): return UUID(value) - raise_unexpected_structure(UUID, type(value)) # noqa: RET503 # NoReturn not handled by Ruff + raise_unexpected_structure(UUID, type(value)) # noqa: RET503 # NoReturn diff --git a/tests/strategies/test_extra_types.py b/tests/strategies/test_extra_types.py index ca591c69..71c92c33 100644 --- a/tests/strategies/test_extra_types.py +++ b/tests/strategies/test_extra_types.py @@ -35,8 +35,8 @@ from cattrs.preconf import msgspec from cattrs.preconf import orjson else: - msgspec = 'msgspec' - orjson = 'orjson' + msgspec = "msgspec" + orjson = "orjson" PRECONF_MODULES = [bson, cbor2, json, msgpack, msgspec, orjson, pyyaml, tomlkit, ujson] # isort: on @@ -51,6 +51,7 @@ class Extras: EXTRA_TYPES = {attr.name: attr.type for attr in fields(Extras)} + @composite def extras(draw: DrawFn): return Extras( @@ -59,8 +60,10 @@ def extras(draw: DrawFn): zoneinfo=draw(builds(zoneinfo.ZoneInfo, timezone_keys())), ) + # converters + @fixture(scope="session") def raw_converter(converter_cls) -> Converter: """Raw BaseConverter and Converter.""" @@ -68,6 +71,7 @@ def raw_converter(converter_cls) -> Converter: register_extra_types(conv, *EXTRA_TYPES.values()) return conv + @fixture(scope="session", params=PRECONF_MODULES) def preconf_converter(request) -> Converter: """All preconfigured converters.""" @@ -78,6 +82,7 @@ def preconf_converter(request) -> Converter: register_extra_types(conv, *EXTRA_TYPES.values()) return conv + @fixture(scope="session", params=[None, *PRECONF_MODULES]) def any_converter(request) -> Converter: """Global converter and all preconfigured converters.""" @@ -88,13 +93,16 @@ def any_converter(request) -> Converter: register_extra_types(conv, *EXTRA_TYPES.values()) return conv + # common tests + @given(extras()) def test_restructure_attrs(any_converter, item: Extras): """Extra types as attributes can be unstructured and restructured.""" assert any_converter.structure(any_converter.unstructure(item), Extras) == item + @given(extras()) def test_restructure_values(any_converter, item: Extras): """Extra types as standalone values can be unstructured and restructured.""" @@ -102,15 +110,18 @@ def test_restructure_values(any_converter, item: Extras): value = getattr(item, attr) assert any_converter.structure(any_converter.unstructure(value), cl) == value + @given(extras()) def test_restructure_optional(any_converter, item: Extras): """Extra types as optional standalone values can be structured.""" for attr, cl in EXTRA_TYPES.items(): value = getattr(item, attr) assert any_converter.structure(None, cl | None) is None - assert any_converter.structure( - any_converter.unstructure(value), cl | None - ) == value + assert ( + any_converter.structure(any_converter.unstructure(value), cl | None) + == value + ) + @given(extras()) def test_dumpload_attrs(preconf_converter, item: Extras): @@ -118,31 +129,35 @@ def test_dumpload_attrs(preconf_converter, item: Extras): if has_format(preconf_converter, "bson"): # BsonConverter requires explicit UUID representation codec_options = bson.DEFAULT_CODEC_OPTIONS.with_options( - uuid_representation=UuidRepresentation.STANDARD, + uuid_representation=UuidRepresentation.STANDARD ) dumps = partial(preconf_converter.dumps, codec_options=codec_options) loads = partial(preconf_converter.loads, codec_options=codec_options) elif has_format(preconf_converter, "msgspec"): # MsgspecJsonConverter can be used with dumps/loads factories for extra types dumps = preconf_converter.get_dumps_hook(Extras) - loads = lambda v, cl: preconf_converter.get_loads_hook(cl)(v) # noqa: E731 # lambda is fine here + loads = lambda v, cl: preconf_converter.get_loads_hook(cl)(v) # noqa: E731 else: dumps = preconf_converter.dumps loads = preconf_converter.loads # test assert loads(dumps(item), Extras) == item + # builtins.complex + @mark.parametrize("unstructured,structured", [([1.0, 0.0], complex(1, 0))]) def test_specific_complex(raw_converter, unstructured, structured) -> None: """Raw converter structures complex.""" assert raw_converter.structure(unstructured, complex) == structured + # uuid.UUID UUID_NIL = uuid.UUID(bytes=b"\x00" * 16) + @mark.parametrize( "value", ( @@ -161,10 +176,11 @@ def test_specific_uuid(raw_converter, value) -> None: """Raw converter structures from all formats supported by uuid.UUID.""" assert raw_converter.structure(value, uuid.UUID) == UUID_NIL + # zoneinfo.ZoneInfo + @mark.parametrize("value", ("EET", "Europe/Kiev")) def test_specific_zoneinfo(raw_converter, value) -> None: """Raw converter structures zoneinfo.ZoneInfo.""" - assert raw_converter.structure(value, zoneinfo.ZoneInfo) \ - == zoneinfo.ZoneInfo(value) + assert raw_converter.structure(value, zoneinfo.ZoneInfo) == zoneinfo.ZoneInfo(value) From b419528f224be2352dd646620348d7943d3633d4 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 14:21:47 +0300 Subject: [PATCH 13/16] Revert dispatch types refactoring --- src/cattrs/cols.py | 2 +- src/cattrs/converters.py | 20 +++++++------- src/cattrs/dispatch.py | 13 +++++++-- src/cattrs/fns.py | 12 --------- src/cattrs/gen/__init__.py | 3 ++- src/cattrs/gen/_shared.py | 2 +- src/cattrs/preconf/__init__.py | 2 +- src/cattrs/preconf/bson.py | 2 +- src/cattrs/preconf/msgspec.py | 2 +- .../strategies/_extra_types/__init__.py | 14 ++++++++-- .../strategies/_extra_types/_builtins.py | 2 +- src/cattrs/strategies/_extra_types/_uuid.py | 2 +- .../strategies/_extra_types/_zoneinfo.py | 2 +- src/cattrs/typealiases.py | 2 +- src/cattrs/types.py | 27 +++---------------- 15 files changed, 47 insertions(+), 60 deletions(-) diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index 3eb6b9a8..0b578eb0 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -28,6 +28,7 @@ is_subclass, ) from ._compat import is_mutable_set as is_set +from .dispatch import StructureHook, UnstructureHook from .errors import IterableValidationError, IterableValidationNote from .fns import identity from .gen import ( @@ -40,7 +41,6 @@ mapping_unstructure_factory, ) from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory -from .types import StructureHook, UnstructureHook if TYPE_CHECKING: from .converters import BaseConverter diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 8d94764c..fe1a7ba6 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -64,7 +64,15 @@ namedtuple_unstructure_factory, ) from .disambiguators import create_default_dis_func, is_supported_union -from .dispatch import MultiStrategyDispatch +from .dispatch import ( + HookFactory, + MultiStrategyDispatch, + StructuredValue, + StructureHook, + TargetType, + UnstructuredValue, + UnstructureHook, +) from .errors import ( IterableValidationError, IterableValidationNote, @@ -88,15 +96,7 @@ is_type_alias, type_alias_structure_factory, ) -from .types import ( - HookFactory, - SimpleStructureHook, - StructuredValue, - StructureHook, - TargetType, - UnstructuredValue, - UnstructureHook, -) +from .types import SimpleStructureHook __all__ = ["BaseConverter", "Converter", "GenConverter", "UnstructureStrategy"] diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index 3e47d9be..966808e6 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -1,16 +1,25 @@ from __future__ import annotations from functools import lru_cache, singledispatch -from typing import TYPE_CHECKING, Any, Callable, Generic, Literal +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeAlias, TypeVar from attrs import Factory, define from .fns import Predicate -from .types import Hook, HookFactory, TargetType if TYPE_CHECKING: from .converters import BaseConverter +TargetType: TypeAlias = Any +UnstructuredValue: TypeAlias = Any +StructuredValue: TypeAlias = Any + +StructureHook: TypeAlias = Callable[[UnstructuredValue, TargetType], StructuredValue] +UnstructureHook: TypeAlias = Callable[[StructuredValue], UnstructuredValue] + +Hook = TypeVar("Hook", StructureHook, UnstructureHook) +HookFactory: TypeAlias = Callable[[TargetType], Hook] + @define class _DispatchNotFound: diff --git a/src/cattrs/fns.py b/src/cattrs/fns.py index a625fd75..984c05eb 100644 --- a/src/cattrs/fns.py +++ b/src/cattrs/fns.py @@ -1,11 +1,9 @@ """Useful internal functions.""" -from functools import wraps from typing import Any, Callable, NoReturn, TypeVar from ._compat import TypeAlias from .errors import StructureHandlerNotFoundError -from .types import StructuredValue, StructureHook, TargetType, UnstructuredValue T = TypeVar("T") @@ -18,16 +16,6 @@ def identity(obj: T) -> T: return obj -def bypass(target: type, structure_hook: StructureHook) -> StructureHook: - """Bypass structure hook when given object of target type.""" - - @wraps(structure_hook) - def wrapper(obj: UnstructuredValue, cl: TargetType) -> StructuredValue: - return obj if type(obj) is target else structure_hook(obj, cl) - - return wrapper - - def raise_error(_, cl: Any) -> NoReturn: """At the bottom of the condition stack, we explode if we can't handle it.""" msg = f"Unsupported type: {cl!r}. Register a structure hook for it." diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index d1a61dd6..3afa3b9b 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -19,6 +19,7 @@ is_generic, ) from .._generics import deep_copy_with +from ..dispatch import UnstructureHook from ..errors import ( AttributeValidationNote, ClassValidationError, @@ -28,7 +29,7 @@ StructureHandlerNotFoundError, ) from ..fns import identity -from ..types import SimpleStructureHook, UnstructureHook +from ..types import SimpleStructureHook from ._consts import AttributeOverride, already_generating, neutral from ._generics import generate_mapping from ._lc import generate_unique_filename diff --git a/src/cattrs/gen/_shared.py b/src/cattrs/gen/_shared.py index fd553ceb..904c7744 100644 --- a/src/cattrs/gen/_shared.py +++ b/src/cattrs/gen/_shared.py @@ -5,9 +5,9 @@ from attrs import NOTHING, Attribute, Factory from .._compat import is_bare_final +from ..dispatch import StructureHook from ..errors import StructureHandlerNotFoundError from ..fns import raise_error -from ..types import StructureHook if TYPE_CHECKING: from ..converters import BaseConverter diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index d01c37dd..6ee1d77c 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -5,8 +5,8 @@ from .._compat import is_subclass from ..converters import Converter +from ..dispatch import UnstructureHook from ..fns import identity -from ..types import UnstructureHook from ._all import ConverterFormat as ConverterFormat from ._all import PreconfiguredConverter as PreconfiguredConverter from ._all import has_format as has_format diff --git a/src/cattrs/preconf/bson.py b/src/cattrs/preconf/bson.py index bdbe46d5..49574893 100644 --- a/src/cattrs/preconf/bson.py +++ b/src/cattrs/preconf/bson.py @@ -10,10 +10,10 @@ from .._compat import is_mapping, is_subclass from ..cols import mapping_structure_factory from ..converters import BaseConverter, Converter +from ..dispatch import StructureHook from ..fns import identity from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough -from ..types import StructureHook from . import ( is_primitive_enum, literals_with_enums_unstructure_factory, diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index 8ab7e754..6274a32b 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -17,11 +17,11 @@ from .._compat import fields, get_args, get_origin, is_bare, is_mapping, is_sequence from ..cols import is_namedtuple from ..converters import BaseConverter, Converter +from ..dispatch import UnstructureHook from ..fns import identity from ..gen import make_hetero_tuple_unstructure_fn from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough -from ..types import UnstructureHook from . import literals_with_enums_unstructure_factory, wrap T = TypeVar("T") diff --git a/src/cattrs/strategies/_extra_types/__init__.py b/src/cattrs/strategies/_extra_types/__init__.py index b8a23612..4ac46fc7 100644 --- a/src/cattrs/strategies/_extra_types/__init__.py +++ b/src/cattrs/strategies/_extra_types/__init__.py @@ -1,10 +1,10 @@ -from functools import cache +from functools import cache, wraps from importlib import import_module from types import ModuleType from typing import NoReturn from ...converters import Converter -from ...fns import bypass +from ...dispatch import StructuredValue, StructureHook, TargetType, UnstructuredValue def register_extra_types(converter: Converter, *classes: type) -> None: @@ -26,6 +26,16 @@ def register_extra_types(converter: Converter, *classes: type) -> None: converter.register_unstructure_hook(cl, unstruct_hook) +def bypass(target: type, structure_hook: StructureHook) -> StructureHook: + """Bypass structure hook when given object of target type.""" + + @wraps(structure_hook) + def wrapper(obj: UnstructuredValue, cl: TargetType) -> StructuredValue: + return obj if type(obj) is target else structure_hook(obj, cl) + + return wrapper + + @cache def get_module(cl: type) -> ModuleType: modname = getattr(cl, "__module__", "builtins") diff --git a/src/cattrs/strategies/_extra_types/_builtins.py b/src/cattrs/strategies/_extra_types/_builtins.py index 007133d7..e711b97b 100644 --- a/src/cattrs/strategies/_extra_types/_builtins.py +++ b/src/cattrs/strategies/_extra_types/_builtins.py @@ -3,8 +3,8 @@ from numbers import Real from ...converters import Converter +from ...dispatch import StructureHook, UnstructureHook from ...preconf import has_format -from ...types import StructureHook, UnstructureHook from . import raise_unexpected_structure MISSING_SPECIAL_FLOATS = ("msgspec", "orjson") diff --git a/src/cattrs/strategies/_extra_types/_uuid.py b/src/cattrs/strategies/_extra_types/_uuid.py index 7c4337dc..9c148f0f 100644 --- a/src/cattrs/strategies/_extra_types/_uuid.py +++ b/src/cattrs/strategies/_extra_types/_uuid.py @@ -2,9 +2,9 @@ from uuid import UUID from ...converters import Converter +from ...dispatch import StructureHook, UnstructureHook from ...fns import identity from ...preconf import has_format -from ...types import StructureHook, UnstructureHook from . import raise_unexpected_structure SUPPORTS_UUID = ("bson", "cbor", "msgspec", "orjson") diff --git a/src/cattrs/strategies/_extra_types/_zoneinfo.py b/src/cattrs/strategies/_extra_types/_zoneinfo.py index 0ca00939..60484f5b 100644 --- a/src/cattrs/strategies/_extra_types/_zoneinfo.py +++ b/src/cattrs/strategies/_extra_types/_zoneinfo.py @@ -1,7 +1,7 @@ from functools import cache from zoneinfo import ZoneInfo -from ...types import StructureHook, UnstructureHook +from ...dispatch import StructureHook, UnstructureHook @cache diff --git a/src/cattrs/typealiases.py b/src/cattrs/typealiases.py index d902de16..d3a20c48 100644 --- a/src/cattrs/typealiases.py +++ b/src/cattrs/typealiases.py @@ -7,8 +7,8 @@ from ._compat import is_generic from ._generics import deep_copy_with +from .dispatch import StructureHook from .gen._generics import generate_mapping -from .types import StructureHook if TYPE_CHECKING: from .converters import BaseConverter diff --git a/src/cattrs/types.py b/src/cattrs/types.py index 361c8432..22f35c5d 100644 --- a/src/cattrs/types.py +++ b/src/cattrs/types.py @@ -1,31 +1,10 @@ -from collections.abc import Callable -from typing import Any, Protocol, TypeAlias, TypeVar +from typing import Protocol, TypeVar -__all__ = [ - "Hook", - "HookFactory", - "SimpleStructureHook", - "StructureHook", - "StructuredValue", - "TargetType", - "Unavailable", - "UnstructureHook", - "UnstructuredValue", -] +__all__ = ["SimpleStructureHook", "Unavailable"] In = TypeVar("In") T = TypeVar("T") -TargetType: TypeAlias = Any -UnstructuredValue: TypeAlias = Any -StructuredValue: TypeAlias = Any - -StructureHook: TypeAlias = Callable[[UnstructuredValue, TargetType], StructuredValue] -UnstructureHook: TypeAlias = Callable[[StructuredValue], UnstructuredValue] - -Hook = TypeVar("Hook", StructureHook, UnstructureHook) -HookFactory: TypeAlias = Callable[[TargetType], Hook] - class SimpleStructureHook(Protocol[In, T]): """A structure hook with an optional (ignored) second argument.""" @@ -34,4 +13,4 @@ def __call__(self, _: In, /, cl=...) -> T: ... class Unavailable: - """Placeholder class to substitute missing converter class on import.""" + """Placeholder class to substitute missing class on import.""" From a18d46101ba2192b29f45d3569fce10d7b53b15d Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 14:38:59 +0300 Subject: [PATCH 14/16] Fix leftovers after reverted refactoring --- src/cattrs/dispatch.py | 3 ++- src/cattrs/preconf/__init__.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index 966808e6..f98dc51d 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -1,10 +1,11 @@ from __future__ import annotations from functools import lru_cache, singledispatch -from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar from attrs import Factory, define +from ._compat import TypeAlias from .fns import Predicate if TYPE_CHECKING: diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index 6ee1d77c..adfd0e0c 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -4,8 +4,7 @@ from typing import Any, Callable, TypeVar, get_args from .._compat import is_subclass -from ..converters import Converter -from ..dispatch import UnstructureHook +from ..converters import Converter, UnstructureHook from ..fns import identity from ._all import ConverterFormat as ConverterFormat from ._all import PreconfiguredConverter as PreconfiguredConverter From c1e8d69ab0f9fb09811613bc5f1119c850fc515b Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 15:06:20 +0300 Subject: [PATCH 15/16] Minor cosmetic changes --- src/cattrs/strategies/_extra_types/_builtins.py | 8 +++----- tests/strategies/test_extra_types.py | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/cattrs/strategies/_extra_types/_builtins.py b/src/cattrs/strategies/_extra_types/_builtins.py index e711b97b..4acac7c2 100644 --- a/src/cattrs/strategies/_extra_types/_builtins.py +++ b/src/cattrs/strategies/_extra_types/_builtins.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from contextlib import suppress from functools import cache, partial from numbers import Real @@ -35,15 +36,12 @@ def structure_complex(obj: object, _) -> complex: and len(obj) == 2 and all(isinstance(x, (Real, str)) for x in obj) ): - try: - # for all converters, string inf and nan are allowed - obj = [ + with suppress(ValueError): + obj = [ # for all converters, string inf and nan are allowed float(x) if (isinstance(x, str) and x.lower() in SPECIAL_STR) else x for x in obj ] return complex(*obj) - except ValueError: - pass # to error raise_unexpected_structure(complex, type(obj)) # noqa: RET503 # NoReturn diff --git a/tests/strategies/test_extra_types.py b/tests/strategies/test_extra_types.py index 71c92c33..c6fa15c7 100644 --- a/tests/strategies/test_extra_types.py +++ b/tests/strategies/test_extra_types.py @@ -37,9 +37,9 @@ else: msgspec = "msgspec" orjson = "orjson" +# isort: on PRECONF_MODULES = [bson, cbor2, json, msgpack, msgspec, orjson, pyyaml, tomlkit, ujson] -# isort: on @define From abe578b4530d33cbc001a63227421f2d95dd0883 Mon Sep 17 00:00:00 2001 From: "Michael.Makukha" Date: Wed, 7 May 2025 15:27:13 +0300 Subject: [PATCH 16/16] Use future-proof msgspec format name --- src/cattrs/preconf/_all.py | 6 +++--- src/cattrs/strategies/_extra_types/_builtins.py | 2 +- src/cattrs/strategies/_extra_types/_uuid.py | 2 +- tests/strategies/test_extra_types.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cattrs/preconf/_all.py b/src/cattrs/preconf/_all.py index a5d26b79..75756e24 100644 --- a/src/cattrs/preconf/_all.py +++ b/src/cattrs/preconf/_all.py @@ -67,7 +67,7 @@ "cbor2", "json", "msgpack", - "msgspec", + "msgspec-json", "orjson", "pyyaml", "tomlkit", @@ -87,7 +87,7 @@ def has_format(converter: C, fmt: Literal["json"]) -> TypeIs["JsonConverter"]: . def has_format(converter: C, fmt: Literal["msgpack"]) -> TypeIs["MsgpackConverter"]: ... @overload def has_format( - converter: C, fmt: Literal["msgspec"] + converter: C, fmt: Literal["msgspec-json"] ) -> TypeIs["MsgspecJsonConverter"]: ... @overload def has_format(converter: C, fmt: Literal["orjson"]) -> TypeIs["OrjsonConverter"]: ... @@ -123,7 +123,7 @@ def has_format( return isinstance(converter, MsgpackConverter) - if "msgspec" in fmt and converter.__class__.__name__ == "MsgspecJsonConverter": + if "msgspec-json" in fmt and converter.__class__.__name__ == "MsgspecJsonConverter": from .msgspec import MsgspecJsonConverter return isinstance(converter, MsgspecJsonConverter) diff --git a/src/cattrs/strategies/_extra_types/_builtins.py b/src/cattrs/strategies/_extra_types/_builtins.py index 4acac7c2..b35b6afb 100644 --- a/src/cattrs/strategies/_extra_types/_builtins.py +++ b/src/cattrs/strategies/_extra_types/_builtins.py @@ -8,7 +8,7 @@ from ...preconf import has_format from . import raise_unexpected_structure -MISSING_SPECIAL_FLOATS = ("msgspec", "orjson") +MISSING_SPECIAL_FLOATS = ("msgspec-json", "orjson") SPECIAL = (float("inf"), float("-inf"), float("nan")) SPECIAL_STR = ("inf", "+inf", "-inf", "infinity", "+infinity", "-infinity", "nan") diff --git a/src/cattrs/strategies/_extra_types/_uuid.py b/src/cattrs/strategies/_extra_types/_uuid.py index 9c148f0f..09894303 100644 --- a/src/cattrs/strategies/_extra_types/_uuid.py +++ b/src/cattrs/strategies/_extra_types/_uuid.py @@ -7,7 +7,7 @@ from ...preconf import has_format from . import raise_unexpected_structure -SUPPORTS_UUID = ("bson", "cbor", "msgspec", "orjson") +SUPPORTS_UUID = ("bson", "cbor", "msgspec-json", "orjson") @cache diff --git a/tests/strategies/test_extra_types.py b/tests/strategies/test_extra_types.py index c6fa15c7..d38f317d 100644 --- a/tests/strategies/test_extra_types.py +++ b/tests/strategies/test_extra_types.py @@ -133,7 +133,7 @@ def test_dumpload_attrs(preconf_converter, item: Extras): ) dumps = partial(preconf_converter.dumps, codec_options=codec_options) loads = partial(preconf_converter.loads, codec_options=codec_options) - elif has_format(preconf_converter, "msgspec"): + elif has_format(preconf_converter, "msgspec-json"): # MsgspecJsonConverter can be used with dumps/loads factories for extra types dumps = preconf_converter.get_dumps_hook(Extras) loads = lambda v, cl: preconf_converter.get_loads_hook(cl)(v) # noqa: E731