1use super::*;
2
3pub 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 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 _ => {
81 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 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 format!("uv init --lib {}", opts.name)
127 }
128 }
129
130 fn test_command(&self) -> String {
131 "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 fn run_command_for_dir(&self, path: &Path) -> String {
158 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 if let Ok(content) = std::fs::read_to_string(path.join("pyproject.toml")) {
172 if content.contains("[project.scripts]") {
173 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 "uv run python -m main".to_string()
198 }
199
200 fn syntax_check_command(&self) -> Option<String> {
203 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 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 fallback_command: Some("pyright --outputjson".to_string()),
268 fallback_available: pyright,
269 },
270 VerifierCapability {
271 stage: VerifierStage::Build,
272 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 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 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 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 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 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}