Skip to content

Commit 525a9ef

Browse files
committed
FIy.
1 parent 3078b97 commit 525a9ef

2 files changed

Lines changed: 25 additions & 18 deletions

File tree

src/_pytask/_inspect.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ def get_annotations(
4040
while collecting tasks. Using :func:`inspect.get_annotations` would therefore yield
4141
the same product path for every repeated task. By asking :mod:`annotationlib` for
4242
string representations and re-evaluating them with reconstructed locals (globals,
43-
default arguments, and the snapshots captured via ``@task``) we recover the correct
44-
per-task values. If any of these ingredients are missing—for example on Python
43+
default arguments, and the frame locals captured via ``@task`` at decoration time)
44+
we recover the correct per-task values. The frame locals capture is essential for
45+
cases where loop variables are only referenced in annotations (not in the function
46+
body or closure). If any of these ingredients are missing—for example on Python
4547
versions without :mod:`annotationlib` - we fall back to the stdlib implementation,
4648
so behaviour on 3.10-3.13 remains unchanged.
4749
"""

src/_pytask/task_utils.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
import functools
66
import inspect
7+
import sys
78
from collections import defaultdict
8-
from contextlib import suppress
99
from types import BuiltinFunctionType
1010
from typing import TYPE_CHECKING
1111
from typing import Any
@@ -44,14 +44,15 @@
4444
"""
4545

4646

47-
def task( # noqa: PLR0913
47+
def task( # noqa: PLR0913, C901
4848
name: str | None = None,
4949
*,
5050
after: str | Callable[..., Any] | list[Callable[..., Any]] | None = None,
5151
is_generator: bool = False,
5252
id: str | None = None, # noqa: A002
5353
kwargs: dict[Any, Any] | None = None,
5454
produces: Any | None = None,
55+
_caller_locals: dict[str, Any] | None = None,
5556
) -> Callable[..., Callable[..., Any]]:
5657
"""Decorate a task function.
5758
@@ -109,6 +110,11 @@ def create_text_file() -> Annotated[str, Path("file.txt")]:
109110
return "Hello, World!"
110111
111112
"""
113+
# Capture the caller's frame locals for deferred annotation evaluation in Python
114+
# 3.14+. This must be done here (not in wrapper) to get the correct scope when
115+
# @task is used without parentheses.
116+
if _caller_locals is None:
117+
_caller_locals = sys._getframe(1).f_locals.copy()
112118

113119
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
114120
# Omits frame when a builtin function is wrapped.
@@ -144,7 +150,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
144150
parsed_name = _parse_name(unwrapped, name)
145151
parsed_after = _parse_after(after)
146152

147-
annotation_locals = _snapshot_annotation_locals(unwrapped)
153+
annotation_locals = _snapshot_annotation_locals(_caller_locals)
148154

149155
if hasattr(unwrapped, "pytask_meta"):
150156
unwrapped.pytask_meta.after = parsed_after
@@ -182,7 +188,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
182188
# In case the decorator is used without parentheses, wrap the function which is
183189
# passed as the first argument with the default arguments.
184190
if is_task_function(name) and kwargs is None:
185-
return task()(name)
191+
return task(_caller_locals=_caller_locals)(name)
186192
return wrapper
187193

188194

@@ -308,21 +314,20 @@ def parse_keyword_arguments_from_signature_defaults(
308314
return kwargs
309315

310316

311-
def _snapshot_annotation_locals(func: Callable[..., Any]) -> dict[str, Any] | None:
312-
"""Capture the values of free variables at decoration time for annotations."""
313-
while isinstance(func, functools.partial):
314-
func = func.func
317+
def _snapshot_annotation_locals(
318+
caller_locals: dict[str, Any] | None,
319+
) -> dict[str, Any] | None:
320+
"""Capture caller's frame locals at decoration time for deferred annotation eval.
315321
316-
closure = getattr(func, "__closure__", None)
317-
if not closure:
318-
return None
322+
This function captures variables that may be referenced in type annotations but
323+
won't be available when annotations are evaluated later (e.g., loop variables in
324+
task generators under Python 3.14's PEP 649 deferred annotations).
319325
320-
snapshot = {}
321-
for name, cell in zip(func.__code__.co_freevars, closure, strict=False):
322-
with suppress(ValueError):
323-
snapshot[name] = cell.cell_contents
326+
We capture the caller's frame locals - variables in the scope where @task is
327+
applied (e.g., loop variables like `path` that are only referenced in annotations).
324328
325-
return snapshot or None
329+
"""
330+
return caller_locals.copy() if caller_locals else None
326331

327332

328333
def _generate_ids_for_tasks(

0 commit comments

Comments
 (0)