Skip to content

Commit b7aaed1

Browse files
authored
Sync with codetracer main (#49)
Changes which are part of integrating with `ct record` - part 1. Part 2 is in the main codetracer repo
2 parents 26e5dca + 0e6c7fd commit b7aaed1

File tree

9 files changed

+535
-84
lines changed

9 files changed

+535
-84
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Codetracer Python Recorder
2+
3+
`codetracer-python-recorder` is the Rust-backed recorder module that powers Python
4+
tracing inside Codetracer. The PyO3 extension exposes a small Python façade so
5+
packaged environments (desktop bundles, `uv run`, virtualenvs) can start and stop
6+
recording without shipping an additional interpreter.
7+
8+
## Command-line entry point
9+
10+
The wheel installs a console script named `codetracer-python-recorder` and the
11+
package can also be invoked with `python -m codetracer_python_recorder`. Both
12+
forms share the same arguments:
13+
14+
```bash
15+
python -m codetracer_python_recorder \
16+
--trace-dir ./trace-out \
17+
--format json \
18+
--activation-path app/main.py \
19+
--with-diff \
20+
app/main.py --arg=value
21+
```
22+
23+
- `--trace-dir` (default: `./trace-out`) – directory that will receive
24+
`trace.json`, `trace_metadata.json`, and `trace_paths.json`.
25+
- `--format` – trace serialisation format (`binary` or `json`). Use `json` for
26+
integration with the DB backend importer.
27+
- `--activation-path` – optional gate that postpones tracing until the interpreter
28+
executes this file (defaults to the target script).
29+
- `--with-diff` / `--no-with-diff` – records the caller’s preference in
30+
`trace_metadata.json`. The desktop Codetracer CLI is responsible for generating
31+
diff artefacts; the recorder simply surfaces the flag.
32+
33+
All additional arguments are forwarded to the target script unchanged. The CLI
34+
reuses whichever interpreter launches it so wrappers such as `uv run`, `pipx`,
35+
or activated virtual environments behave identically to `python script.py`.
36+
37+
## Packaging expectations
38+
39+
Desktop installers add the wheel to `PYTHONPATH` before invoking the user’s
40+
interpreter. When embedding the recorder elsewhere, ensure the wheel (or its
41+
extracted site-packages directory) is discoverable on `sys.path` and run the CLI
42+
with the interpreter you want to trace.
43+
44+
The CLI writes recorder metadata into `trace_metadata.json` describing the wheel
45+
version, target script, and diff preference so downstream tooling can make
46+
decisions without re-running the trace.

codetracer-python-recorder/codetracer_python_recorder/__main__.py

Lines changed: 2 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,7 @@
1-
"""CLI to record a trace while running a Python script.
2-
3-
Usage:
4-
python -m codetracer_python_recorder [codetracer options] <script.py> [script args...]
5-
6-
Codetracer options (must appear before the script path):
7-
--codetracer-trace PATH Output events file (default: trace.bin or trace.json)
8-
--codetracer-format {binary,json} Output format (default: binary)
9-
--codetracer-capture-values BOOL Whether to capture values (default: true)
10-
11-
Examples:
12-
python -m codetracer_python_recorder --codetracer-format=json app.py --flag=1
13-
python -m codetracer_python_recorder --codetracer-trace=out.bin script.py --x=2
14-
python -m codetracer_python_recorder --codetracer-capture-values=false script.py
15-
"""
1+
"""Thin wrapper for running the recorder CLI via ``python -m``."""
162
from __future__ import annotations
173

18-
import runpy
19-
import sys
20-
from pathlib import Path
21-
22-
from . import DEFAULT_FORMAT, start, stop
23-
import argparse
24-
25-
26-
def _default_trace_path(fmt: str) -> Path:
27-
# Keep a simple filename; Rust side derives sidecars (metadata/paths)
28-
if fmt == "json":
29-
return Path.cwd() / "trace.json"
30-
return Path.cwd() / "trace.bin"
31-
32-
33-
def main(argv: list[str] | None = None) -> int:
34-
if argv is None:
35-
argv = sys.argv[1:]
36-
37-
parser = argparse.ArgumentParser(add_help=True)
38-
parser.add_argument(
39-
"--codetracer-trace",
40-
dest="trace",
41-
default=None,
42-
help="Path to trace folder. If omitted, defaults to trace.bin or trace.json in the current directory based on --codetracer-format.",
43-
)
44-
parser.add_argument(
45-
"--codetracer-format",
46-
dest="format",
47-
choices=["binary", "json"],
48-
default=DEFAULT_FORMAT,
49-
help="Output format for trace events. 'binary' is compact; 'json' is human-readable. Default: %(default)s.",
50-
)
51-
# Only parse our options; leave script and script args in unknown
52-
ns, unknown = parser.parse_known_args(argv)
53-
54-
# Validate that the first unknown token is a script path; otherwise show usage.
55-
if not unknown or not Path(unknown[0]).exists():
56-
sys.stderr.write("Usage: python -m codetracer_python_recorder [codetracer options] <script.py> [args...]\n")
57-
return 2
58-
59-
script_path = Path(unknown[0]).resolve()
60-
script_args = unknown[1:]
61-
62-
fmt = ns.format or DEFAULT_FORMAT
63-
trace_path = Path(ns.trace) if ns.trace else _default_trace_path(fmt)
64-
65-
old_argv = sys.argv
66-
sys.argv = [str(script_path)] + script_args
67-
# Activate tracing only after entering the target script file.
68-
session = start(
69-
trace_path,
70-
format=fmt,
71-
start_on_enter=script_path,
72-
)
73-
try:
74-
runpy.run_path(str(script_path), run_name="__main__")
75-
return 0
76-
except SystemExit as e:
77-
# Preserve script's exit code
78-
code = e.code if isinstance(e.code, int) else 1
79-
return code
80-
finally:
81-
# Ensure tracer stops and files are flushed
82-
try:
83-
session.flush()
84-
finally:
85-
stop()
86-
sys.argv = old_argv
4+
from .cli import main
875

886

897
if __name__ == "__main__": # pragma: no cover
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Command-line interface for the Codetracer Python recorder."""
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import runpy
7+
import sys
8+
from dataclasses import dataclass
9+
from importlib import metadata
10+
from pathlib import Path
11+
from typing import Iterable, Sequence
12+
13+
from . import flush, start, stop
14+
from .formats import DEFAULT_FORMAT, SUPPORTED_FORMATS, normalize_format
15+
16+
17+
@dataclass(frozen=True)
18+
class RecorderCLIConfig:
19+
"""Resolved CLI options for a recorder invocation."""
20+
21+
trace_dir: Path
22+
format: str
23+
activation_path: Path
24+
script: Path
25+
script_args: list[str]
26+
27+
28+
def _default_trace_dir() -> Path:
29+
return Path.cwd() / "trace-out"
30+
31+
32+
def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
33+
parser = argparse.ArgumentParser(
34+
prog="codetracer_python_recorder",
35+
description=(
36+
"Record a trace for a Python script using the Codetracer runtime tracer. "
37+
"All script arguments must be provided after the script path or a '--' separator."
38+
),
39+
allow_abbrev=False,
40+
)
41+
parser.add_argument(
42+
"--trace-dir",
43+
type=Path,
44+
default=_default_trace_dir(),
45+
help=(
46+
"Directory where trace artefacts will be written "
47+
"(defaults to %(default)s relative to the current working directory)."
48+
),
49+
)
50+
parser.add_argument(
51+
"--format",
52+
default=DEFAULT_FORMAT,
53+
help=(
54+
"Trace serialisation format. Supported values: "
55+
+ ", ".join(sorted(SUPPORTED_FORMATS))
56+
+ f". Defaults to {DEFAULT_FORMAT}."
57+
),
58+
)
59+
parser.add_argument(
60+
"--activation-path",
61+
type=Path,
62+
help=(
63+
"Optional path used to gate tracing. When provided, tracing begins once the "
64+
"interpreter enters this file. Defaults to the target script."
65+
),
66+
)
67+
68+
known, remainder = parser.parse_known_args(argv)
69+
pending: list[str] = list(remainder)
70+
if not pending:
71+
parser.error("missing script to execute")
72+
73+
if pending[0] == "--":
74+
pending.pop(0)
75+
if not pending:
76+
parser.error("missing script path after '--'")
77+
78+
script_token = pending[0]
79+
script_path = Path(script_token).expanduser()
80+
if not script_path.exists():
81+
parser.error(f"script '{script_path}' does not exist")
82+
script_path = script_path.resolve()
83+
84+
script_args = pending[1:]
85+
if script_args and script_args[0] == "--":
86+
script_args = script_args[1:]
87+
88+
trace_dir = Path(known.trace_dir).expanduser().resolve()
89+
fmt = normalize_format(known.format)
90+
if fmt not in SUPPORTED_FORMATS:
91+
parser.error(
92+
f"unsupported trace format '{known.format}'. Expected one of: "
93+
+ ", ".join(sorted(SUPPORTED_FORMATS))
94+
)
95+
96+
activation_path = (
97+
Path(known.activation_path).expanduser().resolve()
98+
if known.activation_path
99+
else script_path
100+
)
101+
102+
return RecorderCLIConfig(
103+
trace_dir=trace_dir,
104+
format=fmt,
105+
activation_path=activation_path,
106+
script=script_path,
107+
script_args=script_args,
108+
)
109+
110+
111+
def _resolve_package_version() -> str | None:
112+
try:
113+
return metadata.version("codetracer-python-recorder")
114+
except metadata.PackageNotFoundError: # pragma: no cover - dev checkout
115+
return None
116+
117+
118+
def _serialise_metadata(
119+
trace_dir: Path,
120+
*,
121+
script: Path,
122+
) -> None:
123+
"""Augment trace metadata with recorder-specific information."""
124+
metadata_path = trace_dir / "trace_metadata.json"
125+
try:
126+
raw = metadata_path.read_text(encoding="utf-8")
127+
except FileNotFoundError:
128+
return
129+
130+
try:
131+
payload = json.loads(raw) if raw else {}
132+
except json.JSONDecodeError:
133+
return
134+
135+
recorder_block = payload.setdefault(
136+
"recorder",
137+
{
138+
"name": "codetracer_python_recorder",
139+
},
140+
)
141+
if isinstance(recorder_block, dict):
142+
recorder_block.setdefault("name", "codetracer_python_recorder")
143+
recorder_block["target_script"] = str(script)
144+
version = _resolve_package_version()
145+
if version:
146+
recorder_block["version"] = version
147+
else:
148+
# Unexpected schema — bail out without mutating further.
149+
return
150+
151+
metadata_path.write_text(json.dumps(payload), encoding="utf-8")
152+
153+
154+
def main(argv: Iterable[str] | None = None) -> int:
155+
"""Entry point for ``python -m codetracer_python_recorder``."""
156+
if argv is None:
157+
argv = sys.argv[1:]
158+
159+
try:
160+
config = _parse_args(list(argv))
161+
except SystemExit:
162+
# argparse already printed a helpful message; propagate exit code.
163+
raise
164+
except Exception as exc: # pragma: no cover - defensive guardrail
165+
sys.stderr.write(f"Failed to parse arguments: {exc}\n")
166+
return 2
167+
168+
trace_dir = config.trace_dir
169+
script_path = config.script
170+
script_args = config.script_args
171+
172+
old_argv = sys.argv
173+
sys.argv = [str(script_path)] + script_args
174+
175+
try:
176+
start(
177+
trace_dir,
178+
format=config.format,
179+
start_on_enter=config.activation_path,
180+
)
181+
except Exception as exc:
182+
sys.stderr.write(f"Failed to start Codetracer session: {exc}\n")
183+
sys.argv = old_argv
184+
return 1
185+
186+
exit_code: int | None = None
187+
try:
188+
try:
189+
runpy.run_path(str(script_path), run_name="__main__")
190+
except SystemExit as exc:
191+
exit_code = exc.code if isinstance(exc.code, int) else 1
192+
else:
193+
exit_code = 0
194+
finally:
195+
try:
196+
flush()
197+
finally:
198+
stop()
199+
sys.argv = old_argv
200+
201+
_serialise_metadata(trace_dir, script=script_path)
202+
203+
return exit_code if exit_code is not None else 0
204+
205+
206+
__all__ = ("main", "RecorderCLIConfig")

codetracer-python-recorder/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ classifiers = [
1212
"Programming Language :: Rust",
1313
]
1414

15+
[project.scripts]
16+
codetracer-python-recorder = "codetracer_python_recorder.cli:main"
17+
1518
[tool.maturin]
1619
# Build the PyO3 extension module
1720
bindings = "pyo3"

0 commit comments

Comments
 (0)