Skip to main content

supercov_engine/
python_project.rs

1//! Python project discovery and ahead-of-run obligation preparation.
2//!
3//! The project runs in place: nothing here copies or rewrites sources. Rust
4//! reads every in-scope `.py` file once, builds the complete manifest and the
5//! runtime probe plan, and records which files were included, excluded or
6//! unparseable so the run can say exactly what its denominator covers.
7
8use std::{
9    collections::{BTreeMap, BTreeSet},
10    fs,
11    path::{Path, PathBuf},
12};
13
14use serde_json::json;
15
16use crate::{
17    coverage_report::CoverageManifest,
18    integrity::ExplicitIntegrityInputs,
19    python_instrumenter::{
20        PYTHON_PROBE_PLAN_VERSION, PythonFilePlan, PythonProbePlan, build_python_obligations,
21    },
22    source_discovery::{SourceScope, SourceScopeEntry, SourceScopeMode, SourceScopeStatus},
23};
24
25pub const UNPARSEABLE_LIMITATION: &str = "python-source-unparseable";
26
27/// Directories that never hold the project's own measured source.
28const EXCLUDED_DIRECTORIES: &[&str] = &[
29    ".git",
30    ".hg",
31    ".svn",
32    ".supercov",
33    ".mcdc-pool",
34    ".cache",
35    "node_modules",
36    "__pycache__",
37    ".venv",
38    "venv",
39    ".env",
40    "env",
41    ".tox",
42    ".nox",
43    ".mypy_cache",
44    ".pytest_cache",
45    ".ruff_cache",
46    ".hypothesis",
47    ".eggs",
48    "build",
49    "dist",
50    "target",
51    "site-packages",
52    "htmlcov",
53];
54
55const DEPENDENCY_FILES: &[&str] = &[
56    "pyproject.toml",
57    "setup.cfg",
58    "setup.py",
59    "requirements.txt",
60    "requirements-dev.txt",
61    "Pipfile",
62    "Pipfile.lock",
63    "poetry.lock",
64    "uv.lock",
65    "pdm.lock",
66];
67
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct PythonFiles {
70    /// Relative, `/`-separated paths of measured application sources.
71    pub sources: Vec<String>,
72    /// Relative paths of test modules, conftests and other excluded `.py`.
73    pub tests: Vec<String>,
74    pub dependency_files: Vec<PathBuf>,
75    pub configuration_files: Vec<PathBuf>,
76    pub excluded: Vec<(String, &'static str)>,
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct PreparedPythonProject {
81    pub root: PathBuf,
82    pub files: PythonFiles,
83    pub manifest: CoverageManifest,
84    pub plan: PythonProbePlan,
85    pub unparseable: Vec<(String, String)>,
86}
87
88fn is_venv(directory: &Path) -> bool {
89    fs::symlink_metadata(directory.join("pyvenv.cfg")).is_ok()
90}
91
92fn is_test_path(relative: &str) -> Option<&'static str> {
93    let mut components = relative.split('/').peekable();
94    let mut file_name = "";
95    while let Some(component) = components.next() {
96        if components.peek().is_none() {
97            file_name = component;
98            break;
99        }
100        if matches!(component, "tests" | "test" | "testing" | "__tests__") {
101            return Some("inside a test directory");
102        }
103    }
104    if file_name == "conftest.py" {
105        return Some("pytest conftest");
106    }
107    if file_name.starts_with("test_") && file_name.ends_with(".py") {
108        return Some("test module by name");
109    }
110    if file_name.ends_with("_test.py") || file_name.ends_with("_tests.py") {
111        return Some("test module by name");
112    }
113    if matches!(
114        file_name,
115        "setup.py" | "noxfile.py" | "tasks.py" | "fabfile.py"
116    ) {
117        return Some("build/test tooling script");
118    }
119    None
120}
121
122fn walk(
123    root: &Path,
124    directory: &Path,
125    files: &mut PythonFiles,
126    all_python: &mut Vec<String>,
127) -> Result<(), String> {
128    let mut entries = fs::read_dir(directory)
129        .map_err(|error| format!("{}: {error}", directory.display()))?
130        .collect::<Result<Vec<_>, _>>()
131        .map_err(|error| error.to_string())?;
132    entries.sort_by_key(fs::DirEntry::file_name);
133    for entry in entries {
134        let path = entry.path();
135        let name = entry.file_name().into_string().map_err(|_| {
136            format!(
137                "Python project contains a non-UTF-8 path: {}",
138                path.display()
139            )
140        })?;
141        let file_type = entry.file_type().map_err(|error| error.to_string())?;
142        let relative = path
143            .strip_prefix(root)
144            .map_err(|_| format!("path escaped root: {}", path.display()))?
145            .to_string_lossy()
146            .replace('\\', "/");
147        if file_type.is_dir() {
148            if EXCLUDED_DIRECTORIES.contains(&name.as_str())
149                || name.ends_with(".egg-info")
150                || is_venv(&path)
151            {
152                files
153                    .excluded
154                    .push((relative, "tooling or environment directory"));
155                continue;
156            }
157            walk(root, &path, files, all_python)?;
158        } else if file_type.is_file() {
159            if DEPENDENCY_FILES.contains(&name.as_str())
160                || (name.starts_with("requirements") && name.ends_with(".txt"))
161            {
162                files.dependency_files.push(PathBuf::from(&relative));
163                if name != "setup.py" {
164                    continue;
165                }
166            }
167            if matches!(
168                name.as_str(),
169                "pytest.ini" | "tox.ini" | ".coveragerc" | "mypy.ini" | ".python-version"
170            ) {
171                files.configuration_files.push(PathBuf::from(&relative));
172                continue;
173            }
174            if !name.ends_with(".py") {
175                continue;
176            }
177            all_python.push(relative.clone());
178            match is_test_path(&relative) {
179                Some(reason) => {
180                    files.tests.push(relative.clone());
181                    files.excluded.push((relative, reason));
182                }
183                None => files.sources.push(relative),
184            }
185        }
186        // Symlinks are neither followed nor measured: a linked source tree is
187        // outside the project's own denominator.
188    }
189    Ok(())
190}
191
192pub fn discover_python_files(root: &Path) -> Result<PythonFiles, String> {
193    let mut files = PythonFiles::default();
194    let mut all_python = Vec::new();
195    walk(root, root, &mut files, &mut all_python)?;
196    files.sources.sort();
197    files.tests.sort();
198    files.dependency_files.sort();
199    files.configuration_files.sort();
200    Ok(files)
201}
202
203fn limitation(id: &str, kind: &str, file: &str, reason: &str) -> serde_json::Value {
204    json!({
205        "id": id,
206        "kind": kind,
207        "file": file,
208        "line": 1,
209        "column": 0,
210        "source": "",
211        "reason": reason
212    })
213}
214
215pub fn prepare_python_project(root: &Path) -> Result<PreparedPythonProject, String> {
216    let files = discover_python_files(root)?;
217    if files.sources.is_empty() && files.tests.is_empty() {
218        return Err(
219            "no Python source files were found under the project root; Supercov measures .py files outside virtual environments, build output and test directories".into(),
220        );
221    }
222    let mut manifest = CoverageManifest {
223        unmeasured: Vec::new(),
224        decisions: Vec::new(),
225        points: Vec::new(),
226        branches: Vec::new(),
227        limitations: Vec::new(),
228        scope: None,
229    };
230    let mut plan_files = BTreeMap::<String, PythonFilePlan>::new();
231    let mut limitation_ids = BTreeSet::new();
232    let mut unparseable = Vec::new();
233    for relative in &files.sources {
234        let path = root.join(relative);
235        let source = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
236        let Ok(source) = String::from_utf8(source) else {
237            unparseable.push((relative.clone(), "source is not valid UTF-8".to_owned()));
238            continue;
239        };
240        match build_python_obligations(relative, &source) {
241            Ok(obligations) => {
242                manifest.points.extend(obligations.manifest.points);
243                manifest.decisions.extend(obligations.manifest.decisions);
244                manifest.branches.extend(obligations.manifest.branches);
245                manifest.unmeasured.extend(obligations.manifest.unmeasured);
246                for item in obligations.manifest.limitations {
247                    let id = item
248                        .get("id")
249                        .and_then(serde_json::Value::as_str)
250                        .unwrap_or_default()
251                        .to_owned();
252                    if limitation_ids.insert(id) {
253                        manifest.limitations.push(item);
254                    }
255                }
256                plan_files.insert(relative.clone(), obligations.plan);
257            }
258            Err(error) => unparseable.push((relative.clone(), error.to_string())),
259        }
260    }
261    if let Some((file, reason)) = unparseable.first()
262        && limitation_ids.insert(UNPARSEABLE_LIMITATION.into())
263    {
264        manifest.limitations.push(limitation(
265            UNPARSEABLE_LIMITATION,
266            "source-scope",
267            file,
268            &format!(
269                "{} source file(s) could not be parsed and carry no obligations; first: {file}: {reason}",
270                unparseable.len()
271            ),
272        ));
273    }
274    manifest.unmeasured.sort();
275    manifest.unmeasured.dedup();
276    let mut entries = Vec::new();
277    for file in &files.sources {
278        let unparseable_file = unparseable.iter().any(|(path, _)| path == file);
279        entries.push(SourceScopeEntry {
280            file: file.clone(),
281            status: if unparseable_file {
282                SourceScopeStatus::Excluded
283            } else {
284                SourceScopeStatus::Included
285            },
286            reason: if unparseable_file {
287                "could not be parsed".into()
288            } else {
289                "Python application source".into()
290            },
291            package_root: None,
292        });
293    }
294    for (file, reason) in &files.excluded {
295        if file.ends_with(".py") {
296            entries.push(SourceScopeEntry {
297                file: file.clone(),
298                status: SourceScopeStatus::Excluded,
299                reason: (*reason).into(),
300                package_root: None,
301            });
302        }
303    }
304    entries.sort_by(|left, right| left.file.cmp(&right.file));
305    manifest.scope = Some(
306        serde_json::to_value(SourceScope {
307            version: 1,
308            mode: SourceScopeMode::Automatic,
309            roots: vec![".".into()],
310            entries,
311        })
312        .map_err(|error| error.to_string())?,
313    );
314    Ok(PreparedPythonProject {
315        root: root.to_owned(),
316        plan: PythonProbePlan {
317            version: PYTHON_PROBE_PLAN_VERSION,
318            root: root.display().to_string(),
319            files: plan_files,
320        },
321        manifest,
322        files,
323        unparseable,
324    })
325}
326
327#[cfg(unix)]
328fn os_string_bytes(value: &std::ffi::OsStr) -> Vec<u8> {
329    use std::os::unix::ffi::OsStrExt as _;
330    value.as_bytes().to_vec()
331}
332
333#[cfg(not(unix))]
334fn os_string_bytes(value: &std::ffi::OsStr) -> Vec<u8> {
335    value.to_string_lossy().as_bytes().to_vec()
336}
337
338fn append_identity_field(destination: &mut Vec<u8>, value: &[u8]) {
339    destination.extend_from_slice(&(value.len() as u64).to_le_bytes());
340    destination.extend_from_slice(value);
341}
342
343/// Integrity inputs: sources and tests are hashed separately, dependency and
344/// configuration files identify the environment, and the command plus the
345/// supervisor environment identify execution.
346pub fn python_integrity_inputs(files: &PythonFiles, command: &[String]) -> ExplicitIntegrityInputs {
347    let mut execution_configuration = command.join("\0").into_bytes();
348    let mut environment = std::env::vars_os()
349        .map(|(key, value)| (os_string_bytes(&key), os_string_bytes(&value)))
350        .collect::<Vec<_>>();
351    environment.sort();
352    for (key, value) in environment {
353        append_identity_field(&mut execution_configuration, &key);
354        append_identity_field(&mut execution_configuration, &value);
355    }
356    ExplicitIntegrityInputs {
357        source_files: files.sources.iter().map(PathBuf::from).collect(),
358        test_files: files.tests.iter().map(PathBuf::from).collect(),
359        dependency_files: files.dependency_files.clone(),
360        configuration_files: files.configuration_files.clone(),
361        execution_configuration,
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use std::time::{SystemTime, UNIX_EPOCH};
368
369    use super::*;
370
371    fn fixture(name: &str) -> PathBuf {
372        let nonce = SystemTime::now()
373            .duration_since(UNIX_EPOCH)
374            .unwrap()
375            .as_nanos();
376        let root = std::env::temp_dir().join(format!(
377            "supercov-python-project-{}-{nonce}-{name}",
378            std::process::id()
379        ));
380        fs::create_dir_all(&root).unwrap();
381        root
382    }
383
384    fn write(root: &Path, relative: &str, contents: &str) {
385        let path = root.join(relative);
386        fs::create_dir_all(path.parent().unwrap()).unwrap();
387        fs::write(path, contents).unwrap();
388    }
389
390    #[test]
391    fn separates_sources_tests_environments_and_tooling() {
392        let root = fixture("discover");
393        write(&root, "pyproject.toml", "[project]\nname='x'\n");
394        write(&root, "pytest.ini", "[pytest]\n");
395        write(&root, "src/pkg/__init__.py", "");
396        write(&root, "src/pkg/core.py", "def f(a):\n    return a and 1\n");
397        write(&root, "tests/test_core.py", "def test():\n    pass\n");
398        write(&root, "conftest.py", "");
399        write(&root, "setup.py", "print(1)\n");
400        write(&root, ".venv/pyvenv.cfg", "home = /usr\n");
401        write(&root, ".venv/lib/site.py", "x = 1\n");
402        write(&root, "env2/pyvenv.cfg", "home = /usr\n");
403        write(&root, "env2/lib/thing.py", "y = 2\n");
404        write(&root, "broken/old.py", "print 'python 2'\n");
405        let project = prepare_python_project(&root).unwrap();
406        assert_eq!(
407            project.files.sources,
408            ["broken/old.py", "src/pkg/__init__.py", "src/pkg/core.py"]
409        );
410        assert_eq!(
411            project.files.tests,
412            ["conftest.py", "setup.py", "tests/test_core.py"]
413        );
414        assert_eq!(
415            project.files.dependency_files,
416            [PathBuf::from("pyproject.toml"), PathBuf::from("setup.py")]
417        );
418        assert_eq!(
419            project.files.configuration_files,
420            [PathBuf::from("pytest.ini")]
421        );
422        assert_eq!(project.unparseable.len(), 1);
423        assert!(project.plan.files.contains_key("src/pkg/core.py"));
424        assert!(!project.plan.files.contains_key("broken/old.py"));
425        let ids = project
426            .manifest
427            .limitations
428            .iter()
429            .map(|item| item["id"].as_str().unwrap().to_owned())
430            .collect::<BTreeSet<_>>();
431        assert_eq!(ids.len(), 1);
432        assert!(ids.contains(UNPARSEABLE_LIMITATION));
433        assert!(
434            project
435                .manifest
436                .points
437                .iter()
438                .all(|point| point.file.starts_with("src/"))
439        );
440        fs::remove_dir_all(root).unwrap();
441    }
442}