1use async_trait::async_trait;
42use deps_core::error::{DepsError, Result};
43use deps_core::lockfile::{
44 LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
45 locate_lockfile_for_manifest,
46};
47use std::path::{Path, PathBuf};
48use toml_edit::DocumentMut;
49use tower_lsp_server::ls_types::Uri;
50
51pub struct PypiLockParser;
83
84impl PypiLockParser {
85 const LOCKFILE_NAMES: &'static [&'static str] = &["poetry.lock", "uv.lock"];
87}
88
89#[async_trait]
90impl LockFileProvider for PypiLockParser {
91 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
92 locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
93 }
94
95 async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages> {
96 tracing::debug!("Parsing lock file: {}", lockfile_path.display());
97
98 let content = tokio::fs::read_to_string(lockfile_path)
99 .await
100 .map_err(|e| DepsError::ParseError {
101 file_type: format!("lock file at {}", lockfile_path.display()),
102 source: Box::new(e),
103 })?;
104
105 let doc: DocumentMut = content.parse().map_err(|e| DepsError::ParseError {
106 file_type: "Python lock file".into(),
107 source: Box::new(e),
108 })?;
109
110 let mut packages = ResolvedPackages::new();
111
112 let Some(package_array) = doc
113 .get("package")
114 .and_then(|v: &toml_edit::Item| v.as_array_of_tables())
115 else {
116 tracing::warn!("Lock file missing [[package]] array of tables");
117 return Ok(packages);
118 };
119
120 for table in package_array {
121 let Some(name) = table.get("name").and_then(|v: &toml_edit::Item| v.as_str()) else {
123 tracing::warn!("Package missing name field");
124 continue;
125 };
126
127 let Some(version) = table
128 .get("version")
129 .and_then(|v: &toml_edit::Item| v.as_str())
130 else {
131 tracing::warn!("Package '{}' missing version field", name);
132 continue;
133 };
134
135 let source = parse_pypi_source(table);
137
138 let dependencies = parse_pypi_dependencies(table);
140
141 let normalized_name = name.to_lowercase().replace('-', "_");
143 packages.insert(ResolvedPackage {
144 name: normalized_name,
145 version: version.to_string(),
146 source,
147 dependencies,
148 });
149 }
150
151 tracing::info!(
152 "Parsed lock file: {} packages from {}",
153 packages.len(),
154 lockfile_path.display()
155 );
156
157 Ok(packages)
158 }
159}
160
161fn parse_pypi_source(table: &toml_edit::Table) -> ResolvedSource {
177 let Some(source_item) = table.get("source") else {
178 return ResolvedSource::Registry {
180 url: "https://pypi.org/simple".to_string(),
181 checksum: String::new(),
182 };
183 };
184
185 if let Some(source_table) = source_item.as_inline_table() {
187 if let Some(registry) = source_table.get("registry").and_then(|v| v.as_str()) {
189 return ResolvedSource::Registry {
190 url: registry.to_string(),
191 checksum: String::new(),
192 };
193 }
194
195 if let Some(git_url) = source_table.get("git").and_then(|v| v.as_str()) {
197 let rev = source_table
198 .get("rev")
199 .and_then(|v| v.as_str())
200 .unwrap_or("")
201 .to_string();
202
203 return ResolvedSource::Git {
204 url: git_url.to_string(),
205 rev,
206 };
207 }
208
209 if let Some(path) = source_table.get("path").and_then(|v| v.as_str()) {
211 return ResolvedSource::Path {
212 path: path.to_string(),
213 };
214 }
215 }
216
217 if let Some(source_table) = source_item.as_table() {
219 if let Some(source_type) = source_table.get("type").and_then(|v| v.as_str()) {
221 match source_type {
222 "git" => {
223 let url = source_table
224 .get("url")
225 .and_then(|v| v.as_str())
226 .unwrap_or("")
227 .to_string();
228
229 let rev = source_table
230 .get("resolved_reference")
231 .or_else(|| source_table.get("reference"))
232 .and_then(|v| v.as_str())
233 .unwrap_or("")
234 .to_string();
235
236 return ResolvedSource::Git { url, rev };
237 }
238 "directory" | "file" => {
239 let path = source_table
240 .get("url")
241 .and_then(|v| v.as_str())
242 .unwrap_or("")
243 .to_string();
244
245 return ResolvedSource::Path { path };
246 }
247 _ => {}
248 }
249 }
250 }
251
252 ResolvedSource::Registry {
254 url: "https://pypi.org/simple".to_string(),
255 checksum: String::new(),
256 }
257}
258
259fn parse_pypi_dependencies(table: &toml_edit::Table) -> Vec<String> {
280 if let Some(deps_value) = table.get("dependencies")
282 && let Some(deps_array) = deps_value.as_array()
283 {
284 return deps_array
285 .iter()
286 .filter_map(|item| {
287 if let Some(dep_table) = item.as_inline_table()
289 && let Some(name) = dep_table.get("name").and_then(|v| v.as_str())
290 {
291 return Some(name.to_string());
292 }
293
294 if let Some(s) = item.as_str() {
296 return Some(s.to_string());
297 }
298
299 None
300 })
301 .collect();
302 }
303
304 if let Some(deps_item) = table.get("dependencies")
306 && let Some(deps_table) = deps_item.as_table()
307 {
308 return deps_table
309 .iter()
310 .map(|(name, _)| name.to_string())
311 .collect();
312 }
313
314 vec![]
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[tokio::test]
322 async fn test_parse_simple_poetry_lock() {
323 let lockfile_content = r#"
324# This file is automatically generated by poetry.
325[[package]]
326name = "requests"
327version = "2.31.0"
328description = "Python HTTP for Humans."
329
330[package.dependencies]
331certifi = ">=2017.4.17"
332charset-normalizer = ">=2,<4"
333
334[[package]]
335name = "certifi"
336version = "2023.7.22"
337description = "Python package for providing Mozilla's CA Bundle."
338
339[metadata]
340lock-version = "2.0"
341python-versions = "^3.9"
342"#;
343
344 let temp_dir = tempfile::tempdir().unwrap();
345 let lockfile_path = temp_dir.path().join("poetry.lock");
346 std::fs::write(&lockfile_path, lockfile_content).unwrap();
347
348 let parser = PypiLockParser;
349 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
350
351 assert_eq!(resolved.len(), 2);
352 assert_eq!(resolved.get_version("requests"), Some("2.31.0"));
353 assert_eq!(resolved.get_version("certifi"), Some("2023.7.22"));
354
355 let requests_pkg = resolved.get("requests").unwrap();
356 assert_eq!(requests_pkg.dependencies.len(), 2);
357 assert!(requests_pkg.dependencies.contains(&"certifi".to_string()));
358 assert!(
359 requests_pkg
360 .dependencies
361 .contains(&"charset-normalizer".to_string())
362 );
363
364 match &requests_pkg.source {
366 ResolvedSource::Registry { url, .. } => {
367 assert_eq!(url, "https://pypi.org/simple");
368 }
369 _ => panic!("Expected Registry source"),
370 }
371 }
372
373 #[tokio::test]
374 async fn test_parse_uv_lock() {
375 let lockfile_content = r#"
376version = 1
377
378[[package]]
379name = "requests"
380version = "2.31.0"
381source = { registry = "https://pypi.org/simple" }
382dependencies = [
383 { name = "certifi" },
384 { name = "charset-normalizer" },
385]
386
387[[package]]
388name = "certifi"
389version = "2023.7.22"
390source = { registry = "https://pypi.org/simple" }
391"#;
392
393 let temp_dir = tempfile::tempdir().unwrap();
394 let lockfile_path = temp_dir.path().join("uv.lock");
395 std::fs::write(&lockfile_path, lockfile_content).unwrap();
396
397 let parser = PypiLockParser;
398 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
399
400 assert_eq!(resolved.len(), 2);
401 assert_eq!(resolved.get_version("requests"), Some("2.31.0"));
402 assert_eq!(resolved.get_version("certifi"), Some("2023.7.22"));
403
404 let requests_pkg = resolved.get("requests").unwrap();
405 assert_eq!(requests_pkg.dependencies.len(), 2);
406 assert!(requests_pkg.dependencies.contains(&"certifi".to_string()));
407
408 match &requests_pkg.source {
409 ResolvedSource::Registry { url, .. } => {
410 assert_eq!(url, "https://pypi.org/simple");
411 }
412 _ => panic!("Expected Registry source"),
413 }
414 }
415
416 #[tokio::test]
417 async fn test_parse_poetry_lock_with_git() {
418 let lockfile_content = r#"
419[[package]]
420name = "my-git-dep"
421version = "0.1.0"
422description = "Git dependency"
423
424[package.source]
425type = "git"
426url = "https://github.com/user/repo"
427resolved_reference = "abc123def456"
428"#;
429
430 let temp_dir = tempfile::tempdir().unwrap();
431 let lockfile_path = temp_dir.path().join("poetry.lock");
432 std::fs::write(&lockfile_path, lockfile_content).unwrap();
433
434 let parser = PypiLockParser;
435 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
436
437 assert_eq!(resolved.len(), 1);
439 let pkg = resolved.get("my_git_dep").unwrap();
440 assert_eq!(pkg.version, "0.1.0");
441
442 match &pkg.source {
443 ResolvedSource::Git { url, rev } => {
444 assert_eq!(url, "https://github.com/user/repo");
445 assert_eq!(rev, "abc123def456");
446 }
447 _ => panic!("Expected Git source"),
448 }
449 }
450
451 #[tokio::test]
452 async fn test_parse_uv_lock_with_git() {
453 let lockfile_content = r#"
454version = 1
455
456[[package]]
457name = "my-git-dep"
458version = "0.1.0"
459source = { git = "https://github.com/user/repo", rev = "abc123" }
460"#;
461
462 let temp_dir = tempfile::tempdir().unwrap();
463 let lockfile_path = temp_dir.path().join("uv.lock");
464 std::fs::write(&lockfile_path, lockfile_content).unwrap();
465
466 let parser = PypiLockParser;
467 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
468
469 assert_eq!(resolved.len(), 1);
471 let pkg = resolved.get("my_git_dep").unwrap();
472
473 match &pkg.source {
474 ResolvedSource::Git { url, rev } => {
475 assert_eq!(url, "https://github.com/user/repo");
476 assert_eq!(rev, "abc123");
477 }
478 _ => panic!("Expected Git source"),
479 }
480 }
481
482 #[tokio::test]
483 async fn test_parse_poetry_lock_with_path() {
484 let lockfile_content = r#"
485[[package]]
486name = "my-local-dep"
487version = "0.1.0"
488
489[package.source]
490type = "directory"
491url = "../local-package"
492"#;
493
494 let temp_dir = tempfile::tempdir().unwrap();
495 let lockfile_path = temp_dir.path().join("poetry.lock");
496 std::fs::write(&lockfile_path, lockfile_content).unwrap();
497
498 let parser = PypiLockParser;
499 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
500
501 assert_eq!(resolved.len(), 1);
503 let pkg = resolved.get("my_local_dep").unwrap();
504
505 match &pkg.source {
506 ResolvedSource::Path { path } => {
507 assert_eq!(path, "../local-package");
508 }
509 _ => panic!("Expected Path source"),
510 }
511 }
512
513 #[tokio::test]
514 async fn test_parse_uv_lock_with_path() {
515 let lockfile_content = r#"
516version = 1
517
518[[package]]
519name = "my-local-dep"
520version = "0.1.0"
521source = { path = "../local-package" }
522"#;
523
524 let temp_dir = tempfile::tempdir().unwrap();
525 let lockfile_path = temp_dir.path().join("uv.lock");
526 std::fs::write(&lockfile_path, lockfile_content).unwrap();
527
528 let parser = PypiLockParser;
529 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
530
531 assert_eq!(resolved.len(), 1);
533 let pkg = resolved.get("my_local_dep").unwrap();
534
535 match &pkg.source {
536 ResolvedSource::Path { path } => {
537 assert_eq!(path, "../local-package");
538 }
539 _ => panic!("Expected Path source"),
540 }
541 }
542
543 #[tokio::test]
544 async fn test_parse_empty_lock_file() {
545 let lockfile_content = r"
546version = 1
547";
548
549 let temp_dir = tempfile::tempdir().unwrap();
550 let lockfile_path = temp_dir.path().join("poetry.lock");
551 std::fs::write(&lockfile_path, lockfile_content).unwrap();
552
553 let parser = PypiLockParser;
554 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
555
556 assert_eq!(resolved.len(), 0);
557 assert!(resolved.is_empty());
558 }
559
560 #[tokio::test]
561 async fn test_parse_malformed_toml() {
562 let lockfile_content = "not valid toml {{{";
563
564 let temp_dir = tempfile::tempdir().unwrap();
565 let lockfile_path = temp_dir.path().join("poetry.lock");
566 std::fs::write(&lockfile_path, lockfile_content).unwrap();
567
568 let parser = PypiLockParser;
569 let result = parser.parse_lockfile(&lockfile_path).await;
570
571 assert!(result.is_err());
572 }
573
574 #[test]
575 fn test_locate_lockfile_poetry_priority() {
576 let temp_dir = tempfile::tempdir().unwrap();
577 let manifest_path = temp_dir.path().join("pyproject.toml");
578 let poetry_lock = temp_dir.path().join("poetry.lock");
579 let uv_lock = temp_dir.path().join("uv.lock");
580
581 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
582 std::fs::write(&poetry_lock, "# poetry.lock").unwrap();
583 std::fs::write(&uv_lock, "# uv.lock").unwrap();
584
585 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
586 let parser = PypiLockParser;
587
588 let located = parser.locate_lockfile(&manifest_uri);
589 assert!(located.is_some());
590 assert_eq!(
591 located.unwrap(),
592 poetry_lock,
593 "poetry.lock should take priority over uv.lock"
594 );
595 }
596
597 #[test]
598 fn test_locate_lockfile_uv_fallback() {
599 let temp_dir = tempfile::tempdir().unwrap();
600 let manifest_path = temp_dir.path().join("pyproject.toml");
601 let uv_lock = temp_dir.path().join("uv.lock");
602
603 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
604 std::fs::write(&uv_lock, "# uv.lock").unwrap();
605
606 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
607 let parser = PypiLockParser;
608
609 let located = parser.locate_lockfile(&manifest_uri);
610 assert!(located.is_some());
611 assert_eq!(located.unwrap(), uv_lock);
612 }
613
614 #[test]
615 fn test_locate_lockfile_not_found() {
616 let temp_dir = tempfile::tempdir().unwrap();
617 let manifest_path = temp_dir.path().join("pyproject.toml");
618 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
619
620 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
621 let parser = PypiLockParser;
622
623 let located = parser.locate_lockfile(&manifest_uri);
624 assert!(located.is_none());
625 }
626
627 #[tokio::test]
628 async fn test_parse_poetry_lock_missing_fields() {
629 let lockfile_content = r#"
630[[package]]
631name = "valid-package"
632version = "1.0.0"
633
634[[package]]
635# Missing name field
636version = "2.0.0"
637
638[[package]]
639name = "missing-version"
640# Missing version field
641"#;
642
643 let temp_dir = tempfile::tempdir().unwrap();
644 let lockfile_path = temp_dir.path().join("poetry.lock");
645 std::fs::write(&lockfile_path, lockfile_content).unwrap();
646
647 let parser = PypiLockParser;
648 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
649
650 assert_eq!(resolved.len(), 1);
652 assert_eq!(resolved.get_version("valid_package"), Some("1.0.0"));
653 assert!(resolved.get("missing_version").is_none());
654 }
655
656 #[test]
657 fn test_is_lockfile_stale_not_modified() {
658 let temp_dir = tempfile::tempdir().unwrap();
659 let lockfile_path = temp_dir.path().join("poetry.lock");
660 std::fs::write(&lockfile_path, "version = 1").unwrap();
661
662 let mtime = std::fs::metadata(&lockfile_path)
663 .unwrap()
664 .modified()
665 .unwrap();
666 let parser = PypiLockParser;
667
668 assert!(
669 !parser.is_lockfile_stale(&lockfile_path, mtime),
670 "Lock file should not be stale when mtime matches"
671 );
672 }
673
674 #[test]
675 fn test_is_lockfile_stale_modified() {
676 let temp_dir = tempfile::tempdir().unwrap();
677 let lockfile_path = temp_dir.path().join("poetry.lock");
678 std::fs::write(&lockfile_path, "version = 1").unwrap();
679
680 let old_time = std::time::UNIX_EPOCH;
681 let parser = PypiLockParser;
682
683 assert!(
684 parser.is_lockfile_stale(&lockfile_path, old_time),
685 "Lock file should be stale when last_modified is old"
686 );
687 }
688
689 #[test]
690 fn test_is_lockfile_stale_deleted() {
691 let parser = PypiLockParser;
692 let non_existent = std::path::Path::new("/nonexistent/poetry.lock");
693
694 assert!(
695 parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
696 "Non-existent lock file should be considered stale"
697 );
698 }
699}