Skip to content

Enable Python 3.14 #653

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 3 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ 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
just python=python3.10 test
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 { "" } }}
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 22 additions & 4 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
_AnnotatedAlias,
_GenericAlias,
_SpecialGenericAlias,
_UnionGenericAlias,
get_args,
get_origin,
get_type_hints,
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
)


Expand Down
4 changes: 2 additions & 2 deletions src/cattrs/_generics.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
3 changes: 1 addition & 2 deletions src/cattrs/disambiguators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,7 +16,6 @@
adapted_fields,
fields_dict,
get_args,
get_origin,
has,
is_literal,
is_union_type,
Expand Down
10 changes: 6 additions & 4 deletions src/cattrs/strategies/_subclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
24 changes: 24 additions & 0 deletions src/cattrs/subclasses.py
Original file line number Diff line number Diff line change
@@ -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
)
]
36 changes: 36 additions & 0 deletions tests/strategies/test_include_subclasses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typing
from copy import deepcopy
from dataclasses import dataclass
from functools import partial

import pytest
Expand All @@ -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")


Expand Down Expand Up @@ -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")
55 changes: 41 additions & 14 deletions tests/test_baseconverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand All @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion tests/test_gen_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions tests/test_preconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading
Loading