Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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