Skip to content

Commit cdb529e

Browse files
authored
Add support for Python 3.14 (#710)
2 parents b951973 + ef66b4e commit cdb529e

14 files changed

Lines changed: 294 additions & 53 deletions

File tree

.github/workflows/main.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
fail-fast: false
4444
matrix:
4545
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
46-
python-version: ['3.10', '3.11', '3.12', '3.13']
46+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
4747

4848
steps:
4949
- uses: actions/checkout@v6
@@ -60,9 +60,16 @@ jobs:
6060
sudo apt-get install graphviz graphviz-dev
6161
6262
- name: Run tests, doctests, and notebook tests
63+
if: matrix.os != 'windows-latest' || matrix.python-version != '3.14'
6364
shell: bash -l {0}
6465
run: just test-cov
6566

67+
# pywin32 has no wheels for Python 3.14 yet, so skip notebook tests
68+
- name: Run tests without notebook tests (Windows + Python 3.14)
69+
if: matrix.os == 'windows-latest' && matrix.python-version == '3.14'
70+
shell: bash -l {0}
71+
run: uv run --group test pytest --cov=src --cov=tests --cov-report=xml -n auto
72+
6673
- name: Upload test coverage reports to Codecov with GitHub Action
6774
uses: codecov/codecov-action@v5
6875

@@ -71,5 +78,5 @@ jobs:
7178
run: just test-lowest
7279

7380
- name: Run tests with highest resolution
74-
if: matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest'
81+
if: matrix.python-version == '3.14' && matrix.os == 'ubuntu-latest'
7582
run: just test-highest

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.12
1+
3.14

.readthedocs.yaml

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
version: 2
22

3+
sphinx:
4+
configuration: docs/source/conf.py
5+
fail_on_warning: true
6+
37
build:
48
os: ubuntu-24.04
59
tools:
6-
python: "3.12"
10+
python: "3.13"
711
jobs:
8-
create_environment:
9-
- asdf plugin add uv
10-
- asdf install uv latest
11-
- asdf global uv latest
12-
- UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --group docs
13-
install:
14-
- "true"
15-
16-
sphinx:
17-
configuration: docs/source/conf.py
18-
fail_on_warning: true
12+
pre_create_environment:
13+
- asdf plugin add uv
14+
- asdf install uv latest
15+
- asdf global uv latest
16+
create_environment:
17+
- uv venv "${READTHEDOCS_VIRTUALENV_PATH}"
18+
install:
19+
- UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and
3030
- {pull}`706` disables syntax highlighting for platform version information in session header.
3131
- {pull}`707` drops support for Python 3.9 as it has reached end of life.
3232
- {pull}`708` updates mypy and fixes type issues.
33-
- {pull}`709` add uv pre-commit check.
33+
- {pull}`709` adds uv pre-commit check.
34+
- {pull}`710` adds support for Python 3.14.
3435
- {pull}`713` removes uv as a test dependency. Closes {issue}`712`. Thanks to {user}`erooke`!
3536
- {pull}`718` fixes {issue}`717` by properly parsing the `pdbcls` configuration option from config files. Thanks to {user}`MImmesberger` for the report!
3637
- {pull}`719` fixes repeated tasks with the same function name in the programmatic interface to ensure all tasks execute correctly.

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ test-lowest:
3535

3636
# Run tests with highest dependency resolution (like CI)
3737
test-highest:
38-
uv run --python 3.13 --group test --resolution highest pytest --nbmake -n auto
38+
uv run --python 3.14 --group test --resolution highest pytest --nbmake -n auto

pyproject.toml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ classifiers = [
1515
"Programming Language :: Python :: 3.11",
1616
"Programming Language :: Python :: 3.12",
1717
"Programming Language :: Python :: 3.13",
18+
"Programming Language :: Python :: 3.14",
1819
"Topic :: Scientific/Engineering",
1920
"Topic :: Software Development :: Build Tools",
2021
]
@@ -59,20 +60,21 @@ docs = [
5960
"sphinx-design>=0.3",
6061
"sphinx-toolbox>=4.0.0",
6162
"sphinxext-opengraph>=0.10.0",
62-
"sphinx-autobuild>=2024.10.3",
6363
]
64+
docs-live = ["sphinx-autobuild>=2024.10.3"]
6465
plugin-list = ["httpx>=0.27.0", "tabulate[widechars]>=0.9.0", "tqdm>=4.66.3"]
6566
test = [
6667
"cloudpickle>=3.0.0",
6768
"deepdiff>=7.0.0",
68-
"nbmake>=1.5.5",
69+
# nbmake requires pywin32 on Windows, which has no wheels for Python 3.14 yet
70+
"nbmake>=1.5.5; platform_system != 'Windows' or python_version < '3.14'",
6971
"pygments>=2.18.0",
7072
"pexpect>=4.9.0",
7173
"pytest>=8.4.0",
7274
"pytest-cov>=5.0.0",
7375
"pytest-xdist>=3.6.1",
7476
"syrupy>=4.5.0",
75-
"aiohttp>=3.11.0", # For HTTPPath tests.
77+
"aiohttp>=3.11.0", # For HTTPPath tests.
7678
"coiled>=1.42.0",
7779
"pygraphviz>=1.12;platform_system=='Linux'",
7880
]
@@ -165,11 +167,22 @@ markers = [
165167
filterwarnings = [
166168
"ignore:'@pytask.mark.*. is deprecated:FutureWarning",
167169
"ignore:The --rsyncdir command line argument:DeprecationWarning",
170+
"ignore:'asyncio\\..*' is deprecated:DeprecationWarning",
168171
]
169172

170173
[tool.ty.rules]
171174
unused-ignore-comment = "error"
172175

176+
[[tool.ty.overrides]]
177+
include = [
178+
"src/_pytask/_version.py",
179+
"src/_pytask/click.py",
180+
"tests/test_dag_command.py",
181+
]
182+
183+
[tool.ty.overrides.rules]
184+
unused-ignore-comment = "ignore"
185+
173186
[tool.ty.src]
174187
exclude = ["src/_pytask/_hashlib.py"]
175188

src/_pytask/_inspect.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,140 @@
11
from __future__ import annotations
22

3+
import ast
4+
import inspect
5+
import sys
6+
from inspect import get_annotations as _get_annotations_from_inspect
7+
from typing import TYPE_CHECKING
8+
from typing import Any
9+
from typing import cast
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Callable
13+
314
__all__ = ["get_annotations"]
415

516

6-
from inspect import get_annotations
17+
def get_annotations(
18+
obj: Callable[..., Any],
19+
*,
20+
globals: dict[str, Any] | None = None, # noqa: A002
21+
locals: dict[str, Any] | None = None, # noqa: A002
22+
eval_str: bool = False,
23+
) -> dict[str, Any]:
24+
"""Return evaluated annotations with better support for deferred evaluation.
25+
26+
Context
27+
-------
28+
* PEP 649 introduces deferred annotations which are only evaluated when explicitly
29+
requested. See https://peps.python.org/pep-0649/ for background and why locals can
30+
disappear between definition and evaluation time.
31+
* Python 3.14 ships :mod:`annotationlib` which exposes the raw annotation source and
32+
provides the building blocks we reuse here. The module doc explains the available
33+
formats: https://docs.python.org/3/library/annotationlib.html
34+
* Other projects run into the same constraints. Pydantic tracks their work in
35+
https://github.com/pydantic/pydantic/issues/12080; we might copy improvements from
36+
there once they settle on a stable strategy.
37+
38+
Rationale
39+
---------
40+
When annotations refer to loop variables inside task generators, the locals that
41+
existed during decoration have vanished by the time pytask evaluates annotations
42+
while collecting tasks. Using :func:`inspect.get_annotations` would therefore yield
43+
the same product path for every repeated task. By asking :mod:`annotationlib` for
44+
string representations and re-evaluating them with reconstructed locals (globals,
45+
default arguments, and the frame locals captured via ``@task`` at decoration time)
46+
we recover the correct per-task values. The frame locals capture is essential for
47+
cases where loop variables are only referenced in annotations (not in the function
48+
body or closure). If any of these ingredients are missing—for example on Python
49+
versions without :mod:`annotationlib` - we fall back to the stdlib implementation,
50+
so behaviour on 3.10-3.13 remains unchanged.
51+
"""
52+
if not eval_str or not hasattr(obj, "__globals__"):
53+
return _get_annotations_from_inspect(
54+
obj, globals=globals, locals=locals, eval_str=eval_str
55+
)
56+
57+
if sys.version_info < (3, 14):
58+
raw_annotations = _get_annotations_from_inspect(
59+
obj, globals=globals, locals=locals, eval_str=False
60+
)
61+
evaluation_globals = cast(
62+
"dict[str, Any]", obj.__globals__ if globals is None else globals
63+
)
64+
evaluation_locals = evaluation_globals if locals is None else locals
65+
evaluated_annotations = {}
66+
for name, expression in raw_annotations.items():
67+
evaluated_annotations[name] = _evaluate_annotation_expression(
68+
expression, evaluation_globals, evaluation_locals
69+
)
70+
return evaluated_annotations
71+
72+
import annotationlib # noqa: PLC0415
73+
74+
raw_annotations = annotationlib.get_annotations(
75+
obj, globals=globals, locals=locals, format=annotationlib.Format.STRING
76+
)
77+
78+
evaluation_globals = obj.__globals__ if globals is None else globals
79+
evaluation_locals = _build_evaluation_locals(obj, locals)
80+
81+
evaluated_annotations = {}
82+
for name, expression in raw_annotations.items():
83+
evaluated_annotations[name] = _evaluate_annotation_expression(
84+
expression, evaluation_globals, evaluation_locals
85+
)
86+
87+
return evaluated_annotations
88+
89+
90+
def _build_evaluation_locals(
91+
obj: Callable[..., Any], provided_locals: dict[str, Any] | None
92+
) -> dict[str, Any]:
93+
# Order matters: later updates override earlier ones.
94+
# Default arguments are lowest priority (fallbacks), then provided_locals,
95+
# then snapshot_locals (captured loop variables) have highest priority.
96+
evaluation_locals: dict[str, Any] = {}
97+
evaluation_locals.update(_get_default_argument_locals(obj))
98+
if provided_locals:
99+
evaluation_locals.update(provided_locals)
100+
evaluation_locals.update(_get_snapshot_locals(obj))
101+
return evaluation_locals
102+
103+
104+
def _get_snapshot_locals(obj: Callable[..., Any]) -> dict[str, Any]:
105+
metadata = getattr(obj, "pytask_meta", None)
106+
snapshot = getattr(metadata, "annotation_locals", None)
107+
return dict(snapshot) if snapshot else {}
108+
109+
110+
def _get_default_argument_locals(obj: Callable[..., Any]) -> dict[str, Any]:
111+
try:
112+
parameters = inspect.signature(obj).parameters.values()
113+
except (TypeError, ValueError):
114+
return {}
115+
116+
defaults = {}
117+
for parameter in parameters:
118+
if parameter.default is not inspect.Parameter.empty:
119+
defaults[parameter.name] = parameter.default
120+
return defaults
121+
122+
123+
def _evaluate_annotation_expression(
124+
expression: Any, globals_: dict[str, Any] | None, locals_: dict[str, Any]
125+
) -> Any:
126+
if not isinstance(expression, str):
127+
return expression
128+
evaluation_globals = globals_ if globals_ is not None else {}
129+
evaluated = eval(expression, evaluation_globals, locals_) # noqa: S307
130+
if isinstance(evaluated, str):
131+
try:
132+
literal = ast.literal_eval(expression)
133+
except (SyntaxError, ValueError):
134+
return evaluated
135+
if isinstance(literal, str):
136+
try:
137+
return eval(literal, evaluation_globals, locals_) # noqa: S307
138+
except Exception: # noqa: BLE001
139+
return evaluated
140+
return evaluated

src/_pytask/click.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535

3636

3737
if importlib.metadata.version("click") < "8.2":
38-
from click.parser import split_opt
38+
from click.parser import split_opt as _split_opt
3939
else:
40-
from click.parser import _split_opt as split_opt # ty: ignore[unresolved-import]
40+
from click.parser import _split_opt # ty: ignore[unresolved-import]
41+
42+
split_opt = _split_opt
4143

4244

4345
class EnumChoice(Choice):

src/_pytask/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class CollectionMetadata:
3838
kwargs
3939
A dictionary containing keyword arguments which are passed to the task when it
4040
is executed.
41+
annotation_locals
42+
A snapshot of local variables captured during decoration which helps evaluate
43+
deferred annotations later on.
4144
markers
4245
A list of markers that are attached to the task.
4346
name
@@ -51,6 +54,7 @@ class CollectionMetadata:
5154

5255
after: str | list[Callable[..., Any]] = field(factory=list)
5356
attributes: dict[str, Any] = field(factory=dict)
57+
annotation_locals: dict[str, Any] | None = None
5458
is_generator: bool = False
5559
id_: str | None = None
5660
kwargs: dict[str, Any] = field(factory=dict)

0 commit comments

Comments
 (0)