Skip to main content

deps_pypi/
types.rs

1use std::any::Any;
2use tower_lsp_server::ls_types::Range;
3
4/// Parsed dependency from pyproject.toml with position tracking.
5///
6/// Stores all information about a Python dependency declaration, including its name,
7/// version requirement, extras, environment markers, and source positions for LSP operations.
8/// Positions are critical for features like hover, completion, and inlay hints.
9///
10/// # Examples
11///
12/// ```
13/// use deps_pypi::types::{PypiDependency, PypiDependencySection, PypiDependencySource};
14/// use tower_lsp_server::ls_types::{Position, Range};
15///
16/// let dep = PypiDependency {
17///     name: "requests".into(),
18///     name_range: Range::new(Position::new(5, 4), Position::new(5, 12)),
19///     version_req: Some(">=2.28.0,<3.0".into()),
20///     version_range: Some(Range::new(Position::new(5, 13), Position::new(5, 27))),
21///     extras: vec!["security".into()],
22///     extras_range: None,
23///     markers: Some("python_full_version >= '3.8'".into()),
24///     markers_range: None,
25///     section: PypiDependencySection::Dependencies,
26///     source: PypiDependencySource::Registry,
27/// };
28///
29/// assert_eq!(dep.name, "requests");
30/// assert!(matches!(dep.section, PypiDependencySection::Dependencies));
31/// ```
32#[derive(Debug, Clone, PartialEq)]
33pub struct PypiDependency {
34    /// The package name **as produced by the parse path that created this
35    /// dependency** — normalization is non-uniform, not a property callers
36    /// can rely on directly:
37    ///
38    /// - PEP 508 string paths (PEP 621 `[project] dependencies`, PEP 735
39    ///   dependency groups, `requirements.txt`) — already PEP 503-normalized,
40    ///   because `pep508_rs::PackageName` normalizes at construction.
41    /// - Poetry table-key paths (`[tool.poetry.dependencies]`,
42    ///   `[tool.poetry.group.*.dependencies]`) — the **verbatim** TOML key,
43    ///   unnormalized.
44    ///
45    /// Callers must therefore never assume a normalized name. Apply
46    /// [`crate::name::normalize`] (via
47    /// `EcosystemFormatter::normalize_package_name`) at lookup time.
48    pub name: deps_core::PackageName,
49    /// LSP range of the package name
50    pub name_range: Range,
51    /// PEP 440 version specifier (e.g., ">=2.28.0,<3.0")
52    pub version_req: Option<deps_core::VersionReq>,
53    /// LSP range of the version specifier
54    pub version_range: Option<Range>,
55    /// PEP 508 extras (e.g., ["security", "socks"])
56    pub extras: Vec<String>,
57    /// LSP range of the extras specification
58    pub extras_range: Option<Range>,
59    /// PEP 508 environment markers (e.g., "python_full_version >= '3.8'"; `pep508_rs`
60    /// canonicalizes `python_version` comparisons to `python_full_version` on serialization)
61    pub markers: Option<String>,
62    /// LSP range of the markers specification
63    pub markers_range: Option<Range>,
64    /// Section where this dependency is declared
65    pub section: PypiDependencySection,
66    /// Source of the dependency (PyPI, Git, Path, URL)
67    pub source: PypiDependencySource,
68}
69
70/// Section in pyproject.toml where a dependency is declared.
71///
72/// Python projects use different sections for different types of dependencies:
73/// - `[project.dependencies]`: Runtime dependencies (PEP 621)
74/// - `[project.optional-dependencies.*]`: Optional dependency groups (PEP 621)
75/// - `[tool.poetry.dependencies]`: Runtime dependencies (Poetry)
76/// - `[tool.poetry.group.*.dependencies]`: Dependency groups (Poetry)
77///
78/// # Examples
79///
80/// ```
81/// use deps_pypi::types::PypiDependencySection;
82///
83/// let section = PypiDependencySection::Dependencies;
84/// assert!(matches!(section, PypiDependencySection::Dependencies));
85/// ```
86#[derive(Debug, Clone, PartialEq)]
87#[non_exhaustive]
88pub enum PypiDependencySection {
89    /// PEP 517/518 build system requires (`[build-system.requires]`)
90    BuildSystem,
91    /// PEP 621 runtime dependencies (`[project.dependencies]`)
92    Dependencies,
93    /// PEP 621 optional dependency group (`[project.optional-dependencies.{group}]`)
94    OptionalDependencies { group: String },
95    /// PEP 735 dependency group (`[dependency-groups.{group}]`)
96    DependencyGroup { group: String },
97    /// Poetry runtime dependencies (`[tool.poetry.dependencies]`)
98    PoetryDependencies,
99    /// Poetry dependency group (`[tool.poetry.group.{group}.dependencies]`)
100    PoetryGroup { group: String },
101    /// A line in a `requirements.txt`- or `constraints.txt`-format file (pip's
102    /// requirements file format). Both file kinds map to this single variant —
103    /// nothing downstream distinguishes a constraint from a requirement.
104    Requirements,
105}
106
107pub use deps_core::parser::DependencySource as PypiDependencySource;
108
109/// Version information for a package from PyPI.
110///
111/// Retrieved from the PyPI JSON API at `https://pypi.org/pypi/{package}/json`.
112/// Contains version number, yanked status, and prerelease detection.
113///
114/// # Examples
115///
116/// ```
117/// use deps_pypi::types::PypiVersion;
118///
119/// let version = PypiVersion {
120///     version: "2.28.2".into(),
121///     yanked: false,
122///     published_at: None,
123/// };
124///
125/// assert!(!version.yanked);
126/// assert!(!version.is_prerelease());
127/// ```
128#[derive(Debug, Clone)]
129pub struct PypiVersion {
130    /// Version string (PEP 440 compliant)
131    pub version: deps_core::ConcreteVersion,
132    /// Whether this version has been yanked from PyPI
133    pub yanked: bool,
134    /// Earliest `upload-time` across this version's release files.
135    ///
136    /// `None` when no file reports one, or every reported value fails to
137    /// parse as RFC 3339 — degrades gracefully, per
138    /// [US-003](https://github.com/bug-ops/deps-lsp/issues/145).
139    pub published_at: Option<deps_core::PublishTime>,
140}
141
142impl PypiVersion {
143    /// Check if this version is a prerelease (alpha, beta, rc).
144    ///
145    /// Uses PEP 440 version parsing for accurate prerelease detection.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use deps_pypi::types::PypiVersion;
151    ///
152    /// let stable = PypiVersion { version: "1.0.0".into(), yanked: false, published_at: None };
153    /// let alpha = PypiVersion { version: "1.0.0a1".into(), yanked: false, published_at: None };
154    /// let beta = PypiVersion { version: "1.0.0b2".into(), yanked: false, published_at: None };
155    /// let rc = PypiVersion { version: "1.0.0rc1".into(), yanked: false, published_at: None };
156    ///
157    /// assert!(!stable.is_prerelease());
158    /// assert!(alpha.is_prerelease());
159    /// assert!(beta.is_prerelease());
160    /// assert!(rc.is_prerelease());
161    /// ```
162    ///
163    /// Do not rename or remove this method without updating the
164    /// `prerelease:` closure in the `impl_version!` call below, which
165    /// delegates to it by name and relies on inherent-beats-trait method
166    /// resolution to reach it.
167    pub fn is_prerelease(&self) -> bool {
168        use pep440_rs::Version;
169        use std::str::FromStr;
170
171        Version::from_str(self.version.as_str())
172            .map(|v| v.is_pre())
173            .unwrap_or(false)
174    }
175}
176
177// Use macro to implement VersionInfo and Version traits. Without an explicit
178// `prerelease:` arm, `impl_version!` would fall back to the trait's default
179// hyphen-substring heuristic, which is unreachable-shadowing for `PypiVersion`
180// once boxed as `dyn deps_core::Version` — its inherent `is_prerelease` above
181// is PEP 440-aware and must be the one actually consulted.
182//
183// `v.is_prerelease()` below resolves to the inherent method (defined above,
184// on the concrete `PypiVersion` type) because inherent methods take priority
185// over trait methods for the same receiver — this is deliberate delegation,
186// not a mistake. If that inherent method is ever renamed or removed, this
187// call would silently rebind to the trait method being defined right here,
188// causing unbounded recursion; keep the two in sync.
189deps_core::impl_version!(PypiVersion {
190    version: version,
191    status: |v: &PypiVersion| deps_core::RemovalStatus::from_yanked(v.yanked),
192    published_at: published_at,
193    prerelease: |v: &PypiVersion| v.is_prerelease(),
194});
195
196/// Package metadata from PyPI.
197///
198/// Contains basic information about a PyPI package for display in completion
199/// suggestions. Retrieved from `https://pypi.org/pypi/{package}/json`.
200///
201/// # Examples
202///
203/// ```
204/// use deps_pypi::types::PypiPackage;
205///
206/// let pkg = PypiPackage {
207///     name: deps_core::PackageName::new("requests"),
208///     summary: Some("Python HTTP for Humans.".into()),
209///     project_urls: vec![
210///         ("Homepage".into(), "https://requests.readthedocs.io".into()),
211///         ("Repository".into(), "https://github.com/psf/requests".into()),
212///     ],
213///     latest_version: "2.28.2".into(),
214/// };
215///
216/// assert_eq!(pkg.name, "requests");
217/// ```
218#[derive(Debug, Clone)]
219pub struct PypiPackage {
220    /// Package name (canonical form)
221    pub name: deps_core::PackageName,
222    /// Short package summary/description
223    pub summary: Option<String>,
224    /// Project URLs (homepage, repository, documentation, etc.)
225    pub project_urls: Vec<(String, String)>,
226    /// Latest stable version
227    pub latest_version: deps_core::ConcreteVersion,
228}
229
230// Implement deps_core traits
231
232impl deps_core::Dependency for PypiDependency {
233    fn name(&self) -> &deps_core::PackageName {
234        &self.name
235    }
236
237    fn name_range(&self) -> Range {
238        self.name_range
239    }
240
241    fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
242        self.version_req.as_ref()
243    }
244
245    fn version_range(&self) -> Option<Range> {
246        self.version_range
247    }
248
249    fn source(&self) -> deps_core::parser::DependencySource {
250        self.source.clone()
251    }
252
253    fn features(&self) -> &[String] {
254        &self.extras
255    }
256
257    fn markers(&self) -> Option<&str> {
258        self.markers.as_deref()
259    }
260
261    fn markers_range(&self) -> Option<Range> {
262        self.markers_range
263    }
264
265    fn as_any(&self) -> &dyn Any {
266        self
267    }
268}
269
270impl deps_core::Metadata for PypiPackage {
271    fn name(&self) -> &deps_core::PackageName {
272        &self.name
273    }
274
275    fn description(&self) -> Option<&str> {
276        self.summary.as_deref()
277    }
278
279    fn repository(&self) -> Option<&str> {
280        self.project_urls
281            .iter()
282            .find(|(key, _)| {
283                key.eq_ignore_ascii_case("repository")
284                    || key.eq_ignore_ascii_case("source")
285                    || key.eq_ignore_ascii_case("code")
286            })
287            .map(|(_, url)| url.as_str())
288    }
289
290    fn documentation(&self) -> Option<&str> {
291        self.project_urls
292            .iter()
293            .find(|(key, _)| {
294                key.eq_ignore_ascii_case("documentation")
295                    || key.eq_ignore_ascii_case("docs")
296                    || key.eq_ignore_ascii_case("homepage")
297            })
298            .map(|(_, url)| url.as_str())
299    }
300
301    fn latest_version(&self) -> &deps_core::ConcreteVersion {
302        &self.latest_version
303    }
304
305    fn as_any(&self) -> &dyn Any {
306        self
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use deps_core::{Metadata, Version};
314    use std::assert_matches;
315    use tower_lsp_server::ls_types::Position;
316
317    #[test]
318    fn test_pypi_dependency_creation() {
319        let dep = PypiDependency {
320            name: "flask".into(),
321            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
322            version_req: Some(">=3.0.0".into()),
323            version_range: Some(Range::new(Position::new(0, 6), Position::new(0, 14))),
324            extras: vec!["async".into()],
325            extras_range: None,
326            markers: Some("python_version>='3.9'".into()),
327            markers_range: None,
328            section: PypiDependencySection::Dependencies,
329            source: PypiDependencySource::Registry,
330        };
331
332        assert_eq!(dep.name, "flask");
333        assert_eq!(dep.version_req, Some(">=3.0.0".into()));
334        assert_eq!(dep.extras, vec!["async"]);
335    }
336
337    #[test]
338    fn test_pypi_dependency_features_maps_to_extras() {
339        use deps_core::Dependency;
340
341        let dep = PypiDependency {
342            name: "requests".into(),
343            name_range: Range::default(),
344            version_req: None,
345            version_range: None,
346            extras: vec!["security".into(), "socks".into()],
347            extras_range: None,
348            markers: None,
349            markers_range: None,
350            section: PypiDependencySection::Dependencies,
351            source: PypiDependencySource::Registry,
352        };
353
354        assert_eq!(
355            dep.features(),
356            &["security".to_string(), "socks".to_string()]
357        );
358    }
359
360    #[test]
361    fn test_dependency_section_variants() {
362        let deps = PypiDependencySection::Dependencies;
363        let opt_deps = PypiDependencySection::OptionalDependencies {
364            group: "dev".into(),
365        };
366        let dep_group = PypiDependencySection::DependencyGroup {
367            group: "dev".into(),
368        };
369        let poetry_deps = PypiDependencySection::PoetryDependencies;
370        let poetry_group = PypiDependencySection::PoetryGroup {
371            group: "test".into(),
372        };
373
374        assert_matches!(deps, PypiDependencySection::Dependencies);
375        assert_matches!(opt_deps, PypiDependencySection::OptionalDependencies { .. });
376        assert_matches!(dep_group, PypiDependencySection::DependencyGroup { .. });
377        assert_matches!(poetry_deps, PypiDependencySection::PoetryDependencies);
378        assert_matches!(poetry_group, PypiDependencySection::PoetryGroup { .. });
379    }
380
381    #[test]
382    fn test_dependency_source_variants() {
383        let registry = PypiDependencySource::Registry;
384        let git = PypiDependencySource::Git {
385            url: "https://github.com/user/repo.git".into(),
386            rev: Some("main".into()),
387        };
388        let path = PypiDependencySource::Path {
389            path: "../local".into(),
390        };
391        let url = PypiDependencySource::Url {
392            url: "https://example.com/package.whl".into(),
393        };
394
395        assert!(registry.is_registry());
396        assert_matches!(git, PypiDependencySource::Git { .. });
397        assert_matches!(path, PypiDependencySource::Path { .. });
398        assert!(!url.is_registry());
399    }
400
401    #[test]
402    fn test_pypi_version_creation() {
403        let version = PypiVersion {
404            version: "1.0.0".into(),
405            yanked: false,
406            published_at: None,
407        };
408
409        assert_eq!(version.version, "1.0.0");
410        assert!(!version.yanked);
411        assert!(!version.is_prerelease());
412    }
413
414    #[test]
415    fn test_pypi_version_prerelease_detection() {
416        let stable = PypiVersion {
417            version: "1.0.0".into(),
418            yanked: false,
419            published_at: None,
420        };
421        let alpha = PypiVersion {
422            version: "1.0.0a1".into(),
423            yanked: false,
424            published_at: None,
425        };
426        let beta = PypiVersion {
427            version: "1.0.0b2".into(),
428            yanked: false,
429            published_at: None,
430        };
431        let rc = PypiVersion {
432            version: "1.0.0rc1".into(),
433            yanked: false,
434            published_at: None,
435        };
436
437        assert!(!stable.is_prerelease());
438        assert!(alpha.is_prerelease());
439        assert!(beta.is_prerelease());
440        assert!(rc.is_prerelease());
441    }
442
443    #[test]
444    fn test_pypi_version_prerelease_detection_through_version_trait() {
445        // Regression test for #322: `impl_version!` must not shadow the
446        // PEP 440-aware inherent `is_prerelease` with the deps-core default
447        // hyphen-substring heuristic once boxed as `dyn deps_core::Version`.
448        let rc: Box<dyn Version> = Box::new(PypiVersion {
449            version: "1.0.0rc1".into(),
450            yanked: false,
451            published_at: None,
452        });
453        let stable: Box<dyn Version> = Box::new(PypiVersion {
454            version: "1.0.0".into(),
455            yanked: false,
456            published_at: None,
457        });
458
459        assert!(rc.is_prerelease());
460        assert!(!rc.is_stable());
461        assert!(!stable.is_prerelease());
462        assert!(stable.is_stable());
463    }
464
465    #[test]
466    fn test_pypi_version_trait() {
467        let version = PypiVersion {
468            version: "2.28.2".into(),
469            yanked: true,
470            published_at: None,
471        };
472
473        assert_eq!(version.version_string(), "2.28.2");
474        assert!(version.removal_status().blocks_resolution());
475    }
476
477    #[test]
478    fn test_pypi_package_creation() {
479        let pkg = PypiPackage {
480            name: "requests".into(),
481            summary: Some("Python HTTP for Humans.".into()),
482            project_urls: vec![
483                ("Homepage".into(), "https://requests.readthedocs.io".into()),
484                (
485                    "Repository".into(),
486                    "https://github.com/psf/requests".into(),
487                ),
488            ],
489            latest_version: "2.28.2".into(),
490        };
491
492        assert_eq!(pkg.name, "requests");
493        assert_eq!(pkg.latest_version, "2.28.2");
494    }
495
496    #[test]
497    fn test_pypi_package_metadata_trait() {
498        let pkg = PypiPackage {
499            name: "flask".into(),
500            summary: Some("A micro web framework".into()),
501            project_urls: vec![
502                (
503                    "Documentation".into(),
504                    "https://flask.palletsprojects.com/".into(),
505                ),
506                (
507                    "Repository".into(),
508                    "https://github.com/pallets/flask".into(),
509                ),
510            ],
511            latest_version: "3.0.0".into(),
512        };
513
514        assert_eq!(pkg.name(), "flask");
515        assert_eq!(pkg.description(), Some("A micro web framework"));
516        assert_eq!(pkg.repository(), Some("https://github.com/pallets/flask"));
517        assert_eq!(
518            pkg.documentation(),
519            Some("https://flask.palletsprojects.com/")
520        );
521        assert_eq!(pkg.latest_version(), "3.0.0");
522    }
523
524    #[test]
525    fn test_package_url_fallbacks() {
526        let pkg = PypiPackage {
527            name: "test".into(),
528            summary: None,
529            project_urls: vec![
530                ("Homepage".into(), "https://example.com".into()),
531                ("Source".into(), "https://github.com/test/test".into()),
532            ],
533            latest_version: "1.0.0".into(),
534        };
535
536        // Should find "Source" as fallback for repository
537        assert_eq!(pkg.repository(), Some("https://github.com/test/test"));
538        // Should find "Homepage" as fallback for documentation
539        assert_eq!(pkg.documentation(), Some("https://example.com"));
540    }
541}