1use std::{
10 collections::BTreeMap,
11 ffi::OsString,
12 fs,
13 io::Write,
14 path::{Path, PathBuf},
15 time::Instant,
16};
17
18use serde::{Deserialize, Serialize};
19
20use crate::workspace::canonicalize_simplified;
21use crate::{
22 evidence_archive::write_archive,
23 frontend_protocol::validate_frontend_report_request,
24 integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
25 lifecycle::{
26 Archived, ProjectLock, finalize_published_run, note_kept_evidence, publish_archived_run,
27 recover_abandoned_runs, remove_stored_tree_deferred,
28 },
29 orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
30 process_supervision::{CommandSpec, SupervisionOptions},
31 python_evidence::{PythonFrontendRun, build_python_frontend_run},
32 python_project::{PreparedPythonProject, prepare_python_project, python_integrity_inputs},
33 run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
34};
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct DirectPythonRunRequest {
39 pub root: PathBuf,
40 pub command: Vec<String>,
41 pub run_id: String,
42 pub started_at: String,
43}
44
45#[derive(Debug, Clone, PartialEq)]
46pub struct DirectPythonRunResult {
47 pub run_id: String,
48 pub run_directory: PathBuf,
49 pub exit_code: i32,
50 pub tests: usize,
51 pub source_files: usize,
52 pub interpreters: usize,
53 pub python_versions: Vec<String>,
54 pub recovered_runs: Vec<String>,
55 pub metadata: RunMetadata,
56}
57
58fn elapsed_ms(started: Instant) -> f64 {
59 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
60}
61
62fn embedded_runtime_files() -> [(&'static str, &'static [u8]); 6] {
63 [
64 (
65 "sitecustomize.py",
66 include_bytes!("../runtime-assets/python/sitecustomize.py"),
67 ),
68 (
69 "supercov_runtime.py",
70 include_bytes!("../runtime-assets/python/supercov_runtime.py"),
71 ),
72 (
73 "supercov_pytest.py",
74 include_bytes!("../runtime-assets/python/supercov_pytest.py"),
75 ),
76 (
77 "supercov_unittest.py",
78 include_bytes!("../runtime-assets/python/supercov_unittest.py"),
79 ),
80 (
81 "supercov_probes.py",
82 include_bytes!("../runtime-assets/python/supercov_probes.py"),
83 ),
84 (
85 "supercov_instrument.py",
86 include_bytes!("../runtime-assets/python/supercov_instrument.py"),
87 ),
88 ]
89}
90
91fn write_runtime(directory: &Path) -> Result<(), String> {
92 fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
93 for (name, contents) in embedded_runtime_files() {
94 let path = directory.join(name);
95 fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
96 }
97 Ok(())
98}
99
100fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
101 for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
102 let entry = entry.map_err(|error| error.to_string())?;
103 let target = destination.join(entry.file_name());
104 if entry
105 .file_type()
106 .map_err(|error| error.to_string())?
107 .is_dir()
108 {
109 fs::create_dir_all(&target).map_err(|error| error.to_string())?;
110 copy_tree(&entry.path(), &target)?;
111 } else {
112 fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
113 }
114 }
115 Ok(())
116}
117
118fn prepend_path_list(existing: Option<OsString>, entry: &Path) -> OsString {
119 let mut value = entry.as_os_str().to_owned();
120 if let Some(existing) = existing.filter(|existing| !existing.is_empty()) {
121 value.push(if cfg!(windows) { ";" } else { ":" });
122 value.push(existing);
123 }
124 value
125}
126
127fn append_list(existing: Option<OsString>, entry: &str, separator: &str) -> OsString {
128 match existing.filter(|existing| !existing.is_empty()) {
129 Some(existing) => {
130 let mut value = existing;
131 value.push(separator);
132 value.push(entry);
133 value
134 }
135 None => entry.into(),
136 }
137}
138
139fn environment(
140 root: &Path,
141 run_id: &str,
142 runtime_directory: &Path,
143 plan_path: &Path,
144 evidence_directory: &Path,
145) -> Vec<(OsString, OsString)> {
146 let mut variables = std::env::vars_os().collect::<Vec<_>>();
147 let mut take = |key: &str| {
148 let position = variables.iter().position(|(name, _)| name == key);
149 position.map(|index| variables.remove(index).1)
150 };
151 let python_path = prepend_path_list(take("PYTHONPATH"), runtime_directory);
152 let pytest_plugins = append_list(take("PYTEST_PLUGINS"), "supercov_pytest", ",");
153 let pytest_addopts = take("PYTEST_ADDOPTS").unwrap_or_default();
161 for key in [
162 "SUPERCOV_PYTHON_PLAN",
163 "SUPERCOV_PYTHON_EVIDENCE_DIR",
164 "SUPERCOV_RUN_ID",
165 "SUPERCOV_PROJECT_ROOT",
166 "SUPERCOV_CONTEXT",
167 "SUPERCOV_PYTHON_WORKER",
168 ] {
169 take(key);
170 }
171 variables.extend([
172 ("PYTHONPATH".into(), python_path),
173 ("PYTEST_PLUGINS".into(), pytest_plugins),
174 ("PYTEST_ADDOPTS".into(), pytest_addopts),
175 (
176 "SUPERCOV_PYTHON_PLAN".into(),
177 plan_path.as_os_str().to_owned(),
178 ),
179 (
180 "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
181 evidence_directory.as_os_str().to_owned(),
182 ),
183 ("SUPERCOV_RUN_ID".into(), run_id.into()),
184 ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
185 ]);
186 variables
187}
188
189pub fn current_python_integrity(
192 root: &Path,
193 command: &[String],
194 source_roots: Option<&[String]>,
195) -> Result<crate::run_store::RunIntegrity, String> {
196 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
197 let mut files = crate::python_project::discover_python_files(&root)?;
198 if let Some(configured) = source_roots.filter(|roots| !roots.is_empty()) {
201 crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
202 .map_err(|error| error.to_string())?
203 .narrow(&mut files.sources, &mut files.excluded, String::as_str);
204 }
205 let mut inputs = python_integrity_inputs(&files, command);
206 crate::source_discovery::fold_roots_into_configuration(
207 &mut inputs.execution_configuration,
208 source_roots,
209 );
210 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_python())
211 .map_err(|error| error.to_string())
212}
213
214fn assertion_sites(files: &crate::source_manifest::Files) -> BTreeMap<String, Vec<usize>> {
219 let mut sites = BTreeMap::<String, Vec<usize>>::new();
220 for (file, text) in files.iter().filter(|(file, _)| file.ends_with(".py")) {
221 let Ok(starts) = assertion_starts(text) else {
222 continue;
223 };
224 let lines = sites.entry(file.clone()).or_default();
225 lines.extend(
226 starts
227 .into_iter()
228 .map(|start| text[..start].bytes().filter(|b| *b == b'\n').count() + 1),
229 );
230 lines.sort_unstable();
231 lines.dedup();
232 }
233 sites.retain(|_, lines| !lines.is_empty());
234 sites
235}
236
237fn assertion_starts(source: &str) -> Result<Vec<usize>, String> {
239 use ruff_python_ast::{
240 Expr, Stmt,
241 visitor::{Visitor, walk_expr, walk_stmt},
242 };
243 use ruff_text_size::Ranged;
244 struct Collector(Vec<usize>);
245 impl<'a> Visitor<'a> for Collector {
246 fn visit_stmt(&mut self, stmt: &'a Stmt) {
247 if let Stmt::Assert(_) = stmt {
248 self.0.push(stmt.range().start().to_usize());
249 }
250 walk_stmt(self, stmt);
251 }
252 fn visit_expr(&mut self, expr: &'a Expr) {
253 if let Expr::Call(call) = expr
254 && let Expr::Attribute(attr) = call.func.as_ref()
255 && attr.attr.as_str().starts_with("assert")
256 {
257 self.0.push(expr.range().start().to_usize());
258 }
259 walk_expr(self, expr);
260 }
261 }
262 let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
263 let mut collector = Collector(vec![]);
264 for stmt in &parsed.syntax().body {
265 collector.visit_stmt(stmt);
266 }
267 Ok(collector.0)
268}
269
270pub fn run_direct_python(
271 request: &DirectPythonRunRequest,
272 diagnostics: &mut dyn Write,
273) -> Result<DirectPythonRunResult, String> {
274 if request.command.is_empty() {
275 return Err("test command must not be empty".into());
276 }
277 let total_started = Instant::now();
278 let initialization_started = Instant::now();
279 let root = canonicalize_simplified(&request.root)
280 .map_err(|error| format!("{}: {error}", request.root.display()))?;
281 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
282 .map_err(|error| error.to_string())?;
283 let initialization_ms = elapsed_ms(initialization_started);
284 let work_directory = root.join(".supercov/work").join(&request.run_id);
285 let measured = std::cell::Cell::new(false);
289 let result = (|| {
290 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
291 .map_err(|error| error.to_string())?;
292 if !recovered_runs.is_empty() {
293 writeln!(
294 diagnostics,
295 "[supercov] recovered abandoned run(s): {}",
296 recovered_runs.join(", ")
297 )
298 .map_err(|error| error.to_string())?;
299 }
300
301 let adapter_started = Instant::now();
302 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
303 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
304 .map_err(|error| error.to_string())?;
305 let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
306 let source_roots = crate::source_discovery::configured_source_roots(
307 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
308 );
309 let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
310 crate::source_discovery::fold_roots_into_configuration(
311 &mut integrity_inputs.execution_configuration,
312 source_roots.as_deref(),
313 );
314 let integrity = create_explicit_run_integrity(
315 &root,
316 &integrity_inputs,
317 &FrontendIntegrityInputs::embedded_python(),
318 )
319 .map_err(|error| error.to_string())?;
320 let source_inputs = crate::source_capture::capture(
321 &root,
322 "python",
323 python_integrity_inputs(&project.files, &request.command).assertion_paths(),
324 )?;
325 let python_directory = work_directory.join("python");
326 let runtime_directory = python_directory.join("runtime");
327 let evidence_directory = python_directory.join("evidence");
328 let plan_path = python_directory.join("plan.json");
329 write_runtime(&runtime_directory)?;
330 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
331 let plan = crate::python_instrumenter::PythonProbePlan {
332 assertion_sites: assertion_sites(&source_inputs.files),
333 ..project.plan.clone()
334 };
335 fs::write(
336 &plan_path,
337 serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
338 )
339 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
340 writeln!(
341 diagnostics,
342 "[supercov] detected Python; measuring {} source file(s) in place through probes compiled at import",
343 project.plan.files.len(),
344 )
345 .map_err(|error| error.to_string())?;
346 for (file, reason) in &project.unparseable {
347 writeln!(
348 diagnostics,
349 "[supercov] could not parse {file}: {reason}; it carries no obligations"
350 )
351 .map_err(|error| error.to_string())?;
352 }
353 let adapter_setup_ms = elapsed_ms(adapter_started);
354
355 let test_started = Instant::now();
356 let plan = ExecutionPlan {
357 preparation: Vec::new(),
358 test: ExecutionPhase {
359 name: "test".into(),
360 kind: PhaseKind::Test,
361 command: CommandSpec {
362 program: request.command[0].clone().into(),
363 arguments: request.command[1..].iter().map(OsString::from).collect(),
364 cwd: root.clone(),
365 environment: Some(environment(
366 &root,
367 &request.run_id,
368 &runtime_directory,
369 &plan_path,
370 &evidence_directory,
371 )),
372 captured_output: None,
373 },
374 },
375 };
376 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
377 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
378 .map_err(|error| error.to_string())?;
379 let test_command_ms = elapsed_ms(test_started);
380 if let Some(signal) = execution.interrupted_signal {
381 return Err(format!(
382 "the test command was interrupted by {signal:?}; no run was published"
383 ));
384 }
385
386 let publication_started = Instant::now();
387 let verbose = std::env::var("SUPERCOV_VERBOSE")
388 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
389 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
390 measured.set(true);
391 let mut run: PythonFrontendRun = match build_python_frontend_run(
392 &project.manifest,
393 &evidence_directory,
394 &request.run_id,
395 &request.started_at,
396 execution.exit_code,
397 &root,
398 ) {
399 Err(
404 crate::python_evidence::PythonEvidenceError::NoTests
405 | crate::python_evidence::PythonEvidenceError::NoInterpreter,
406 ) if execution.exit_code != 0 => {
407 measured.set(false);
408 return Err(format!(
409 "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
410 execution.exit_code
411 ));
412 }
413 Err(
417 error @ (crate::python_evidence::PythonEvidenceError::UnsupportedPython(_)
418 | crate::python_evidence::PythonEvidenceError::NoTests
419 | crate::python_evidence::PythonEvidenceError::NoInterpreter),
420 ) => {
421 measured.set(false);
422 return Err(error.to_string());
423 }
424 result => result.map_err(|error| error.to_string())?,
425 };
426 validate_frontend_report_request(&run.declaration, &run.request)
427 .map_err(|error| error.to_string())?;
428 let joined_ms = elapsed_ms(publication_started);
429 let archive_path = work_directory.join("evidence.raw.gz");
430 run.request.integrity = serde_json::to_value(&integrity).ok();
434 let (archived, analysed) = std::thread::scope(|scope| {
435 let analysis = scope.spawn(|| {
436 crate::coverage_report::analyze_frontend_request(
437 &run.declaration,
438 &run.request,
439 crate::coverage_report::TransportStats::none(),
440 )
441 });
442 let archived = (|| {
443 let entries = run.archive_entries().map_err(|error| error.to_string())?;
444 let serialized_ms = elapsed_ms(publication_started) - joined_ms;
445 let entries = crate::source_capture::append(entries, &source_inputs)?;
446 let raw =
447 write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
448 let archive_ms = elapsed_ms(publication_started) - joined_ms - serialized_ms;
449 Ok::<_, String>((raw, serialized_ms, archive_ms))
450 })();
451 let analysed = analysis
452 .join()
453 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
454 (archived, analysed)
455 });
456 let analysis_ms = elapsed_ms(publication_started) - joined_ms;
457 let (raw, serialized_ms, archive_ms) = archived?;
458 let (tests, interpreters, python_versions) =
463 (run.tests, run.interpreters, run.python_versions.clone());
464 if verbose {
465 writeln!(
466 diagnostics,
467 "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={archive_ms}ms analysis={analysis_ms}ms (beside the archive)"
468 )
469 .map_err(|error| error.to_string())?;
470 }
471 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
472 let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
473 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
474 copy_tree(&python_directory, &debug_directory)?;
475 }
476 remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
477 let evidence_publication_ms = elapsed_ms(publication_started);
478 let timings = RunTimings {
479 initialization_ms,
480 workspace_preparation_ms: 0.0,
481 adapter_setup_ms,
482 instrumented_build_ms: 0.0,
483 test_command_ms,
484 evidence_publication_ms,
485 };
486 let metadata = RunMetadata {
487 id: request.run_id.clone(),
488 started_at: request.started_at.clone(),
489 duration_ms: elapsed_ms(total_started),
490 command: request.command.clone(),
491 test_exit_code: Some(execution.exit_code),
492 integrity,
493 raw_evidence: RawEvidenceMetadata {
494 schema_version: raw.schema_version,
495 format: raw.format.into(),
496 file: raw.file.into(),
497 files: raw.files,
498 uncompressed_bytes: raw.uncompressed_bytes,
499 compressed_bytes: raw.compressed_bytes,
500 },
501 isolated_build: None,
502 instrumented_build_cache: None,
503 timings: Some(timings),
504 merged: None,
505 parents: None,
506 source_roots: source_roots.clone(),
507 };
508 drop(run);
509 let run_directory = publish_archived_run(
510 &root,
511 &metadata,
512 &archive_path,
513 Archived {
514 report: analysed.ok(),
515 inputs: Some(source_inputs.manifest()),
516 },
517 )
518 .map_err(|error| error.to_string())?;
519 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
520 Ok(DirectPythonRunResult {
521 run_id: request.run_id.clone(),
522 run_directory,
523 exit_code: execution.exit_code,
524 tests,
525 source_files: project.plan.files.len(),
526 interpreters,
527 python_versions,
528 recovered_runs,
529 metadata,
530 })
531 })();
532 let result = result.map_err(|error| {
533 if !measured.get() {
534 return error;
535 }
536 note_kept_evidence(
537 &root,
538 &work_directory.join("python/evidence"),
539 &request.run_id,
540 error,
541 )
542 });
543 if result.is_err() {
544 let _ = remove_stored_tree_deferred(&root, &work_directory);
545 }
546 let release = lock.release().map_err(|error| error.to_string());
547 match (result, release) {
548 (Ok(result), Ok(())) => Ok(result),
549 (Err(error), _) => Err(error),
550 (Ok(_), Err(error)) => Err(error),
551 }
552}
553
554#[cfg(test)]
555mod tests {
556
557 #[test]
562 fn the_plan_names_each_test_files_assertion_lines_once_and_sorted() {
563 let files = crate::source_manifest::Files::from([
564 ("tests/test_b.py".to_string(), "def test_b():\n x = 1\n\n\n\n\n\n\n assert x\n".to_string()),
565 (
566 "tests/test_a.py".to_string(),
567 "import unittest\n\nassert True\n\n\n\n\n\n\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1, 1); assert 1\n".to_string(),
569 ),
570 ("src/app.py".to_string(), "def f():\n return 1\n".to_string()),
571 ("README.md".to_string(), "assert nothing\n".to_string()),
572 ]);
573 let sites = assertion_sites(&files);
574 assert_eq!(
575 sites,
576 BTreeMap::from([
577 ("tests/test_a.py".to_string(), vec![3, 12]),
578 ("tests/test_b.py".to_string(), vec![9]),
579 ])
580 );
581
582 let plan = crate::python_instrumenter::PythonProbePlan {
585 version: 1,
586 root: "/p".into(),
587 files: BTreeMap::new(),
588 assertion_sites: sites,
589 };
590 let json = serde_json::to_string(&plan).unwrap();
591 assert!(
592 json.contains("\"assertionSites\":{\"tests/test_a.py\":[3,12]"),
593 "{json}"
594 );
595 let empty = crate::python_instrumenter::PythonProbePlan {
596 assertion_sites: BTreeMap::new(),
597 ..plan
598 };
599 assert!(
600 !serde_json::to_string(&empty)
601 .unwrap()
602 .contains("assertionSites")
603 );
604 }
605 use super::*;
606 use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
607
608 fn fixture(name: &str) -> PathBuf {
609 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
610 let root = std::env::temp_dir().join(format!(
611 "supercov-python-run-{}-{}-{name}",
612 std::process::id(),
613 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
614 ));
615 let _ = fs::remove_dir_all(&root);
616 for (file, contents) in [
617 ("a.py", "def f(x):\n return x if x else 0\n"),
618 ("vendor/pkg/b.py", "def g():\n return 1\n"),
619 ("test/test_a.py", "from a import f\n"),
620 ] {
621 let path = root.join(file);
622 fs::create_dir_all(path.parent().unwrap()).unwrap();
623 fs::write(path, contents).unwrap();
624 }
625 canonicalize_simplified(&root).unwrap()
626 }
627
628 #[test]
629 fn a_rooted_run_is_current_whatever_shell_asks() {
630 let root = fixture("current");
636 let configured = vec!["a.py".to_owned()];
637 let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
638
639 let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
640 let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
641 let mut inputs = python_integrity_inputs(&project.files, &command);
642 fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
643 let recorded = create_explicit_run_integrity(
644 &root,
645 &inputs,
646 &FrontendIntegrityInputs::embedded_python(),
647 )
648 .unwrap();
649
650 let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
651 assert_eq!(recorded.fingerprint, replayed.fingerprint);
652
653 let ignoring = current_python_integrity(&root, &command, None).unwrap();
656 assert_ne!(recorded.fingerprint, ignoring.fingerprint);
657
658 fs::write(root.join("vendor/pkg/b.py"), "def g():\n return 2\n").unwrap();
661 assert_eq!(
662 current_python_integrity(&root, &command, Some(&configured))
663 .unwrap()
664 .fingerprint,
665 recorded.fingerprint,
666 "a vendored file changing does not make a rooted run stale"
667 );
668 fs::write(root.join("a.py"), "def f(x):\n return 1\n").unwrap();
669 assert_ne!(
670 current_python_integrity(&root, &command, Some(&configured))
671 .unwrap()
672 .fingerprint,
673 recorded.fingerprint,
674 "a first-party file changing does"
675 );
676 fs::remove_dir_all(root).ok();
677 }
678}