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::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 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
183pub 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 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 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 let ignoring = current_python_integrity(&root, &command, None).unwrap();
461 assert_ne!(recorded.fingerprint, ignoring.fingerprint);
462
463 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}