diff --git a/crates/pixi_manifest/src/document.rs b/crates/pixi_manifest/src/document.rs index 06c830384..0d142f87c 100644 --- a/crates/pixi_manifest/src/document.rs +++ b/crates/pixi_manifest/src/document.rs @@ -267,7 +267,8 @@ impl ManifestSource { } } ManifestSource::PixiToml(_) => { - let mut pypi_requirement = PyPiRequirement::try_from(requirement.clone())?; + let mut pypi_requirement = + PyPiRequirement::try_from(requirement.clone()).map_err(Box::new)?; if let Some(editable) = editable { pypi_requirement.set_editable(editable); } diff --git a/crates/pixi_manifest/src/error.rs b/crates/pixi_manifest/src/error.rs index e4e273573..c476d43bf 100644 --- a/crates/pixi_manifest/src/error.rs +++ b/crates/pixi_manifest/src/error.rs @@ -18,6 +18,8 @@ pub enum DependencyError { NoDependency(String), #[error("No Pypi dependencies.")] NoPyPiDependencies, + #[error(transparent)] + Pep508ToPyPiRequirementError(#[from] Box), } #[derive(Error, Debug)] @@ -44,7 +46,7 @@ pub enum TomlError { table_name: String, }, #[error("Could not convert pep508 to pixi pypi requirement")] - Conversion(#[from] Pep508ToPyPiRequirementError), + Conversion(#[from] Box), } impl TomlError { diff --git a/crates/pixi_manifest/src/manifest.rs b/crates/pixi_manifest/src/manifest.rs index d2767e527..5acecc1bd 100644 --- a/crates/pixi_manifest/src/manifest.rs +++ b/crates/pixi_manifest/src/manifest.rs @@ -97,12 +97,15 @@ impl Manifest { let contents = contents.into(); let (parsed, file_name) = match manifest_kind { ManifestKind::Pixi => (ParsedManifest::from_toml_str(&contents), "pixi.toml"), - ManifestKind::Pyproject => ( - PyProjectManifest::from_toml_str(&contents) + ManifestKind::Pyproject => { + let manifest = match PyProjectManifest::from_toml_str(&contents) .and_then(|m| m.ensure_pixi(&contents)) - .map(|x| x.into()), - "pyproject.toml", - ), + { + Ok(manifest) => Ok(manifest.try_into().into_diagnostic()?), + Err(e) => Err(e), + }; + (manifest, "pyproject.toml") + } }; let (manifest, document) = match parsed.and_then(|manifest| { @@ -374,7 +377,7 @@ impl Manifest { } /// Add a pypi requirement to the manifest - pub fn add_pypi_dependency( + pub fn add_pep508_dependency( &mut self, requirement: &pep508_rs::Requirement, platforms: &[Platform], @@ -387,7 +390,7 @@ impl Manifest { // Add the pypi dependency to the manifest match self .get_or_insert_target_mut(platform, Some(feature_name)) - .try_add_pypi_dependency(requirement, editable, overwrite_behavior) + .try_add_pep508_dependency(requirement, editable, overwrite_behavior) { Ok(true) => { self.document.add_pypi_dependency( @@ -2108,4 +2111,35 @@ bar = "*" ChannelPriority::Disabled ); } + + #[test] + pub fn test_unsupported_pep508_errors() { + let manifest_error = Manifest::from_str( + Path::new("pyproject.toml"), + r#" + [project] + name = "issue-1797" + version = "0.1.0" + dependencies = [ + "attrs @ git+ssh://git@github.com/python-attrs/attrs.git@main" + ] + + [tool.pixi.project] + channels = ["conda-forge"] + platforms = ["win-64"] + "#, + ) + .unwrap_err(); + + let mut error = String::new(); + let report_handler = NarratableReportHandler::new().with_cause_chain(); + report_handler + .render_report(&mut error, manifest_error.as_ref()) + .unwrap(); + insta::assert_snapshot!(error, @r###" + Unsupported pep508 requirement: 'attrs @ git+ssh://git@github.com/python-attrs/attrs.git@main' + Diagnostic severity: error + Caused by: Found invalid characters for git revision 'main', branches and tags are not supported yet + "###); + } } diff --git a/crates/pixi_manifest/src/pypi/mod.rs b/crates/pixi_manifest/src/pypi/mod.rs index 3de0028ef..0707c9675 100644 --- a/crates/pixi_manifest/src/pypi/mod.rs +++ b/crates/pixi_manifest/src/pypi/mod.rs @@ -1,5 +1,4 @@ pub mod pypi_options; pub mod pypi_requirement; pub mod pypi_requirement_types; - pub use pypi_requirement_types::{GitRev, PyPiPackageName, VersionOrStar}; diff --git a/crates/pixi_manifest/src/pypi/pypi_requirement.rs b/crates/pixi_manifest/src/pypi/pypi_requirement.rs index 04a16f915..f3b198518 100644 --- a/crates/pixi_manifest/src/pypi/pypi_requirement.rs +++ b/crates/pixi_manifest/src/pypi/pypi_requirement.rs @@ -1,21 +1,68 @@ -use std::{fmt, fmt::Formatter, path::PathBuf, str::FromStr}; - -use super::{pypi_requirement_types::GitRevParseError, GitRev, VersionOrStar}; -use crate::utils::extract_directory_from_url; +use std::{ + fmt, + fmt::Formatter, + path::{Path, PathBuf}, + str::FromStr, +}; + +use pep440_rs::VersionSpecifiers; use pep508_rs::ExtraName; use serde::{Deserialize, Serialize}; use thiserror::Error; use url::Url; +use super::{pypi_requirement_types::GitRevParseError, GitRev, VersionOrStar}; +use crate::utils::extract_directory_from_url; + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct ParsedGitUrl { + pub git: Url, + pub branch: Option, + pub tag: Option, + pub rev: Option, + pub subdirectory: Option, +} + +impl TryFrom for ParsedGitUrl { + type Error = Pep508ToPyPiRequirementError; + + fn try_from(url: Url) -> Result { + let subdirectory = extract_directory_from_url(&url); + + // Strip the git+ from the url. + let url_without_git = url.as_str().strip_prefix("git+").unwrap_or(url.as_str()); + let url = Url::parse(url_without_git)?; + + // Split the repository url and the rev. + let (repository_url, rev) = if let Some((prefix, suffix)) = url + .path() + .rsplit_once('@') + .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) + { + let mut repository_url = url.clone(); + repository_url.set_path(&prefix); + (repository_url, Some(GitRev::from_str(&suffix)?)) + } else { + (url, None) + }; + + Ok(ParsedGitUrl { + git: repository_url, + branch: None, + tag: None, + rev, + subdirectory, + }) + } +} + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash)] #[serde(untagged, rename_all = "snake_case", deny_unknown_fields)] pub enum PyPiRequirement { Git { - git: Url, - branch: Option, - tag: Option, - rev: Option, - subdirectory: Option, + #[serde(flatten)] + url: ParsedGitUrl, #[serde(default)] extras: Vec, }, @@ -93,11 +140,14 @@ impl From for toml_edit::Value { toml_edit::Value::InlineTable(table.to_owned()) } PyPiRequirement::Git { - git, - branch, - tag, - rev, - subdirectory: _, + url: + ParsedGitUrl { + git, + branch, + tag, + rev, + subdirectory: _, + }, extras, } => { let mut table = toml_edit::Table::new().into_inline_table(); @@ -185,6 +235,23 @@ pub enum Pep508ToPyPiRequirementError { #[error("could not convert '{0}' to a file path")] PathUrlIntoPath(Url), + + #[error("Unsupported URL prefix `{prefix}` in Url: `{url}` ({message})")] + UnsupportedUrlPrefix { + prefix: String, + url: Url, + message: &'static str, + }, +} + +impl From for VersionOrStar { + fn from(value: VersionSpecifiers) -> Self { + if value.is_empty() { + VersionOrStar::Star + } else { + VersionOrStar::Version(value) + } + } } /// Implement from [`pep508_rs::Requirement`] to make the conversion easier. @@ -194,59 +261,71 @@ impl TryFrom for PyPiRequirement { let converted = if let Some(version_or_url) = req.version_or_url { match version_or_url { pep508_rs::VersionOrUrl::VersionSpecifier(v) => PyPiRequirement::Version { - version: if v.is_empty() { - VersionOrStar::Star - } else { - VersionOrStar::Version(v) - }, + version: v.into(), extras: req.extras, }, pep508_rs::VersionOrUrl::Url(u) => { - // If serialization starts with `git+` then it is a git url. - if let Some(stripped_url) = u.to_string().strip_prefix("git+") { - if let Some((url, version)) = stripped_url.split_once('@') { - let url = Url::parse(url)?; - PyPiRequirement::Git { - git: url, - branch: None, - tag: None, - rev: Some(GitRev::from_str(version)?), - subdirectory: None, + let url = u.to_url(); + if let Some((prefix, ..)) = url.scheme().split_once('+') { + match prefix { + "git" => Self::Git { + url: ParsedGitUrl::try_from(url)?, extras: req.extras, + }, + "bzr" => { + return Err(Pep508ToPyPiRequirementError::UnsupportedUrlPrefix { + prefix: prefix.to_string(), + url: u.to_url(), + message: "Bazaar is not supported", + }) } - } else { - let url = Url::parse(stripped_url)?; - PyPiRequirement::Git { - git: url, - branch: None, - tag: None, - rev: None, - subdirectory: None, - extras: req.extras, + "hg" => { + return Err(Pep508ToPyPiRequirementError::UnsupportedUrlPrefix { + prefix: prefix.to_string(), + url: u.to_url(), + message: "Bazaar is not supported", + }) } - } - } else { - let url = u.to_url(); - // Have a different code path when the url is a file. - // i.e. package @ file:///path/to/package - if url.scheme() == "file" { - // Convert the file url to a path. - let file = url.to_file_path().map_err(|_| { - Pep508ToPyPiRequirementError::PathUrlIntoPath(url.clone()) - })?; - PyPiRequirement::Path { - path: file, - editable: None, - extras: req.extras, + "svn" => { + return Err(Pep508ToPyPiRequirementError::UnsupportedUrlPrefix { + prefix: prefix.to_string(), + url: u.to_url(), + message: "Bazaar is not supported", + }) } - } else { - let subdirectory = extract_directory_from_url(&url); - PyPiRequirement::Url { - url, - extras: req.extras, - subdirectory, + _ => { + return Err(Pep508ToPyPiRequirementError::UnsupportedUrlPrefix { + prefix: prefix.to_string(), + url: u.to_url(), + message: "Unknown scheme", + }) } } + } else if Path::new(url.path()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("git")) + { + Self::Git { + url: ParsedGitUrl::try_from(url)?, + extras: req.extras, + } + } else if url.scheme().eq_ignore_ascii_case("file") { + // Convert the file url to a path. + let file = url.to_file_path().map_err(|_| { + Pep508ToPyPiRequirementError::PathUrlIntoPath(url.clone()) + })?; + PyPiRequirement::Path { + path: file, + editable: None, + extras: req.extras, + } + } else { + let subdirectory = extract_directory_from_url(&url); + PyPiRequirement::Url { + url, + extras: req.extras, + subdirectory, + } } } } @@ -302,12 +381,14 @@ impl PyPiRequirement { mod tests { use std::str::FromStr; - use super::*; - use crate::pypi::PyPiPackageName; + use assert_matches::assert_matches; use indexmap::IndexMap; use insta::assert_snapshot; use pep508_rs::Requirement; + use super::*; + use crate::pypi::PyPiPackageName; + #[test] fn test_pypi_to_string() { let req = pep508_rs::Requirement::from_str("numpy[testing]==1.0.0; os_name == \"posix\"") @@ -514,11 +595,13 @@ mod tests { assert_eq!( requirement.first().unwrap().1, &PyPiRequirement::Git { - git: Url::parse("https://test.url.git").unwrap(), - branch: None, - tag: None, - rev: None, - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://test.url.git").unwrap(), + branch: None, + tag: None, + rev: None, + subdirectory: None, + }, extras: vec![], } ); @@ -535,11 +618,13 @@ mod tests { assert_eq!( requirement.first().unwrap().1, &PyPiRequirement::Git { - git: Url::parse("https://test.url.git").unwrap(), - branch: Some("main".to_string()), - tag: None, - rev: None, - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://test.url.git").unwrap(), + branch: Some("main".to_string()), + tag: None, + rev: None, + subdirectory: None, + }, extras: vec![], } ); @@ -556,11 +641,13 @@ mod tests { assert_eq!( requirement.first().unwrap().1, &PyPiRequirement::Git { - git: Url::parse("https://test.url.git").unwrap(), - tag: Some("v.1.2.3".to_string()), - branch: None, - rev: None, - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://test.url.git").unwrap(), + tag: Some("v.1.2.3".to_string()), + branch: None, + rev: None, + subdirectory: None, + }, extras: vec![], } ); @@ -577,11 +664,13 @@ mod tests { assert_eq!( requirement.first().unwrap().1, &PyPiRequirement::Git { - git: Url::parse("https://github.com/pallets/flask.git").unwrap(), - tag: Some("3.0.0".to_string()), - branch: None, - rev: None, - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://github.com/pallets/flask.git").unwrap(), + tag: Some("3.0.0".to_string()), + branch: None, + rev: None, + subdirectory: None, + }, extras: vec![], }, ); @@ -598,11 +687,13 @@ mod tests { assert_eq!( requirement.first().unwrap().1, &PyPiRequirement::Git { - git: Url::parse("https://test.url.git").unwrap(), - rev: Some(GitRev::Short("123456".to_string())), - tag: None, - branch: None, - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://test.url.git").unwrap(), + rev: Some(GitRev::Short("123456".to_string())), + tag: None, + branch: None, + subdirectory: None, + }, extras: vec![], } ); @@ -627,11 +718,13 @@ mod tests { assert_eq!( as_pypi_req, PyPiRequirement::Git { - git: Url::parse("https://github.com/ecederstrand/exchangelib").unwrap(), - branch: None, - tag: None, - rev: None, - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://github.com/ecederstrand/exchangelib").unwrap(), + branch: None, + tag: None, + rev: None, + subdirectory: None, + }, extras: vec![] } ); @@ -641,24 +734,26 @@ mod tests { assert_eq!( as_pypi_req, PyPiRequirement::Git { - git: Url::parse("https://github.com/ecederstrand/exchangelib").unwrap(), - branch: None, - tag: None, - rev: Some(GitRev::Full( - "b283011c6df4a9e034baca9aea19aa8e5a70e3ab".to_string() - )), - subdirectory: None, + url: ParsedGitUrl { + git: Url::parse("https://github.com/ecederstrand/exchangelib").unwrap(), + branch: None, + tag: None, + rev: Some(GitRev::Full( + "b283011c6df4a9e034baca9aea19aa8e5a70e3ab".to_string() + )), + subdirectory: None, + }, extras: vec![] } ); let pypi: Requirement = "boltons @ https://files.pythonhosted.org/packages/46/35/e50d4a115f93e2a3fbf52438435bb2efcf14c11d4fcd6bdcd77a6fc399c9/boltons-24.0.0-py3-none-any.whl".parse().unwrap(); let as_pypi_req: PyPiRequirement = pypi.try_into().unwrap(); - assert_eq!(as_pypi_req, PyPiRequirement::Url{url: Url::parse("https://files.pythonhosted.org/packages/46/35/e50d4a115f93e2a3fbf52438435bb2efcf14c11d4fcd6bdcd77a6fc399c9/boltons-24.0.0-py3-none-any.whl").unwrap(), extras: vec![], subdirectory: None }); + assert_eq!(as_pypi_req, PyPiRequirement::Url { url: Url::parse("https://files.pythonhosted.org/packages/46/35/e50d4a115f93e2a3fbf52438435bb2efcf14c11d4fcd6bdcd77a6fc399c9/boltons-24.0.0-py3-none-any.whl").unwrap(), extras: vec![], subdirectory: None }); let pypi: Requirement = "boltons[nichita] @ https://files.pythonhosted.org/packages/46/35/e50d4a115f93e2a3fbf52438435bb2efcf14c11d4fcd6bdcd77a6fc399c9/boltons-24.0.0-py3-none-any.whl".parse().unwrap(); let as_pypi_req: PyPiRequirement = pypi.try_into().unwrap(); - assert_eq!(as_pypi_req, PyPiRequirement::Url{url: Url::parse("https://files.pythonhosted.org/packages/46/35/e50d4a115f93e2a3fbf52438435bb2efcf14c11d4fcd6bdcd77a6fc399c9/boltons-24.0.0-py3-none-any.whl").unwrap(), extras: vec![ExtraName::new("nichita".to_string()).unwrap()], subdirectory: None }); + assert_eq!(as_pypi_req, PyPiRequirement::Url { url: Url::parse("https://files.pythonhosted.org/packages/46/35/e50d4a115f93e2a3fbf52438435bb2efcf14c11d4fcd6bdcd77a6fc399c9/boltons-24.0.0-py3-none-any.whl").unwrap(), extras: vec![ExtraName::new("nichita".to_string()).unwrap()], subdirectory: None }); #[cfg(target_os = "windows")] let pypi: Requirement = "boltons @ file:///C:/path/to/boltons".parse().unwrap(); @@ -686,4 +781,18 @@ mod tests { } ); } + + #[test] + fn test_git_url() { + let parsed = pep508_rs::Requirement::from_str( + "attrs @ git+ssh://git@github.com/python-attrs/attrs.git@main", + ) + .unwrap(); + assert_matches!( + PyPiRequirement::try_from(parsed), + Err(Pep508ToPyPiRequirementError::ParseGitRev( + GitRevParseError::InvalidCharacters(characters) + )) if characters == "main" + ); + } } diff --git a/crates/pixi_manifest/src/pypi/pypi_requirement_types.rs b/crates/pixi_manifest/src/pypi/pypi_requirement_types.rs index 081ad158e..8fbbcecf0 100644 --- a/crates/pixi_manifest/src/pypi/pypi_requirement_types.rs +++ b/crates/pixi_manifest/src/pypi/pypi_requirement_types.rs @@ -147,7 +147,9 @@ impl GitRev { pub enum GitRevParseError { #[error("Invalid length must be less than 40, actual size: {0}")] InvalidLength(usize), - #[error("Found invalid characters for git revision {0}")] + #[error( + "Found invalid characters for git revision '{0}', branches and tags are not supported yet" + )] InvalidCharacters(String), } diff --git a/crates/pixi_manifest/src/pyproject.rs b/crates/pixi_manifest/src/pyproject.rs index faca72bb2..91479e60b 100644 --- a/crates/pixi_manifest/src/pyproject.rs +++ b/crates/pixi_manifest/src/pyproject.rs @@ -1,20 +1,21 @@ use std::{collections::HashMap, fs, path::PathBuf, str::FromStr}; use indexmap::IndexMap; -use miette::{IntoDiagnostic, Report, WrapErr}; +use miette::{Diagnostic, IntoDiagnostic, Report, WrapErr}; use pep440_rs::VersionSpecifiers; use pep508_rs::Requirement; use pixi_spec::PixiSpec; use pyproject_toml::{self, Project}; use rattler_conda_types::{PackageName, ParseStrictness::Lenient, VersionSpec}; use serde::Deserialize; +use thiserror::Error; use toml_edit::DocumentMut; use super::{ error::{RequirementConversionError, TomlError}, - Feature, ParsedManifest, SpecType, + DependencyOverwriteBehavior, Feature, ParsedManifest, SpecType, }; -use crate::FeatureName; +use crate::{error::DependencyError, FeatureName}; #[derive(Deserialize, Debug, Clone)] pub struct PyProjectManifest { @@ -221,8 +222,16 @@ impl PyProjectManifest { } } -impl From for ParsedManifest { - fn from(item: PyProjectManifest) -> Self { +#[derive(Debug, Error, Diagnostic)] +pub enum PyProjectToManifestError { + #[error("Unsupported pep508 requirement: '{0}'")] + DependencyError(Requirement, #[source] DependencyError), +} + +impl TryFrom for ParsedManifest { + type Error = PyProjectToManifestError; + + fn try_from(item: PyProjectManifest) -> Result { // Load the data nested under '[tool.pixi]' as pixi manifest let mut manifest = item .pixi() @@ -268,7 +277,15 @@ impl From for ParsedManifest { // Add pyproject dependencies as pypi dependencies if let Some(deps) = item.project().and_then(|p| p.dependencies.clone()) { for requirement in deps.iter() { - target.add_pypi_dependency(requirement, None); + target + .try_add_pep508_dependency( + requirement, + None, + DependencyOverwriteBehavior::Error, + ) + .map_err(|err| { + PyProjectToManifestError::DependencyError(requirement.clone(), err) + })?; } } @@ -288,13 +305,21 @@ impl From for ParsedManifest { for requirement in reqs.iter() { // filter out any self references in groups of extra dependencies if project_name.as_ref() != Some(&requirement.name) { - target.add_pypi_dependency(requirement, None); + target + .try_add_pep508_dependency( + requirement, + None, + DependencyOverwriteBehavior::Error, + ) + .map_err(|err| { + PyProjectToManifestError::DependencyError(requirement.clone(), err) + })?; } } } } - manifest + Ok(manifest) } } @@ -502,7 +527,7 @@ mod tests { // Add numpy to pyproject let requirement = pep508_rs::Requirement::from_str("numpy>=3.12").unwrap(); manifest - .add_pypi_dependency( + .add_pep508_dependency( &requirement, &[], &FeatureName::Default, @@ -525,7 +550,7 @@ mod tests { // Add numpy to feature in pyproject let requirement = pep508_rs::Requirement::from_str("pytest>=3.12").unwrap(); manifest - .add_pypi_dependency( + .add_pep508_dependency( &requirement, &[], &FeatureName::Named("test".to_string()), diff --git a/crates/pixi_manifest/src/target.rs b/crates/pixi_manifest/src/target.rs index 9fe3792dc..a5611d041 100644 --- a/crates/pixi_manifest/src/target.rs +++ b/crates/pixi_manifest/src/target.rs @@ -216,31 +216,17 @@ impl Target { /// Adds a pypi dependency to a target /// /// This will overwrite any existing dependency of the same name - pub fn add_pypi_dependency( - &mut self, - requirement: &pep508_rs::Requirement, - editable: Option, - ) { - // TODO: add proper error handling for this - let mut pypi_requirement = PyPiRequirement::try_from(requirement.clone()) - .expect("could not convert pep508 requirement"); - if let Some(editable) = editable { - pypi_requirement.set_editable(editable); - } - + pub fn add_pypi_dependency(&mut self, name: PyPiPackageName, requirement: PyPiRequirement) { self.pypi_dependencies .get_or_insert_with(Default::default) - .insert( - PyPiPackageName::from_normalized(requirement.name.clone()), - pypi_requirement, - ); + .insert(name, requirement); } /// Adds a pypi dependency to a target /// /// This will return an error if the exact same dependency already exist /// This will overwrite any existing dependency of the same name - pub fn try_add_pypi_dependency( + pub fn try_add_pep508_dependency( &mut self, requirement: &pep508_rs::Requirement, editable: Option, @@ -260,7 +246,15 @@ impl Target { _ => {} } } - self.add_pypi_dependency(requirement, editable); + + // Convert to an internal representation + let name = PyPiPackageName::from_normalized(requirement.name.clone()); + let mut requirement = PyPiRequirement::try_from(requirement.clone()).map_err(Box::new)?; + if let Some(editable) = editable { + requirement.set_editable(editable); + } + + self.add_pypi_dependency(name, requirement); Ok(true) } } diff --git a/crates/pixi_manifest/src/utils/mod.rs b/crates/pixi_manifest/src/utils/mod.rs index 399224d7c..32888c077 100644 --- a/crates/pixi_manifest/src/utils/mod.rs +++ b/crates/pixi_manifest/src/utils/mod.rs @@ -16,3 +16,39 @@ pub(crate) fn extract_directory_from_url(url: &Url) -> Option { .find_map(|fragment| fragment.strip_prefix("subdirectory="))?; Some(subdirectory.into()) } + +#[cfg(test)] +mod test { + use rstest::*; + use url::Url; + + use super::*; + + #[rstest] + #[case( + "git+https://github.com/foobar.git@v1.0#subdirectory=pkg_dir", + Some("pkg_dir") + )] + #[case( + "git+ssh://gitlab.org/foobar.git@v1.0#egg=pkg&subdirectory=pkg_dir", + Some("pkg_dir") + )] + #[case("git+https://github.com/foobar.git@v1.0", None)] + #[case("git+https://github.com/foobar.git@v1.0#egg=pkg", None)] + #[case( + "git+https://github.com/foobar.git@v1.0#subdirectory=pkg_dir&other=val", + Some("pkg_dir") + )] + #[case( + "git+https://github.com/foobar.git@v1.0#other=val&subdirectory=pkg_dir", + Some("pkg_dir") + )] + #[case( + "git+https://github.com/foobar.git@v1.0#subdirectory=pkg_dir&subdirectory=another_dir", + Some("pkg_dir") + )] + fn test_get_subdirectory(#[case] url: Url, #[case] expected: Option<&str>) { + let subdirectory = extract_directory_from_url(&url); + assert_eq!(subdirectory.as_deref(), expected); + } +} diff --git a/crates/pixi_uv_conversions/src/requirements.rs b/crates/pixi_uv_conversions/src/requirements.rs index a6d422aa5..0179e0800 100644 --- a/crates/pixi_uv_conversions/src/requirements.rs +++ b/crates/pixi_uv_conversions/src/requirements.rs @@ -5,7 +5,10 @@ use std::{ use distribution_filename::DistExtension; use pep508_rs::VerbatimUrl; -use pixi_manifest::{pypi::GitRev, PyPiRequirement}; +use pixi_manifest::{ + pypi::{pypi_requirement::ParsedGitUrl, GitRev}, + PyPiRequirement, +}; use pypi_types::RequirementSource; use thiserror::Error; use url::Url; @@ -74,11 +77,14 @@ pub fn as_uv_req( } } PyPiRequirement::Git { - git, - rev, - tag, - subdirectory, - branch, + url: + ParsedGitUrl { + git, + rev, + tag, + subdirectory, + branch, + }, .. } => RequirementSource::Git { repository: git.clone(), diff --git a/src/cli/add.rs b/src/cli/add.rs index d35b251e0..87c639fbb 100644 --- a/src/cli/add.rs +++ b/src/cli/add.rs @@ -131,7 +131,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { DependencyType::PypiDependency => { let specs = dependency_config.pypi_deps(&project)?; for (name, spec) in specs { - let added = project.manifest.add_pypi_dependency( + let added = project.manifest.add_pep508_dependency( &spec, &dependency_config.platform, &dependency_config.feature_name(), @@ -340,7 +340,7 @@ fn update_pypi_specs_from_lock_file( version_or_url: Some(VersionSpecifier(version_spec)), ..req }; - project.manifest.add_pypi_dependency( + project.manifest.add_pep508_dependency( &req, platforms, feature_name, diff --git a/src/cli/init.rs b/src/cli/init.rs index 54721aedf..7ea9de052 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,5 +1,5 @@ -use std::cmp::PartialEq; use std::{ + cmp::PartialEq, fs, io::{Error, ErrorKind, Write}, path::{Path, PathBuf}, @@ -8,9 +8,12 @@ use std::{ use clap::{Parser, ValueEnum}; use miette::IntoDiagnostic; use minijinja::{context, Environment}; +use pixi_config::{get_default_author, Config}; +use pixi_consts::consts; use pixi_manifest::{ pyproject::PyProjectManifest, DependencyOverwriteBehavior, FeatureName, SpecType, }; +use pixi_utils::conda_environment_file::CondaEnvFile; use rattler_conda_types::{NamedChannelOrUrl, Platform}; use url::Url; @@ -18,9 +21,6 @@ use crate::{ environment::{update_prefix, LockFileUsage}, Project, }; -use pixi_config::{get_default_author, Config}; -use pixi_consts::consts; -use pixi_utils::conda_environment_file::CondaEnvFile; #[derive(Parser, Debug, Clone, PartialEq, ValueEnum)] pub enum ManifestFormat { @@ -233,7 +233,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { )?; } for requirement in pypi_deps { - project.manifest.add_pypi_dependency( + project.manifest.add_pep508_dependency( &requirement, &platforms, &FeatureName::default(), @@ -262,7 +262,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { && pyproject_manifest_path.is_file() { dialoguer::Confirm::new() - .with_prompt(format!("\nA '{}' file already exists.\nDo you want to extend it with the '{}' configuration?", console::style(consts::PYPROJECT_MANIFEST).bold(), console::style("[tool.pixi]").bold().green()) ) + .with_prompt(format!("\nA '{}' file already exists.\nDo you want to extend it with the '{}' configuration?", console::style(consts::PYPROJECT_MANIFEST).bold(), console::style("[tool.pixi]").bold().green())) .default(false) .show_default(true) .interact()