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_RUN_ID",
165        "SUPERCOV_PROJECT_ROOT",
166        "SUPERCOV_CONTEXT",
167        "SUPERCOV_PYTHON_WORKER",
168    ] {
169        take(key);
170    }
171    variables.extend([
172        ("PYTHONPATH".into(), python_path),
173        ("PYTEST_PLUGINS".into(), pytest_plugins),
174        ("PYTEST_ADDOPTS".into(), pytest_addopts),
175        (
176            "SUPERCOV_PYTHON_PLAN".into(),
177            plan_path.as_os_str().to_owned(),
178        ),
179        (
180            "SUPERCOV_PYTHON_EVIDENCE_DIR".into(),
181            evidence_directory.as_os_str().to_owned(),
182        ),
183        ("SUPERCOV_RUN_ID".into(), run_id.into()),
184        ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
185    ]);
186    variables
187}
188
189/// The fingerprint a later query compares against the stored run: the same
190/// discovery and inputs the run used, without preparing a plan.
191pub fn current_python_integrity(
192    root: &Path,
193    command: &[String],
194    source_roots: Option<&[String]>,
195) -> Result<crate::run_store::RunIntegrity, String> {
196    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
197    let mut files = crate::python_project::discover_python_files(&root)?;
198    // The roots the run was measured under, not this shell's: the run
199    // digested the files they kept, and nothing else would match it.
200    if let Some(configured) = source_roots.filter(|roots| !roots.is_empty()) {
201        crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
202            .map_err(|error| error.to_string())?
203            .narrow(&mut files.sources, &mut files.excluded, String::as_str);
204    }
205    let mut inputs = python_integrity_inputs(&files, command);
206    crate::source_discovery::fold_roots_into_configuration(
207        &mut inputs.execution_configuration,
208        source_roots,
209    );
210    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_python())
211        .map_err(|error| error.to_string())
212}
213
214/// The lines of each captured Python file that hold an assertion -- an
215/// `assert` statement or an `assert*` method call -- for the plan. The probe on
216/// such a line marks where its test's assertion phase begins. Sorted and
217/// deduplicated: a line with two assertions is one line to arm.
218fn assertion_sites(files: &crate::source_manifest::Files) -> BTreeMap<String, Vec<usize>> {
219    let mut sites = BTreeMap::<String, Vec<usize>>::new();
220    for (file, text) in files.iter().filter(|(file, _)| file.ends_with(".py")) {
221        let Ok(starts) = assertion_starts(text) else {
222            continue;
223        };
224        let lines = sites.entry(file.clone()).or_default();
225        lines.extend(
226            starts
227                .into_iter()
228                .map(|start| text[..start].bytes().filter(|b| *b == b'\n').count() + 1),
229        );
230        lines.sort_unstable();
231        lines.dedup();
232    }
233    sites.retain(|_, lines| !lines.is_empty());
234    sites
235}
236
237/// The byte offset of every `assert` statement and `assert*` method call.
238fn assertion_starts(source: &str) -> Result<Vec<usize>, String> {
239    use ruff_python_ast::{
240        Expr, Stmt,
241        visitor::{Visitor, walk_expr, walk_stmt},
242    };
243    use ruff_text_size::Ranged;
244    struct Collector(Vec<usize>);
245    impl<'a> Visitor<'a> for Collector {
246        fn visit_stmt(&mut self, stmt: &'a Stmt) {
247            if let Stmt::Assert(_) = stmt {
248                self.0.push(stmt.range().start().to_usize());
249            }
250            walk_stmt(self, stmt);
251        }
252        fn visit_expr(&mut self, expr: &'a Expr) {
253            if let Expr::Call(call) = expr
254                && let Expr::Attribute(attr) = call.func.as_ref()
255                && attr.attr.as_str().starts_with("assert")
256            {
257                self.0.push(expr.range().start().to_usize());
258            }
259            walk_expr(self, expr);
260        }
261    }
262    let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
263    let mut collector = Collector(vec![]);
264    for stmt in &parsed.syntax().body {
265        collector.visit_stmt(stmt);
266    }
267    Ok(collector.0)
268}
269
270pub fn run_direct_python(
271    request: &DirectPythonRunRequest,
272    diagnostics: &mut dyn Write,
273) -> Result<DirectPythonRunResult, String> {
274    if request.command.is_empty() {
275        return Err("test command must not be empty".into());
276    }
277    let total_started = Instant::now();
278    let initialization_started = Instant::now();
279    let root = canonicalize_simplified(&request.root)
280        .map_err(|error| format!("{}: {error}", request.root.display()))?;
281    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
282        .map_err(|error| error.to_string())?;
283    let initialization_ms = elapsed_ms(initialization_started);
284    let work_directory = root.join(".supercov/work").join(&request.run_id);
285    // Whether the tests were measured. A run that failed before that has no
286    // evidence worth keeping; one that failed after has evidence that cost the
287    // suite its wall clock.
288    let measured = std::cell::Cell::new(false);
289    let result = (|| {
290        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
291            .map_err(|error| error.to_string())?;
292        if !recovered_runs.is_empty() {
293            writeln!(
294                diagnostics,
295                "[supercov] recovered abandoned run(s): {}",
296                recovered_runs.join(", ")
297            )
298            .map_err(|error| error.to_string())?;
299        }
300
301        let adapter_started = Instant::now();
302        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
303        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
304            .map_err(|error| error.to_string())?;
305        let project: PreparedPythonProject = prepare_python_project(&root, roots.as_ref())?;
306        let source_roots = crate::source_discovery::configured_source_roots(
307            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
308        );
309        let mut integrity_inputs = python_integrity_inputs(&project.files, &request.command);
310        crate::source_discovery::fold_roots_into_configuration(
311            &mut integrity_inputs.execution_configuration,
312            source_roots.as_deref(),
313        );
314        let integrity = create_explicit_run_integrity(
315            &root,
316            &integrity_inputs,
317            &FrontendIntegrityInputs::embedded_python(),
318        )
319        .map_err(|error| error.to_string())?;
320        let source_inputs = crate::source_capture::capture(
321            &root,
322            "python",
323            python_integrity_inputs(&project.files, &request.command).assertion_paths(),
324        )?;
325        let python_directory = work_directory.join("python");
326        let runtime_directory = python_directory.join("runtime");
327        let evidence_directory = python_directory.join("evidence");
328        let plan_path = python_directory.join("plan.json");
329        write_runtime(&runtime_directory)?;
330        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
331        let plan = crate::python_instrumenter::PythonProbePlan {
332            assertion_sites: assertion_sites(&source_inputs.files),
333            ..project.plan.clone()
334        };
335        fs::write(
336            &plan_path,
337            serde_json::to_vec(&plan).map_err(|error| error.to_string())?,
338        )
339        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
340        writeln!(
341            diagnostics,
342            "[supercov] detected Python; measuring {} source file(s) in place through probes compiled at import",
343            project.plan.files.len(),
344        )
345        .map_err(|error| error.to_string())?;
346        for (file, reason) in &project.unparseable {
347            writeln!(
348                diagnostics,
349                "[supercov] could not parse {file}: {reason}; it carries no obligations"
350            )
351            .map_err(|error| error.to_string())?;
352        }
353        let adapter_setup_ms = elapsed_ms(adapter_started);
354
355        let test_started = Instant::now();
356        let plan = ExecutionPlan {
357            preparation: Vec::new(),
358            test: ExecutionPhase {
359                name: "test".into(),
360                kind: PhaseKind::Test,
361                command: CommandSpec {
362                    program: request.command[0].clone().into(),
363                    arguments: request.command[1..].iter().map(OsString::from).collect(),
364                    cwd: root.clone(),
365                    environment: Some(environment(
366                        &root,
367                        &request.run_id,
368                        &runtime_directory,
369                        &plan_path,
370                        &evidence_directory,
371                    )),
372                    captured_output: None,
373                },
374            },
375        };
376        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
377        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
378            .map_err(|error| error.to_string())?;
379        let test_command_ms = elapsed_ms(test_started);
380        if let Some(signal) = execution.interrupted_signal {
381            return Err(format!(
382                "the test command was interrupted by {signal:?}; no run was published"
383            ));
384        }
385
386        let publication_started = Instant::now();
387        let verbose = std::env::var("SUPERCOV_VERBOSE")
388            .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
389            .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
390        measured.set(true);
391        let mut run: PythonFrontendRun = match build_python_frontend_run(
392            &project.manifest,
393            &evidence_directory,
394            &request.run_id,
395            &request.started_at,
396            execution.exit_code,
397            &root,
398        ) {
399            // No test reported an outcome and the command itself failed: it
400            // never ran its tests (`No module named pytest`, an error while
401            // collecting). That is the command's failure, said in its own
402            // output, not a Supercov bug to report with evidence.
403            Err(
404                crate::python_evidence::PythonEvidenceError::NoTests
405                | crate::python_evidence::PythonEvidenceError::NoInterpreter,
406            ) if execution.exit_code != 0 => {
407                measured.set(false);
408                return Err(format!(
409                    "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
410                    execution.exit_code
411                ));
412            }
413            // The interpreter or the runner the command used is not one
414            // Supercov measures: the message says what to change, and the
415            // evidence has nothing in it to diagnose.
416            Err(
417                error @ (crate::python_evidence::PythonEvidenceError::UnsupportedPython(_)
418                | crate::python_evidence::PythonEvidenceError::NoTests
419                | crate::python_evidence::PythonEvidenceError::NoInterpreter),
420            ) => {
421                measured.set(false);
422                return Err(error.to_string());
423            }
424            result => result.map_err(|error| error.to_string())?,
425        };
426        validate_frontend_report_request(&run.declaration, &run.request)
427            .map_err(|error| error.to_string())?;
428        let joined_ms = elapsed_ms(publication_started);
429        let archive_path = work_directory.join("evidence.raw.gz");
430        // Analysed as the archive will analyse -- the request it archives,
431        // with the run's integrity record, which the archive does not hold
432        // -- while it is written: neither waits on the other.
433        run.request.integrity = serde_json::to_value(&integrity).ok();
434        let (archived, analysed) = std::thread::scope(|scope| {
435            let analysis = scope.spawn(|| {
436                crate::coverage_report::analyze_frontend_request(
437                    &run.declaration,
438                    &run.request,
439                    crate::coverage_report::TransportStats::none(),
440                )
441            });
442            let archived = (|| {
443                let entries = run.archive_entries().map_err(|error| error.to_string())?;
444                let serialized_ms = elapsed_ms(publication_started) - joined_ms;
445                let entries = crate::source_capture::append(entries, &source_inputs)?;
446                let raw =
447                    write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
448                let archive_ms = elapsed_ms(publication_started) - joined_ms - serialized_ms;
449                Ok::<_, String>((raw, serialized_ms, archive_ms))
450            })();
451            let analysed = analysis
452                .join()
453                .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
454            (archived, analysed)
455        });
456        let analysis_ms = elapsed_ms(publication_started) - joined_ms;
457        let (raw, serialized_ms, archive_ms) = archived?;
458        // The joined evidence is in the archive now, and publication reads it
459        // from there. Held to the end for three counts, it was the largest
460        // thing alive while publication analysed the archive: 1.4 GB on a
461        // 3,900-test run, on top of the analysis's own.
462        let (tests, interpreters, python_versions) =
463            (run.tests, run.interpreters, run.python_versions.clone());
464        if verbose {
465            writeln!(
466                diagnostics,
467                "[supercov] python evidence: join={joined_ms}ms serialize={serialized_ms}ms archive={archive_ms}ms analysis={analysis_ms}ms (beside the archive)"
468            )
469            .map_err(|error| error.to_string())?;
470        }
471        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
472            let debug_directory = root.join(".supercov/python-debug").join(&request.run_id);
473            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
474            copy_tree(&python_directory, &debug_directory)?;
475        }
476        remove_stored_tree_deferred(&root, &python_directory).map_err(|error| error.to_string())?;
477        let evidence_publication_ms = elapsed_ms(publication_started);
478        let timings = RunTimings {
479            initialization_ms,
480            workspace_preparation_ms: 0.0,
481            adapter_setup_ms,
482            instrumented_build_ms: 0.0,
483            test_command_ms,
484            evidence_publication_ms,
485        };
486        let metadata = RunMetadata {
487            id: request.run_id.clone(),
488            started_at: request.started_at.clone(),
489            duration_ms: elapsed_ms(total_started),
490            command: request.command.clone(),
491            test_exit_code: Some(execution.exit_code),
492            integrity,
493            raw_evidence: RawEvidenceMetadata {
494                schema_version: raw.schema_version,
495                format: raw.format.into(),
496                file: raw.file.into(),
497                files: raw.files,
498                uncompressed_bytes: raw.uncompressed_bytes,
499                compressed_bytes: raw.compressed_bytes,
500            },
501            isolated_build: None,
502            instrumented_build_cache: None,
503            timings: Some(timings),
504            merged: None,
505            parents: None,
506            source_roots: source_roots.clone(),
507        };
508        drop(run);
509        let run_directory = publish_archived_run(
510            &root,
511            &metadata,
512            &archive_path,
513            Archived {
514                report: analysed.ok(),
515                inputs: Some(source_inputs.manifest()),
516            },
517        )
518        .map_err(|error| error.to_string())?;
519        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
520        Ok(DirectPythonRunResult {
521            run_id: request.run_id.clone(),
522            run_directory,
523            exit_code: execution.exit_code,
524            tests,
525            source_files: project.plan.files.len(),
526            interpreters,
527            python_versions,
528            recovered_runs,
529            metadata,
530        })
531    })();
532    let result = result.map_err(|error| {
533        if !measured.get() {
534            return error;
535        }
536        note_kept_evidence(
537            &root,
538            &work_directory.join("python/evidence"),
539            &request.run_id,
540            error,
541        )
542    });
543    if result.is_err() {
544        let _ = remove_stored_tree_deferred(&root, &work_directory);
545    }
546    let release = lock.release().map_err(|error| error.to_string());
547    match (result, release) {
548        (Ok(result), Ok(())) => Ok(result),
549        (Err(error), _) => Err(error),
550        (Ok(_), Err(error)) => Err(error),
551    }
552}
553
554#[cfg(test)]
555mod tests {
556
557    // The plan names each test file's assertion lines, so the runtime can
558    // mark where a test's assertion phase begins from the line event that runs
559    // it, and pytest's assertion-pass hook -- a failure explanation built for
560    // every passing assertion -- stays off.
561    #[test]
562    fn the_plan_names_each_test_files_assertion_lines_once_and_sorted() {
563        let files = crate::source_manifest::Files::from([
564            ("tests/test_b.py".to_string(), "def test_b():\n    x = 1\n\n\n\n\n\n\n    assert x\n".to_string()),
565            (
566                "tests/test_a.py".to_string(),
567                // Two assertions on line 12 are one line to arm.
568                "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(),
569            ),
570            ("src/app.py".to_string(), "def f():\n    return 1\n".to_string()),
571            ("README.md".to_string(), "assert nothing\n".to_string()),
572        ]);
573        let sites = assertion_sites(&files);
574        assert_eq!(
575            sites,
576            BTreeMap::from([
577                ("tests/test_a.py".to_string(), vec![3, 12]),
578                ("tests/test_b.py".to_string(), vec![9]),
579            ])
580        );
581
582        // Serialized under the key the runtime reads, and absent -- not an
583        // empty object -- when there is nothing to arm.
584        let plan = crate::python_instrumenter::PythonProbePlan {
585            version: 1,
586            root: "/p".into(),
587            files: BTreeMap::new(),
588            assertion_sites: sites,
589        };
590        let json = serde_json::to_string(&plan).unwrap();
591        assert!(
592            json.contains("\"assertionSites\":{\"tests/test_a.py\":[3,12]"),
593            "{json}"
594        );
595        let empty = crate::python_instrumenter::PythonProbePlan {
596            assertion_sites: BTreeMap::new(),
597            ..plan
598        };
599        assert!(
600            !serde_json::to_string(&empty)
601                .unwrap()
602                .contains("assertionSites")
603        );
604    }
605    use super::*;
606    use crate::source_discovery::{ExplicitSourceRoots, fold_roots_into_configuration};
607
608    fn fixture(name: &str) -> PathBuf {
609        static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
610        let root = std::env::temp_dir().join(format!(
611            "supercov-python-run-{}-{}-{name}",
612            std::process::id(),
613            UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
614        ));
615        let _ = fs::remove_dir_all(&root);
616        for (file, contents) in [
617            ("a.py", "def f(x):\n    return x if x else 0\n"),
618            ("vendor/pkg/b.py", "def g():\n    return 1\n"),
619            ("test/test_a.py", "from a import f\n"),
620        ] {
621            let path = root.join(file);
622            fs::create_dir_all(path.parent().unwrap()).unwrap();
623            fs::write(path, contents).unwrap();
624        }
625        canonicalize_simplified(&root).unwrap()
626    }
627
628    #[test]
629    fn a_rooted_run_is_current_whatever_shell_asks() {
630        // A run recorded under roots digested only the files they kept, while
631        // the query that later judged it rediscovered every file: the two could
632        // never agree, and each rooted run read as stale the moment it was
633        // written. The query has to replay the roots the run recorded, because
634        // the shell it runs in is usually not the one the run came from.
635        let root = fixture("current");
636        let configured = vec!["a.py".to_owned()];
637        let command = vec!["python".to_owned(), "-m".to_owned(), "unittest".to_owned()];
638
639        let roots = ExplicitSourceRoots::resolve(&root, &configured).unwrap();
640        let project = crate::python_project::prepare_python_project(&root, Some(&roots)).unwrap();
641        let mut inputs = python_integrity_inputs(&project.files, &command);
642        fold_roots_into_configuration(&mut inputs.execution_configuration, Some(&configured));
643        let recorded = create_explicit_run_integrity(
644            &root,
645            &inputs,
646            &FrontendIntegrityInputs::embedded_python(),
647        )
648        .unwrap();
649
650        let replayed = current_python_integrity(&root, &command, Some(&configured)).unwrap();
651        assert_eq!(recorded.fingerprint, replayed.fingerprint);
652
653        // Judged without the roots, it is a different run -- which is what the
654        // query used to do.
655        let ignoring = current_python_integrity(&root, &command, None).unwrap();
656        assert_ne!(recorded.fingerprint, ignoring.fingerprint);
657
658        // A change the roots left out is not a change to this run; one they
659        // kept is.
660        fs::write(root.join("vendor/pkg/b.py"), "def g():\n    return 2\n").unwrap();
661        assert_eq!(
662            current_python_integrity(&root, &command, Some(&configured))
663                .unwrap()
664                .fingerprint,
665            recorded.fingerprint,
666            "a vendored file changing does not make a rooted run stale"
667        );
668        fs::write(root.join("a.py"), "def f(x):\n    return 1\n").unwrap();
669        assert_ne!(
670            current_python_integrity(&root, &command, Some(&configured))
671                .unwrap()
672                .fingerprint,
673            recorded.fingerprint,
674            "a first-party file changing does"
675        );
676        fs::remove_dir_all(root).ok();
677    }
678}