Skip to main content

supercov_engine/
python_run.rs

1//! Public Python coverage run lifecycle.
2//!
3//! The project runs in place with its own interpreter, environment and test
4//! command. Supercov prepares the complete obligation manifest and probe plan
5//! from source, materialises its stdlib-only runtime under `.supercov/`,
6//! points the interpreter at it through environment variables, supervises the
7//! user's command unchanged, and publishes the joined evidence.
8
9use 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    // Supercov no longer turns on pytest's assertion-pass hook. Assertion
154    // sites come from line events on the lines the plan names, and the hook
155    // made pytest build a failure explanation for every passing assertion --
156    // 30 microseconds each on two integers, 140 on a nested dict, more than
157    // everything else the runtime does put together. A user's own
158    // `PYTEST_ADDOPTS` passes through untouched; if it enables the hook, the
159    // plugin still records what the hook reports.
160    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
189/// The fingerprint a later query compares against the stored run: the same
190/// discovery and inputs the run used, without preparing a plan.
191pub 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    // The roots the run was measured under, not this shell's: the run
199    // digested the files they kept, and nothing else would match it.
200    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
214/// The lines of each test file that hold an inventoried assertion site, for
215/// the plan. Sorted and deduplicated: a line with two sites is one line to
216/// arm, and the report keeps telling the two apart by column.
217fn 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    // Whether the tests were measured. A run that failed before that has no
250    // evidence worth keeping; one that failed after has evidence that cost the
251    // suite its wall clock.
252    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        // Analysed as the archive will analyse -- the request it archives,
369        // with the run's integrity record, which the archive does not hold
370        // -- while it is written: neither waits on the other.
371        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        // The joined evidence is in the archive now, and publication reads it
397        // from there. Held to the end for three counts, it was the largest
398        // thing alive while publication analysed the archive: 1.4 GB on a
399        // 3,900-test run, on top of the analysis's own.
400        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    // The plan names each test file's assertion lines from the inventory, so
496    // the runtime can record a site from the line event that runs it and
497    // pytest's assertion-pass hook -- a failure explanation built for every
498    // passing assertion -- stays off.
499    #[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            // Two sites on one line are one line to arm.
515            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        // Serialized under the key the runtime reads, and absent -- not an
528        // empty object -- when there is nothing to arm.
529        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        // A run recorded under roots digested only the files they kept, while
576        // the query that later judged it rediscovered every file: the two could
577        // never agree, and each rooted run read as stale the moment it was
578        // written. The query has to replay the roots the run recorded, because
579        // the shell it runs in is usually not the one the run came from.
580        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        // Judged without the roots, it is a different run -- which is what the
599        // query used to do.
600        let ignoring = current_python_integrity(&root, &command, None).unwrap();
601        assert_ne!(recorded.fingerprint, ignoring.fingerprint);
602
603        // A change the roots left out is not a change to this run; one they
604        // kept is.
605        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}