Skip to content

Commit ef66b4e

Browse files
authored
Handle lazy annotations for task generators in Python 3.14 (#724)
1 parent b857f67 commit ef66b4e

9 files changed

Lines changed: 259 additions & 36 deletions

File tree

pyproject.toml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ docs = [
6060
"sphinx-design>=0.3",
6161
"sphinx-toolbox>=4.0.0",
6262
"sphinxext-opengraph>=0.10.0",
63-
"sphinx-autobuild>=2024.10.3",
6463
]
64+
docs-live = ["sphinx-autobuild>=2024.10.3"]
6565
plugin-list = ["httpx>=0.27.0", "tabulate[widechars]>=0.9.0", "tqdm>=4.66.3"]
6666
test = [
6767
"cloudpickle>=3.0.0",
@@ -74,7 +74,7 @@ test = [
7474
"pytest-cov>=5.0.0",
7575
"pytest-xdist>=3.6.1",
7676
"syrupy>=4.5.0",
77-
"aiohttp>=3.11.0", # For HTTPPath tests.
77+
"aiohttp>=3.11.0", # For HTTPPath tests.
7878
"coiled>=1.42.0",
7979
"pygraphviz>=1.12;platform_system=='Linux'",
8080
]
@@ -173,6 +173,16 @@ filterwarnings = [
173173
[tool.ty.rules]
174174
unused-ignore-comment = "error"
175175

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+
176186
[tool.ty.src]
177187
exclude = ["src/_pytask/_hashlib.py"]
178188

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)

src/_pytask/task_utils.py

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Contains utilities related to the :func:`@task <pytask.task>`."""
22

33
from __future__ import annotations
4+
import __future__
45

56
import functools
67
import inspect
8+
import sys
79
from collections import defaultdict
810
from types import BuiltinFunctionType
911
from typing import TYPE_CHECKING
1012
from typing import Any
1113
from typing import TypeVar
14+
from typing import cast
1215

1316
import attrs
1417

@@ -79,30 +82,18 @@ def task( # noqa: PLR0913
7982
information.
8083
is_generator
8184
An indicator whether this task is a task generator.
82-
id
83-
An id for the task if it is part of a parametrization. Otherwise, an automatic
84-
id will be generated. See
85-
:doc:`this tutorial <../tutorials/repeating_tasks_with_different_inputs>` for
86-
more information.
87-
kwargs
88-
A dictionary containing keyword arguments which are passed to the task when it
89-
is executed.
90-
produces
91-
Definition of products to parse the function returns and store them. See
92-
:doc:`this how-to guide <../how_to_guides/using_task_returns>` for more
9385
id
9486
An id for the task if it is part of a repetition. Otherwise, an automatic id
9587
will be generated. See :ref:`how-to-repeat-a-task-with-different-inputs-the-id`
9688
for more information.
9789
kwargs
98-
Use a dictionary to pass any keyword arguments to the task function which can be
99-
dependencies or products of the task. Read :ref:`task-kwargs` for more
100-
information.
101-
produces
102-
Use this argument if you want to parse the return of the task function as a
103-
product, but you cannot annotate the return of the function. See :doc:`this
104-
how-to guide <../how_to_guides/using_task_returns>` or :ref:`task-produces` for
90+
A dictionary containing keyword arguments which are passed to the task function.
91+
These can be dependencies or products of the task. Read :ref:`task-kwargs` for
10592
more information.
93+
produces
94+
Use this argument to parse the return of the task function as a product. See
95+
:doc:`this how-to guide <../how_to_guides/using_task_returns>` or
96+
:ref:`task-produces` for more information.
10697
10798
Examples
10899
--------
@@ -117,12 +108,23 @@ def create_text_file() -> Annotated[str, Path("file.txt")]:
117108
return "Hello, World!"
118109
119110
"""
111+
# Capture the caller's frame locals for deferred annotation evaluation in Python
112+
# 3.14+. If ``from __future__ import annotations`` is active, keep the pre-3.14
113+
# behavior by evaluating annotations against current globals instead of snapshots.
114+
caller_frame = sys._getframe(1)
115+
has_future_annotations = bool(
116+
caller_frame.f_code.co_flags & __future__.annotations.compiler_flag
117+
)
118+
caller_locals = None if has_future_annotations else caller_frame.f_locals.copy()
120119

121120
def wrapper(func: T) -> TaskDecorated[T]:
122121
# Omits frame when a builtin function is wrapped.
123122
_rich_traceback_omit = True
124123

125-
for arg, arg_name in ((name, "name"), (id, "id")):
124+
# When @task is used without parentheses, name is the function, not a string.
125+
effective_name = None if is_task_function(name) else name
126+
127+
for arg, arg_name in ((effective_name, "name"), (id, "id")):
126128
if not (isinstance(arg, str) or arg is None):
127129
msg = (
128130
f"Argument {arg_name!r} of @task must be a str, but it is {arg!r}."
@@ -149,7 +151,7 @@ def wrapper(func: T) -> TaskDecorated[T]:
149151
path = get_file(unwrapped)
150152

151153
parsed_kwargs = {} if kwargs is None else kwargs
152-
parsed_name = _parse_name(unwrapped, name)
154+
parsed_name = _parse_name(unwrapped, effective_name)
153155
parsed_after = _parse_after(after)
154156

155157
if isinstance(unwrapped, TaskFunction):
@@ -160,10 +162,11 @@ def wrapper(func: T) -> TaskDecorated[T]:
160162
unwrapped.pytask_meta.markers.append(Mark("task", (), {}))
161163
unwrapped.pytask_meta.name = parsed_name
162164
unwrapped.pytask_meta.produces = produces
163-
unwrapped.pytask_meta.after = parsed_after
165+
unwrapped.pytask_meta.annotation_locals = caller_locals
164166
else:
165167
unwrapped.pytask_meta = CollectionMetadata( # type: ignore[attr-defined]
166168
after=parsed_after,
169+
annotation_locals=caller_locals,
167170
is_generator=is_generator,
168171
id_=id,
169172
kwargs=parsed_kwargs,
@@ -181,10 +184,9 @@ def wrapper(func: T) -> TaskDecorated[T]:
181184

182185
return unwrapped
183186

184-
# In case the decorator is used without parentheses, wrap the function which is
185-
# passed as the first argument with the default arguments.
187+
# When decorator is used without parentheses, call wrapper directly.
186188
if is_task_function(name) and kwargs is None:
187-
return task()(name)
189+
return wrapper(cast("T", name))
188190
return wrapper
189191

190192

tests/conftest.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,27 @@
66
import sys
77
from contextlib import contextmanager
88
from pathlib import Path
9+
from typing import TYPE_CHECKING
910
from typing import Any
1011
from typing import NamedTuple
1112

1213
import pytest
1314
from click.testing import CliRunner
1415
from packaging import version
1516

17+
from pytask import console
18+
from pytask import storage
19+
20+
if TYPE_CHECKING:
21+
from nbmake.pytest_items import NotebookItem as _NotebookItem
22+
23+
NotebookItem: type[Any] | None
1624
try:
17-
from nbmake.pytest_items import NotebookItem
25+
from nbmake.pytest_items import NotebookItem as _NotebookItem
1826
except ImportError:
1927
NotebookItem = None
20-
21-
from pytask import console
22-
from pytask import storage
28+
else:
29+
NotebookItem = _NotebookItem
2330

2431

2532
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)