Skip to content

Commit 305c18e

Browse files
committed
WS7 - Test coverage & Tooling Enforcement
1 parent 003935f commit 305c18e

File tree

8 files changed

+406
-9
lines changed

8 files changed

+406
-9
lines changed

Justfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ cargo-test:
4242

4343
py-test:
4444
uv run --group dev --group test pytest codetracer-python-recorder/tests/python codetracer-pure-python-recorder
45+
46+
lint: lint-rust lint-errors
47+
48+
lint-rust:
49+
uv run cargo clippy --manifest-path codetracer-python-recorder/Cargo.toml --workspace --no-default-features -- -D clippy::panic
50+
51+
lint-errors:
52+
uv run python3 codetracer-python-recorder/scripts/lint_no_unwraps.py
4553

4654
# Run tests only on the pure recorder
4755
test-pure:

codetracer-python-recorder/crates/recorder-errors/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,26 @@ mod tests {
621621
assert_eq!(err.code, ErrorCode::FrameIntrospectionFailed);
622622
}
623623

624+
#[test]
625+
fn target_macro_marks_target_error() {
626+
let err = target!(ErrorCode::TraceIncomplete, "target callback failed");
627+
assert_eq!(err.kind, ErrorKind::Target);
628+
assert_eq!(err.code, ErrorCode::TraceIncomplete);
629+
}
630+
631+
#[test]
632+
fn ensure_internal_marks_internal_failures() {
633+
fn guarded(assert_ok: bool) -> RecorderResult<()> {
634+
ensure_internal!(assert_ok, ErrorCode::TraceIncomplete, "invariant broken");
635+
Ok(())
636+
}
637+
638+
let err = guarded(false).expect_err("expected invariant failure");
639+
assert_eq!(err.kind, ErrorKind::Internal);
640+
assert_eq!(err.code, ErrorCode::TraceIncomplete);
641+
guarded(true).expect("guarded success");
642+
}
643+
624644
#[test]
625645
fn parse_roundtrip_matches_known_codes() {
626646
for code in [
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
"""Fail the build when unchecked `.unwrap(` usage appears outside the allowlist."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import pathlib
7+
import sys
8+
from typing import Iterable
9+
10+
DEFAULT_ALLOWLIST = {
11+
pathlib.Path("codetracer-python-recorder/src/runtime/value_encoder.rs"),
12+
pathlib.Path("codetracer-python-recorder/src/runtime/mod.rs"),
13+
pathlib.Path("codetracer-python-recorder/src/monitoring/mod.rs"),
14+
pathlib.Path("codetracer-python-recorder/src/monitoring/tracer.rs"),
15+
}
16+
17+
18+
def scan_for_unsafe_unwraps(root: pathlib.Path, allowlist: Iterable[pathlib.Path]) -> list[pathlib.Path]:
19+
repo_root = root.resolve()
20+
src_root = repo_root / "codetracer-python-recorder" / "src"
21+
allowed = {path.resolve() for path in (repo_root / entry for entry in allowlist)}
22+
23+
failures: list[pathlib.Path] = []
24+
for candidate in src_root.rglob("*.rs"):
25+
if candidate in allowed:
26+
continue
27+
text = candidate.read_text()
28+
if ".unwrap(" in text:
29+
failures.append(candidate.relative_to(repo_root))
30+
return failures
31+
32+
33+
def main() -> int:
34+
parser = argparse.ArgumentParser(description=__doc__)
35+
parser.add_argument(
36+
"--repo-root",
37+
type=pathlib.Path,
38+
default=pathlib.Path(__file__).resolve().parents[2],
39+
help="Path to the repository root (auto-detected by default)",
40+
)
41+
parser.add_argument(
42+
"--allow",
43+
action="append",
44+
dest="allow",
45+
default=[str(path) for path in DEFAULT_ALLOWLIST],
46+
help="Additional relative paths that may contain unwrap usage",
47+
)
48+
args = parser.parse_args()
49+
50+
allowlist = [pathlib.Path(entry) for entry in args.allow]
51+
failures = scan_for_unsafe_unwraps(args.repo_root, allowlist)
52+
if failures:
53+
print("Found disallowed `.unwrap(` usage in the recorder crate:", file=sys.stderr)
54+
for failure in sorted(failures):
55+
print(f" - {failure}", file=sys.stderr)
56+
return 1
57+
return 0
58+
59+
60+
if __name__ == "__main__":
61+
sys.exit(main())

codetracer-python-recorder/src/ffi.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,105 @@ where
136136
Err(panic_payload) => Err(handle_panic(label, panic_payload)),
137137
}
138138
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use super::*;
143+
use recorder_errors::{enverr, target, usage};
144+
145+
#[test]
146+
fn map_recorder_error_sets_python_attributes() {
147+
Python::with_gil(|py| {
148+
let err = usage!(
149+
ErrorCode::UnsupportedFormat,
150+
"invalid trace format"
151+
)
152+
.with_context("format", "yaml")
153+
.with_source(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
154+
let pyerr = map_recorder_error(err);
155+
let ty = pyerr.get_type(py);
156+
assert!(ty.is(py.get_type::<PyUsageError>()));
157+
let value = pyerr.value(py);
158+
assert_eq!(
159+
value
160+
.getattr("code")
161+
.expect("error code attribute")
162+
.extract::<String>()
163+
.expect("code string"),
164+
"ERR_UNSUPPORTED_FORMAT"
165+
);
166+
assert_eq!(
167+
value
168+
.getattr("kind")
169+
.expect("error kind attribute")
170+
.extract::<String>()
171+
.expect("kind string"),
172+
"Usage"
173+
);
174+
let context_obj = value.getattr("context").expect("context attribute");
175+
let ctx = context_obj
176+
.downcast::<PyDict>()
177+
.expect("context attribute downcast");
178+
let format_value = ctx
179+
.get_item("format")
180+
.expect("context lookup failed")
181+
.expect("context map missing 'format'");
182+
assert_eq!(
183+
format_value
184+
.extract::<String>()
185+
.expect("format value extraction"),
186+
"yaml"
187+
);
188+
});
189+
}
190+
191+
#[test]
192+
fn dispatch_converts_recorder_error_to_pyerr() {
193+
Python::with_gil(|py| {
194+
let result: PyResult<()> = dispatch("dispatch_env", || {
195+
Err(enverr!(ErrorCode::Io, "disk full"))
196+
});
197+
let err = result.expect_err("expected PyErr");
198+
let ty = err.get_type(py);
199+
assert!(ty.is(py.get_type::<PyEnvironmentError>()));
200+
});
201+
}
202+
203+
#[test]
204+
fn dispatch_converts_panic_into_internal_error() {
205+
Python::with_gil(|py| {
206+
let result: PyResult<()> = dispatch("dispatch_panic", || panic!("boom"));
207+
let err = result.expect_err("expected panic to map into PyErr");
208+
let ty = err.get_type(py);
209+
assert!(ty.is(py.get_type::<PyInternalError>()));
210+
assert!(err.to_string().contains("panic in dispatch_panic"));
211+
});
212+
}
213+
214+
#[test]
215+
fn wrap_pyfunction_passes_through_success() {
216+
let result = wrap_pyfunction("wrap_ok", || Ok::<_, PyErr>(42));
217+
assert_eq!(result.expect("expected success"), 42);
218+
}
219+
220+
#[test]
221+
fn wrap_pyfunction_converts_errors_and_panics() {
222+
Python::with_gil(|py| {
223+
let err = wrap_pyfunction("wrap_error", || -> PyResult<()> {
224+
Err(map_recorder_error(target!(
225+
ErrorCode::TraceIncomplete,
226+
"target failure"
227+
)))
228+
})
229+
.expect_err("expected error");
230+
assert!(err.get_type(py).is(py.get_type::<PyTargetError>()));
231+
232+
let panic_err = wrap_pyfunction("wrap_panic", || -> PyResult<()> {
233+
panic!("boom");
234+
})
235+
.expect_err("expected panic");
236+
assert!(panic_err.get_type(py).is(py.get_type::<PyInternalError>()));
237+
assert!(panic_err.to_string().contains("panic in wrap_panic"));
238+
});
239+
}
240+
}

codetracer-python-recorder/src/policy.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,19 @@ mod tests {
351351
reset_policy();
352352
}
353353

354+
#[test]
355+
fn configure_policy_from_env_rejects_invalid_boolean() {
356+
reset_policy();
357+
let env_guard = env_lock();
358+
env::set_var(ENV_REQUIRE_TRACE, "sometimes");
359+
360+
let err = configure_policy_from_env().expect_err("invalid bool should error");
361+
assert_eq!(err.code, ErrorCode::InvalidPolicyValue);
362+
363+
drop(env_guard);
364+
reset_policy();
365+
}
366+
354367
fn env_lock() -> EnvGuard {
355368
EnvGuard
356369
}

codetracer-python-recorder/src/runtime/mod.rs

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use std::sync::OnceLock;
2525
use pyo3::prelude::*;
2626
use pyo3::types::PyAny;
2727

28-
use recorder_errors::{bug, enverr, usage, ErrorCode, RecorderResult};
28+
use recorder_errors::{bug, enverr, target, usage, ErrorCode, RecorderResult};
2929
use runtime_tracing::NonStreamingTraceWriter;
3030
use runtime_tracing::{Line, TraceEventsFileFormat, TraceWriter};
3131

@@ -97,6 +97,8 @@ impl FailureStage {
9797
enum FailureMode {
9898
Stage(FailureStage),
9999
SuppressEvents,
100+
TargetArgs,
101+
Panic,
100102
}
101103

102104
#[cfg(feature = "integration-test")]
@@ -116,26 +118,46 @@ fn configured_failure_mode() -> Option<FailureMode> {
116118
"line" => Some(FailureMode::Stage(FailureStage::Line)),
117119
"finish" => Some(FailureMode::Stage(FailureStage::Finish)),
118120
"suppress-events" | "suppress_events" | "suppress" => Some(FailureMode::SuppressEvents),
121+
"target" | "target-args" | "target_args" => Some(FailureMode::TargetArgs),
122+
"panic" | "panic-callback" | "panic_callback" => Some(FailureMode::Panic),
119123
_ => None,
120124
})
121125
})
122126
}
123127

124128
#[cfg(feature = "integration-test")]
125129
fn should_inject_failure(stage: FailureStage) -> bool {
126-
match configured_failure_mode() {
127-
Some(FailureMode::Stage(mode)) if mode == stage => {
128-
!FAILURE_TRIGGERED.swap(true, Ordering::SeqCst)
129-
}
130-
_ => false,
131-
}
130+
matches!(configured_failure_mode(), Some(FailureMode::Stage(mode)) if mode == stage)
131+
&& mark_failure_triggered()
132132
}
133133

134134
#[cfg(not(feature = "integration-test"))]
135135
fn should_inject_failure(_stage: FailureStage) -> bool {
136136
false
137137
}
138138

139+
#[cfg(feature = "integration-test")]
140+
fn should_inject_target_error() -> bool {
141+
matches!(configured_failure_mode(), Some(FailureMode::TargetArgs))
142+
&& mark_failure_triggered()
143+
}
144+
145+
#[cfg(not(feature = "integration-test"))]
146+
fn should_inject_target_error() -> bool {
147+
false
148+
}
149+
150+
#[cfg(feature = "integration-test")]
151+
fn should_panic_in_callback() -> bool {
152+
matches!(configured_failure_mode(), Some(FailureMode::Panic)) && mark_failure_triggered()
153+
}
154+
155+
#[cfg(not(feature = "integration-test"))]
156+
#[allow(dead_code)]
157+
fn should_panic_in_callback() -> bool {
158+
false
159+
}
160+
139161
#[cfg(feature = "integration-test")]
140162
fn suppress_events() -> bool {
141163
matches!(configured_failure_mode(), Some(FailureMode::SuppressEvents))
@@ -146,6 +168,17 @@ fn suppress_events() -> bool {
146168
false
147169
}
148170

171+
#[cfg(feature = "integration-test")]
172+
fn mark_failure_triggered() -> bool {
173+
!FAILURE_TRIGGERED.swap(true, Ordering::SeqCst)
174+
}
175+
176+
#[cfg(not(feature = "integration-test"))]
177+
#[allow(dead_code)]
178+
fn mark_failure_triggered() -> bool {
179+
false
180+
}
181+
149182
#[cfg(feature = "integration-test")]
150183
fn injected_failure_err(stage: FailureStage) -> PyErr {
151184
let err = bug!(
@@ -340,6 +373,16 @@ impl Tracer for RuntimeTracer {
340373
return Err(injected_failure_err(FailureStage::PyStart));
341374
}
342375

376+
if should_inject_target_error() {
377+
return Err(ffi::map_recorder_error(
378+
target!(
379+
ErrorCode::TraceIncomplete,
380+
"test-injected target error from capture_call_arguments"
381+
)
382+
.with_context("injection_stage", "capture_call_arguments"),
383+
));
384+
}
385+
343386
log_event(py, code, "on_py_start", None);
344387

345388
if let Ok(fid) = self.ensure_function_id(py, code) {
@@ -381,6 +424,13 @@ impl Tracer for RuntimeTracer {
381424
return Err(injected_failure_err(FailureStage::Line));
382425
}
383426

427+
#[cfg(feature = "integration-test")]
428+
{
429+
if should_panic_in_callback() {
430+
panic!("test-injected panic in on_line");
431+
}
432+
}
433+
384434
log_event(py, code, "on_line", Some(lineno));
385435

386436
if let Ok(filename) = code.filename(py) {

0 commit comments

Comments
 (0)