From e66bf078d38d6176cc36576b0d029eb2bd2df9bf Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 27 Feb 2022 22:06:49 +0100 Subject: [PATCH 01/21] Implement a couple of tests and add to changes. --- docs/source/changes.rst | 2 + tests/test_task.py | 109 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/docs/source/changes.rst b/docs/source/changes.rst index aab4067a9..6b822490f 100644 --- a/docs/source/changes.rst +++ b/docs/source/changes.rst @@ -16,6 +16,8 @@ all releases are available on `PyPI `_ and parametrized arguments to the task class. - :pull:`228` removes ``task.pytaskmark`` and moves the information to :attr:`_pytask.models.CollectionMetadata.markers`. +- :pull:`229` implements a new loop-based approach to parametrizations using the + :func:`@pytask.mark.task <_pytask.task.task>` decorator. 0.1.9 - 2022-02-23 diff --git a/tests/test_task.py b/tests/test_task.py index 316a1c227..5c3cbf69c 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -4,6 +4,7 @@ import pytest from _pytask.nodes import create_task_name +from pytask import cli from pytask import ExitCode from pytask import main @@ -70,3 +71,111 @@ def {func_name}(produces): assert session.tasks[1].name == create_task_name( tmp_path.joinpath("task_module.py"), f"{func_name}[out_2.txt]" ) + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task + @pytask.mark.produces(f"out_{i}.txt") + def task_example(produces): + produces.write_text("Your advertisement could be here.") + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "task_example[0]" in result.output + assert "task_example[1]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_from_markers(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task + @pytask.mark.depends_on(f"in_{i}.txt") + @pytask.mark.produces(f"out_{i}.txt") + def example(depends_on, produces): + produces.write_text(depends_on.read_text()) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + tmp_path.joinpath("in_0.txt").write_text("Your advertisement could be here.") + tmp_path.joinpath("in_1.txt").write_text("Or here.") + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "example[depends_on0-produces0]" in result.output + assert "example[depends_on1-produces1]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_from_signature(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task + def example(depends_on=f"in_{i}.txt", produces=f"out_{i}.txt"): + produces.write_text(depends_on.read_text()) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + tmp_path.joinpath("in_0.txt").write_text("Your advertisement could be here.") + tmp_path.joinpath("in_1.txt").write_text("Or here.") + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "example[depends_on0-produces0]" in result.output + assert "example[depends_on1-produces1]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_from_markers_and_args(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task + @pytask.mark.produces(f"out_{i}.txt") + def example(produces, i=i): + produces.write_text(str(i)) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "example[produces0-0]" in result.output + assert "example[produces1-1]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_from_decorator(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task(name="deco_task", kwargs={"i": i, "produces": f"out_{i}.txt"}) + def example(produces, i): + produces.write_text(str(i)) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "deco_task[produces0-0]" in result.output + assert "deco_task[produces1-1]" in result.output From 5390091963853ccf5901b459d544983bc23d2d05 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 00:58:30 +0100 Subject: [PATCH 02/21] Implement functionality to make tests pass. --- src/_pytask/collect.py | 13 +-- src/_pytask/hookspecs.py | 2 +- src/_pytask/mark/structures.py | 7 +- src/_pytask/models.py | 3 + src/_pytask/parametrize.py | 7 -- src/_pytask/task.py | 45 +++++++++- src/_pytask/task_utils.py | 145 ++++++++++++++++++++++++++++++--- tests/test_task.py | 12 +-- 8 files changed, 197 insertions(+), 37 deletions(-) diff --git a/src/_pytask/collect.py b/src/_pytask/collect.py index d56b5f282..55d0c5202 100644 --- a/src/_pytask/collect.py +++ b/src/_pytask/collect.py @@ -2,6 +2,7 @@ from __future__ import annotations import inspect +import itertools import os import sys import time @@ -65,7 +66,8 @@ def _collect_from_paths(session: Session) -> None: reports = session.hook.pytask_collect_file_protocol( session=session, path=path, reports=session.collection_reports ) - if reports is not None: + + if reports: session.collection_reports.extend(reports) session.tasks.extend( i.node for i in reports if i.outcome == CollectionOutcome.SUCCESS @@ -85,20 +87,21 @@ def pytask_collect_file_protocol( ) -> list[CollectionReport]: """Wrap the collection of tasks from a file to collect reports.""" try: - reports = session.hook.pytask_collect_file( + new_reports = session.hook.pytask_collect_file( session=session, path=path, reports=reports ) + flat_reports = list(itertools.chain.from_iterable(new_reports)) except Exception: node = FilePathNode.from_path(path) - reports = [ + flat_reports = [ CollectionReport.from_exception( outcome=CollectionOutcome.FAIL, node=node, exc_info=sys.exc_info() ) ] - session.hook.pytask_collect_file_log(session=session, reports=reports) + session.hook.pytask_collect_file_log(session=session, reports=flat_reports) - return reports + return flat_reports @hookimpl diff --git a/src/_pytask/hookspecs.py b/src/_pytask/hookspecs.py index d830674dc..ecaee3ceb 100644 --- a/src/_pytask/hookspecs.py +++ b/src/_pytask/hookspecs.py @@ -159,7 +159,7 @@ def pytask_collect_file_protocol( """ -@hookspec(firstresult=True) +@hookspec def pytask_collect_file( session: Session, path: Path, reports: list[CollectionReport] ) -> list[CollectionReport] | None: diff --git a/src/_pytask/mark/structures.py b/src/_pytask/mark/structures.py index d044e776a..8c1e94dce 100644 --- a/src/_pytask/mark/structures.py +++ b/src/_pytask/mark/structures.py @@ -184,7 +184,7 @@ class MarkGenerator: markers: set[str] = set() """Set[str]: The set of markers.""" - def __getattr__(self, name: str) -> MarkDecorator: + def __getattr__(self, name: str) -> MarkDecorator | Any: if name[0] == "_": raise AttributeError("Marker name must NOT start with underscore") @@ -210,6 +210,11 @@ def __getattr__(self, name: str) -> MarkDecorator: stacklevel=2, ) + if name == "task": + from _pytask.task_utils import task + + return task + return MarkDecorator(Mark(name, (), {})) diff --git a/src/_pytask/models.py b/src/_pytask/models.py index abb502335..c72495474 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -5,6 +5,7 @@ from typing import Dict from typing import List from typing import TYPE_CHECKING +from typing import Union import attr @@ -20,3 +21,5 @@ class CollectionMetadata: """Contains kwargs which are necessary for the task function on execution.""" markers = attr.ib(factory=list, type=List["Mark"]) """Contains the markers of the function.""" + name = attr.ib(default=None, type=Union[str, None]) + """The name of the task function.""" diff --git a/src/_pytask/parametrize.py b/src/_pytask/parametrize.py index 73e170865..db641095d 100644 --- a/src/_pytask/parametrize.py +++ b/src/_pytask/parametrize.py @@ -15,11 +15,9 @@ from _pytask.console import TASK_ICON from _pytask.mark import Mark from _pytask.mark import MARK_GEN as mark # noqa: N811 -from _pytask.mark_utils import has_marker from _pytask.mark_utils import remove_markers_from_func from _pytask.nodes import find_duplicates from _pytask.session import Session -from _pytask.task_utils import parse_task_marker def parametrize( @@ -104,11 +102,6 @@ def pytask_parametrize_task( "en/stable/how_to_guides/bp_parametrizations.html." ) - if has_marker(obj, "task"): - parsed_name = parse_task_marker(obj) - if parsed_name is not None: - name = parsed_name - base_arg_names, arg_names, arg_values = _parse_parametrize_markers( markers, name ) diff --git a/src/_pytask/task.py b/src/_pytask/task.py index 67dcf628f..064bfb8aa 100644 --- a/src/_pytask/task.py +++ b/src/_pytask/task.py @@ -7,8 +7,10 @@ from _pytask.config import hookimpl from _pytask.mark_utils import has_marker from _pytask.nodes import PythonFunctionTask +from _pytask.report import CollectionReport from _pytask.session import Session -from _pytask.task_utils import parse_task_marker +from _pytask.task_utils import COLLECTED_TASKS +from _pytask.task_utils import parse_collected_tasks_with_task_marker @hookimpl @@ -17,6 +19,44 @@ def pytask_parse_config(config: dict[str, Any]) -> None: config["markers"]["task"] = "Mark a function as a task regardless of its name." +@hookimpl(trylast=True) +def pytask_collect_file( + session: Session, path: Path, reports: list[CollectionReport] +) -> list[CollectionReport] | None: + """Collect a file.""" + if ( + any(path.match(pattern) for pattern in session.config["task_files"]) + and COLLECTED_TASKS[path] + ): + tasks = COLLECTED_TASKS[path] + + name_to_function = parse_collected_tasks_with_task_marker(tasks) + + collected_reports = [] + for name, function in name_to_function.items(): + session.hook.pytask_parametrize_kwarg_to_marker( + obj=function, kwargs=function.pytask_meta.kwargs + ) + + if has_marker(function, "parametrize"): + names_and_objects = session.hook.pytask_parametrize_task( + session=session, name=name, obj=function + ) + else: + names_and_objects = [(name, function)] + + for name_, obj_ in names_and_objects: + report = session.hook.pytask_collect_task_protocol( + session=session, reports=reports, path=path, name=name_, obj=obj_ + ) + if report is not None: + collected_reports.append(report) + + return collected_reports + else: + return None + + @hookimpl def pytask_collect_task( session: Session, path: Path, name: str, obj: Any @@ -29,9 +69,6 @@ def pytask_collect_task( """ if has_marker(obj, "task") and callable(obj): - parsed_name = parse_task_marker(obj) - if parsed_name is not None: - name = parsed_name return PythonFunctionTask.from_path_name_function_session( path, name, obj, session ) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 12508957c..04de44e30 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -1,29 +1,148 @@ """This module contains utilities related to the ``@pytask.mark.task`` decorator.""" from __future__ import annotations +import inspect +from collections import defaultdict +from pathlib import Path from typing import Any from typing import Callable from _pytask.mark import Mark -from _pytask.mark_utils import remove_markers_from_func +from _pytask.models import CollectionMetadata +from _pytask.nodes import find_duplicates +from typing_extensions import Protocol -def task(name: str | None = None) -> str: +class FuncWithMeta(Protocol): + pytask_meta: CollectionMetadata + __name__: str + + def __call__(self) -> Any: + ... + + +FuncWithMetaStub = Any + + +COLLECTED_TASKS: dict[Path, list[Callable[..., Any]]] = defaultdict(list) +"""A container for collecting tasks. + +Tasks marked by the ``@pytask.mark.task`` decorator can be generated in a loop where one +iteration overwrites the previous task. To retrieve the tasks later, use this dictionary +mapping from paths of modules to a list of tasks per module. + +""" + + +def task( + name: str | None = None, kwargs: dict[Any, Any] | None = None +) -> Callable[..., None]: """Parse inputs of the ``@pytask.mark.task`` decorator.""" - return name + + def wrapper(func: FuncWithMetaStub) -> None: + unwrapped = inspect.unwrap(func) + path = Path(inspect.getfile(unwrapped)).absolute().resolve() + parsed_kwargs = {} if kwargs is None else kwargs + parsed_name = name if isinstance(name, str) else func.__name__ + + if hasattr(unwrapped, "pytask_meta"): + unwrapped.pytask_meta.name = parsed_name + unwrapped.pytask_meta.kwargs = parsed_kwargs + unwrapped.pytask_meta.markers.append(Mark("task", (), {})) + else: + unwrapped.pytask_meta = CollectionMetadata( + name=parsed_name, kwargs=parsed_kwargs, markers=[Mark("task", (), {})] + ) + + COLLECTED_TASKS[path].append(unwrapped) + + # Todo: Maybe necessary to not collect a task twice?! + return None + + # In case the decorator is used without parentheses, wrap the function which is + # passed as the first argument with the default arguments. + if callable(name) and kwargs is None: + return task(name=None, kwargs=None)(name) + else: + return wrapper -def parse_task_marker(obj: Callable[..., Any]) -> str: - """Parse the ``@pytask.mark.task`` decorator.""" - obj, task_markers = remove_markers_from_func(obj, "task") +def parse_collected_tasks_with_task_marker( + tasks: list[Callable[..., Any]], +) -> dict[str, FuncWithMetaStub]: + """Parse collected tasks with a task marker.""" + parsed_tasks = _parse_tasks_with_preliminary_names(tasks) + all_names = {i[0] for i in parsed_tasks} + duplicated_names = find_duplicates([i[0] for i in parsed_tasks]) - if len(task_markers) != 1: + collected_tasks = {} + for name in all_names: + if name in duplicated_names: + selected_tasks = [i for i in parsed_tasks if i[0] == name] + names_to_functions = _generate_ids_for_tasks(selected_tasks) + for unique_name, task in names_to_functions.items(): + collected_tasks[unique_name] = task + else: + collected_tasks[name] = [i[1] for i in parsed_tasks if i[0] == name][0] + + return collected_tasks + + +def _parse_tasks_with_preliminary_names( + tasks: list[Callable[..., Any]], +) -> list[tuple[str, Callable[..., Any]]]: + """Parse tasks and generate preliminary names for tasks. + + The names are preliminary since they can be duplicated and need to be extended to + properly parametrized ids. + + """ + parsed_tasks = [] + for task in tasks: + name, function = _parse_task(task) + parsed_tasks.append((name, function)) + return parsed_tasks + + +def _parse_task(task: FuncWithMetaStub) -> tuple[str, Callable[..., Any]]: + """Parse a single task.""" + if task.pytask_meta.name is None and task.__name__ == "_": raise ValueError( - "The @pytask.mark.task decorator cannot be applied more than once to a " - "single task." + "A task function either needs 'name' passed by the ``@pytask.mark.task`` " + "decorator or the function name of the task function must not be '_'." ) + else: + parsed_name = ( + task.__name__ if task.pytask_meta.name is None else task.pytask_meta.name + ) + + signature_kwargs = _parse_keyword_arguments_from_signature_defaults(task) + task.pytask_meta.kwargs = {**task.pytask_meta.kwargs, **signature_kwargs} + + return parsed_name, task + + +def _parse_keyword_arguments_from_signature_defaults( + task: Callable[..., Any] +) -> dict[str, Any]: + """Parse keyword arguments from signature defaults.""" + parameters = inspect.signature(task).parameters + kwargs = {} + for parameter in parameters.values(): + if parameter.default is not parameter.empty: + kwargs[parameter.name] = parameter.default + return kwargs + + +def _generate_ids_for_tasks( + tasks: list[tuple[str, Callable[..., Any]]] +) -> dict[str, Callable[..., Any]]: + parameters = inspect.signature(tasks[0][1]).parameters + template_id = "-".join([parameter + "{i}" for parameter in parameters]) - task_marker = task_markers[0] - name = task(*task_marker.args, **task_marker.kwargs) - obj.pytask_meta.markers.append(Mark("task", (), {})) # type: ignore[attr-defined] - return name + out = {} + for i, (name, task) in enumerate(tasks): + stringified_args = template_id.format(i=i) + id_ = f"{name}[{stringified_args}]" + out[id_] = task + return out diff --git a/tests/test_task.py b/tests/test_task.py index 5c3cbf69c..94d898822 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -90,8 +90,8 @@ def task_example(produces): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "task_example[0]" in result.output - assert "task_example[1]" in result.output + assert "task_example[produces0]" in result.output + assert "task_example[produces1]" in result.output @pytest.mark.end_to_end @@ -157,8 +157,8 @@ def example(produces, i=i): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "example[produces0-0]" in result.output - assert "example[produces1-1]" in result.output + assert "example[produces0-i0]" in result.output + assert "example[produces1-i1]" in result.output @pytest.mark.end_to_end @@ -177,5 +177,5 @@ def example(produces, i): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "deco_task[produces0-0]" in result.output - assert "deco_task[produces1-1]" in result.output + assert "deco_task[produces0-i0]" in result.output + assert "deco_task[produces1-i1]" in result.output From aca9f606d5a7c59dfd2d1dc772c05fbf2f86ef04 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 01:08:28 +0100 Subject: [PATCH 03/21] Fix errors. --- src/_pytask/__init__.py | 1 + src/_pytask/task_utils.py | 10 +--------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/_pytask/__init__.py b/src/_pytask/__init__.py index f493641cf..8ee216253 100644 --- a/src/_pytask/__init__.py +++ b/src/_pytask/__init__.py @@ -1,3 +1,4 @@ +"""This module should not contain any imports except for the version.""" from __future__ import annotations try: diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 04de44e30..ffbb6ef24 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -10,15 +10,6 @@ from _pytask.mark import Mark from _pytask.models import CollectionMetadata from _pytask.nodes import find_duplicates -from typing_extensions import Protocol - - -class FuncWithMeta(Protocol): - pytask_meta: CollectionMetadata - __name__: str - - def __call__(self) -> Any: - ... FuncWithMetaStub = Any @@ -137,6 +128,7 @@ def _parse_keyword_arguments_from_signature_defaults( def _generate_ids_for_tasks( tasks: list[tuple[str, Callable[..., Any]]] ) -> dict[str, Callable[..., Any]]: + """Generate unique ids for parametrized tasks.""" parameters = inspect.signature(tasks[0][1]).parameters template_id = "-".join([parameter + "{i}" for parameter in parameters]) From 9cc800772e47ee7d974bf4a460741d7f4ea6dc34 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 09:00:42 +0100 Subject: [PATCH 04/21] Make name of task deco positional, rest kwargs, and add test for id. --- src/_pytask/task_utils.py | 4 +++- tests/test_task.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index ffbb6ef24..298ec423b 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -26,7 +26,9 @@ def task( - name: str | None = None, kwargs: dict[Any, Any] | None = None + name: str | None = None, + *, + kwargs: dict[Any, Any] | None = None, ) -> Callable[..., None]: """Parse inputs of the ``@pytask.mark.task`` decorator.""" diff --git a/tests/test_task.py b/tests/test_task.py index 94d898822..87ec3d835 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -179,3 +179,25 @@ def example(produces, i): assert result.exit_code == ExitCode.OK assert "deco_task[produces0-i0]" in result.output assert "deco_task[produces1-i1]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_with_ids(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task( + "deco_task", id=str(i), kwargs={"i": i, "produces": f"out_{i}.txt"} + ) + def example(produces, i): + produces.write_text(str(i)) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "deco_task[0]" in result.output + assert "deco_task[1]" in result.output From 2819db699c7d7e7ad6c81e98c9fd1036b76f5835 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 09:26:08 +0100 Subject: [PATCH 05/21] Implement id as a kwarg to the task marker. --- src/_pytask/models.py | 3 +++ src/_pytask/task_utils.py | 20 +++++++++++++++----- tests/test_task.py | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/_pytask/models.py b/src/_pytask/models.py index c72495474..7188ae1bd 100644 --- a/src/_pytask/models.py +++ b/src/_pytask/models.py @@ -4,6 +4,7 @@ from typing import Any from typing import Dict from typing import List +from typing import Optional from typing import TYPE_CHECKING from typing import Union @@ -17,6 +18,8 @@ class CollectionMetadata: """A class for carrying metadata from functions to tasks.""" + id_ = attr.ib(default=None, type=Optional[str]) + """The id for a single parametrization.""" kwargs = attr.ib(factory=dict, type=Dict[str, Any]) """Contains kwargs which are necessary for the task function on execution.""" markers = attr.ib(factory=list, type=List["Mark"]) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 298ec423b..69494e8dd 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -2,6 +2,7 @@ from __future__ import annotations import inspect +import itertools from collections import defaultdict from pathlib import Path from typing import Any @@ -28,6 +29,7 @@ def task( name: str | None = None, *, + id: str | None = None, # noqa: A002 kwargs: dict[Any, Any] | None = None, ) -> Callable[..., None]: """Parse inputs of the ``@pytask.mark.task`` decorator.""" @@ -42,9 +44,13 @@ def wrapper(func: FuncWithMetaStub) -> None: unwrapped.pytask_meta.name = parsed_name unwrapped.pytask_meta.kwargs = parsed_kwargs unwrapped.pytask_meta.markers.append(Mark("task", (), {})) + unwrapped.pytask_meta.id_ = id else: unwrapped.pytask_meta = CollectionMetadata( - name=parsed_name, kwargs=parsed_kwargs, markers=[Mark("task", (), {})] + name=parsed_name, + kwargs=parsed_kwargs, + markers=[Mark("task", (), {})], + id_=id, ) COLLECTED_TASKS[path].append(unwrapped) @@ -55,7 +61,7 @@ def wrapper(func: FuncWithMetaStub) -> None: # In case the decorator is used without parentheses, wrap the function which is # passed as the first argument with the default arguments. if callable(name) and kwargs is None: - return task(name=None, kwargs=None)(name) + return task()(name) else: return wrapper @@ -135,8 +141,12 @@ def _generate_ids_for_tasks( template_id = "-".join([parameter + "{i}" for parameter in parameters]) out = {} - for i, (name, task) in enumerate(tasks): - stringified_args = template_id.format(i=i) - id_ = f"{name}[{stringified_args}]" + counter = itertools.count() + for name, task in tasks: + if task.pytask_meta.id_ is not None: # type: ignore[attr-defined] + id_ = f"{name}[{str(task.pytask_meta.id_)}]" # type: ignore[attr-defined] + else: + stringified_args = template_id.format(i=next(counter)) + id_ = f"{name}[{stringified_args}]" out[id_] = task return out diff --git a/tests/test_task.py b/tests/test_task.py index 87ec3d835..453f98694 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -189,7 +189,7 @@ def test_parametrization_in_for_loop_with_ids(tmp_path, runner): for i in range(2): @pytask.mark.task( - "deco_task", id=str(i), kwargs={"i": i, "produces": f"out_{i}.txt"} + "deco_task", id=i, kwargs={"i": i, "produces": f"out_{i}.txt"} ) def example(produces, i): produces.write_text(str(i)) From ab695191b48dc188b6d03762b09740c338b7af93 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 12:58:23 +0100 Subject: [PATCH 06/21] Implement parsing of args where id components are stringified. --- src/_pytask/parametrize.py | 42 ++----------------------------- src/_pytask/parametrize_utils.py | 43 ++++++++++++++++++++++++++++++++ src/_pytask/task_utils.py | 21 +++++++++++----- tests/test_task.py | 12 ++++----- 4 files changed, 66 insertions(+), 52 deletions(-) create mode 100644 src/_pytask/parametrize_utils.py diff --git a/src/_pytask/parametrize.py b/src/_pytask/parametrize.py index db641095d..5c3daf5c1 100644 --- a/src/_pytask/parametrize.py +++ b/src/_pytask/parametrize.py @@ -17,6 +17,7 @@ from _pytask.mark import MARK_GEN as mark # noqa: N811 from _pytask.mark_utils import remove_markers_from_func from _pytask.nodes import find_duplicates +from _pytask.parametrize_utils import arg_value_to_id_component from _pytask.session import Session @@ -343,7 +344,7 @@ def _create_parametrize_ids_components( parsed_ids = [] for i, _arg_values in enumerate(arg_values): id_components = tuple( - _arg_value_to_id_component(arg_names[j], arg_value, i, ids) + arg_value_to_id_component(arg_names[j], arg_value, i, ids) for j, arg_value in enumerate(_arg_values) ) parsed_ids.append(id_components) @@ -351,45 +352,6 @@ def _create_parametrize_ids_components( return parsed_ids -def _arg_value_to_id_component( - arg_name: str, arg_value: Any, i: int, id_func: Callable[..., Any] | None -) -> str: - """Create id component from the name and value of the argument. - - First, transform the value of the argument with a user-defined function if given. - Otherwise, take the original value. Then, if the value is a :obj:`bool`, - :obj:`float`, :obj:`int`, or :obj:`str`, cast it to a string. Otherwise, define a - placeholder value from the name of the argument and the iteration. - - Parameters - ---------- - arg_name : str - Name of the parametrized function argument. - arg_value : Any - Value of the argument. - i : int - The ith iteration of the parametrization. - id_func : Union[Callable[..., Any], None] - A callable which maps argument values to :obj:`bool`, :obj:`float`, :obj:`int`, - or :obj:`str` or anything else. Any object with a different dtype than the first - will be mapped to an auto-generated id component. - - Returns - ------- - id_component : str - A part of the final parametrized id. - - """ - id_component = id_func(arg_value) if id_func is not None else None - if isinstance(id_component, (bool, float, int, str)): - id_component = str(id_component) - elif isinstance(arg_value, (bool, float, int, str)): - id_component = str(arg_value) - else: - id_component = arg_name + str(i) - return id_component - - @hookimpl def pytask_parametrize_kwarg_to_marker(obj: Any, kwargs: dict[str, str]) -> None: """Add some parametrized keyword arguments as decorator.""" diff --git a/src/_pytask/parametrize_utils.py b/src/_pytask/parametrize_utils.py new file mode 100644 index 000000000..f259c7b31 --- /dev/null +++ b/src/_pytask/parametrize_utils.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any +from typing import Callable + + +def arg_value_to_id_component( + arg_name: str, arg_value: Any, i: int, id_func: Callable[..., Any] | None +) -> str: + """Create id component from the name and value of the argument. + + First, transform the value of the argument with a user-defined function if given. + Otherwise, take the original value. Then, if the value is a :obj:`bool`, + :obj:`float`, :obj:`int`, or :obj:`str`, cast it to a string. Otherwise, define a + placeholder value from the name of the argument and the iteration. + + Parameters + ---------- + arg_name : str + Name of the parametrized function argument. + arg_value : Any + Value of the argument. + i : int + The ith iteration of the parametrization. + id_func : Union[Callable[..., Any], None] + A callable which maps argument values to :obj:`bool`, :obj:`float`, :obj:`int`, + or :obj:`str` or anything else. Any object with a different dtype than the first + will be mapped to an auto-generated id component. + + Returns + ------- + id_component : str + A part of the final parametrized id. + + """ + id_component = id_func(arg_value) if id_func is not None else None + if isinstance(id_component, (bool, float, int, str)): + id_component = str(id_component) + elif isinstance(arg_value, (bool, float, int, str)): + id_component = str(arg_value) + else: + id_component = arg_name + str(i) + return id_component diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 69494e8dd..b9ff5b0e0 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import inspect -import itertools from collections import defaultdict from pathlib import Path from typing import Any @@ -11,6 +10,7 @@ from _pytask.mark import Mark from _pytask.models import CollectionMetadata from _pytask.nodes import find_duplicates +from _pytask.parametrize_utils import arg_value_to_id_component FuncWithMetaStub = Any @@ -138,15 +138,24 @@ def _generate_ids_for_tasks( ) -> dict[str, Callable[..., Any]]: """Generate unique ids for parametrized tasks.""" parameters = inspect.signature(tasks[0][1]).parameters - template_id = "-".join([parameter + "{i}" for parameter in parameters]) out = {} - counter = itertools.count() - for name, task in tasks: + for i, (name, task) in enumerate(tasks): if task.pytask_meta.id_ is not None: # type: ignore[attr-defined] id_ = f"{name}[{str(task.pytask_meta.id_)}]" # type: ignore[attr-defined] else: - stringified_args = template_id.format(i=next(counter)) - id_ = f"{name}[{stringified_args}]" + stringified_args = [ + arg_value_to_id_component( + arg_name=parameter, + arg_value=task.pytask_meta.kwargs.get( # type: ignore[attr-defined] + parameter + ), + i=i, + id_func=None, + ) + for parameter in parameters + ] + id_ = "-".join(stringified_args) + id_ = f"{name}[{id_}]" out[id_] = task return out diff --git a/tests/test_task.py b/tests/test_task.py index 453f98694..99bad4e33 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -136,8 +136,8 @@ def example(depends_on=f"in_{i}.txt", produces=f"out_{i}.txt"): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "example[depends_on0-produces0]" in result.output - assert "example[depends_on1-produces1]" in result.output + assert "example[in_0.txt-out_0.txt]" in result.output + assert "example[in_1.txt-out_1.txt]" in result.output @pytest.mark.end_to_end @@ -157,8 +157,8 @@ def example(produces, i=i): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "example[produces0-i0]" in result.output - assert "example[produces1-i1]" in result.output + assert "example[produces0-0]" in result.output + assert "example[produces1-1]" in result.output @pytest.mark.end_to_end @@ -177,8 +177,8 @@ def example(produces, i): result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - assert "deco_task[produces0-i0]" in result.output - assert "deco_task[produces1-i1]" in result.output + assert "deco_task[out_0.txt-0]" in result.output + assert "deco_task[out_1.txt-1]" in result.output @pytest.mark.end_to_end From 29c4170e26f50ab85613c8af766515c79bdc737f Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 13:07:11 +0100 Subject: [PATCH 07/21] Add task testing error handling. --- tests/test_task.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_task.py b/tests/test_task.py index 99bad4e33..a8bd5b1e9 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -201,3 +201,25 @@ def example(produces, i): assert result.exit_code == ExitCode.OK assert "deco_task[0]" in result.output assert "deco_task[1]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_with_error(tmp_path, runner): + source = """ + import pytask + + for i in range(2): + + @pytask.mark.task + def task_example(produces=f"out_{i}.txt"): + raise ValueError + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.FAILED + assert "2 Failed" in result.output + assert "Traceback" in result.output + assert "task_example[out_0.txt]" in result.output + assert "task_example[out_1.txt]" in result.output From 93e52d08293c6f106039aee0f5a03e440617fc1c Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 13:13:33 +0100 Subject: [PATCH 08/21] fix tests. --- tests/test_parametrize.py | 23 ----------------------- tests/test_parametrize_utils.py | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 tests/test_parametrize_utils.py diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py index 314669030..e783a87dc 100644 --- a/tests/test_parametrize.py +++ b/tests/test_parametrize.py @@ -8,7 +8,6 @@ import _pytask.parametrize import pytask import pytest -from _pytask.parametrize import _arg_value_to_id_component from _pytask.parametrize import _check_if_n_arg_names_matches_n_arg_values from _pytask.parametrize import _parse_arg_names from _pytask.parametrize import _parse_arg_values @@ -299,28 +298,6 @@ def task_func(): assert isinstance(session.collection_reports[0].exc_info[1], ValueError) -@pytest.mark.unit -@pytest.mark.parametrize( - "arg_name, arg_value, i, id_func, expected", - [ - ("arg", 1, 0, None, "1"), - ("arg", True, 0, None, "True"), - ("arg", False, 0, None, "False"), - ("arg", 1.0, 0, None, "1.0"), - ("arg", None, 0, None, "arg0"), - ("arg", (1,), 0, None, "arg0"), - ("arg", [1], 0, None, "arg0"), - ("arg", {1, 2}, 0, None, "arg0"), - ("arg", 1, 0, lambda x: bool(x), "True"), - ("arg", 1, 1, lambda x: None, "1"), - ("arg", [1], 2, lambda x: None, "arg2"), - ], -) -def test_arg_value_to_id_component(arg_name, arg_value, i, id_func, expected): - result = _arg_value_to_id_component(arg_name, arg_value, i, id_func) - assert result == expected - - @pytest.mark.end_to_end def test_raise_error_if_parametrization_produces_non_unique_tasks(tmp_path): tmp_path.joinpath("task_module.py").write_text( diff --git a/tests/test_parametrize_utils.py b/tests/test_parametrize_utils.py new file mode 100644 index 000000000..f0635823f --- /dev/null +++ b/tests/test_parametrize_utils.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import pytest +from _pytask.parametrize_utils import arg_value_to_id_component + + +@pytest.mark.unit +@pytest.mark.parametrize( + "arg_name, arg_value, i, id_func, expected", + [ + ("arg", 1, 0, None, "1"), + ("arg", True, 0, None, "True"), + ("arg", False, 0, None, "False"), + ("arg", 1.0, 0, None, "1.0"), + ("arg", None, 0, None, "arg0"), + ("arg", (1,), 0, None, "arg0"), + ("arg", [1], 0, None, "arg0"), + ("arg", {1, 2}, 0, None, "arg0"), + ("arg", 1, 0, lambda x: bool(x), "True"), + ("arg", 1, 1, lambda x: None, "1"), + ("arg", [1], 2, lambda x: None, "arg2"), + ], +) +def test_arg_value_to_id_component(arg_name, arg_value, i, id_func, expected): + result = arg_value_to_id_component(arg_name, arg_value, i, id_func) + assert result == expected From 8b296a2f5114e233f7be0c52b5fe2a1e89228be2 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 1 Mar 2022 13:30:38 +0100 Subject: [PATCH 09/21] Extend docstring. --- src/_pytask/task_utils.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index b9ff5b0e0..5d63d3056 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -32,7 +32,23 @@ def task( id: str | None = None, # noqa: A002 kwargs: dict[Any, Any] | None = None, ) -> Callable[..., None]: - """Parse inputs of the ``@pytask.mark.task`` decorator.""" + """Parse inputs of the ``@pytask.mark.task`` decorator. + + The decorator wraps task functions and stores them in the global variable + :obj:`COLLECTED_TASKS` to avoid garbage collection when the function definition is + overwritten in a loop. + + Parameters + ---------- + name : str | None + The name of the task. + id : str | None + An id for the task if it is part of a parametrization. + kwargs : dict[Any, Any] | None + A dictionary containing keyword arguments which are passed to the task when it + is executed. + + """ def wrapper(func: FuncWithMetaStub) -> None: unwrapped = inspect.unwrap(func) From 3636716dd6e87e71c9088e2ac78c1248aafb9704 Mon Sep 17 00:00:00 2001 From: Hans-Martin von Gaudecker Date: Tue, 1 Mar 2022 14:13:10 +0100 Subject: [PATCH 10/21] Add tabulate to environment. --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 5e78ecbf2..ddb22a611 100644 --- a/environment.yml +++ b/environment.yml @@ -36,6 +36,7 @@ dependencies: - pytest - pytest-cov - pytest-xdist + - tabulate - tox-conda # Documentation From d1906ec69e3559be09a2699bb3ed966472bad54a Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 01:02:09 +0100 Subject: [PATCH 11/21] Rewrite tutorial and move explanation on @pytask.mark.parametrize to the how to guides. --- .../how_to_parametrize_a_task_the_old_way.rst | 223 ++++++++++++++++++ docs/source/how_to_guides/index.rst | 1 + .../tutorials/how_to_parametrize_a_task.rst | 209 +++++----------- 3 files changed, 289 insertions(+), 144 deletions(-) create mode 100644 docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst diff --git a/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst b/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst new file mode 100644 index 000000000..26907ad07 --- /dev/null +++ b/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst @@ -0,0 +1,223 @@ +How to parametrize a task - The old way +======================================= + +.. important:: + + This guide shows you how to parametrize tasks with the old approach which is similar + to pytest's. For the new and preferred approach, see this :doc:`tutorial + <../tutorials/how_to_parametrize>`. + +You want to define a task which should be repeated over a range of inputs? Parametrize +your task function! + +.. seealso:: + + If you want to know more about best practices for parametrizations, check out this + :doc:`guide <../how_to_guides/bp_parametrizations>` after you made yourself familiar + this tutorial. + + +An example +---------- + +We reuse the previous example of a task which generates random data and repeat the same +operation over a number of seeds to receive multiple, reproducible samples. + +First, we write the task for one seed. + +.. code-block:: python + + import numpy as np + import pytask + + + @pytask.mark.produces(BLD / "data_0.pkl") + def task_create_random_data(produces): + rng = np.random.default_rng(0) + ... + +In the next step, we repeat the same task over the numbers 0, 1, and 2 and pass them to +the ``seed`` argument. We also vary the name of the produced file in every iteration. + +.. code-block:: python + + @pytask.mark.parametrize( + "produces, seed", + [(BLD / "data_0.pkl", 0), (BLD / "data_1.pkl", 1), (BLD / "data_2.pkl", 2)], + ) + def task_create_random_data(seed, produces): + rng = np.random.default_rng(seed) + ... + +The parametrize decorator receives two arguments. The first argument is ``"produces, +seed"`` - the signature. It is a comma-separated string where each value specifies the +name of a task function argument. + +.. seealso:: + + The signature is explained in detail :ref:`below `. + +The second argument of the parametrize decorator is a list (or any iterable) which has +as many elements as there are iterations over the task function. Each element has to +provide one value for each argument name in the signature - two in this case. + +Putting all together, the task is executed three times and each run the path from the +list is mapped to the argument ``produces`` and ``seed`` receives the seed. + +.. note:: + + If you use ``produces`` or ``depends_on`` in the signature of the parametrize + decorator, the values are handled as if they were attached to the function with + ``@pytask.mark.depends_on`` or ``@pytask.mark.produces``. + +Un-parametrized dependencies +---------------------------- + +To specify a dependency which is the same for all parametrizations, add it with +``pytask.mark.depends_on``. + +.. code-block:: python + + @pytask.mark.depends_on(SRC / "common_dependency.file") + @pytask.mark.parametrize( + "produces, seed", + [(BLD / "data_0.pkl", 0), (BLD / "data_1.pkl", 1), (BLD / "data_2.pkl", 2)], + ) + def task_create_random_data(seed, produces): + rng = np.random.default_rng(seed) + ... + + +.. _parametrize_signature: + +The signature +------------- + +The signature can be passed in three different formats. + +1. The signature can be a comma-separated string like an entry in a csv table. Note that + white-space is stripped from each name which you can use to separate the names for + readability. Here are some examples: + + .. code-block:: python + + "single_argument" + "first_argument,second_argument" + "first_argument, second_argument" + +2. The signature can be a tuple of strings where each string is one argument name. Here + is an example. + + .. code-block:: python + + ("first_argument", "second_argument") + +3. Finally, it is also possible to use a list of strings. + + .. code-block:: python + + ["first_argument", "second_argument"] + + +The id +------ + +Every task has a unique id which can be used to :doc:`select it `. +The normal id combines the path to the module where the task is defined, a double colon, +and the name of the task function. Here is an example. + +.. code-block:: + + ../task_example.py::task_example + +This behavior would produce duplicate ids for parametrized tasks. Therefore, there exist +multiple mechanisms to produce unique ids. + + +.. _auto_generated_ids: + +Auto-generated ids +~~~~~~~~~~~~~~~~~~ + +To avoid duplicate task ids, the ids of parametrized tasks are extended with +descriptions of the values they are parametrized with. Booleans, floats, integers and +strings enter the task id directly. For example, a task function which receives four +arguments, ``True``, ``1.0``, ``2``, and ``"hello"``, one of each dtype, has the +following id. + +.. code-block:: + + task_example.py::task_example[True-1.0-2-hello] + +Arguments with other dtypes cannot be easily converted to strings and, thus, are +replaced with a combination of the argument name and the iteration counter. + +For example, the following function is parametrized with tuples. + +.. code-block:: python + + @pytask.mark.parametrized("i", [(0,), (1,)]) + def task_example(i): + pass + +Since the tuples are not converted to strings, the ids of the two tasks are + +.. code-block:: + + task_example.py::task_example[i0] + task_example.py::task_example[i1] + + +.. _ids: + +User-defined ids +~~~~~~~~~~~~~~~~ + +Instead of a function, you can also pass a list or another iterable of id values via +``ids``. + +This code + +.. code-block:: python + + @pytask.mark.parametrized("i", [(0,), (1,)], ids=["first", "second"]) + def task_example(i): + pass + +produces these ids + +.. code-block:: + + task_example.py::task_example[first] # (0,) + task_example.py::task_example[second] # (1,) + + +.. _how_to_parametrize_a_task_convert_other_objects: + +Convert other objects +~~~~~~~~~~~~~~~~~~~~~ + +To change the representation of tuples and other objects, you can pass a function to the +``ids`` argument of the :func:`~_pytask.parametrize.parametrize` decorator. The function +is called for every argument and may return a boolean, number, or string which will be +integrated into the id. For every other return, the auto-generated value is used. + +To get a unique representation of a tuple, we can use the hash value. + +.. code-block:: python + + def tuple_to_hash(value): + if isinstance(value, tuple): + return hash(a) + + + @pytask.mark.parametrized("i", [(0,), (1,)], ids=tuple_to_hash) + def task_example(i): + pass + +This produces the following ids: + +.. code-block:: + + task_example.py::task_example[3430018387555] # (0,) + task_example.py::task_example[3430019387558] # (1,) diff --git a/docs/source/how_to_guides/index.rst b/docs/source/how_to_guides/index.rst index e2e9cf0f2..1b44c72e6 100644 --- a/docs/source/how_to_guides/index.rst +++ b/docs/source/how_to_guides/index.rst @@ -15,6 +15,7 @@ specific tasks with pytask. how_to_write_a_plugin how_to_influence_build_order + how_to_parametrize_a_task_the_old_way Best Practice Guides diff --git a/docs/source/tutorials/how_to_parametrize_a_task.rst b/docs/source/tutorials/how_to_parametrize_a_task.rst index 53cd0f9e9..d9d90e547 100644 --- a/docs/source/tutorials/how_to_parametrize_a_task.rst +++ b/docs/source/tutorials/how_to_parametrize_a_task.rst @@ -1,14 +1,20 @@ How to parametrize a task ========================= -Often, you want to define a task which should be repeated over a range of inputs. pytask -allows to parametrize task functions to accomplish this behavior. +You want to define a task which should be repeated over a range of inputs? Parametrize +your task function! + +.. important:: + + If you are looking for information on the old way to parametrizations with the + :func:`@pytask.mark.parametrize <_pytask.parametrize.parametrize>` decorator, you + can find it :doc:`here <../how_to_guides/how_to_parametrize_a_task_the_old_way>`. .. seealso:: If you want to know more about best practices for parametrizations, check out this :doc:`guide <../how_to_guides/bp_parametrizations>` after you made yourself familiar - this tutorial. + with this tutorial. An example @@ -17,7 +23,8 @@ An example We reuse the previous example of a task which generates random data and repeat the same operation over a number of seeds to receive multiple, reproducible samples. -First, we write the task for one seed. +Apply the :func:`@pytask.mark.task <_pytask.task_utils.task>` decorator, loop over the +function and supply values to the function. .. code-block:: python @@ -25,92 +32,32 @@ First, we write the task for one seed. import pytask - @pytask.mark.produces(BLD / "data_0.pkl") - def task_create_random_data(produces): - rng = np.random.default_rng(0) - ... - -In the next step, we repeat the same task over the numbers 0, 1, and 2 and pass them to -the ``seed`` argument. We also vary the name of the produced file in every iteration. - -.. code-block:: python - - @pytask.mark.parametrize( - "produces, seed", - [(BLD / "data_0.pkl", 0), (BLD / "data_1.pkl", 1), (BLD / "data_2.pkl", 2)], - ) - def task_create_random_data(seed, produces): - rng = np.random.default_rng(seed) - ... - -The parametrize decorator receives two arguments. The first argument is ``"produces, -seed"`` - the signature. It is a comma-separated string where each value specifies the -name of a task function argument. - -.. seealso:: - - The signature is explained in detail :ref:`below `. - -The second argument of the parametrize decorator is a list (or any iterable) which has -as many elements as there are iterations over the task function. Each element has to -provide one value for each argument name in the signature - two in this case. + for i in range(10): -Putting all together, the task is executed three times and each run the path from the -list is mapped to the argument ``produces`` and ``seed`` receives the seed. + @pytask.mark.task + def task_create_random_data(produces=f"data_{i}.pkl", seed=i): + rng = np.random.default_rng(seed) + ... -.. note:: - If you use ``produces`` or ``depends_on`` in the signature of the parametrize - decorator, the values are handled as if they were attached to the function with - ``@pytask.mark.depends_on`` or ``@pytask.mark.produces``. +``depends_on`` and ``produces`` +------------------------------- -Un-parametrized dependencies ----------------------------- +You can also use decorators to supply values to the function. To specify a dependency which is the same for all parametrizations, add it with -``pytask.mark.depends_on``. +``@pytask.mark.depends_on``. And add a product with ``@pytask.mark.produces`` .. code-block:: python - @pytask.mark.depends_on(SRC / "common_dependency.file") - @pytask.mark.parametrize( - "produces, seed", - [(BLD / "data_0.pkl", 0), (BLD / "data_1.pkl", 1), (BLD / "data_2.pkl", 2)], - ) - def task_create_random_data(seed, produces): - rng = np.random.default_rng(seed) - ... - + for i in range(10): -.. _parametrize_signature: - -The signature -------------- - -The signature can be passed in three different formats. - -1. The signature can be a comma-separated string like an entry in a csv table. Note that - white-space is stripped from each name which you can use to separate the names for - readability. Here are some examples: - - .. code-block:: python - - "single_argument" - "first_argument,second_argument" - "first_argument, second_argument" - -2. The signature can be a tuple of strings where each string is one argument name. Here - is an example. - - .. code-block:: python - - ("first_argument", "second_argument") - -3. Finally, it is also possible to use a list of strings. - - .. code-block:: python - - ["first_argument", "second_argument"] + @pytask.mark.task + @pytask.mark.depends_on(SRC / "common_dependency.file") + @pytask.mark.produces(f"data_{i}.pkl") + def task_create_random_data(produces, seed=i): + rng = np.random.default_rng(seed) + ... .. _how_to_parametrize_a_task_the_id: @@ -126,95 +73,69 @@ and the name of the task function. Here is an example. ../task_example.py::task_example -This behavior would produce duplicate ids for parametrized tasks. Therefore, there exist -multiple mechanisms to produce unique ids. - +This behavior would produce duplicate ids for parametrized tasks. By default, +auto-generated ids are used which are explained :ref:`here `. -Auto-generated ids -~~~~~~~~~~~~~~~~~~ +More powerful are user-defined ids. -To avoid duplicate task ids, the ids of parametrized tasks are extended with -descriptions of the values they are parametrized with. Booleans, floats, integers and -strings enter the task id directly. For example, a task function which receives four -arguments, ``True``, ``1.0``, ``2``, and ``"hello"``, one of each dtype, has the -following id. -.. code-block:: - - task_example.py::task_example[True-1.0-2-hello] +.. _ids: -Arguments with other dtypes cannot be easily converted to strings and, thus, are -replaced with a combination of the argument name and the iteration counter. +User-defined ids +~~~~~~~~~~~~~~~~ -For example, the following function is parametrized with tuples. +The :func:`@pytask.mark.task <_pytask.task_utils.task>` decorator has an ``id`` keyword +which allows the user to set the a special name for the iteration. .. code-block:: python - @pytask.mark.parametrized("i", [(0,), (1,)]) - def task_example(i): - pass + for i, id_ in [(0, "first"), (1, "second")]: + + @pytask.mark.task(id=id_) + def task_example(i=i, produces=f"out_{i}.txt"): + ... -Since the tuples are not converted to strings, the ids of the two tasks are +produces these ids .. code-block:: - task_example.py::task_example[i0] - task_example.py::task_example[i1] + task_example.py::task_example[first] + task_example.py::task_example[second] -.. _how_to_parametrize_a_task_convert_other_objects: +Complex example +--------------- -Convert other objects -~~~~~~~~~~~~~~~~~~~~~ +Parametrizations are becoming more complex quickly. Often, you need to supply many +arguments and ids to tasks. -To change the representation of tuples and other objects, you can pass a function to the -``ids`` argument of the :func:`~_pytask.parametrize.parametrize` decorator. The function -is called for every argument and may return a boolean, number, or string which will be -integrated into the id. For every other return, the auto-generated value is used. +Two changes will make your life easier. -To get a unique representation of a tuple, we can use the hash value. +1. Build the arguments and ids for every parametrization in a separate function. +2. Use the ``kwargs`` argument of the ``pytask.mark.task`` decorator to pass the + arguments to the task. .. code-block:: python - def tuple_to_hash(value): - if isinstance(value, tuple): - return hash(a) - + def create_parametrization(): + id_to_kwargs = {} + for i, id_ in enumerate(["first", "second"]): + id_to_kwargs[id_] = {"produces": f"out_{i}.txt"} - @pytask.mark.parametrized("i", [(0,), (1,)], ids=tuple_to_hash) - def task_example(i): - pass + return id_to_kwargs -This produces the following ids: -.. code-block:: + ID_TO_KWARGS = create_parametrization() - task_example.py::task_example[3430018387555] # (0,) - task_example.py::task_example[3430019387558] # (1,) + for id_, kwargs in ID_TO_KWARGS.items(): -.. _ids: + @pytask.mark.task(id=id_, kwargs=kwargs) + def task_example(i, produces): + ... -User-defined ids -~~~~~~~~~~~~~~~~ - -Instead of a function, you can also pass a list or another iterable of id values via -``ids``. - -This code - -.. code-block:: python - - @pytask.mark.parametrized("i", [(0,), (1,)], ids=["first", "second"]) - def task_example(i): - pass - -produces these ids - -.. code-block:: - - task_example.py::task_example[first] # (0,) - task_example.py::task_example[second] # (1,) +.. seealso:: -This is arguably the easiest way to change the representation of many objects at once -while also producing ids which are easy to remember and type. + The :doc:`best-practices guide on parametrizations + <../how_to_guides/bp_parametrizations>` goes into even more detail on how to scale + parametrizations. From 7581871b5d0b9c45481a335eb0e6ef20a9c37e6c Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 01:17:14 +0100 Subject: [PATCH 12/21] Update the best-practices guide on parametrizations: --- .../how_to_guides/bp_parametrizations.rst | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/docs/source/how_to_guides/bp_parametrizations.rst b/docs/source/how_to_guides/bp_parametrizations.rst index 6dd8728b8..5602ef4b6 100644 --- a/docs/source/how_to_guides/bp_parametrizations.rst +++ b/docs/source/how_to_guides/bp_parametrizations.rst @@ -106,26 +106,27 @@ parametrization. def _create_parametrization(data): - parametrizations = [] - ids = [] + id_to_kwargs = {} for data_name in data: - ids.append(data_name) depends_on = path_to_input_data(data_name) produces = path_to_processed_data(data_name) - parametrizations.append((depends_on, produces)) - return "depends_on, produces", parametrizations, ids + id_to_kwargs[data_name] = {"depends_on": depends_on, "produces": produces} + return id_to_kwargs - _SIGNATURE, _PARAMETRIZATION, _IDS = _create_parametrization(DATA) + _ID_TO_KWARGS = _create_parametrization(DATA) - @pytask.mark.parametrize(_SIGNATURE, _PARAMETRIZATION, ids=_IDS) - def task_prepare_data(depends_on, produces): - ... + for id_, kwargs in _ID_TO_KWARGS.items(): -All arguments for the ``parametrize`` decorator are built within a function to keep the -logic in one place and the namespace of the module clean. + @pytask.mark.task(id=id_, kwargs=kwargs) + def task_prepare_data(depends_on, produces): + ... + +All arguments for the loop and the :func:`@pytask.mark.task <_pytask.task_utils.task>` +decorator are built within a function to keep the logic in one place and the namespace +of the module clean. Ids are used to make the task :ref:`ids ` more descriptive and to simplify their selection with :ref:`expressions `. Here is an example of the task ids with @@ -183,25 +184,29 @@ And, here is the task file. def _create_parametrization(estimations): - parametrizations = [] - ids = [] + id_to_kwargs = {} for name, config in estimations.items(): - ids.append(name) depends_on = path_to_processed_data(config["data"]) produces = path_to_estimation_result(name) - parametrizations.append((depends_on, config["model"], produces)) - return "depends_on, model, produces", parametrizations, ids + id_to_kwargs[name] = { + "depends_on": depends_on, + "model": config["model"], + "produces": produces, + } + return id_to_kwargs - _SIGNATURE, _PARAMETRIZATION, _IDS = _create_parametrization(ESTIMATIONS) + _ID_TO_KWARGS = _create_parametrization(ESTIMATIONS) - @pytask.mark.parametrize(_SIGNATURE, _PARAMETRIZATION, ids=_IDS) - def task_estmate_models(depends_on, model, produces): - if model == "linear_probability": - ... - ... + + for id_, kwargs in _ID_TO_KWARGS.items(): + + @pytask.mark.task(id=id_, kwargs=kwars) + def task_estmate_models(depends_on, model, produces): + if model == "linear_probability": + ... Replicating this pattern across a project allows for a clean way to define parametrizations. From ba912f43be2ab944d04904b5348a3ecc79216891 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 01:25:57 +0100 Subject: [PATCH 13/21] Fix link error. --- .../how_to_guides/how_to_parametrize_a_task_the_old_way.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst b/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst index 26907ad07..994c48957 100644 --- a/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst +++ b/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst @@ -1,6 +1,9 @@ How to parametrize a task - The old way ======================================= +You want to define a task which should be repeated over a range of inputs? Parametrize +your task function! + .. important:: This guide shows you how to parametrize tasks with the old approach which is similar @@ -168,8 +171,6 @@ Since the tuples are not converted to strings, the ids of the two tasks are task_example.py::task_example[i1] -.. _ids: - User-defined ids ~~~~~~~~~~~~~~~~ From b7975be31a98a82ab281a775d2ad31002e22c939 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 01:27:36 +0100 Subject: [PATCH 14/21] fix. --- docs/source/tutorials/how_to_parametrize_a_task.rst | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/source/tutorials/how_to_parametrize_a_task.rst b/docs/source/tutorials/how_to_parametrize_a_task.rst index d9d90e547..ad244cbaa 100644 --- a/docs/source/tutorials/how_to_parametrize_a_task.rst +++ b/docs/source/tutorials/how_to_parametrize_a_task.rst @@ -134,8 +134,6 @@ Two changes will make your life easier. def task_example(i, produces): ... -.. seealso:: - - The :doc:`best-practices guide on parametrizations - <../how_to_guides/bp_parametrizations>` goes into even more detail on how to scale - parametrizations. +The :doc:`best-practices guide on parametrizations +<../how_to_guides/bp_parametrizations>` goes into even more detail on how to scale +parametrizations. From ebb5dc9b3facb780669135dba1ce9f256076d8a4 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 01:33:28 +0100 Subject: [PATCH 15/21] fix. --- docs/source/tutorials/how_to_parametrize_a_task.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/source/tutorials/how_to_parametrize_a_task.rst b/docs/source/tutorials/how_to_parametrize_a_task.rst index ad244cbaa..900fa5b39 100644 --- a/docs/source/tutorials/how_to_parametrize_a_task.rst +++ b/docs/source/tutorials/how_to_parametrize_a_task.rst @@ -10,12 +10,6 @@ your task function! :func:`@pytask.mark.parametrize <_pytask.parametrize.parametrize>` decorator, you can find it :doc:`here <../how_to_guides/how_to_parametrize_a_task_the_old_way>`. -.. seealso:: - - If you want to know more about best practices for parametrizations, check out this - :doc:`guide <../how_to_guides/bp_parametrizations>` after you made yourself familiar - with this tutorial. - An example ---------- From 07994afbf7b6cf261b952d55158f0df51fa19d35 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 18:38:17 +0100 Subject: [PATCH 16/21] Integrate comments from HMG. --- .../images/how-to-parametrize-a-task.png | Bin 0 -> 62615 bytes ..._to_parametrize_a_task_the_pytest_way.rst} | 17 ++-- docs/source/how_to_guides/index.rst | 2 +- .../tutorials/how_to_parametrize_a_task.rst | 77 ++++++++++++++---- docs/source/tutorials/how_to_write_a_task.rst | 6 +- 5 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 docs/source/_static/images/how-to-parametrize-a-task.png rename docs/source/how_to_guides/{how_to_parametrize_a_task_the_old_way.rst => how_to_parametrize_a_task_the_pytest_way.rst} (91%) diff --git a/docs/source/_static/images/how-to-parametrize-a-task.png b/docs/source/_static/images/how-to-parametrize-a-task.png new file mode 100644 index 0000000000000000000000000000000000000000..ec2e8bd82c60e2a0857d9d6702c240c7051d9857 GIT binary patch literal 62615 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 literal 0 HcmV?d00001 diff --git a/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst b/docs/source/how_to_guides/how_to_parametrize_a_task_the_pytest_way.rst similarity index 91% rename from docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst rename to docs/source/how_to_guides/how_to_parametrize_a_task_the_pytest_way.rst index 994c48957..e98a440e1 100644 --- a/docs/source/how_to_guides/how_to_parametrize_a_task_the_old_way.rst +++ b/docs/source/how_to_guides/how_to_parametrize_a_task_the_pytest_way.rst @@ -1,14 +1,14 @@ -How to parametrize a task - The old way -======================================= +How to parametrize a task - The pytest way +========================================== You want to define a task which should be repeated over a range of inputs? Parametrize your task function! .. important:: - This guide shows you how to parametrize tasks with the old approach which is similar - to pytest's. For the new and preferred approach, see this :doc:`tutorial - <../tutorials/how_to_parametrize>`. + This guide shows you how to parametrize tasks with the pytest approach. For the new + and preferred approach, see this :doc:`tutorial + <../tutorials/how_to_parametrize_a_task>`. You want to define a task which should be repeated over a range of inputs? Parametrize your task function! @@ -199,9 +199,10 @@ Convert other objects ~~~~~~~~~~~~~~~~~~~~~ To change the representation of tuples and other objects, you can pass a function to the -``ids`` argument of the :func:`~_pytask.parametrize.parametrize` decorator. The function -is called for every argument and may return a boolean, number, or string which will be -integrated into the id. For every other return, the auto-generated value is used. +``ids`` argument of the :func:`@pytask.mark.parametrize +<_pytask.parametrize.parametrize>` decorator. The function is called for every argument +and may return a boolean, number, or string which will be integrated into the id. For +every other return, the auto-generated value is used. To get a unique representation of a tuple, we can use the hash value. diff --git a/docs/source/how_to_guides/index.rst b/docs/source/how_to_guides/index.rst index 1b44c72e6..39e9da6cc 100644 --- a/docs/source/how_to_guides/index.rst +++ b/docs/source/how_to_guides/index.rst @@ -15,7 +15,7 @@ specific tasks with pytask. how_to_write_a_plugin how_to_influence_build_order - how_to_parametrize_a_task_the_old_way + how_to_parametrize_a_task_the_pytest_way Best Practice Guides diff --git a/docs/source/tutorials/how_to_parametrize_a_task.rst b/docs/source/tutorials/how_to_parametrize_a_task.rst index 900fa5b39..5e28b35af 100644 --- a/docs/source/tutorials/how_to_parametrize_a_task.rst +++ b/docs/source/tutorials/how_to_parametrize_a_task.rst @@ -6,19 +6,24 @@ your task function! .. important:: - If you are looking for information on the old way to parametrizations with the - :func:`@pytask.mark.parametrize <_pytask.parametrize.parametrize>` decorator, you - can find it :doc:`here <../how_to_guides/how_to_parametrize_a_task_the_old_way>`. + Before v0.2.0, pytask supported only one approach to parametrizations which is + similar to pytest's using a :func:`@pytask.mark.parametrize + <_pytask.parametrize.parametrize>` decorator. You can find it :doc:`here + <../how_to_guides/how_to_parametrize_a_task_the_pytest_way>`. + + Here you find the new and preferred approach. An example ---------- -We reuse the previous example of a task which generates random data and repeat the same -operation over a number of seeds to receive multiple, reproducible samples. +We reuse the task from the previous :doc:`tutorial ` which +generates random data and repeat the same operation over a number of seeds to receive +multiple, reproducible samples. Apply the :func:`@pytask.mark.task <_pytask.task_utils.task>` decorator, loop over the -function and supply values to the function. +function and supply different seeds and output paths as default arguments of the +function. .. code-block:: python @@ -33,6 +38,10 @@ function and supply values to the function. rng = np.random.default_rng(seed) ... +Executing pytask gives you this: + +.. image:: /_static/images/how-to-parametrize-a-task.png + ``depends_on`` and ``produces`` ------------------------------- @@ -65,7 +74,7 @@ and the name of the task function. Here is an example. .. code-block:: - ../task_example.py::task_example + ../task_data_preparation.py::task_create_random_data This behavior would produce duplicate ids for parametrized tasks. By default, auto-generated ids are used which are explained :ref:`here `. @@ -83,18 +92,18 @@ which allows the user to set the a special name for the iteration. .. code-block:: python - for i, id_ in [(0, "first"), (1, "second")]: + for seed, id_ in [(0, "first"), (1, "second")]: @pytask.mark.task(id=id_) - def task_example(i=i, produces=f"out_{i}.txt"): + def task_create_random_data(seed=i, produces=f"out_{i}.txt"): ... produces these ids .. code-block:: - task_example.py::task_example[first] - task_example.py::task_example[second] + task_data_preparation.py::task_create_random_data[first] + task_data_preparation.py::task_create_random_data[second] Complex example @@ -103,11 +112,47 @@ Complex example Parametrizations are becoming more complex quickly. Often, you need to supply many arguments and ids to tasks. -Two changes will make your life easier. +To organize your ids and arguments use nested dictionaries where keys are ids and values +are dictionaries mapping from argument names to values. + +.. code-block:: python + + ID_TO_KWARGS = { + "first": { + "seed": 0, + "produces": "data_0.pkl", + }, + "second": { + "seed": 1, + "produces": "data_1.pkl", + }, + } + +The parametrization becomes + +.. code-block:: python + + for id_, kwargs in ID_TO_KWARGS.items(): + + @pytask.mark.task(id=id_) + def task_create_random_data(seed=kwargs["seed"], produces=kwargs["produces"]): + ... + +Unpacking all the arguments can become tedious. Use instead the ``kwargs`` argument of +the :func:`@pytask.mark.task <_pytask.task_utils.task` decorator to pass keyword +arguments to the task. + +.. code-block:: python + + for id_, kwargs in ID_TO_KWARGS.items(): + + @pytask.mark.task(id=id_, kwargs=kwargs) + def task_create_random_data(seed, produces): + ... -1. Build the arguments and ids for every parametrization in a separate function. -2. Use the ``kwargs`` argument of the ``pytask.mark.task`` decorator to pass the - arguments to the task. +As a last step to organize our code even more, we can write a function which creates +``ID_TO_KWARGS``. You can hide the creation of input and output paths and other +arguments in this function. .. code-block:: python @@ -125,7 +170,7 @@ Two changes will make your life easier. for id_, kwargs in ID_TO_KWARGS.items(): @pytask.mark.task(id=id_, kwargs=kwargs) - def task_example(i, produces): + def task_create_random_data(i, produces): ... The :doc:`best-practices guide on parametrizations diff --git a/docs/source/tutorials/how_to_write_a_task.rst b/docs/source/tutorials/how_to_write_a_task.rst index a8e0eba0e..7e6e7500a 100644 --- a/docs/source/tutorials/how_to_write_a_task.rst +++ b/docs/source/tutorials/how_to_write_a_task.rst @@ -43,9 +43,11 @@ Here, we define the function @pytask.mark.produces(BLD / "data.pkl") def task_create_random_data(produces): + rng = np.random.default_rng(0) beta = 2 - x = np.random.normal(loc=5, scale=10, size=1_000) - epsilon = np.random.standard_normal(1_000) + + x = rng.normal(loc=5, scale=10, size=1_000) + epsilon = rng.standard_normal(1_000) y = beta * x + epsilon From 7084b1171f1f71453a36c930854964bf649d20ed Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 18:45:57 +0100 Subject: [PATCH 17/21] Clarify returnining None in the wrapper function. --- src/_pytask/task_utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 5d63d3056..26ef21ffa 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -34,10 +34,13 @@ def task( ) -> Callable[..., None]: """Parse inputs of the ``@pytask.mark.task`` decorator. - The decorator wraps task functions and stores them in the global variable + The decorator wraps task functions and stores it in the global variable :obj:`COLLECTED_TASKS` to avoid garbage collection when the function definition is overwritten in a loop. + The function also attaches some metadata to the function like parsed kwargs and + markers. + Parameters ---------- name : str | None @@ -71,7 +74,9 @@ def wrapper(func: FuncWithMetaStub) -> None: COLLECTED_TASKS[path].append(unwrapped) - # Todo: Maybe necessary to not collect a task twice?! + # Returning None is necessary since the remaining function in the module will be + # collected again from the namespace, although we already collect the function + # from ``COLLECTED_TASKS``. return None # In case the decorator is used without parentheses, wrap the function which is From 1d063b4dfa03a444ff21ff08532f4190cad3f9ab Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 18:51:05 +0100 Subject: [PATCH 18/21] Remove function type with pytask_meta defined. --- src/_pytask/task.py | 3 ++- src/_pytask/task_utils.py | 21 ++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/_pytask/task.py b/src/_pytask/task.py index 064bfb8aa..041d4b0f5 100644 --- a/src/_pytask/task.py +++ b/src/_pytask/task.py @@ -35,7 +35,8 @@ def pytask_collect_file( collected_reports = [] for name, function in name_to_function.items(): session.hook.pytask_parametrize_kwarg_to_marker( - obj=function, kwargs=function.pytask_meta.kwargs + obj=function, + kwargs=function.pytask_meta.kwargs, # type: ignore[attr-defined] ) if has_marker(function, "parametrize"): diff --git a/src/_pytask/task_utils.py b/src/_pytask/task_utils.py index 26ef21ffa..bc9510429 100644 --- a/src/_pytask/task_utils.py +++ b/src/_pytask/task_utils.py @@ -13,9 +13,6 @@ from _pytask.parametrize_utils import arg_value_to_id_component -FuncWithMetaStub = Any - - COLLECTED_TASKS: dict[Path, list[Callable[..., Any]]] = defaultdict(list) """A container for collecting tasks. @@ -53,7 +50,7 @@ def task( """ - def wrapper(func: FuncWithMetaStub) -> None: + def wrapper(func: Callable[..., Any]) -> None: unwrapped = inspect.unwrap(func) path = Path(inspect.getfile(unwrapped)).absolute().resolve() parsed_kwargs = {} if kwargs is None else kwargs @@ -89,7 +86,7 @@ def wrapper(func: FuncWithMetaStub) -> None: def parse_collected_tasks_with_task_marker( tasks: list[Callable[..., Any]], -) -> dict[str, FuncWithMetaStub]: +) -> dict[str, Callable[..., Any]]: """Parse collected tasks with a task marker.""" parsed_tasks = _parse_tasks_with_preliminary_names(tasks) all_names = {i[0] for i in parsed_tasks} @@ -124,20 +121,22 @@ def _parse_tasks_with_preliminary_names( return parsed_tasks -def _parse_task(task: FuncWithMetaStub) -> tuple[str, Callable[..., Any]]: +def _parse_task(task: Callable[..., Any]) -> tuple[str, Callable[..., Any]]: """Parse a single task.""" - if task.pytask_meta.name is None and task.__name__ == "_": + name = task.pytask_meta.name # type: ignore[attr-defined] + if name is None and task.__name__ == "_": raise ValueError( "A task function either needs 'name' passed by the ``@pytask.mark.task`` " "decorator or the function name of the task function must not be '_'." ) else: - parsed_name = ( - task.__name__ if task.pytask_meta.name is None else task.pytask_meta.name - ) + parsed_name = task.__name__ if name is None else name signature_kwargs = _parse_keyword_arguments_from_signature_defaults(task) - task.pytask_meta.kwargs = {**task.pytask_meta.kwargs, **signature_kwargs} + task.pytask_meta.kwargs = { # type: ignore[attr-defined] + **task.pytask_meta.kwargs, # type: ignore[attr-defined] + **signature_kwargs, + } return parsed_name, task From e60574cb7969320db2c6f0d2e59d8e6101954342 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 19:02:25 +0100 Subject: [PATCH 19/21] Add a test with irregular dicts which causes a TypeError during execution since an arg is missing. --- tests/test_task.py | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/test_task.py b/tests/test_task.py index a8bd5b1e9..58dda481c 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -51,7 +51,8 @@ def test_task_with_task_decorator_with_parametrize(tmp_path, func_name, task_nam def {func_name}(produces): produces.write_text("Hello. It's me.") """ - tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + path_to_module = tmp_path.joinpath("task_module.py") + path_to_module.write_text(textwrap.dedent(source)) session = main({"paths": tmp_path}) @@ -59,17 +60,17 @@ def {func_name}(produces): if task_name: assert session.tasks[0].name == create_task_name( - tmp_path.joinpath("task_module.py"), f"{task_name}[out_1.txt]" + path_to_module, f"{task_name}[out_1.txt]" ) assert session.tasks[1].name == create_task_name( - tmp_path.joinpath("task_module.py"), f"{task_name}[out_2.txt]" + path_to_module, f"{task_name}[out_2.txt]" ) else: assert session.tasks[0].name == create_task_name( - tmp_path.joinpath("task_module.py"), f"{func_name}[out_1.txt]" + path_to_module, f"{func_name}[out_1.txt]" ) assert session.tasks[1].name == create_task_name( - tmp_path.joinpath("task_module.py"), f"{func_name}[out_2.txt]" + path_to_module, f"{func_name}[out_2.txt]" ) @@ -223,3 +224,31 @@ def task_example(produces=f"out_{i}.txt"): assert "Traceback" in result.output assert "task_example[out_0.txt]" in result.output assert "task_example[out_1.txt]" in result.output + + +@pytest.mark.end_to_end +def test_parametrization_in_for_loop_from_decorator_w_irregular_dicts(tmp_path, runner): + source = """ + import pytask + + ID_TO_KWARGS = { + "first": {"i": 0, "produces": "out_0.txt"}, + "second": {"produces": "out_1.txt"}, + } + + for id_, kwargs in ID_TO_KWARGS.items(): + + @pytask.mark.task(name="deco_task", id=id_, kwargs=kwargs) + def example(produces, i): + produces.write_text(str(i)) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.FAILED + assert "deco_task[first]" in result.output + assert "deco_task[second]" in result.output + assert "1 Succeeded" + assert "1 Failed" + assert "TypeError: example() missing 1 required" in result.output From 58b93658be7d98d41231f2316b9ca2ebc7b22974 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 3 Mar 2022 19:09:58 +0100 Subject: [PATCH 20/21] Highlight parametrizations via loops in README.rst. --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index c7c1ca3f4..447952a48 100644 --- a/README.rst +++ b/README.rst @@ -60,6 +60,10 @@ projects. Its features include: `_ if a task fails, get feedback quickly, and be more productive. +- **Parametrizations via loops.** `Loop over task functions + `_ + to run the same task with different inputs. + - **Select tasks via expressions.** Run only a subset of tasks with `expressions and marker expressions `_ From caaaa91620c890e72491d086c9bf899d5adda5b6 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Mon, 7 Mar 2022 12:55:16 +0100 Subject: [PATCH 21/21] Rerun ci.