Skip to main content

deps_pypi/
lockfile.rs

1//! Poetry/uv lock file parsing.
2//!
3//! Parses `poetry.lock` and `uv.lock` files to extract resolved dependency
4//! versions. Both formats use TOML with `[[package]]` sections.
5//!
6//! # Lock File Formats
7//!
8//! ## Poetry
9//!
10//! ```toml
11//! # This file is automatically generated by poetry.
12//! [[package]]
13//! name = "requests"
14//! version = "2.31.0"
15//! description = "Python HTTP for Humans."
16//!
17//! [package.dependencies]
18//! certifi = ">=2017.4.17"
19//! charset-normalizer = ">=2,<4"
20//!
21//! [metadata]
22//! lock-version = "2.0"
23//! python-versions = "^3.9"
24//! ```
25//!
26//! ## uv
27//!
28//! ```toml
29//! version = 1
30//!
31//! [[package]]
32//! name = "requests"
33//! version = "2.31.0"
34//! source = { registry = "https://pypi.org/simple" }
35//! dependencies = [
36//!     { name = "certifi" },
37//!     { name = "charset-normalizer" },
38//! ]
39//! ```
40
41use 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
50/// PyPI lock file parser.
51///
52/// Implements lock file parsing for Python package managers (Poetry, uv).
53/// Supports both `poetry.lock` and `uv.lock` formats.
54///
55/// # Lock File Location
56///
57/// The parser searches for lock files in the following order:
58/// 1. `poetry.lock` in the same directory as `pyproject.toml`
59/// 2. `uv.lock` in the same directory as `pyproject.toml`
60///
61/// Poetry takes priority because it's more established.
62///
63/// # Examples
64///
65/// ```no_run
66/// use deps_pypi::lockfile::PypiLockParser;
67/// use deps_core::lockfile::LockFileProvider;
68/// use tower_lsp_server::ls_types::Uri;
69///
70/// # async fn example() -> deps_core::error::Result<()> {
71/// let parser = PypiLockParser;
72/// let manifest_uri = Uri::from_file_path("/path/to/pyproject.toml").unwrap();
73///
74/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) {
75///     let resolved = parser.parse_lockfile(&lockfile_path).await?;
76///     println!("Found {} resolved packages", resolved.len());
77/// }
78/// # Ok(())
79/// # }
80/// ```
81pub struct PypiLockParser;
82
83impl PypiLockParser {
84    /// Lock file names for PyPI ecosystem (poetry.lock, uv.lock).
85    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            // [[package]] in TOML is an array of tables; toml-span represents it as an array of values
123            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                // Extract required fields
138                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                // Parse source (format varies between poetry and uv)
149                let source = parse_pypi_source(table);
150
151                // Parse dependencies (format varies between poetry and uv)
152                let dependencies = parse_pypi_dependencies(table);
153
154                // Normalize name for consistent lookup (PEP 503: case/`_`/`.`-insensitive)
155                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
175/// Parses source information from package table.
176///
177/// Handles both Poetry and uv source formats:
178///
179/// # Poetry Format
180///
181/// - No `source` field → PyPI registry (default)
182/// - `source.type = "git"` with `source.url` and `source.resolved_reference`
183/// - `source.type = "directory"` or `source.type = "file"` with `source.url`
184///
185/// # uv Format
186///
187/// - `source.registry = "https://pypi.org/simple"` → Registry
188/// - `source.git = "https://github.com/..."` → Git
189/// - `source.path = "..."` → Path
190fn parse_pypi_source(table: &Table<'_>) -> ResolvedSource {
191    let Some(source_val) = table.get("source") else {
192        // No source field = PyPI registry (poetry default)
193        return ResolvedSource::Registry {
194            url: "https://pypi.org/simple".to_string(),
195            checksum: String::new(),
196        };
197    };
198
199    // In toml-span, both inline tables and regular tables are represented as Table
200    if let Some(source_table) = source_val.as_table() {
201        // uv: source = { registry = "https://pypi.org/simple" }
202        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        // uv: source = { git = "https://github.com/..." }
210        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        // uv: source = { path = "..." }
224        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        // poetry: [package.source] type = "git"
231        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    // Default to PyPI registry
264    ResolvedSource::Registry {
265        url: "https://pypi.org/simple".to_string(),
266        checksum: String::new(),
267    }
268}
269
270/// Parses dependencies from package table.
271///
272/// Handles both Poetry and uv dependency formats:
273///
274/// # Poetry Format
275///
276/// ```toml
277/// [package.dependencies]
278/// certifi = ">=2017.4.17"
279/// charset-normalizer = ">=2,<4"
280/// ```
281///
282/// # uv Format
283///
284/// ```toml
285/// dependencies = [
286///     { name = "certifi" },
287///     { name = "charset-normalizer" },
288/// ]
289/// ```
290fn parse_pypi_dependencies(table: &Table<'_>) -> Vec<String> {
291    let Some(deps_val) = table.get("dependencies") else {
292        return vec![];
293    };
294
295    // uv format: dependencies as array of inline tables [{ name = "certifi" }]
296    if let Some(deps_array) = deps_val.as_array() {
297        return deps_array
298            .iter()
299            .filter_map(|item| {
300                // uv format: { name = "certifi" }
301                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                // Simple string format (fallback)
309                item.as_str().map(String::from)
310            })
311            .collect();
312    }
313
314    // Poetry format: dependencies as table with package names as keys
315    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        // Well past MAX_TOML_NESTING_DEPTH (64) but far below the depth
331        // that would actually overflow the stack, so the guard is what's
332        // being exercised here, not the crash itself.
333        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        // Verify it's a registry source
391        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        // Names are normalized via PEP 503 (case/`_`/`.` collapsed to `-`)
464        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        // Names are normalized via PEP 503 (case/`_`/`.` collapsed to `-`)
496        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        // Names are normalized via PEP 503 (case/`_`/`.` collapsed to `-`)
528        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        // Names are normalized via PEP 503 (case/`_`/`.` collapsed to `-`)
558        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        // Should only parse valid package (names are normalized: - → _)
677        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}