Skip to content

Commit

Permalink
Implement Final (#349)
Browse files Browse the repository at this point in the history
* Implement Final

* Maybe fix on 3.7

* Revert union syntax

* More 3.7 tweaks
  • Loading branch information
Tinche authored Apr 8, 2023
1 parent b5de29d commit 5ccd616
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 34 deletions.
4 changes: 3 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) [#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)
([#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.
Expand Down
16 changes: 14 additions & 2 deletions docs/structuring.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/unstructuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) -> Optional[type]:
"""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

Expand Down
19 changes: 19 additions & 0 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
Sequence,
Set,
fields,
get_final_base,
get_newtype_base,
get_origin,
has,
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
67 changes: 40 additions & 27 deletions src/cattrs/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
get_origin,
is_annotated,
is_bare,
is_bare_final,
is_generic,
)
from ._generics import deep_copy_with
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/test_final.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from attrs import Factory, define

from cattrs import Converter
from cattrs._compat import Final


@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)
10 changes: 8 additions & 2 deletions tests/test_gen_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)

Expand All @@ -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,
),
)

Expand Down

0 comments on commit 5ccd616

Please sign in to comment.