diff --git a/Cargo.lock b/Cargo.lock index d7d3245e..0b77e024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + [[package]] name = "bumpalo" version = "3.19.1" @@ -230,7 +236,7 @@ dependencies = [ [[package]] name = "deps-cargo" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "deps-core", @@ -247,7 +253,7 @@ dependencies = [ [[package]] name = "deps-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "dashmap 6.1.0", @@ -264,7 +270,7 @@ dependencies = [ [[package]] name = "deps-lsp" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "dashmap 6.1.0", @@ -286,7 +292,7 @@ dependencies = [ [[package]] name = "deps-npm" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "deps-core", @@ -300,6 +306,25 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "deps-pypi" +version = "0.2.1" +dependencies = [ + "async-trait", + "deps-core", + "insta", + "mockito", + "pep440_rs", + "pep508_rs", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "toml_edit", + "tower-lsp", + "tracing", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -311,6 +336,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -767,6 +798,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.16" @@ -864,7 +904,7 @@ checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "cfg-if", "miette-derive", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -991,6 +1031,41 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pep440_rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c" +dependencies = [ + "once_cell", + "serde", + "unicode-width 0.2.2", + "unscanny", + "version-ranges", +] + +[[package]] +name = "pep508_rs" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faee7227064121fcadcd2ff788ea26f0d8f2bd23a0574da11eca23bc935bcc05" +dependencies = [ + "boxcar", + "indexmap", + "itertools", + "once_cell", + "pep440_rs", + "regex", + "rustc-hash", + "serde", + "smallvec", + "thiserror 1.0.69", + "unicode-width 0.2.2", + "url", + "urlencoding", + "version-ranges", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1856,6 +1931,18 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + [[package]] name = "untrusted" version = "0.9.0" @@ -1892,6 +1979,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-ranges" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3595ffe225639f1e0fd8d7269dcc05d2fbfea93cfac2fea367daf1adb60aae91" +dependencies = [ + "smallvec", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 5f32d2b2..c9abeb10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,14 @@ dashmap = "6.1" deps-core = { version = "0.2.1", path = "crates/deps-core" } deps-cargo = { version = "0.2.1", path = "crates/deps-cargo" } deps-npm = { version = "0.2.1", path = "crates/deps-npm" } +deps-pypi = { version = "0.2.1", path = "crates/deps-pypi" } deps-lsp = { version = "0.2.1", path = "crates/deps-lsp" } futures = "0.3" insta = "1" mockito = "1" node-semver = "2.2" +pep440_rs = "0.7" +pep508_rs = "0.9" reqwest = { version = "0.12", default-features = false } semver = "1" serde = "1" diff --git a/README.md b/README.md index ebfa8ada..da74b7f0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/deps-lsp)](https://crates.io/crates/deps-lsp) [![docs.rs](https://img.shields.io/docsrs/deps-lsp)](https://docs.rs/deps-lsp) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ)](https://codecov.io/gh/bug-ops/deps-lsp) [![CI](https://img.shields.io/github/actions/workflow/status/bug-ops/deps-lsp/ci.yml?branch=main)](https://github.com/bug-ops/deps-lsp/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![MSRV](https://img.shields.io/badge/MSRV-1.89-blue)](https://blog.rust-lang.org/) diff --git a/crates/deps-cargo/README.md b/crates/deps-cargo/README.md index b16fa8b2..b8124645 100644 --- a/crates/deps-cargo/README.md +++ b/crates/deps-cargo/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/deps-cargo)](https://crates.io/crates/deps-cargo) [![docs.rs](https://img.shields.io/docsrs/deps-cargo)](https://docs.rs/deps-cargo) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ&flag=deps-cargo)](https://codecov.io/gh/bug-ops/deps-lsp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) Cargo.toml support for deps-lsp. diff --git a/crates/deps-core/README.md b/crates/deps-core/README.md index 34722ee2..cf32e80a 100644 --- a/crates/deps-core/README.md +++ b/crates/deps-core/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/deps-core)](https://crates.io/crates/deps-core) [![docs.rs](https://img.shields.io/docsrs/deps-core)](https://docs.rs/deps-core) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ&flag=deps-core)](https://codecov.io/gh/bug-ops/deps-lsp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) Core abstractions for deps-lsp: caching, errors, and traits. diff --git a/crates/deps-lsp/README.md b/crates/deps-lsp/README.md index 007dd4e8..cd33aa08 100644 --- a/crates/deps-lsp/README.md +++ b/crates/deps-lsp/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/deps-lsp)](https://crates.io/crates/deps-lsp) [![docs.rs](https://img.shields.io/docsrs/deps-lsp)](https://docs.rs/deps-lsp) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ&flag=deps-lsp)](https://codecov.io/gh/bug-ops/deps-lsp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) Language Server Protocol implementation for dependency management. diff --git a/crates/deps-npm/README.md b/crates/deps-npm/README.md index 3512fec5..b532db65 100644 --- a/crates/deps-npm/README.md +++ b/crates/deps-npm/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/deps-npm)](https://crates.io/crates/deps-npm) [![docs.rs](https://img.shields.io/docsrs/deps-npm)](https://docs.rs/deps-npm) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ&flag=deps-npm)](https://codecov.io/gh/bug-ops/deps-lsp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) npm/package.json support for deps-lsp. diff --git a/crates/deps-pypi/Cargo.toml b/crates/deps-pypi/Cargo.toml new file mode 100644 index 00000000..6b92a569 --- /dev/null +++ b/crates/deps-pypi/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "deps-pypi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "PyPI/Python support for deps-lsp" + +[dependencies] +deps-core = { workspace = true } +async-trait = { workspace = true } +pep440_rs = { workspace = true } +pep508_rs = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tower-lsp = { workspace = true } +toml_edit = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +insta = { workspace = true, features = ["json"] } +mockito = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/deps-pypi/README.md b/crates/deps-pypi/README.md new file mode 100644 index 00000000..5a634bd3 --- /dev/null +++ b/crates/deps-pypi/README.md @@ -0,0 +1,67 @@ +# deps-pypi + +[![Crates.io](https://img.shields.io/crates/v/deps-pypi)](https://crates.io/crates/deps-pypi) +[![docs.rs](https://img.shields.io/docsrs/deps-pypi)](https://docs.rs/deps-pypi) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ&flag=deps-pypi)](https://codecov.io/gh/bug-ops/deps-lsp) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) + +PyPI/Python support for deps-lsp. + +This crate provides parsing, validation, and registry client functionality for Python dependency management in `pyproject.toml` files, supporting both PEP 621 and Poetry formats. + +## Features + +- **PEP 621 Support**: Parse `[project.dependencies]` and `[project.optional-dependencies]` +- **Poetry Support**: Parse `[tool.poetry.dependencies]` and `[tool.poetry.group.*.dependencies]` +- **PEP 508 Parsing**: Handle complex dependency specifications with extras and markers +- **PEP 440 Versions**: Validate and compare Python version specifiers +- **PyPI API Client**: Fetch package metadata from PyPI JSON API with HTTP caching + +## Usage + +```rust +use deps_pypi::{PypiParser, PypiRegistry}; +use deps_core::PackageRegistry; + +// Parse pyproject.toml +let content = std::fs::read_to_string("pyproject.toml")?; +let parser = PypiParser::new(); +let dependencies = parser.parse(&content)?; + +// Fetch versions from PyPI +let registry = PypiRegistry::new(); +let versions = registry.get_versions("requests").await?; +``` + +## Supported Formats + +### PEP 621 (Standard) + +```toml +[project] +dependencies = [ + "requests>=2.28.0,<3.0", + "flask[async]>=3.0", + "numpy>=1.24; python_version>='3.9'", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0", "mypy>=1.0"] +``` + +### Poetry + +```toml +[tool.poetry.dependencies] +python = "^3.9" +requests = "^2.28.0" +flask = {version = "^3.0", extras = ["async"]} + +[tool.poetry.group.dev.dependencies] +pytest = "^7.0" +mypy = "^1.0" +``` + +## License + +MIT diff --git a/crates/deps-pypi/src/error.rs b/crates/deps-pypi/src/error.rs new file mode 100644 index 00000000..0c492570 --- /dev/null +++ b/crates/deps-pypi/src/error.rs @@ -0,0 +1,143 @@ +use thiserror::Error; + +/// Errors specific to PyPI/Python dependency handling. +/// +/// These errors cover parsing pyproject.toml files, validating PEP 440/508 specifications, +/// and communicating with the PyPI registry. +#[derive(Error, Debug)] +pub enum PypiError { + /// Failed to parse pyproject.toml + #[error("Failed to parse pyproject.toml: {source}")] + TomlParseError { + #[source] + source: toml_edit::TomlError, + }, + + /// Invalid PEP 440 version specifier + #[error("Invalid PEP 440 version specifier '{specifier}': {source}")] + InvalidVersionSpecifier { + specifier: String, + #[source] + source: pep440_rs::VersionSpecifiersParseError, + }, + + /// Invalid PEP 508 dependency specification + #[error("Invalid PEP 508 dependency specification: {source}")] + InvalidDependencySpec { + #[source] + source: pep508_rs::Pep508Error, + }, + + /// Package not found on PyPI + #[error("Package '{package}' not found on PyPI")] + PackageNotFound { package: String }, + + /// PyPI registry request failed + #[error("PyPI registry request failed for '{package}': {source}")] + RegistryError { + package: String, + #[source] + source: Box, + }, + + /// Failed to deserialize PyPI API response + #[error("Failed to parse PyPI API response for '{package}': {source}")] + ApiResponseError { + package: String, + #[source] + source: serde_json::Error, + }, + + /// Unsupported dependency format + #[error("Unsupported dependency format: {message}")] + UnsupportedFormat { message: String }, + + /// Missing required field in pyproject.toml + #[error("Missing required field '{field}' in {section}")] + MissingField { section: String, field: String }, + + /// Cache error + #[error("Cache error: {0}")] + CacheError(String), + + /// Generic error wrapper + #[error(transparent)] + Other(#[from] Box), +} + +/// Result type alias for PyPI operations. +pub type Result = std::result::Result; + +impl PypiError { + /// Create a registry error from any error type. + pub fn registry_error( + package: impl Into, + error: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::RegistryError { + package: package.into(), + source: Box::new(error), + } + } + + /// Create an API response error. + pub fn api_response_error(package: impl Into, error: serde_json::Error) -> Self { + Self::ApiResponseError { + package: package.into(), + source: error, + } + } + + /// Create an unsupported format error. + pub fn unsupported_format(message: impl Into) -> Self { + Self::UnsupportedFormat { + message: message.into(), + } + } + + /// Create a missing field error. + pub fn missing_field(section: impl Into, field: impl Into) -> Self { + Self::MissingField { + section: section.into(), + field: field.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_display() { + let err = PypiError::PackageNotFound { + package: "nonexistent".into(), + }; + assert_eq!(err.to_string(), "Package 'nonexistent' not found on PyPI"); + + let err = PypiError::missing_field("project", "dependencies"); + assert_eq!( + err.to_string(), + "Missing required field 'dependencies' in project" + ); + + let err = PypiError::unsupported_format("invalid table format"); + assert_eq!( + err.to_string(), + "Unsupported dependency format: invalid table format" + ); + } + + #[test] + fn test_error_construction() { + let err = PypiError::registry_error( + "requests", + std::io::Error::from(std::io::ErrorKind::NotFound), + ); + assert!(matches!(err, PypiError::RegistryError { .. })); + + let json_err = serde_json::from_str::("invalid").unwrap_err(); + let err = PypiError::api_response_error("flask", json_err); + assert!(matches!(err, PypiError::ApiResponseError { .. })); + } +} diff --git a/crates/deps-pypi/src/lib.rs b/crates/deps-pypi/src/lib.rs new file mode 100644 index 00000000..0ba87349 --- /dev/null +++ b/crates/deps-pypi/src/lib.rs @@ -0,0 +1,109 @@ +//! PyPI/Python support for deps-lsp. +//! +//! This crate provides parsing, validation, and registry client functionality +//! for Python dependency management in `pyproject.toml` files, supporting both +//! PEP 621 and Poetry formats. +//! +//! # Features +//! +//! - **PEP 621 Support**: Parse `[project.dependencies]` and `[project.optional-dependencies]` +//! - **Poetry Support**: Parse `[tool.poetry.dependencies]` and `[tool.poetry.group.*.dependencies]` +//! - **PEP 508 Parsing**: Handle complex dependency specifications with extras and markers +//! - **PEP 440 Versions**: Validate and compare Python version specifiers +//! - **PyPI API Client**: Fetch package metadata from PyPI JSON API with HTTP caching +//! +//! # Architecture +//! +//! deps-pypi follows the same architecture as deps-cargo and deps-npm: +//! - **Types**: `PypiDependency`, `PypiVersion`, `PypiPackage` with LSP range tracking +//! - **Parser**: Parse both PEP 621 and Poetry formats using `toml_edit` +//! - **Registry**: PyPI JSON API client with HTTP caching +//! - **Error Handling**: Typed errors with `thiserror` +//! +//! # Examples +//! +//! ## Parsing pyproject.toml +//! +//! ```no_run +//! use deps_pypi::PypiParser; +//! +//! let content = r#" +//! [project] +//! dependencies = [ +//! "requests>=2.28.0,<3.0", +//! "flask[async]>=3.0", +//! ] +//! "#; +//! +//! let parser = PypiParser::new(); +//! let result = parser.parse_content(content).unwrap(); +//! +//! assert_eq!(result.dependencies.len(), 2); +//! assert_eq!(result.dependencies[0].name, "requests"); +//! assert_eq!(result.dependencies[1].extras, vec!["async"]); +//! ``` +//! +//! ## Fetching versions from PyPI +//! +//! ```no_run +//! use deps_pypi::PypiRegistry; +//! use deps_core::HttpCache; +//! use std::sync::Arc; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let cache = Arc::new(HttpCache::new()); +//! let registry = PypiRegistry::new(cache); +//! +//! let versions = registry.get_versions("requests").await.unwrap(); +//! assert!(!versions.is_empty()); +//! +//! let latest = registry +//! .get_latest_matching("requests", ">=2.28.0,<3.0") +//! .await +//! .unwrap(); +//! assert!(latest.is_some()); +//! # } +//! ``` +//! +//! ## Supported Formats +//! +//! ### PEP 621 (Standard) +//! +//! ```toml +//! [project] +//! dependencies = [ +//! "requests>=2.28.0,<3.0", +//! "flask[async]>=3.0", +//! "numpy>=1.24; python_version>='3.9'", +//! ] +//! +//! [project.optional-dependencies] +//! dev = ["pytest>=7.0", "mypy>=1.0"] +//! ``` +//! +//! ### Poetry +//! +//! ```toml +//! [tool.poetry.dependencies] +//! python = "^3.9" +//! requests = "^2.28.0" +//! flask = {version = "^3.0", extras = ["async"]} +//! +//! [tool.poetry.group.dev.dependencies] +//! pytest = "^7.0" +//! mypy = "^1.0" +//! ``` + +pub mod error; +pub mod parser; +pub mod registry; +pub mod types; + +// Re-export commonly used types +pub use error::{PypiError, Result}; +pub use parser::PypiParser; +pub use registry::PypiRegistry; +pub use types::{ + PypiDependency, PypiDependencySection, PypiDependencySource, PypiPackage, PypiVersion, +}; diff --git a/crates/deps-pypi/src/parser.rs b/crates/deps-pypi/src/parser.rs new file mode 100644 index 00000000..7d5a8bee --- /dev/null +++ b/crates/deps-pypi/src/parser.rs @@ -0,0 +1,717 @@ +use crate::error::{PypiError, Result}; +use crate::types::{PypiDependency, PypiDependencySection, PypiDependencySource}; +use pep508_rs::{Requirement, VersionOrUrl}; +use std::str::FromStr; +use toml_edit::{DocumentMut, Item, Table}; +use tower_lsp::lsp_types::{Position, Range, Url}; + +/// Parse result containing all dependencies from pyproject.toml. +/// +/// Stores dependencies and optional workspace information for LSP operations. +#[derive(Debug, Clone)] +pub struct ParseResult { + /// All dependencies found in the manifest + pub dependencies: Vec, + /// Workspace root path (None for Python - no workspace concept like Cargo) + pub workspace_root: Option, +} + +/// Parser for Python pyproject.toml files. +/// +/// Supports both PEP 621 standard format and Poetry format. +/// Uses `toml_edit` to preserve source positions for LSP operations. +/// +/// # Examples +/// +/// ```no_run +/// use deps_pypi::parser::PypiParser; +/// +/// let content = r#" +/// [project] +/// dependencies = ["requests>=2.28.0", "flask[async]>=3.0"] +/// "#; +/// +/// let parser = PypiParser::new(); +/// let result = parser.parse_content(content).unwrap(); +/// assert_eq!(result.dependencies.len(), 2); +/// ``` +pub struct PypiParser; + +impl PypiParser { + /// Create a new PyPI parser. + pub fn new() -> Self { + Self + } + + /// Parse pyproject.toml content and extract all dependencies. + /// + /// Parses both PEP 621 and Poetry formats in a single pass. + /// + /// # Errors + /// + /// Returns an error if: + /// - TOML is malformed + /// - PEP 508 dependency specifications are invalid + /// + /// # Examples + /// + /// ```no_run + /// # use deps_pypi::parser::PypiParser; + /// let parser = PypiParser::new(); + /// let content = std::fs::read_to_string("pyproject.toml").unwrap(); + /// let result = parser.parse_content(&content).unwrap(); + /// ``` + pub fn parse_content(&self, content: &str) -> Result { + let doc = content + .parse::() + .map_err(|e| PypiError::TomlParseError { source: e })?; + + let mut dependencies = Vec::new(); + + // Parse PEP 621 format + if let Some(project) = doc.get("project").and_then(|i| i.as_table()) { + dependencies.extend(self.parse_pep621_dependencies(project, content)?); + dependencies.extend(self.parse_pep621_optional_dependencies(project, content)?); + } + + // Parse Poetry format + if let Some(tool) = doc.get("tool").and_then(|i| i.as_table()) + && let Some(poetry) = tool.get("poetry").and_then(|i| i.as_table()) + { + dependencies.extend(self.parse_poetry_dependencies(poetry, content)?); + dependencies.extend(self.parse_poetry_groups(poetry, content)?); + } + + Ok(ParseResult { + dependencies, + workspace_root: None, + }) + } + + /// Parse PEP 621 `[project.dependencies]` array. + fn parse_pep621_dependencies( + &self, + project: &Table, + content: &str, + ) -> Result> { + let Some(deps_item) = project.get("dependencies") else { + return Ok(Vec::new()); + }; + + let Some(deps_array) = deps_item.as_array() else { + return Ok(Vec::new()); + }; + + let mut dependencies = Vec::new(); + + for (idx, value) in deps_array.iter().enumerate() { + if let Some(dep_str) = value.as_str() { + let position = + self.find_array_element_position(content, "project.dependencies", idx); + + match self.parse_pep508_requirement(dep_str, position) { + Ok(mut dep) => { + dep.section = PypiDependencySection::Dependencies; + dependencies.push(dep); + } + Err(e) => { + tracing::warn!("Failed to parse dependency '{}': {}", dep_str, e); + } + } + } + } + + Ok(dependencies) + } + + /// Parse PEP 621 `[project.optional-dependencies]` tables. + fn parse_pep621_optional_dependencies( + &self, + project: &Table, + content: &str, + ) -> Result> { + let Some(opt_deps_item) = project.get("optional-dependencies") else { + return Ok(Vec::new()); + }; + + let Some(opt_deps_table) = opt_deps_item.as_table() else { + return Ok(Vec::new()); + }; + + let mut dependencies = Vec::new(); + + for (group_name, group_item) in opt_deps_table.iter() { + if let Some(group_array) = group_item.as_array() { + for (idx, value) in group_array.iter().enumerate() { + if let Some(dep_str) = value.as_str() { + let section_name = format!("project.optional-dependencies.{}", group_name); + let position = + self.find_array_element_position(content, §ion_name, idx); + + match self.parse_pep508_requirement(dep_str, position) { + Ok(mut dep) => { + dep.section = PypiDependencySection::OptionalDependencies { + group: group_name.to_string(), + }; + dependencies.push(dep); + } + Err(e) => { + tracing::warn!("Failed to parse dependency '{}': {}", dep_str, e); + } + } + } + } + } + } + + Ok(dependencies) + } + + /// Parse Poetry `[tool.poetry.dependencies]` table. + fn parse_poetry_dependencies( + &self, + poetry: &Table, + content: &str, + ) -> Result> { + let Some(deps_item) = poetry.get("dependencies") else { + return Ok(Vec::new()); + }; + + let Some(deps_table) = deps_item.as_table() else { + return Ok(Vec::new()); + }; + + let mut dependencies = Vec::new(); + + for (name, value) in deps_table.iter() { + // Skip Python version constraint + if name == "python" { + continue; + } + + let position = self.find_table_key_position(content, "tool.poetry.dependencies", name); + + match self.parse_poetry_dependency(name, value, position) { + Ok(mut dep) => { + dep.section = PypiDependencySection::PoetryDependencies; + dependencies.push(dep); + } + Err(e) => { + tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e); + } + } + } + + Ok(dependencies) + } + + /// Parse Poetry `[tool.poetry.group.*.dependencies]` tables. + fn parse_poetry_groups(&self, poetry: &Table, content: &str) -> Result> { + let Some(group_item) = poetry.get("group") else { + return Ok(Vec::new()); + }; + + let Some(groups_table) = group_item.as_table() else { + return Ok(Vec::new()); + }; + + let mut dependencies = Vec::new(); + + for (group_name, group_item) in groups_table.iter() { + if let Some(group_table) = group_item.as_table() + && let Some(deps_item) = group_table.get("dependencies") + && let Some(deps_table) = deps_item.as_table() + { + for (name, value) in deps_table.iter() { + let section_path = format!("tool.poetry.group.{}.dependencies", group_name); + let position = self.find_table_key_position(content, §ion_path, name); + + match self.parse_poetry_dependency(name, value, position) { + Ok(mut dep) => { + dep.section = PypiDependencySection::PoetryGroup { + group: group_name.to_string(), + }; + dependencies.push(dep); + } + Err(e) => { + tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e); + } + } + } + } + } + + Ok(dependencies) + } + + /// Parse a PEP 508 requirement string. + /// + /// Example: `requests[security,socks]>=2.28.0,<3.0; python_version>='3.8'` + fn parse_pep508_requirement( + &self, + requirement_str: &str, + base_position: Option, + ) -> Result { + let requirement = Requirement::from_str(requirement_str) + .map_err(|e| PypiError::InvalidDependencySpec { source: e })?; + + let name = requirement.name.to_string(); + let name_range = base_position + .map(|pos| { + Range::new( + pos, + Position::new(pos.line, pos.character + name.len() as u32), + ) + }) + .unwrap_or_default(); + + let (version_req, version_range, source) = match requirement.version_or_url { + Some(VersionOrUrl::VersionSpecifier(specs)) => { + let version_str = specs.to_string(); + let start_offset = name.len() + requirement.extras.len(); + let version_range = base_position.map(|pos| { + Range::new( + Position::new(pos.line, pos.character + start_offset as u32), + Position::new( + pos.line, + pos.character + start_offset as u32 + version_str.len() as u32, + ), + ) + }); + (Some(version_str), version_range, PypiDependencySource::PyPI) + } + Some(VersionOrUrl::Url(url)) => { + let url_str = url.to_string(); + if url_str.starts_with("git+") { + ( + None, + None, + PypiDependencySource::Git { + url: url_str.clone(), + rev: None, + }, + ) + } else if url_str.ends_with(".whl") || url_str.ends_with(".tar.gz") { + (None, None, PypiDependencySource::Url { url: url_str }) + } else { + (None, None, PypiDependencySource::PyPI) + } + } + None => (None, None, PypiDependencySource::PyPI), + }; + + let extras: Vec = requirement + .extras + .into_iter() + .map(|e| e.to_string()) + .collect(); + // For now, skip markers - we'll implement proper MarkerTree serialization later + // TODO: Implement proper marker serialization + let markers = None; + + Ok(PypiDependency { + name, + name_range, + version_req, + version_range, + extras, + extras_range: None, + markers, + markers_range: None, + section: PypiDependencySection::Dependencies, + source, + }) + } + + /// Parse a Poetry dependency (can be string or table). + /// + /// Examples: + /// - String: `requests = "^2.28.0"` + /// - Table: `flask = { version = "^3.0", extras = ["async"] }` + fn parse_poetry_dependency( + &self, + name: &str, + value: &Item, + base_position: Option, + ) -> Result { + let name_range = base_position + .map(|pos| { + Range::new( + pos, + Position::new(pos.line, pos.character + name.len() as u32), + ) + }) + .unwrap_or_default(); + + // Simple string version + if let Some(version_str) = value.as_str() { + let version_range = base_position.map(|pos| { + Range::new( + Position::new(pos.line, pos.character + name.len() as u32 + 3), + Position::new( + pos.line, + pos.character + name.len() as u32 + 3 + version_str.len() as u32, + ), + ) + }); + + return Ok(PypiDependency { + name: name.to_string(), + name_range, + version_req: Some(version_str.to_string()), + version_range, + extras: Vec::new(), + extras_range: None, + markers: None, + markers_range: None, + section: PypiDependencySection::PoetryDependencies, + source: PypiDependencySource::PyPI, + }); + } + + // Table format + if let Some(table) = value.as_table() { + let version_req = table + .get("version") + .and_then(|v| v.as_str()) + .map(String::from); + let extras = table + .get("extras") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let markers = table + .get("markers") + .and_then(|m| m.as_str()) + .map(String::from); + + let source = if table.contains_key("git") { + PypiDependencySource::Git { + url: table + .get("git") + .and_then(|g| g.as_str()) + .unwrap_or("") + .to_string(), + rev: table.get("rev").and_then(|r| r.as_str()).map(String::from), + } + } else if table.contains_key("path") { + PypiDependencySource::Path { + path: table + .get("path") + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(), + } + } else if table.contains_key("url") { + PypiDependencySource::Url { + url: table + .get("url") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(), + } + } else { + PypiDependencySource::PyPI + }; + + return Ok(PypiDependency { + name: name.to_string(), + name_range, + version_req, + version_range: None, + extras, + extras_range: None, + markers, + markers_range: None, + section: PypiDependencySection::PoetryDependencies, + source, + }); + } + + Err(PypiError::unsupported_format(format!( + "Unsupported Poetry dependency format for '{}'", + name + ))) + } + + /// Find position of array element in source content. + fn find_array_element_position( + &self, + _content: &str, + _section: &str, + _index: usize, + ) -> Option { + // TODO: Implement actual position tracking using toml_edit spans + // For now, return None - positions will be default + None + } + + /// Find position of table key in source content. + fn find_table_key_position( + &self, + _content: &str, + _section: &str, + _key: &str, + ) -> Option { + // TODO: Implement actual position tracking using toml_edit spans + // For now, return None - positions will be default + None + } +} + +impl Default for PypiParser { + fn default() -> Self { + Self::new() + } +} + +// Implement deps_core traits for interoperability with LSP server + +impl deps_core::ManifestParser for PypiParser { + type Dependency = PypiDependency; + type ParseResult = ParseResult; + + fn parse(&self, content: &str, _doc_uri: &Url) -> deps_core::error::Result { + self.parse_content(content) + .map_err(|e| deps_core::error::DepsError::ParseError { + file_type: "pyproject.toml".to_string(), + source: Box::new(e), + }) + } +} + +impl deps_core::DependencyInfo for PypiDependency { + fn name(&self) -> &str { + &self.name + } + + fn name_range(&self) -> Range { + self.name_range + } + + fn version_requirement(&self) -> Option<&str> { + self.version_req.as_deref() + } + + fn version_range(&self) -> Option { + self.version_range + } + + fn source(&self) -> deps_core::DependencySource { + match &self.source { + PypiDependencySource::PyPI => deps_core::DependencySource::Registry, + PypiDependencySource::Git { url, rev } => deps_core::DependencySource::Git { + url: url.clone(), + rev: rev.clone(), + }, + PypiDependencySource::Path { path } => { + deps_core::DependencySource::Path { path: path.clone() } + } + // URL dependencies are treated as Registry since they're still remote packages + PypiDependencySource::Url { .. } => deps_core::DependencySource::Registry, + } + } + + fn features(&self) -> &[String] { + &self.extras + } +} + +impl deps_core::ParseResultInfo for ParseResult { + type Dependency = PypiDependency; + + fn dependencies(&self) -> &[Self::Dependency] { + &self.dependencies + } + + fn workspace_root(&self) -> Option<&std::path::Path> { + self.workspace_root.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pep621_dependencies() { + let content = r#" +[project] +dependencies = [ + "requests>=2.28.0", + "flask[async]>=3.0", +] +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + assert_eq!(deps.len(), 2); + assert_eq!(deps[0].name, "requests"); + assert_eq!(deps[0].version_req, Some(">=2.28.0".to_string())); + assert!(matches!( + deps[0].section, + PypiDependencySection::Dependencies + )); + + assert_eq!(deps[1].name, "flask"); + assert_eq!(deps[1].extras, vec!["async"]); + } + + #[test] + fn test_parse_pep621_optional_dependencies() { + let content = r#" +[project.optional-dependencies] +dev = ["pytest>=7.0", "mypy>=1.0"] +docs = ["sphinx>=5.0"] +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + assert_eq!(deps.len(), 3); + + let dev_deps: Vec<_> = deps.iter().filter(|d| { + matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "dev") + }).collect(); + assert_eq!(dev_deps.len(), 2); + + let docs_deps: Vec<_> = deps.iter().filter(|d| { + matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "docs") + }).collect(); + assert_eq!(docs_deps.len(), 1); + } + + #[test] + fn test_parse_poetry_dependencies() { + let content = r#" +[tool.poetry.dependencies] +python = "^3.9" +requests = "^2.28.0" +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + // Should skip "python" + assert_eq!(deps.len(), 1); + assert_eq!(deps[0].name, "requests"); + assert!(matches!( + deps[0].section, + PypiDependencySection::PoetryDependencies + )); + } + + #[test] + fn test_parse_poetry_groups() { + let content = r#" +[tool.poetry.group.dev.dependencies] +pytest = "^7.0" +mypy = "^1.0" + +[tool.poetry.group.docs.dependencies] +sphinx = "^5.0" +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + assert_eq!(deps.len(), 3); + + let dev_deps: Vec<_> = deps.iter().filter(|d| { + matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "dev") + }).collect(); + assert_eq!(dev_deps.len(), 2); + + let docs_deps: Vec<_> = deps.iter().filter(|d| { + matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "docs") + }).collect(); + assert_eq!(docs_deps.len(), 1); + } + + #[test] + fn test_parse_pep508_with_markers() { + let content = r#" +[project] +dependencies = [ + "numpy>=1.24; python_version>='3.9'", +] +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + assert_eq!(deps.len(), 1); + assert_eq!(deps[0].name, "numpy"); + // TODO: Implement proper marker serialization from MarkerTree + // assert_eq!(deps[0].markers, Some("python_version >= '3.9'".to_string())); + assert_eq!(deps[0].markers, None); + } + + #[test] + fn test_parse_mixed_formats() { + let content = r#" +[project] +dependencies = ["requests>=2.28.0"] + +[tool.poetry.dependencies] +python = "^3.9" +flask = "^3.0" +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + assert_eq!(deps.len(), 2); + + let pep621_deps: Vec<_> = deps + .iter() + .filter(|d| matches!(d.section, PypiDependencySection::Dependencies)) + .collect(); + assert_eq!(pep621_deps.len(), 1); + + let poetry_deps: Vec<_> = deps + .iter() + .filter(|d| matches!(d.section, PypiDependencySection::PoetryDependencies)) + .collect(); + assert_eq!(poetry_deps.len(), 1); + } + + #[test] + fn test_parse_invalid_toml() { + let content = "invalid toml {{{"; + let parser = PypiParser::new(); + let result = parser.parse_content(content); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + PypiError::TomlParseError { .. } + )); + } + + #[test] + fn test_parse_empty_dependencies() { + let content = r#" +[project] +name = "test" +"#; + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let deps = &result.dependencies; + + assert_eq!(deps.len(), 0); + } +} diff --git a/crates/deps-pypi/src/registry.rs b/crates/deps-pypi/src/registry.rs new file mode 100644 index 00000000..bccad451 --- /dev/null +++ b/crates/deps-pypi/src/registry.rs @@ -0,0 +1,425 @@ +//! PyPI registry client. +//! +//! Provides access to the PyPI registry via: +//! - Package metadata API () for version lookups +//! - Simple API () for version index (future) +//! +//! All HTTP requests are cached aggressively using ETag/Last-Modified headers. + +use crate::error::{PypiError, Result}; +use crate::types::{PypiPackage, PypiVersion}; +use async_trait::async_trait; +use deps_core::{HttpCache, PackageRegistry}; +use pep440_rs::{Version, VersionSpecifiers}; +use serde::Deserialize; +use std::str::FromStr; +use std::sync::Arc; + +const PYPI_BASE: &str = "https://pypi.org/pypi"; + +/// Base URL for package pages on pypi.org +pub const PYPI_URL: &str = "https://pypi.org/project"; + +/// Normalize package name according to PEP 503. +/// +/// Converts package name to lowercase and replaces underscores/dots with hyphens, +/// then filters out consecutive hyphens. This ensures consistent package lookups +/// regardless of how the package name is written. +/// +/// # Examples +/// +/// ``` +/// # use deps_pypi::registry::normalize_package_name; +/// assert_eq!(normalize_package_name("Flask"), "flask"); +/// assert_eq!(normalize_package_name("django_rest_framework"), "django-rest-framework"); +/// assert_eq!(normalize_package_name("Pillow.Image"), "pillow-image"); +/// assert_eq!(normalize_package_name("my__package"), "my-package"); +/// ``` +pub fn normalize_package_name(name: &str) -> String { + name.to_lowercase() + .replace(&['_', '.'][..], "-") + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Returns the URL for a package's page on pypi.org. +pub fn package_url(name: &str) -> String { + format!("{}/{}", PYPI_URL, normalize_package_name(name)) +} + +/// Client for interacting with the PyPI registry. +/// +/// Uses the PyPI JSON API for package metadata. +/// All requests are cached via the provided HttpCache. +/// +/// # Examples +/// +/// ```no_run +/// # use deps_pypi::PypiRegistry; +/// # use deps_core::HttpCache; +/// # use std::sync::Arc; +/// # #[tokio::main] +/// # async fn main() { +/// let cache = Arc::new(HttpCache::new()); +/// let registry = PypiRegistry::new(cache); +/// +/// let versions = registry.get_versions("requests").await.unwrap(); +/// assert!(!versions.is_empty()); +/// # } +/// ``` +#[derive(Clone)] +pub struct PypiRegistry { + cache: Arc, +} + +impl PypiRegistry { + /// Creates a new PyPI registry client with the given HTTP cache. + pub fn new(cache: Arc) -> Self { + Self { cache } + } + + /// Fetches all versions for a package from PyPI. + /// + /// Returns versions sorted newest-first. Filters out yanked versions by default. + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - Response body is invalid UTF-8 + /// - JSON parsing fails + /// - Package does not exist + /// + /// # Examples + /// + /// ```no_run + /// # use deps_pypi::PypiRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = PypiRegistry::new(cache); + /// + /// let versions = registry.get_versions("flask").await.unwrap(); + /// assert!(!versions.is_empty()); + /// # } + /// ``` + pub async fn get_versions(&self, name: &str) -> Result> { + let normalized = normalize_package_name(name); + let url = format!("{}/{}/json", PYPI_BASE, normalized); + let data = self.cache.get_cached(&url).await.map_err(|e| { + if e.to_string().contains("404") { + PypiError::PackageNotFound { + package: name.to_string(), + } + } else { + PypiError::registry_error(name, e) + } + })?; + + parse_package_metadata(name, &data) + } + + /// Finds the latest version matching the given PEP 440 version specifier. + /// + /// Only returns non-yanked, non-prerelease versions by default. + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - Package does not exist + /// - Version specifier is invalid + /// + /// # Examples + /// + /// ```no_run + /// # use deps_pypi::PypiRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = PypiRegistry::new(cache); + /// + /// let latest = registry.get_latest_matching("flask", ">=3.0,<4.0").await.unwrap(); + /// assert!(latest.is_some()); + /// # } + /// ``` + pub async fn get_latest_matching( + &self, + name: &str, + req_str: &str, + ) -> Result> { + let versions = self.get_versions(name).await?; + + // Parse PEP 440 version specifiers + let specs = VersionSpecifiers::from_str(req_str).map_err(|e| { + PypiError::InvalidVersionSpecifier { + specifier: req_str.to_string(), + source: e, + } + })?; + + Ok(versions.into_iter().find(|v| { + if let Ok(version) = Version::from_str(&v.version) { + specs.contains(&version) && !v.yanked && !v.is_prerelease() + } else { + false + } + })) + } + + /// Searches for packages by name/keywords. + /// + /// Note: PyPI does not provide an official search API, so this returns + /// an empty result for now. Future implementation could use third-party + /// search services or scraping. + /// + /// # Errors + /// + /// Currently always returns Ok with empty vector. + /// + /// # Examples + /// + /// ```no_run + /// # use deps_pypi::PypiRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = PypiRegistry::new(cache); + /// + /// let results = registry.search("flask", 10).await.unwrap(); + /// // Currently returns empty, to be implemented + /// # } + /// ``` + pub async fn search(&self, _query: &str, _limit: usize) -> Result> { + // TODO: Implement search using third-party API or scraping + // PyPI deprecated their XML-RPC search API + Ok(Vec::new()) + } + + /// Fetches package metadata including description and project URLs. + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - Package does not exist + /// - JSON parsing fails + pub async fn get_package_metadata(&self, name: &str) -> Result { + let normalized = normalize_package_name(name); + let url = format!("{}/{}/json", PYPI_BASE, normalized); + let data = self.cache.get_cached(&url).await.map_err(|e| { + if e.to_string().contains("404") { + PypiError::PackageNotFound { + package: name.to_string(), + } + } else { + PypiError::registry_error(name, e) + } + })?; + + parse_package_info(name, &data) + } +} + +#[async_trait] +impl PackageRegistry for PypiRegistry { + type Version = PypiVersion; + type Metadata = PypiPackage; + type VersionReq = String; + + async fn get_versions(&self, name: &str) -> deps_core::error::Result> { + PypiRegistry::get_versions(self, name) + .await + .map_err(|e| deps_core::error::DepsError::CacheError(e.to_string())) + } + + async fn get_latest_matching( + &self, + name: &str, + req: &Self::VersionReq, + ) -> deps_core::error::Result> { + PypiRegistry::get_latest_matching(self, name, req) + .await + .map_err(|e| deps_core::error::DepsError::CacheError(e.to_string())) + } + + async fn search( + &self, + query: &str, + limit: usize, + ) -> deps_core::error::Result> { + PypiRegistry::search(self, query, limit) + .await + .map_err(|e| deps_core::error::DepsError::CacheError(e.to_string())) + } +} + +// JSON response types + +#[derive(Debug, Deserialize)] +struct PypiResponse { + info: PypiInfo, + releases: std::collections::HashMap>, +} + +#[derive(Debug, Deserialize)] +struct PypiInfo { + name: String, + summary: Option, + project_urls: Option>, + version: String, +} + +#[derive(Debug, Deserialize)] +struct PypiRelease { + yanked: Option, +} + +/// Parse package metadata from PyPI JSON response. +fn parse_package_metadata(package_name: &str, data: &[u8]) -> Result> { + let response: PypiResponse = + serde_json::from_slice(data).map_err(|e| PypiError::api_response_error(package_name, e))?; + + // Parse versions once and cache with the parsed Version for sorting + let mut versions_with_parsed: Vec<(PypiVersion, Version)> = response + .releases + .into_iter() + .filter_map(|(version_str, releases)| { + // Check if any release file is yanked + let yanked = releases.iter().any(|r| r.yanked.unwrap_or(false)); + + // Parse version to validate it's a valid PEP 440 version + Version::from_str(&version_str).ok().map(|parsed| { + ( + PypiVersion { + version: version_str, + yanked, + }, + parsed, + ) + }) + }) + .collect(); + + // Sort by version (newest first) using pre-parsed versions + versions_with_parsed.sort_by(|a, b| b.1.cmp(&a.1)); + + // Extract sorted versions, discarding parsed data + let versions: Vec = versions_with_parsed.into_iter().map(|(v, _)| v).collect(); + + Ok(versions) +} + +/// Parse package info from PyPI JSON response. +fn parse_package_info(package_name: &str, data: &[u8]) -> Result { + let response: PypiResponse = + serde_json::from_slice(data).map_err(|e| PypiError::api_response_error(package_name, e))?; + + let project_urls = response + .info + .project_urls + .unwrap_or_default() + .into_iter() + .collect(); + + Ok(PypiPackage { + name: response.info.name, + summary: response.info.summary, + project_urls, + latest_version: response.info.version, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_package_url() { + assert_eq!(package_url("requests"), "https://pypi.org/project/requests"); + assert_eq!(package_url("flask"), "https://pypi.org/project/flask"); + } + + #[test] + fn test_parse_package_metadata() { + let json = r#"{ + "info": { + "name": "requests", + "summary": "Python HTTP for Humans.", + "version": "2.28.2", + "project_urls": { + "Homepage": "https://requests.readthedocs.io" + } + }, + "releases": { + "2.28.2": [{"yanked": false}], + "2.28.1": [{"yanked": false}], + "2.28.0": [{"yanked": true}], + "2.27.0": [{"yanked": false}] + } + }"#; + + let versions = parse_package_metadata("requests", json.as_bytes()).unwrap(); + + assert_eq!(versions.len(), 4); + assert_eq!(versions[0].version, "2.28.2"); + assert!(!versions[0].yanked); + assert!(versions[2].yanked); // 2.28.0 is yanked + } + + #[test] + fn test_parse_package_info() { + let json = r#"{ + "info": { + "name": "flask", + "summary": "A micro web framework", + "version": "3.0.0", + "project_urls": { + "Documentation": "https://flask.palletsprojects.com/", + "Repository": "https://github.com/pallets/flask" + } + }, + "releases": {} + }"#; + + let pkg = parse_package_info("flask", json.as_bytes()).unwrap(); + + assert_eq!(pkg.name, "flask"); + assert_eq!(pkg.summary, Some("A micro web framework".to_string())); + assert_eq!(pkg.latest_version, "3.0.0"); + assert_eq!(pkg.project_urls.len(), 2); + } + + #[test] + fn test_prerelease_detection() { + let json = r#"{ + "info": { + "name": "test", + "version": "1.0.0", + "project_urls": null + }, + "releases": { + "1.0.0": [{"yanked": false}], + "1.0.0a1": [{"yanked": false}], + "1.0.0b2": [{"yanked": false}], + "1.0.0rc1": [{"yanked": false}] + } + }"#; + + let versions = parse_package_metadata("test", json.as_bytes()).unwrap(); + + let stable: Vec<_> = versions.iter().filter(|v| !v.is_prerelease()).collect(); + let prerelease: Vec<_> = versions.iter().filter(|v| v.is_prerelease()).collect(); + + assert_eq!(stable.len(), 1); + assert_eq!(prerelease.len(), 3); + } +} diff --git a/crates/deps-pypi/src/types.rs b/crates/deps-pypi/src/types.rs new file mode 100644 index 00000000..fe4e3df1 --- /dev/null +++ b/crates/deps-pypi/src/types.rs @@ -0,0 +1,439 @@ +use tower_lsp::lsp_types::Range; + +/// Parsed dependency from pyproject.toml with position tracking. +/// +/// Stores all information about a Python dependency declaration, including its name, +/// version requirement, extras, environment markers, and source positions for LSP operations. +/// Positions are critical for features like hover, completion, and inlay hints. +/// +/// # Examples +/// +/// ``` +/// use deps_pypi::types::{PypiDependency, PypiDependencySection, PypiDependencySource}; +/// use tower_lsp::lsp_types::{Position, Range}; +/// +/// let dep = PypiDependency { +/// name: "requests".into(), +/// name_range: Range::new(Position::new(5, 4), Position::new(5, 12)), +/// version_req: Some(">=2.28.0,<3.0".into()), +/// version_range: Some(Range::new(Position::new(5, 13), Position::new(5, 27))), +/// extras: vec!["security".into()], +/// extras_range: None, +/// markers: Some("python_version>='3.8'".into()), +/// markers_range: None, +/// section: PypiDependencySection::Dependencies, +/// source: PypiDependencySource::PyPI, +/// }; +/// +/// assert_eq!(dep.name, "requests"); +/// assert!(matches!(dep.section, PypiDependencySection::Dependencies)); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct PypiDependency { + /// Package name (normalized to lowercase with underscores replaced by hyphens) + pub name: String, + /// LSP range of the package name + pub name_range: Range, + /// PEP 440 version specifier (e.g., ">=2.28.0,<3.0") + pub version_req: Option, + /// LSP range of the version specifier + pub version_range: Option, + /// PEP 508 extras (e.g., ["security", "socks"]) + pub extras: Vec, + /// LSP range of the extras specification + pub extras_range: Option, + /// PEP 508 environment markers (e.g., "python_version>='3.8'") + pub markers: Option, + /// LSP range of the markers specification + pub markers_range: Option, + /// Section where this dependency is declared + pub section: PypiDependencySection, + /// Source of the dependency (PyPI, Git, Path, URL) + pub source: PypiDependencySource, +} + +/// Section in pyproject.toml where a dependency is declared. +/// +/// Python projects use different sections for different types of dependencies: +/// - `[project.dependencies]`: Runtime dependencies (PEP 621) +/// - `[project.optional-dependencies.*]`: Optional dependency groups (PEP 621) +/// - `[tool.poetry.dependencies]`: Runtime dependencies (Poetry) +/// - `[tool.poetry.group.*.dependencies]`: Dependency groups (Poetry) +/// +/// # Examples +/// +/// ``` +/// use deps_pypi::types::PypiDependencySection; +/// +/// let section = PypiDependencySection::Dependencies; +/// assert!(matches!(section, PypiDependencySection::Dependencies)); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub enum PypiDependencySection { + /// PEP 621 runtime dependencies (`[project.dependencies]`) + Dependencies, + /// PEP 621 optional dependency group (`[project.optional-dependencies.{group}]`) + OptionalDependencies { group: String }, + /// Poetry runtime dependencies (`[tool.poetry.dependencies]`) + PoetryDependencies, + /// Poetry dependency group (`[tool.poetry.group.{group}.dependencies]`) + PoetryGroup { group: String }, +} + +/// Source location of a Python dependency. +/// +/// Python dependencies can come from PyPI, Git repositories, local paths, or direct URLs. +/// This affects how the LSP server resolves version information and provides completions. +/// +/// # Examples +/// +/// ``` +/// use deps_pypi::types::PypiDependencySource; +/// +/// let pypi = PypiDependencySource::PyPI; +/// let git = PypiDependencySource::Git { +/// url: "https://github.com/psf/requests.git".into(), +/// rev: Some("v2.28.0".into()), +/// }; +/// let path = PypiDependencySource::Path { +/// path: "../local-package".into(), +/// }; +/// let url = PypiDependencySource::Url { +/// url: "https://example.com/package.whl".into(), +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub enum PypiDependencySource { + /// Dependency from PyPI registry + PyPI, + /// Dependency from Git repository + Git { url: String, rev: Option }, + /// Dependency from local filesystem path + Path { path: String }, + /// Dependency from direct URL (wheel or source archive) + Url { url: String }, +} + +/// Version information for a package from PyPI. +/// +/// Retrieved from the PyPI JSON API at `https://pypi.org/pypi/{package}/json`. +/// Contains version number, yanked status, and prerelease detection. +/// +/// # Examples +/// +/// ``` +/// use deps_pypi::types::PypiVersion; +/// +/// let version = PypiVersion { +/// version: "2.28.2".into(), +/// yanked: false, +/// }; +/// +/// assert!(!version.yanked); +/// assert!(!version.is_prerelease()); +/// ``` +#[derive(Debug, Clone)] +pub struct PypiVersion { + /// Version string (PEP 440 compliant) + pub version: String, + /// Whether this version has been yanked from PyPI + pub yanked: bool, +} + +impl PypiVersion { + /// Check if this version is a prerelease (alpha, beta, rc). + /// + /// Uses PEP 440 version parsing for accurate prerelease detection. + /// + /// # Examples + /// + /// ``` + /// use deps_pypi::types::PypiVersion; + /// + /// let stable = PypiVersion { version: "1.0.0".into(), yanked: false }; + /// let alpha = PypiVersion { version: "1.0.0a1".into(), yanked: false }; + /// let beta = PypiVersion { version: "1.0.0b2".into(), yanked: false }; + /// let rc = PypiVersion { version: "1.0.0rc1".into(), yanked: false }; + /// + /// assert!(!stable.is_prerelease()); + /// assert!(alpha.is_prerelease()); + /// assert!(beta.is_prerelease()); + /// assert!(rc.is_prerelease()); + /// ``` + pub fn is_prerelease(&self) -> bool { + use pep440_rs::Version; + use std::str::FromStr; + + Version::from_str(&self.version) + .map(|v| v.is_pre()) + .unwrap_or(false) + } +} + +/// Package metadata from PyPI. +/// +/// Contains basic information about a PyPI package for display in completion +/// suggestions. Retrieved from `https://pypi.org/pypi/{package}/json`. +/// +/// # Examples +/// +/// ``` +/// use deps_pypi::types::PypiPackage; +/// +/// let pkg = PypiPackage { +/// name: "requests".into(), +/// summary: Some("Python HTTP for Humans.".into()), +/// project_urls: vec![ +/// ("Homepage".into(), "https://requests.readthedocs.io".into()), +/// ("Repository".into(), "https://github.com/psf/requests".into()), +/// ], +/// latest_version: "2.28.2".into(), +/// }; +/// +/// assert_eq!(pkg.name, "requests"); +/// ``` +#[derive(Debug, Clone)] +pub struct PypiPackage { + /// Package name (canonical form) + pub name: String, + /// Short package summary/description + pub summary: Option, + /// Project URLs (homepage, repository, documentation, etc.) + pub project_urls: Vec<(String, String)>, + /// Latest stable version + pub latest_version: String, +} + +// Implement deps_core traits + +impl deps_core::VersionInfo for PypiVersion { + fn version_string(&self) -> &str { + &self.version + } + + fn is_yanked(&self) -> bool { + self.yanked + } +} + +impl deps_core::PackageMetadata for PypiPackage { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> Option<&str> { + self.summary.as_deref() + } + + fn repository(&self) -> Option<&str> { + self.project_urls + .iter() + .find(|(key, _)| { + key.eq_ignore_ascii_case("repository") + || key.eq_ignore_ascii_case("source") + || key.eq_ignore_ascii_case("code") + }) + .map(|(_, url)| url.as_str()) + } + + fn documentation(&self) -> Option<&str> { + self.project_urls + .iter() + .find(|(key, _)| { + key.eq_ignore_ascii_case("documentation") + || key.eq_ignore_ascii_case("docs") + || key.eq_ignore_ascii_case("homepage") + }) + .map(|(_, url)| url.as_str()) + } + + fn latest_version(&self) -> &str { + &self.latest_version + } +} + +#[cfg(test)] +mod tests { + use super::*; + use deps_core::{PackageMetadata, VersionInfo}; + use tower_lsp::lsp_types::Position; + + #[test] + fn test_pypi_dependency_creation() { + let dep = PypiDependency { + name: "flask".into(), + name_range: Range::new(Position::new(0, 0), Position::new(0, 5)), + version_req: Some(">=3.0.0".into()), + version_range: Some(Range::new(Position::new(0, 6), Position::new(0, 14))), + extras: vec!["async".into()], + extras_range: None, + markers: Some("python_version>='3.9'".into()), + markers_range: None, + section: PypiDependencySection::Dependencies, + source: PypiDependencySource::PyPI, + }; + + assert_eq!(dep.name, "flask"); + assert_eq!(dep.version_req, Some(">=3.0.0".into())); + assert_eq!(dep.extras, vec!["async"]); + } + + #[test] + fn test_dependency_section_variants() { + let deps = PypiDependencySection::Dependencies; + let opt_deps = PypiDependencySection::OptionalDependencies { + group: "dev".into(), + }; + let poetry_deps = PypiDependencySection::PoetryDependencies; + let poetry_group = PypiDependencySection::PoetryGroup { + group: "test".into(), + }; + + assert!(matches!(deps, PypiDependencySection::Dependencies)); + assert!(matches!( + opt_deps, + PypiDependencySection::OptionalDependencies { .. } + )); + assert!(matches!( + poetry_deps, + PypiDependencySection::PoetryDependencies + )); + assert!(matches!( + poetry_group, + PypiDependencySection::PoetryGroup { .. } + )); + } + + #[test] + fn test_dependency_source_variants() { + let pypi = PypiDependencySource::PyPI; + let git = PypiDependencySource::Git { + url: "https://github.com/user/repo.git".into(), + rev: Some("main".into()), + }; + let path = PypiDependencySource::Path { + path: "../local".into(), + }; + let url = PypiDependencySource::Url { + url: "https://example.com/package.whl".into(), + }; + + assert!(matches!(pypi, PypiDependencySource::PyPI)); + assert!(matches!(git, PypiDependencySource::Git { .. })); + assert!(matches!(path, PypiDependencySource::Path { .. })); + assert!(matches!(url, PypiDependencySource::Url { .. })); + } + + #[test] + fn test_pypi_version_creation() { + let version = PypiVersion { + version: "1.0.0".into(), + yanked: false, + }; + + assert_eq!(version.version, "1.0.0"); + assert!(!version.yanked); + assert!(!version.is_prerelease()); + } + + #[test] + fn test_pypi_version_prerelease_detection() { + let stable = PypiVersion { + version: "1.0.0".into(), + yanked: false, + }; + let alpha = PypiVersion { + version: "1.0.0a1".into(), + yanked: false, + }; + let beta = PypiVersion { + version: "1.0.0b2".into(), + yanked: false, + }; + let rc = PypiVersion { + version: "1.0.0rc1".into(), + yanked: false, + }; + + assert!(!stable.is_prerelease()); + assert!(alpha.is_prerelease()); + assert!(beta.is_prerelease()); + assert!(rc.is_prerelease()); + } + + #[test] + fn test_pypi_version_info_trait() { + let version = PypiVersion { + version: "2.28.2".into(), + yanked: true, + }; + + assert_eq!(version.version_string(), "2.28.2"); + assert!(version.is_yanked()); + } + + #[test] + fn test_pypi_package_creation() { + let pkg = PypiPackage { + name: "requests".into(), + summary: Some("Python HTTP for Humans.".into()), + project_urls: vec![ + ("Homepage".into(), "https://requests.readthedocs.io".into()), + ( + "Repository".into(), + "https://github.com/psf/requests".into(), + ), + ], + latest_version: "2.28.2".into(), + }; + + assert_eq!(pkg.name, "requests"); + assert_eq!(pkg.latest_version, "2.28.2"); + } + + #[test] + fn test_pypi_package_metadata_trait() { + let pkg = PypiPackage { + name: "flask".into(), + summary: Some("A micro web framework".into()), + project_urls: vec![ + ( + "Documentation".into(), + "https://flask.palletsprojects.com/".into(), + ), + ( + "Repository".into(), + "https://github.com/pallets/flask".into(), + ), + ], + latest_version: "3.0.0".into(), + }; + + assert_eq!(pkg.name(), "flask"); + assert_eq!(pkg.description(), Some("A micro web framework")); + assert_eq!(pkg.repository(), Some("https://github.com/pallets/flask")); + assert_eq!( + pkg.documentation(), + Some("https://flask.palletsprojects.com/") + ); + assert_eq!(pkg.latest_version(), "3.0.0"); + } + + #[test] + fn test_package_url_fallbacks() { + let pkg = PypiPackage { + name: "test".into(), + summary: None, + project_urls: vec![ + ("Homepage".into(), "https://example.com".into()), + ("Source".into(), "https://github.com/test/test".into()), + ], + latest_version: "1.0.0".into(), + }; + + // Should find "Source" as fallback for repository + assert_eq!(pkg.repository(), Some("https://github.com/test/test")); + // Should find "Homepage" as fallback for documentation + assert_eq!(pkg.documentation(), Some("https://example.com")); + } +} diff --git a/deny.toml b/deny.toml index b407103f..07cd1a43 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,7 @@ allow = [ "Zlib", "0BSD", "CDLA-Permissive-2.0", + "MPL-2.0", ] [[licenses.clarify]]