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::{PythonAssertionInventory, 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(
218 inventory: &[crate::assertion_map::InventorySite],
219) -> BTreeMap<String, Vec<usize>> {
220 let mut sites = BTreeMap::<String, Vec<usize>>::new();
221 for site in inventory {
222 sites
223 .entry(site.at.file.clone())
224 .or_default()
225 .push(site.at.line);
226 }
227 for lines in sites.values_mut() {
228 lines.sort_unstable();
229 lines.dedup();
230 }
231 sites
232}
233
234pub fn run_direct_python(
235 request: &DirectPythonRunRequest,
236 diagnostics: &mut dyn Write,
237) -> Result<DirectPythonRunResult, String> {
238 if request.command.is_empty() {
239 return Err("test command must not be empty".into());
240 }
241 let total_started = Instant::now();
242 let initialization_started = Instant::now();
243 let root = canonicalize_simplified(&request.root)
244 .map_err(|error| format!("{}: {error}", request.root.display()))?;
245 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
246 .map_err(|error| error.to_string())?;
247 let initialization_ms = elapsed_ms(initialization_started);
248 let work_directory = root.join(".supercov/work").join(&request.run_id);
249 let measured = std::cell::Cell::new(false);
253 let result = (|| {
254 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
255 .map_err(|error| error.to_string())?;
256 if !recovered_runs.is_empty() {
257 writeln!(
258 diagnostics,
259 "[supercov] recovered abandoned run(s): {}",
260 recovered_runs.join(", ")
261 )
262 .map_err(|error| error.to_string())?;
263 }
264
265 let adapter_started = Instant::now();
266 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
267 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
268 .map_err(|error| error.to_string())?;
269 let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
270 let source_roots = crate::source_discovery::configured_source_roots(
271 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
272 );
273 let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
274 crate::source_discovery::fold_roots_into_configuration(
275 &mut integrity_inputs.execution_configuration,
276 source_roots.as_deref(),
277 );
278 let integrity = create_explicit_run_integrity(
279 &root,
280 &integrity_inputs,
281 &FrontendIntegrityInputs::embedded_python(),
282 )
283 .map_err(|error| error.to_string())?;
284 let assertion_inputs = crate::assertion_inputs::capture(
285 &root,
286 "python",
287 python_integrity_inputs(&project.files, &request.command).assertion_paths(),
288 )?;
289 let python_directory = work_directory.join("python");
290 let runtime_directory = python_directory.join("runtime");
291 let evidence_directory = python_directory.join("evidence");
292 let plan_path = python_directory.join("plan.json");
293 write_runtime(&runtime_directory)?;
294 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
295 let plan = crate::python_instrumenter::PythonProbePlan {
296 assertion_sites: assertion_sites(&assertion_inputs.assertions),
297 ..project.plan.clone()
298 };
299 fs::write(
300 &plan_path,
301 serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
302 )
303 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
304 writeln!(
305 diagnostics,
306 "[supercov] detected Python; measuring {} source file(s) in place through probes compiled at import",
307 project.plan.files.len(),
308 )
309 .map_err(|error| error.to_string())?;
310 for (file, reason) in &project.unparseable {
311 writeln!(
312 diagnostics,
313 "[supercov] could not parse {file}: {reason}; it carries no obligations"
314 )
315 .map_err(|error| error.to_string())?;
316 }
317 let adapter_setup_ms = elapsed_ms(adapter_started);
318
319 let test_started = Instant::now();
320 let plan = ExecutionPlan {
321 preparation: Vec::new(),
322 test: ExecutionPhase {
323 name: "test".into(),
324 kind: PhaseKind::Test,
325 command: CommandSpec {
326 program: request.command[0].clone().into(),
327 arguments: request.command[1..].iter().map(OsString::from).collect(),
328 cwd: root.clone(),
329 environment: Some(environment(
330 &root,
331 &request.run_id,
332 &runtime_directory,
333 &plan_path,
334 &evidence_directory,
335 )),
336 captured_output: None,
337 },
338 },
339 };
340 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
341 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
342 .map_err(|error| error.to_string())?;
343 let test_command_ms = elapsed_ms(test_started);
344 if let Some(signal) = execution.interrupted_signal {
345 return Err(format!(
346 "the test command was interrupted by {signal:?}; no run was published"
347 ));
348 }
349
350 let publication_started = Instant::now();
351 let verbose = std::env::var("SUPERCOV_VERBOSE")
352 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
353 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
354 measured.set(true);
355 let mut run: PythonFrontendRun = build_python_frontend_run(
356 &project.manifest,
357 &evidence_directory,
358 &request.run_id,
359 &request.started_at,
360 execution.exit_code,
361 &PythonAssertionInventory::new(&root, &assertion_inputs),
362 )
363 .map_err(|error| error.to_string())?;
364 validate_frontend_report_request(&run.declaration, &run.request)
365 .map_err(|error| error.to_string())?;
366 let joined_ms = elapsed_ms(publication_started);
367 let archive_path = work_directory.join("evidence.raw.gz");
368 run.request.integrity = serde_json::to_value(&integrity).ok();
372 let (archived, analysed) = std::thread::scope(|scope| {
373 let analysis = scope.spawn(|| {
374 crate::coverage_report::analyze_frontend_request(
375 &run.declaration,
376 &run.request,
377 crate::coverage_report::TransportStats::none(),
378 )
379 });
380 let archived = (|| {
381 let entries = run.archive_entries().map_err(|error| error.to_string())?;
382 let serialized_ms = elapsed_ms(publication_started) - joined_ms;
383 let entries = crate::assertion_inputs::append(entries, &assertion_inputs)?;
384 let raw =
385 write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
386 let archive_ms = elapsed_ms(publication_started) - joined_ms - serialized_ms;
387 Ok::<_, String>((raw, serialized_ms, archive_ms))
388 })();
389 let analysed = analysis
390 .join()
391 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
392 (archived, analysed)
393 });
394 let analysis_ms = elapsed_ms(publication_started) - joined_ms;
395 let (raw, serialized_ms, archive_ms) = archived?;
396 let (tests, interpreters, python_versions) =
401 (run.tests, run.interpreters, run.python_versions.clone());
402 if verbose {
403 writeln!(
404 diagnostics,
405 "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={archive_ms}ms analysis={analysis_ms}ms (beside the archive)"
406 )
407 .map_err(|error| error.to_string())?;
408 }
409 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
410 let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
411 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
412 copy_tree(&python_directory, &debug_directory)?;
413 }
414 remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
415 let evidence_publication_ms = elapsed_ms(publication_started);
416 let timings = RunTimings {
417 initialization_ms,
418 workspace_preparation_ms: 0.0,
419 adapter_setup_ms,
420 instrumented_build_ms: 0.0,
421 test_command_ms,
422 evidence_publication_ms,
423 };
424 let metadata = RunMetadata {
425 id: request.run_id.clone(),
426 started_at: request.started_at.clone(),
427 duration_ms: elapsed_ms(total_started),
428 command: request.command.clone(),
429 test_exit_code: Some(execution.exit_code),
430 integrity,
431 raw_evidence: RawEvidenceMetadata {
432 schema_version: raw.schema_version,
433 format: raw.format.into(),
434 file: raw.file.into(),
435 files: raw.files,
436 uncompressed_bytes: raw.uncompressed_bytes,
437 compressed_bytes: raw.compressed_bytes,
438 },
439 isolated_build: None,
440 instrumented_build_cache: None,
441 timings: Some(timings),
442 merged: None,
443 parents: None,
444 source_roots: source_roots.clone(),
445 };
446 drop(run);
447 let run_directory = publish_archived_run(
448 &root,
449 &metadata,
450 &archive_path,
451 Archived {
452 report: analysed.ok(),
453 inputs: Some(assertion_inputs.manifest()),
454 },
455 )
456 .map_err(|error| error.to_string())?;
457 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
458 Ok(DirectPythonRunResult {
459 run_id: request.run_id.clone(),
460 run_directory,
461 exit_code: execution.exit_code,
462 tests,
463 source_files: project.plan.files.len(),
464 interpreters,
465 python_versions,
466 recovered_runs,
467 metadata,
468 })
469 })();
470 let result = result.map_err(|error| {
471 if !measured.get() {
472 return error;
473 }
474 note_kept_evidence(
475 &root,
476 &work_directory.join("python/evidence"),
477 &request.run_id,
478 error,
479 )
480 });
481 if result.is_err() {
482 let _ = remove_stored_tree_deferred(&root, &work_directory);
483 }
484 let release = lock.release().map_err(|error| error.to_string());
485 match (result, release) {
486 (Ok(result), Ok(())) => Ok(result),
487 (Err(error), _) => Err(error),
488 (Ok(_), Err(error)) => Err(error),
489 }
490}
491
492#[cfg(test)]
493mod tests {
494
495 #[test]
500 fn the_plan_names_each_test_files_assertion_lines_once_and_sorted() {
501 use crate::assertion_map::{Anchor, InventorySite};
502 let site = |file: &str, line: usize, column: usize| InventorySite {
503 at: Anchor {
504 file: file.into(),
505 line,
506 column,
507 text: String::new(),
508 },
509 operation: "assert".into(),
510 };
511 let inventory = vec![
512 site("tests/test_b.py", 9, 4),
513 site("tests/test_a.py", 12, 4),
514 site("tests/test_a.py", 12, 30),
516 site("tests/test_a.py", 3, 4),
517 ];
518 let sites = assertion_sites(&inventory);
519 assert_eq!(
520 sites,
521 BTreeMap::from([
522 ("tests/test_a.py".to_string(), vec![3, 12]),
523 ("tests/test_b.py".to_string(), vec![9]),
524 ])
525 );
526
527 let plan = crate::python_instrumenter::PythonProbePlan {
530 version: 1,
531 root: "/p".into(),
532 files: BTreeMap::new(),
533 assertion_sites: sites,
534 };
535 let json = serde_json::to_string(&plan).unwrap();
536 assert!(
537 json.contains("\"assertionSites\":{\"tests/test_a.py\":[3,12]"),
538 "{json}"
539 );
540 let empty = crate::python_instrumenter::PythonProbePlan {
541 assertion_sites: BTreeMap::new(),
542 ..plan
543 };
544 assert!(
545 !serde_json::to_string(&empty)
546 .unwrap()
547 .contains("assertionSites")
548 );
549 }
550 use super::*;
551 use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
552
553 fn fixture(name: &str) -> PathBuf {
554 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
555 let root = std::env::temp_dir().join(format!(
556 "supercov-python-run-{}-{}-{name}",
557 std::process::id(),
558 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
559 ));
560 let _ = fs::remove_dir_all(&root);
561 for (file, contents) in [
562 ("a.py", "def f(x):\n return x if x else 0\n"),
563 ("vendor/pkg/b.py", "def g():\n return 1\n"),
564 ("test/test_a.py", "from a import f\n"),
565 ] {
566 let path = root.join(file);
567 fs::create_dir_all(path.parent().unwrap()).unwrap();
568 fs::write(path, contents).unwrap();
569 }
570 canonicalize_simplified(&root).unwrap()
571 }
572
573 #[test]
574 fn a_rooted_run_is_current_whatever_shell_asks() {
575 let root = fixture("current");
581 let configured = vec!["a.py".to_owned()];
582 let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
583
584 let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
585 let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
586 let mut inputs = python_integrity_inputs(&project.files, &command);
587 fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
588 let recorded = create_explicit_run_integrity(
589 &root,
590 &inputs,
591 &FrontendIntegrityInputs::embedded_python(),
592 )
593 .unwrap();
594
595 let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
596 assert_eq!(recorded.fingerprint, replayed.fingerprint);
597
598 let ignoring = current_python_integrity(&root, &command, None).unwrap();
601 assert_ne!(recorded.fingerprint, ignoring.fingerprint);
602
603 fs::write(root.join("vendor/pkg/b.py"), "def g():\n return 2\n").unwrap();
606 assert_eq!(
607 current_python_integrity(&root, &command, Some(&configured))
608 .unwrap()
609 .fingerprint,
610 recorded.fingerprint,
611 "a vendored file changing does not make a rooted run stale"
612 );
613 fs::write(root.join("a.py"), "def f(x):\n return 1\n").unwrap();
614 assert_ne!(
615 current_python_integrity(&root, &command, Some(&configured))
616 .unwrap()
617 .fingerprint,
618 recorded.fingerprint,
619 "a first-party file changing does"
620 );
621 fs::remove_dir_all(root).ok();
622 }
623}