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::{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_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
190/// The fingerprint a later query compares against the stored run: the same
191/// discovery and inputs the run used, without preparing a plan.
192pub 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    // The roots the run was measured under, not this shell's: the run
200    // digested the files they kept, and nothing else would match it.
201    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
221/// The lines of each captured Python file that hold an assertion -- an
222/// `assert` statement or an `assert*` method call -- for the plan. The probe on
223/// such a line marks where its test's assertion phase begins. Sorted and
224/// deduplicated: a line with two assertions is one line to arm.
225fn 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
244/// The byte offset of every `assert` statement and `assert*` method call.
245fn 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
284/// Archive a run, analysed as the archive will analyse -- the request it
285/// archives, with the run's integrity record, which the archive does not
286/// hold -- while it is written: neither waits on the other.
287fn 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    // Whether the tests were measured. A run that failed before that has no
340    // evidence worth keeping; one that failed after has evidence that cost the
341    // suite its wall clock.
342    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            // No test reported an outcome and the command itself failed: it
455            // never ran its tests (`No module named pytest`, an error while
456            // collecting). That is the command's failure, said in its own
457            // output, not a Supercov bug to report with evidence.
458            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            // The interpreter or the runner the command used is not one
469            // Supercov measures: the message says what to change, and the
470            // evidence has nothing in it to diagnose.
471            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        // The joined evidence is in the archive now, and publication reads it
500        // from there. Held to the end for three counts, it was the largest
501        // thing alive while publication analysed the archive: 1.4 GB on a
502        // 3,900-test run, on top of the analysis's own.
503        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
599/// Python measured inside a browser suite's run: the server its tests drive,
600/// which Playwright's `webServer` or a fixture starts. Prepared whenever the
601/// project has Python source, since only the server's launch says which
602/// program it is; a run whose suite starts no measured Python publishes
603/// nothing of it.
604pub 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    /// The plan and runtime for the project's Python source, or None when it
614    /// has none. `workspace` is where the suite runs: the probes find the
615    /// server's files there.
616    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        // Roots named for the JavaScript source may hold no Python at all.
624        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    /// What the suite's environment needs for a Python process it starts to
677    /// be measured; the run id and project root are the browser run's own.
678    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    /// Publish what the server ran as a Python run of its own, credited to
704    /// the browser tests that sent each request. None when no measured
705    /// Python process ran, or when none answered a test's request: Python a
706    /// suite only ran in passing (a build tool, a seed script) is not a
707    /// server's coverage.
708    #[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            // No browser test reported: the suite was not Playwright's, or
721            // never got to its tests.
722            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            // The evidence was recorded under the browser run's id; a run
754            // published beside it has its own.
755            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    // The plan names each test file's assertion lines, so the runtime can
828    // mark where a test's assertion phase begins from the line event that runs
829    // it, and pytest's assertion-pass hook -- a failure explanation built for
830    // every passing assertion -- stays off.
831    #[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                // Two assertions on line 12 are one line to arm.
838                "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        // Serialized under the key the runtime reads, and absent -- not an
853        // empty object -- when there is nothing to arm.
854        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        // A run recorded under roots digested only the files they kept, while
901        // the query that later judged it rediscovered every file: the two could
902        // never agree, and each rooted run read as stale the moment it was
903        // written. The query has to replay the roots the run recorded, because
904        // the shell it runs in is usually not the one the run came from.
905        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        // Judged without the roots, it is a different run -- which is what the
924        // query used to do.
925        let ignoring = current_python_integrity(&root, &command, None).unwrap();
926        assert_ne!(recorded.fingerprint, ignoring.fingerprint);
927
928        // A change the roots left out is not a change to this run; one they
929        // kept is.
930        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}