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_PYTHON_SOURCE_ROOT",
165 "SUPERCOV_RUN_ID",
166 "SUPERCOV_PROJECT_ROOT",
167 "SUPERCOV_CONTEXT",
168 "SUPERCOV_PYTHON_WORKER",
169 ] {
170 take(key);
171 }
172 variables.extend([
173 ("PYTHONPATH".into(), python_path),
174 ("PYTEST_PLUGINS".into(), pytest_plugins),
175 ("PYTEST_ADDOPTS".into(), pytest_addopts),
176 (
177 "SUPERCOV_PYTHON_PLAN".into(),
178 plan_path.as_os_str().to_owned(),
179 ),
180 (
181 "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
182 evidence_directory.as_os_str().to_owned(),
183 ),
184 ("SUPERCOV_RUN_ID".into(), run_id.into()),
185 ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
186 ]);
187 variables
188}
189
190pub fn current_python_integrity(
193 root: &Path,
194 command: &[String],
195 source_roots: Option<&[String]>,
196) -> Result<crate::run_store::RunIntegrity, String> {
197 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
198 let mut files = crate::python_project::discover_python_files(&root)?;
199 match source_roots.filter(|roots| !roots.is_empty()) {
202 Some(configured) => {
203 crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
204 .map_err(|error| error.to_string())?
205 .narrow(&mut files.sources, &mut files.excluded, String::as_str);
206 }
207 None => crate::source_discovery::leave_out_what_lies_beside(
208 &mut files.sources,
209 &mut files.excluded,
210 ),
211 }
212 let mut inputs = python_integrity_inputs(&files, command);
213 crate::source_discovery::fold_roots_into_configuration(
214 &mut inputs.execution_configuration,
215 source_roots,
216 );
217 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_python())
218 .map_err(|error| error.to_string())
219}
220
221fn assertion_sites(files: &crate::source_manifest::Files) -> BTreeMap<String, Vec<usize>> {
226 let mut sites = BTreeMap::<String, Vec<usize>>::new();
227 for (file, text) in files.iter().filter(|(file, _)| file.ends_with(".py")) {
228 let Ok(starts) = assertion_starts(text) else {
229 continue;
230 };
231 let lines = sites.entry(file.clone()).or_default();
232 lines.extend(
233 starts
234 .into_iter()
235 .map(|start| text[..start].bytes().filter(|b| *b == b'\n').count() + 1),
236 );
237 lines.sort_unstable();
238 lines.dedup();
239 }
240 sites.retain(|_, lines| !lines.is_empty());
241 sites
242}
243
244fn assertion_starts(source: &str) -> Result<Vec<usize>, String> {
246 use ruff_python_ast::{
247 Expr, Stmt,
248 visitor::{Visitor, walk_expr, walk_stmt},
249 };
250 use ruff_text_size::Ranged;
251 struct Collector(Vec<usize>);
252 impl<'a> Visitor<'a> for Collector {
253 fn visit_stmt(&mut self, stmt: &'a Stmt) {
254 if let Stmt::Assert(_) = stmt {
255 self.0.push(stmt.range().start().to_usize());
256 }
257 walk_stmt(self, stmt);
258 }
259 fn visit_expr(&mut self, expr: &'a Expr) {
260 if let Expr::Call(call) = expr
261 && let Expr::Attribute(attr) = call.func.as_ref()
262 && attr.attr.as_str().starts_with("assert")
263 {
264 self.0.push(expr.range().start().to_usize());
265 }
266 walk_expr(self, expr);
267 }
268 }
269 let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
270 let mut collector = Collector(vec![]);
271 for stmt in &parsed.syntax().body {
272 collector.visit_stmt(stmt);
273 }
274 Ok(collector.0)
275}
276
277struct Archive {
278 raw: crate::evidence_archive::EvidenceArchiveMetadata,
279 analysed: Option<crate::coverage_report::CoverageReport>,
280 serialized_ms: f64,
281 archive_ms: f64,
282}
283
284fn archive_python_run(
288 run: &PythonFrontendRun,
289 source_inputs: &crate::source_manifest::Inputs,
290 archive_path: &Path,
291 publication_started: Instant,
292 joined_ms: f64,
293) -> Result<Archive, String> {
294 let (archived, analysed) = std::thread::scope(|scope| {
295 let analysis = scope.spawn(|| {
296 crate::coverage_report::analyze_frontend_request(
297 &run.declaration,
298 &run.request,
299 crate::coverage_report::TransportStats::none(),
300 )
301 });
302 let archived = (|| {
303 let entries = run.archive_entries().map_err(|error| error.to_string())?;
304 let serialized_ms = elapsed_ms(publication_started) - joined_ms;
305 let entries = crate::source_capture::append(entries, source_inputs)?;
306 let raw = write_archive(entries, archive_path).map_err(|error| error.to_string())?;
307 let archive_ms = elapsed_ms(publication_started) - joined_ms - serialized_ms;
308 Ok::<_, String>((raw, serialized_ms, archive_ms))
309 })();
310 let analysed = analysis
311 .join()
312 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
313 (archived, analysed)
314 });
315 let (raw, serialized_ms, archive_ms) = archived?;
316 Ok(Archive {
317 raw,
318 analysed: analysed.ok(),
319 serialized_ms,
320 archive_ms,
321 })
322}
323
324pub fn run_direct_python(
325 request: &DirectPythonRunRequest,
326 diagnostics: &mut dyn Write,
327) -> Result<DirectPythonRunResult, String> {
328 if request.command.is_empty() {
329 return Err("test command must not be empty".into());
330 }
331 let total_started = Instant::now();
332 let initialization_started = Instant::now();
333 let root = canonicalize_simplified(&request.root)
334 .map_err(|error| format!("{}: {error}", request.root.display()))?;
335 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
336 .map_err(|error| error.to_string())?;
337 let initialization_ms = elapsed_ms(initialization_started);
338 let work_directory = root.join(".supercov/work").join(&request.run_id);
339 let measured = std::cell::Cell::new(false);
343 let result = (|| {
344 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
345 .map_err(|error| error.to_string())?;
346 if !recovered_runs.is_empty() {
347 writeln!(
348 diagnostics,
349 "[supercov] recovered abandoned run(s): {}",
350 recovered_runs.join(", ")
351 )
352 .map_err(|error| error.to_string())?;
353 }
354
355 let adapter_started = Instant::now();
356 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
357 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
358 .map_err(|error| error.to_string())?;
359 let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
360 let source_roots = crate::source_discovery::configured_source_roots(
361 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
362 );
363 let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
364 crate::source_discovery::fold_roots_into_configuration(
365 &mut integrity_inputs.execution_configuration,
366 source_roots.as_deref(),
367 );
368 let integrity = create_explicit_run_integrity(
369 &root,
370 &integrity_inputs,
371 &FrontendIntegrityInputs::embedded_python(),
372 )
373 .map_err(|error| error.to_string())?;
374 let source_inputs = crate::source_capture::capture(
375 &root,
376 "python",
377 python_integrity_inputs(&project.files, &request.command).assertion_paths(),
378 )?;
379 let python_directory = work_directory.join("python");
380 let runtime_directory = python_directory.join("runtime");
381 let evidence_directory = python_directory.join("evidence");
382 let plan_path = python_directory.join("plan.json");
383 write_runtime(&runtime_directory)?;
384 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
385 let plan = crate::python_instrumenter::PythonProbePlan {
386 assertion_sites: assertion_sites(&source_inputs.files),
387 ..project.plan.clone()
388 };
389 fs::write(
390 &plan_path,
391 serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
392 )
393 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
394 writeln!(
395 diagnostics,
396 "[supercov] detected Python; measuring {} source file(s) in place through probes compiled at import",
397 project.plan.files.len(),
398 )
399 .map_err(|error| error.to_string())?;
400 for (file, reason) in &project.unparseable {
401 writeln!(
402 diagnostics,
403 "[supercov] could not parse {file}: {reason}; it carries no obligations"
404 )
405 .map_err(|error| error.to_string())?;
406 }
407 let adapter_setup_ms = elapsed_ms(adapter_started);
408
409 let test_started = Instant::now();
410 let plan = ExecutionPlan {
411 preparation: Vec::new(),
412 test: ExecutionPhase {
413 name: "test".into(),
414 kind: PhaseKind::Test,
415 command: CommandSpec {
416 program: request.command[0].clone().into(),
417 arguments: request.command[1..].iter().map(OsString::from).collect(),
418 cwd: root.clone(),
419 environment: Some(environment(
420 &root,
421 &request.run_id,
422 &runtime_directory,
423 &plan_path,
424 &evidence_directory,
425 )),
426 captured_output: None,
427 console: None,
428 },
429 },
430 };
431 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
432 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
433 .map_err(|error| error.to_string())?;
434 let test_command_ms = elapsed_ms(test_started);
435 if let Some(signal) = execution.interrupted_signal {
436 return Err(format!(
437 "the test command was interrupted by {signal:?}; no run was published"
438 ));
439 }
440
441 let publication_started = Instant::now();
442 let verbose = std::env::var("SUPERCOV_VERBOSE")
443 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
444 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
445 measured.set(true);
446 let mut run: PythonFrontendRun = match build_python_frontend_run(
447 &project.manifest,
448 &evidence_directory,
449 &request.run_id,
450 &request.started_at,
451 execution.exit_code,
452 &root,
453 ) {
454 Err(
459 crate::python_evidence::PythonEvidenceError::NoTests
460 | crate::python_evidence::PythonEvidenceError::NoInterpreter,
461 ) if execution.exit_code != 0 => {
462 measured.set(false);
463 return Err(format!(
464 "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
465 execution.exit_code
466 ));
467 }
468 Err(
472 error @ (crate::python_evidence::PythonEvidenceError::UnsupportedPython(_)
473 | crate::python_evidence::PythonEvidenceError::NoTests
474 | crate::python_evidence::PythonEvidenceError::NoInterpreter),
475 ) => {
476 measured.set(false);
477 return Err(error.to_string());
478 }
479 result => result.map_err(|error| error.to_string())?,
480 };
481 validate_frontend_report_request(&run.declaration, &run.request)
482 .map_err(|error| error.to_string())?;
483 let joined_ms = elapsed_ms(publication_started);
484 let archive_path = work_directory.join("evidence.raw.gz");
485 run.request.integrity = serde_json::to_value(&integrity).ok();
486 let Archive {
487 raw,
488 analysed,
489 serialized_ms,
490 archive_ms,
491 } = archive_python_run(
492 &run,
493 &source_inputs,
494 &archive_path,
495 publication_started,
496 joined_ms,
497 )?;
498 let analysis_ms = elapsed_ms(publication_started) - joined_ms;
499 let (tests, interpreters, python_versions) =
504 (run.tests, run.interpreters, run.python_versions.clone());
505 if verbose {
506 writeln!(
507 diagnostics,
508 "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={archive_ms}ms analysis={analysis_ms}ms (beside the archive)"
509 )
510 .map_err(|error| error.to_string())?;
511 }
512 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
513 let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
514 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
515 copy_tree(&python_directory, &debug_directory)?;
516 }
517 remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
518 let evidence_publication_ms = elapsed_ms(publication_started);
519 let timings = RunTimings {
520 initialization_ms,
521 workspace_preparation_ms: 0.0,
522 adapter_setup_ms,
523 instrumented_build_ms: 0.0,
524 test_command_ms,
525 evidence_publication_ms,
526 };
527 let metadata = RunMetadata {
528 id: request.run_id.clone(),
529 started_at: request.started_at.clone(),
530 duration_ms: elapsed_ms(total_started),
531 command: request.command.clone(),
532 test_exit_code: Some(execution.exit_code),
533 integrity,
534 raw_evidence: RawEvidenceMetadata {
535 schema_version: raw.schema_version,
536 format: raw.format.into(),
537 file: raw.file.into(),
538 files: raw.files,
539 uncompressed_bytes: raw.uncompressed_bytes,
540 compressed_bytes: raw.compressed_bytes,
541 },
542 isolated_build: None,
543 instrumented_build_cache: None,
544 timings: Some(timings),
545 merged: None,
546 parents: None,
547 source_roots: source_roots.clone(),
548 copy: None,
549 merged_across: None,
550 recorded_by: None,
551 companions: None,
552 };
553 drop(run);
554 let run_directory = publish_archived_run(
555 &root,
556 &metadata,
557 &archive_path,
558 Archived {
559 report: analysed,
560 inputs: Some(source_inputs.manifest()),
561 },
562 )
563 .map_err(|error| error.to_string())?;
564 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
565 Ok(DirectPythonRunResult {
566 run_id: request.run_id.clone(),
567 run_directory,
568 exit_code: execution.exit_code,
569 tests,
570 source_files: project.plan.files.len(),
571 interpreters,
572 python_versions,
573 recovered_runs,
574 metadata,
575 })
576 })();
577 let result = result.map_err(|error| {
578 if !measured.get() {
579 return error;
580 }
581 note_kept_evidence(
582 &root,
583 &work_directory.join("python/evidence"),
584 &request.run_id,
585 error,
586 )
587 });
588 if result.is_err() {
589 let _ = remove_stored_tree_deferred(&root, &work_directory);
590 }
591 let release = lock.release().map_err(|error| error.to_string());
592 match (result, release) {
593 (Ok(result), Ok(())) => Ok(result),
594 (Err(error), _) => Err(error),
595 (Ok(_), Err(error)) => Err(error),
596 }
597}
598
599pub struct PythonServer {
605 project: PreparedPythonProject,
606 integrity: crate::run_store::RunIntegrity,
607 source_inputs: crate::source_manifest::Inputs,
608 source_roots: Option<Vec<String>>,
609 directory: PathBuf,
610}
611
612impl PythonServer {
613 pub fn prepare(
617 root: &Path,
618 workspace: &Path,
619 run_id: &str,
620 command: &[String],
621 ) -> Result<Option<Self>, String> {
622 let ambient = std::env::vars().collect::<BTreeMap<_, _>>();
623 let Ok(roots) =
625 crate::source_discovery::ExplicitSourceRoots::from_environment(root, &ambient)
626 else {
627 return Ok(None);
628 };
629 let project = prepare_python_project(root, roots.as_ref())?;
630 if project.plan.files.is_empty() {
631 return Ok(None);
632 }
633 let source_roots = crate::source_discovery::configured_source_roots(&ambient);
634 let mut integrity_inputs = python_integrity_inputs(&project.files, command);
635 crate::source_discovery::fold_roots_into_configuration(
636 &mut integrity_inputs.execution_configuration,
637 source_roots.as_deref(),
638 );
639 let integrity = create_explicit_run_integrity(
640 root,
641 &integrity_inputs,
642 &FrontendIntegrityInputs::embedded_python(),
643 )
644 .map_err(|error| error.to_string())?;
645 let source_inputs = crate::source_capture::capture(
646 root,
647 "python",
648 python_integrity_inputs(&project.files, command).assertion_paths(),
649 )?;
650 let directory = root.join(".supercov/work").join(run_id).join("python");
651 write_runtime(&directory.join("runtime"))?;
652 fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
653 let plan = crate::python_instrumenter::PythonProbePlan {
654 root: workspace.display().to_string(),
655 ..project.plan.clone()
656 };
657 let plan_path = directory.join("plan.json");
658 fs::write(
659 &plan_path,
660 serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
661 )
662 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
663 Ok(Some(Self {
664 project,
665 integrity,
666 source_inputs,
667 source_roots,
668 directory,
669 }))
670 }
671
672 pub fn source_files(&self) -> usize {
673 self.project.plan.files.len()
674 }
675
676 pub fn environment(&self) -> BTreeMap<String, String> {
679 let python_path = prepend_path_list(
680 std::env::var_os("PYTHONPATH"),
681 &self.directory.join("runtime"),
682 );
683 BTreeMap::from([
684 (
685 "PYTHONPATH".into(),
686 python_path.to_string_lossy().into_owned(),
687 ),
688 (
689 "SUPERCOV_PYTHON_PLAN".into(),
690 self.directory.join("plan.json").display().to_string(),
691 ),
692 (
693 "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
694 self.directory.join("evidence").display().to_string(),
695 ),
696 (
697 "SUPERCOV_PYTHON_SOURCE_ROOT".into(),
698 self.project.root.display().to_string(),
699 ),
700 ])
701 }
702
703 #[allow(clippy::too_many_arguments)]
709 pub fn publish(
710 self,
711 root: &Path,
712 evidence_run_id: &str,
713 metadata: RunMetadata,
714 served: &[crate::served_tests::ServedTest],
715 diagnostics: &mut dyn Write,
716 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
717 let started = Instant::now();
718 let evidence_directory = self.directory.join("evidence");
719 let result = (|| {
720 if served.is_empty() {
723 return Ok(None);
724 }
725 let mut run = match crate::python_evidence::build_python_server_run(
726 &self.project.manifest,
727 &evidence_directory,
728 evidence_run_id,
729 &metadata.started_at,
730 metadata.test_exit_code.unwrap_or_default(),
731 Path::new(&self.project.plan.root),
732 served,
733 ) {
734 Err(
735 crate::python_evidence::PythonEvidenceError::NoInterpreter
736 | crate::python_evidence::PythonEvidenceError::NoTests,
737 ) => return Ok(None),
738 result => result.map_err(|error| error.to_string())?,
739 };
740 let answered = run.request.raw_results.iter().any(|raw| {
741 raw.role == "test" && raw.runtime.iter().any(|snapshot| !snapshot.hits.is_empty())
742 });
743 if !answered {
744 writeln!(
745 diagnostics,
746 "[supercov] a Python process ran, and no request a test sent reached measured Python code; no Python run was published"
747 )
748 .map_err(|error| error.to_string())?;
749 return Ok(None);
750 }
751 validate_frontend_report_request(&run.declaration, &run.request)
752 .map_err(|error| error.to_string())?;
753 run.request.run_id = metadata.id.clone();
756 for scope in run
757 .request
758 .raw_results
759 .iter_mut()
760 .filter_map(|raw| raw.scope.as_mut())
761 {
762 scope.run_id = metadata.id.clone();
763 }
764 run.request.integrity = serde_json::to_value(&self.integrity).ok();
765 let archive_path = root
766 .join(".supercov/work")
767 .join(&metadata.id)
768 .join("python-evidence.raw.gz");
769 if let Some(parent) = archive_path.parent() {
770 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
771 }
772 let Archive { raw, analysed, .. } =
773 archive_python_run(&run, &self.source_inputs, &archive_path, started, 0.0)?;
774 let tests = run.tests;
775 drop(run);
776 let metadata = RunMetadata {
777 integrity: self.integrity.clone(),
778 raw_evidence: RawEvidenceMetadata {
779 schema_version: raw.schema_version,
780 format: raw.format.into(),
781 file: raw.file.into(),
782 files: raw.files,
783 uncompressed_bytes: raw.uncompressed_bytes,
784 compressed_bytes: raw.compressed_bytes,
785 },
786 source_roots: self.source_roots.clone(),
787 ..metadata
788 };
789 let run_directory = publish_archived_run(
790 root,
791 &metadata,
792 &archive_path,
793 Archived {
794 report: analysed,
795 inputs: Some(self.source_inputs.manifest()),
796 },
797 )
798 .map_err(|error| error.to_string())?;
799 writeln!(
800 diagnostics,
801 "[supercov] Python server coverage: {tests} test(s) across {} source file(s)",
802 self.project.plan.files.len()
803 )
804 .map_err(|error| error.to_string())?;
805 Ok(Some((run_directory, metadata)))
806 })();
807 let result = result.map_err(|error| {
808 note_kept_evidence(
809 root,
810 &evidence_directory,
811 &metadata_id_hint(evidence_run_id),
812 error,
813 )
814 });
815 let _ = remove_stored_tree_deferred(root, &self.directory);
816 result
817 }
818}
819
820fn metadata_id_hint(run_id: &str) -> String {
821 format!("{run_id}-python")
822}
823
824#[cfg(test)]
825mod tests {
826
827 #[test]
832 fn the_plan_names_each_test_files_assertion_lines_once_and_sorted() {
833 let files = crate::source_manifest::Files::from([
834 ("tests/test_b.py".to_string(), "def test_b():\n x = 1\n\n\n\n\n\n\n assert x\n".to_string()),
835 (
836 "tests/test_a.py".to_string(),
837 "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(),
839 ),
840 ("src/app.py".to_string(), "def f():\n return 1\n".to_string()),
841 ("README.md".to_string(), "assert nothing\n".to_string()),
842 ]);
843 let sites = assertion_sites(&files);
844 assert_eq!(
845 sites,
846 BTreeMap::from([
847 ("tests/test_a.py".to_string(), vec![3, 12]),
848 ("tests/test_b.py".to_string(), vec![9]),
849 ])
850 );
851
852 let plan = crate::python_instrumenter::PythonProbePlan {
855 version: 1,
856 root: "/p".into(),
857 files: BTreeMap::new(),
858 assertion_sites: sites,
859 };
860 let json = serde_json::to_string(&plan).unwrap();
861 assert!(
862 json.contains("\"assertionSites\":{\"tests/test_a.py\":[3,12]"),
863 "{json}"
864 );
865 let empty = crate::python_instrumenter::PythonProbePlan {
866 assertion_sites: BTreeMap::new(),
867 ..plan
868 };
869 assert!(
870 !serde_json::to_string(&empty)
871 .unwrap()
872 .contains("assertionSites")
873 );
874 }
875 use super::*;
876 use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
877
878 fn fixture(name: &str) -> PathBuf {
879 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
880 let root = std::env::temp_dir().join(format!(
881 "supercov-python-run-{}-{}-{name}",
882 std::process::id(),
883 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
884 ));
885 let _ = fs::remove_dir_all(&root);
886 for (file, contents) in [
887 ("a.py", "def f(x):\n return x if x else 0\n"),
888 ("vendor/pkg/b.py", "def g():\n return 1\n"),
889 ("test/test_a.py", "from a import f\n"),
890 ] {
891 let path = root.join(file);
892 fs::create_dir_all(path.parent().unwrap()).unwrap();
893 fs::write(path, contents).unwrap();
894 }
895 canonicalize_simplified(&root).unwrap()
896 }
897
898 #[test]
899 fn a_rooted_run_is_current_whatever_shell_asks() {
900 let root = fixture("current");
906 let configured = vec!["a.py".to_owned()];
907 let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
908
909 let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
910 let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
911 let mut inputs = python_integrity_inputs(&project.files, &command);
912 fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
913 let recorded = create_explicit_run_integrity(
914 &root,
915 &inputs,
916 &FrontendIntegrityInputs::embedded_python(),
917 )
918 .unwrap();
919
920 let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
921 assert_eq!(recorded.fingerprint, replayed.fingerprint);
922
923 let ignoring = current_python_integrity(&root, &command, None).unwrap();
926 assert_ne!(recorded.fingerprint, ignoring.fingerprint);
927
928 fs::write(root.join("vendor/pkg/b.py"), "def g():\n return 2\n").unwrap();
931 assert_eq!(
932 current_python_integrity(&root, &command, Some(&configured))
933 .unwrap()
934 .fingerprint,
935 recorded.fingerprint,
936 "a vendored file changing does not make a rooted run stale"
937 );
938 fs::write(root.join("a.py"), "def f(x):\n return 1\n").unwrap();
939 assert_ne!(
940 current_python_integrity(&root, &command, Some(&configured))
941 .unwrap()
942 .fingerprint,
943 recorded.fingerprint,
944 "a first-party file changing does"
945 );
946 fs::remove_dir_all(root).ok();
947 }
948}