Skip to content

Commit a0ce7fc

Browse files
patrick91ampagent
andauthored
Add Python 3.15 test coverage (#4565)
* Add Python 3.15 test coverage * Install Python 3.15 build dependencies * Complete Python 3.15 build toolchain * Skip codeflash on Python 3.15 * Avoid private dataclasses APIs * Preserve ClassVar detection across Python versions --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 00cb770 commit a0ce7fc

8 files changed

Lines changed: 238 additions & 187 deletions

File tree

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
jq 'map(
3535
{
3636
session,
37+
python: .python,
3738
name: "\( .name ) on \( .python )\( if .call_spec != {} then " (\(.call_spec | to_entries | map("\(.key)=\(.value)") | join(", ")))" else "" end )"
3839
}
3940
)'
@@ -52,6 +53,11 @@ jobs:
5253
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
5354
with:
5455
persist-credentials: false
56+
- name: Set up Python
57+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
58+
with:
59+
python-version: ${{ matrix.session.python }}
60+
allow-prereleases: true
5561
- name: Install uv
5662
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
5763
with:

RELEASE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
release type: patch
3+
social_messages:
4+
x: >-
5+
{project_name} {version} is out! This release adds support for the upcoming
6+
Python 3.15. 🍓 https://strawberry.rocks/release/{version}
7+
linkedin: >-
8+
{project_name} {version} is out. This release adds support for the upcoming
9+
Python 3.15, keeping Strawberry tested across the latest Python release. 🍓
10+
---
11+
12+
This release adds support for the upcoming Python 3.15.
13+
14+
Strawberry's test suite now runs on Python 3.15.

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
nox.options.error_on_external_run = True
99
nox.options.default_venv_backend = "uv"
1010

11-
PYTHON_VERSIONS = ["3.14", "3.13", "3.12", "3.11", "3.10"]
11+
PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11", "3.10"]
1212

1313
GQL_CORE_VERSIONS = [
1414
"3.2.6",

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ strawberry = "strawberry.cli:run"
3939
[project.optional-dependencies]
4040
aiohttp = ["aiohttp>=3.7.4.post0,<4"]
4141
asgi = ["starlette>=0.18.0", "python-multipart>=0.0.7"]
42-
debug = ["rich>=12.0.0", "libcst"]
42+
debug = ["rich>=12.0.0", "libcst>=1.9.0"]
4343
django = ["Django>=5.2", "asgiref>=3.2"]
4444
channels = ["channels>=4.0.0", "Django>=5.2", "asgiref>=3.2"]
4545
flask = ["flask>=1.1"]
@@ -54,7 +54,7 @@ cli = [
5454
"typer>=0.12.4",
5555
"pygments>=2.3",
5656
"rich>=12.0.0",
57-
"libcst",
57+
"libcst>=1.9.0",
5858
"starlette>=0.18.0",
5959
"uvicorn>=0.11.6",
6060
"websockets>=15.0.1,<17",
@@ -69,7 +69,7 @@ dev = [
6969
"asgiref>=3.2,<4.0",
7070
"email-validator>=1.1.3,<3.0.0",
7171
"freezegun>=1.2.1,<2.0.0",
72-
"libcst>=1.8.2,<2.0.0",
72+
"libcst>=1.9.0,<2.0.0",
7373
"markupsafe~=3.0.3",
7474
"nox>=2025.11.12,<2026.8.0",
7575
"opentelemetry-api<2",
@@ -103,7 +103,7 @@ dev = [
103103
"types-pyyaml>=6.0.12.20240917,<7.0.0.0",
104104
"mypy>=1.15.0,<3.0.0",
105105
"pyright~=1.1.408",
106-
"codeflash>=0.19.1",
106+
"codeflash>=0.19.1; python_version < '3.15'",
107107
"pre-commit",
108108
"protobuf (>=7.34.1,<8.0.0)",
109109
]

strawberry/utils/typing.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ast
2-
import dataclasses
2+
import re
33
import sys
44
import typing
55
from collections.abc import AsyncGenerator
@@ -22,6 +22,8 @@
2222
get_origin,
2323
)
2424

25+
_CLASSVAR_RE = re.compile(r"^\s*(\w+(?:\s*\.\s*\w+)*)")
26+
2527

2628
@lru_cache
2729
def get_generic_alias(type_: type) -> type:
@@ -144,24 +146,29 @@ def is_type_var(annotation: type) -> bool:
144146
return isinstance(annotation, TypeVar)
145147

146148

147-
def is_classvar(cls: type, annotation: ForwardRef | str) -> bool:
149+
def is_classvar(cls: type, annotation: object) -> bool:
148150
"""Returns True if the annotation is a ClassVar."""
149-
# This code was copied from the dataclassses cpython implementation to check
150-
# if a field is annotated with ClassVar or not, taking future annotations
151-
# in consideration.
152-
if dataclasses._is_classvar(annotation, typing): # type: ignore
151+
if annotation is ClassVar or get_origin(annotation) is ClassVar:
153152
return True
154153

155154
annotation_str = (
156155
annotation.__forward_arg__ if isinstance(annotation, ForwardRef) else annotation
157156
)
158-
return isinstance(annotation_str, str) and dataclasses._is_type( # type: ignore
159-
annotation_str,
160-
cls,
161-
typing,
162-
typing.ClassVar,
163-
dataclasses._is_classvar, # type: ignore
164-
)
157+
module = sys.modules.get(cls.__module__)
158+
if not isinstance(annotation_str, str) or module is None:
159+
return False
160+
161+
match = _CLASSVAR_RE.match(annotation_str)
162+
if match is None:
163+
return False
164+
165+
resolved: object | None = module
166+
for path_item in match.group(1).split("."):
167+
resolved = getattr(resolved, path_item.strip(), None)
168+
if resolved is None:
169+
return False
170+
171+
return resolved is ClassVar or get_origin(resolved) is ClassVar
165172

166173

167174
def type_has_annotation(type_: object, annotation: type) -> bool:

tests/relay/test_types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import typing_extensions
12
from collections.abc import AsyncGenerator, AsyncIterable
23
from typing import Any, Optional, Union, cast
34
from typing_extensions import assert_type
@@ -259,6 +260,18 @@ def fruit(self) -> Fruit:
259260
strawberry.Schema(query=Query)
260261

261262

263+
def test_resolve_id_attr_ignores_qualified_classvar():
264+
@strawberry.type
265+
class Fruit(relay.Node):
266+
id: relay.NodeID[int]
267+
268+
Fruit.__annotations__["registry"] = (
269+
f"{typing_extensions.__name__}.ClassVar[MissingType]"
270+
)
271+
272+
assert Fruit.resolve_id_attr() == "id"
273+
274+
262275
def test_list_connection_without_edges_or_page_info(mocker: MagicMock):
263276
@strawberry.type(name="Connection", description="A connection to a list of items.")
264277
class DummyListConnectionWithTotalCount(relay.ListConnection[relay.NodeType]):

tests/utils/test_typing_forward_refs.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
from __future__ import annotations
22

33
import typing
4+
import typing_extensions
5+
from types import SimpleNamespace
46
from typing import ClassVar, ForwardRef, Optional, Union
57

68
from strawberry.scalars import JSON
79
from strawberry.utils.typing import eval_type, is_classvar
810

11+
_classvar_proxy = SimpleNamespace(
12+
nested=SimpleNamespace(ClassVar=ClassVar),
13+
)
14+
915

1016
def test_eval_type():
1117
class Foo: ...
@@ -51,7 +57,12 @@ class Foo:
5157
attr1: str
5258
attr2: ClassVar[str]
5359
attr3: typing.ClassVar[str]
60+
attr4: typing_extensions.ClassVar[str]
61+
62+
Foo.__annotations__["attr5"] = "_classvar_proxy.nested.ClassVar[str]"
5463

5564
assert not is_classvar(Foo, Foo.__annotations__["attr1"])
5665
assert is_classvar(Foo, Foo.__annotations__["attr2"])
5766
assert is_classvar(Foo, Foo.__annotations__["attr3"])
67+
assert is_classvar(Foo, Foo.__annotations__["attr4"])
68+
assert is_classvar(Foo, Foo.__annotations__["attr5"])

0 commit comments

Comments
 (0)