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 diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index 81f530d5..266e153b 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 @@ -172,7 +173,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] @@ -192,7 +193,11 @@ 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: + # 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) + ) + 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 5067d6ac..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 @@ -967,6 +968,56 @@ 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_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