1use 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
27const 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 pub sources: Vec<String>,
72 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 }
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
343pub 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}