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    ffi::OsString,
11    fs,
12    io::Write,
13    path::{Path, PathBuf},
14    time::Instant,
15};
16
17use serde::{Deserialize, Serialize};
18
19use crate::workspace::canonicalize_simplified;
20use crate::{
21    evidence_archive::write_archive,
22    frontend_protocol::validate_frontend_report_request,
23    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
24    lifecycle::{
25        ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
26        remove_stored_tree_deferred,
27    },
28    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
29    process_supervision::{CommandSpec, SupervisionOptions},
30    python_evidence::{PythonAssertionInventory, PythonFrontendRun, build_python_frontend_run},
31    python_project::{PreparedPythonProject, prepare_python_project, python_integrity_inputs},
32    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
33};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct DirectPythonRunRequest {
38    pub root: PathBuf,
39    pub command: Vec<String>,
40    pub run_id: String,
41    pub started_at: String,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct DirectPythonRunResult {
46    pub run_id: String,
47    pub run_directory: PathBuf,
48    pub exit_code: i32,
49    pub tests: usize,
50    pub source_files: usize,
51    pub interpreters: usize,
52    pub python_versions: Vec<String>,
53    pub recovered_runs: Vec<String>,
54    pub metadata: RunMetadata,
55}
56
57fn elapsed_ms(started: Instant) -> f64 {
58    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
59}
60
61fn embedded_runtime_files() -> [(&'static str, &'static [u8]); 4] {
62    [
63        (
64            "sitecustomize.py",
65            include_bytes!("../runtime-assets/python/sitecustomize.py"),
66        ),
67        (
68            "supercov_runtime.py",
69            include_bytes!("../runtime-assets/python/supercov_runtime.py"),
70        ),
71        (
72            "supercov_pytest.py",
73            include_bytes!("../runtime-assets/python/supercov_pytest.py"),
74        ),
75        (
76            "supercov_unittest.py",
77            include_bytes!("../runtime-assets/python/supercov_unittest.py"),
78        ),
79    ]
80}
81
82fn write_runtime(directory: &Path) -> Result<(), String> {
83    fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
84    for (name, contents) in embedded_runtime_files() {
85        let path = directory.join(name);
86        fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
87    }
88    Ok(())
89}
90
91fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
92    for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
93        let entry = entry.map_err(|error| error.to_string())?;
94        let target = destination.join(entry.file_name());
95        if entry
96            .file_type()
97            .map_err(|error| error.to_string())?
98            .is_dir()
99        {
100            fs::create_dir_all(&target).map_err(|error| error.to_string())?;
101            copy_tree(&entry.path(), &target)?;
102        } else {
103            fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
104        }
105    }
106    Ok(())
107}
108
109fn prepend_path_list(existing: Option<OsString>, entry: &Path) -> OsString {
110    let mut value = entry.as_os_str().to_owned();
111    if let Some(existing) = existing.filter(|existing| !existing.is_empty()) {
112        value.push(if cfg!(windows) { ";" } else { ":" });
113        value.push(existing);
114    }
115    value
116}
117
118fn append_list(existing: Option<OsString>, entry: &str, separator: &str) -> OsString {
119    match existing.filter(|existing| !existing.is_empty()) {
120        Some(existing) => {
121            let mut value = existing;
122            value.push(separator);
123            value.push(entry);
124            value
125        }
126        None => entry.into(),
127    }
128}
129
130fn environment(
131    root: &Path,
132    run_id: &str,
133    runtime_directory: &Path,
134    plan_path: &Path,
135    evidence_directory: &Path,
136) -> Vec<(OsString, OsString)> {
137    let mut variables = std::env::vars_os().collect::<Vec<_>>();
138    let mut take = |key: &str| {
139        let position = variables.iter().position(|(name, _)| name == key);
140        position.map(|index| variables.remove(index).1)
141    };
142    let python_path = prepend_path_list(take("PYTHONPATH"), runtime_directory);
143    let pytest_plugins = append_list(take("PYTEST_PLUGINS"), "supercov_pytest", ",");
144    // pytest calls its assertion-pass hook only from modules rewritten with
145    // this option on; the plugin gives those rewrites a cache name of their
146    // own. First in the list, so an explicit `-o` on the command line wins.
147    let pytest_addopts = {
148        let mut value = OsString::from("-o enable_assertion_pass_hook=true");
149        if let Some(existing) = take("PYTEST_ADDOPTS").filter(|existing| !existing.is_empty()) {
150            value.push(" ");
151            value.push(existing);
152        }
153        value
154    };
155    for key in [
156        "SUPERCOV_PYTHON_PLAN",
157        "SUPERCOV_PYTHON_EVIDENCE_DIR",
158        "SUPERCOV_RUN_ID",
159        "SUPERCOV_PROJECT_ROOT",
160        "SUPERCOV_CONTEXT",
161        "SUPERCOV_PYTHON_WORKER",
162    ] {
163        take(key);
164    }
165    variables.extend([
166        ("PYTHONPATH".into(), python_path),
167        ("PYTEST_PLUGINS".into(), pytest_plugins),
168        ("PYTEST_ADDOPTS".into(), pytest_addopts),
169        (
170            "SUPERCOV_PYTHON_PLAN".into(),
171            plan_path.as_os_str().to_owned(),
172        ),
173        (
174            "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
175            evidence_directory.as_os_str().to_owned(),
176        ),
177        ("SUPERCOV_RUN_ID".into(), run_id.into()),
178        ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
179    ]);
180    variables
181}
182
183/// The fingerprint a later query compares against the stored run: the same
184/// discovery and inputs the run used, without preparing a plan.
185pub fn current_python_integrity(
186    root: &Path,
187    command: &[String],
188    source_roots: Option<&[String]>,
189) -> Result<crate::run_store::RunIntegrity, String> {
190    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
191    let mut files = crate::python_project::discover_python_files(&root)?;
192    // The roots the run was measured under, not this shell's: the run
193    // digested the files they kept, and nothing else would match it.
194    if let Some(configured) = source_roots.filter(|roots| !roots.is_empty()) {
195        crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
196            .map_err(|error| error.to_string())?
197            .narrow(&mut files.sources, &mut files.excluded, String::as_str);
198    }
199    let mut inputs = python_integrity_inputs(&files, command);
200    crate::source_discovery::fold_roots_into_configuration(
201        &mut inputs.execution_configuration,
202        source_roots,
203    );
204    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_python())
205        .map_err(|error| error.to_string())
206}
207
208pub fn run_direct_python(
209    request: &DirectPythonRunRequest,
210    diagnostics: &mut dyn Write,
211) -> Result<DirectPythonRunResult, String> {
212    if request.command.is_empty() {
213        return Err("test command must not be empty".into());
214    }
215    let total_started = Instant::now();
216    let initialization_started = Instant::now();
217    let root = canonicalize_simplified(&request.root)
218        .map_err(|error| format!("{}: {error}", request.root.display()))?;
219    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
220        .map_err(|error| error.to_string())?;
221    let initialization_ms = elapsed_ms(initialization_started);
222    let work_directory = root.join(".supercov/work").join(&request.run_id);
223    let result = (|| {
224        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
225            .map_err(|error| error.to_string())?;
226        if !recovered_runs.is_empty() {
227            writeln!(
228                diagnostics,
229                "[supercov] recovered abandoned run(s): {}",
230                recovered_runs.join(", ")
231            )
232            .map_err(|error| error.to_string())?;
233        }
234
235        let adapter_started = Instant::now();
236        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
237        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
238            .map_err(|error| error.to_string())?;
239        let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
240        let source_roots = crate::source_discovery::configured_source_roots(
241            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
242        );
243        let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
244        crate::source_discovery::fold_roots_into_configuration(
245            &mut integrity_inputs.execution_configuration,
246            source_roots.as_deref(),
247        );
248        let integrity = create_explicit_run_integrity(
249            &root,
250            &integrity_inputs,
251            &FrontendIntegrityInputs::embedded_python(),
252        )
253        .map_err(|error| error.to_string())?;
254        let assertion_inputs = crate::assertion_inputs::capture(
255            &root,
256            "python",
257            python_integrity_inputs(&project.files, &request.command).assertion_paths(),
258        )?;
259        let python_directory = work_directory.join("python");
260        let runtime_directory = python_directory.join("runtime");
261        let evidence_directory = python_directory.join("evidence");
262        let plan_path = python_directory.join("plan.json");
263        write_runtime(&runtime_directory)?;
264        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
265        fs::write(
266            &plan_path,
267            serde_json::to_vec(&project.plan).map_err(|error| error.to_string())?,
268        )
269        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
270        writeln!(
271            diagnostics,
272            "[supercov] detected Python; measuring {} source file(s) in place through CPython monitoring",
273            project.plan.files.len()
274        )
275        .map_err(|error| error.to_string())?;
276        for (file, reason) in &project.unparseable {
277            writeln!(
278                diagnostics,
279                "[supercov] could not parse {file}: {reason}; it carries no obligations"
280            )
281            .map_err(|error| error.to_string())?;
282        }
283        let adapter_setup_ms = elapsed_ms(adapter_started);
284
285        let test_started = Instant::now();
286        let plan = ExecutionPlan {
287            preparation: Vec::new(),
288            test: ExecutionPhase {
289                name: "test".into(),
290                kind: PhaseKind::Test,
291                command: CommandSpec {
292                    program: request.command[0].clone().into(),
293                    arguments: request.command[1..].iter().map(OsString::from).collect(),
294                    cwd: root.clone(),
295                    environment: Some(environment(
296                        &root,
297                        &request.run_id,
298                        &runtime_directory,
299                        &plan_path,
300                        &evidence_directory,
301                    )),
302                    captured_output: None,
303                },
304            },
305        };
306        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
307        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
308            .map_err(|error| error.to_string())?;
309        let test_command_ms = elapsed_ms(test_started);
310        if let Some(signal) = execution.interrupted_signal {
311            return Err(format!(
312                "the test command was interrupted by {signal:?}; no run was published"
313            ));
314        }
315
316        let publication_started = Instant::now();
317        let verbose = std::env::var("SUPERCOV_VERBOSE")
318            .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
319            .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
320        let run: PythonFrontendRun = build_python_frontend_run(
321            &project.manifest,
322            &evidence_directory,
323            &request.run_id,
324            &request.started_at,
325            execution.exit_code,
326            &PythonAssertionInventory::new(&root, &assertion_inputs),
327        )
328        .map_err(|error| error.to_string())?;
329        validate_frontend_report_request(&run.declaration, &run.request)
330            .map_err(|error| error.to_string())?;
331        let joined_ms = elapsed_ms(publication_started);
332        let archive_path = work_directory.join("evidence.raw.gz");
333        let entries = run.archive_entries().map_err(|error| error.to_string())?;
334        let serialized_ms = elapsed_ms(publication_started) - joined_ms;
335        let entries = crate::assertion_inputs::append(entries, &assertion_inputs)?;
336        let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
337        if verbose {
338            writeln!(
339                diagnostics,
340                "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={}ms",
341                elapsed_ms(publication_started) - joined_ms - serialized_ms
342            )
343            .map_err(|error| error.to_string())?;
344        }
345        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
346            let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
347            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
348            copy_tree(&python_directory, &debug_directory)?;
349        }
350        remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
351        let evidence_publication_ms = elapsed_ms(publication_started);
352        let timings = RunTimings {
353            initialization_ms,
354            workspace_preparation_ms: 0.0,
355            adapter_setup_ms,
356            instrumented_build_ms: 0.0,
357            test_command_ms,
358            evidence_publication_ms,
359        };
360        let metadata = RunMetadata {
361            id: request.run_id.clone(),
362            started_at: request.started_at.clone(),
363            duration_ms: elapsed_ms(total_started),
364            command: request.command.clone(),
365            test_exit_code: Some(execution.exit_code),
366            integrity,
367            raw_evidence: RawEvidenceMetadata {
368                schema_version: raw.schema_version,
369                format: raw.format.into(),
370                file: raw.file.into(),
371                files: raw.files,
372                uncompressed_bytes: raw.uncompressed_bytes,
373                compressed_bytes: raw.compressed_bytes,
374            },
375            isolated_build: None,
376            instrumented_build_cache: None,
377            timings: Some(timings),
378            merged: None,
379            parents: None,
380            source_roots: source_roots.clone(),
381        };
382        let run_directory =
383            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
384        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
385        Ok(DirectPythonRunResult {
386            run_id: request.run_id.clone(),
387            run_directory,
388            exit_code: execution.exit_code,
389            tests: run.tests,
390            source_files: project.plan.files.len(),
391            interpreters: run.interpreters,
392            python_versions: run.python_versions,
393            recovered_runs,
394            metadata,
395        })
396    })();
397    if result.is_err() {
398        let _ = remove_stored_tree_deferred(&root, &work_directory);
399    }
400    let release = lock.release().map_err(|error| error.to_string());
401    match (result, release) {
402        (Ok(result), Ok(())) => Ok(result),
403        (Err(error), _) => Err(error),
404        (Ok(_), Err(error)) => Err(error),
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
412
413    fn fixture(name: &str) -> PathBuf {
414        static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
415        let root = std::env::temp_dir().join(format!(
416            "supercov-python-run-{}-{}-{name}",
417            std::process::id(),
418            UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
419        ));
420        let _ = fs::remove_dir_all(&root);
421        for (file, contents) in [
422            ("a.py", "def f(x):\n    return x if x else 0\n"),
423            ("vendor/pkg/b.py", "def g():\n    return 1\n"),
424            ("test/test_a.py", "from a import f\n"),
425        ] {
426            let path = root.join(file);
427            fs::create_dir_all(path.parent().unwrap()).unwrap();
428            fs::write(path, contents).unwrap();
429        }
430        canonicalize_simplified(&root).unwrap()
431    }
432
433    #[test]
434    fn a_rooted_run_is_current_whatever_shell_asks() {
435        // A run recorded under roots digested only the files they kept, while
436        // the query that later judged it rediscovered every file: the two could
437        // never agree, and each rooted run read as stale the moment it was
438        // written. The query has to replay the roots the run recorded, because
439        // the shell it runs in is usually not the one the run came from.
440        let root = fixture("current");
441        let configured = vec!["a.py".to_owned()];
442        let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
443
444        let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
445        let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
446        let mut inputs = python_integrity_inputs(&project.files, &command);
447        fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
448        let recorded = create_explicit_run_integrity(
449            &root,
450            &inputs,
451            &FrontendIntegrityInputs::embedded_python(),
452        )
453        .unwrap();
454
455        let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
456        assert_eq!(recorded.fingerprint, replayed.fingerprint);
457
458        // Judged without the roots, it is a different run -- which is what the
459        // query used to do.
460        let ignoring = current_python_integrity(&root, &command, None).unwrap();
461        assert_ne!(recorded.fingerprint, ignoring.fingerprint);
462
463        // A change the roots left out is not a change to this run; one they
464        // kept is.
465        fs::write(root.join("vendor/pkg/b.py"), "def g():\n    return 2\n").unwrap();
466        assert_eq!(
467            current_python_integrity(&root, &command, Some(&configured))
468                .unwrap()
469                .fingerprint,
470            recorded.fingerprint,
471            "a vendored file changing does not make a rooted run stale"
472        );
473        fs::write(root.join("a.py"), "def f(x):\n    return 1\n").unwrap();
474        assert_ne!(
475            current_python_integrity(&root, &command, Some(&configured))
476                .unwrap()
477                .fingerprint,
478            recorded.fingerprint,
479            "a first-party file changing does"
480        );
481        fs::remove_dir_all(root).ok();
482    }
483}