From f99cebac3b4d9b73e65851d16a36f0b8dfb5d47b Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 22 Nov 2025 11:38:29 +0100 Subject: [PATCH 1/8] Handle lazy annotations in task generators. --- src/_pytask/_inspect.py | 112 +++++++++++++++++++++++++++++++++++++- src/_pytask/models.py | 4 ++ src/_pytask/task_utils.py | 22 +++++++- 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/_pytask/_inspect.py b/src/_pytask/_inspect.py index 98a94702..80d8ce9c 100644 --- a/src/_pytask/_inspect.py +++ b/src/_pytask/_inspect.py @@ -1,6 +1,116 @@ from __future__ import annotations +import inspect +import sys +from inspect import get_annotations as _get_annotations_from_inspect +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from collections.abc import Callable + __all__ = ["get_annotations"] +try: # Python < 3.14. + import annotationlib # type: ignore[import-not-found] +except ModuleNotFoundError: # pragma: no cover - depends on interpreter version. + annotationlib = None + + +def get_annotations( + obj: Callable[..., Any], + *, + globals: dict[str, Any] | None = None, # noqa: A002 - mimics inspect signature. + locals: dict[str, Any] | None = None, # noqa: A002 - mimics inspect signature. + eval_str: bool = False, +) -> dict[str, Any]: + """Return evaluated annotations with better support for deferred evaluation. + + Context + ------- + * PEP 649 introduces deferred annotations which are only evaluated when explicitly + requested. See https://peps.python.org/pep-0649/ for background and why locals can + disappear between definition and evaluation time. + * Python 3.14 ships :mod:`annotationlib` which exposes the raw annotation source and + provides the building blocks we reuse here. The module doc explains the available + formats: https://docs.python.org/3/library/annotationlib.html + * Other projects run into the same constraints. Pydantic tracks their work in + https://github.com/pydantic/pydantic/issues/12080; we might copy improvements from + there once they settle on a stable strategy. + + Rationale + --------- + When annotations refer to loop variables inside task generators, the locals that + existed during decoration have vanished by the time pytask evaluates annotations + while collecting tasks. Using :func:`inspect.get_annotations` would therefore yield + the same product path for every repeated task. By asking :mod:`annotationlib` for + string representations and re-evaluating them with reconstructed locals (globals, + default arguments, and the snapshots captured via ``@task``) we recover the correct + per-task values. If any of these ingredients are missing—for example on Python + versions without :mod:`annotationlib` - we fall back to the stdlib implementation, + so behaviour on 3.10-3.13 remains unchanged. + """ + if ( + annotationlib is None + or sys.version_info < (3, 14) + or not eval_str + or not callable(obj) + or not hasattr(obj, "__globals__") + ): + return _get_annotations_from_inspect( + obj, globals=globals, locals=locals, eval_str=eval_str + ) + + raw_annotations = annotationlib.get_annotations( + obj, globals=globals, locals=locals, format=annotationlib.Format.STRING + ) + + evaluation_globals = obj.__globals__ if globals is None else globals + evaluation_locals = _build_evaluation_locals(obj, locals) + + evaluated_annotations = {} + for name, expression in raw_annotations.items(): + evaluated_annotations[name] = _evaluate_annotation_expression( + expression, evaluation_globals, evaluation_locals + ) + + return evaluated_annotations + + +def _build_evaluation_locals( + obj: Callable[..., Any], provided_locals: dict[str, Any] | None +) -> dict[str, Any]: + evaluation_locals: dict[str, Any] = {} + if provided_locals: + evaluation_locals.update(provided_locals) + evaluation_locals.update(_get_snapshot_locals(obj)) + evaluation_locals.update(_get_default_argument_locals(obj)) + return evaluation_locals + + +def _get_snapshot_locals(obj: Callable[..., Any]) -> dict[str, Any]: + metadata = getattr(obj, "pytask_meta", None) + snapshot = getattr(metadata, "annotation_locals", None) + return dict(snapshot) if snapshot else {} + + +def _get_default_argument_locals(obj: Callable[..., Any]) -> dict[str, Any]: + try: + parameters = inspect.signature(obj).parameters.values() + except (TypeError, ValueError): + return {} + + defaults = {} + for parameter in parameters: + if parameter.default is not inspect._empty: + defaults[parameter.name] = parameter.default + return defaults + -from inspect import get_annotations +def _evaluate_annotation_expression( + expression: Any, globals_: dict[str, Any] | None, locals_: dict[str, Any] +) -> Any: + if not isinstance(expression, str): + return expression + evaluation_globals = globals_ if globals_ is not None else {} + return eval(expression, evaluation_globals, locals_) # noqa: S307 diff --git a/src/_pytask/models.py b/src/_pytask/models.py index 7511f3e9..3b12d442 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -38,6 +38,9 @@ class CollectionMetadata: kwargs A dictionary containing keyword arguments which are passed to the task when it is executed. + annotation_locals + A snapshot of local variables captured during decoration which helps evaluate + deferred annotations later on. markers A list of markers that are attached to the task. name @@ -51,6 +54,7 @@ class CollectionMetadata: after: str | list[Callable[..., Any]] = field(factory=list) attributes: dict[str, Any] = field(factory=dict) + annotation_locals: dict[str, Any] | None = None is_generator: bool = False id_: str | None = None kwargs: dict[str, Any] = field(factory=dict) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 9dbfb049..7b7c620b 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -5,6 +5,7 @@ import functools import inspect from collections import defaultdict +from contextlib import suppress from types import BuiltinFunctionType from typing import TYPE_CHECKING from typing import Any @@ -143,6 +144,8 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: parsed_name = _parse_name(unwrapped, name) parsed_after = _parse_after(after) + annotation_locals = _snapshot_annotation_locals(unwrapped) + if hasattr(unwrapped, "pytask_meta"): unwrapped.pytask_meta.after = parsed_after unwrapped.pytask_meta.is_generator = is_generator @@ -155,6 +158,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: else: unwrapped.pytask_meta = CollectionMetadata( # type: ignore[attr-defined] after=parsed_after, + annotation_locals=annotation_locals, is_generator=is_generator, id_=id, kwargs=parsed_kwargs, @@ -163,6 +167,9 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: produces=produces, ) + if annotation_locals is not None and hasattr(unwrapped, "pytask_meta"): + unwrapped.pytask_meta.annotation_locals = annotation_locals + if coiled_kwargs and hasattr(unwrapped, "pytask_meta"): unwrapped.pytask_meta.attributes["coiled_kwargs"] = coiled_kwargs @@ -208,7 +215,7 @@ def _parse_after( for func in after: if not hasattr(func, "pytask_meta"): func = task()(func) # noqa: PLW2901 - new_after.append(func.pytask_meta._id) # type: ignore[attr-defined] + new_after.append(func.pytask_meta._id) return new_after msg = ( "'after' should be an expression string, a task, or a list of tasks. Got " @@ -301,6 +308,19 @@ def parse_keyword_arguments_from_signature_defaults( return kwargs +def _snapshot_annotation_locals(func: Callable[..., Any]) -> dict[str, Any] | None: + """Capture the values of free variables at decoration time for annotations.""" + if func.__closure__ is None: + return None + + snapshot = {} + for name, cell in zip(func.__code__.co_freevars, func.__closure__, strict=False): + with suppress(ValueError): + snapshot[name] = cell.cell_contents + + return snapshot or None + + def _generate_ids_for_tasks( tasks: list[tuple[str, Callable[..., Any]]], ) -> dict[str, Callable[..., Any]]: From ea71f038b5dbbbd28375d3c4f21a6766f54397cc Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 22 Nov 2025 11:53:42 +0100 Subject: [PATCH 2/8] Fix. --- src/_pytask/_inspect.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/_pytask/_inspect.py b/src/_pytask/_inspect.py index 80d8ce9c..d7f9db4c 100644 --- a/src/_pytask/_inspect.py +++ b/src/_pytask/_inspect.py @@ -11,17 +11,12 @@ __all__ = ["get_annotations"] -try: # Python < 3.14. - import annotationlib # type: ignore[import-not-found] -except ModuleNotFoundError: # pragma: no cover - depends on interpreter version. - annotationlib = None - def get_annotations( obj: Callable[..., Any], *, - globals: dict[str, Any] | None = None, # noqa: A002 - mimics inspect signature. - locals: dict[str, Any] | None = None, # noqa: A002 - mimics inspect signature. + globals: dict[str, Any] | None = None, # noqa: A002 + locals: dict[str, Any] | None = None, # noqa: A002 eval_str: bool = False, ) -> dict[str, Any]: """Return evaluated annotations with better support for deferred evaluation. @@ -50,17 +45,13 @@ def get_annotations( versions without :mod:`annotationlib` - we fall back to the stdlib implementation, so behaviour on 3.10-3.13 remains unchanged. """ - if ( - annotationlib is None - or sys.version_info < (3, 14) - or not eval_str - or not callable(obj) - or not hasattr(obj, "__globals__") - ): + if sys.version_info < (3, 14): return _get_annotations_from_inspect( obj, globals=globals, locals=locals, eval_str=eval_str ) + import annotationlib # noqa: PLC0415 + raw_annotations = annotationlib.get_annotations( obj, globals=globals, locals=locals, format=annotationlib.Format.STRING ) From 96864ba2b76426ab1cc101769929dbe4a6032452 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 22 Nov 2025 13:47:15 +0100 Subject: [PATCH 3/8] Fix. --- src/_pytask/_inspect.py | 2 +- src/_pytask/task_utils.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/_pytask/_inspect.py b/src/_pytask/_inspect.py index d7f9db4c..4ef63b9e 100644 --- a/src/_pytask/_inspect.py +++ b/src/_pytask/_inspect.py @@ -45,7 +45,7 @@ def get_annotations( versions without :mod:`annotationlib` - we fall back to the stdlib implementation, so behaviour on 3.10-3.13 remains unchanged. """ - if sys.version_info < (3, 14): + if sys.version_info < (3, 14) or not eval_str or not hasattr(obj, "__globals__"): return _get_annotations_from_inspect( obj, globals=globals, locals=locals, eval_str=eval_str ) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 7b7c620b..22c101be 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -310,11 +310,15 @@ def parse_keyword_arguments_from_signature_defaults( def _snapshot_annotation_locals(func: Callable[..., Any]) -> dict[str, Any] | None: """Capture the values of free variables at decoration time for annotations.""" - if func.__closure__ is None: + while isinstance(func, functools.partial): + func = func.func + + closure = getattr(func, "__closure__", None) + if not closure: return None snapshot = {} - for name, cell in zip(func.__code__.co_freevars, func.__closure__, strict=False): + for name, cell in zip(func.__code__.co_freevars, closure, strict=False): with suppress(ValueError): snapshot[name] = cell.cell_contents From 3fa2eaab8d649944ee8f509d04473d2d3afd20a5 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 22 Nov 2025 13:59:35 +0100 Subject: [PATCH 4/8] Fix tests. --- src/_pytask/task_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 22c101be..61b9107f 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -215,7 +215,7 @@ def _parse_after( for func in after: if not hasattr(func, "pytask_meta"): func = task()(func) # noqa: PLW2901 - new_after.append(func.pytask_meta._id) + new_after.append(func.pytask_meta._id) # type: ignore[attr-defined] return new_after msg = ( "'after' should be an expression string, a task, or a list of tasks. Got " From 525a9ef1a95475de48d5e8e08b257c8e69574029 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 20 Dec 2025 22:41:34 +0100 Subject: [PATCH 5/8] FIy. --- src/_pytask/_inspect.py | 6 ++++-- src/_pytask/task_utils.py | 37 +++++++++++++++++++++---------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/_pytask/_inspect.py b/src/_pytask/_inspect.py index 4ef63b9e..904b601c 100644 --- a/src/_pytask/_inspect.py +++ b/src/_pytask/_inspect.py @@ -40,8 +40,10 @@ def get_annotations( while collecting tasks. Using :func:`inspect.get_annotations` would therefore yield the same product path for every repeated task. By asking :mod:`annotationlib` for string representations and re-evaluating them with reconstructed locals (globals, - default arguments, and the snapshots captured via ``@task``) we recover the correct - per-task values. If any of these ingredients are missing—for example on Python + default arguments, and the frame locals captured via ``@task`` at decoration time) + we recover the correct per-task values. The frame locals capture is essential for + cases where loop variables are only referenced in annotations (not in the function + body or closure). If any of these ingredients are missing—for example on Python versions without :mod:`annotationlib` - we fall back to the stdlib implementation, so behaviour on 3.10-3.13 remains unchanged. """ diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 61b9107f..a937ffe6 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -4,8 +4,8 @@ import functools import inspect +import sys from collections import defaultdict -from contextlib import suppress from types import BuiltinFunctionType from typing import TYPE_CHECKING from typing import Any @@ -44,7 +44,7 @@ """ -def task( # noqa: PLR0913 +def task( # noqa: PLR0913, C901 name: str | None = None, *, after: str | Callable[..., Any] | list[Callable[..., Any]] | None = None, @@ -52,6 +52,7 @@ def task( # noqa: PLR0913 id: str | None = None, # noqa: A002 kwargs: dict[Any, Any] | None = None, produces: Any | None = None, + _caller_locals: dict[str, Any] | None = None, ) -> Callable[..., Callable[..., Any]]: """Decorate a task function. @@ -109,6 +110,11 @@ def create_text_file() -> Annotated[str, Path("file.txt")]: return "Hello, World!" """ + # Capture the caller's frame locals for deferred annotation evaluation in Python + # 3.14+. This must be done here (not in wrapper) to get the correct scope when + # @task is used without parentheses. + if _caller_locals is None: + _caller_locals = sys._getframe(1).f_locals.copy() def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: # Omits frame when a builtin function is wrapped. @@ -144,7 +150,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: parsed_name = _parse_name(unwrapped, name) parsed_after = _parse_after(after) - annotation_locals = _snapshot_annotation_locals(unwrapped) + annotation_locals = _snapshot_annotation_locals(_caller_locals) if hasattr(unwrapped, "pytask_meta"): unwrapped.pytask_meta.after = parsed_after @@ -182,7 +188,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: # In case the decorator is used without parentheses, wrap the function which is # passed as the first argument with the default arguments. if is_task_function(name) and kwargs is None: - return task()(name) + return task(_caller_locals=_caller_locals)(name) return wrapper @@ -308,21 +314,20 @@ def parse_keyword_arguments_from_signature_defaults( return kwargs -def _snapshot_annotation_locals(func: Callable[..., Any]) -> dict[str, Any] | None: - """Capture the values of free variables at decoration time for annotations.""" - while isinstance(func, functools.partial): - func = func.func +def _snapshot_annotation_locals( + caller_locals: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Capture caller's frame locals at decoration time for deferred annotation eval. - closure = getattr(func, "__closure__", None) - if not closure: - return None + This function captures variables that may be referenced in type annotations but + won't be available when annotations are evaluated later (e.g., loop variables in + task generators under Python 3.14's PEP 649 deferred annotations). - snapshot = {} - for name, cell in zip(func.__code__.co_freevars, closure, strict=False): - with suppress(ValueError): - snapshot[name] = cell.cell_contents + We capture the caller's frame locals - variables in the scope where @task is + applied (e.g., loop variables like `path` that are only referenced in annotations). - return snapshot or None + """ + return caller_locals.copy() if caller_locals else None def _generate_ids_for_tasks( From 9231432b8dbd8ff095efd919600e84176d9e6ebc Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Mon, 22 Dec 2025 09:31:21 +0100 Subject: [PATCH 6/8] Simplify deferred annotation handling code - Remove redundant _snapshot_annotation_locals function (was just a copy) - Use _caller_locals directly instead of double-copying - Fix priority order in _build_evaluation_locals: snapshot locals now have highest priority over default arguments - Use public API inspect.Parameter.empty instead of inspect._empty - Clean up duplicate docstring entries for id, kwargs, produces - Remove unnecessary C901 noqa (function simplified) --- src/_pytask/_inspect.py | 7 ++++-- src/_pytask/task_utils.py | 51 +++++++-------------------------------- 2 files changed, 14 insertions(+), 44 deletions(-) diff --git a/src/_pytask/_inspect.py b/src/_pytask/_inspect.py index 904b601c..728aae68 100644 --- a/src/_pytask/_inspect.py +++ b/src/_pytask/_inspect.py @@ -73,11 +73,14 @@ def get_annotations( def _build_evaluation_locals( obj: Callable[..., Any], provided_locals: dict[str, Any] | None ) -> dict[str, Any]: + # Order matters: later updates override earlier ones. + # Default arguments are lowest priority (fallbacks), then provided_locals, + # then snapshot_locals (captured loop variables) have highest priority. evaluation_locals: dict[str, Any] = {} + evaluation_locals.update(_get_default_argument_locals(obj)) if provided_locals: evaluation_locals.update(provided_locals) evaluation_locals.update(_get_snapshot_locals(obj)) - evaluation_locals.update(_get_default_argument_locals(obj)) return evaluation_locals @@ -95,7 +98,7 @@ def _get_default_argument_locals(obj: Callable[..., Any]) -> dict[str, Any]: defaults = {} for parameter in parameters: - if parameter.default is not inspect._empty: + if parameter.default is not inspect.Parameter.empty: defaults[parameter.name] = parameter.default return defaults diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index a937ffe6..ed6ffb6c 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -44,7 +44,7 @@ """ -def task( # noqa: PLR0913, C901 +def task( # noqa: PLR0913 name: str | None = None, *, after: str | Callable[..., Any] | list[Callable[..., Any]] | None = None, @@ -72,30 +72,18 @@ def task( # noqa: PLR0913, C901 information. is_generator An indicator whether this task is a task generator. - id - An id for the task if it is part of a parametrization. Otherwise, an automatic - id will be generated. See - :doc:`this tutorial <../tutorials/repeating_tasks_with_different_inputs>` for - more information. - kwargs - A dictionary containing keyword arguments which are passed to the task when it - is executed. - produces - Definition of products to parse the function returns and store them. See - :doc:`this how-to guide <../how_to_guides/using_task_returns>` for more id An id for the task if it is part of a repetition. Otherwise, an automatic id will be generated. See :ref:`how-to-repeat-a-task-with-different-inputs-the-id` for more information. kwargs - Use a dictionary to pass any keyword arguments to the task function which can be - dependencies or products of the task. Read :ref:`task-kwargs` for more - information. - produces - Use this argument if you want to parse the return of the task function as a - product, but you cannot annotate the return of the function. See :doc:`this - how-to guide <../how_to_guides/using_task_returns>` or :ref:`task-produces` for + A dictionary containing keyword arguments which are passed to the task function. + These can be dependencies or products of the task. Read :ref:`task-kwargs` for more information. + produces + Use this argument to parse the return of the task function as a product. See + :doc:`this how-to guide <../how_to_guides/using_task_returns>` or + :ref:`task-produces` for more information. Examples -------- @@ -150,8 +138,6 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: parsed_name = _parse_name(unwrapped, name) parsed_after = _parse_after(after) - annotation_locals = _snapshot_annotation_locals(_caller_locals) - if hasattr(unwrapped, "pytask_meta"): unwrapped.pytask_meta.after = parsed_after unwrapped.pytask_meta.is_generator = is_generator @@ -160,11 +146,11 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: unwrapped.pytask_meta.markers.append(Mark("task", (), {})) unwrapped.pytask_meta.name = parsed_name unwrapped.pytask_meta.produces = produces - unwrapped.pytask_meta.after = parsed_after + unwrapped.pytask_meta.annotation_locals = _caller_locals else: unwrapped.pytask_meta = CollectionMetadata( # type: ignore[attr-defined] after=parsed_after, - annotation_locals=annotation_locals, + annotation_locals=_caller_locals, is_generator=is_generator, id_=id, kwargs=parsed_kwargs, @@ -173,9 +159,6 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: produces=produces, ) - if annotation_locals is not None and hasattr(unwrapped, "pytask_meta"): - unwrapped.pytask_meta.annotation_locals = annotation_locals - if coiled_kwargs and hasattr(unwrapped, "pytask_meta"): unwrapped.pytask_meta.attributes["coiled_kwargs"] = coiled_kwargs @@ -314,22 +297,6 @@ def parse_keyword_arguments_from_signature_defaults( return kwargs -def _snapshot_annotation_locals( - caller_locals: dict[str, Any] | None, -) -> dict[str, Any] | None: - """Capture caller's frame locals at decoration time for deferred annotation eval. - - This function captures variables that may be referenced in type annotations but - won't be available when annotations are evaluated later (e.g., loop variables in - task generators under Python 3.14's PEP 649 deferred annotations). - - We capture the caller's frame locals - variables in the scope where @task is - applied (e.g., loop variables like `path` that are only referenced in annotations). - - """ - return caller_locals.copy() if caller_locals else None - - def _generate_ids_for_tasks( tasks: list[tuple[str, Callable[..., Any]]], ) -> dict[str, Callable[..., Any]]: From 192856b3a60bde6dedefc70b83d4533b45771b44 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Mon, 22 Dec 2025 09:51:32 +0100 Subject: [PATCH 7/8] Remove _caller_locals parameter from task decorator Instead of recursively calling task() and passing captured locals via a parameter, detect the no-parentheses case early and call wrapper() directly. The closure captures caller_locals, eliminating the need for the parameter. This simplifies the public API while maintaining the same behavior for deferred annotation evaluation in Python 3.14+. --- src/_pytask/task_utils.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index ed6ffb6c..f09f5fe6 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -52,7 +52,6 @@ def task( # noqa: PLR0913 id: str | None = None, # noqa: A002 kwargs: dict[Any, Any] | None = None, produces: Any | None = None, - _caller_locals: dict[str, Any] | None = None, ) -> Callable[..., Callable[..., Any]]: """Decorate a task function. @@ -99,15 +98,18 @@ def create_text_file() -> Annotated[str, Path("file.txt")]: """ # Capture the caller's frame locals for deferred annotation evaluation in Python - # 3.14+. This must be done here (not in wrapper) to get the correct scope when - # @task is used without parentheses. - if _caller_locals is None: - _caller_locals = sys._getframe(1).f_locals.copy() - - def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: - # Omits frame when a builtin function is wrapped. - _rich_traceback_omit = True + # 3.14+. The wrapper closure captures this variable. + caller_locals = sys._getframe(1).f_locals.copy() + # Detect if decorator is used without parentheses: @task instead of @task() + # In this case, `name` is actually the function being decorated. + if is_task_function(name) and kwargs is None: + func_to_wrap = name + actual_name = None + else: + func_to_wrap = None + actual_name = name + # Validate arguments only when used with parentheses for arg, arg_name in ((name, "name"), (id, "id")): if not (isinstance(arg, str) or arg is None): msg = ( @@ -115,6 +117,10 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: ) raise ValueError(msg) + def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: + # Omits frame when a builtin function is wrapped. + _rich_traceback_omit = True + unwrapped = unwrap_task_function(func) if isinstance(unwrapped, Function): coiled_kwargs = extract_coiled_function_kwargs(unwrapped) @@ -135,7 +141,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: path = get_file(unwrapped) parsed_kwargs = {} if kwargs is None else kwargs - parsed_name = _parse_name(unwrapped, name) + parsed_name = _parse_name(unwrapped, actual_name) parsed_after = _parse_after(after) if hasattr(unwrapped, "pytask_meta"): @@ -146,11 +152,11 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: unwrapped.pytask_meta.markers.append(Mark("task", (), {})) unwrapped.pytask_meta.name = parsed_name unwrapped.pytask_meta.produces = produces - unwrapped.pytask_meta.annotation_locals = _caller_locals + unwrapped.pytask_meta.annotation_locals = caller_locals else: unwrapped.pytask_meta = CollectionMetadata( # type: ignore[attr-defined] after=parsed_after, - annotation_locals=_caller_locals, + annotation_locals=caller_locals, is_generator=is_generator, id_=id, kwargs=parsed_kwargs, @@ -168,10 +174,8 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: return unwrapped - # In case the decorator is used without parentheses, wrap the function which is - # passed as the first argument with the default arguments. - if is_task_function(name) and kwargs is None: - return task(_caller_locals=_caller_locals)(name) + if func_to_wrap is not None: + return wrapper(func_to_wrap) return wrapper From 9340fb4499004dcc3d5c379fc4256042fa349f56 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Mon, 22 Dec 2025 11:02:57 +0100 Subject: [PATCH 8/8] Simplify again. --- src/_pytask/task_utils.py | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index f09f5fe6..c9701b4b 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -101,26 +101,20 @@ def create_text_file() -> Annotated[str, Path("file.txt")]: # 3.14+. The wrapper closure captures this variable. caller_locals = sys._getframe(1).f_locals.copy() - # Detect if decorator is used without parentheses: @task instead of @task() - # In this case, `name` is actually the function being decorated. - if is_task_function(name) and kwargs is None: - func_to_wrap = name - actual_name = None - else: - func_to_wrap = None - actual_name = name - # Validate arguments only when used with parentheses - for arg, arg_name in ((name, "name"), (id, "id")): + def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: + # Omits frame when a builtin function is wrapped. + _rich_traceback_omit = True + + # When @task is used without parentheses, name is the function, not a string. + effective_name = None if is_task_function(name) else name + + for arg, arg_name in ((effective_name, "name"), (id, "id")): if not (isinstance(arg, str) or arg is None): msg = ( f"Argument {arg_name!r} of @task must be a str, but it is {arg!r}." ) raise ValueError(msg) - def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: - # Omits frame when a builtin function is wrapped. - _rich_traceback_omit = True - unwrapped = unwrap_task_function(func) if isinstance(unwrapped, Function): coiled_kwargs = extract_coiled_function_kwargs(unwrapped) @@ -141,7 +135,7 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: path = get_file(unwrapped) parsed_kwargs = {} if kwargs is None else kwargs - parsed_name = _parse_name(unwrapped, actual_name) + parsed_name = _parse_name(unwrapped, effective_name) parsed_after = _parse_after(after) if hasattr(unwrapped, "pytask_meta"): @@ -174,8 +168,9 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: return unwrapped - if func_to_wrap is not None: - return wrapper(func_to_wrap) + # When decorator is used without parentheses, call wrapper directly. + if is_task_function(name) and kwargs is None: + return wrapper(name) return wrapper