Skip to main content

perspt_core/plugin/
python.rs

1use super::*;
2
3/// Python language plugin (uses ty via uvx)
4pub struct PythonPlugin;
5
6impl LanguagePlugin for PythonPlugin {
7    fn name(&self) -> &str {
8        "python"
9    }
10
11    fn extensions(&self) -> &[&str] {
12        &["py"]
13    }
14
15    fn key_files(&self) -> &[&str] {
16        &["pyproject.toml", "setup.py", "requirements.txt", "uv.lock"]
17    }
18
19    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
20        vec![
21            (
22                "uv",
23                "package manager",
24                "curl -LsSf https://astral.sh/uv/install.sh | sh",
25            ),
26            (
27                "python3",
28                "interpreter",
29                "uv python install (or install from https://python.org)",
30            ),
31            (
32                "uvx",
33                "tool runner/LSP",
34                "Installed with uv — curl -LsSf https://astral.sh/uv/install.sh | sh",
35            ),
36        ]
37    }
38
39    fn get_lsp_config(&self) -> LspConfig {
40        // Prefer ty (via uvx) as the native Python support
41        // Falls back to pyright if ty is not available
42        LspConfig {
43            server_binary: "uvx".to_string(),
44            args: vec!["ty".to_string(), "server".to_string()],
45            language_id: "python".to_string(),
46        }
47    }
48
49    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
50        let command = match opts.package_manager.as_deref() {
51            Some("poetry") => {
52                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
53                    "poetry init --no-interaction".to_string()
54                } else {
55                    format!("poetry new {}", opts.name)
56                }
57            }
58            Some("pdm") => {
59                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
60                    "pdm init --non-interactive".to_string()
61                } else {
62                    format!(
63                        "mkdir -p {} && cd {} && pdm init --non-interactive",
64                        opts.name, opts.name
65                    )
66                }
67            }
68            Some("pipenv") => {
69                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
70                    "pipenv install".to_string()
71                } else {
72                    format!(
73                        "mkdir -p {} && cd {} && pipenv install",
74                        opts.name, opts.name
75                    )
76                }
77            }
78            // uv is the default for any other (or unspecified) value — the plugin
79            // owns this fallback, so an unrecognized manager degrades gracefully.
80            _ => {
81                // Default to uv --lib for src-layout with build-system
82                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
83                    "uv init --lib".to_string()
84                } else {
85                    format!("uv init --lib {}", opts.name)
86                }
87            }
88        };
89        let description = match opts.package_manager.as_deref() {
90            Some("poetry") => "Initialize Python project with Poetry",
91            Some("pdm") => "Initialize Python project with PDM",
92            Some("pipenv") => "Initialize Python project with Pipenv",
93            _ => "Initialize Python project with uv",
94        };
95        ProjectAction::ExecCommand {
96            command,
97            description: description.to_string(),
98        }
99    }
100
101    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
102        // Check for pyproject.toml but missing .venv or uv.lock
103        let has_pyproject = path.join("pyproject.toml").exists();
104        let has_venv = path.join(".venv").exists();
105        let has_uv_lock = path.join("uv.lock").exists();
106
107        if has_pyproject && (!has_venv || !has_uv_lock) {
108            ProjectAction::ExecCommand {
109                command: "uv sync".to_string(),
110                description: "Sync Python dependencies with uv".to_string(),
111            }
112        } else {
113            ProjectAction::NoAction
114        }
115    }
116
117    fn init_command(&self, opts: &InitOptions) -> String {
118        if opts.package_manager.as_deref() == Some("poetry") {
119            if opts.name == "." || opts.name == "./" {
120                "poetry init".to_string()
121            } else {
122                format!("poetry new {}", opts.name)
123            }
124        } else {
125            // uv init --lib for src-layout with build-system
126            format!("uv init --lib {}", opts.name)
127        }
128    }
129
130    fn test_command(&self) -> String {
131        // `--no-sync` keeps verification offline against the project's
132        // already-synced environment: the governed verifier sandbox denies
133        // network, so installation must have happened via `uv sync` before
134        // the run (the degraded_reason above already tells the user so).
135        // `--junitxml` writes the structured result the coding adapter's
136        // JUnit parser consumes; the harness appends the file to the raw
137        // sensor output after the run (live structured pytest, PSP-10
138        // system 26).
139        "uv run --no-sync pytest -q --junitxml .perspt-junit.xml".to_string()
140    }
141
142    fn test_command_with_filter(&self, filter: Option<&str>) -> String {
143        match filter {
144            Some(filter) => {
145                format!("uv run --no-sync pytest -q -k {filter} --junitxml .perspt-junit.xml")
146            }
147            None => self.test_command(),
148        }
149    }
150
151    fn run_command(&self) -> String {
152        "uv run python -m main".to_string()
153    }
154
155    /// Detect the package name from pyproject.toml or src layout and return
156    /// an appropriate run command.
157    fn run_command_for_dir(&self, path: &Path) -> String {
158        // Check src/<pkg>/__main__.py first
159        if let Ok(entries) = std::fs::read_dir(path.join("src")) {
160            for entry in entries.flatten() {
161                if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
162                    let name = entry.file_name().to_string_lossy().to_string();
163                    if !name.starts_with('.') && !name.starts_with('_') {
164                        return format!("uv run python -m {}", name);
165                    }
166                }
167            }
168        }
169
170        // Check for [project.scripts] in pyproject.toml
171        if let Ok(content) = std::fs::read_to_string(path.join("pyproject.toml")) {
172            if content.contains("[project.scripts]") {
173                // Parse the first script name
174                let mut in_scripts = false;
175                for raw_line in content.lines() {
176                    let line = raw_line.trim();
177                    if line == "[project.scripts]" {
178                        in_scripts = true;
179                        continue;
180                    }
181                    if in_scripts {
182                        if line.starts_with('[') {
183                            break;
184                        }
185                        if let Some((name, _)) = line.split_once('=') {
186                            let script = name.trim().trim_matches('"');
187                            if !script.is_empty() {
188                                return format!("uv run {}", script);
189                            }
190                        }
191                    }
192                }
193            }
194        }
195
196        // Default: run main module
197        "uv run python -m main".to_string()
198    }
199
200    // PSP-5 capability methods
201
202    fn syntax_check_command(&self) -> Option<String> {
203        // `ty` is the primary Python sensor (PSP-10 decision 15) whenever
204        // its host binary is present — offline, no `uvx` download. The
205        // stdlib compileall boundary remains the no-`ty` fallback: cheap,
206        // offline, and import/syntax errors are also caught by tests.
207        if host_binary_available("ty") {
208            return Some("ty check --output-format concise".to_string());
209        }
210        Some(r"uv run --no-sync python -m compileall -q -x '(\.venv|\.perspt|\.git)' .".to_string())
211    }
212
213    fn lint_command(&self) -> Option<String> {
214        Some("uv run --no-sync ruff check .".to_string())
215    }
216
217    fn file_ownership_patterns(&self) -> &[&str] {
218        &["py", "pyproject.toml", "setup.py", "requirements.txt"]
219    }
220
221    fn is_test_file(&self, path: &str) -> bool {
222        let normalized = path.replace('\\', "/");
223        let file = normalized.rsplit('/').next().unwrap_or(&normalized);
224        normalized.starts_with("tests/")
225            || normalized.contains("/tests/")
226            || file.starts_with("test_") && file.ends_with(".py")
227            || file.ends_with("_test.py")
228    }
229
230    fn host_tool_available(&self) -> bool {
231        host_binary_available("uv")
232    }
233
234    fn format_command(&self) -> Option<String> {
235        host_binary_available("ruff").then(|| "ruff format .".to_string())
236    }
237
238    fn format_check_command(&self) -> Option<String> {
239        host_binary_available("ruff").then(|| "ruff format --check .".to_string())
240    }
241
242    fn lsp_fallback(&self) -> Option<LspConfig> {
243        Some(LspConfig {
244            server_binary: "pyright-langserver".to_string(),
245            args: vec!["--stdio".to_string()],
246            language_id: "python".to_string(),
247        })
248    }
249
250    fn verifier_profile(&self) -> VerifierProfile {
251        let uv = host_binary_available("uv");
252        let pyright = host_binary_available("pyright");
253
254        // Verification is offline and environment-first: the governed
255        // sandbox denies network, so every command must be satisfiable from
256        // the project's synced `.venv` or a host binary. `uvx`-style
257        // download-on-demand tools are not usable as gate sensors.
258        let ruff = host_binary_available("ruff");
259        let capabilities = vec![
260            VerifierCapability {
261                stage: VerifierStage::SyntaxCheck,
262                command: self.syntax_check_command(),
263                available: uv || host_binary_available("ty"),
264                // Pyright is the separately fingerprinted fallback sensor;
265                // `--outputjson` feeds the structured parser, and its
266                // measurements are never pooled with `ty`.
267                fallback_command: Some("pyright --outputjson".to_string()),
268                fallback_available: pyright,
269            },
270            VerifierCapability {
271                stage: VerifierStage::Build,
272                // Python has no separate build step; declare the capability
273                // so the sensor doesn't appear as Unavailable/degraded.
274                command: None,
275                available: true,
276                fallback_command: None,
277                fallback_available: false,
278            },
279            VerifierCapability {
280                stage: VerifierStage::Test,
281                command: Some(self.test_command()),
282                available: uv,
283                // bare pytest fallback
284                fallback_command: Some("python -m pytest".to_string()),
285                fallback_available: host_binary_available("python3")
286                    || host_binary_available("python"),
287            },
288            VerifierCapability {
289                stage: VerifierStage::Lint,
290                // Lint gates only where ruff is actually present on the
291                // host; otherwise the stage is a declared no-op, not a
292                // missing sensor — projects are not required to ship ruff.
293                command: ruff.then(|| "ruff check .".to_string()),
294                available: true,
295                fallback_command: None,
296                fallback_available: false,
297            },
298            VerifierCapability {
299                stage: VerifierStage::Format,
300                // Same posture as lint: a declared no-op without ruff.
301                command: self.format_check_command(),
302                available: true,
303                fallback_command: None,
304                fallback_available: false,
305            },
306        ];
307
308        let primary = self.get_lsp_config();
309        let primary_available = host_binary_available("uvx");
310        let fallback = self.lsp_fallback();
311        let fallback_available = fallback
312            .as_ref()
313            .map(|f| host_binary_available(&f.server_binary))
314            .unwrap_or(false);
315
316        VerifierProfile {
317            plugin_name: self.name().to_string(),
318            capabilities,
319            lsp: LspCapability {
320                primary,
321                primary_available,
322                fallback,
323                fallback_available,
324            },
325        }
326    }
327
328    // PSP-7 correction contract
329
330    fn legal_support_files(&self) -> &[&str] {
331        &[
332            "pyproject.toml",
333            "setup.py",
334            "setup.cfg",
335            "__init__.py",
336            "conftest.py",
337        ]
338    }
339
340    fn dependency_commands(
341        &self,
342        action: crate::plugin::DependencyAction,
343        packages: &[String],
344        dev: bool,
345    ) -> Vec<String> {
346        use crate::plugin::DependencyAction;
347        let list = packages.join(" ");
348        match action {
349            DependencyAction::Add if dev => vec![format!("uv add --dev {list}")],
350            DependencyAction::Add => vec![format!("uv add {list}")],
351            DependencyAction::Remove => vec![format!("uv remove {list}")],
352            DependencyAction::Update => {
353                let upgrades = packages
354                    .iter()
355                    .map(|p| format!("--upgrade-package {p}"))
356                    .collect::<Vec<_>>()
357                    .join(" ");
358                // The verifier sandbox runs offline against the synced env,
359                // so syncing is part of the governed action.
360                vec![format!("uv lock {upgrades}"), "uv sync".into()]
361            }
362        }
363    }
364
365    fn dependency_files(&self) -> Vec<String> {
366        vec!["pyproject.toml".into(), "uv.lock".into()]
367    }
368
369    fn dependency_command_policy(&self, command: &str) -> crate::types::CommandPolicyDecision {
370        let trimmed = command.trim();
371        if trimmed.starts_with("uv add ")
372            || trimmed.starts_with("uv pip install ")
373            || trimmed.starts_with("pip install ")
374            || trimmed.starts_with("uv sync")
375            || trimmed.starts_with("uv lock")
376        {
377            crate::types::CommandPolicyDecision::Allow
378        } else if trimmed.starts_with("uv remove ") || trimmed.starts_with("pip uninstall ") {
379            crate::types::CommandPolicyDecision::RequireApproval
380        } else {
381            crate::types::CommandPolicyDecision::Deny
382        }
383    }
384
385    fn test_file_patterns(&self) -> &[&str] {
386        &["tests/*.py", "tests/**/*.py", "test_*.py", "*_test.py"]
387    }
388}