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