Skip to content
Merged
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/codeflash.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ jobs:
- name: Run Codeflash to optimize code
run: |
poetry env use python
poetry run codeflash --benchmark
poetry run codeflash --benchmark --benchmarks-root tests/benchmarks
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
3.11
3.12
3.13
3.14-dev

- run: pip install poetry nox nox-poetry uv
- run: nox -r -t tests -s "${{ matrix.session.session }}"
Expand Down
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Release type: patch

This release adds support for the upcoming Python 3.14
5 changes: 5 additions & 0 deletions TWEET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
🆕 Release $version is out! Thanks to $contributor for the PR 👏

This release adds support for Python 3.14!

Get it here 👉 $release_url
36 changes: 30 additions & 6 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
nox.options.error_on_external_run = True
nox.options.default_venv_backend = "uv"

PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"]
PYTHON_VERSIONS = ["3.14", "3.13", "3.12", "3.11", "3.10", "3.9"]

GQL_CORE_VERSIONS = [
"3.2.3",
"3.2.6",
"3.3.0a9",
]

Expand Down Expand Up @@ -129,12 +129,35 @@ def tests_integrations(session: Session, integration: str, gql_core: str) -> Non
session.run("pytest", *COMMON_PYTEST_OPTIONS, "-m", integration)


@session(
python=["3.9", "3.10", "3.11", "3.12", "3.13"],
name="Pydantic V1 tests",
tags=["tests", "pydantic"],
)
@gql_core_parametrize
def test_pydantic(session: Session, gql_core: str) -> None:
session.run_always("poetry", "install", "--without=integrations", external=True)

session._session.install("pydantic~=1.10") # type: ignore
_install_gql_core(session, gql_core)
session.run(
"pytest",
"--cov=.",
"--cov-append",
"--cov-report=xml",
"-m",
"pydantic",
"--ignore=tests/cli",
"--ignore=tests/benchmarks",
)


@session(python=PYTHON_VERSIONS, name="Pydantic tests", tags=["tests", "pydantic"])
@with_gql_core_parametrize("pydantic", ["1.10", "2.9.0", "2.10.0", "2.11.0"])
def test_pydantic(session: Session, pydantic: str, gql_core: str) -> None:
@gql_core_parametrize
def test_pydantic_v2(session: Session, gql_core: str) -> None:
session.run_always("poetry", "install", "--without=integrations", external=True)

session._session.install(f"pydantic~={pydantic}") # type: ignore
session._session.install("pydantic~=2.12") # type: ignore
_install_gql_core(session, gql_core)
session.run(
"pytest",
Expand Down Expand Up @@ -166,7 +189,8 @@ def tests_typecheckers(session: Session) -> None:
)


@session(python=PYTHON_VERSIONS, name="CLI tests", tags=["tests"])
# skipping python 3.9 because of some changes in click 8.2.0
@session(python=PYTHON_VERSIONS[:-1], name="CLI tests", tags=["tests"])
def tests_cli(session: Session) -> None:
session.run_always("poetry", "install", "--without=integrations", external=True)

Expand Down
1,950 changes: 1,096 additions & 854 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ dev = [
"mypy (>=1.15.0,<2.0.0)",
"pyright (==1.1.401)",
"codeflash (>=0.9.2)",
"pre-commit (>=4.3.0,<5.0.0)",
"pre-commit",
]
integrations = [
"aiohttp (>=3.7.4.post0,<4.0.0)",
Expand Down
16 changes: 8 additions & 8 deletions strawberry/schema/_graphql_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
from strawberry.types import ExecutionResult

try:
from graphql import (
ExperimentalIncrementalExecutionResults as GraphQLIncrementalExecutionResults, # type: ignore[attr-defined]
from graphql import ( # type: ignore[attr-defined]
ExperimentalIncrementalExecutionResults as GraphQLIncrementalExecutionResults,
)
from graphql.execution import (
InitialIncrementalExecutionResult, # type: ignore[attr-defined]
experimental_execute_incrementally, # type: ignore[attr-defined]
from graphql.execution import ( # type: ignore[attr-defined]
InitialIncrementalExecutionResult,
experimental_execute_incrementally,
)
from graphql.type.directives import (
GraphQLDeferDirective, # type: ignore[attr-defined]
GraphQLStreamDirective, # type: ignore[attr-defined]
from graphql.type.directives import ( # type: ignore[attr-defined]
GraphQLDeferDirective,
GraphQLStreamDirective,
)

incremental_execution_directives = (
Expand Down
4 changes: 4 additions & 0 deletions strawberry/types/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ def __init__(
if sys.version_info >= (3, 10):
kwargs["kw_only"] = dataclasses.MISSING

# doc was added to python 3.14 and it is required
if sys.version_info >= (3, 14):
kwargs["doc"] = None

super().__init__(
default=default,
default_factory=default_factory, # type: ignore
Expand Down
5 changes: 3 additions & 2 deletions strawberry/types/object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Union,
overload,
)
from typing_extensions import dataclass_transform
from typing_extensions import dataclass_transform, get_annotations

from strawberry.exceptions import (
InvalidSuperclassInterfaceError,
Expand Down Expand Up @@ -51,7 +51,8 @@ def _check_field_annotations(cls: builtins.type[Any]) -> None:

https://github.com/python/cpython/blob/6fed3c85402c5ca704eb3f3189ca3f5c67a08d19/Lib/dataclasses.py#L881-L884
"""
cls_annotations = cls.__dict__.get("__annotations__", {})
cls_annotations = get_annotations(cls)
# TODO: do we need this?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, on Python 3.9 or less, __annotations__ will not exist in the class unless we have at least one annotation defined in it. Even if the parent classes have one

So either this is to work around that, or because we are relying on the class' annotations instead of getting from parents as well, which now that we have get_annotations, we can probably refactor?

Let me know what you think and I can try to refactor the codebase to use get_annotations only, basing on top of this branch in another PR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are using get_annotations, because __annotations__ is not working anymore (or at least it is not working in the same way) in Python 3.14

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrmm I assumed get_annotations was going to work like I described, just saw this and now I understand its purpose.

# /// script
# dependencies = ["typing-extensions"]
# ///

from typing_extensions import get_annotations, Format


class A:
    foo: str
    bar: int


class B(A):
    bar: "float"
    baz: bool


for format in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
    print(f"Format: {format}")
    print(get_annotations(A, format=format))
    print(get_annotations(B, format=format))
    print()

Gives:

Format: 1
{'foo': <class 'str'>, 'bar': <class 'int'>}
{'bar': 'float', 'baz': <class 'bool'>}

Format: 3
{'foo': <class 'str'>, 'bar': <class 'int'>}
{'bar': 'float', 'baz': <class 'bool'>}

Format: 4
{'foo': 'str', 'bar': 'int'}
{'bar': 'float', 'baz': 'bool'}

So it doesn't automatically get annotations from parents, we still need to go through the MRO to get the final dict

So the reason of this TODO is indeed python 3.9, but unless we are going to use get_annotations in other places where we retrieve the annotations from the class, we still need to have this to make sure when we do cls.__annotations__ again, we get what get_annotations returned

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, right :) feel free to send another PR, maybe after merging this? :D

cls.__annotations__ = cls_annotations

for field_name, field_ in cls.__dict__.items():
Expand Down
3 changes: 1 addition & 2 deletions strawberry/utils/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,8 @@ def get_optional_annotation(annotation: type) -> type:

# if we have multiple non none types we want to return a copy of this
# type (normally a Union type).

if len(non_none_types) > 1:
return annotation.copy_with(non_none_types) # type: ignore[attr-defined]
return Union[non_none_types] # type: ignore

return non_none_types[0]

Expand Down
4 changes: 2 additions & 2 deletions tests/cli/test_locate_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_find_missing_model(cli_app: Typer, cli_runner: CliRunner):
result = cli_runner.invoke(cli_app, ["locate-definition", selector, "Missing"])

assert result.exit_code == 1
assert result.stdout.strip() == snapshot("Definition not found: Missing")
assert result.stderr.strip() == snapshot("Definition not found: Missing")


def test_find_missing_model_field(cli_app: Typer, cli_runner: CliRunner):
Expand All @@ -52,7 +52,7 @@ def test_find_missing_model_field(cli_app: Typer, cli_runner: CliRunner):
)

assert result.exit_code == 1
assert result.stdout.strip() == snapshot("Definition not found: Missing.field")
assert result.stderr.strip() == snapshot("Definition not found: Missing.field")


def test_find_missing_schema(cli_app: Typer, cli_runner: CliRunner):
Expand Down
7 changes: 7 additions & 0 deletions tests/experimental/pydantic/schema/test_1_and_2.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import sys
import textwrap
from typing import Optional, Union

import pytest

import strawberry
from tests.experimental.pydantic.utils import needs_pydantic_v2


@pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Pydantic v1 is not compatible with Python 3.14+",
)
@needs_pydantic_v2
def test_can_use_both_pydantic_1_and_2():
import pydantic
Expand Down
2 changes: 1 addition & 1 deletion tests/schema/types/test_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def test_serialization_error_message_for_incorrect_date_string():
"""
result = execute_mutation("2021-13-01")
assert result.errors
assert result.errors[0].message == (
assert result.errors[0].message.startswith(
"Variable '$value' got invalid value '2021-13-01'; Value cannot represent a "
'Date: "2021-13-01". month must be in 1..12'
)
2 changes: 1 addition & 1 deletion tests/schema/types/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def test_serialization_error_message_for_incorrect_datetime_string():
"""
result = execute_mutation("2021-13-01T09:00:00")
assert result.errors
assert result.errors[0].message == (
assert result.errors[0].message.startswith(
"Variable '$value' got invalid value '2021-13-01T09:00:00'; Value cannot "
'represent a DateTime: "2021-13-01T09:00:00". month must be in 1..12'
)
2 changes: 1 addition & 1 deletion tests/schema/types/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def test_serialization_error_message_for_incorrect_time_string():
"""
result = execute_mutation("25:00")
assert result.errors
assert result.errors[0].message == (
assert result.errors[0].message.startswith(
"Variable '$value' got invalid value '25:00'; Value cannot represent a "
'Time: "25:00". hour must be in 0..23'
)
9 changes: 8 additions & 1 deletion tests/typecheckers/utils/mypy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import os
import pathlib
import subprocess
import tempfile
Expand Down Expand Up @@ -52,7 +53,13 @@ def run_mypy(code: str, strict: bool = True) -> list[Result]:
module_path.write_text(code)

process_result = subprocess.run(
[*args, str(module_path)], check=False, capture_output=True
[*args, str(module_path)],
check=False,
capture_output=True,
env={
"PYTHONWARNINGS": "error,ignore::SyntaxWarning",
"PATH": os.environ["PATH"],
},
)

full_output = (
Expand Down
Loading