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::{
20    evidence_archive::write_archive,
21    frontend_protocol::validate_frontend_report_request,
22    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
23    lifecycle::{
24        ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
25        remove_stored_tree_deferred,
26    },
27    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
28    process_supervision::{CommandSpec, SupervisionOptions},
29    python_evidence::{PythonFrontendRun, build_python_frontend_run},
30    python_project::{PreparedPythonProject, prepare_python_project, python_integrity_inputs},
31    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
32};
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct DirectPythonRunRequest {
37    pub root: PathBuf,
38    pub command: Vec<String>,
39    pub run_id: String,
40    pub started_at: String,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct DirectPythonRunResult {
45    pub run_id: String,
46    pub run_directory: PathBuf,
47    pub exit_code: i32,
48    pub tests: usize,
49    pub source_files: usize,
50    pub interpreters: usize,
51    pub python_versions: Vec<String>,
52    pub recovered_runs: Vec<String>,
53    pub metadata: RunMetadata,
54}
55
56fn elapsed_ms(started: Instant) -> f64 {
57    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
58}
59
60fn embedded_runtime_files() -> [(&'static str, &'static [u8]); 4] {
61    [
62        (
63            "sitecustomize.py",
64            include_bytes!("../runtime-assets/python/sitecustomize.py"),
65        ),
66        (
67            "supercov_runtime.py",
68            include_bytes!("../runtime-assets/python/supercov_runtime.py"),
69        ),
70        (
71            "supercov_pytest.py",
72            include_bytes!("../runtime-assets/python/supercov_pytest.py"),
73        ),
74        (
75            "supercov_unittest.py",
76            include_bytes!("../runtime-assets/python/supercov_unittest.py"),
77        ),
78    ]
79}
80
81fn write_runtime(directory: &Path) -> Result<(), String> {
82    fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
83    for (name, contents) in embedded_runtime_files() {
84        let path = directory.join(name);
85        fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
86    }
87    Ok(())
88}
89
90fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
91    for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
92        let entry = entry.map_err(|error| error.to_string())?;
93        let target = destination.join(entry.file_name());
94        if entry
95            .file_type()
96            .map_err(|error| error.to_string())?
97            .is_dir()
98        {
99            fs::create_dir_all(&target).map_err(|error| error.to_string())?;
100            copy_tree(&entry.path(), &target)?;
101        } else {
102            fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
103        }
104    }
105    Ok(())
106}
107
108fn prepend_path_list(existing: Option<OsString>, entry: &Path) -> OsString {
109    let mut value = entry.as_os_str().to_owned();
110    if let Some(existing) = existing.filter(|existing| !existing.is_empty()) {
111        value.push(if cfg!(windows) { ";" } else { ":" });
112        value.push(existing);
113    }
114    value
115}
116
117fn append_list(existing: Option<OsString>, entry: &str, separator: &str) -> OsString {
118    match existing.filter(|existing| !existing.is_empty()) {
119        Some(existing) => {
120            let mut value = existing;
121            value.push(separator);
122            value.push(entry);
123            value
124        }
125        None => entry.into(),
126    }
127}
128
129fn environment(
130    root: &Path,
131    run_id: &str,
132    runtime_directory: &Path,
133    plan_path: &Path,
134    evidence_directory: &Path,
135) -> Vec<(OsString, OsString)> {
136    let mut variables = std::env::vars_os().collect::<Vec<_>>();
137    let mut take = |key: &str| {
138        let position = variables.iter().position(|(name, _)| name == key);
139        position.map(|index| variables.remove(index).1)
140    };
141    let python_path = prepend_path_list(take("PYTHONPATH"), runtime_directory);
142    let pytest_plugins = append_list(take("PYTEST_PLUGINS"), "supercov_pytest", ",");
143    for key in [
144        "SUPERCOV_PYTHON_PLAN",
145        "SUPERCOV_PYTHON_EVIDENCE_DIR",
146        "SUPERCOV_RUN_ID",
147        "SUPERCOV_PROJECT_ROOT",
148        "SUPERCOV_CONTEXT",
149        "SUPERCOV_PYTHON_WORKER",
150    ] {
151        take(key);
152    }
153    variables.extend([
154        ("PYTHONPATH".into(), python_path),
155        ("PYTEST_PLUGINS".into(), pytest_plugins),
156        (
157            "SUPERCOV_PYTHON_PLAN".into(),
158            plan_path.as_os_str().to_owned(),
159        ),
160        (
161            "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
162            evidence_directory.as_os_str().to_owned(),
163        ),
164        ("SUPERCOV_RUN_ID".into(), run_id.into()),
165        ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
166    ]);
167    variables
168}
169
170/// The fingerprint a later query compares against the stored run: the same
171/// discovery and inputs the run used, without preparing a plan.
172pub fn current_python_integrity(
173    root: &Path,
174    command: &[String],
175) -> Result<crate::run_store::RunIntegrity, String> {
176    let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
177    let files = crate::python_project::discover_python_files(&root)?;
178    create_explicit_run_integrity(
179        &root,
180        &python_integrity_inputs(&files, command),
181        &FrontendIntegrityInputs::embedded_python(),
182    )
183    .map_err(|error| error.to_string())
184}
185
186pub fn run_direct_python(
187    request: &DirectPythonRunRequest,
188    diagnostics: &mut dyn Write,
189) -> Result<DirectPythonRunResult, String> {
190    if request.command.is_empty() {
191        return Err("test command must not be empty".into());
192    }
193    let total_started = Instant::now();
194    let initialization_started = Instant::now();
195    let root = fs::canonicalize(&request.root)
196        .map_err(|error| format!("{}: {error}", request.root.display()))?;
197    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
198        .map_err(|error| error.to_string())?;
199    let initialization_ms = elapsed_ms(initialization_started);
200    let work_directory = root.join(".supercov/work").join(&request.run_id);
201    let result = (|| {
202        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
203            .map_err(|error| error.to_string())?;
204        if !recovered_runs.is_empty() {
205            writeln!(
206                diagnostics,
207                "[supercov] recovered abandoned run(s): {}",
208                recovered_runs.join(", ")
209            )
210            .map_err(|error| error.to_string())?;
211        }
212
213        let adapter_started = Instant::now();
214        let project: PreparedPythonProject = prepare_python_project(&root)?;
215        let integrity = create_explicit_run_integrity(
216            &root,
217            &python_integrity_inputs(&project.files, &request.command),
218            &FrontendIntegrityInputs::embedded_python(),
219        )
220        .map_err(|error| error.to_string())?;
221        let python_directory = work_directory.join("python");
222        let runtime_directory = python_directory.join("runtime");
223        let evidence_directory = python_directory.join("evidence");
224        let plan_path = python_directory.join("plan.json");
225        write_runtime(&runtime_directory)?;
226        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
227        fs::write(
228            &plan_path,
229            serde_json::to_vec(&project.plan).map_err(|error| error.to_string())?,
230        )
231        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
232        writeln!(
233            diagnostics,
234            "[supercov] detected Python; measuring {} source file(s) in place through CPython monitoring",
235            project.plan.files.len()
236        )
237        .map_err(|error| error.to_string())?;
238        for (file, reason) in &project.unparseable {
239            writeln!(
240                diagnostics,
241                "[supercov] could not parse {file}: {reason}; it carries no obligations"
242            )
243            .map_err(|error| error.to_string())?;
244        }
245        let adapter_setup_ms = elapsed_ms(adapter_started);
246
247        let test_started = Instant::now();
248        let plan = ExecutionPlan {
249            preparation: Vec::new(),
250            test: ExecutionPhase {
251                name: "test".into(),
252                kind: PhaseKind::Test,
253                command: CommandSpec {
254                    program: request.command[0].clone().into(),
255                    arguments: request.command[1..].iter().map(OsString::from).collect(),
256                    cwd: root.clone(),
257                    environment: Some(environment(
258                        &root,
259                        &request.run_id,
260                        &runtime_directory,
261                        &plan_path,
262                        &evidence_directory,
263                    )),
264                    captured_output: None,
265                },
266            },
267        };
268        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
269        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
270            .map_err(|error| error.to_string())?;
271        let test_command_ms = elapsed_ms(test_started);
272        if let Some(signal) = execution.interrupted_signal {
273            return Err(format!(
274                "the test command was interrupted by {signal:?}; no run was published"
275            ));
276        }
277
278        let publication_started = Instant::now();
279        let verbose = std::env::var("SUPERCOV_VERBOSE")
280            .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
281            .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
282        let run: PythonFrontendRun = build_python_frontend_run(
283            &project.manifest,
284            &evidence_directory,
285            &request.run_id,
286            &request.started_at,
287            execution.exit_code,
288        )
289        .map_err(|error| error.to_string())?;
290        validate_frontend_report_request(&run.declaration, &run.request)
291            .map_err(|error| error.to_string())?;
292        let joined_ms = elapsed_ms(publication_started);
293        let archive_path = work_directory.join("evidence.raw.gz");
294        let entries = run.archive_entries().map_err(|error| error.to_string())?;
295        let serialized_ms = elapsed_ms(publication_started) - joined_ms;
296        let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
297        if verbose {
298            writeln!(
299                diagnostics,
300                "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={}ms",
301                elapsed_ms(publication_started) - joined_ms - serialized_ms
302            )
303            .map_err(|error| error.to_string())?;
304        }
305        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
306            let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
307            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
308            copy_tree(&python_directory, &debug_directory)?;
309        }
310        remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
311        let evidence_publication_ms = elapsed_ms(publication_started);
312        let timings = RunTimings {
313            initialization_ms,
314            workspace_preparation_ms: 0.0,
315            adapter_setup_ms,
316            instrumented_build_ms: 0.0,
317            test_command_ms,
318            evidence_publication_ms,
319        };
320        let metadata = RunMetadata {
321            id: request.run_id.clone(),
322            started_at: request.started_at.clone(),
323            duration_ms: elapsed_ms(total_started),
324            command: request.command.clone(),
325            test_exit_code: Some(execution.exit_code),
326            integrity,
327            raw_evidence: RawEvidenceMetadata {
328                schema_version: raw.schema_version,
329                format: raw.format.into(),
330                file: raw.file.into(),
331                files: raw.files,
332                uncompressed_bytes: raw.uncompressed_bytes,
333                compressed_bytes: raw.compressed_bytes,
334            },
335            isolated_build: None,
336            instrumented_build_cache: None,
337            timings: Some(timings),
338            merged: None,
339            parents: None,
340        };
341        let run_directory =
342            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
343        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
344        Ok(DirectPythonRunResult {
345            run_id: request.run_id.clone(),
346            run_directory,
347            exit_code: execution.exit_code,
348            tests: run.tests,
349            source_files: project.plan.files.len(),
350            interpreters: run.interpreters,
351            python_versions: run.python_versions,
352            recovered_runs,
353            metadata,
354        })
355    })();
356    if result.is_err() {
357        let _ = remove_stored_tree_deferred(&root, &work_directory);
358    }
359    let release = lock.release().map_err(|error| error.to_string());
360    match (result, release) {
361        (Ok(result), Ok(())) => Ok(result),
362        (Err(error), _) => Err(error),
363        (Ok(_), Err(error)) => Err(error),
364    }
365}