diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c86a2f52..401bcc3f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] fail-fast: false steps: diff --git a/HISTORY.md b/HISTORY.md index 2d5e3a34..7c7a2300 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -21,6 +21,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python - Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`. - Python 3.13 is now supported. ([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547)) +- Python 3.8 is no longer supported, as it is end-of-life. Use previous versions on this Python version. ## 24.1.2 (2024-09-22) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 46b1fc56..fb819555 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -287,13 +287,11 @@ Sets and frozensets are unstructured into the same class. {'a': 1} ``` -Both [_total_ and _non-total_](https://peps.python.org/pep-0589/#totality) TypedDicts are supported, and inheritance between any combination works (except on 3.8 when `typing.TypedDict` is used, see below). +Both [_total_ and _non-total_](https://peps.python.org/pep-0589/#totality) TypedDicts are supported, and inheritance between any combination works. Generic TypedDicts work on Python 3.11 and later, since that is the first Python version that supports them in general. [`typing.Required` and `typing.NotRequired`](https://peps.python.org/pep-0655/) are supported. -On Python 3.8, using `typing_extensions.TypedDict` is recommended since `typing.TypedDict` doesn't support all necessary features so certain combinations of subclassing, totality and `typing.Required` won't work. - [Similar to _attrs_ classes](customizing.md#using-cattrsgen-generators), un/structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn` and {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. ```{doctest} diff --git a/pyproject.toml b/pyproject.toml index 741a1ec7..0a562cbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions>=4.12.2", "exceptiongroup>=1.1.1; python_version < '3.11'", ] -requires-python = ">=3.8" +requires-python = ">=3.9" readme = "README.md" license = {text = "MIT"} keywords = ["attrs", "serialization", "dataclasses"] @@ -54,7 +54,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -150,6 +149,8 @@ ignore = [ "B006", # mutable argument defaults "DTZ001", # datetimes in tests "DTZ006", # datetimes in tests + "UP006", # We support old typing constructs at runtime + "UP035", # We support old typing constructs at runtime ] [tool.ruff.lint.pyupgrade] diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 027ef477..e7a87820 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -1,32 +1,41 @@ import sys -from collections import deque +from collections import Counter, deque from collections.abc import Mapping as AbcMapping from collections.abc import MutableMapping as AbcMutableMapping +from collections.abc import MutableSequence as AbcMutableSequence from collections.abc import MutableSet as AbcMutableSet +from collections.abc import Sequence as AbcSequence from collections.abc import Set as AbcSet from dataclasses import MISSING, Field, is_dataclass from dataclasses import fields as dataclass_fields from functools import partial from inspect import signature as _signature -from typing import AbstractSet as TypingAbstractSet +from types import GenericAlias from typing import ( + Annotated, Any, Deque, Dict, Final, FrozenSet, + Generic, List, Literal, NewType, Optional, Protocol, Tuple, - Type, + TypedDict, Union, + _AnnotatedAlias, + _GenericAlias, + _SpecialGenericAlias, + _UnionGenericAlias, get_args, get_origin, get_type_hints, ) +from typing import Counter as TypingCounter from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence @@ -94,11 +103,11 @@ NoneType = type(None) -def is_optional(typ: Type) -> bool: +def is_optional(typ: Any) -> bool: return is_union_type(typ) and NoneType in typ.__args__ and len(typ.__args__) == 2 -def is_typeddict(cls): +def is_typeddict(cls: Any): """Thin wrapper around typing(_extensions).is_typeddict""" return _is_typeddict(getattr(cls, "__origin__", cls)) @@ -133,14 +142,14 @@ def fields(type): return dataclass_fields(type) -def fields_dict(type) -> Dict[str, Union[Attribute, Field]]: +def fields_dict(type) -> dict[str, Union[Attribute, Field]]: """Return the fields_dict for attrs and dataclasses.""" if is_dataclass(type): return {f.name: f for f in dataclass_fields(type)} return attrs_fields_dict(type) -def adapted_fields(cl) -> List[Attribute]: +def adapted_fields(cl) -> list[Attribute]: """Return the attrs format of `fields()` for attrs and dataclasses.""" if is_dataclass(cl): attrs = dataclass_fields(cl) @@ -219,261 +228,86 @@ def get_final_base(type) -> Optional[type]: if sys.version_info >= (3, 10): signature = partial(_signature, eval_str=True) -if sys.version_info >= (3, 9): - from collections import Counter - from collections.abc import MutableSequence as AbcMutableSequence - from collections.abc import MutableSet as AbcMutableSet - from collections.abc import Sequence as AbcSequence - from collections.abc import Set as AbcSet - from types import GenericAlias - from typing import ( - Annotated, - Generic, - TypedDict, - Union, - _AnnotatedAlias, - _GenericAlias, - _SpecialGenericAlias, - _UnionGenericAlias, - ) - from typing import Counter as TypingCounter - - try: - # Not present on 3.9.0, so we try carefully. - from typing import _LiteralGenericAlias - - def is_literal(type) -> bool: - return type in LITERALS or ( - isinstance( - type, (_GenericAlias, _LiteralGenericAlias, _SpecialGenericAlias) - ) - and type.__origin__ in LITERALS - ) - - except ImportError: # pragma: no cover - - def is_literal(_) -> bool: - return False - Set = AbcSet - AbstractSet = AbcSet - MutableSet = AbcMutableSet - Sequence = AbcSequence - MutableSequence = AbcMutableSequence - MutableMapping = AbcMutableMapping - Mapping = AbcMapping - FrozenSetSubscriptable = frozenset - TupleSubscriptable = tuple - - def is_annotated(type) -> bool: - return getattr(type, "__class__", None) is _AnnotatedAlias +try: + # Not present on 3.9.0, so we try carefully. + from typing import _LiteralGenericAlias - def is_tuple(type): - return ( - type in (Tuple, tuple) - or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, Tuple)) - or (getattr(type, "__origin__", None) is tuple) + def is_literal(type) -> bool: + return type in LITERALS or ( + isinstance( + type, (_GenericAlias, _LiteralGenericAlias, _SpecialGenericAlias) + ) + and type.__origin__ in LITERALS ) - 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): - from types import UnionType +except ImportError: # pragma: no cover - return ( - obj is Union - or (isinstance(obj, _UnionGenericAlias) and obj.__origin__ is Union) - or isinstance(obj, UnionType) - ) + def is_literal(_) -> bool: + return False - def get_newtype_base(typ: Any) -> Optional[type]: - if typ is NewType or isinstance(typ, NewType): - return typ.__supertype__ - return None - if sys.version_info >= (3, 11): - from typing import NotRequired, Required - else: - from typing_extensions import NotRequired, Required +Set = AbcSet +AbstractSet = AbcSet +MutableSet = AbcMutableSet +Sequence = AbcSequence +MutableSequence = AbcMutableSequence +MutableMapping = AbcMutableMapping +Mapping = AbcMapping +FrozenSetSubscriptable = frozenset +TupleSubscriptable = tuple - else: - from typing_extensions import NotRequired, Required - def is_union_type(obj): - return ( - obj is Union - or isinstance(obj, _UnionGenericAlias) - and obj.__origin__ is Union - ) +def is_annotated(type) -> bool: + return getattr(type, "__class__", None) is _AnnotatedAlias - def get_newtype_base(typ: Any) -> Optional[type]: - supertype = getattr(typ, "__supertype__", None) - if ( - supertype is not None - and getattr(typ, "__qualname__", "") == "NewType..new_type" - and typ.__module__ in ("typing", "typing_extensions") - ): - return supertype - return None - - def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]": - if is_annotated(type): - # Handle `Annotated[NotRequired[int]]` - type = get_args(type)[0] - if get_origin(type) in (NotRequired, Required): - return get_args(type)[0] - return NOTHING - - def is_sequence(type: Any) -> bool: - """A predicate function for sequences. - - Matches lists, sequences, mutable sequences, deques and homogenous - tuples. - """ - origin = getattr(type, "__origin__", None) - return ( - type - in ( - List, - list, - TypingSequence, - TypingMutableSequence, - AbcMutableSequence, - tuple, - Tuple, - deque, - Deque, - ) - or ( - type.__class__ is _GenericAlias - and ( - (origin is not tuple) - and is_subclass(origin, TypingSequence) - or origin is tuple - and type.__args__[1] is ... - ) - ) - or (origin in (list, deque, AbcMutableSequence, AbcSequence)) - or (origin is tuple and type.__args__[1] is ...) - ) - def is_deque(type): - return ( - type in (deque, Deque) - or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, deque)) - or (getattr(type, "__origin__", None) is deque) - ) +def is_tuple(type): + return ( + type in (Tuple, tuple) + or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, Tuple)) + or (getattr(type, "__origin__", None) is tuple) + ) - def is_mutable_set(type: Any) -> bool: - """A predicate function for (mutable) sets. - Matches built-in sets and sets from the typing module. - """ - return ( - type in (TypingSet, TypingMutableSet, set) - or ( - type.__class__ is _GenericAlias - and is_subclass(type.__origin__, TypingMutableSet) - ) - or (getattr(type, "__origin__", None) in (set, AbcMutableSet, AbcSet)) - ) +if sys.version_info >= (3, 12): + from typing import TypeAliasType - def is_frozenset(type: Any) -> bool: - """A predicate function for frozensets. + def is_type_alias(type: Any) -> bool: + """Is this a PEP 695 type alias?""" + return isinstance(type, TypeAliasType) - Matches built-in frozensets and frozensets from the typing module. - """ - return ( - type in (FrozenSet, frozenset) - or ( - type.__class__ is _GenericAlias - and is_subclass(type.__origin__, FrozenSet) - ) - or (getattr(type, "__origin__", None) is frozenset) - ) - def is_bare(type): - return isinstance(type, _SpecialGenericAlias) or ( - not hasattr(type, "__origin__") and not hasattr(type, "__args__") - ) +if sys.version_info >= (3, 10): - def is_mapping(type: Any) -> bool: - """A predicate function for mappings.""" - return ( - type in (dict, Dict, TypingMapping, TypingMutableMapping, AbcMutableMapping) - or ( - type.__class__ is _GenericAlias - and is_subclass(type.__origin__, TypingMapping) - ) - or is_subclass( - getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping) - ) - ) + def is_union_type(obj): + from types import UnionType - def is_counter(type): return ( - type in (Counter, TypingCounter) - or getattr(type, "__origin__", None) is Counter - ) - - def is_generic(type) -> bool: - """Whether `type` is a generic type.""" - # Inheriting from protocol will inject `Generic` into the MRO - # without `__orig_bases__`. - return isinstance(type, (_GenericAlias, GenericAlias)) or ( - is_subclass(type, Generic) and hasattr(type, "__orig_bases__") + obj is Union + or (isinstance(obj, _UnionGenericAlias) and obj.__origin__ is Union) + or isinstance(obj, UnionType) ) - def copy_with(type, args): - """Replace a generic type's arguments.""" - if is_annotated(type): - # typing.Annotated requires a special case. - return Annotated[args] - if isinstance(args, tuple) and len(args) == 1: - # Some annotations can't handle 1-tuples. - args = args[0] - return type.__origin__[args] + def get_newtype_base(typ: Any) -> Optional[type]: + if typ is NewType or isinstance(typ, NewType): + return typ.__supertype__ + return None - def get_full_type_hints(obj, globalns=None, localns=None): - return get_type_hints(obj, globalns, localns, include_extras=True) + if sys.version_info >= (3, 11): + from typing import NotRequired, Required + else: + from typing_extensions import NotRequired, Required else: - # 3.8 - Set = TypingSet - AbstractSet = TypingAbstractSet - MutableSet = TypingMutableSet - - Sequence = TypingSequence - MutableSequence = TypingMutableSequence - MutableMapping = TypingMutableMapping - Mapping = TypingMapping - FrozenSetSubscriptable = FrozenSet - TupleSubscriptable = Tuple - - from collections import Counter as ColCounter - from typing import Counter, Generic, TypedDict, Union, _GenericAlias - - from typing_extensions import Annotated, NotRequired, Required - from typing_extensions import get_origin as te_get_origin - - def is_annotated(type) -> bool: - return te_get_origin(type) is Annotated - - def is_tuple(type): - return type in (Tuple, tuple) or ( - type.__class__ is _GenericAlias and is_subclass(type.__origin__, Tuple) - ) + # 3.9 + from typing_extensions import NotRequired, Required def is_union_type(obj): return ( - obj is Union or isinstance(obj, _GenericAlias) and obj.__origin__ is Union + obj is Union + or isinstance(obj, _UnionGenericAlias) + and obj.__origin__ is Union ) def get_newtype_base(typ: Any) -> Optional[type]: @@ -486,91 +320,133 @@ def get_newtype_base(typ: Any) -> Optional[type]: return supertype return None - def is_sequence(type: Any) -> bool: - return type in (List, list, Tuple, tuple) or ( + +def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]": + if is_annotated(type): + # Handle `Annotated[NotRequired[int]]` + type = get_args(type)[0] + if get_origin(type) in (NotRequired, Required): + return get_args(type)[0] + return NOTHING + + +def is_sequence(type: Any) -> bool: + """A predicate function for sequences. + + Matches lists, sequences, mutable sequences, deques and homogenous + tuples. + """ + origin = getattr(type, "__origin__", None) + return ( + type + in ( + List, + list, + TypingSequence, + TypingMutableSequence, + AbcMutableSequence, + tuple, + Tuple, + deque, + Deque, + ) + or ( type.__class__ is _GenericAlias and ( - type.__origin__ not in (Union, Tuple, tuple) - and is_subclass(type.__origin__, TypingSequence) + (origin is not tuple) + and is_subclass(origin, TypingSequence) + or origin is tuple + and type.__args__[1] is ... ) - or (type.__origin__ in (Tuple, tuple) and type.__args__[1] is ...) ) + or (origin in (list, deque, AbcMutableSequence, AbcSequence)) + or (origin is tuple and type.__args__[1] is ...) + ) - def is_deque(type: Any) -> bool: - return ( - type in (deque, Deque) - or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, deque)) - or type.__origin__ is deque - ) - def is_mutable_set(type) -> bool: - return type in (set, TypingAbstractSet) or ( +def is_deque(type): + return ( + type in (deque, Deque) + or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, deque)) + or (getattr(type, "__origin__", None) is deque) + ) + + +def is_mutable_set(type: Any) -> bool: + """A predicate function for (mutable) sets. + + Matches built-in sets and sets from the typing module. + """ + return ( + type in (TypingSet, TypingMutableSet, set) + or ( type.__class__ is _GenericAlias - and is_subclass(type.__origin__, (MutableSet, TypingAbstractSet)) + and is_subclass(type.__origin__, TypingMutableSet) ) + or (getattr(type, "__origin__", None) in (set, AbcMutableSet, AbcSet)) + ) - def is_frozenset(type): - return type is frozenset or ( - type.__class__ is _GenericAlias and is_subclass(type.__origin__, FrozenSet) - ) - def is_mapping(type: Any) -> bool: - """A predicate function for mappings.""" - return ( - type in (TypingMapping, dict) - or ( - type.__class__ is _GenericAlias - and is_subclass(type.__origin__, TypingMapping) - ) - or is_subclass( - getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping) - ) - ) +def is_frozenset(type: Any) -> bool: + """A predicate function for frozensets. - bare_generic_args = { - List.__args__, - TypingSequence.__args__, - TypingMapping.__args__, - Dict.__args__, - TypingMutableSequence.__args__, - Tuple.__args__, - None, # non-parametrized containers do not have `__args__ attribute in py3.7-8 - } + Matches built-in frozensets and frozensets from the typing module. + """ + return ( + type in (FrozenSet, frozenset) + or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, FrozenSet)) + or (getattr(type, "__origin__", None) is frozenset) + ) - def is_bare(type): - return getattr(type, "__args__", None) in bare_generic_args - def is_counter(type): - return ( - type in (Counter, ColCounter) - or getattr(type, "__origin__", None) is ColCounter - ) +def is_bare(type): + return isinstance(type, _SpecialGenericAlias) or ( + not hasattr(type, "__origin__") and not hasattr(type, "__args__") + ) - def is_literal(type) -> bool: - return type in LITERALS or ( - isinstance(type, _GenericAlias) and type.__origin__ in LITERALS - ) - def is_generic(obj): - return isinstance(obj, _GenericAlias) or ( - is_subclass(obj, Generic) and hasattr(obj, "__orig_bases__") +def is_mapping(type: Any) -> bool: + """A predicate function for mappings.""" + return ( + type in (dict, Dict, TypingMapping, TypingMutableMapping, AbcMutableMapping) + or ( + type.__class__ is _GenericAlias + and is_subclass(type.__origin__, TypingMapping) + ) + or is_subclass( + getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping) ) + ) + + +def is_counter(type): + return ( + type in (Counter, TypingCounter) or getattr(type, "__origin__", None) is Counter + ) + + +def is_generic(type) -> bool: + """Whether `type` is a generic type.""" + # Inheriting from protocol will inject `Generic` into the MRO + # without `__orig_bases__`. + return isinstance(type, (_GenericAlias, GenericAlias)) or ( + is_subclass(type, Generic) and hasattr(type, "__orig_bases__") + ) - def copy_with(type, args): - """Replace a generic type's arguments.""" - return type.copy_with(args) - def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]": - if is_annotated(type): - # Handle `Annotated[NotRequired[int]]` - type = get_origin(type) +def copy_with(type, args): + """Replace a generic type's arguments.""" + if is_annotated(type): + # typing.Annotated requires a special case. + return Annotated[args] + if isinstance(args, tuple) and len(args) == 1: + # Some annotations can't handle 1-tuples. + args = args[0] + return type.__origin__[args] - if get_origin(type) in (NotRequired, Required): - return get_args(type)[0] - return NOTHING - def get_full_type_hints(obj, globalns=None, localns=None): - return get_type_hints(obj, globalns, localns) +def get_full_type_hints(obj, globalns=None, localns=None): + return get_type_hints(obj, globalns, localns, include_extras=True) def is_generic_attrs(type) -> bool: diff --git a/src/cattrs/_generics.py b/src/cattrs/_generics.py index c473f433..a982bb10 100644 --- a/src/cattrs/_generics.py +++ b/src/cattrs/_generics.py @@ -1,4 +1,5 @@ -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from ._compat import copy_with, get_args, is_annotated, is_generic diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index 40a79f17..43d225f8 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -2,17 +2,8 @@ from __future__ import annotations -from sys import version_info -from typing import ( - TYPE_CHECKING, - Any, - Iterable, - Literal, - NamedTuple, - Tuple, - TypeVar, - get_type_hints, -) +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, get_type_hints from attrs import NOTHING, Attribute @@ -58,34 +49,15 @@ def is_any_set(type) -> bool: return is_set(type) or is_frozenset(type) -if version_info[:2] >= (3, 9): - - def is_namedtuple(type: Any) -> bool: - """A predicate function for named tuples.""" - - if is_subclass(type, tuple): - for cl in type.mro(): - orig_bases = cl.__dict__.get("__orig_bases__", ()) - if NamedTuple in orig_bases: - return True - return False - -else: - - def is_namedtuple(type: Any) -> bool: - """A predicate function for named tuples.""" - # This is tricky. It may not be possible for this function to be 100% - # accurate, since it doesn't seem like we can distinguish between tuple - # subclasses and named tuples reliably. +def is_namedtuple(type: Any) -> bool: + """A predicate function for named tuples.""" - if is_subclass(type, tuple): - for cl in type.mro(): - if cl is tuple: - # No point going further. - break - if "_fields" in cl.__dict__: - return True - return False + if is_subclass(type, tuple): + for cl in type.mro(): + orig_bases = cl.__dict__.get("__orig_bases__", ()) + if NamedTuple in orig_bases: + return True + return False def _is_passthrough(type: type[tuple], converter: BaseConverter) -> bool: @@ -182,7 +154,7 @@ def namedtuple_structure_factory( ) -> StructureHook: """A hook factory for structuring namedtuples from iterables.""" # We delegate to the existing infrastructure for heterogenous tuples. - hetero_tuple_type = Tuple[tuple(cl.__annotations__.values())] + hetero_tuple_type = tuple[tuple(cl.__annotations__.values())] base_hook = converter.get_structure_hook(hetero_tuple_type) return lambda v, _: cl(*base_hook(v, hetero_tuple_type)) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 3e67bd7f..1218a71d 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1,15 +1,15 @@ from __future__ import annotations from collections import Counter, deque +from collections.abc import Iterable from collections.abc import Mapping as AbcMapping from collections.abc import MutableMapping as AbcMutableMapping -from collections.abc import MutableSet as AbcMutableSet from dataclasses import Field from enum import Enum from inspect import Signature from inspect import signature as inspect_signature from pathlib import Path -from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar, overload +from typing import Any, Callable, Optional, Tuple, TypeVar, overload from attrs import Attribute, resolve_types from attrs import has as attrs_has @@ -1093,7 +1093,6 @@ def __init__( if OriginAbstractSet in co: if OriginMutableSet not in co: co[OriginMutableSet] = co[OriginAbstractSet] - co[AbcMutableSet] = co[OriginAbstractSet] # For 3.8 compatibility. if FrozenSetSubscriptable not in co: co[FrozenSetSubscriptable] = co[OriginAbstractSet] @@ -1101,9 +1100,6 @@ def __init__( if OriginMutableSet in co and set not in co: co[set] = co[OriginMutableSet] - if FrozenSetSubscriptable in co: - co[frozenset] = co[FrozenSetSubscriptable] # For 3.8 compatibility. - # abc.Sequence overrides, if defined, can apply to MutableSequences, lists and # tuples if Sequence in co: diff --git a/src/cattrs/disambiguators.py b/src/cattrs/disambiguators.py index ad36ae3b..83e8c3f1 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Mapping from dataclasses import MISSING from functools import reduce from operator import or_ -from typing import TYPE_CHECKING, Any, Callable, Literal, Mapping, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Union from attrs import NOTHING, Attribute, AttrsInstance diff --git a/src/cattrs/errors.py b/src/cattrs/errors.py index 9148bf10..2da3145c 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Set, Tuple, Type, Union +from typing import Any, Optional, Union from cattrs._compat import ExceptionGroup @@ -9,15 +9,15 @@ class StructureHandlerNotFoundError(Exception): :attr:`type_`. """ - def __init__(self, message: str, type_: Type) -> None: + def __init__(self, message: str, type_: type) -> None: super().__init__(message) self.type_ = type_ class BaseValidationError(ExceptionGroup): - cl: Type + cl: type - def __new__(cls, message, excs, cl: Type): + def __new__(cls, message, excs, cl: type): obj = super().__new__(cls, message, excs) obj.cl = cl return obj @@ -40,7 +40,7 @@ def __new__( instance.type = type return instance - def __getnewargs__(self) -> Tuple[str, Union[int, str], Any]: + def __getnewargs__(self) -> tuple[str, Union[int, str], Any]: return (str(self), self.index, self.type) @@ -49,7 +49,7 @@ class IterableValidationError(BaseValidationError): def group_exceptions( self, - ) -> Tuple[List[Tuple[Exception, IterableValidationNote]], List[Exception]]: + ) -> tuple[list[tuple[Exception, IterableValidationNote]], list[Exception]]: """Split the exceptions into two groups: with and without validation notes.""" excs_with_notes = [] other_excs = [] @@ -79,7 +79,7 @@ def __new__(cls, string: str, name: str, type: Any) -> "AttributeValidationNote" instance.type = type return instance - def __getnewargs__(self) -> Tuple[str, str, Any]: + def __getnewargs__(self) -> tuple[str, str, Any]: return (str(self), self.name, self.type) @@ -88,7 +88,7 @@ class ClassValidationError(BaseValidationError): def group_exceptions( self, - ) -> Tuple[List[Tuple[Exception, AttributeValidationNote]], List[Exception]]: + ) -> tuple[list[tuple[Exception, AttributeValidationNote]], list[Exception]]: """Split the exceptions into two groups: with and without validation notes.""" excs_with_notes = [] other_excs = [] @@ -117,7 +117,7 @@ class ForbiddenExtraKeysError(Exception): """ def __init__( - self, message: Optional[str], cl: Type, extra_fields: Set[str] + self, message: Optional[str], cl: type, extra_fields: set[str] ) -> None: self.cl = cl self.extra_fields = extra_fields diff --git a/src/cattrs/fns.py b/src/cattrs/fns.py index 748cfb3d..984c05eb 100644 --- a/src/cattrs/fns.py +++ b/src/cattrs/fns.py @@ -1,6 +1,6 @@ """Useful internal functions.""" -from typing import Any, Callable, NoReturn, Type, TypeVar +from typing import Any, Callable, NoReturn, TypeVar from ._compat import TypeAlias from .errors import StructureHandlerNotFoundError @@ -16,7 +16,7 @@ def identity(obj: T) -> T: return obj -def raise_error(_, cl: Type) -> NoReturn: +def raise_error(_, cl: Any) -> NoReturn: """At the bottom of the condition stack, we explode if we can't handle it.""" msg = f"Unsupported type: {cl!r}. Register a structure hook for it." raise StructureHandlerNotFoundError(msg, type_=cl) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index f89f63fe..cfaddc9d 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -1,17 +1,8 @@ from __future__ import annotations import re -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Final, - Iterable, - Literal, - Mapping, - Tuple, - TypeVar, -) +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, Callable, Final, Literal, TypeVar from attrs import NOTHING, Attribute, Factory, resolve_types from typing_extensions import NoDefault @@ -793,7 +784,7 @@ def make_dict_structure_fn( #: A type alias for heterogeneous tuple unstructure hooks. -HeteroTupleUnstructureFn: TypeAlias = Callable[[Tuple[Any, ...]], Any] +HeteroTupleUnstructureFn: TypeAlias = Callable[[tuple[Any, ...]], Any] def make_hetero_tuple_unstructure_fn( diff --git a/src/cattrs/gen/_lc.py b/src/cattrs/gen/_lc.py index 04843cd3..71e8b61d 100644 --- a/src/cattrs/gen/_lc.py +++ b/src/cattrs/gen/_lc.py @@ -1,10 +1,9 @@ """Line-cache functionality.""" import linecache -from typing import List -def generate_unique_filename(cls: type, func_name: str, lines: List[str] = []) -> str: +def generate_unique_filename(cls: type, func_name: str, lines: list[str] = []) -> str: """ Create a "filename" suitable for a function being generated. diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index 5614d6f8..d2474e5d 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -547,8 +547,8 @@ def _is_extensions_typeddict(cls) -> bool: def _required_keys(cls: type) -> set[str]: return cls.__required_keys__ -elif sys.version_info >= (3, 9): - from typing_extensions import Annotated, NotRequired, Required, get_args +else: + from typing_extensions import Annotated, NotRequired, get_args # Note that there is no `typing.Required` on 3.9 and 3.10, only in # `typing_extensions`. Therefore, `typing.TypedDict` will not honor this @@ -563,7 +563,7 @@ def _required_keys(cls: type) -> set[str]: # gathering required keys. *sigh* own_annotations = cls.__dict__.get("__annotations__", {}) required_keys = set() - # On 3.8 - 3.10, typing.TypedDict doesn't put typeddict superclasses + # On 3.9 - 3.10, typing.TypedDict doesn't put typeddict superclasses # in the MRO, therefore we cannot handle non-required keys properly # in some situations. Oh well. for key in getattr(cls, "__required_keys__", []): @@ -580,32 +580,3 @@ def _required_keys(cls: type) -> set[str]: elif cls.__total__: required_keys.add(key) return required_keys - -else: - from typing_extensions import Annotated, NotRequired, Required, get_args - - # On 3.8, typing.TypedDicts do not have __required_keys__. - - def _required_keys(cls: type) -> set[str]: - """Our own processor for required keys.""" - if _is_extensions_typeddict(cls): - return cls.__required_keys__ - - own_annotations = cls.__dict__.get("__annotations__", {}) - required_keys = set() - for key in own_annotations: - annotation_type = own_annotations[key] - - if is_annotated(annotation_type): - # If this is `Annotated`, we need to get the origin twice. - annotation_type = get_origin(annotation_type) - - annotation_origin = get_origin(annotation_type) - - if annotation_origin is Required: - required_keys.add(key) - elif annotation_origin is NotRequired: - pass - elif cls.__total__: - required_keys.add(key) - return required_keys diff --git a/src/cattrs/preconf/bson.py b/src/cattrs/preconf/bson.py index e73d1316..0d8f5c65 100644 --- a/src/cattrs/preconf/bson.py +++ b/src/cattrs/preconf/bson.py @@ -2,7 +2,7 @@ from base64 import b85decode, b85encode from datetime import date, datetime -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from bson import DEFAULT_CODEC_OPTIONS, CodecOptions, Int64, ObjectId, decode, encode @@ -38,7 +38,7 @@ def dumps( def loads( self, data: bytes, - cl: Type[T], + cl: type[T], codec_options: CodecOptions = DEFAULT_CODEC_OPTIONS, ) -> T: return self.structure(decode(data, codec_options=codec_options), cl) diff --git a/src/cattrs/preconf/cbor2.py b/src/cattrs/preconf/cbor2.py index 73a9a972..63600c6a 100644 --- a/src/cattrs/preconf/cbor2.py +++ b/src/cattrs/preconf/cbor2.py @@ -1,7 +1,7 @@ """Preconfigured converters for cbor2.""" from datetime import date, datetime, timezone -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from cbor2 import dumps, loads @@ -18,7 +18,7 @@ class Cbor2Converter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> bytes: return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: bytes, cl: Type[T], **kwargs: Any) -> T: + def loads(self, data: bytes, cl: type[T], **kwargs: Any) -> T: return self.structure(loads(data, **kwargs), cl) diff --git a/src/cattrs/preconf/json.py b/src/cattrs/preconf/json.py index acc82ae9..85e0cbc9 100644 --- a/src/cattrs/preconf/json.py +++ b/src/cattrs/preconf/json.py @@ -3,7 +3,7 @@ from base64 import b85decode, b85encode from datetime import date, datetime from json import dumps, loads -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from .._compat import AbstractSet, Counter from ..converters import BaseConverter, Converter @@ -17,7 +17,7 @@ class JsonConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str: return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: Union[bytes, str], cl: Type[T], **kwargs: Any) -> T: + def loads(self, data: Union[bytes, str], cl: type[T], **kwargs: Any) -> T: return self.structure(loads(data, **kwargs), cl) diff --git a/src/cattrs/preconf/msgpack.py b/src/cattrs/preconf/msgpack.py index dd7c3696..530c3b54 100644 --- a/src/cattrs/preconf/msgpack.py +++ b/src/cattrs/preconf/msgpack.py @@ -1,7 +1,7 @@ """Preconfigured converters for msgpack.""" from datetime import date, datetime, time, timezone -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from msgpack import dumps, loads @@ -18,7 +18,7 @@ class MsgpackConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> bytes: return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: bytes, cl: Type[T], **kwargs: Any) -> T: + def loads(self, data: bytes, cl: type[T], **kwargs: Any) -> T: return self.structure(loads(data, **kwargs), cl) diff --git a/src/cattrs/preconf/orjson.py b/src/cattrs/preconf/orjson.py index 4b595bcf..1594ce6c 100644 --- a/src/cattrs/preconf/orjson.py +++ b/src/cattrs/preconf/orjson.py @@ -4,7 +4,7 @@ from datetime import date, datetime from enum import Enum from functools import partial -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from orjson import dumps, loads @@ -22,7 +22,7 @@ class OrjsonConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> bytes: return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: Union[bytes, bytearray, memoryview, str], cl: Type[T]) -> T: + def loads(self, data: Union[bytes, bytearray, memoryview, str], cl: type[T]) -> T: return self.structure(loads(data), cl) diff --git a/src/cattrs/preconf/pyyaml.py b/src/cattrs/preconf/pyyaml.py index 73746257..9c0ca99b 100644 --- a/src/cattrs/preconf/pyyaml.py +++ b/src/cattrs/preconf/pyyaml.py @@ -2,7 +2,7 @@ from datetime import date, datetime from functools import partial -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from yaml import safe_dump, safe_load @@ -15,7 +15,7 @@ T = TypeVar("T") -def validate_date(v, _): +def validate_date(v: Any, _): if not isinstance(v, date): raise ValueError(f"Expected date, got {v}") return v @@ -25,7 +25,7 @@ class PyyamlConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str: return safe_dump(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: str, cl: Type[T]) -> T: + def loads(self, data: str, cl: type[T]) -> T: return self.structure(safe_load(data), cl) diff --git a/src/cattrs/preconf/tomlkit.py b/src/cattrs/preconf/tomlkit.py index 0d0180bf..f940aeac 100644 --- a/src/cattrs/preconf/tomlkit.py +++ b/src/cattrs/preconf/tomlkit.py @@ -4,7 +4,7 @@ from datetime import date, datetime from enum import Enum from operator import attrgetter -from typing import Any, Type, TypeVar, Union +from typing import Any, TypeVar, Union from tomlkit import dumps, loads from tomlkit.items import Float, Integer, String @@ -23,7 +23,7 @@ class TomlkitConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str: return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: str, cl: Type[T]) -> T: + def loads(self, data: str, cl: type[T]) -> T: return self.structure(loads(data), cl) diff --git a/src/cattrs/preconf/ujson.py b/src/cattrs/preconf/ujson.py index 7256d52a..c5906d21 100644 --- a/src/cattrs/preconf/ujson.py +++ b/src/cattrs/preconf/ujson.py @@ -2,7 +2,7 @@ from base64 import b85decode, b85encode from datetime import date, datetime -from typing import Any, AnyStr, Type, TypeVar, Union +from typing import Any, AnyStr, TypeVar, Union from ujson import dumps, loads @@ -19,7 +19,7 @@ class UjsonConverter(Converter): def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str: return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) - def loads(self, data: AnyStr, cl: Type[T], **kwargs: Any) -> T: + def loads(self, data: AnyStr, cl: type[T], **kwargs: Any) -> T: return self.structure(loads(data, **kwargs), cl) diff --git a/src/cattrs/strategies/_class_methods.py b/src/cattrs/strategies/_class_methods.py index c2b63253..80a3c90d 100644 --- a/src/cattrs/strategies/_class_methods.py +++ b/src/cattrs/strategies/_class_methods.py @@ -1,7 +1,7 @@ """Strategy for using class-specific (un)structuring methods.""" from inspect import signature -from typing import Any, Callable, Optional, Type, TypeVar +from typing import Any, Callable, Optional, TypeVar from .. import BaseConverter @@ -35,7 +35,7 @@ def use_class_methods( if structure_method_name: - def make_class_method_structure(cl: Type[T]) -> Callable[[Any, Type[T]], T]: + def make_class_method_structure(cl: type[T]) -> Callable[[Any, type[T]], T]: fn = getattr(cl, structure_method_name) n_parameters = len(signature(fn).parameters) if n_parameters == 1: @@ -50,7 +50,7 @@ def make_class_method_structure(cl: Type[T]) -> Callable[[Any, Type[T]], T]: if unstructure_method_name: - def make_class_method_unstructure(cl: Type[T]) -> Callable[[T], T]: + def make_class_method_unstructure(cl: type[T]) -> Callable[[T], T]: fn = getattr(cl, unstructure_method_name) n_parameters = len(signature(fn).parameters) if n_parameters == 1: diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index f0d270d9..c8872019 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Callable, Dict, Literal, Type, Union +from typing import Any, Callable, Literal, Union from attrs import NOTHING @@ -13,7 +13,7 @@ ] -def default_tag_generator(typ: Type) -> str: +def default_tag_generator(typ: type) -> str: """Return the class name.""" return typ.__name__ @@ -21,9 +21,9 @@ def default_tag_generator(typ: Type) -> str: def configure_tagged_union( union: Any, converter: BaseConverter, - tag_generator: Callable[[Type], str] = default_tag_generator, + tag_generator: Callable[[type], str] = default_tag_generator, tag_name: str = "_type", - default: Union[Type, Literal[NOTHING]] = NOTHING, + default: Union[type, Literal[NOTHING]] = NOTHING, ) -> None: """ Configure the converter so that `union` (which should be a union) is @@ -78,7 +78,7 @@ def unstructure_tagged_union( _exact_cl_unstruct_hooks=exact_cl_unstruct_hooks, _cl_to_tag=cl_to_tag, _tag_name=tag_name, - ) -> Dict: + ) -> dict: res = _exact_cl_unstruct_hooks[val.__class__](val) res[_tag_name] = _cl_to_tag[val.__class__] return res diff --git a/src/cattrs/v.py b/src/cattrs/v.py index c3ab18cc..5c40310d 100644 --- a/src/cattrs/v.py +++ b/src/cattrs/v.py @@ -1,6 +1,6 @@ """Cattrs validation.""" -from typing import Callable, List, Union +from typing import Callable, Union from .errors import ( ClassValidationError, @@ -65,7 +65,7 @@ def transform_error( format_exception: Callable[ [BaseException, Union[type, None]], str ] = format_exception, -) -> List[str]: +) -> list[str]: """Transform an exception into a list of error messages. To get detailed error messages, the exception should be produced by a converter diff --git a/tests/_compat.py b/tests/_compat.py index dba215bd..8d293bda 100644 --- a/tests/_compat.py +++ b/tests/_compat.py @@ -1,20 +1,10 @@ import sys -is_py38 = sys.version_info[:2] == (3, 8) -is_py39 = sys.version_info[:2] == (3, 9) -is_py39_plus = sys.version_info >= (3, 9) is_py310 = sys.version_info[:2] == (3, 10) is_py310_plus = sys.version_info >= (3, 10) is_py311_plus = sys.version_info >= (3, 11) is_py312_plus = sys.version_info >= (3, 12) -if is_py38: - from typing import Dict, List - List_origin = List - Dict_origin = Dict - - -else: - List_origin = list - Dict_origin = dict +List_origin = list +Dict_origin = dict diff --git a/tests/preconf/test_pyyaml.py b/tests/preconf/test_pyyaml.py index ebf0cfb3..ec808561 100644 --- a/tests/preconf/test_pyyaml.py +++ b/tests/preconf/test_pyyaml.py @@ -10,7 +10,6 @@ from cattrs.errors import ClassValidationError from cattrs.preconf.pyyaml import make_converter -from .._compat import is_py38 from ..test_preconf import Everything, everythings, native_unions @@ -40,10 +39,7 @@ def test_pyyaml_converter_unstruct_collection_overrides(everything: Everything): assert raw["a_frozenset"] == sorted(raw["a_frozenset"]) -@given( - union_and_val=native_unions(include_bools=not is_py38), # Literal issues on 3.8 - detailed_validation=..., -) +@given(union_and_val=native_unions(), detailed_validation=...) def test_pyyaml_unions(union_and_val: tuple, detailed_validation: bool): """Native union passthrough works.""" converter = make_converter(detailed_validation=detailed_validation) diff --git a/tests/test_converter.py b/tests/test_converter.py index 92c9bbb3..118d407a 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -29,7 +29,7 @@ ) from cattrs.gen import make_dict_structure_fn, override -from ._compat import is_py39_plus, is_py310_plus +from ._compat import is_py310_plus from .typed import ( nested_typed_classes, simple_typed_attrs, @@ -562,17 +562,6 @@ class Outer: (deque, MutableSequence), (tuple, Sequence), ] - if is_py39_plus - else [ - (tuple, Tuple), - (list, List), - (deque, Deque), - (set, Set), - (frozenset, FrozenSet), - (list, MutableSequence), - (deque, MutableSequence), - (tuple, Sequence), - ] ), ) def test_seq_of_simple_classes_unstructure(cls_and_vals, seq_type_and_annotation): @@ -610,14 +599,6 @@ def test_seq_of_simple_classes_unstructure(cls_and_vals, seq_type_and_annotation (frozenset, frozenset), (frozenset, FrozenSet), ] - if is_py39_plus - else [ - (tuple, Tuple), - (list, List), - (deque, Deque), - (set, Set), - (frozenset, FrozenSet), - ] ) ) def test_seq_of_bare_classes_structure(seq_type_and_annotation): @@ -649,7 +630,6 @@ class C: assert outputs == expected -@pytest.mark.skipif(not is_py39_plus, reason="3.9+ only") def test_annotated_attrs(): """Annotation support works for attrs classes.""" from typing import Annotated diff --git a/tests/test_copy.py b/tests/test_copy.py index e6b699e2..43d045d3 100644 --- a/tests/test_copy.py +++ b/tests/test_copy.py @@ -1,6 +1,6 @@ from collections import OrderedDict from copy import deepcopy -from typing import Callable, Type +from typing import Callable from attr import define from hypothesis import given @@ -20,7 +20,7 @@ class Simple: @given(strat=unstructure_strats, detailed_validation=..., prefer_attrib=...) def test_deepcopy( - converter_cls: Type[BaseConverter], + converter_cls: type[BaseConverter], strat: UnstructureStrategy, prefer_attrib: bool, detailed_validation: bool, @@ -47,7 +47,7 @@ def test_deepcopy( dict_factory=one_of(just(dict), just(OrderedDict)), ) def test_copy( - converter_cls: Type[BaseConverter], + converter_cls: type[BaseConverter], strat: UnstructureStrategy, prefer_attrib: bool, detailed_validation: bool, @@ -114,7 +114,7 @@ def test_copy_converter( dict_factory=one_of(just(dict), just(OrderedDict)), ) def test_copy_hooks( - converter_cls: Type[BaseConverter], + converter_cls: type[BaseConverter], strat: UnstructureStrategy, prefer_attrib: bool, detailed_validation: bool, @@ -152,7 +152,7 @@ def test_copy_hooks( dict_factory=one_of(just(dict), just(OrderedDict)), ) def test_copy_func_hooks( - converter_cls: Type[BaseConverter], + converter_cls: type[BaseConverter], strat: UnstructureStrategy, prefer_attrib: bool, detailed_validation: bool, diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 1130e766..d60a5d74 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -12,7 +12,6 @@ from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override -from ._compat import is_py39_plus from .typed import nested_typed_classes, simple_typed_classes, simple_typed_dataclasses from .untyped import nested_classes, simple_classes @@ -313,7 +312,6 @@ class A: assert not hasattr(structured, "b") -@pytest.mark.skipif(not is_py39_plus, reason="literals and annotated are 3.9+") def test_type_names_with_quotes(): """Types with quote characters in their reprs should work.""" from typing import Annotated, Literal, Union diff --git a/tests/test_generics.py b/tests/test_generics.py index d0898a5e..5e846ed2 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -10,13 +10,7 @@ from cattrs.errors import StructureHandlerNotFoundError from cattrs.gen._generics import generate_mapping -from ._compat import ( - Dict_origin, - List_origin, - is_py39_plus, - is_py310_plus, - is_py311_plus, -) +from ._compat import Dict_origin, List_origin, is_py310_plus, is_py311_plus T = TypeVar("T") T2 = TypeVar("T2") @@ -77,7 +71,6 @@ def test_structure_generics_with_cols(t, result, detailed_validation): assert res == result -@pytest.mark.skipif(not is_py39_plus, reason="3.9+ generics syntax") @pytest.mark.parametrize( ("t", "result"), ((int, (1, [2], {"3": 3})), (str, ("1", ["2"], {"3": "3"}))) ) diff --git a/tests/test_preconf.py b/tests/test_preconf.py index b7cf4648..6e8991dd 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -296,11 +296,7 @@ def test_stdlib_json_converter_unstruct_collection_overrides(everything: Everyth @given( - union_and_val=native_unions( - include_bytes=False, - include_datetimes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), + union_and_val=native_unions(include_bytes=False, include_datetimes=False), detailed_validation=..., ) @settings(max_examples=1000) @@ -313,11 +309,7 @@ def test_stdlib_json_unions(union_and_val: tuple, detailed_validation: bool): @given( - union_and_val=native_unions( - include_strings=False, - include_bytes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), + union_and_val=native_unions(include_strings=False, include_bytes=False), detailed_validation=..., ) def test_stdlib_json_unions_with_spillover( @@ -374,11 +366,7 @@ def test_ujson_converter_unstruct_collection_overrides(everything: Everything): @given( - union_and_val=native_unions( - include_bytes=False, - include_datetimes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), + union_and_val=native_unions(include_bytes=False, include_datetimes=False), detailed_validation=..., ) def test_ujson_unions(union_and_val: tuple, detailed_validation: bool): @@ -442,11 +430,7 @@ def test_orjson_converter_unstruct_collection_overrides(everything: Everything): @pytest.mark.skipif(python_implementation() == "PyPy", reason="no orjson on PyPy") @given( - union_and_val=native_unions( - include_bytes=False, - include_datetimes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), + union_and_val=native_unions(include_bytes=False, include_datetimes=False), detailed_validation=..., ) def test_orjson_unions(union_and_val: tuple, detailed_validation: bool): @@ -490,13 +474,7 @@ def test_msgpack_converter_unstruct_collection_overrides(everything: Everything) assert raw["a_frozenset"] == sorted(raw["a_frozenset"]) -@given( - union_and_val=native_unions( - include_datetimes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), - detailed_validation=..., -) +@given(union_and_val=native_unions(include_datetimes=False), detailed_validation=...) def test_msgpack_unions(union_and_val: tuple, detailed_validation: bool): """Native union passthrough works.""" converter = msgpack_make_converter(detailed_validation=detailed_validation) @@ -564,13 +542,7 @@ def test_bson_converter_unstruct_collection_overrides(everything: Everything): assert raw["a_frozenset"] == sorted(raw["a_frozenset"]) -@given( - union_and_val=native_unions( - include_objectids=True, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), - detailed_validation=..., -) +@given(union_and_val=native_unions(include_objectids=True), detailed_validation=...) def test_bson_unions(union_and_val: tuple, detailed_validation: bool): """Native union passthrough works.""" converter = bson_make_converter(detailed_validation=detailed_validation) @@ -633,10 +605,7 @@ def test_tomlkit_converter_unstruct_collection_overrides(everything: Everything) @given( union_and_val=native_unions( - include_nones=False, - include_bytes=False, - include_datetimes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 + include_nones=False, include_bytes=False, include_datetimes=False ), detailed_validation=..., ) @@ -684,13 +653,7 @@ def test_cbor2_converter_unstruct_collection_overrides(everything: Everything): assert raw["a_frozenset"] == sorted(raw["a_frozenset"]) -@given( - union_and_val=native_unions( - include_datetimes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), - detailed_validation=..., -) +@given(union_and_val=native_unions(include_datetimes=False), detailed_validation=...) def test_cbor2_unions(union_and_val: tuple, detailed_validation: bool): """Native union passthrough works.""" converter = cbor2_make_converter(detailed_validation=detailed_validation) @@ -729,11 +692,7 @@ def test_msgspec_json_unstruct_collection_overrides(everything: Everything): @pytest.mark.skipif(NO_MSGSPEC, reason="msgspec not available") @given( - union_and_val=native_unions( - include_datetimes=False, - include_bytes=False, - include_bools=sys.version_info[:2] != (3, 8), # Literal issues on 3.8 - ), + union_and_val=native_unions(include_datetimes=False, include_bytes=False), detailed_validation=..., ) def test_msgspec_json_unions(union_and_val: tuple, detailed_validation: bool): diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index bf435fba..492750c8 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -1,7 +1,7 @@ """Tests for TypedDict un/structuring.""" from datetime import datetime, timezone -from typing import Dict, Generic, NewType, Set, Tuple, TypedDict, TypeVar +from typing import Generic, NewType, TypedDict, TypeVar import pytest from attrs import NOTHING @@ -25,7 +25,7 @@ make_dict_unstructure_fn, ) -from ._compat import is_py38, is_py39, is_py310, is_py311_plus +from ._compat import is_py311_plus from .typeddicts import ( generic_typeddicts, simple_typeddicts, @@ -77,7 +77,7 @@ def get_annot(t) -> dict: return get_annots(t) -@given(simple_typeddicts(typeddict_cls=None if not is_py38 else ExtensionsTypedDict)) +@given(simple_typeddicts()) def test_simple_roundtrip(cls_and_instance) -> None: """Round-trips for simple classes work.""" c = mk_converter() @@ -97,12 +97,7 @@ def test_simple_roundtrip(cls_and_instance) -> None: assert restructured == instance -@given( - simple_typeddicts( - total=False, typeddict_cls=None if not is_py38 else ExtensionsTypedDict - ), - booleans(), -) +@given(simple_typeddicts(total=False), booleans()) def test_simple_nontotal(cls_and_instance, detailed_validation: bool) -> None: """Non-total dicts work.""" c = mk_converter(detailed_validation=detailed_validation) @@ -122,7 +117,7 @@ def test_simple_nontotal(cls_and_instance, detailed_validation: bool) -> None: assert restructured == instance -@given(simple_typeddicts(typeddict_cls=None if not is_py38 else ExtensionsTypedDict)) +@given(simple_typeddicts()) def test_int_override(cls_and_instance) -> None: """Overriding a base unstructure handler should work.""" cls, instance = cls_and_instance @@ -138,14 +133,9 @@ def test_int_override(cls_and_instance) -> None: assert unstructured == instance -@given( - simple_typeddicts_with_extra_keys( - typeddict_cls=None if not is_py38 else ExtensionsTypedDict - ), - booleans(), -) +@given(simple_typeddicts_with_extra_keys(), booleans()) def test_extra_keys( - cls_instance_extra: Tuple[type, Dict, Set[str]], detailed_validation: bool + cls_instance_extra: tuple[type, dict, set[str]], detailed_validation: bool ) -> None: """Extra keys are preserved.""" cls, instance, extra = cls_instance_extra @@ -167,7 +157,7 @@ def test_extra_keys( @pytest.mark.skipif(not is_py311_plus, reason="3.11+ only") @given(generic_typeddicts(total=True), booleans()) def test_generics( - cls_and_instance: Tuple[type, Dict], detailed_validation: bool + cls_and_instance: tuple[type, dict], detailed_validation: bool ) -> None: """Generic TypedDicts work.""" c = mk_converter(detailed_validation=detailed_validation) @@ -231,7 +221,7 @@ class GenericChild(GenericParent[Int], Generic[T1]): @given(simple_typeddicts(total=True, not_required=True), booleans()) def test_not_required( - cls_and_instance: Tuple[type, Dict], detailed_validation: bool + cls_and_instance: tuple[type, dict], detailed_validation: bool ) -> None: """NotRequired[] keys are handled.""" c = mk_converter(detailed_validation=detailed_validation) @@ -243,16 +233,9 @@ def test_not_required( assert restructured == instance -@given( - simple_typeddicts( - total=False, - not_required=True, - typeddict_cls=None if not is_py38 else ExtensionsTypedDict, - ), - booleans(), -) +@given(simple_typeddicts(total=False, not_required=True), booleans()) def test_required( - cls_and_instance: Tuple[type, Dict], detailed_validation: bool + cls_and_instance: tuple[type, dict], detailed_validation: bool ) -> None: """Required[] keys are handled.""" c = mk_converter(detailed_validation=detailed_validation) @@ -264,9 +247,9 @@ def test_required( assert restructured == instance -@pytest.mark.skipif(is_py39 or is_py310, reason="Sigh") +@pytest.mark.skipif(not is_py311_plus, reason="Sigh") def test_required_keys() -> None: - """We don't support the full gamut of functionality on 3.8. + """We don't support the full gamut of functionality on 3.9 and 3.10. When using `typing.TypedDict` we have only partial functionality; this test tests only a subset of this. @@ -287,7 +270,7 @@ class Sub(Base): @given(simple_typeddicts(min_attrs=1, total=True), booleans()) -def test_omit(cls_and_instance: Tuple[type, Dict], detailed_validation: bool) -> None: +def test_omit(cls_and_instance: tuple[type, dict], detailed_validation: bool) -> None: """`override(omit=True)` works.""" c = mk_converter(detailed_validation=detailed_validation) @@ -329,7 +312,7 @@ def test_omit(cls_and_instance: Tuple[type, Dict], detailed_validation: bool) -> @given(simple_typeddicts(min_attrs=1, total=True, not_required=True), booleans()) -def test_rename(cls_and_instance: Tuple[type, Dict], detailed_validation: bool) -> None: +def test_rename(cls_and_instance: tuple[type, dict], detailed_validation: bool) -> None: """`override(rename=...)` works.""" c = mk_converter(detailed_validation=detailed_validation) @@ -355,7 +338,7 @@ def test_rename(cls_and_instance: Tuple[type, Dict], detailed_validation: bool) @given(simple_typeddicts(total=True), booleans()) def test_forbid_extra_keys( - cls_and_instance: Tuple[type, Dict], detailed_validation: bool + cls_and_instance: tuple[type, dict], detailed_validation: bool ) -> None: """Extra keys can be forbidden.""" c = mk_converter(detailed_validation) diff --git a/tests/test_unstructure_collections.py b/tests/test_unstructure_collections.py index d06287bc..6654c889 100644 --- a/tests/test_unstructure_collections.py +++ b/tests/test_unstructure_collections.py @@ -9,17 +9,13 @@ ) from functools import partial -import attr -import pytest +from attrs import define, field from immutables import Map from cattrs import Converter from cattrs.converters import is_mutable_set, is_sequence -from ._compat import is_py39_plus - -@pytest.mark.skipif(not is_py39_plus, reason="Requires Python 3.9+") def test_collection_unstructure_override_set(): """Test overriding unstructuring sets.""" @@ -60,49 +56,6 @@ def test_collection_unstructure_override_set(): assert c.unstructure({1, 2, 3}) == [1, 2, 3] -@pytest.mark.skipif(is_py39_plus, reason="Requires Python 3.8 or lower") -def test_collection_unstructure_override_set_38(): - """Test overriding unstructuring sets.""" - from typing import AbstractSet, MutableSet, Set - - # First approach, predicate hook with is_mutable_set - c = Converter() - - c._unstructure_func.register_func_list( - [ - ( - is_mutable_set, - partial(c.gen_unstructure_iterable, unstructure_to=list), - True, - ) - ] - ) - - assert c.unstructure({1, 2, 3}, unstructure_as=Set[int]) == [1, 2, 3] - - # Second approach, using __builtins__.set - c = Converter(unstruct_collection_overrides={set: list}) - - assert c.unstructure({1, 2, 3}, unstructure_as=Set[int]) == [1, 2, 3] - assert c.unstructure({1, 2, 3}, unstructure_as=MutableSet[int]) == {1, 2, 3} - assert c.unstructure({1, 2, 3}) == [1, 2, 3] - - # Second approach, using typing.MutableSet - c = Converter(unstruct_collection_overrides={MutableSet: list}) - - assert c.unstructure({1, 2, 3}, unstructure_as=Set[int]) == [1, 2, 3] - assert c.unstructure({1, 2, 3}, unstructure_as=MutableSet[int]) == [1, 2, 3] - assert c.unstructure({1, 2, 3}) == [1, 2, 3] - - # Second approach, using typing.AbstractSet - c = Converter(unstruct_collection_overrides={AbstractSet: list}) - - assert c.unstructure({1, 2, 3}, unstructure_as=Set[int]) == [1, 2, 3] - assert c.unstructure({1, 2, 3}, unstructure_as=MutableSet[int]) == [1, 2, 3] - assert c.unstructure({1, 2, 3}) == [1, 2, 3] - - -@pytest.mark.skipif(not is_py39_plus, reason="Requires Python 3.9+") def test_collection_unstructure_override_seq(): """Test overriding unstructuring seq.""" @@ -115,9 +68,9 @@ def test_collection_unstructure_override_seq(): assert c.unstructure([1, 2, 3], unstructure_as=Sequence[int]) == (1, 2, 3) - @attr.define + @define class MyList: - args = attr.ib(converter=list) + args = field(converter=list) # Second approach, using abc.MutableSequence c = Converter(unstruct_collection_overrides={MutableSequence: MyList}) @@ -158,7 +111,6 @@ class MyList: assert c.unstructure((1, 2, 3)) == MyList([1, 2, 3]) -@pytest.mark.skipif(not is_py39_plus, reason="Requires Python 3.9+") def test_collection_unstructure_override_mapping(): """Test overriding unstructuring mappings.""" diff --git a/tox.ini b/tox.ini index 31ca9dce..535a740c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ # Keep docs in sync with docs env and .readthedocs.yml. [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311, docs @@ -11,7 +10,7 @@ python = [tox] -envlist = pypy3, py38, py39, py310, py311, py312, py313, lint, docs +envlist = pypy3, py39, py310, py311, py312, py313, lint, docs isolated_build = true skipsdist = true @@ -63,7 +62,7 @@ commands_pre = python -c 'import pathlib; pathlib.Path("{env_site_packages_dir}/cov.pth").write_text("import coverage; coverage.process_startup()")' [testenv:docs] -basepython = python3.11 +basepython = python3.12 setenv = PYTHONHASHSEED = 0 commands_pre =