From 5249395446e606e1c0860de693c8e8f9cf999648 Mon Sep 17 00:00:00 2001 From: Tobias Raabe <22533006+tobiasraabe@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:53:16 +0200 Subject: [PATCH 1/5] Add mock Stata runtime for CI tests --- README.md | 25 ++++++------ tests/conftest.py | 64 ++++++++++++++++++++++++++++--- tests/mock_stata.py | 81 +++++++++++++++++++++++++++++++++++++++ tests/test_execute.py | 48 +++++++++++++++++------ tests/test_parallel.py | 14 ++++--- tests/test_parametrize.py | 16 ++++---- 6 files changed, 205 insertions(+), 43 deletions(-) create mode 100644 tests/mock_stata.py diff --git a/README.md b/README.md index a108197..bc917df 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ Here is an example where you want to run `script.do`. ```python import pytask +from pathlib import Path @pytask.mark.stata(script="script.do") -@pytask.mark.produces("auto.dta") -def task_run_do_file(): +def task_run_do_file(produces=Path("auto.dta")): pass ``` @@ -56,9 +56,8 @@ want to read with a relative path from the script. ### Dependencies and Products -Dependencies and products can be added as with a normal pytask task using the -`@pytask.mark.depends_on` and `@pytask.mark.produces` decorators. which is explained in -this +Dependencies and products can be added as with a normal pytask task using function +arguments, which is explained in this [tutorial](https://pytask-dev.readthedocs.io/en/stable/tutorials/defining_dependencies_products.html). ### Accessing dependencies and products in the script @@ -68,8 +67,7 @@ example, pass the path of the product with ```python @pytask.mark.stata(script="script.do", options="auto.dta") -@pytask.mark.produces("auto.dta") -def task_run_do_file(): +def task_run_do_file(produces=Path("auto.dta")): pass ``` @@ -95,8 +93,7 @@ from src.config import BLD @pytask.mark.stata(script="script.do", options=BLD / "auto.dta") -@pytask.mark.produces(BLD / "auto.dta") -def task_run_do_file(): +def task_run_do_file(produces=BLD / "auto.dta"): pass ``` @@ -108,12 +105,16 @@ as well as passing different command line arguments to the same do-file. The following task executes two do-files which produce different outputs. ```python +import pytask +from pathlib import Path +from pytask import task + + for i in range(2): - @pytask.mark.task + @task(id=str(i)) @pytask.mark.stata(script=f"script_{i}.do", options=f"{i}.dta") - @pytask.mark.produces(f"{i}.dta") - def task_execute_do_file(): + def task_execute_do_file(produces=Path(f"{i}.dta")): pass ``` diff --git a/tests/conftest.py b/tests/conftest.py index 9e0f1a4..ddd8edb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os import shutil import sys +import warnings from contextlib import contextmanager +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -14,13 +17,62 @@ if TYPE_CHECKING: from collections.abc import Callable -needs_stata = pytest.mark.skipif( - next( - (executable for executable in STATA_COMMANDS if shutil.which(executable)), None +needs_stata = pytest.mark.usefixtures("stata_runtime") + + +@pytest.fixture(scope="session") +def stata_runtime(tmp_path_factory): + """Use mock Stata on CI and require a real Stata executable locally.""" + if os.environ.get("CI"): + bin_path = tmp_path_factory.mktemp("mock_stata_bin") + mock_stata_path = Path(__file__).with_name("mock_stata.py") + original_path = os.environ["PATH"] + original_pathext = os.environ.get("PATHEXT") + + if sys.platform == "win32": + for executable in STATA_COMMANDS: + shim = bin_path / f"{executable}.cmd" + shim.write_text( + f'@echo off\n"{sys.executable}" "{mock_stata_path}" %*\n' + ) + os.environ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD" + else: + for executable in STATA_COMMANDS: + shim = bin_path / executable + shim.write_text( + f'#!/bin/sh\nexec "{sys.executable}" "{mock_stata_path}" "$@"\n' + ) + shim.chmod(0o755) + + os.environ["PATH"] = f"{bin_path}{os.pathsep}{original_path}" + mock_executable = shutil.which(STATA_COMMANDS[0]) + + warnings.warn( + "Using mock Stata runtime because CI is set: " + f"{mock_executable} -> {mock_stata_path}.", + stacklevel=1, + ) + yield + + os.environ["PATH"] = original_path + if original_pathext is None: + os.environ.pop("PATHEXT", None) + else: + os.environ["PATHEXT"] = original_pathext + return + + executable = next( + (executable for executable in STATA_COMMANDS if shutil.which(executable)), + None, ) - is None, - reason="Stata needs to be installed.", -) + if executable is None: + msg = ( + "Stata-dependent tests require a real Stata executable on PATH when CI is " + "not set. Set CI=1 to run them against tests/mock_stata.py." + ) + pytest.fail(msg) + + yield class SysPathsSnapshot: diff --git a/tests/mock_stata.py b/tests/mock_stata.py new file mode 100644 index 0000000..f9f651a --- /dev/null +++ b/tests/mock_stata.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def main() -> int: + args = sys.argv[1:] + if len(args) < 4 or args[:2] != ["-e", "do"]: + return 198 + + script = Path(args[2]).resolve() + options = args[3:-1] + log_arg = args[-1] + + if log_arg == "-": + log = script.with_suffix(".log") + else: + log = Path.cwd() / f"{log_arg.removeprefix('-')}.log" + + lines = [f"running mock Stata for {script.name}"] + macros: dict[str, str] = {} + error_code = None + + for raw_line in script.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("*"): + continue + + line = _expand_local_macros(line, macros) + lines.append(f". {line}") + + command, _, rest = line.partition(" ") + command = command.lower() + rest = rest.strip() + + if command == "args": + for name, value in zip(rest.split(), options, strict=False): + macros[name] = value + elif command == "sysuse": + continue + elif command == "save": + target = _parse_save_target(rest) + if not target: + error_code = 198 + break + path = Path(target) + if path.suffix == "": + path = path.with_suffix(".dta") + if not path.is_absolute(): + path = Path.cwd() / path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("mock Stata dataset\n") + elif command in {"error", "exit"}: + error_code = int(rest.split()[0]) + break + else: + error_code = 199 + break + + if error_code is not None: + lines.append(f"r({error_code})") + else: + lines.append("end of mock do-file") + + log.write_text("\n".join(lines) + "\n") + return 0 + + +def _expand_local_macros(line: str, macros: dict[str, str]) -> str: + return re.sub(r"`([^']+)'", lambda match: macros.get(match.group(1), ""), line) + + +def _parse_save_target(rest: str) -> str: + target = rest.split(",", maxsplit=1)[0].strip() + return target.strip("\"'") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_execute.py b/tests/test_execute.py index 9307756..15c5158 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -44,10 +44,10 @@ def test_pytask_execute_task_setup_raise_error(stata, platform, expectation): def test_run_do_file(runner, tmp_path): task_source = """ import pytask + from pathlib import Path @pytask.mark.stata(script="script.do") - @pytask.mark.produces("auto.dta") - def task_run_do_file(): + def task_run_do_file(produces=Path("auto.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) @@ -73,11 +73,12 @@ def task_run_do_file(): def test_run_do_file_w_task_decorator(runner, tmp_path): task_source = """ import pytask + from pathlib import Path + from pytask import task - @pytask.mark.task + @task @pytask.mark.stata(script="script.do") - @pytask.mark.produces("auto.dta") - def run_do_file(): + def run_do_file(produces=Path("auto.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) @@ -130,10 +131,10 @@ def test_run_do_file_w_wrong_cmd_option(runner, tmp_path): """Apparently, Stata simply discards wrong cmd options.""" task_source = """ import pytask + from pathlib import Path @pytask.mark.stata(script="script.do", options="--wrong-flag") - @pytask.mark.produces("out.dta") - def task_run_do_file(): + def task_run_do_file(produces=Path("out.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) @@ -158,8 +159,7 @@ def test_run_do_file_by_passing_path(runner, tmp_path): from pathlib import Path @pytask.mark.stata(script="script.do", options=Path(__file__).parent / "auto.dta") - @pytask.mark.produces("auto.dta") - def task_run_do_file(): + def task_run_do_file(produces=Path("auto.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) @@ -175,15 +175,38 @@ def task_run_do_file(): assert result.exit_code == ExitCode.OK +@needs_stata +def test_run_do_file_fails_with_stata_error(runner, tmp_path): + task_source = """ + import pytask + from pathlib import Path + + @pytask.mark.stata(script="script.do") + def task_run_do_file(produces=Path("out.dta")): + pass + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) + + do_file = """ + error 601 + """ + tmp_path.joinpath("script.do").write_text(textwrap.dedent(do_file)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.FAILED + assert "r(601)" in result.output + + @needs_stata def test_run_do_file_fails_with_multiple_marks(runner, tmp_path): task_source = """ import pytask + from pathlib import Path @pytask.mark.stata(script="script.do") @pytask.mark.stata(script="script.do") - @pytask.mark.produces("auto.dta") - def task_run_do_file(): + def task_run_do_file(produces=Path("auto.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) @@ -199,10 +222,11 @@ def task_run_do_file(): def test_with_task_without_path(runner, tmp_path): task_source = """ import pytask + from pathlib import Path from pytask import task task_example = pytask.mark.stata(script="script.do")( - pytask.mark.produces("auto.dta")(task()(lambda x: None)) + task(kwargs={"produces": Path("auto.dta")})(lambda produces: None) ) """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) diff --git a/tests/test_parallel.py b/tests/test_parallel.py index 5120ccd..1aae6b3 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -27,13 +27,14 @@ def test_parallel_parametrization_over_source_files_w_loop(runner, tmp_path): source = """ import pytask + from pathlib import Path + from pytask import task for i in range (1, 3): - @pytask.mark.task + @task(id=str(i)) @pytask.mark.stata(script=f"script_{i}.do") - @pytask.mark.produces(f"{i}.dta") - def task_execute_do_file(): + def task_execute_do_file(produces=Path(f"{i}.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -58,13 +59,14 @@ def task_execute_do_file(): def test_parallel_parametrization_over_source_file_w_loop(runner, tmp_path): source = """ import pytask + from pathlib import Path + from pytask import task for i in range (1, 3): - @pytask.mark.task + @task(id=str(i)) @pytask.mark.stata(script="script.do", options=f"output_{i}") - @pytask.mark.produces(f"output_{i}.dta") - def task_execute_do_file(): + def task_execute_do_file(produces=Path(f"output_{i}.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py index 79a86ba..d809c47 100644 --- a/tests/test_parametrize.py +++ b/tests/test_parametrize.py @@ -13,13 +13,14 @@ def test_parametrized_execution_of_do_file_w_loop(runner, tmp_path): source = """ import pytask + from pathlib import Path + from pytask import task for i in range (1, 3): - @pytask.mark.task + @task(id=str(i)) @pytask.mark.stata(script=f"script_{i}.do") - @pytask.mark.produces(f"{i}.dta") - def task_execute_do_file(): + def task_execute_do_file(produces=Path(f"{i}.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) @@ -42,13 +43,14 @@ def task_execute_do_file(): def test_parametrize_command_line_options_w_loop(runner, tmp_path): task_source = """ import pytask + from pathlib import Path + from pytask import task for i in range (1, 3): - @pytask.mark.task + @task(id=str(i)) @pytask.mark.stata(script="script.do", options=f"output_{i}") - @pytask.mark.produces(f"output_{i}.dta") - def task_execute_do_file(): + def task_execute_do_file(produces=Path(f"output_{i}.dta")): pass """ tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(task_source)) @@ -68,7 +70,7 @@ def task_execute_do_file(): # Test that log files with different names are produced. if sys.platform == "win32": - assert tmp_path.joinpath("task_example_py_task_execute_do_file[0].log").exists() assert tmp_path.joinpath("task_example_py_task_execute_do_file[1].log").exists() + assert tmp_path.joinpath("task_example_py_task_execute_do_file[2].log").exists() else: assert tmp_path.joinpath("script.log").exists() From dd4f147390ca865d4b1c33579a24f838d111b9ec Mon Sep 17 00:00:00 2001 From: Tobias Raabe <22533006+tobiasraabe@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:58:55 +0200 Subject: [PATCH 2/5] Fix mock Stata lint issues --- tests/mock_stata.py | 105 +++++++++++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/tests/mock_stata.py b/tests/mock_stata.py index f9f651a..cd52aa8 100644 --- a/tests/mock_stata.py +++ b/tests/mock_stata.py @@ -4,24 +4,44 @@ import sys from pathlib import Path +INVALID_SYNTAX = 198 +UNKNOWN_COMMAND = 199 +MINIMUM_ARGUMENTS = 4 + def main() -> int: - args = sys.argv[1:] - if len(args) < 4 or args[:2] != ["-e", "do"]: - return 198 + parsed = _parse_invocation(sys.argv[1:]) + if parsed is None: + return INVALID_SYNTAX + + script, options, log = parsed + lines, error_code = _run_script(script, options) + + lines.append( + f"r({error_code})" if error_code is not None else "end of mock do-file" + ) + log.write_text("\n".join(lines) + "\n") + return 0 + + +def _parse_invocation(args: list[str]) -> tuple[Path, list[str], Path] | None: + if len(args) < MINIMUM_ARGUMENTS or args[:2] != ["-e", "do"]: + return None script = Path(args[2]).resolve() options = args[3:-1] log_arg = args[-1] + log = ( + script.with_suffix(".log") + if log_arg == "-" + else Path.cwd() / f"{log_arg.removeprefix('-')}.log" + ) + return script, options, log - if log_arg == "-": - log = script.with_suffix(".log") - else: - log = Path.cwd() / f"{log_arg.removeprefix('-')}.log" +def _run_script(script: Path, options: list[str]) -> tuple[list[str], int | None]: lines = [f"running mock Stata for {script.name}"] macros: dict[str, str] = {} - error_code = None for raw_line in script.read_text().splitlines(): line = raw_line.strip() @@ -30,42 +50,45 @@ def main() -> int: line = _expand_local_macros(line, macros) lines.append(f". {line}") + error_code = _execute_line(line, macros, options) + if error_code is not None: + return lines, error_code + + return lines, None + + +def _execute_line(line: str, macros: dict[str, str], options: list[str]) -> int | None: + command, _, rest = line.partition(" ") + command = command.lower() + rest = rest.strip() + + if command == "args": + macros.update(dict(zip(rest.split(), options, strict=False))) + elif command == "sysuse": + return None + elif command == "save": + return _save_dataset(rest) + elif command in {"error", "exit"}: + return int(rest.split()[0]) + else: + return UNKNOWN_COMMAND - command, _, rest = line.partition(" ") - command = command.lower() - rest = rest.strip() + return None - if command == "args": - for name, value in zip(rest.split(), options, strict=False): - macros[name] = value - elif command == "sysuse": - continue - elif command == "save": - target = _parse_save_target(rest) - if not target: - error_code = 198 - break - path = Path(target) - if path.suffix == "": - path = path.with_suffix(".dta") - if not path.is_absolute(): - path = Path.cwd() / path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("mock Stata dataset\n") - elif command in {"error", "exit"}: - error_code = int(rest.split()[0]) - break - else: - error_code = 199 - break - - if error_code is not None: - lines.append(f"r({error_code})") - else: - lines.append("end of mock do-file") - log.write_text("\n".join(lines) + "\n") - return 0 +def _save_dataset(rest: str) -> int | None: + target = _parse_save_target(rest) + if not target: + return INVALID_SYNTAX + + path = Path(target) + if path.suffix == "": + path = path.with_suffix(".dta") + if not path.is_absolute(): + path = Path.cwd() / path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("mock Stata dataset\n") + return None def _expand_local_macros(line: str, macros: dict[str, str]) -> str: From f3ba6f2351967ffea09eb43840e78963bb7c1caa Mon Sep 17 00:00:00 2001 From: Tobias Raabe <22533006+tobiasraabe@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:15:21 +0200 Subject: [PATCH 3/5] Package mock Stata executable for CI --- .github/workflows/main.yml | 6 +- justfile | 12 ++++ packages/stata_mock/pyproject.toml | 29 +++++++++ .../stata_mock/src/stata_mock/__init__.py | 1 + .../stata_mock/src/stata_mock/cli.py | 3 + pyproject.toml | 7 ++ tests/conftest.py | 65 ++++++++----------- uv.lock | 15 +++++ 8 files changed, 97 insertions(+), 41 deletions(-) create mode 100644 packages/stata_mock/pyproject.toml create mode 100644 packages/stata_mock/src/stata_mock/__init__.py rename tests/mock_stata.py => packages/stata_mock/src/stata_mock/cli.py (95%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8c5152c..6c2c337 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,15 +66,15 @@ jobs: - name: Run tests shell: bash -l {0} - run: just test + run: just test-ci - name: Upload test coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 - name: Run tests with lowest resolution if: matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' - run: just test-lowest + run: just test-lowest-ci - name: Run tests with highest resolution if: matrix.python-version == '3.14' && matrix.os == 'ubuntu-latest' - run: just test-highest + run: just test-highest-ci diff --git a/justfile b/justfile index e5caf20..483e4e3 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,10 @@ install: test: uv run --group test pytest --cov=src --cov=tests --cov-report=xml +# Run tests in CI with the mock Stata executable installed. +test-ci: + uv run --group test --group test-mock-stata pytest --cov=src --cov=tests --cov-report=xml + # Run type checking typing: uv run --group typing --group test --isolated ty check @@ -21,6 +25,14 @@ check: lint typing test test-lowest: uv run --group test --resolution lowest-direct pytest +# Run tests with lowest dependency resolution in CI with the mock Stata executable installed. +test-lowest-ci: + uv run --group test --group test-mock-stata --resolution lowest-direct pytest + # Run tests with highest dependency resolution test-highest: uv run --group test --resolution highest pytest + +# Run tests with highest dependency resolution in CI with the mock Stata executable installed. +test-highest-ci: + uv run --group test --group test-mock-stata --resolution highest pytest diff --git a/packages/stata_mock/pyproject.toml b/packages/stata_mock/pyproject.toml new file mode 100644 index 0000000..00e4d98 --- /dev/null +++ b/packages/stata_mock/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "stata-mock" +version = "0.0.0" +description = "Mock Stata executable for pytask-stata tests." +requires-python = ">=3.10" + +[project.scripts] +Stata = "stata_mock.cli:main" +Stata-64 = "stata_mock.cli:main" +Stata-ia = "stata_mock.cli:main" +Stata64 = "stata_mock.cli:main" +Stata64MP = "stata_mock.cli:main" +Stata64SE = "stata_mock.cli:main" +StataMP = "stata_mock.cli:main" +StataMP-64 = "stata_mock.cli:main" +StataMP-ia = "stata_mock.cli:main" +StataSE = "stata_mock.cli:main" +StataSE-64 = "stata_mock.cli:main" +StataSE-ia = "stata_mock.cli:main" +WMPSTATA = "stata_mock.cli:main" +WSESTATA = "stata_mock.cli:main" +WSTATA = "stata_mock.cli:main" +stata = "stata_mock.cli:main" +stata-mp = "stata_mock.cli:main" +stata-se = "stata_mock.cli:main" diff --git a/packages/stata_mock/src/stata_mock/__init__.py b/packages/stata_mock/src/stata_mock/__init__.py new file mode 100644 index 0000000..235a696 --- /dev/null +++ b/packages/stata_mock/src/stata_mock/__init__.py @@ -0,0 +1 @@ +"""Mock Stata executable for tests.""" diff --git a/tests/mock_stata.py b/packages/stata_mock/src/stata_mock/cli.py similarity index 95% rename from tests/mock_stata.py rename to packages/stata_mock/src/stata_mock/cli.py index cd52aa8..43370a6 100644 --- a/tests/mock_stata.py +++ b/packages/stata_mock/src/stata_mock/cli.py @@ -1,3 +1,5 @@ +"""Command line interface for the mock Stata executable.""" + from __future__ import annotations import re @@ -10,6 +12,7 @@ def main() -> int: + """Run a tiny subset of Stata syntax for pytask-stata tests.""" parsed = _parse_invocation(sys.argv[1:]) if parsed is None: return INVALID_SYNTAX diff --git a/pyproject.toml b/pyproject.toml index 36645eb..afb95b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,15 @@ test = [ "pytest-cov>=5.0.0", "pytest-xdist>=3.6.1", ] +test-mock-stata = ["stata-mock"] typing = ["pytask-parallel>=0.5.1", "ty>=0.0.8"] +[tool.uv.sources] +stata-mock = { workspace = true } + +[tool.uv.workspace] +members = ["packages/stata_mock"] + [tool.hatch.build.hooks.vcs] version-file = "src/pytask_stata/_version.py" diff --git a/tests/conftest.py b/tests/conftest.py index ddd8edb..741695e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,60 +21,49 @@ @pytest.fixture(scope="session") -def stata_runtime(tmp_path_factory): +def stata_runtime(): """Use mock Stata on CI and require a real Stata executable locally.""" + executable_path = _find_stata_executable() if os.environ.get("CI"): - bin_path = tmp_path_factory.mktemp("mock_stata_bin") - mock_stata_path = Path(__file__).with_name("mock_stata.py") - original_path = os.environ["PATH"] - original_pathext = os.environ.get("PATHEXT") - - if sys.platform == "win32": - for executable in STATA_COMMANDS: - shim = bin_path / f"{executable}.cmd" - shim.write_text( - f'@echo off\n"{sys.executable}" "{mock_stata_path}" %*\n' - ) - os.environ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD" - else: - for executable in STATA_COMMANDS: - shim = bin_path / executable - shim.write_text( - f'#!/bin/sh\nexec "{sys.executable}" "{mock_stata_path}" "$@"\n' - ) - shim.chmod(0o755) - - os.environ["PATH"] = f"{bin_path}{os.pathsep}{original_path}" - mock_executable = shutil.which(STATA_COMMANDS[0]) - + if executable_path is None: + msg = ( + "CI requires the stata-mock package to be installed so one of " + f"{STATA_COMMANDS} is available on PATH." + ) + pytest.fail(msg) warnings.warn( - "Using mock Stata runtime because CI is set: " - f"{mock_executable} -> {mock_stata_path}.", + f"Using mock Stata runtime because CI is set: {executable_path}.", stacklevel=1, ) yield - - os.environ["PATH"] = original_path - if original_pathext is None: - os.environ.pop("PATHEXT", None) - else: - os.environ["PATHEXT"] = original_pathext return - executable = next( - (executable for executable in STATA_COMMANDS if shutil.which(executable)), - None, - ) - if executable is None: + if executable_path is None or _is_mock_stata_executable(executable_path): msg = ( "Stata-dependent tests require a real Stata executable on PATH when CI is " - "not set. Set CI=1 to run them against tests/mock_stata.py." + "not set. Run the CI test recipe to use the stata-mock package." ) pytest.fail(msg) yield +def _is_mock_stata_executable(executable_path: str) -> bool: + try: + Path(executable_path).resolve().relative_to(Path(sys.prefix).resolve()) + except ValueError: + return False + else: + return True + + +def _find_stata_executable() -> str | None: + return next( + (path for executable in STATA_COMMANDS if (path := shutil.which(executable))), + None, + ) + + class SysPathsSnapshot: """A snapshot for sys.path.""" diff --git a/uv.lock b/uv.lock index f7d5831..c0d910a 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[manifest] +members = [ + "pytask-stata", + "stata-mock", +] + [[package]] name = "attrs" version = "25.3.0" @@ -513,6 +519,9 @@ test = [ { name = "pytest-cov" }, { name = "pytest-xdist" }, ] +test-mock-stata = [ + { name = "stata-mock" }, +] typing = [ { name = "pytask-parallel" }, { name = "ty" }, @@ -530,6 +539,7 @@ test = [ { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, ] +test-mock-stata = [{ name = "stata-mock", editable = "packages/stata_mock" }] typing = [ { name = "pytask-parallel", specifier = ">=0.5.1" }, { name = "ty", specifier = ">=0.0.8" }, @@ -638,6 +648,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, ] +[[package]] +name = "stata-mock" +version = "0.0.0" +source = { editable = "packages/stata_mock" } + [[package]] name = "tomli" version = "2.2.1" From c721573b97638e6da3ee39c2037842b5a159b207 Mon Sep 17 00:00:00 2001 From: Tobias Raabe <22533006+tobiasraabe@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:27:08 +0200 Subject: [PATCH 4/5] Relax task id and log name tests --- README.md | 2 +- tests/test_execute.py | 20 +++++++------------- tests/test_parallel.py | 4 ++-- tests/test_parametrize.py | 12 +++--------- 4 files changed, 13 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 19b44bc..cd7b08e 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ from pytask import task for i in range(2): - @task(id=str(i)) + @task @mark.stata(script=Path(f"script_{i}.do"), options=f"{i}.dta") def task_execute_do_file(produces: Path = Path(f"{i}.dta")): pass diff --git a/tests/test_execute.py b/tests/test_execute.py index 15c5158..0495623 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys import textwrap from contextlib import ExitStack as does_not_raise # noqa: N813 from pathlib import Path @@ -18,6 +17,10 @@ from tests.conftest import needs_stata +def _assert_log_exists(path: Path) -> None: + assert list(path.glob("*.log")) + + @pytest.mark.parametrize( ("stata", "expectation"), [(executable, does_not_raise()) for executable in STATA_COMMANDS] @@ -63,10 +66,7 @@ def task_run_do_file(produces=Path("auto.dta")): assert result.exit_code == ExitCode.OK assert tmp_path.joinpath("auto.dta").exists() - if sys.platform == "win32": - assert tmp_path.joinpath("task_example_py_task_run_do_file.log").exists() - else: - assert tmp_path.joinpath("script.log").exists() + _assert_log_exists(tmp_path) @needs_stata @@ -94,10 +94,7 @@ def run_do_file(produces=Path("auto.dta")): assert result.exit_code == ExitCode.OK assert tmp_path.joinpath("auto.dta").exists() - if sys.platform == "win32": - assert tmp_path.joinpath("task_example_py_run_do_file.log").exists() - else: - assert tmp_path.joinpath("script.log").exists() + _assert_log_exists(tmp_path) def test_raise_error_if_stata_is_not_found(tmp_path, monkeypatch): @@ -242,7 +239,4 @@ def test_with_task_without_path(runner, tmp_path): assert result.exit_code == ExitCode.OK assert tmp_path.joinpath("auto.dta").exists() - if sys.platform == "win32": - assert tmp_path.joinpath("task_example_py_lambda.log").exists() - else: - assert not tmp_path.joinpath("task_example_py_lambda.log").exists() + _assert_log_exists(tmp_path) diff --git a/tests/test_parallel.py b/tests/test_parallel.py index 1aae6b3..bae2346 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -32,7 +32,7 @@ def test_parallel_parametrization_over_source_files_w_loop(runner, tmp_path): for i in range (1, 3): - @task(id=str(i)) + @task @pytask.mark.stata(script=f"script_{i}.do") def task_execute_do_file(produces=Path(f"{i}.dta")): pass @@ -64,7 +64,7 @@ def test_parallel_parametrization_over_source_file_w_loop(runner, tmp_path): for i in range (1, 3): - @task(id=str(i)) + @task @pytask.mark.stata(script="script.do", options=f"output_{i}") def task_execute_do_file(produces=Path(f"output_{i}.dta")): pass diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py index d809c47..85c5deb 100644 --- a/tests/test_parametrize.py +++ b/tests/test_parametrize.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys import textwrap from pytask import ExitCode @@ -18,7 +17,7 @@ def test_parametrized_execution_of_do_file_w_loop(runner, tmp_path): for i in range (1, 3): - @task(id=str(i)) + @task @pytask.mark.stata(script=f"script_{i}.do") def task_execute_do_file(produces=Path(f"{i}.dta")): pass @@ -48,7 +47,7 @@ def test_parametrize_command_line_options_w_loop(runner, tmp_path): for i in range (1, 3): - @task(id=str(i)) + @task @pytask.mark.stata(script="script.do", options=f"output_{i}") def task_execute_do_file(produces=Path(f"output_{i}.dta")): pass @@ -68,9 +67,4 @@ def task_execute_do_file(produces=Path(f"output_{i}.dta")): assert tmp_path.joinpath("output_1.dta").exists() assert tmp_path.joinpath("output_2.dta").exists() - # Test that log files with different names are produced. - if sys.platform == "win32": - assert tmp_path.joinpath("task_example_py_task_execute_do_file[1].log").exists() - assert tmp_path.joinpath("task_example_py_task_execute_do_file[2].log").exists() - else: - assert tmp_path.joinpath("script.log").exists() + assert list(tmp_path.glob("*.log")) From bc9ef2c52a415c7e92d66130e73d469c3fdbd3fe Mon Sep 17 00:00:00 2001 From: Tobias Raabe <22533006+tobiasraabe@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:40:31 +0200 Subject: [PATCH 5/5] Restore exact log assertions --- tests/test_execute.py | 20 +++++++++++++------- tests/test_parametrize.py | 11 ++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/test_execute.py b/tests/test_execute.py index 0495623..15c5158 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys import textwrap from contextlib import ExitStack as does_not_raise # noqa: N813 from pathlib import Path @@ -17,10 +18,6 @@ from tests.conftest import needs_stata -def _assert_log_exists(path: Path) -> None: - assert list(path.glob("*.log")) - - @pytest.mark.parametrize( ("stata", "expectation"), [(executable, does_not_raise()) for executable in STATA_COMMANDS] @@ -66,7 +63,10 @@ def task_run_do_file(produces=Path("auto.dta")): assert result.exit_code == ExitCode.OK assert tmp_path.joinpath("auto.dta").exists() - _assert_log_exists(tmp_path) + if sys.platform == "win32": + assert tmp_path.joinpath("task_example_py_task_run_do_file.log").exists() + else: + assert tmp_path.joinpath("script.log").exists() @needs_stata @@ -94,7 +94,10 @@ def run_do_file(produces=Path("auto.dta")): assert result.exit_code == ExitCode.OK assert tmp_path.joinpath("auto.dta").exists() - _assert_log_exists(tmp_path) + if sys.platform == "win32": + assert tmp_path.joinpath("task_example_py_run_do_file.log").exists() + else: + assert tmp_path.joinpath("script.log").exists() def test_raise_error_if_stata_is_not_found(tmp_path, monkeypatch): @@ -239,4 +242,7 @@ def test_with_task_without_path(runner, tmp_path): assert result.exit_code == ExitCode.OK assert tmp_path.joinpath("auto.dta").exists() - _assert_log_exists(tmp_path) + if sys.platform == "win32": + assert tmp_path.joinpath("task_example_py_lambda.log").exists() + else: + assert not tmp_path.joinpath("task_example_py_lambda.log").exists() diff --git a/tests/test_parametrize.py b/tests/test_parametrize.py index 85c5deb..b8ec83d 100644 --- a/tests/test_parametrize.py +++ b/tests/test_parametrize.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys import textwrap from pytask import ExitCode @@ -67,4 +68,12 @@ def task_execute_do_file(produces=Path(f"output_{i}.dta")): assert tmp_path.joinpath("output_1.dta").exists() assert tmp_path.joinpath("output_2.dta").exists() - assert list(tmp_path.glob("*.log")) + if sys.platform == "win32": + assert tmp_path.joinpath( + "task_example_py_task_execute_do_file[produces0].log" + ).exists() + assert tmp_path.joinpath( + "task_example_py_task_execute_do_file[produces1].log" + ).exists() + else: + assert tmp_path.joinpath("script.log").exists()