Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 12 additions & 28 deletions oracletrace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 <script.py>, "
"python -m <module>, <script.py>. "
"Use -- to separate oracletrace options from the command.",
)

parsed: Namespace = parser.parse_args(argv)
Expand All @@ -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__":
Expand Down
156 changes: 156 additions & 0 deletions oracletrace/runner.py
Original file line number Diff line number Diff line change
@@ -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 <module> [args] -> runpy.run_module()
python <script.py> [args] -> runpy.run_path()
-m <module> [args] -> runpy.run_module()
<script.py> [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 <module>"
)
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 <script.py>' or 'python -m <module>'"
)
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 <script.py>, python -m <module>, <script.py>"
)
3 changes: 2 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading