Skip to content

Commit 4876258

Browse files
committed
feat: add Python tracing API with Rust placeholders
1 parent a855503 commit 4876258

File tree

4 files changed

+251
-7
lines changed

4 files changed

+251
-7
lines changed
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
Implement the Python API described in the design document for the Rust-based module. Write tests. Don't actually implement tracing using `runtime_tracing` yet, just add placeholders.
1+
Implement the Python API described in the design document for the Rust-based module. Write tests. Don't actually implement tracing using `runtime_tracing` yet, just add placeholders.
2+
--- FOLLOW UP TASK ---
3+
Implement the Python API described in the design document for the Rust-based module. Write tests. Don't actually implement tracing using runtime_tracing yet, just add placeholders.

codetracer/__init__.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""High-level tracing API built on a Rust backend.
2+
3+
This module exposes a minimal interface for starting and stopping
4+
runtime traces. The heavy lifting is delegated to the
5+
`codetracer_python_recorder` Rust extension which will eventually hook
6+
into `runtime_tracing` and `sys.monitoring`. For now the Rust side only
7+
maintains placeholder state and performs no actual tracing.
8+
"""
9+
from __future__ import annotations
10+
11+
import contextlib
12+
import os
13+
from pathlib import Path
14+
from typing import Iterable, Iterator, Optional
15+
16+
from codetracer_python_recorder import (
17+
flush_tracing as _flush_backend,
18+
is_tracing as _is_tracing_backend,
19+
start_tracing as _start_backend,
20+
stop_tracing as _stop_backend,
21+
)
22+
23+
TRACE_BINARY: str = "binary"
24+
TRACE_JSON: str = "json"
25+
DEFAULT_FORMAT: str = TRACE_BINARY
26+
27+
_active_session: Optional["TraceSession"] = None
28+
29+
30+
def _normalize_source_roots(source_roots: Iterable[os.PathLike | str] | None) -> Optional[list[str]]:
31+
if source_roots is None:
32+
return None
33+
return [str(Path(p)) for p in source_roots]
34+
35+
36+
def start(
37+
path: os.PathLike | str,
38+
*,
39+
format: str = DEFAULT_FORMAT,
40+
capture_values: bool = True,
41+
source_roots: Iterable[os.PathLike | str] | None = None,
42+
) -> "TraceSession":
43+
"""Start a global trace session.
44+
45+
Parameters mirror the design document. The current implementation
46+
merely records the active state on the Rust side and performs no
47+
tracing.
48+
"""
49+
global _active_session
50+
if _is_tracing_backend():
51+
raise RuntimeError("tracing already active")
52+
53+
trace_path = Path(path)
54+
_start_backend(str(trace_path), format, capture_values, _normalize_source_roots(source_roots))
55+
session = TraceSession(path=trace_path, format=format)
56+
_active_session = session
57+
return session
58+
59+
60+
def stop() -> None:
61+
"""Stop the active trace session if one is running."""
62+
global _active_session
63+
if not _is_tracing_backend():
64+
return
65+
_stop_backend()
66+
_active_session = None
67+
68+
69+
def is_tracing() -> bool:
70+
"""Return ``True`` when a trace session is active."""
71+
return _is_tracing_backend()
72+
73+
74+
def flush() -> None:
75+
"""Flush buffered trace data.
76+
77+
With the current placeholder implementation this is a no-op but the
78+
function is provided to match the planned public API.
79+
"""
80+
if _is_tracing_backend():
81+
_flush_backend()
82+
83+
84+
@contextlib.contextmanager
85+
def trace(
86+
path: os.PathLike | str,
87+
*,
88+
format: str = DEFAULT_FORMAT,
89+
capture_values: bool = True,
90+
source_roots: Iterable[os.PathLike | str] | None = None,
91+
) -> Iterator["TraceSession"]:
92+
"""Context manager helper for scoped tracing."""
93+
session = start(
94+
path,
95+
format=format,
96+
capture_values=capture_values,
97+
source_roots=source_roots,
98+
)
99+
try:
100+
yield session
101+
finally:
102+
session.stop()
103+
104+
105+
class TraceSession:
106+
"""Handle representing a live tracing session."""
107+
108+
path: Path
109+
format: str
110+
111+
def __init__(self, path: Path, format: str) -> None:
112+
self.path = path
113+
self.format = format
114+
115+
def stop(self) -> None:
116+
"""Stop this trace session."""
117+
if _active_session is self:
118+
stop()
119+
120+
def flush(self) -> None:
121+
"""Flush buffered trace data for this session."""
122+
flush()
123+
124+
def __enter__(self) -> "TraceSession":
125+
return self
126+
127+
def __exit__(self, exc_type, exc, tb) -> None: # pragma: no cover - thin wrapper
128+
self.stop()
129+
130+
131+
def _auto_start_from_env() -> None:
132+
path = os.getenv("CODETRACER_TRACE")
133+
if not path:
134+
return
135+
fmt = os.getenv("CODETRACER_FORMAT", DEFAULT_FORMAT)
136+
capture_env = os.getenv("CODETRACER_CAPTURE_VALUES")
137+
capture = True
138+
if capture_env is not None:
139+
capture = capture_env.lower() not in {"0", "false", "no"}
140+
start(path, format=fmt, capture_values=capture)
141+
142+
143+
_auto_start_from_env()
144+
145+
__all__ = [
146+
"TraceSession",
147+
"DEFAULT_FORMAT",
148+
"TRACE_BINARY",
149+
"TRACE_JSON",
150+
"start",
151+
"stop",
152+
"is_tracing",
153+
"trace",
154+
"flush",
155+
]
Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,58 @@
1+
use std::sync::atomic::{AtomicBool, Ordering};
2+
3+
use pyo3::exceptions::PyRuntimeError;
14
use pyo3::prelude::*;
25

3-
/// codetracer_python_recorder
4-
///
5-
/// Minimal placeholder for the Rust-backed recorder. This exposes a trivial
6-
/// function to verify the module builds and imports successfully.
6+
/// Global flag tracking whether tracing is active.
7+
static ACTIVE: AtomicBool = AtomicBool::new(false);
8+
9+
/// Start tracing. Placeholder implementation that simply flips the
10+
/// global active flag and ignores all parameters.
11+
#[pyfunction]
12+
fn start_tracing(
13+
_path: &str,
14+
_format: &str,
15+
_capture_values: bool,
16+
_source_roots: Option<Vec<String>>,
17+
) -> PyResult<()> {
18+
if ACTIVE.swap(true, Ordering::SeqCst) {
19+
return Err(PyRuntimeError::new_err("tracing already active"));
20+
}
21+
Ok(())
22+
}
23+
24+
/// Stop tracing by resetting the global flag.
25+
#[pyfunction]
26+
fn stop_tracing() -> PyResult<()> {
27+
ACTIVE.store(false, Ordering::SeqCst);
28+
Ok(())
29+
}
30+
31+
/// Query whether tracing is currently active.
32+
#[pyfunction]
33+
fn is_tracing() -> PyResult<bool> {
34+
Ok(ACTIVE.load(Ordering::SeqCst))
35+
}
36+
37+
/// Flush buffered trace data. No-op placeholder for now.
38+
#[pyfunction]
39+
fn flush_tracing() -> PyResult<()> {
40+
Ok(())
41+
}
42+
43+
/// Trivial function kept for smoke tests verifying the module builds.
744
#[pyfunction]
845
fn hello() -> PyResult<String> {
946
Ok("Hello from codetracer-python-recorder (Rust)".to_string())
1047
}
1148

49+
/// Python module definition.
1250
#[pymodule]
1351
fn codetracer_python_recorder(_py: Python<'_>, m: Bound<'_, PyModule>) -> PyResult<()> {
14-
let hello_fn = wrap_pyfunction!(hello, &m)?;
15-
m.add_function(hello_fn)?;
52+
m.add_function(wrap_pyfunction!(start_tracing, &m)?)?;
53+
m.add_function(wrap_pyfunction!(stop_tracing, &m)?)?;
54+
m.add_function(wrap_pyfunction!(is_tracing, &m)?)?;
55+
m.add_function(wrap_pyfunction!(flush_tracing, &m)?)?;
56+
m.add_function(wrap_pyfunction!(hello, &m)?)?;
1657
Ok(())
1758
}

tests/test_codetracer_api.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import os
2+
import subprocess
3+
import sys
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
8+
import codetracer
9+
10+
11+
class TracingApiTests(unittest.TestCase):
12+
def setUp(self) -> None: # ensure clean state before each test
13+
codetracer.stop()
14+
15+
def test_start_stop_and_status(self) -> None:
16+
with tempfile.TemporaryDirectory() as tmpdir:
17+
trace_path = Path(tmpdir) / "trace.bin"
18+
session = codetracer.start(trace_path)
19+
self.assertTrue(codetracer.is_tracing())
20+
self.assertIsInstance(session, codetracer.TraceSession)
21+
self.assertEqual(session.path, trace_path)
22+
self.assertEqual(session.format, codetracer.DEFAULT_FORMAT)
23+
codetracer.flush() # should not raise
24+
session.flush() # same
25+
session.stop()
26+
self.assertFalse(codetracer.is_tracing())
27+
28+
def test_context_manager(self) -> None:
29+
with tempfile.TemporaryDirectory() as tmpdir:
30+
trace_path = Path(tmpdir) / "trace.bin"
31+
with codetracer.trace(trace_path) as session:
32+
self.assertTrue(codetracer.is_tracing())
33+
self.assertIsInstance(session, codetracer.TraceSession)
34+
self.assertFalse(codetracer.is_tracing())
35+
36+
def test_environment_auto_start(self) -> None:
37+
script = "import codetracer, sys; sys.stdout.write(str(codetracer.is_tracing()))"
38+
with tempfile.TemporaryDirectory() as tmpdir:
39+
env = os.environ.copy()
40+
env["CODETRACER_TRACE"] = str(Path(tmpdir) / "trace.bin")
41+
out = subprocess.check_output([sys.executable, "-c", script], env=env)
42+
self.assertEqual(out.decode(), "True")
43+
44+
45+
if __name__ == "__main__":
46+
unittest.main()

0 commit comments

Comments
 (0)