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 match source_roots.filter(|roots| !roots.is_empty()) {
201 Some(configured) => {
202 crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
203 .map_err(|error| error.to_string())?
204 .narrow(&mut files.sources, &mut files.excluded, String::as_str);
205 }
206 None => crate::source_discovery::leave_out_what_lies_beside(
207 &mut files.sources,
208 &mut files.excluded,
209 ),
210 }
211 let mut inputs = python_integrity_inputs(&files, command);
212 crate::source_discovery::fold_roots_into_configuration(
213 &mut inputs.execution_configuration,
214 source_roots,
215 );
216 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_python())
217 .map_err(|error| error.to_string())
218}
219
220fn assertion_sites(files: &crate::source_manifest::Files) -> BTreeMap<String, Vec<usize>> {
225 let mut sites = BTreeMap::<String, Vec<usize>>::new();
226 for (file, text) in files.iter().filter(|(file, _)| file.ends_with(".py")) {
227 let Ok(starts) = assertion_starts(text) else {
228 continue;
229 };
230 let lines = sites.entry(file.clone()).or_default();
231 lines.extend(
232 starts
233 .into_iter()
234 .map(|start| text[..start].bytes().filter(|b| *b == b'\n').count() + 1),
235 );
236 lines.sort_unstable();
237 lines.dedup();
238 }
239 sites.retain(|_, lines| !lines.is_empty());
240 sites
241}
242
243fn assertion_starts(source: &str) -> Result<Vec<usize>, String> {
245 use ruff_python_ast::{
246 Expr, Stmt,
247 visitor::{Visitor, walk_expr, walk_stmt},
248 };
249 use ruff_text_size::Ranged;
250 struct Collector(Vec<usize>);
251 impl<'a> Visitor<'a> for Collector {
252 fn visit_stmt(&mut self, stmt: &'a Stmt) {
253 if let Stmt::Assert(_) = stmt {
254 self.0.push(stmt.range().start().to_usize());
255 }
256 walk_stmt(self, stmt);
257 }
258 fn visit_expr(&mut self, expr: &'a Expr) {
259 if let Expr::Call(call) = expr
260 && let Expr::Attribute(attr) = call.func.as_ref()
261 && attr.attr.as_str().starts_with("assert")
262 {
263 self.0.push(expr.range().start().to_usize());
264 }
265 walk_expr(self, expr);
266 }
267 }
268 let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
269 let mut collector = Collector(vec![]);
270 for stmt in &parsed.syntax().body {
271 collector.visit_stmt(stmt);
272 }
273 Ok(collector.0)
274}
275
276pub fn run_direct_python(
277 request: &DirectPythonRunRequest,
278 diagnostics: &mut dyn Write,
279) -> Result<DirectPythonRunResult, String> {
280 if request.command.is_empty() {
281 return Err("test command must not be empty".into());
282 }
283 let total_started = Instant::now();
284 let initialization_started = Instant::now();
285 let root = canonicalize_simplified(&request.root)
286 .map_err(|error| format!("{}: {error}", request.root.display()))?;
287 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
288 .map_err(|error| error.to_string())?;
289 let initialization_ms = elapsed_ms(initialization_started);
290 let work_directory = root.join(".supercov/work").join(&request.run_id);
291 let measured = std::cell::Cell::new(false);
295 let result = (|| {
296 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
297 .map_err(|error| error.to_string())?;
298 if !recovered_runs.is_empty() {
299 writeln!(
300 diagnostics,
301 "[supercov] recovered abandoned run(s): {}",
302 recovered_runs.join(", ")
303 )
304 .map_err(|error| error.to_string())?;
305 }
306
307 let adapter_started = Instant::now();
308 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
309 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
310 .map_err(|error| error.to_string())?;
311 let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
312 let source_roots = crate::source_discovery::configured_source_roots(
313 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
314 );
315 let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
316 crate::source_discovery::fold_roots_into_configuration(
317 &mut integrity_inputs.execution_configuration,
318 source_roots.as_deref(),
319 );
320 let integrity = create_explicit_run_integrity(
321 &root,
322 &integrity_inputs,
323 &FrontendIntegrityInputs::embedded_python(),
324 )
325 .map_err(|error| error.to_string())?;
326 let source_inputs = crate::source_capture::capture(
327 &root,
328 "python",
329 python_integrity_inputs(&project.files, &request.command).assertion_paths(),
330 )?;
331 let python_directory = work_directory.join("python");
332 let runtime_directory = python_directory.join("runtime");
333 let evidence_directory = python_directory.join("evidence");
334 let plan_path = python_directory.join("plan.json");
335 write_runtime(&runtime_directory)?;
336 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
337 let plan = crate::python_instrumenter::PythonProbePlan {
338 assertion_sites: assertion_sites(&source_inputs.files),
339 ..project.plan.clone()
340 };
341 fs::write(
342 &plan_path,
343 serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
344 )
345 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
346 writeln!(
347 diagnostics,
348 "[supercov] detected Python; measuring {} source file(s) in place through probes compiled at import",
349 project.plan.files.len(),
350 )
351 .map_err(|error| error.to_string())?;
352 for (file, reason) in &project.unparseable {
353 writeln!(
354 diagnostics,
355 "[supercov] could not parse {file}: {reason}; it carries no obligations"
356 )
357 .map_err(|error| error.to_string())?;
358 }
359 let adapter_setup_ms = elapsed_ms(adapter_started);
360
361 let test_started = Instant::now();
362 let plan = ExecutionPlan {
363 preparation: Vec::new(),
364 test: ExecutionPhase {
365 name: "test".into(),
366 kind: PhaseKind::Test,
367 command: CommandSpec {
368 program: request.command[0].clone().into(),
369 arguments: request.command[1..].iter().map(OsString::from).collect(),
370 cwd: root.clone(),
371 environment: Some(environment(
372 &root,
373 &request.run_id,
374 &runtime_directory,
375 &plan_path,
376 &evidence_directory,
377 )),
378 captured_output: None,
379 console: None,
380 },
381 },
382 };
383 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
384 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
385 .map_err(|error| error.to_string())?;
386 let test_command_ms = elapsed_ms(test_started);
387 if let Some(signal) = execution.interrupted_signal {
388 return Err(format!(
389 "the test command was interrupted by {signal:?}; no run was published"
390 ));
391 }
392
393 let publication_started = Instant::now();
394 let verbose = std::env::var("SUPERCOV_VERBOSE")
395 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
396 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
397 measured.set(true);
398 let mut run: PythonFrontendRun = match build_python_frontend_run(
399 &project.manifest,
400 &evidence_directory,
401 &request.run_id,
402 &request.started_at,
403 execution.exit_code,
404 &root,
405 ) {
406 Err(
411 crate::python_evidence::PythonEvidenceError::NoTests
412 | crate::python_evidence::PythonEvidenceError::NoInterpreter,
413 ) if execution.exit_code != 0 => {
414 measured.set(false);
415 return Err(format!(
416 "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
417 execution.exit_code
418 ));
419 }
420 Err(
424 error @ (crate::python_evidence::PythonEvidenceError::UnsupportedPython(_)
425 | crate::python_evidence::PythonEvidenceError::NoTests
426 | crate::python_evidence::PythonEvidenceError::NoInterpreter),
427 ) => {
428 measured.set(false);
429 return Err(error.to_string());
430 }
431 result => result.map_err(|error| error.to_string())?,
432 };
433 validate_frontend_report_request(&run.declaration, &run.request)
434 .map_err(|error| error.to_string())?;
435 let joined_ms = elapsed_ms(publication_started);
436 let archive_path = work_directory.join("evidence.raw.gz");
437 run.request.integrity = serde_json::to_value(&integrity).ok();
441 let (archived, analysed) = std::thread::scope(|scope| {
442 let analysis = scope.spawn(|| {
443 crate::coverage_report::analyze_frontend_request(
444 &run.declaration,
445 &run.request,
446 crate::coverage_report::TransportStats::none(),
447 )
448 });
449 let archived = (|| {
450 let entries = run.archive_entries().map_err(|error| error.to_string())?;
451 let serialized_ms = elapsed_ms(publication_started) - joined_ms;
452 let entries = crate::source_capture::append(entries, &source_inputs)?;
453 let raw =
454 write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
455 let archive_ms = elapsed_ms(publication_started) - joined_ms - serialized_ms;
456 Ok::<_, String>((raw, serialized_ms, archive_ms))
457 })();
458 let analysed = analysis
459 .join()
460 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
461 (archived, analysed)
462 });
463 let analysis_ms = elapsed_ms(publication_started) - joined_ms;
464 let (raw, serialized_ms, archive_ms) = archived?;
465 let (tests, interpreters, python_versions) =
470 (run.tests, run.interpreters, run.python_versions.clone());
471 if verbose {
472 writeln!(
473 diagnostics,
474 "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={archive_ms}ms analysis={analysis_ms}ms (beside the archive)"
475 )
476 .map_err(|error| error.to_string())?;
477 }
478 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
479 let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
480 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
481 copy_tree(&python_directory, &debug_directory)?;
482 }
483 remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
484 let evidence_publication_ms = elapsed_ms(publication_started);
485 let timings = RunTimings {
486 initialization_ms,
487 workspace_preparation_ms: 0.0,
488 adapter_setup_ms,
489 instrumented_build_ms: 0.0,
490 test_command_ms,
491 evidence_publication_ms,
492 };
493 let metadata = RunMetadata {
494 id: request.run_id.clone(),
495 started_at: request.started_at.clone(),
496 duration_ms: elapsed_ms(total_started),
497 command: request.command.clone(),
498 test_exit_code: Some(execution.exit_code),
499 integrity,
500 raw_evidence: RawEvidenceMetadata {
501 schema_version: raw.schema_version,
502 format: raw.format.into(),
503 file: raw.file.into(),
504 files: raw.files,
505 uncompressed_bytes: raw.uncompressed_bytes,
506 compressed_bytes: raw.compressed_bytes,
507 },
508 isolated_build: None,
509 instrumented_build_cache: None,
510 timings: Some(timings),
511 merged: None,
512 parents: None,
513 source_roots: source_roots.clone(),
514 };
515 drop(run);
516 let run_directory = publish_archived_run(
517 &root,
518 &metadata,
519 &archive_path,
520 Archived {
521 report: analysed.ok(),
522 inputs: Some(source_inputs.manifest()),
523 },
524 )
525 .map_err(|error| error.to_string())?;
526 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
527 Ok(DirectPythonRunResult {
528 run_id: request.run_id.clone(),
529 run_directory,
530 exit_code: execution.exit_code,
531 tests,
532 source_files: project.plan.files.len(),
533 interpreters,
534 python_versions,
535 recovered_runs,
536 metadata,
537 })
538 })();
539 let result = result.map_err(|error| {
540 if !measured.get() {
541 return error;
542 }
543 note_kept_evidence(
544 &root,
545 &work_directory.join("python/evidence"),
546 &request.run_id,
547 error,
548 )
549 });
550 if result.is_err() {
551 let _ = remove_stored_tree_deferred(&root, &work_directory);
552 }
553 let release = lock.release().map_err(|error| error.to_string());
554 match (result, release) {
555 (Ok(result), Ok(())) => Ok(result),
556 (Err(error), _) => Err(error),
557 (Ok(_), Err(error)) => Err(error),
558 }
559}
560
561#[cfg(test)]
562mod tests {
563
564 #[test]
569 fn the_plan_names_each_test_files_assertion_lines_once_and_sorted() {
570 let files = crate::source_manifest::Files::from([
571 ("tests/test_b.py".to_string(), "def test_b():\n x = 1\n\n\n\n\n\n\n assert x\n".to_string()),
572 (
573 "tests/test_a.py".to_string(),
574 "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(),
576 ),
577 ("src/app.py".to_string(), "def f():\n return 1\n".to_string()),
578 ("README.md".to_string(), "assert nothing\n".to_string()),
579 ]);
580 let sites = assertion_sites(&files);
581 assert_eq!(
582 sites,
583 BTreeMap::from([
584 ("tests/test_a.py".to_string(), vec![3, 12]),
585 ("tests/test_b.py".to_string(), vec![9]),
586 ])
587 );
588
589 let plan = crate::python_instrumenter::PythonProbePlan {
592 version: 1,
593 root: "/p".into(),
594 files: BTreeMap::new(),
595 assertion_sites: sites,
596 };
597 let json = serde_json::to_string(&plan).unwrap();
598 assert!(
599 json.contains("\"assertionSites\":{\"tests/test_a.py\":[3,12]"),
600 "{json}"
601 );
602 let empty = crate::python_instrumenter::PythonProbePlan {
603 assertion_sites: BTreeMap::new(),
604 ..plan
605 };
606 assert!(
607 !serde_json::to_string(&empty)
608 .unwrap()
609 .contains("assertionSites")
610 );
611 }
612 use super::*;
613 use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
614
615 fn fixture(name: &str) -> PathBuf {
616 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
617 let root = std::env::temp_dir().join(format!(
618 "supercov-python-run-{}-{}-{name}",
619 std::process::id(),
620 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
621 ));
622 let _ = fs::remove_dir_all(&root);
623 for (file, contents) in [
624 ("a.py", "def f(x):\n return x if x else 0\n"),
625 ("vendor/pkg/b.py", "def g():\n return 1\n"),
626 ("test/test_a.py", "from a import f\n"),
627 ] {
628 let path = root.join(file);
629 fs::create_dir_all(path.parent().unwrap()).unwrap();
630 fs::write(path, contents).unwrap();
631 }
632 canonicalize_simplified(&root).unwrap()
633 }
634
635 #[test]
636 fn a_rooted_run_is_current_whatever_shell_asks() {
637 let root = fixture("current");
643 let configured = vec!["a.py".to_owned()];
644 let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
645
646 let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
647 let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
648 let mut inputs = python_integrity_inputs(&project.files, &command);
649 fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
650 let recorded = create_explicit_run_integrity(
651 &root,
652 &inputs,
653 &FrontendIntegrityInputs::embedded_python(),
654 )
655 .unwrap();
656
657 let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
658 assert_eq!(recorded.fingerprint, replayed.fingerprint);
659
660 let ignoring = current_python_integrity(&root, &command, None).unwrap();
663 assert_ne!(recorded.fingerprint, ignoring.fingerprint);
664
665 fs::write(root.join("vendor/pkg/b.py"), "def g():\n return 2\n").unwrap();
668 assert_eq!(
669 current_python_integrity(&root, &command, Some(&configured))
670 .unwrap()
671 .fingerprint,
672 recorded.fingerprint,
673 "a vendored file changing does not make a rooted run stale"
674 );
675 fs::write(root.join("a.py"), "def f(x):\n return 1\n").unwrap();
676 assert_ne!(
677 current_python_integrity(&root, &command, Some(&configured))
678 .unwrap()
679 .fingerprint,
680 recorded.fingerprint,
681 "a first-party file changing does"
682 );
683 fs::remove_dir_all(root).ok();
684 }
685}