From 4238ec5469e3600ee6309d8c3d6aa501ada7147c Mon Sep 17 00:00:00 2001 From: thefourCraft Date: Mon, 4 May 2026 10:40:54 +0300 Subject: [PATCH 1/2] feat: support Windows Python venv executables in pytest/mypy/pip-audit Windows virtualenvs place tool binaries under .venv/Scripts/.exe rather than .venv/bin/. All three Python runners now check both paths via a shared venv_tool() helper in runners/python_venv.rs: 1. POSIX path (.venv/bin/) is preferred when it exists 2. Windows path (.venv/Scripts/.exe) is checked second 3. Falls back to PATH when neither local path exists has_pytest_cov coverage detection also uses venv_tool() so Windows venvs get coverage metrics too. --- src/runners/mod.rs | 1 + src/runners/mypy.rs | 56 +++++++++++++++++++++++++++++----- src/runners/pip_audit.rs | 56 +++++++++++++++++++++++++++++----- src/runners/pytest.rs | 61 ++++++++++++++++++++++++++++++-------- src/runners/python_venv.rs | 17 +++++++++++ 5 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 src/runners/python_venv.rs diff --git a/src/runners/mod.rs b/src/runners/mod.rs index 30789ed..b4ffb4e 100644 --- a/src/runners/mod.rs +++ b/src/runners/mod.rs @@ -15,6 +15,7 @@ pub mod mutmut; pub mod mypy; pub mod npm_audit; pub mod pip_audit; +pub(super) mod python_venv; pub mod playwright; pub mod proptest; pub mod pytest; diff --git a/src/runners/mypy.rs b/src/runners/mypy.rs index 1131efe..d3533c3 100644 --- a/src/runners/mypy.rs +++ b/src/runners/mypy.rs @@ -6,6 +6,7 @@ use crate::error::Result; use crate::plugin::{Layer, TestRunner}; use crate::process::{OsProcessRunner, SubprocessRunner}; use crate::report::{Finding, LayerMetrics, LayerResult, LayerStatus, Severity}; +use crate::runners::python_venv::venv_tool; use std::path::Path; use std::sync::Arc; use std::time::Instant; @@ -31,8 +32,7 @@ impl TestRunner for MypyRunner { fn is_available(&self, project: &ProjectInfo) -> bool { if project.language != crate::detect::Language::Python { return false; } let root = Path::new(&project.root); - let local = root.join(".venv").join("bin").join("mypy"); - if local.exists() { return true; } + if venv_tool(root, "mypy").is_some() { return true; } self.proc.is_available("mypy", &["--version"]) } @@ -40,12 +40,7 @@ impl TestRunner for MypyRunner { let start = Instant::now(); let root = Path::new(&project.root); - let local = root.join(".venv").join("bin").join("mypy"); - let mypy_cmd = if local.exists() { - local.to_string_lossy().to_string() - } else { - "mypy".to_string() - }; + let mypy_cmd = venv_tool(root, "mypy").unwrap_or_else(|| "mypy".to_string()); match self.proc.run(&mypy_cmd, &[".", "--ignore-missing-imports", "--no-error-summary"], root) { Ok(out) => { @@ -177,6 +172,51 @@ mod tests { assert!(!r.is_available(&i)); } + #[test] + fn available_via_windows_scripts_exe() { + let dir = tempfile::tempdir().unwrap(); + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("mypy.exe"), b"").unwrap(); + let info = ProjectInfo { + language: Language::Python, + root: dir.path().to_string_lossy().to_string(), + has_tests: true, + package_name: None, + frameworks: Default::default(), + workspace_root: None, + }; + let r = MypyRunner { proc: Arc::new(MockProcessRunner::unavailable()) }; + assert!(r.is_available(&info), "mypy.exe in .venv/Scripts must make runner available"); + } + + #[test] + fn run_uses_windows_venv_mypy_exe() { + let dir = tempfile::tempdir().unwrap(); + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("mypy.exe"), b"").unwrap(); + let info = ProjectInfo { + language: Language::Python, + root: dir.path().to_string_lossy().to_string(), + has_tests: true, + package_name: None, + frameworks: Default::default(), + workspace_root: None, + }; + + struct CapturingProc; + impl SubprocessRunner for CapturingProc { + fn run(&self, cmd: &str, _: &[&str], _: &Path) -> std::io::Result { + assert!(cmd.contains("Scripts") && cmd.ends_with("mypy.exe"), + "run() must use .venv/Scripts/mypy.exe on Windows layout, got: {cmd}"); + Ok(crate::process::ProcessOutput { stdout: "Success: no issues found in 1 source file".to_string(), stderr: String::new(), success: true }) + } + } + let r = MypyRunner { proc: Arc::new(CapturingProc) }; + r.run(&info).unwrap(); + } + #[test] fn available_when_mypy_installed() { let r = MypyRunner { proc: Arc::new(MockProcessRunner::passing("mypy 1.8.0")) }; diff --git a/src/runners/pip_audit.rs b/src/runners/pip_audit.rs index de56c63..03ecca1 100644 --- a/src/runners/pip_audit.rs +++ b/src/runners/pip_audit.rs @@ -5,6 +5,7 @@ use crate::error::Result; use crate::plugin::{Layer, TestRunner}; use crate::process::{OsProcessRunner, SubprocessRunner}; use crate::report::{Finding, LayerMetrics, LayerResult, LayerStatus, Severity}; +use crate::runners::python_venv::venv_tool; use std::path::Path; use std::sync::Arc; use std::time::Instant; @@ -30,8 +31,7 @@ impl TestRunner for PipAuditRunner { fn is_available(&self, project: &ProjectInfo) -> bool { if project.language != crate::detect::Language::Python { return false; } let root = Path::new(&project.root); - let local = root.join(".venv").join("bin").join("pip-audit"); - if local.exists() { return true; } + if venv_tool(root, "pip-audit").is_some() { return true; } self.proc.is_available("pip-audit", &["--version"]) } @@ -39,12 +39,7 @@ impl TestRunner for PipAuditRunner { let start = Instant::now(); let root = Path::new(&project.root); - let local = root.join(".venv").join("bin").join("pip-audit"); - let cmd = if local.exists() { - local.to_string_lossy().to_string() - } else { - "pip-audit".to_string() - }; + let cmd = venv_tool(root, "pip-audit").unwrap_or_else(|| "pip-audit".to_string()); match self.proc.run(&cmd, &["--format=json", "--progress-spinner=off"], root) { Ok(out) => { @@ -172,6 +167,51 @@ mod tests { assert!(!r.is_available(&i)); } + #[test] + fn available_via_windows_scripts_exe() { + let dir = tempfile::tempdir().unwrap(); + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("pip-audit.exe"), b"").unwrap(); + let info = ProjectInfo { + language: Language::Python, + root: dir.path().to_string_lossy().to_string(), + has_tests: true, + package_name: None, + frameworks: Default::default(), + workspace_root: None, + }; + let r = PipAuditRunner { proc: Arc::new(MockProcessRunner::unavailable()) }; + assert!(r.is_available(&info), "pip-audit.exe in .venv/Scripts must make runner available"); + } + + #[test] + fn run_uses_windows_venv_pip_audit_exe() { + let dir = tempfile::tempdir().unwrap(); + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("pip-audit.exe"), b"").unwrap(); + let info = ProjectInfo { + language: Language::Python, + root: dir.path().to_string_lossy().to_string(), + has_tests: true, + package_name: None, + frameworks: Default::default(), + workspace_root: None, + }; + + struct CapturingProc; + impl SubprocessRunner for CapturingProc { + fn run(&self, cmd: &str, _: &[&str], _: &Path) -> std::io::Result { + assert!(cmd.contains("Scripts") && cmd.ends_with("pip-audit.exe"), + "run() must use .venv/Scripts/pip-audit.exe on Windows layout, got: {cmd}"); + Ok(crate::process::ProcessOutput { stdout: r#"{"dependencies":[]}"#.to_string(), stderr: String::new(), success: true }) + } + } + let r = PipAuditRunner { proc: Arc::new(CapturingProc) }; + r.run(&info).unwrap(); + } + #[test] fn available_when_pip_audit_installed() { let r = PipAuditRunner { proc: Arc::new(MockProcessRunner::passing("pip-audit 2.4.0")) }; diff --git a/src/runners/pytest.rs b/src/runners/pytest.rs index f10bae7..974bf4f 100644 --- a/src/runners/pytest.rs +++ b/src/runners/pytest.rs @@ -3,6 +3,7 @@ use crate::error::Result; use crate::plugin::{Layer, TestRunner}; use crate::process::{OsProcessRunner, SubprocessRunner}; use crate::report::{Finding, LayerMetrics, LayerResult, LayerStatus, Severity}; +use crate::runners::python_venv::venv_tool; use std::path::Path; use std::sync::Arc; use std::time::Instant; @@ -27,11 +28,8 @@ impl TestRunner for PytestRunner { fn is_available(&self, project: &ProjectInfo) -> bool { if project.language != crate::detect::Language::Python { return false; } - // Check if pytest is installed in the project's environment let root = Path::new(&project.root); - // Try project-local pytest first, then system - let local = root.join(".venv").join("bin").join("pytest"); - if local.exists() { return true; } + if venv_tool(root, "pytest").is_some() { return true; } self.proc.is_available("pytest", &["--version"]) } @@ -39,13 +37,7 @@ impl TestRunner for PytestRunner { let start = Instant::now(); let root = Path::new(&project.root); - // Prefer .venv/bin/pytest if it exists - let local_pytest = root.join(".venv").join("bin").join("pytest"); - let pytest_cmd = if local_pytest.exists() { - local_pytest.to_string_lossy().to_string() - } else { - "pytest".to_string() - }; + let pytest_cmd = venv_tool(root, "pytest").unwrap_or_else(|| "pytest".to_string()); // Add --cov if pytest-cov is available in the environment let has_cov = has_pytest_cov(root); @@ -158,7 +150,7 @@ fn has_pytest_cov(root: &Path) -> bool { } // Also check if pytest-cov is installed in venv root.join(".venv").join("lib").exists() - && root.join(".venv").join("bin").join("pytest").exists() + && venv_tool(root, "pytest").is_some() && std::fs::read_dir(root.join(".venv").join("lib")) .ok() .and_then(|mut d| d.next()) @@ -278,6 +270,51 @@ mod tests { assert!(!r.is_available(&i)); } + #[test] + fn available_via_windows_scripts_exe() { + let dir = tempfile::tempdir().unwrap(); + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("pytest.exe"), b"").unwrap(); + let info = ProjectInfo { + language: Language::Python, + root: dir.path().to_string_lossy().to_string(), + has_tests: true, + package_name: None, + frameworks: Default::default(), + workspace_root: None, + }; + let r = PytestRunner { proc: Arc::new(MockProcessRunner::unavailable()) }; + assert!(r.is_available(&info), "pytest.exe in .venv/Scripts must make runner available"); + } + + #[test] + fn run_uses_windows_venv_pytest_exe() { + let dir = tempfile::tempdir().unwrap(); + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("pytest.exe"), b"").unwrap(); + let info = ProjectInfo { + language: Language::Python, + root: dir.path().to_string_lossy().to_string(), + has_tests: true, + package_name: None, + frameworks: Default::default(), + workspace_root: None, + }; + + struct CapturingProc; + impl SubprocessRunner for CapturingProc { + fn run(&self, cmd: &str, _: &[&str], _: &Path) -> std::io::Result { + assert!(cmd.contains("Scripts") && cmd.ends_with("pytest.exe"), + "run() must use .venv/Scripts/pytest.exe on Windows layout, got: {cmd}"); + Ok(crate::process::ProcessOutput { stdout: "1 passed in 0.1s".to_string(), stderr: String::new(), success: true }) + } + } + let r = PytestRunner { proc: Arc::new(CapturingProc) }; + r.run(&info).unwrap(); + } + #[test] fn run_pass_parses_counts() { let stdout = "5 passed in 0.45s"; diff --git a/src/runners/python_venv.rs b/src/runners/python_venv.rs new file mode 100644 index 0000000..5b02c7e --- /dev/null +++ b/src/runners/python_venv.rs @@ -0,0 +1,17 @@ +use std::path::Path; + +/// Resolve a project-local Python venv executable for `name`. +/// Checks POSIX layout first (`.venv/bin/`), then Windows layout +/// (`.venv/Scripts/.exe`). Returns the path string if found, or +/// `None` to fall back to the system PATH. +pub fn venv_tool(root: &Path, name: &str) -> Option { + let posix = root.join(".venv").join("bin").join(name); + if posix.exists() { + return Some(posix.to_string_lossy().into_owned()); + } + let windows = root.join(".venv").join("Scripts").join(format!("{}.exe", name)); + if windows.exists() { + return Some(windows.to_string_lossy().into_owned()); + } + None +} From 3162793e52949bffd0504b9fa8019b2f0fd8e4b9 Mon Sep 17 00:00:00 2001 From: thefourCraft Date: Mon, 4 May 2026 10:41:47 +0300 Subject: [PATCH 2/2] fix: detect pytest-cov in Windows venv Lib/site-packages layout has_pytest_cov was POSIX-only for site-packages scanning. It now also checks .venv/Lib/site-packages/pytest_cov (Windows venv layout) when .venv/Scripts/pytest.exe is present, so coverage metrics are reported on Windows the same way they are on POSIX. --- src/runners/pytest.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/runners/pytest.rs b/src/runners/pytest.rs index 974bf4f..a9f9771 100644 --- a/src/runners/pytest.rs +++ b/src/runners/pytest.rs @@ -148,8 +148,8 @@ fn has_pytest_cov(root: &Path) -> bool { if content.contains("pytest-cov") { return true; } } } - // Also check if pytest-cov is installed in venv - root.join(".venv").join("lib").exists() + // Check POSIX venv: .venv/lib//site-packages/pytest_cov + if root.join(".venv").join("lib").exists() && venv_tool(root, "pytest").is_some() && std::fs::read_dir(root.join(".venv").join("lib")) .ok() @@ -157,6 +157,12 @@ fn has_pytest_cov(root: &Path) -> bool { .and_then(|e| e.ok()) .map(|site| site.path().join("site-packages").join("pytest_cov").exists()) .unwrap_or(false) + { + return true; + } + // Check Windows venv: .venv/Lib/site-packages/pytest_cov + root.join(".venv").join("Lib").join("site-packages").join("pytest_cov").exists() + && venv_tool(root, "pytest").is_some() } /// Parse `TOTAL ... 85%` line from pytest-cov output. @@ -372,6 +378,21 @@ mod tests { assert!(pct.is_none()); } + #[test] + fn has_pytest_cov_detects_windows_lib_layout() { + let dir = tempfile::tempdir().unwrap(); + // Create .venv/Scripts/pytest.exe (Windows venv executable) + let scripts = dir.path().join(".venv").join("Scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("pytest.exe"), b"").unwrap(); + // Create .venv/Lib/site-packages/pytest_cov (Windows site-packages layout) + let site_pkg = dir.path().join(".venv").join("Lib").join("site-packages").join("pytest_cov"); + std::fs::create_dir_all(&site_pkg).unwrap(); + + assert!(has_pytest_cov(dir.path()), + "has_pytest_cov must return true for Windows .venv/Lib/site-packages/pytest_cov layout"); + } + proptest! { #[test] fn parse_pytest_never_panics(s in ".*") { let _ = parse_pytest_output(&s); }