diff --git a/HISTORY.md b/HISTORY.md index 430d47c5..b4839b54 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -40,6 +40,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#450](https://github.com/python-attrs/cattrs/pull/450)) - `typing_extensions.Literal` is now automatically structured, just like `typing.Literal`. ([#460](https://github.com/python-attrs/cattrs/issues/460) [#467](https://github.com/python-attrs/cattrs/pull/467)) +- `typing_extensions.Any` is now supported and handled like `typing.Any`. + ([#488](https://github.com/python-attrs/cattrs/issues/488) [#490](https://github.com/python-attrs/cattrs/pull/490)) - [PEP 695](https://peps.python.org/pep-0695/) generics are now tested. ([#452](https://github.com/python-attrs/cattrs/pull/452)) - Imports are now sorted using Ruff. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index f6a3e3e8..86f77f48 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -469,6 +469,10 @@ When unstructuring, `typing.Any` will make the value be unstructured according t Previously, the unstructuring rules for `Any` were underspecified, leading to inconsistent behavior. ``` +```{versionchanged} 24.1.0 +`typing_extensions.Any` is now also supported. +``` + ### `typing.Literal` When structuring, [PEP 586](https://peps.python.org/pep-0586/) literals are validated to be in the allowed set of values. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index ec15259a..0e010eda 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -35,6 +35,7 @@ from attrs import fields_dict as attrs_fields_dict __all__ = [ + "ANIES", "adapted_fields", "fields_dict", "ExceptionGroup", @@ -77,6 +78,15 @@ except ImportError: # pragma: no cover pass +# On some Python versions, `typing_extensions.Any` is different than +# `typing.Any`. +try: + from typing_extensions import Any as teAny + + ANIES = frozenset([Any, teAny]) +except ImportError: # pragma: no cover + ANIES = frozenset([Any]) + NoneType = type(None) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index e4ab30f5..5e7dcc61 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -11,6 +11,7 @@ from attrs import has as attrs_has from ._compat import ( + ANIES, FrozenSetSubscriptable, Mapping, MutableMapping, @@ -171,7 +172,7 @@ def __init__( (lambda t: issubclass(t, Enum), self._unstructure_enum), (has, self._unstructure_attrs), (is_union_type, self._unstructure_union), - (lambda t: t is Any, self.unstructure), + (lambda t: t in ANIES, self.unstructure), ] ) @@ -181,7 +182,10 @@ def __init__( self._structure_func = MultiStrategyDispatch(structure_fallback_factory) self._structure_func.register_func_list( [ - (lambda cl: cl is Any or cl is Optional or cl is None, lambda v, _: v), + ( + lambda cl: cl in ANIES or cl is Optional or cl is None, + lambda v, _: v, + ), (is_generic_attrs, self._gen_structure_generic, True), (lambda t: get_newtype_base(t) is not None, self._structure_newtype), (is_type_alias, self._find_type_alias_structure_hook, True), @@ -545,7 +549,7 @@ def structure_attrs_fromdict(self, obj: Mapping[str, Any], cl: type[T]) -> T: def _structure_list(self, obj: Iterable[T], cl: Any) -> list[T]: """Convert an iterable to a potentially generic list.""" - if is_bare(cl) or cl.__args__[0] is Any: + if is_bare(cl) or cl.__args__[0] in ANIES: res = list(obj) else: elem_type = cl.__args__[0] @@ -575,7 +579,7 @@ def _structure_list(self, obj: Iterable[T], cl: Any) -> list[T]: def _structure_deque(self, obj: Iterable[T], cl: Any) -> deque[T]: """Convert an iterable to a potentially generic deque.""" - if is_bare(cl) or cl.__args__[0] is Any: + if is_bare(cl) or cl.__args__[0] in ANIES: res = deque(e for e in obj) else: elem_type = cl.__args__[0] @@ -607,7 +611,7 @@ def _structure_set( self, obj: Iterable[T], cl: Any, structure_to: type = set ) -> Set[T]: """Convert an iterable into a potentially generic set.""" - if is_bare(cl) or cl.__args__[0] is Any: + if is_bare(cl) or cl.__args__[0] in ANIES: return structure_to(obj) elem_type = cl.__args__[0] handler = self._structure_func.dispatch(elem_type) @@ -646,10 +650,10 @@ def _structure_dict(self, obj: Mapping[T, V], cl: Any) -> dict[T, V]: if is_bare(cl) or cl.__args__ == (Any, Any): return dict(obj) key_type, val_type = cl.__args__ - if key_type is Any: + if key_type in ANIES: val_conv = self._structure_func.dispatch(val_type) return {k: val_conv(v, val_type) for k, v in obj.items()} - if val_type is Any: + if val_type in ANIES: key_conv = self._structure_func.dispatch(key_type) return {key_conv(k, key_type): v for k, v in obj.items()} key_conv = self._structure_func.dispatch(key_type) @@ -673,7 +677,7 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T: """Deal with structuring into a tuple.""" tup_params = None if tup in (Tuple, tuple) else tup.__args__ has_ellipsis = tup_params and tup_params[-1] is Ellipsis - if tup_params is None or (has_ellipsis and tup_params[0] is Any): + if tup_params is None or (has_ellipsis and tup_params[0] in ANIES): # Just a Tuple. (No generic information.) return tuple(obj) if has_ellipsis: diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 806898ba..b2277e53 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -6,6 +6,7 @@ from attrs import NOTHING, Factory, resolve_types from .._compat import ( + ANIES, TypeAlias, adapted_fields, get_args, @@ -831,7 +832,7 @@ def make_mapping_structure_fn( (key_type,) = args val_type = Any - is_bare_dict = val_type is Any and key_type is Any + is_bare_dict = val_type in ANIES and key_type in ANIES if not is_bare_dict: # We can do the dispatch here and now. key_handler = converter.get_structure_hook(key_type, cache_result=False) diff --git a/tests/test_any.py b/tests/test_any.py index c580bfc5..291125d5 100644 --- a/tests/test_any.py +++ b/tests/test_any.py @@ -2,6 +2,7 @@ from typing import Any, Dict, Optional from attrs import define +from typing_extensions import Any as ExtendedAny @define @@ -24,3 +25,12 @@ def test_unstructure_optional_any(converter): """Unstructuring `Optional[Any]` should use the runtime value.""" assert converter.unstructure(A(), Optional[Any]) == {} + + +def test_extended_any(converter): + """`typing_extensions.Any` works.""" + + assert converter.unstructure(A(), unstructure_as=ExtendedAny) == {} + + d = {} + assert converter.structure(d, ExtendedAny) is d