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        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    // Supercov no longer turns on pytest's assertion-pass hook. Assertion
150    // sites come from line events on the lines the plan names, and the hook
151    // made pytest build a failure explanation for every passing assertion --
152    // 30 microseconds each on two integers, 140 on a nested dict, more than
153    // everything else the runtime does put together. A user's own
154    // `PYTEST_ADDOPTS` passes through untouched; if it enables the hook, the
155    // plugin still records what the hook reports.
156    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
185/// The fingerprint a later query compares against the stored run: the same
186/// discovery and inputs the run used, without preparing a plan.
187pub 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    // The roots the run was measured under, not this shell's: the run
195    // digested the files they kept, and nothing else would match it.
196    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
210/// The lines of each test file that hold an inventoried assertion site, for
211/// the plan. Sorted and deduplicated: a line with two sites is one line to
212/// arm, and the report keeps telling the two apart by column.
213fn 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    // Whether the tests were measured. A run that failed before that has no
246    // evidence worth keeping; one that failed after has evidence that cost the
247    // suite its wall clock.
248    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    // The plan names each test file's assertion lines from the inventory, so
454    // the runtime can record a site from the line event that runs it and
455    // pytest's assertion-pass hook -- a failure explanation built for every
456    // passing assertion -- stays off.
457    #[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            // Two sites on one line are one line to arm.
473            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        // Serialized under the key the runtime reads, and absent -- not an
486        // empty object -- when there is nothing to arm.
487        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        // A run recorded under roots digested only the files they kept, while
534        // the query that later judged it rediscovered every file: the two could
535        // never agree, and each rooted run read as stale the moment it was
536        // written. The query has to replay the roots the run recorded, because
537        // the shell it runs in is usually not the one the run came from.
538        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        // Judged without the roots, it is a different run -- which is what the
557        // query used to do.
558        let ignoring = current_python_integrity(&root, &command, None).unwrap();
559        assert_ne!(recorded.fingerprint, ignoring.fingerprint);
560
561        // A change the roots left out is not a change to this run; one they
562        // kept is.
563        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}