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 ProjectLock, finalize_published_run, note_kept_evidence, publish_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]); 5] {
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}
86
87fn write_runtime(directory: &Path) -> Result<(), String> {
88 fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
89 for (name, contents) in embedded_runtime_files() {
90 let path = directory.join(name);
91 fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
92 }
93 Ok(())
94}
95
96fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
97 for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
98 let entry = entry.map_err(|error| error.to_string())?;
99 let target = destination.join(entry.file_name());
100 if entry
101 .file_type()
102 .map_err(|error| error.to_string())?
103 .is_dir()
104 {
105 fs::create_dir_all(&target).map_err(|error| error.to_string())?;
106 copy_tree(&entry.path(), &target)?;
107 } else {
108 fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
109 }
110 }
111 Ok(())
112}
113
114fn prepend_path_list(existing: Option<OsString>, entry: &Path) -> OsString {
115 let mut value = entry.as_os_str().to_owned();
116 if let Some(existing) = existing.filter(|existing| !existing.is_empty()) {
117 value.push(if cfg!(windows) { ";" } else { ":" });
118 value.push(existing);
119 }
120 value
121}
122
123fn append_list(existing: Option<OsString>, entry: &str, separator: &str) -> OsString {
124 match existing.filter(|existing| !existing.is_empty()) {
125 Some(existing) => {
126 let mut value = existing;
127 value.push(separator);
128 value.push(entry);
129 value
130 }
131 None => entry.into(),
132 }
133}
134
135fn environment(
136 root: &Path,
137 run_id: &str,
138 runtime_directory: &Path,
139 plan_path: &Path,
140 evidence_directory: &Path,
141) -> Vec<(OsString, OsString)> {
142 let mut variables = std::env::vars_os().collect::<Vec<_>>();
143 let mut take = |key: &str| {
144 let position = variables.iter().position(|(name, _)| name == key);
145 position.map(|index| variables.remove(index).1)
146 };
147 let python_path = prepend_path_list(take("PYTHONPATH"), runtime_directory);
148 let pytest_plugins = append_list(take("PYTEST_PLUGINS"), "supercov_pytest", ",");
149 let pytest_addopts = take("PYTEST_ADDOPTS").unwrap_or_default();
157 for key in [
158 "SUPERCOV_PYTHON_PLAN",
159 "SUPERCOV_PYTHON_EVIDENCE_DIR",
160 "SUPERCOV_RUN_ID",
161 "SUPERCOV_PROJECT_ROOT",
162 "SUPERCOV_CONTEXT",
163 "SUPERCOV_PYTHON_WORKER",
164 ] {
165 take(key);
166 }
167 variables.extend([
168 ("PYTHONPATH".into(), python_path),
169 ("PYTEST_PLUGINS".into(), pytest_plugins),
170 ("PYTEST_ADDOPTS".into(), pytest_addopts),
171 (
172 "SUPERCOV_PYTHON_PLAN".into(),
173 plan_path.as_os_str().to_owned(),
174 ),
175 (
176 "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
177 evidence_directory.as_os_str().to_owned(),
178 ),
179 ("SUPERCOV_RUN_ID".into(), run_id.into()),
180 ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
181 ]);
182 variables
183}
184
185pub fn current_python_integrity(
188 root: &Path,
189 command: &[String],
190 source_roots: Option<&[String]>,
191) -> Result<crate::run_store::RunIntegrity, String> {
192 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
193 let mut files = crate::python_project::discover_python_files(&root)?;
194 if let Some(configured) = source_roots.filter(|roots| !roots.is_empty()) {
197 crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
198 .map_err(|error| error.to_string())?
199 .narrow(&mut files.sources, &mut files.excluded, String::as_str);
200 }
201 let mut inputs = python_integrity_inputs(&files, command);
202 crate::source_discovery::fold_roots_into_configuration(
203 &mut inputs.execution_configuration,
204 source_roots,
205 );
206 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_python())
207 .map_err(|error| error.to_string())
208}
209
210fn assertion_sites(
214 inventory: &[crate::assertion_map::InventorySite],
215) -> BTreeMap<String, Vec<usize>> {
216 let mut sites = BTreeMap::<String, Vec<usize>>::new();
217 for site in inventory {
218 sites
219 .entry(site.at.file.clone())
220 .or_default()
221 .push(site.at.line);
222 }
223 for lines in sites.values_mut() {
224 lines.sort_unstable();
225 lines.dedup();
226 }
227 sites
228}
229
230pub fn run_direct_python(
231 request: &DirectPythonRunRequest,
232 diagnostics: &mut dyn Write,
233) -> Result<DirectPythonRunResult, String> {
234 if request.command.is_empty() {
235 return Err("test command must not be empty".into());
236 }
237 let total_started = Instant::now();
238 let initialization_started = Instant::now();
239 let root = canonicalize_simplified(&request.root)
240 .map_err(|error| format!("{}: {error}", request.root.display()))?;
241 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
242 .map_err(|error| error.to_string())?;
243 let initialization_ms = elapsed_ms(initialization_started);
244 let work_directory = root.join(".supercov/work").join(&request.run_id);
245 let measured = std::cell::Cell::new(false);
249 let result = (|| {
250 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
251 .map_err(|error| error.to_string())?;
252 if !recovered_runs.is_empty() {
253 writeln!(
254 diagnostics,
255 "[supercov] recovered abandoned run(s): {}",
256 recovered_runs.join(", ")
257 )
258 .map_err(|error| error.to_string())?;
259 }
260
261 let adapter_started = Instant::now();
262 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
263 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
264 .map_err(|error| error.to_string())?;
265 let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
266 let source_roots = crate::source_discovery::configured_source_roots(
267 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
268 );
269 let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
270 crate::source_discovery::fold_roots_into_configuration(
271 &mut integrity_inputs.execution_configuration,
272 source_roots.as_deref(),
273 );
274 let integrity = create_explicit_run_integrity(
275 &root,
276 &integrity_inputs,
277 &FrontendIntegrityInputs::embedded_python(),
278 )
279 .map_err(|error| error.to_string())?;
280 let assertion_inputs = crate::assertion_inputs::capture(
281 &root,
282 "python",
283 python_integrity_inputs(&project.files, &request.command).assertion_paths(),
284 )?;
285 let python_directory = work_directory.join("python");
286 let runtime_directory = python_directory.join("runtime");
287 let evidence_directory = python_directory.join("evidence");
288 let plan_path = python_directory.join("plan.json");
289 write_runtime(&runtime_directory)?;
290 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
291 let plan = crate::python_instrumenter::PythonProbePlan {
292 assertion_sites: assertion_sites(&assertion_inputs.assertions),
293 ..project.plan.clone()
294 };
295 fs::write(
296 &plan_path,
297 serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
298 )
299 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
300 writeln!(
301 diagnostics,
302 "[supercov] detected Python; measuring {} source file(s) in place through probes compiled at import",
303 project.plan.files.len(),
304 )
305 .map_err(|error| error.to_string())?;
306 for (file, reason) in &project.unparseable {
307 writeln!(
308 diagnostics,
309 "[supercov] could not parse {file}: {reason}; it carries no obligations"
310 )
311 .map_err(|error| error.to_string())?;
312 }
313 let adapter_setup_ms = elapsed_ms(adapter_started);
314
315 let test_started = Instant::now();
316 let plan = ExecutionPlan {
317 preparation: Vec::new(),
318 test: ExecutionPhase {
319 name: "test".into(),
320 kind: PhaseKind::Test,
321 command: CommandSpec {
322 program: request.command[0].clone().into(),
323 arguments: request.command[1..].iter().map(OsString::from).collect(),
324 cwd: root.clone(),
325 environment: Some(environment(
326 &root,
327 &request.run_id,
328 &runtime_directory,
329 &plan_path,
330 &evidence_directory,
331 )),
332 captured_output: None,
333 },
334 },
335 };
336 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
337 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
338 .map_err(|error| error.to_string())?;
339 let test_command_ms = elapsed_ms(test_started);
340 if let Some(signal) = execution.interrupted_signal {
341 return Err(format!(
342 "the test command was interrupted by {signal:?}; no run was published"
343 ));
344 }
345
346 let publication_started = Instant::now();
347 let verbose = std::env::var("SUPERCOV_VERBOSE")
348 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
349 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
350 measured.set(true);
351 let run: PythonFrontendRun = build_python_frontend_run(
352 &project.manifest,
353 &evidence_directory,
354 &request.run_id,
355 &request.started_at,
356 execution.exit_code,
357 &PythonAssertionInventory::new(&root, &assertion_inputs),
358 )
359 .map_err(|error| error.to_string())?;
360 validate_frontend_report_request(&run.declaration, &run.request)
361 .map_err(|error| error.to_string())?;
362 let joined_ms = elapsed_ms(publication_started);
363 let archive_path = work_directory.join("evidence.raw.gz");
364 let entries = run.archive_entries().map_err(|error| error.to_string())?;
365 let serialized_ms = elapsed_ms(publication_started) - joined_ms;
366 let entries = crate::assertion_inputs::append(entries, &assertion_inputs)?;
367 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
368 if verbose {
369 writeln!(
370 diagnostics,
371 "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={}ms",
372 elapsed_ms(publication_started) - joined_ms - serialized_ms
373 )
374 .map_err(|error| error.to_string())?;
375 }
376 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
377 let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
378 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
379 copy_tree(&python_directory, &debug_directory)?;
380 }
381 remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
382 let evidence_publication_ms = elapsed_ms(publication_started);
383 let timings = RunTimings {
384 initialization_ms,
385 workspace_preparation_ms: 0.0,
386 adapter_setup_ms,
387 instrumented_build_ms: 0.0,
388 test_command_ms,
389 evidence_publication_ms,
390 };
391 let metadata = RunMetadata {
392 id: request.run_id.clone(),
393 started_at: request.started_at.clone(),
394 duration_ms: elapsed_ms(total_started),
395 command: request.command.clone(),
396 test_exit_code: Some(execution.exit_code),
397 integrity,
398 raw_evidence: RawEvidenceMetadata {
399 schema_version: raw.schema_version,
400 format: raw.format.into(),
401 file: raw.file.into(),
402 files: raw.files,
403 uncompressed_bytes: raw.uncompressed_bytes,
404 compressed_bytes: raw.compressed_bytes,
405 },
406 isolated_build: None,
407 instrumented_build_cache: None,
408 timings: Some(timings),
409 merged: None,
410 parents: None,
411 source_roots: source_roots.clone(),
412 };
413 let run_directory =
414 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
415 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
416 Ok(DirectPythonRunResult {
417 run_id: request.run_id.clone(),
418 run_directory,
419 exit_code: execution.exit_code,
420 tests: run.tests,
421 source_files: project.plan.files.len(),
422 interpreters: run.interpreters,
423 python_versions: run.python_versions,
424 recovered_runs,
425 metadata,
426 })
427 })();
428 let result = result.map_err(|error| {
429 if !measured.get() {
430 return error;
431 }
432 note_kept_evidence(
433 &root,
434 &work_directory.join("python/evidence"),
435 &request.run_id,
436 error,
437 )
438 });
439 if result.is_err() {
440 let _ = remove_stored_tree_deferred(&root, &work_directory);
441 }
442 let release = lock.release().map_err(|error| error.to_string());
443 match (result, release) {
444 (Ok(result), Ok(())) => Ok(result),
445 (Err(error), _) => Err(error),
446 (Ok(_), Err(error)) => Err(error),
447 }
448}
449
450#[cfg(test)]
451mod tests {
452
453 #[test]
458 fn the_plan_names_each_test_files_assertion_lines_once_and_sorted() {
459 use crate::assertion_map::{Anchor, InventorySite};
460 let site = |file: &str, line: usize, column: usize| InventorySite {
461 at: Anchor {
462 file: file.into(),
463 line,
464 column,
465 text: String::new(),
466 },
467 operation: "assert".into(),
468 };
469 let inventory = vec![
470 site("tests/test_b.py", 9, 4),
471 site("tests/test_a.py", 12, 4),
472 site("tests/test_a.py", 12, 30),
474 site("tests/test_a.py", 3, 4),
475 ];
476 let sites = assertion_sites(&inventory);
477 assert_eq!(
478 sites,
479 BTreeMap::from([
480 ("tests/test_a.py".to_string(), vec![3, 12]),
481 ("tests/test_b.py".to_string(), vec![9]),
482 ])
483 );
484
485 let plan = crate::python_instrumenter::PythonProbePlan {
488 version: 1,
489 root: "/p".into(),
490 files: BTreeMap::new(),
491 assertion_sites: sites,
492 };
493 let json = serde_json::to_string(&plan).unwrap();
494 assert!(
495 json.contains("\"assertionSites\":{\"tests/test_a.py\":[3,12]"),
496 "{json}"
497 );
498 let empty = crate::python_instrumenter::PythonProbePlan {
499 assertion_sites: BTreeMap::new(),
500 ..plan
501 };
502 assert!(
503 !serde_json::to_string(&empty)
504 .unwrap()
505 .contains("assertionSites")
506 );
507 }
508 use super::*;
509 use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
510
511 fn fixture(name: &str) -> PathBuf {
512 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
513 let root = std::env::temp_dir().join(format!(
514 "supercov-python-run-{}-{}-{name}",
515 std::process::id(),
516 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
517 ));
518 let _ = fs::remove_dir_all(&root);
519 for (file, contents) in [
520 ("a.py", "def f(x):\n return x if x else 0\n"),
521 ("vendor/pkg/b.py", "def g():\n return 1\n"),
522 ("test/test_a.py", "from a import f\n"),
523 ] {
524 let path = root.join(file);
525 fs::create_dir_all(path.parent().unwrap()).unwrap();
526 fs::write(path, contents).unwrap();
527 }
528 canonicalize_simplified(&root).unwrap()
529 }
530
531 #[test]
532 fn a_rooted_run_is_current_whatever_shell_asks() {
533 let root = fixture("current");
539 let configured = vec!["a.py".to_owned()];
540 let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
541
542 let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
543 let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
544 let mut inputs = python_integrity_inputs(&project.files, &command);
545 fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
546 let recorded = create_explicit_run_integrity(
547 &root,
548 &inputs,
549 &FrontendIntegrityInputs::embedded_python(),
550 )
551 .unwrap();
552
553 let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
554 assert_eq!(recorded.fingerprint, replayed.fingerprint);
555
556 let ignoring = current_python_integrity(&root, &command, None).unwrap();
559 assert_ne!(recorded.fingerprint, ignoring.fingerprint);
560
561 fs::write(root.join("vendor/pkg/b.py"), "def g():\n return 2\n").unwrap();
564 assert_eq!(
565 current_python_integrity(&root, &command, Some(&configured))
566 .unwrap()
567 .fingerprint,
568 recorded.fingerprint,
569 "a vendored file changing does not make a rooted run stale"
570 );
571 fs::write(root.join("a.py"), "def f(x):\n return 1\n").unwrap();
572 assert_ne!(
573 current_python_integrity(&root, &command, Some(&configured))
574 .unwrap()
575 .fingerprint,
576 recorded.fingerprint,
577 "a first-party file changing does"
578 );
579 fs::remove_dir_all(root).ok();
580 }
581}