From 1ca5e2483b81e989f9229782887883a4b2c545fd Mon Sep 17 00:00:00 2001 From: Ansh Bajpai Date: Sat, 11 Jul 2026 18:14:53 +0530 Subject: [PATCH] Universal run: trace any Python entry point, not only pytest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oracletrace run now dispatches pytest, python , python -m , and bare / -m shorthands — all in-process (runpy / pytest.main), because sys.setprofile only observes the current process and a subprocess would silently produce an empty trace. Non-Python commands and python -c are rejected with an error explaining that constraint. New oracletrace/runner.py owns dispatch; cli.py's run handler shrinks to init-tracer -> execute_command -> report. Script exit codes propagate as in #87. Co-Authored-By: Claude Fable 5 --- oracletrace/cli.py | 40 +++------ oracletrace/runner.py | 156 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 3 +- tests/test_run_command.py | 170 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 29 deletions(-) create mode 100644 oracletrace/runner.py create mode 100644 tests/test_run_command.py diff --git a/oracletrace/cli.py b/oracletrace/cli.py index 7d48f32..d9c1ab3 100644 --- a/oracletrace/cli.py +++ b/oracletrace/cli.py @@ -14,6 +14,7 @@ from json import JSONDecodeError from .reporters import generate_html_report +from .runner import execute_command, UnsupportedCommandError from .compare import compare_traces, ComparisonData from .tracer import Tracer, TracerData, TracerMetadata, FunctionData, FunctionAggregate @@ -337,7 +338,7 @@ def _run_target() -> int: parser: ArgumentParser = ArgumentParser( prog=_MODULE_NAME, description="OracleTrace - Lightweight execution tracer for Python projects", - epilog="Subcommands:\n run Run a command (e.g., pytest) with tracing. See 'oracletrace run --help'.", + epilog="Subcommands:\n run Run a command (pytest, python script.py, python -m module) with tracing. See 'oracletrace run --help'.", parents=[_BASE_PARSER], ) parser.add_argument("target", help="Python script to trace") @@ -429,7 +430,9 @@ def _run_command(argv: List[str]) -> int: parser.add_argument( "command", nargs=argparse.REMAINDER, - help="Command to run (e.g., pytest). Use -- to separate oracletrace options from the command.", + help="Command to run. Supported: pytest, python , " + "python -m , . " + "Use -- to separate oracletrace options from the command.", ) parsed: Namespace = parser.parse_args(argv) @@ -445,40 +448,21 @@ def _run_command(argv: List[str]) -> int: ) return 1 - cmd: str = parsed.command[0] - - if cmd == "pytest": - return _run_pytest(parsed) - else: - print( - f"error: unsupported command '{cmd}'. Only 'pytest' is supported.", - file=sys.stderr, - ) - return 1 - - -def _run_pytest(args: Namespace) -> int: - try: - import pytest - except ImportError: - print( - "error: pytest is not installed. Install it with: pip install pytest", - file=sys.stderr, - ) - return 1 - - err, tracer = _init_tracer(args) + err, tracer = _init_tracer(parsed) if err is not None: return err assert tracer is not None try: - pytest_exit_code: int = pytest.main(args.command[1:]) + program_exit: int = execute_command(parsed.command) + except UnsupportedCommandError as e: + print(f"error: {e}", file=sys.stderr) + return 1 finally: tracer.stop() - exit_code = _finish_trace(tracer, args) - return exit_code if exit_code is not None else pytest_exit_code + exit_code = _finish_trace(tracer, parsed) + return exit_code if exit_code is not None else program_exit if __name__ == "__main__": diff --git a/oracletrace/runner.py b/oracletrace/runner.py new file mode 100644 index 0000000..be33ea7 --- /dev/null +++ b/oracletrace/runner.py @@ -0,0 +1,156 @@ +"""Universal in-process command execution for `oracletrace run`. + +The tracer uses sys.setprofile(), which only observes the CURRENT process. +A subprocess would be invisible and traces would come back empty — so every +supported invocation is executed in-process: + + pytest [args] -> pytest.main([args]) + python -m [args] -> runpy.run_module() + python [args] -> runpy.run_path() + -m [args] -> runpy.run_module() + [args] -> runpy.run_path() + +Non-Python launchers (node, cargo, make...) are rejected with a clear error +instead of silently producing an empty trace. +""" + +import os +import sys +import runpy +from contextlib import contextmanager +from typing import Iterator, List + + +class UnsupportedCommandError(Exception): + pass + + +_PYTHON_ALIASES = {"python", "python3", "py"} + + +def _is_python_launcher(token: str) -> bool: + base = os.path.basename(token).lower() + if base.endswith(".exe"): + base = base[:-4] + # strip minor-versioned launchers: python3.12 -> python3 + while base and base[-1].isdigit(): + base = base[:-1] + base = base.rstrip(".") + return base in _PYTHON_ALIASES + + +@contextmanager +def _patched_argv(argv: List[str]) -> Iterator[None]: + original = sys.argv + sys.argv = argv + try: + yield + finally: + sys.argv = original + + +@contextmanager +def _prepended_path(entry: str) -> Iterator[None]: + sys.path.insert(0, entry) + try: + yield + finally: + try: + sys.path.remove(entry) + except ValueError: + pass + + +def _exit_code_from(exc: SystemExit) -> int: + # Same semantics as tracing a script directly: sys.exit() -> 0, + # sys.exit(7) -> 7, sys.exit("msg") -> message on stderr, code 1 + if exc.code is None: + return 0 + if isinstance(exc.code, int): + return exc.code + print(exc.code, file=sys.stderr) + return 1 + + +def _run_module(module: str, args: List[str]) -> int: + # Mirror `python -m`: cwd is importable + with _patched_argv([module] + args), _prepended_path(os.getcwd()): + try: + runpy.run_module(module, run_name="__main__", alter_sys=True) + except SystemExit as e: + return _exit_code_from(e) + return 0 + + +def _run_script(script: str, args: List[str]) -> int: + if not os.path.exists(script): + raise UnsupportedCommandError(f"script not found: {script}") + script = os.path.abspath(script) + # Mirror `python script.py`: script dir is importable + with _patched_argv([script] + args), _prepended_path(os.path.dirname(script)): + try: + runpy.run_path(script, run_name="__main__") + except SystemExit as e: + return _exit_code_from(e) + return 0 + + +def _run_pytest(args: List[str]) -> int: + try: + import pytest + except ImportError: + raise UnsupportedCommandError( + "pytest is not installed. Install it with: pip install pytest" + ) + with _patched_argv(["pytest"] + args): + return int(pytest.main(args)) + + +def execute_command(command: List[str]) -> int: + """Run a command in-process, returning its exit code. + + Raises UnsupportedCommandError for invocations that cannot be traced + in-process (non-Python binaries, `python -c`, missing scripts). + """ + if not command: + raise UnsupportedCommandError("no command specified") + + head, *rest = command + + if head == "pytest": + return _run_pytest(rest) + + if head == "-m": + if not rest: + raise UnsupportedCommandError("'-m' requires a module name") + return _run_module(rest[0], rest[1:]) + + if _is_python_launcher(head): + if not rest: + raise UnsupportedCommandError( + "interactive interpreter cannot be traced; pass a script or -m " + ) + sub, *sub_rest = rest + if sub == "-m": + if not sub_rest: + raise UnsupportedCommandError("'-m' requires a module name") + return _run_module(sub_rest[0], sub_rest[1:]) + if sub == "-c": + raise UnsupportedCommandError( + "'python -c' is not supported; put the code in a script file" + ) + if sub.startswith("-"): + raise UnsupportedCommandError( + f"unsupported interpreter option '{sub}'; " + "use 'python ' or 'python -m '" + ) + return _run_script(sub, sub_rest) + + if head.endswith(".py"): + return _run_script(head, rest) + + raise UnsupportedCommandError( + f"cannot trace '{head}' in-process. The tracer only observes the current " + "Python process, so external binaries would produce an empty trace. " + "Supported: pytest, python , python -m , " + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index aec4325..eab5095 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -690,10 +690,11 @@ def test_run_missing_command(monkeypatch, capsys): def test_run_unsupported_command(monkeypatch, capsys): + # non-Python binaries can't be traced in-process and are rejected exit_code = _run_cli(monkeypatch, ["oracletrace", "run", "--", "mycmd"]) captured = capsys.readouterr() assert exit_code == 1 - assert "unsupported command" in captured.err + assert "cannot trace 'mycmd'" in captured.err def test_run_pytest_basic(monkeypatch, empty_trace_data): diff --git a/tests/test_run_command.py b/tests/test_run_command.py new file mode 100644 index 0000000..b121f28 --- /dev/null +++ b/tests/test_run_command.py @@ -0,0 +1,170 @@ +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from oracletrace.runner import _is_python_launcher, execute_command, UnsupportedCommandError + +REPO_ROOT = Path(__file__).resolve().parents[1] + +SCRIPT = """\ +def work(): + return sum(range(50_000)) + +def main(): + return work() + +main() +""" + +TEST_FILE = """\ +def helper(): + return sum(range(1000)) + +def test_fast(): + assert helper() > 0 +""" + + +def _run(args, cwd): + env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)} + # keep third-party pytest plugins on the host machine out of the + # inner pytest.main run — they aren't part of what's being tested + env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + return subprocess.run( + [sys.executable, "-m", "oracletrace", "run", *args], + capture_output=True, + text=True, + cwd=str(cwd), + env=env, + ) + + +class TestPythonScriptForm: + def test_python_script(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "script.py").write_text(SCRIPT, encoding="utf-8") + result = _run(["--", "python", "script.py"], cwd=tmp) + assert result.returncode == 0, result.stderr + assert "work" in result.stdout + + def test_bare_script_shorthand(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "script.py").write_text(SCRIPT, encoding="utf-8") + result = _run(["--", "script.py"], cwd=tmp) + assert result.returncode == 0, result.stderr + assert "work" in result.stdout + + def test_script_args_reach_the_script(self): + code = "import sys\nprint('ARGS:' + ','.join(sys.argv[1:]))\n" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "script.py").write_text(code, encoding="utf-8") + result = _run(["--", "python", "script.py", "alpha", "beta"], cwd=tmp) + assert result.returncode == 0, result.stderr + assert "ARGS:alpha,beta" in result.stdout + + def test_exit_code_propagates(self): + code = SCRIPT + "import sys\nsys.exit(5)\n" + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "script.py").write_text(code, encoding="utf-8") + result = _run(["--", "python", "script.py"], cwd=tmp) + assert result.returncode == 5, result.stderr + assert "work" in result.stdout # trace still reported + + def test_json_export_works(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "script.py").write_text(SCRIPT, encoding="utf-8") + result = _run(["--json", "t.json", "--", "python", "script.py"], cwd=tmp) + trace = Path(tmp) / "t.json" + assert trace.exists(), result.stderr + assert "work" in trace.read_text(encoding="utf-8") + assert result.returncode == 0 + + def test_missing_script_fails_cleanly(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + result = _run(["--", "python", "ghost.py"], cwd=tmp) + assert result.returncode == 1 + assert "script not found" in result.stderr + + +class TestModuleForm: + def test_python_dash_m_module(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "myjob.py").write_text(SCRIPT, encoding="utf-8") + result = _run(["--", "python", "-m", "myjob"], cwd=tmp) + assert result.returncode == 0, result.stderr + assert "work" in result.stdout + + def test_dash_m_shorthand(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "myjob.py").write_text(SCRIPT, encoding="utf-8") + result = _run(["--", "-m", "myjob"], cwd=tmp) + assert result.returncode == 0, result.stderr + assert "work" in result.stdout + + +class TestPytestForm: + def test_pytest_still_works(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + (Path(tmp) / "test_fast.py").write_text(TEST_FILE, encoding="utf-8") + result = _run(["--", "pytest", "test_fast.py", "-p", "no:cacheprovider"], cwd=tmp) + assert result.returncode == 0, result.stderr + result.stdout + assert "1 passed" in result.stdout + + +class TestRejections: + def test_non_python_binary_rejected(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + result = _run(["--", "node", "app.js"], cwd=tmp) + assert result.returncode == 1 + assert "cannot trace" in result.stderr + + def test_python_dash_c_rejected(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + result = _run(["--", "python", "-c", "print(1)"], cwd=tmp) + assert result.returncode == 1 + assert "not supported" in result.stderr + + def test_bare_python_rejected(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + result = _run(["--", "python"], cwd=tmp) + assert result.returncode == 1 + assert "interactive interpreter" in result.stderr + + +class TestLauncherDetection: + @pytest.mark.parametrize("token", [ + "python", "python3", "python3.12", "py", + "python.exe", "PYTHON3.EXE", "/usr/bin/python3", + "C:\\Python311\\python.exe", + ]) + def test_python_launchers_recognized(self, token): + assert _is_python_launcher(token) + + @pytest.mark.parametrize("token", ["node", "cargo", "make", "pypy-lookalike.js"]) + def test_non_python_not_recognized(self, token): + assert not _is_python_launcher(token) + + +class TestExecuteCommandUnit: + def test_empty_command_raises(self): + with pytest.raises(UnsupportedCommandError, match="no command"): + execute_command([]) + + def test_dash_m_without_module_raises(self): + with pytest.raises(UnsupportedCommandError, match="requires a module name"): + execute_command(["-m"]) + + def test_argv_restored_after_run(self, tmp_path, monkeypatch): + monkeypatch.chdir(REPO_ROOT) # same drive as the package + script = REPO_ROOT / "_argv_check_tmp.py" + script.write_text("pass\n", encoding="utf-8") + try: + before = list(sys.argv) + execute_command(["python", str(script)]) + assert sys.argv == before + finally: + script.unlink()