Skip to content

Commit

Permalink
type aliases: support generics (#618)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche authored Jan 7, 2025
1 parent 5c5876e commit 527291f
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 95 deletions.
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

0 comments on commit 527291f

Please sign in to comment.