1use 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
170pub 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}