From ac574a397bb37489b10fb6a2e8ef7c7c6d3e34aa Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 2 Aug 2026 19:24:35 +0200 Subject: [PATCH 1/5] Run sourceless tasks without paths --- src/_pytask/execute.py | 2 +- tests/test_execute.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index 6cf2f2e1..ff3a1534 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -174,7 +174,7 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C ) if not needs_to_be_executed: - predecessors = set(dag.predecessors(task.signature)) | {task.signature} + predecessors = set(dag.predecessors(task.signature)) for node_signature in node_and_neighbors(dag, task.signature): node = dag.nodes[node_signature] diff --git a/tests/test_execute.py b/tests/test_execute.py index c3315ab5..44ea5754 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -965,6 +965,36 @@ def func(path): assert session.exit_code == ExitCode.OK +def test_sourceless_task_without_path_always_executes(tmp_path): + namespace = {} + exec( # noqa: S102 + textwrap.dedent( + """ + def function(path): + count = int(path.read_text()) if path.exists() else 0 + path.write_text(str(count + 1)) + """ + ), + namespace, + ) + task = TaskWithoutPath( + name="sourceless", + function=namespace["function"], + produces={"path": PathNode(path=tmp_path / "count.txt")}, + ) + + assert task.state() is None + + first_session = build(tasks=task, paths=tmp_path) + second_session = build(tasks=task, paths=tmp_path) + + assert first_session.exit_code == ExitCode.OK + assert second_session.exit_code == ExitCode.OK + assert tmp_path.joinpath("count.txt").read_text() == "2" + assert first_session.execution_reports[0].outcome == TaskOutcome.SUCCESS + assert second_session.execution_reports[0].outcome == TaskOutcome.SUCCESS + + def test_collect_task(runner, tmp_path): source = """ from pytask import Task, PathNode From e07fb8b38b2c952169c6c0668d0b65c5bdbf6877 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:25:33 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_mark.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_mark.py b/tests/test_mark.py index 1fb7713e..62218508 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -191,8 +191,10 @@ def task_func(arg=arg): [ ( "foo or", - ("at column 7: expected not OR left parenthesis OR identifier; got end of " - "input"), + ( + "at column 7: expected not OR left parenthesis OR identifier; got end of " + "input" + ), ), ( "foo or or", From 8d0048497ca24ee5dab98a3b2eff6c6239c78c50 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 22 Sep 2026 22:34:42 +0200 Subject: [PATCH 3/5] fix: validate source for path-backed tasks --- src/_pytask/execute.py | 6 +++++- tests/test_execute.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index 9b7a4cc3..fa84784b 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -33,6 +33,7 @@ from _pytask.node_protocols import PPathNode from _pytask.node_protocols import PProvisionalNode from _pytask.node_protocols import PTask +from _pytask.node_protocols import PTaskWithPath from _pytask.nodes import DirectoryNode from _pytask.outcomes import Exit from _pytask.outcomes import SkippedUnchanged @@ -192,7 +193,10 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C node_state = node.state() - if node_signature in predecessors and not node_state: + is_required = node_signature in predecessors or ( + node_signature == task.signature and isinstance(task, PTaskWithPath) + ) + if is_required and not node_state: msg = f"{task.name!r} requires missing node {node.name!r}." if IS_FILE_SYSTEM_CASE_SENSITIVE: msg += ( diff --git a/tests/test_execute.py b/tests/test_execute.py index f016e820..6ccc4f41 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -18,6 +18,7 @@ from pytask import ExitCode from pytask import NodeNotFoundError from pytask import PathNode +from pytask import Task from pytask import TaskOutcome from pytask import TaskWithoutPath from pytask import build @@ -997,6 +998,26 @@ def function(path): assert second_session.execution_reports[0].outcome == TaskOutcome.SUCCESS +def test_task_with_missing_source_fails(tmp_path): + output_path = tmp_path / "out.txt" + task = Task( + base_name="task", + path=tmp_path / "missing.py", + function=lambda path: path.touch(), + produces={"path": PathNode(path=output_path)}, + ) + + session = build(tasks=task, paths=tmp_path) + + assert session.exit_code == ExitCode.FAILED + assert not output_path.exists() + report = session.execution_reports[0] + assert report.outcome == TaskOutcome.FAIL + exc_info = report.exc_info + assert exc_info is not None + assert isinstance(exc_info[1], NodeNotFoundError) + + def test_collect_task(runner, tmp_path): source = """ from pytask import Task, PathNode From df5912d048746cb4f077cab937c9a8085ee3b71d Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 22 Sep 2026 22:39:30 +0200 Subject: [PATCH 4/5] docs: explain required task states --- src/_pytask/execute.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index fa84784b..266e153b 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -193,6 +193,7 @@ def pytask_execute_task_setup(session: Session, task: PTask) -> None: # noqa: C node_state = node.state() + # Dependencies and source files must exist; sourceless tasks may lack state. is_required = node_signature in predecessors or ( node_signature == task.signature and isinstance(task, PTaskWithPath) ) From e865a21fc9397fcda0e814c2cdc7c2341692fbce Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 22 Sep 2026 22:45:19 +0200 Subject: [PATCH 5/5] docs: note sourceless task execution in changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7de53a..97cd88f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## Unreleased +- [#936](https://github.com/pytask-dev/pytask/pull/936) runs tasks without paths when + their source cannot be inspected, while still rejecting path-backed tasks with + missing source files. - [#997](https://github.com/pytask-dev/pytask/pull/997) documents how task generators behave in dry-run and explain modes and adds regression tests for both modes. - [#995](https://github.com/pytask-dev/pytask/pull/995) documents that return values