From d214e129301d4dcc2216278812af2274ae00bd3c Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 20 Sep 2026 17:09:35 +0200 Subject: [PATCH 1/3] test: cover task generators in simulation modes --- .../show_task_generator_simulation_modes.py | 82 +++++++++++++++++++ tests/test_provisional.py | 35 ++++++++ 2 files changed, 117 insertions(+) create mode 100644 scripts/show_task_generator_simulation_modes.py diff --git a/scripts/show_task_generator_simulation_modes.py b/scripts/show_task_generator_simulation_modes.py new file mode 100644 index 00000000..27a77368 --- /dev/null +++ b/scripts/show_task_generator_simulation_modes.py @@ -0,0 +1,82 @@ +# ruff: noqa: T201 +"""Show how task generators behave in dry-run and explain modes. + +Run this script from the repository root with + + uv run python scripts/show_task_generator_simulation_modes.py +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +TASK_MODULE = """ +from pathlib import Path + +from pytask import task + + +@task(is_generator=True) +def task_generator(): + counter = Path(__file__).with_name("generator-runs.txt") + previous_runs = int(counter.read_text()) if counter.exists() else 0 + counter.write_text(str(previous_runs + 1)) + + @task + def task_generated( + produces=Path(__file__).with_name("generated-task-ran.txt"), + ): + produces.write_text("ran") +""" + + +def run_case(root: Path, mode: str) -> None: + """Run one mode in an isolated directory and print the observable behavior.""" + case_dir = root / mode + case_dir.mkdir() + case_dir.joinpath("task_module.py").write_text( + textwrap.dedent(TASK_MODULE), encoding="utf-8" + ) + + command = [sys.executable, "-m", "pytask", f"--{mode}", str(case_dir)] + environment = {**os.environ, "NO_COLOR": "1", "PYTHONUTF8": "1"} + completed = subprocess.run( + command, + check=False, + capture_output=True, + encoding="utf-8", + env=environment, + ) + + counter = case_dir / "generator-runs.txt" + generated_product = case_dir / "generated-task-ran.txt" + + print(f"\n{mode.upper()}") + print(f"command: {' '.join(command)}") + print(completed.stdout.strip()) + if completed.stderr: + print("stderr:") + print(completed.stderr.strip()) + print(f"process exit code: {completed.returncode}") + print(f"generator body runs: {counter.read_text(encoding='utf-8')}") + print(f"generated task ran: {generated_product.exists()}") + + +def main() -> None: + """Run both simulation modes without sharing build artifacts.""" + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + with tempfile.TemporaryDirectory(prefix="pytask-generator-modes-") as directory: + root = Path(directory) + run_case(root, "dry-run") + run_case(root, "explain") + + +if __name__ == "__main__": + main() diff --git a/tests/test_provisional.py b/tests/test_provisional.py index 15c6d75b..fc9991c6 100644 --- a/tests/test_provisional.py +++ b/tests/test_provisional.py @@ -248,6 +248,41 @@ def task_child(produces=Path("child.txt")): assert "return" not in generator.produces +@pytest.mark.parametrize("mode", ["dry_run", "explain"]) +def test_task_generator_in_simulation_mode(tmp_path, mode): + source = """ + from pathlib import Path + from pytask import task + + @task(is_generator=True) + def task_generator(): + counter = Path(__file__).parent / "counter.txt" + count = int(counter.read_text()) if counter.exists() else 0 + counter.write_text(str(count + 1)) + + @task + def task_generated(produces=Path(__file__).parent / "generated.txt"): + produces.write_text("generated") + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + session = build( + paths=tmp_path, dry_run=mode == "dry_run", explain=mode == "explain" + ) + + outcomes = { + report.task.name.rsplit("::", maxsplit=1)[-1]: report.outcome + for report in session.execution_reports + } + assert session.exit_code == ExitCode.OK + assert outcomes == { + "task_generator": TaskOutcome.SUCCESS, + "task_generated": TaskOutcome.WOULD_BE_EXECUTED, + } + assert tmp_path.joinpath("counter.txt").read_text() == "1" + assert not tmp_path.joinpath("generated.txt").exists() + + def test_task_generator_cannot_define_products_with_argument(runner, tmp_path): source = """ from pathlib import Path From e2b64ed66d6aa51c57913904378a8b994cba92e7 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 20 Sep 2026 17:10:30 +0200 Subject: [PATCH 2/3] docs: add changelog entry for PR 997 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52edc207..3d7de53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## Unreleased +- [#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 from task generators are ignored and adds a regression test for this behavior. - [#996](https://github.com/pytask-dev/pytask/pull/996) raises collection errors when From 5310f08c51b423519aa065ea09c3882d2c1795ac Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 20 Sep 2026 17:33:23 +0200 Subject: [PATCH 3/3] chore: remove generator reproduction script --- .../show_task_generator_simulation_modes.py | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 scripts/show_task_generator_simulation_modes.py diff --git a/scripts/show_task_generator_simulation_modes.py b/scripts/show_task_generator_simulation_modes.py deleted file mode 100644 index 27a77368..00000000 --- a/scripts/show_task_generator_simulation_modes.py +++ /dev/null @@ -1,82 +0,0 @@ -# ruff: noqa: T201 -"""Show how task generators behave in dry-run and explain modes. - -Run this script from the repository root with - - uv run python scripts/show_task_generator_simulation_modes.py -""" - -from __future__ import annotations - -import os -import subprocess -import sys -import tempfile -import textwrap -from pathlib import Path - -TASK_MODULE = """ -from pathlib import Path - -from pytask import task - - -@task(is_generator=True) -def task_generator(): - counter = Path(__file__).with_name("generator-runs.txt") - previous_runs = int(counter.read_text()) if counter.exists() else 0 - counter.write_text(str(previous_runs + 1)) - - @task - def task_generated( - produces=Path(__file__).with_name("generated-task-ran.txt"), - ): - produces.write_text("ran") -""" - - -def run_case(root: Path, mode: str) -> None: - """Run one mode in an isolated directory and print the observable behavior.""" - case_dir = root / mode - case_dir.mkdir() - case_dir.joinpath("task_module.py").write_text( - textwrap.dedent(TASK_MODULE), encoding="utf-8" - ) - - command = [sys.executable, "-m", "pytask", f"--{mode}", str(case_dir)] - environment = {**os.environ, "NO_COLOR": "1", "PYTHONUTF8": "1"} - completed = subprocess.run( - command, - check=False, - capture_output=True, - encoding="utf-8", - env=environment, - ) - - counter = case_dir / "generator-runs.txt" - generated_product = case_dir / "generated-task-ran.txt" - - print(f"\n{mode.upper()}") - print(f"command: {' '.join(command)}") - print(completed.stdout.strip()) - if completed.stderr: - print("stderr:") - print(completed.stderr.strip()) - print(f"process exit code: {completed.returncode}") - print(f"generator body runs: {counter.read_text(encoding='utf-8')}") - print(f"generated task ran: {generated_product.exists()}") - - -def main() -> None: - """Run both simulation modes without sharing build artifacts.""" - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8") - - with tempfile.TemporaryDirectory(prefix="pytask-generator-modes-") as directory: - root = Path(directory) - run_case(root, "dry-run") - run_case(root, "explain") - - -if __name__ == "__main__": - main()