From f7a13702f80cd373cb466d0bc92153a1d3cabace Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Fri, 7 Apr 2023 03:39:36 +0200 Subject: [PATCH 1/4] Implement Final --- HISTORY.md | 4 ++- docs/structuring.md | 16 ++++++++-- docs/unstructuring.md | 12 +++++++ src/cattrs/_compat.py | 16 +++++++++- src/cattrs/converters.py | 19 ++++++++++++ src/cattrs/gen.py | 67 ++++++++++++++++++++++++---------------- tests/test_final.py | 50 ++++++++++++++++++++++++++++++ tests/test_gen_dict.py | 10 ++++-- 8 files changed, 161 insertions(+), 33 deletions(-) create mode 100644 tests/test_final.py diff --git a/HISTORY.md b/HISTORY.md index 327059bd..dc050db1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,8 +4,10 @@ - Introduce the `tagged_union` strategy. ([#318](https://github.com/python-attrs/cattrs/pull/318) [#317](https://github.com/python-attrs/cattrs/issues/317)) - Introduce the `cattrs.transform_error` helper function for formatting validation exceptions. ([258](https://github.com/python-attrs/cattrs/issues/258) [342](https://github.com/python-attrs/cattrs/pull/342)) +- Add support for `typing.Final`. + ([#340](https://github.com/python-attrs/cattrs/issues/340)) - Introduce `override.struct_hook` and `override.unstruct_hook`. Learn more [here](https://catt.rs/en/latest/customizing.html#struct-hook-and-unstruct-hook). - [#326](https://github.com/python-attrs/cattrs/pull/326) + ([#326](https://github.com/python-attrs/cattrs/pull/326)) - Fix generating structuring functions for types with angle brackets (`<>`) and pipe symbols (`|`) in the name. ([#319](https://github.com/python-attrs/cattrs/issues/319) [#327](https://github.com/python-attrs/cattrs/pull/327>)) - `pathlib.Path` is now supported by default. diff --git a/docs/structuring.md b/docs/structuring.md index b29b6e64..09200309 100644 --- a/docs/structuring.md +++ b/docs/structuring.md @@ -1,8 +1,8 @@ # What You Can Structure and How -The philosophy of `cattrs` structuring is simple: give it an instance of Python +The philosophy of _cattrs_ structuring is simple: give it an instance of Python built-in types and collections, and a type describing the data you want out. -`cattrs` will convert the input data into the type you want, or throw an +_cattrs_ will convert the input data into the type you want, or throw an exception. All structuring conversions are composable, where applicable. This is @@ -282,6 +282,18 @@ To support arbitrary unions, register a custom structuring hook for the union Another option is to use a custom tagged union strategy (see [Strategies - Tagged Unions](strategies.md#tagged-unions)). +### `typing.Final` + +[PEP 591](https://peps.python.org/pep-0591/) Final attribute types (`Final[int]`) are supported and structured appropriately. + +```{versionadded} 23.1.0 + +``` + +```{seealso} [Unstructuring Final.](unstructuring.md#typingfinal) + +``` + ### `typing.Annotated` [PEP 593](https://www.python.org/dev/peps/pep-0593/) annotations (`typing.Annotated[type, ...]`) are supported and are diff --git a/docs/unstructuring.md b/docs/unstructuring.md index eeffd2e9..5e606b2d 100644 --- a/docs/unstructuring.md +++ b/docs/unstructuring.md @@ -102,6 +102,18 @@ Similar logic applies to the set and mapping hierarchies. Make sure you're using the types from `collections.abc` on Python 3.9+, and from `typing` on older Python versions. +### `typing.Final` + +[PEP 591](https://peps.python.org/pep-0591/) Final attribute types (`Final[int]`) are supported and unstructured appropriately. + +```{versionadded} 23.1.0 + +``` + +```{seealso} [Structuring Final.](structuring.md#typingfinal) + +``` + ## `typing.Annotated` Fields marked as `typing.Annotated[type, ...]` are supported and are matched diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index c8e77cdd..90b88b41 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -6,7 +6,7 @@ from dataclasses import fields as dataclass_fields from dataclasses import is_dataclass from typing import AbstractSet as TypingAbstractSet -from typing import Any, Dict, FrozenSet, List +from typing import Any, Dict, Final, FrozenSet, List from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence @@ -112,6 +112,20 @@ def is_protocol(type: Any) -> bool: return issubclass(type, Protocol) and getattr(type, "_is_protocol", False) +def is_bare_final(type) -> bool: + return type is Final + + +def get_final_base(type) -> type | None: + """Return the base of the Final annotation, if it is Final.""" + if type is Final: + return Any + elif type.__class__ is _GenericAlias and type.__origin__ is Final: + return type.__args__[0] + else: + return None + + OriginAbstractSet = AbcSet OriginMutableSet = AbcMutableSet diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index a3a13c81..64016c73 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -38,6 +38,7 @@ Sequence, Set, fields, + get_final_base, get_newtype_base, get_origin, has, @@ -160,6 +161,11 @@ def __init__( is_protocol, lambda o: self.unstructure(o, unstructure_as=o.__class__), ), + ( + lambda t: get_final_base(t) is not None, + lambda t: self._unstructure_func.dispatch(get_final_base(t)), + True, + ), (is_mapping, self._unstructure_mapping), (is_sequence, self._unstructure_seq), (is_mutable_set, self._unstructure_seq), @@ -179,6 +185,11 @@ def __init__( (lambda cl: cl is Any 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), + ( + lambda t: get_final_base(t) is not None, + self._structure_final_factory, + True, + ), (is_literal, self._structure_simple_literal), (is_literal_containing_enums, self._structure_enum_literal), (is_sequence, self._structure_list), @@ -442,6 +453,14 @@ def _structure_newtype(self, val, type): base = get_newtype_base(type) return self._structure_func.dispatch(base)(val, base) + def _structure_final_factory(self, type): + base = get_final_base(type) + res = self._structure_func.dispatch(base) + if res == self._structure_call: + # It's not really `structure_call` for Finals (can't call Final()) + return lambda v, _: self._structure_call(v, base) + return res + # Attrs classes. def structure_attrs_fromtuple(self, obj: Tuple[Any, ...], cl: Type[T]) -> T: diff --git a/src/cattrs/gen.py b/src/cattrs/gen.py index 93ce7a3c..6bb4effd 100644 --- a/src/cattrs/gen.py +++ b/src/cattrs/gen.py @@ -36,6 +36,7 @@ get_origin, is_annotated, is_bare, + is_bare_final, is_generic, ) from ._generics import deep_copy_with @@ -142,6 +143,14 @@ def make_dict_unstructure_fn( t = deep_copy_with(t, mapping) if handler is None: + if ( + is_bare_final(t) + and a.default is not NOTHING + and not isinstance(a.default, attr.Factory) + ): + # This is a special case where we can use the + # type of the default to dispatch on. + t = a.default.__class__ try: handler = converter._unstructure_func.dispatch(t) except RecursionError: @@ -256,7 +265,25 @@ def find_structure_handler( if handler == c._structure_error: handler = None elif type is not None: - handler = c._structure_func.dispatch(type) + if ( + is_bare_final(type) + and a.default is not NOTHING + and not isinstance(a.default, attr.Factory) + ): + # This is a special case where we can use the + # type of the default to dispatch on. + type = a.default.__class__ + handler = c._structure_func.dispatch(type) + if handler == c._structure_call: + # Finals can't really be used with _structure_call, so + # we wrap it so the rest of the toolchain doesn't get + # confused. + + def handler(v, _, _h=handler): + return _h(v, type) + + else: + handler = c._structure_func.dispatch(type) else: handler = c.structure return handler @@ -429,20 +456,13 @@ def make_dict_structure_fn( # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be # regenerated. - if a.converter is not None and _cattrs_prefer_attrib_converters: - handler = None - elif ( - a.converter is not None - and not _cattrs_prefer_attrib_converters - and t is not None - ): - handler = converter._structure_func.dispatch(t) - if handler == converter._structure_error: - handler = None - elif t is not None: - handler = converter._structure_func.dispatch(t) + if override.struct_hook is not None: + # If the user has requested an override, just use that. + handler = override.struct_hook else: - handler = converter.structure + handler = find_structure_handler( + a, t, converter, _cattrs_prefer_attrib_converters + ) kn = an if override.rename is None else override.rename allowed_fields.add(kn) @@ -482,20 +502,13 @@ def make_dict_structure_fn( # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be # regenerated. - if a.converter is not None and _cattrs_prefer_attrib_converters: - handler = None - elif ( - a.converter is not None - and not _cattrs_prefer_attrib_converters - and t is not None - ): - handler = converter._structure_func.dispatch(t) - if handler == converter._structure_error: - handler = None - elif t is not None: - handler = converter._structure_func.dispatch(t) + if override.struct_hook is not None: + # If the user has requested an override, just use that. + handler = override.struct_hook else: - handler = converter.structure + handler = find_structure_handler( + a, t, converter, _cattrs_prefer_attrib_converters + ) struct_handler_name = f"__c_structure_{an}" internal_arg_parts[struct_handler_name] = handler diff --git a/tests/test_final.py b/tests/test_final.py new file mode 100644 index 00000000..075edafe --- /dev/null +++ b/tests/test_final.py @@ -0,0 +1,50 @@ +from typing import Final + +from attrs import Factory, define + +from cattrs import Converter + + +@define +class C: + a: Final[int] + + +def test_unstructure_final(genconverter: Converter) -> None: + """Unstructuring should work, and unstructure hooks should work.""" + assert genconverter.unstructure(C(1)) == {"a": 1} + + genconverter.register_unstructure_hook(int, lambda i: str(i)) + assert genconverter.unstructure(C(1)) == {"a": "1"} + + +def test_structure_final(genconverter: Converter) -> None: + """Structuring should work, and structure hooks should work.""" + assert genconverter.structure({"a": 1}, C) == C(1) + + genconverter.register_structure_hook(int, lambda i, _: int(i) + 1) + assert genconverter.structure({"a": "1"}, C) == C(2) + + +@define +class D: + a: Final[int] + b: Final = 5 + c: Final = Factory(lambda: 3) + + +def test_unstructure_bare_final(genconverter: Converter) -> None: + """Unstructuring bare Finals should work, and unstructure hooks should work.""" + assert genconverter.unstructure(D(1)) == {"a": 1, "b": 5, "c": 3} + + genconverter.register_unstructure_hook(int, lambda i: str(i)) + # Bare finals don't work with factories. + assert genconverter.unstructure(D(1)) == {"a": "1", "b": "5", "c": 3} + + +def test_structure_bare_final(genconverter: Converter) -> None: + """Structuring should work, and structure hooks should work.""" + assert genconverter.structure({"a": 1, "b": 3}, D) == D(1, 3) + + genconverter.register_structure_hook(int, lambda i, _: int(i) + 1) + assert genconverter.structure({"a": "1", "b": "3"}, D) == D(2, 4, 3) diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 74ccbd2c..0642f350 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -318,7 +318,10 @@ class A: converter.register_structure_hook( A, make_dict_structure_fn( - A, converter, a=override(struct_hook=lambda v, _: ceil(v)) + A, + converter, + a=override(struct_hook=lambda v, _: ceil(v)), + _cattrs_detailed_validation=converter.detailed_validation, ), ) @@ -336,7 +339,10 @@ class A: converter.register_unstructure_hook( A, make_dict_unstructure_fn( - A, converter, a=override(unstruct_hook=lambda v: v + 1) + A, + converter, + a=override(unstruct_hook=lambda v: v + 1), + _cattrs_detailed_validation=converter.detailed_validation, ), ) From 92d7e3edb923e7c7f4a0afdf23e1419ab3e5562d Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sat, 8 Apr 2023 02:18:51 +0200 Subject: [PATCH 2/4] Maybe fix on 3.7 --- HISTORY.md | 2 +- src/cattrs/_compat.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index dc050db1..c3281ef3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,7 +5,7 @@ - Introduce the `tagged_union` strategy. ([#318](https://github.com/python-attrs/cattrs/pull/318) [#317](https://github.com/python-attrs/cattrs/issues/317)) - Introduce the `cattrs.transform_error` helper function for formatting validation exceptions. ([258](https://github.com/python-attrs/cattrs/issues/258) [342](https://github.com/python-attrs/cattrs/pull/342)) - Add support for `typing.Final`. - ([#340](https://github.com/python-attrs/cattrs/issues/340)) + ([#340](https://github.com/python-attrs/cattrs/issues/340) [#349](https://github.com/python-attrs/cattrs/pull/349)) - Introduce `override.struct_hook` and `override.unstruct_hook`. Learn more [here](https://catt.rs/en/latest/customizing.html#struct-hook-and-unstruct-hook). ([#326](https://github.com/python-attrs/cattrs/pull/326)) - Fix generating structuring functions for types with angle brackets (`<>`) and pipe symbols (`|`) in the name. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 90b88b41..132f3861 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -6,7 +6,7 @@ from dataclasses import fields as dataclass_fields from dataclasses import is_dataclass from typing import AbstractSet as TypingAbstractSet -from typing import Any, Dict, Final, FrozenSet, List +from typing import Any, Dict, FrozenSet, List from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence @@ -34,10 +34,10 @@ def get_args(cl): def get_origin(cl): return getattr(cl, "__origin__", None) - from typing_extensions import Protocol + from typing_extensions import Final, Protocol else: - from typing import Protocol, get_args, get_origin # NOQA + from typing import Final, Protocol, get_args, get_origin # NOQA if "ExceptionGroup" not in dir(builtins): from exceptiongroup import ExceptionGroup From 3aaefe3b55e4e98fcd2bc20e79f973adae33bd94 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sat, 8 Apr 2023 02:22:27 +0200 Subject: [PATCH 3/4] Revert union syntax --- src/cattrs/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 132f3861..18106eff 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -116,7 +116,7 @@ def is_bare_final(type) -> bool: return type is Final -def get_final_base(type) -> type | None: +def get_final_base(type) -> Optional[type]: """Return the base of the Final annotation, if it is Final.""" if type is Final: return Any From 59121822dee6d9aff141d1039228932c303727c5 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sat, 8 Apr 2023 02:25:27 +0200 Subject: [PATCH 4/4] More 3.7 tweaks --- tests/test_final.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_final.py b/tests/test_final.py index 075edafe..5f6680e8 100644 --- a/tests/test_final.py +++ b/tests/test_final.py @@ -1,8 +1,7 @@ -from typing import Final - from attrs import Factory, define from cattrs import Converter +from cattrs._compat import Final @define