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}