diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index de9d546a..1b05c841 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.10"] fail-fast: false steps: diff --git a/HISTORY.md b/HISTORY.md index 743765fe..50e0c99c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -17,6 +17,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python This allows hashability, better immutability and is more consistent with the [`collections.abc.Sequence`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence) type. See [Migrations](https://catt.rs/en/latest/migrations.html#sequences-structuring-into-tuples) for steps to restore legacy behavior. ([#663](https://github.com/python-attrs/cattrs/pull/663)) +- Python 3.14 is now supported and part of the test matrix. + ([#653](https://github.com/python-attrs/cattrs/pull/653)) - Add a `use_alias` parameter to {class}`cattrs.Converter`. {func}`cattrs.gen.make_dict_unstructure_fn_from_attrs`, {func}`cattrs.gen.make_dict_unstructure_fn`, {func}`cattrs.gen.make_dict_structure_fn_from_attrs`, {func}`cattrs.gen.make_dict_structure_fn` diff --git a/Justfile b/Justfile index b76bc923..4edfe0ee 100644 --- a/Justfile +++ b/Justfile @@ -6,7 +6,7 @@ lint: uv run -p python3.13 --group lint black --check src tests docs/conf.py test *args="-x --ff -n auto tests": - uv run {{ if python != '' { '-p ' + python } else { '' } }} --all-extras --group test pytest {{args}} + uv run {{ if python != '' { '-p ' + python } else { '' } }} --all-extras --group test --group lint pytest {{args}} testall: just python=python3.9 test @@ -14,10 +14,10 @@ testall: just python=python3.11 test just python=python3.12 test just python=python3.13 test - just python=pypy3.9 test + just python=pypy3.10 test cov *args="-x --ff -n auto tests": - uv run {{ if python != '' { '-p ' + python } else { '' } }} --all-extras --group test coverage run -m pytest {{args}} + uv run {{ if python != '' { '-p ' + python } else { '' } }} --all-extras --group test --group lint coverage run -m pytest {{args}} {{ if covcleanup == "true" { "uv run coverage combine" } else { "" } }} {{ if covcleanup == "true" { "uv run coverage report" } else { "" } }} {{ if covcleanup == "true" { "@rm .coverage*" } else { "" } }} diff --git a/pyproject.toml b/pyproject.toml index 1beec22d..98844791 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ authors = [ ] dependencies = [ "attrs>=24.3.0", - "typing-extensions>=4.12.2", + "typing-extensions>=4.14.0", "exceptiongroup>=1.1.1; python_version < '3.11'", ] requires-python = ">=3.9" @@ -75,7 +75,7 @@ ujson = [ "ujson>=5.10.0", ] orjson = [ - "orjson>=3.10.7; implementation_name == \"cpython\"", + "orjson>=3.10.7; implementation_name == \"cpython\" and python_version < \"3.14\"", ] msgpack = [ "msgpack>=1.0.5", diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 5bf78d2c..fc7ae487 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -29,7 +29,6 @@ _AnnotatedAlias, _GenericAlias, _SpecialGenericAlias, - _UnionGenericAlias, get_args, get_origin, get_type_hints, @@ -256,7 +255,22 @@ def is_tuple(type): ) -if sys.version_info >= (3, 10): +if sys.version_info >= (3, 14): + + def is_union_type(obj): + from types import UnionType # noqa: PLC0415 + + return obj is Union or isinstance(obj, UnionType) + + def get_newtype_base(typ: Any) -> Optional[type]: + if typ is NewType or isinstance(typ, NewType): + return typ.__supertype__ + return None + + from typing import NotRequired, Required + +elif sys.version_info >= (3, 10): + from typing import _UnionGenericAlias def is_union_type(obj): from types import UnionType # noqa: PLC0415 @@ -279,6 +293,8 @@ def get_newtype_base(typ: Any) -> Optional[type]: else: # 3.9 + from typing import _UnionGenericAlias + from typing_extensions import NotRequired, Required def is_union_type(obj): @@ -411,8 +427,10 @@ 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__") + return ( + isinstance(type, (_GenericAlias, GenericAlias)) + or (is_subclass(type, Generic) and hasattr(type, "__orig_bases__")) + or type.__class__ is Union # On 3.14, unions are no longer typing._GenericAlias ) diff --git a/src/cattrs/_generics.py b/src/cattrs/_generics.py index 6f36e94f..5d3fde8c 100644 --- a/src/cattrs/_generics.py +++ b/src/cattrs/_generics.py @@ -1,10 +1,10 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, get_args from attrs import NOTHING from typing_extensions import Self -from ._compat import copy_with, get_args, is_annotated, is_generic +from ._compat import copy_with, is_annotated, is_generic def deep_copy_with(t, mapping: Mapping[str, Any], self_is=NOTHING): diff --git a/src/cattrs/disambiguators.py b/src/cattrs/disambiguators.py index 6fc5d9da..f6e43924 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -7,7 +7,7 @@ from dataclasses import MISSING from functools import reduce from operator import or_ -from typing import TYPE_CHECKING, Any, Callable, Literal, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Union, get_origin from attrs import NOTHING, Attribute, AttrsInstance @@ -16,7 +16,6 @@ adapted_fields, fields_dict, get_args, - get_origin, has, is_literal, is_union_type, diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py index 483a226e..c4412012 100644 --- a/src/cattrs/strategies/_subclasses.py +++ b/src/cattrs/strategies/_subclasses.py @@ -9,21 +9,20 @@ from ..converters import BaseConverter from ..gen import AttributeOverride, make_dict_structure_fn, make_dict_unstructure_fn from ..gen._consts import already_generating +from ..subclasses import subclasses def _make_subclasses_tree(cl: type) -> list[type]: # get class origin for accessing subclasses (see #648 for more info) cls_origin = typing.get_origin(cl) or cl return [cl] + [ - sscl - for scl in cls_origin.__subclasses__() - for sscl in _make_subclasses_tree(scl) + sscl for scl in subclasses(cls_origin) for sscl in _make_subclasses_tree(scl) ] def _has_subclasses(cl: type, given_subclasses: tuple[type, ...]) -> bool: """Whether the given class has subclasses from `given_subclasses`.""" - actual = set(cl.__subclasses__()) + actual = set(subclasses(cl)) given = set(given_subclasses) return bool(actual & given) @@ -68,6 +67,9 @@ def include_subclasses( .. versionchanged:: 24.1.0 When overrides are not provided, hooks for individual classes are retrieved from the converter instead of generated with no overrides, using converter defaults. + .. versionchanged:: 25.2.0 + Slotted dataclasses work on Python 3.14 via :func:`cattrs.subclasses.subclasses`, + which filters out duplicate classes caused by slotting. """ # Due to https://github.com/python-attrs/attrs/issues/1047 collect() diff --git a/src/cattrs/subclasses.py b/src/cattrs/subclasses.py new file mode 100644 index 00000000..79a10822 --- /dev/null +++ b/src/cattrs/subclasses.py @@ -0,0 +1,24 @@ +import sys + +if sys.version_info <= (3, 13): + + def subclasses(cls: type) -> list[type]: + """A proxy for `cls.__subclasses__()` on older Pythons.""" + return cls.__subclasses__() + +else: + + def subclasses(cls: type) -> list[type]: + """A helper for getting subclasses of a class. + + Filters out duplicate subclasses of slot dataclasses. + """ + return [ + cl + for cl in cls.__subclasses__() + if not ( + "__slots__" not in cl.__dict__ + and hasattr(cls, "__dataclass_params__") + and cls.__dataclass_params__.slots + ) + ] diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py index 42507c3c..17e7ba96 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -1,5 +1,6 @@ import typing from copy import deepcopy +from dataclasses import dataclass from functools import partial import pytest @@ -9,6 +10,8 @@ from cattrs.errors import ClassValidationError, StructureHandlerNotFoundError from cattrs.strategies import configure_tagged_union, include_subclasses +from .._compat import is_py311_plus + T = typing.TypeVar("T") @@ -432,3 +435,36 @@ class Child1G(GenericParent[str]): assert genconverter.structure({"p": 5, "c": 5}, GenericParent[str]) == Child1G( "5", "5" ) + + +def test_dataclasses(genconverter: Converter): + """Dict dataclasses work.""" + + @dataclass + class ParentDC: + a: int + + @dataclass + class ChildDC1(ParentDC): + b: str + + include_subclasses(ParentDC, genconverter) + + assert genconverter.structure({"a": 1, "b": "a"}, ParentDC) == ChildDC1(1, "a") + + +@pytest.mark.skipif(not is_py311_plus, reason="slotted dataclasses supported on 3.11+") +def test_dataclasses_slots(genconverter: Converter): + """Slotted dataclasses work.""" + + @dataclass(slots=True) + class ParentDC: + a: int + + @dataclass(slots=True) + class ChildDC1(ParentDC): + b: str + + include_subclasses(ParentDC, genconverter) + + assert genconverter.structure({"a": 1, "b": "a"}, ParentDC) == ChildDC1(1, "a") diff --git a/tests/test_baseconverter.py b/tests/test_baseconverter.py index 617cd360..0a814989 100644 --- a/tests/test_baseconverter.py +++ b/tests/test_baseconverter.py @@ -149,16 +149,14 @@ def handler(obj, _): simple_typed_classes( defaults="never", newtypes=False, allow_nan=False, min_attrs=1 ), - unstructure_strats, ) -def test_310_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat): +def test_310_union_field_roundtrip_dict(cl_and_vals_a, cl_and_vals_b): """ Classes with union fields can be unstructured and structured. """ - converter = BaseConverter(unstruct_strat=strat) + converter = BaseConverter() cl_a, vals_a, kwargs_a = cl_and_vals_a cl_b, _, _ = cl_and_vals_b - assume(strat is UnstructureStrategy.AS_DICT or not kwargs_a) a_field_names = {a.name for a in fields(cl_a)} b_field_names = {a.name for a in fields(cl_b)} @@ -171,18 +169,47 @@ class C: inst = C(a=cl_a(*vals_a, **kwargs_a)) - if strat is UnstructureStrategy.AS_DICT: - assert inst == converter.structure(converter.unstructure(inst), C) - else: - # Our disambiguation functions only support dictionaries for now. - with pytest.raises(StructureHandlerNotFoundError): - converter.structure(converter.unstructure(inst), C) + assert inst == converter.structure(converter.unstructure(inst), C) - def handler(obj, _): - return converter.structure(obj, cl_a) - converter.register_structure_hook(cl_a | cl_b, handler) - assert inst == converter.structure(converter.unstructure(inst), C) +@pytest.mark.skipif(not is_py310_plus, reason="3.10+ union syntax") +@settings(suppress_health_check=[HealthCheck.too_slow]) +@given( + simple_typed_classes( + defaults="never", newtypes=False, allow_nan=False, min_attrs=1, kw_only="never" + ), + simple_typed_classes( + defaults="never", newtypes=False, allow_nan=False, min_attrs=1, kw_only="never" + ), +) +def test_310_union_field_roundtrip_tuple(cl_and_vals_a, cl_and_vals_b): + """ + Classes with union fields can be unstructured and structured. + """ + converter = BaseConverter(unstruct_strat=UnstructureStrategy.AS_TUPLE) + cl_a, vals_a, kwargs_a = cl_and_vals_a + cl_b, _, _ = cl_and_vals_b + a_field_names = {a.name for a in fields(cl_a)} + b_field_names = {a.name for a in fields(cl_b)} + + common_names = a_field_names & b_field_names + assume(len(a_field_names) > len(common_names)) + + @define + class C: + a: cl_a | cl_b + + inst = C(a=cl_a(*vals_a, **kwargs_a)) + + # Our disambiguation functions only support dictionaries for now. + with pytest.raises(StructureHandlerNotFoundError): + converter.structure(converter.unstructure(inst), C) + + def handler(obj, _): + return converter.structure(obj, cl_a) + + converter.register_structure_hook(cl_a | cl_b, handler) + assert inst == converter.structure(converter.unstructure(inst), C) @given(simple_typed_classes(defaults="never", newtypes=False, allow_nan=False)) diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index ef1e5e62..abd8d9c5 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -327,7 +327,7 @@ def test_type_names_with_quotes(): assert converter.structure({1: 1}, Dict[Annotated[int, "'"], int]) == {1: 1} converter.register_structure_hook_func( - lambda t: t is Union[Literal["a", 2, 3], Literal[4]], lambda v, _: v + lambda t: t == Union[Literal["a", 2, 3], Literal[4]], lambda v, _: v ) assert converter.structure( {2: "a"}, Dict[Union[Literal["a", 2, 3], Literal[4]], str] diff --git a/tests/test_preconf.py b/tests/test_preconf.py index 907b1c35..ae1810d0 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -53,8 +53,8 @@ from cattrs.preconf.tomlkit import make_converter as tomlkit_make_converter from cattrs.preconf.ujson import make_converter as ujson_make_converter -NO_MSGSPEC: Final = python_implementation() == "PyPy" or sys.version_info[:2] >= (3, 13) -NO_ORJSON: Final = python_implementation() == "PyPy" +NO_MSGSPEC: Final = python_implementation() == "PyPy" +NO_ORJSON: Final = python_implementation() == "PyPy" or sys.version_info[:2] >= (3, 14) @define diff --git a/tests/test_structure.py b/tests/test_structure.py index 4b3e61d8..e5c65fa4 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -336,7 +336,7 @@ def test_structuring_unsupported(): with raises(StructureHandlerNotFoundError) as exc: converter.structure(1, Union[int, str]) - assert exc.value.type_ is Union[int, str] + assert exc.value.type_ == Union[int, str] def test_subclass_registration_is_honored(): diff --git a/tests/untyped.py b/tests/untyped.py index 0a2df142..00ac76c3 100644 --- a/tests/untyped.py +++ b/tests/untyped.py @@ -18,7 +18,7 @@ Tuple, ) -import attr +from attr import attrib from attr._make import _CountingAttr from attrs import NOTHING, AttrsInstance, Factory, make_class from hypothesis import strategies as st @@ -206,7 +206,7 @@ def just_class(tup): nested_cl = tup[1][0] default = Factory(nested_cl) combined_attrs = list(tup[0]) - combined_attrs.append((attr.ib(default=default), st.just(nested_cl()))) + combined_attrs.append((attrib(default=default), st.just(nested_cl()))) return _create_hyp_class(combined_attrs) @@ -217,7 +217,7 @@ def make_with_default(takes_self: bool) -> SearchStrategy[AttrsAndArgs]: combined_attrs = list(tup[0]) combined_attrs.append( ( - attr.ib( + attrib( default=( Factory( nested_cl if not takes_self else lambda _: nested_cl(), @@ -238,7 +238,7 @@ def just_frozen_class_with_type(tup): nested_cl = tup[1][0] combined_attrs = list(tup[0]) combined_attrs.append( - (attr.ib(default=nested_cl(), type=nested_cl), st.just(nested_cl())) + (attrib(default=nested_cl(), type=nested_cl), st.just(nested_cl())) ) return _create_hyp_class(combined_attrs) @@ -250,7 +250,7 @@ def make_with_default(takes_self: bool) -> SearchStrategy[AttrsAndArgs]: combined_attrs = list(tup[0]) combined_attrs.append( ( - attr.ib( + attrib( default=( Factory(lambda: [nested_cl()]) if not takes_self @@ -277,7 +277,7 @@ def make_with_default(takes_self: bool) -> SearchStrategy[AttrsAndArgs]: ) combined_attrs = list(tup[0]) combined_attrs.append( - (attr.ib(default=default, type=List[nested_cl]), st.just([nested_cl()])) + (attrib(default=default, type=List[nested_cl]), st.just([nested_cl()])) ) return _create_hyp_class(combined_attrs) @@ -288,7 +288,7 @@ def dict_of_class(tup): nested_cl = tup[1][0] default = Factory(lambda: {"cls": nested_cl()}) combined_attrs = list(tup[0]) - combined_attrs.append((attr.ib(default=default), st.just({"cls": nested_cl()}))) + combined_attrs.append((attrib(default=default), st.just({"cls": nested_cl()}))) return _create_hyp_class(combined_attrs) @@ -328,7 +328,7 @@ def bare_attrs(draw, defaults=None, kw_only=None): if defaults is True or (defaults is None and draw(st.booleans())): default = None return ( - attr.ib( + attrib( default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only ), st.just(None), @@ -345,7 +345,7 @@ def int_attrs(draw, defaults=None, kw_only=None): if defaults is True or (defaults is None and draw(st.booleans())): default = draw(st.integers()) return ( - attr.ib( + attrib( default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only ), st.integers(), @@ -366,12 +366,12 @@ def str_attrs(draw, defaults=None, type_annotations=None, kw_only=None): else: type = None return ( - attr.ib( + attrib( default=default, type=type, kw_only=draw(st.booleans()) if kw_only is None else kw_only, ), - st.text(), + st.text(max_size=5), ) @@ -385,7 +385,7 @@ def float_attrs(draw, defaults=None, kw_only=None): if defaults is True or (defaults is None and draw(st.booleans())): default = draw(st.floats(allow_nan=False)) return ( - attr.ib( + attrib( default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only ), st.floats(allow_nan=False), @@ -399,12 +399,12 @@ def dict_attrs(draw, defaults=None, kw_only=None): for that attribute. The dictionaries map strings to integers. """ default = NOTHING - val_strat = st.dictionaries(keys=st.text(), values=st.integers()) + val_strat = st.dictionaries(keys=st.text(max_size=5), values=st.integers()) if defaults is True or (defaults is None and draw(st.booleans())): default_val = draw(val_strat) default = Factory(lambda: default_val) return ( - attr.ib( + attrib( default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only ), val_strat, @@ -423,7 +423,7 @@ def optional_attrs(draw, defaults=None, kw_only=None): default = draw(val_strat) return ( - attr.ib( + attrib( default=default, kw_only=draw(st.booleans()) if kw_only is None else kw_only ), val_strat, @@ -441,14 +441,16 @@ def simple_attrs(defaults=None, kw_only=None): ) -def lists_of_attrs(defaults=None, min_size=0, kw_only=None): +def lists_of_attrs(defaults=None, min_size=0, kw_only=None) -> SearchStrategy[list]: # Python functions support up to 255 arguments. return st.lists( simple_attrs(defaults, kw_only), min_size=min_size, max_size=10 ).map(lambda lst: sorted(lst, key=lambda t: t[0]._default is not NOTHING)) -def simple_classes(defaults=None, min_attrs=0, frozen=None, kw_only=None): +def simple_classes( + defaults=None, min_attrs=0, frozen=None, kw_only=None +) -> SearchStrategy: """ Return a strategy that yields tuples of simple classes and values to instantiate them. diff --git a/uv.lock b/uv.lock index ab79636b..525b21b1 100644 --- a/uv.lock +++ b/uv.lock @@ -140,7 +140,7 @@ msgspec = [ { name = "msgspec", marker = "implementation_name == 'cpython'" }, ] orjson = [ - { name = "orjson", marker = "implementation_name == 'cpython'" }, + { name = "orjson", marker = "python_full_version < '3.14' and implementation_name == 'cpython'" }, ] pyyaml = [ { name = "pyyaml" }, @@ -187,11 +187,11 @@ requires-dist = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'", specifier = ">=1.1.1" }, { name = "msgpack", marker = "extra == 'msgpack'", specifier = ">=1.0.5" }, { name = "msgspec", marker = "implementation_name == 'cpython' and extra == 'msgspec'", specifier = ">=0.19.0" }, - { name = "orjson", marker = "implementation_name == 'cpython' and extra == 'orjson'", specifier = ">=3.10.7" }, + { name = "orjson", marker = "python_full_version < '3.14' and implementation_name == 'cpython' and extra == 'orjson'", specifier = ">=3.10.7" }, { name = "pymongo", marker = "extra == 'bson'", specifier = ">=4.4.0" }, { name = "pyyaml", marker = "extra == 'pyyaml'", specifier = ">=6.0" }, { name = "tomlkit", marker = "extra == 'tomlkit'", specifier = ">=0.11.8" }, - { name = "typing-extensions", specifier = ">=4.12.2" }, + { name = "typing-extensions", specifier = ">=4.14.0" }, { name = "ujson", marker = "extra == 'ujson'", specifier = ">=5.10.0" }, ] provides-extras = ["bson", "cbor2", "msgpack", "msgspec", "orjson", "pyyaml", "tomlkit", "ujson"]