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