diff --git a/docs/source/changes.md b/docs/source/changes.md index 9a041d47..48c57983 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -13,6 +13,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and - {pull}`619` makes coiled an optional import for tests. Thanks to {user}`erooke`. - {pull}`620` makes tests more flexible about their location. Thanks to {user}`erooke`. - {pull}`621` fixes the pull requests template. +- {pull}`626` resolves an issue with rerunning tasks via the programmatic API. Closes + {issue}`625`. Thanks to @noppelmax for the issue! - {pull}`627` adds a warning when users explicitly pass files to pytask that pytask is going to ignore because they do not match a pattern. Happens quite often when the task module's name does not start with `task_`. diff --git a/src/_pytask/collect.py b/src/_pytask/collect.py index 006f2e43..96276fa5 100644 --- a/src/_pytask/collect.py +++ b/src/_pytask/collect.py @@ -7,6 +7,7 @@ import os import sys import time +from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -114,7 +115,11 @@ def _collect_from_tasks(session: Session) -> None: name = raw_task.pytask_meta.name if has_mark(raw_task, "task"): - COLLECTED_TASKS[path].remove(raw_task) + # When tasks with @task are passed to the programmatic interface multiple + # times, they are deleted from ``COLLECTED_TASKS`` in the first iteration + # and are missing in the later. See #625. + with suppress(ValueError): + COLLECTED_TASKS[path].remove(raw_task) # When a task is not a callable, it can be anything or a PTask. Set arbitrary # values and it will pass without errors and not collected. diff --git a/tests/test_execute.py b/tests/test_execute.py index 07a4ee70..b32d6529 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import pickle import re import subprocess @@ -261,7 +262,15 @@ def task_example(): @pytest.mark.end_to_end() def test_task_executed_with_force_although_unchanged(tmp_path): - tmp_path.joinpath("task_module.py").write_text("def task_example(): pass") + source = """ + from pytask import task + + def task_example(): pass + + @task + def task_example_2(): pass + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) session = build(paths=tmp_path) assert session.execution_reports[0].outcome == TaskOutcome.SUCCESS session = build(paths=tmp_path, force=True) @@ -625,6 +634,35 @@ def create_file( assert tmp_path.joinpath("file.txt").read_text() == "This is the text." +@pytest.mark.end_to_end() +@pytest.mark.skipif( + sys.platform == "win32" and os.environ.get("CI") == "true", + reason="Windows does not pick up the right Python interpreter.", +) +def test_execute_tasks_multiple_times_via_api(tmp_path): + """See #625.""" + source = """ + import pathlib + from typing_extensions import Annotated + from pytask import build, task + import sys + + @task + def task1() -> None: pass + def task2() -> None: pass + + if __name__ == "__main__": + session1 = build(tasks=[task1, task2]) + session2 = build(tasks=[task1, task2]) + sys.exit(session2.exit_code) + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + result = subprocess.run( + ("python", tmp_path.joinpath("task_module.py").as_posix()), check=False + ) + assert result.returncode == ExitCode.OK + + @pytest.mark.end_to_end() def test_pytask_on_a_module_that_uses_the_functional_api(tmp_path): source = """