Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/_pytask/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand All @@ -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 += (
Expand Down
51 changes: 51 additions & 0 deletions tests/test_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading