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 @@ -10,7 +10,7 @@ The third number is for emergencies when we need to start branches for older rel
Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md).


## 24.2.0 (UNRELEASED)
## 25.1.0 (UNRELEASED)

- **Potentially breaking**: The converters raise {class}`StructureHandlerNotFoundError` more eagerly (on hook creation, instead of on hook use).
This helps surfacing problems with missing hooks sooner.
Expand All @@ -22,6 +22,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
- Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and
{func}`cattrs.cols.is_defaultdict` and {func}`cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.
([#519](https://github.com/python-attrs/cattrs/issues/519) [#588](https://github.com/python-attrs/cattrs/pull/588))
- Generic PEP 695 type aliases are now supported.
([#611](https://github.com/python-attrs/cattrs/issues/611) [#618](https://github.com/python-attrs/cattrs/pull/618))
- Many preconf converters (_bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_, _ujson_) skip unstructuring `int` and `str` enums,
leaving them to the underlying libraries to handle with greater efficiency.
([#598](https://github.com/python-attrs/cattrs/pull/598))
Expand Down
89 changes: 43 additions & 46 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 0 additions & 26 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
Optional,
Protocol,
Tuple,
TypedDict,
Union,
_AnnotatedAlias,
_GenericAlias,
Expand Down Expand Up @@ -53,12 +52,9 @@
"fields_dict",
"ExceptionGroup",
"ExtensionsTypedDict",
"get_type_alias_base",
"has",
"is_type_alias",
"is_typeddict",
"TypeAlias",
"TypedDict",
]

try:
Expand Down Expand Up @@ -112,20 +108,6 @@ def is_typeddict(cls: Any):
return _is_typeddict(getattr(cls, "__origin__", cls))


def is_type_alias(type: Any) -> bool:
"""Is this a PEP 695 type alias?"""
return False


def get_type_alias_base(type: Any) -> Any:
"""
What is this a type alias of?

Works only on 3.12+.
"""
return type.__value__


def has(cls):
return hasattr(cls, "__attrs_attrs__") or hasattr(cls, "__dataclass_fields__")

Expand Down Expand Up @@ -273,14 +255,6 @@ def is_tuple(type):
)


if sys.version_info >= (3, 12):
from typing import TypeAliasType

def is_type_alias(type: Any) -> bool:
"""Is this a PEP 695 type alias?"""
return isinstance(type, TypeAliasType)


if sys.version_info >= (3, 10):

def is_union_type(obj):
Expand Down
17 changes: 6 additions & 11 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
get_final_base,
get_newtype_base,
get_origin,
get_type_alias_base,
has,
has_with_generic,
is_annotated,
Expand All @@ -48,7 +47,6 @@
is_protocol,
is_sequence,
is_tuple,
is_type_alias,
is_typeddict,
is_union_type,
signature,
Expand Down Expand Up @@ -92,6 +90,11 @@
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 SimpleStructureHook

__all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"]
Expand Down Expand Up @@ -259,7 +262,7 @@ def __init__(
),
(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),
(is_type_alias, type_alias_structure_factory, "extended"),
(
lambda t: get_final_base(t) is not None,
self._structure_final_factory,
Expand Down Expand Up @@ -699,14 +702,6 @@ def _structure_newtype(self, val: UnstructuredValue, type) -> StructuredValue:
base = get_newtype_base(type)
return self.get_structure_hook(base)(val, base)

def _find_type_alias_structure_hook(self, type: Any) -> StructureHook:
base = get_type_alias_base(type)
res = self.get_structure_hook(base)
if res == self._structure_call:
# we need to replace the type arg of `structure_call`
return lambda v, _, __base=base: __base(v)
return lambda v, _, __base=base: res(v, __base)

def _structure_final_factory(self, type):
base = get_final_base(type)
res = self.get_structure_hook(base)
Expand Down
3 changes: 1 addition & 2 deletions src/cattrs/gen/typeddicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import re
import sys
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict, TypeVar

from attrs import NOTHING, Attribute
from typing_extensions import _TypedDictMeta
Expand All @@ -20,7 +20,6 @@ def get_annots(cl) -> dict[str, Any]:


from .._compat import (
TypedDict,
get_full_type_hints,
get_notrequired_base,
get_origin,
Expand Down
57 changes: 57 additions & 0 deletions src/cattrs/typealiases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Utilities for type aliases."""

from __future__ import annotations

import sys
from typing import TYPE_CHECKING, Any

from ._compat import is_generic
from ._generics import deep_copy_with
from .dispatch import StructureHook
from .gen._generics import generate_mapping

if TYPE_CHECKING:
from .converters import BaseConverter

__all__ = ["is_type_alias", "get_type_alias_base", "type_alias_structure_factory"]

if sys.version_info >= (3, 12):
from types import GenericAlias
from typing import TypeAliasType

def is_type_alias(type: Any) -> bool:
"""Is this a PEP 695 type alias?"""
return isinstance(
type.__origin__ if type.__class__ is GenericAlias else type, TypeAliasType
)

else:

def is_type_alias(type: Any) -> bool:
"""Is this a PEP 695 type alias?"""
return False


def get_type_alias_base(type: Any) -> Any:
"""
What is this a type alias of?

Works only on 3.12+.
"""
return type.__value__


def type_alias_structure_factory(type: Any, converter: BaseConverter) -> StructureHook:
base = get_type_alias_base(type)
if is_generic(type):
mapping = generate_mapping(type)
if base.__name__ in mapping:
# Probably just type T = T
base = mapping[base.__name__]
else:
base = deep_copy_with(base, mapping)
res = converter.get_structure_hook(base)
if res == converter._structure_call:
# we need to replace the type arg of `structure_call`
return lambda v, _, __base=base: __base(v)
return lambda v, _, __base=base: res(v, __base)
16 changes: 16 additions & 0 deletions tests/test_generics_695.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,19 @@ def structure_testclass(val, type):

type TestAlias = TestClass
assert converter.structure(None, TestAlias) is TestClass


def test_generic_type_alias(converter: BaseConverter):
"""Generic type aliases work.

See https://docs.python.org/3/reference/compound_stmts.html#generic-type-aliases
for details.
"""

type Gen1[T] = T

assert converter.structure("1", Gen1[int]) == 1

type Gen2[K, V] = dict[K, V]

assert converter.structure({"a": "1"}, Gen2[str, int]) == {"a": 1}
Loading
Loading