diff --git a/Cargo.lock b/Cargo.lock index 8dda607d84c5..664f256514df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3001,6 +3001,7 @@ dependencies = [ "reqwest-middleware", "tempfile", "test-case", + "thiserror", "tokio", "tracing", "unscanny", diff --git a/crates/pep508-rs/src/unnamed.rs b/crates/pep508-rs/src/unnamed.rs index dced751bfbde..398a278cfb93 100644 --- a/crates/pep508-rs/src/unnamed.rs +++ b/crates/pep508-rs/src/unnamed.rs @@ -214,6 +214,12 @@ fn parse_unnamed_requirement( /// Create a `VerbatimUrl` to represent the requirement, and extracts any extras at the end of the /// URL, to comply with the non-PEP 508 extensions. +/// +/// For example: +/// - `file:///home/ferris/project/scripts/...` +/// - `file:../editable/` +/// - `../editable/` +/// - `https://download.pytorch.org/whl/torch_stable.html` fn preprocess_unnamed_url( url: &str, #[cfg_attr(not(feature = "non-pep508-extensions"), allow(unused))] working_dir: Option<&Path>, @@ -356,8 +362,39 @@ fn parse_unnamed_url( ) -> Result<(Url, Vec), Pep508Error> { // wsp* cursor.eat_whitespace(); + // - let (start, len) = cursor.take_while(|char| !char.is_whitespace()); + let (start, len) = { + let start = cursor.pos(); + let mut len = 0; + let mut backslash = false; + let mut depth = 0u32; + while let Some((_, c)) = cursor.next() { + if backslash { + backslash = false; + } else if c == '\\' { + backslash = true; + } else if c == '[' { + depth = depth.saturating_add(1); + } else if c == ']' { + depth = depth.saturating_sub(1); + } + + // If we see top-level whitespace, we're done. + if depth == 0 && c.is_whitespace() { + break; + } + + // If we see a line break, we're done. + if matches!(c, '\r' | '\n') { + break; + } + + len += c.len_utf8(); + } + (start, len) + }; + let url = cursor.slice(start, len); if url.is_empty() { return Err(Pep508Error { diff --git a/crates/requirements-txt/Cargo.toml b/crates/requirements-txt/Cargo.toml index ccf0c415875c..50f2a2cf57ca 100644 --- a/crates/requirements-txt/Cargo.toml +++ b/crates/requirements-txt/Cargo.toml @@ -26,6 +26,7 @@ fs-err = { workspace = true } regex = { workspace = true } reqwest = { workspace = true, optional = true } reqwest-middleware = { workspace = true, optional = true } +thiserror = { workspace = true } tracing = { workspace = true } unscanny = { workspace = true } url = { workspace = true } diff --git a/crates/requirements-txt/src/lib.rs b/crates/requirements-txt/src/lib.rs index a7ed8d5cd60e..79fc72d5f1b4 100644 --- a/crates/requirements-txt/src/lib.rs +++ b/crates/requirements-txt/src/lib.rs @@ -44,18 +44,17 @@ use tracing::instrument; use unscanny::{Pattern, Scanner}; use url::Url; +use crate::requirement::EditableError; use distribution_types::{UnresolvedRequirement, UnresolvedRequirementSpecification}; use pep508_rs::{ - expand_env_vars, split_scheme, strip_host, Extras, MarkerTree, Pep508Error, Pep508ErrorSource, - RequirementOrigin, Scheme, UnnamedRequirement, VerbatimUrl, + expand_env_vars, split_scheme, strip_host, Pep508Error, RequirementOrigin, Scheme, VerbatimUrl, }; -use pypi_types::{ParsedPathUrl, ParsedUrl, Requirement, VerbatimParsedUrl}; +use pypi_types::{Requirement, VerbatimParsedUrl}; #[cfg(feature = "http")] use uv_client::BaseClient; use uv_client::BaseClientBuilder; use uv_configuration::{NoBinary, NoBuild, PackageNameSpecifier}; use uv_fs::{normalize_url_path, Simplified}; -use uv_normalize::ExtraName; use uv_warnings::warn_user; pub use crate::requirement::RequirementsTxtRequirement; @@ -79,7 +78,7 @@ enum RequirementsTxtStatement { /// PEP 508 requirement plus metadata RequirementEntry(RequirementEntry), /// `-e` - EditableRequirement(EditableRequirement), + EditableRequirementEntry(RequirementEntry), /// `--index-url` IndexUrl(VerbatimUrl), /// `--extra-index-url` @@ -161,234 +160,6 @@ impl FindLink { } } -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct EditableRequirement { - /// The underlying [`VerbatimUrl`] from the `requirements.txt` file or similar. - pub url: VerbatimUrl, - /// The extras that should be included when resolving the editable requirements. - pub extras: Vec, - /// The markers such as `python_version > "3.8"` in `-e ../editable ; python_version > "3.8"`. - pub marker: Option, - /// The local path to the editable. - pub path: PathBuf, - /// The source file containing the requirement. - pub origin: Option, -} - -impl EditableRequirement { - pub fn url(&self) -> &VerbatimUrl { - &self.url - } - - pub fn raw(&self) -> &Url { - self.url.raw() - } -} - -impl EditableRequirement { - /// Parse a raw string for an editable requirement (`pip install -e `), which could be - /// a URL or a local path, and could contain unexpanded environment variables. - /// - /// For example: - /// - `file:///home/ferris/project/scripts/...` - /// - `file:../editable/` - /// - `../editable/` - /// - /// We disallow URLs with schemes other than `file://` (e.g., `https://...`). - pub fn parse( - given: &str, - origin: Option<&Path>, - working_dir: impl AsRef, - ) -> Result { - // Identify the markers. - let (given, marker) = if let Some((requirement, marker)) = Self::split_markers(given) { - let marker = MarkerTree::parse_str(marker).map_err(|err| { - // Map from error on the markers to error on the whole requirement. - let err = Pep508Error { - message: err.message, - start: requirement.len() + err.start, - len: err.len, - input: given.to_string(), - }; - match err.message { - Pep508ErrorSource::String(_) | Pep508ErrorSource::UrlError(_) => { - RequirementsTxtParserError::Pep508 { - start: err.start, - end: err.start + err.len, - source: Box::new(err), - } - } - Pep508ErrorSource::UnsupportedRequirement(_) => { - RequirementsTxtParserError::UnsupportedRequirement { - start: err.start, - end: err.start + err.len, - source: Box::new(err), - } - } - } - })?; - (requirement, Some(marker)) - } else { - (given, None) - }; - - // Identify the extras. - let (requirement, extras) = if let Some((requirement, extras)) = Self::split_extras(given) { - let extras = Extras::parse(extras).map_err(|err| { - // Map from error on the extras to error on the whole requirement. - let err = Pep508Error { - message: err.message, - start: requirement.len() + err.start, - len: err.len, - input: given.to_string(), - }; - match err.message { - Pep508ErrorSource::String(_) | Pep508ErrorSource::UrlError(_) => { - RequirementsTxtParserError::Pep508 { - start: err.start, - end: err.start + err.len, - source: Box::new(err), - } - } - Pep508ErrorSource::UnsupportedRequirement(_) => { - RequirementsTxtParserError::UnsupportedRequirement { - start: err.start, - end: err.start + err.len, - source: Box::new(err), - } - } - } - })?; - (requirement, extras.into_vec()) - } else { - (given, vec![]) - }; - - // Expand environment variables. - let expanded = expand_env_vars(requirement); - - // Create a `VerbatimUrl` to represent the editable requirement. - let url = if let Some((scheme, path)) = split_scheme(&expanded) { - match Scheme::parse(scheme) { - // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/` - Some(Scheme::File) => { - // Strip the leading slashes, along with the `localhost` host, if present. - let path = strip_host(path); - - // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. - let path = normalize_url_path(path); - - VerbatimUrl::parse_path(path.as_ref(), working_dir.as_ref()) - } - - // Ex) `https://download.pytorch.org/whl/torch_stable.html` - Some(_) => { - return Err(RequirementsTxtParserError::UnsupportedUrl( - expanded.to_string(), - )); - } - - // Ex) `C:/Users/ferris/wheel-0.42.0.tar.gz` - _ => VerbatimUrl::parse_path(expanded.as_ref(), working_dir.as_ref()), - } - } else { - // Ex) `../editable/` - VerbatimUrl::parse_path(expanded.as_ref(), working_dir.as_ref()) - }; - - let url = url.map_err(|err| RequirementsTxtParserError::VerbatimUrl { - source: err, - url: expanded.to_string(), - })?; - - // Create a `PathBuf`. - let path = url - .to_file_path() - .map_err(|()| RequirementsTxtParserError::UrlConversion(expanded.to_string()))?; - - // Add the verbatim representation of the URL to the `VerbatimUrl`. - let url = url.with_given(requirement.to_string()); - - Ok(Self { - url, - extras, - marker, - path, - origin: origin.map(Path::to_path_buf).map(RequirementOrigin::File), - }) - } - - /// Identify the extras in an editable URL (e.g., `../editable[dev]`). - /// - /// Pip uses `m = re.match(r'^(.+)(\[[^]]+])$', path)`. Our strategy is: - /// - If the string ends with a closing bracket (`]`)... - /// - Iterate backwards until you find the open bracket (`[`)... - /// - But abort if you find another closing bracket (`]`) first. - pub fn split_extras(given: &str) -> Option<(&str, &str)> { - let mut chars = given.char_indices().rev(); - - // If the string ends with a closing bracket (`]`)... - if !matches!(chars.next(), Some((_, ']'))) { - return None; - } - - // Iterate backwards until you find the open bracket (`[`)... - let (index, _) = chars - .take_while(|(_, c)| *c != ']') - .find(|(_, c)| *c == '[')?; - - Some(given.split_at(index)) - } - - /// Identify the markers in an editable URL (e.g., `../editable ; python_version > "3.8"`). - pub fn split_markers(given: &str) -> Option<(&str, &str)> { - // Take until we see whitespace, unless it's escaped with a backslash, or within brackets - // (which would indicate an extra). - let mut backslash = false; - let mut depth = 0; - for (index, c) in given.char_indices() { - if backslash { - backslash = false; - } else if c == '\\' { - backslash = true; - } else if c == '[' { - depth += 1; - } else if c == ']' { - depth -= 1; - } else if depth == 0 && c.is_whitespace() { - // We found the end of the requirement; now, find the start of the markers, - // delimited by a semicolon. - let (requirement, markers) = given.split_at(index); - - // Skip the whitespace. - for (index, c) in markers.char_indices() { - if backslash { - backslash = false; - } else if c == '\\' { - backslash = true; - } else if c.is_whitespace() { - continue; - } else if c == ';' { - // The marker starts just after the semicolon. - let markers = &markers[index + 1..]; - return Some((requirement, markers)); - } else { - // We saw some other character, so this isn't a marker. - return None; - } - } - } - } - None - } -} - -impl Display for EditableRequirement { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self.url, f) - } -} - /// A [Requirement] with additional metadata from the `requirements.txt`, currently only hashes but in /// the future also editable and similar information. #[derive(Debug, Clone, Eq, PartialEq, Hash)] @@ -418,24 +189,12 @@ impl From for UnresolvedRequirementSpecification { } } -impl From for UnresolvedRequirementSpecification { - fn from(value: EditableRequirement) -> Self { - Self { - requirement: UnresolvedRequirement::Unnamed(UnnamedRequirement { - url: VerbatimParsedUrl { - parsed_url: ParsedUrl::Path(ParsedPathUrl { - url: value.url.to_url(), - path: value.path, - editable: true, - }), - verbatim: value.url, - }, - extras: value.extras, - marker: value.marker, - origin: value.origin, - }), +impl From for UnresolvedRequirementSpecification { + fn from(value: RequirementsTxtRequirement) -> Self { + Self::from(RequirementEntry { + requirement: value, hashes: vec![], - } + }) } } @@ -447,7 +206,7 @@ pub struct RequirementsTxt { /// Constraints included with `-c`. pub constraints: Vec>, /// Editables with `-e`. - pub editables: Vec, + pub editables: Vec, /// The index URL, specified with `--index-url`. pub index_url: Option, /// The extra index URLs, specified with `--extra-index-url`. @@ -636,7 +395,7 @@ impl RequirementsTxt { RequirementsTxtStatement::RequirementEntry(requirement_entry) => { data.requirements.push(requirement_entry); } - RequirementsTxtStatement::EditableRequirement(editable) => { + RequirementsTxtStatement::EditableRequirementEntry(editable) => { data.editables.push(editable); } RequirementsTxtStatement::IndexUrl(url) => { @@ -733,11 +492,27 @@ fn parse_entry( end, } } else if s.eat_if("-e") || s.eat_if("--editable") { - let path_or_url = parse_value(content, s, |c: char| !['\n', '\r', '#'].contains(&c))?; - let editable_requirement = - EditableRequirement::parse(path_or_url, Some(requirements_txt), working_dir) - .map_err(|err| err.with_offset(start))?; - RequirementsTxtStatement::EditableRequirement(editable_requirement) + s.eat_whitespace(); + + let source = if requirements_txt == Path::new("-") { + None + } else { + Some(requirements_txt) + }; + + let (requirement, hashes) = parse_requirement_and_hashes(s, content, source, working_dir)?; + let requirement = + requirement + .into_editable() + .map_err(|err| RequirementsTxtParserError::NonEditable { + source: err, + start, + end: s.cursor(), + })?; + RequirementsTxtStatement::EditableRequirementEntry(RequirementEntry { + requirement, + hashes, + }) } else if s.eat_if("-i") || s.eat_if("--index-url") { let given = parse_value(content, s, |c: char| !['\n', '\r', '#'].contains(&c))?; let expanded = expand_env_vars(given); @@ -1044,6 +819,11 @@ pub enum RequirementsTxtParserError { UrlConversion(String), UnsupportedUrl(String), MissingRequirementPrefix(String), + NonEditable { + source: EditableError, + start: usize, + end: usize, + }, NoBinary { source: uv_normalize::InvalidNameError, specifier: String, @@ -1092,89 +872,6 @@ pub enum RequirementsTxtParserError { Reqwest(reqwest_middleware::Error), } -impl RequirementsTxtParserError { - /// Add a fixed offset to the location of the error. - #[must_use] - fn with_offset(self, offset: usize) -> Self { - match self { - Self::IO(err) => Self::IO(err), - Self::UrlConversion(given) => Self::UrlConversion(given), - Self::Url { - source, - url, - start, - end, - } => Self::Url { - source, - url, - start: start + offset, - end: end + offset, - }, - Self::VerbatimUrl { source, url } => Self::VerbatimUrl { source, url }, - Self::UnsupportedUrl(url) => Self::UnsupportedUrl(url), - Self::MissingRequirementPrefix(given) => Self::MissingRequirementPrefix(given), - Self::NoBinary { - source, - specifier, - start, - end, - } => Self::NoBinary { - source, - specifier, - start: start + offset, - end: end + offset, - }, - Self::OnlyBinary { - source, - specifier, - start, - end, - } => Self::OnlyBinary { - source, - specifier, - start: start + offset, - end: end + offset, - }, - Self::UnnamedConstraint { start, end } => Self::UnnamedConstraint { - start: start + offset, - end: end + offset, - }, - Self::Parser { - message, - line, - column, - } => Self::Parser { - message, - line, - column, - }, - Self::UnsupportedRequirement { source, start, end } => Self::UnsupportedRequirement { - source, - start: start + offset, - end: end + offset, - }, - Self::Pep508 { source, start, end } => Self::Pep508 { - source, - start: start + offset, - end: end + offset, - }, - Self::ParsedUrl { source, start, end } => Self::ParsedUrl { - source, - start: start + offset, - end: end + offset, - }, - Self::Subfile { source, start, end } => Self::Subfile { - source, - start: start + offset, - end: end + offset, - }, - Self::NonUnicodeUrl { url } => Self::NonUnicodeUrl { url }, - #[cfg(feature = "http")] - Self::Reqwest(err) => Self::Reqwest(err), - } - } -} - impl Display for RequirementsTxtParserError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -1191,6 +888,9 @@ impl Display for RequirementsTxtParserError { Self::UnsupportedUrl(url) => { write!(f, "Unsupported URL (expected a `file://` scheme): `{url}`") } + Self::NonEditable { .. } => { + write!(f, "Unsupported editable requirement") + } Self::MissingRequirementPrefix(given) => { write!(f, "Requirement `{given}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?") } @@ -1245,6 +945,7 @@ impl std::error::Error for RequirementsTxtParserError { Self::VerbatimUrl { source, .. } => Some(source), Self::UrlConversion(_) => None, Self::UnsupportedUrl(_) => None, + Self::NonEditable { source, .. } => Some(source), Self::MissingRequirementPrefix(_) => None, Self::NoBinary { source, .. } => Some(source), Self::OnlyBinary { source, .. } => Some(source), @@ -1289,6 +990,13 @@ impl Display for RequirementsTxtFileError { self.file.user_display(), ) } + RequirementsTxtParserError::NonEditable { .. } => { + write!( + f, + "Unsupported editable requirement in `{}`", + self.file.user_display(), + ) + } RequirementsTxtParserError::MissingRequirementPrefix(given) => { write!( f, @@ -1457,7 +1165,7 @@ mod test { use uv_client::BaseClientBuilder; use uv_fs::Simplified; - use crate::{calculate_row_column, EditableRequirement, RequirementsTxt}; + use crate::{calculate_row_column, RequirementsTxt}; fn workspace_test_data_dir() -> PathBuf { Path::new("./test-data").simple_canonicalize().unwrap() @@ -1580,6 +1288,27 @@ mod test { }); } + #[cfg(unix)] + #[test_case(Path::new("semicolon.txt"))] + #[tokio::test] + async fn parse_err(path: &Path) { + let working_dir = workspace_test_data_dir().join("requirements-txt"); + let requirements_txt = working_dir.join(path); + + let actual = + RequirementsTxt::parse(requirements_txt, &working_dir, &BaseClientBuilder::new()) + .await + .unwrap_err(); + + let snapshot = format!("parse-unix-{}", path.to_string_lossy()); + + insta::with_settings!({ + filters => path_filters(&path_filter(&working_dir)), + }, { + insta::assert_debug_snapshot!(snapshot, actual); + }); + } + #[cfg(windows)] #[test_case(Path::new("bare-url.txt"))] #[test_case(Path::new("editable.txt"))] @@ -1730,7 +1459,10 @@ mod test { insta::with_settings!({ filters => filters }, { - insta::assert_snapshot!(errors, @"Unsupported URL (expected a `file://` scheme) in ``: `http://localhost:8080/`"); + insta::assert_snapshot!(errors, @r###" + Unsupported editable requirement in `` + Editable must refer to a local directory, not an HTTPS URL: `http://localhost:8080/` + "###); }); Ok(()) @@ -1759,7 +1491,7 @@ mod test { filters => filters }, { insta::assert_snapshot!(errors, @r###" - Couldn't parse requirement in `` at position 6 + Couldn't parse requirement in `` at position 3 Expected either alphanumerical character (starting the extra name) or ']' (ending the extras section), found ',' black[,abcdef] ^ @@ -2042,31 +1774,54 @@ mod test { requirements: [], constraints: [], editables: [ - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/foo/bar", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/foo/bar", + query: None, + fragment: None, + }, + path: "/foo/bar", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/foo/bar", + query: None, + fragment: None, + }, + given: Some( + "/foo/bar", + ), + }, + }, + extras: [], + marker: None, + origin: Some( + File( + "/grandchild.txt", + ), + ), }, - given: Some( - "/foo/bar", - ), - }, - extras: [], - marker: None, - path: "/foo/bar", - origin: Some( - File( - "/grandchild.txt", - ), ), + hashes: [], }, ], index_url: None, @@ -2339,26 +2094,6 @@ mod test { Ok(()) } - #[test] - fn editable_extra() { - assert_eq!( - EditableRequirement::split_extras("../editable[dev]"), - Some(("../editable", "[dev]")) - ); - assert_eq!( - EditableRequirement::split_extras("../editable[dev]more[extra]"), - Some(("../editable[dev]more", "[extra]")) - ); - assert_eq!( - EditableRequirement::split_extras("../editable[[dev]]"), - None - ); - assert_eq!( - EditableRequirement::split_extras("../editable[[dev]"), - Some(("../editable[", "[dev]")) - ); - } - #[tokio::test] async fn parser_error_line_and_column() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?; diff --git a/crates/requirements-txt/src/requirement.rs b/crates/requirements-txt/src/requirement.rs index 91b280cca10c..ecf91ad8c08d 100644 --- a/crates/requirements-txt/src/requirement.rs +++ b/crates/requirements-txt/src/requirement.rs @@ -3,7 +3,29 @@ use std::path::Path; use pep508_rs::{ Pep508Error, Pep508ErrorSource, RequirementOrigin, TracingReporter, UnnamedRequirement, }; -use pypi_types::VerbatimParsedUrl; +use pypi_types::{ParsedPathUrl, ParsedUrl, VerbatimParsedUrl}; +use uv_normalize::PackageName; + +#[derive(Debug, thiserror::Error)] +pub enum EditableError { + #[error("Editable `{0}` must refer to a local directory")] + MissingVersion(PackageName), + + #[error("Editable `{0}` must refer to a local directory, not a versioned package")] + Versioned(PackageName), + + #[error("Editable `{0}` must refer to a local directory, not an HTTPS URL: `{1}`")] + Https(PackageName, String), + + #[error("Editable `{0}` must refer to a local directory, not a Git URL: `{1}`")] + Git(PackageName, String), + + #[error("Editable must refer to a local directory, not an HTTPS URL: `{0}`")] + UnnamedHttps(String), + + #[error("Editable must refer to a local directory, not a Git URL: `{0}`")] + UnnamedGit(String), +} /// A requirement specifier in a `requirements.txt` file. /// @@ -27,6 +49,69 @@ impl RequirementsTxtRequirement { Self::Unnamed(requirement) => Self::Unnamed(requirement.with_origin(origin)), } } + + /// Convert the [`RequirementsTxtRequirement`] into an editable requirement. + /// + /// # Errors + /// + /// Returns [`EditableError`] if the requirement cannot be interpreted as editable. + /// Specifically, only local directory URLs are supported. + pub fn into_editable(self) -> Result { + match self { + RequirementsTxtRequirement::Named(requirement) => { + let Some(version_or_url) = requirement.version_or_url else { + return Err(EditableError::MissingVersion(requirement.name)); + }; + + let pep508_rs::VersionOrUrl::Url(url) = version_or_url else { + return Err(EditableError::Versioned(requirement.name)); + }; + + let parsed_url = match url.parsed_url { + ParsedUrl::Path(parsed_url) => parsed_url, + ParsedUrl::Archive(_) => { + return Err(EditableError::Https(requirement.name, url.to_string())) + } + ParsedUrl::Git(_) => { + return Err(EditableError::Git(requirement.name, url.to_string())) + } + }; + + Ok(Self::Named(pep508_rs::Requirement { + version_or_url: Some(pep508_rs::VersionOrUrl::Url(VerbatimParsedUrl { + verbatim: url.verbatim, + parsed_url: ParsedUrl::Path(ParsedPathUrl { + editable: true, + ..parsed_url + }), + })), + ..requirement + })) + } + RequirementsTxtRequirement::Unnamed(requirement) => { + let parsed_url = match requirement.url.parsed_url { + ParsedUrl::Path(parsed_url) => parsed_url, + ParsedUrl::Archive(_) => { + return Err(EditableError::UnnamedHttps(requirement.to_string())) + } + ParsedUrl::Git(_) => { + return Err(EditableError::UnnamedGit(requirement.to_string())) + } + }; + + Ok(Self::Unnamed(UnnamedRequirement { + url: VerbatimParsedUrl { + verbatim: requirement.url.verbatim, + parsed_url: ParsedUrl::Path(ParsedPathUrl { + editable: true, + ..parsed_url + }), + }, + ..requirement + })) + } + } + } } impl RequirementsTxtRequirement { diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap index 2df2e5ecc879..de4536bb96f0 100644 --- a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap @@ -6,252 +6,390 @@ RequirementsTxt { requirements: [], constraints: [], editables: [ - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/editable", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, + }, + path: "/editable", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, + }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", + ), + ], + marker: None, + origin: Some( + File( + "/editable.txt", + ), + ), }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: None, - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/editable", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, + }, + path: "/editable", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, + }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", + ), + ], + marker: None, + origin: Some( + File( + "/editable.txt", + ), + ), }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: None, - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/editable", - query: None, - fragment: None, - }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: Some( - And( - [ - Expression( - Version { - key: PythonVersion, - specifier: VersionSpecifier { - operator: GreaterThanEqual, - version: "3.9", + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, }, + path: "/editable", + editable: true, }, ), - Expression( - String { - key: OsName, - operator: Equal, - value: "posix", + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", ), ], - ), - ), - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), + marker: Some( + And( + [ + Expression( + Version { + key: PythonVersion, + specifier: VersionSpecifier { + operator: GreaterThanEqual, + version: "3.9", + }, + }, + ), + Expression( + String { + key: OsName, + operator: Equal, + value: "posix", + }, + ), + ], + ), + ), + origin: Some( + File( + "/editable.txt", + ), + ), + }, ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/editable", - query: None, - fragment: None, - }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: Some( - And( - [ - Expression( - Version { - key: PythonVersion, - specifier: VersionSpecifier { - operator: GreaterThanEqual, - version: "3.9", + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, }, + path: "/editable", + editable: true, }, ), - Expression( - String { - key: OsName, - operator: Equal, - value: "posix", + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", ), ], - ), - ), - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), + marker: Some( + And( + [ + Expression( + Version { + key: PythonVersion, + specifier: VersionSpecifier { + operator: GreaterThanEqual, + version: "3.9", + }, + }, + ), + Expression( + String { + key: OsName, + operator: Equal, + value: "posix", + }, + ), + ], + ), + ), + origin: Some( + File( + "/editable.txt", + ), + ), + }, ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/editable", - query: None, - fragment: None, - }, - given: Some( - "./editable", - ), - }, - extras: [], - marker: Some( - And( - [ - Expression( - Version { - key: PythonVersion, - specifier: VersionSpecifier { - operator: GreaterThanEqual, - version: "3.9", + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, }, + path: "/editable", + editable: true, }, ), - Expression( - String { - key: OsName, - operator: Equal, - value: "posix", + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, }, + given: Some( + "./editable", + ), + }, + }, + extras: [], + marker: Some( + And( + [ + Expression( + Version { + key: PythonVersion, + specifier: VersionSpecifier { + operator: GreaterThanEqual, + version: "3.9", + }, + }, + ), + Expression( + String { + key: OsName, + operator: Equal, + value: "posix", + }, + ), + ], ), - ], - ), - ), - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), + ), + origin: Some( + File( + "/editable.txt", + ), + ), + }, ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "/editable;%20python_version%20%3E=%20%223.9%22%20and%20os_name%20==%20%22posix%22", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable[d", + query: None, + fragment: None, + }, + path: "/editable[d", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable[d", + query: None, + fragment: None, + }, + given: Some( + "./editable[d", + ), + }, + }, + extras: [], + marker: None, + origin: Some( + File( + "/editable.txt", + ), + ), }, - given: Some( - "./editable; python_version >= \"3.9\" and os_name == \"posix\"", - ), - }, - extras: [], - marker: None, - path: "/editable; python_version >= \"3.9\" and os_name == \"posix\"", - origin: Some( - File( - "/editable.txt", - ), ), + hashes: [], }, ], index_url: None, diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-semicolon.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-semicolon.txt.snap new file mode 100644 index 000000000000..768f6fd9ff66 --- /dev/null +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-semicolon.txt.snap @@ -0,0 +1,19 @@ +--- +source: crates/requirements-txt/src/lib.rs +expression: actual +--- +RequirementsTxtFileError { + file: "/semicolon.txt", + error: Pep508 { + source: Pep508Error { + message: String( + "Missing space before ';', the end of the URL is ambiguous", + ), + start: 11, + len: 1, + input: "./editable; python_version >= \"3.9\" and os_name == \"posix\"", + }, + start: 50, + end: 108, + }, +} diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap index ca4a73218048..07f40e8d5290 100644 --- a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap @@ -6,252 +6,390 @@ RequirementsTxt { requirements: [], constraints: [], editables: [ - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "//editable", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, + }, + path: "/editable", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, + }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", + ), + ], + marker: None, + origin: Some( + File( + "/editable.txt", + ), + ), }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: None, - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "//editable", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, + }, + path: "/editable", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, + }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", + ), + ], + marker: None, + origin: Some( + File( + "/editable.txt", + ), + ), }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: None, - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "//editable", - query: None, - fragment: None, - }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: Some( - And( - [ - Expression( - Version { - key: PythonVersion, - specifier: VersionSpecifier { - operator: GreaterThanEqual, - version: "3.9", + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, }, + path: "/editable", + editable: true, }, ), - Expression( - String { - key: OsName, - operator: Equal, - value: "posix", + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", ), ], - ), - ), - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), + marker: Some( + And( + [ + Expression( + Version { + key: PythonVersion, + specifier: VersionSpecifier { + operator: GreaterThanEqual, + version: "3.9", + }, + }, + ), + Expression( + String { + key: OsName, + operator: Equal, + value: "posix", + }, + ), + ], + ), + ), + origin: Some( + File( + "/editable.txt", + ), + ), + }, ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "//editable", - query: None, - fragment: None, - }, - given: Some( - "./editable", - ), - }, - extras: [ - ExtraName( - "d", - ), - ExtraName( - "dev", - ), - ], - marker: Some( - And( - [ - Expression( - Version { - key: PythonVersion, - specifier: VersionSpecifier { - operator: GreaterThanEqual, - version: "3.9", + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, }, + path: "/editable", + editable: true, }, ), - Expression( - String { - key: OsName, - operator: Equal, - value: "posix", + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, }, + given: Some( + "./editable", + ), + }, + }, + extras: [ + ExtraName( + "d", + ), + ExtraName( + "dev", ), ], - ), - ), - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), + marker: Some( + And( + [ + Expression( + Version { + key: PythonVersion, + specifier: VersionSpecifier { + operator: GreaterThanEqual, + version: "3.9", + }, + }, + ), + Expression( + String { + key: OsName, + operator: Equal, + value: "posix", + }, + ), + ], + ), + ), + origin: Some( + File( + "/editable.txt", + ), + ), + }, ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "//editable", - query: None, - fragment: None, - }, - given: Some( - "./editable", - ), - }, - extras: [], - marker: Some( - And( - [ - Expression( - Version { - key: PythonVersion, - specifier: VersionSpecifier { - operator: GreaterThanEqual, - version: "3.9", + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, }, + path: "/editable", + editable: true, }, ), - Expression( - String { - key: OsName, - operator: Equal, - value: "posix", + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, }, + given: Some( + "./editable", + ), + }, + }, + extras: [], + marker: Some( + And( + [ + Expression( + Version { + key: PythonVersion, + specifier: VersionSpecifier { + operator: GreaterThanEqual, + version: "3.9", + }, + }, + ), + Expression( + String { + key: OsName, + operator: Equal, + value: "posix", + }, + ), + ], ), - ], - ), - ), - path: "/editable", - origin: Some( - File( - "/editable.txt", - ), + ), + origin: Some( + File( + "/editable.txt", + ), + ), + }, ), + hashes: [], }, - EditableRequirement { - url: VerbatimUrl { - url: Url { - scheme: "file", - cannot_be_a_base: false, - username: "", - password: None, - host: None, - port: None, - path: "//editable;%20python_version%20%3E=%20%223.9%22%20and%20os_name%20==%20%22posix%22", - query: None, - fragment: None, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable[d", + query: None, + fragment: None, + }, + path: "/editable[d", + editable: true, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable[d", + query: None, + fragment: None, + }, + given: Some( + "./editable[d", + ), + }, + }, + extras: [], + marker: None, + origin: Some( + File( + "/editable.txt", + ), + ), }, - given: Some( - "./editable; python_version >= \"3.9\" and os_name == \"posix\"", - ), - }, - extras: [], - marker: None, - path: "/editable; python_version >= \"3.9\" and os_name == \"posix\"", - origin: Some( - File( - "/editable.txt", - ), ), + hashes: [], }, ], index_url: None, diff --git a/crates/requirements-txt/test-data/requirements-txt/editable.txt b/crates/requirements-txt/test-data/requirements-txt/editable.txt index c28864116467..aaef8781619c 100644 --- a/crates/requirements-txt/test-data/requirements-txt/editable.txt +++ b/crates/requirements-txt/test-data/requirements-txt/editable.txt @@ -13,5 +13,5 @@ # OK -e ./editable ; python_version >= "3.9" and os_name == "posix" -# Disallowed (missing whitespace before colon) --e ./editable; python_version >= "3.9" and os_name == "posix" +# OK (unterminated) +-e ./editable[d diff --git a/crates/requirements-txt/test-data/requirements-txt/semicolon.txt b/crates/requirements-txt/test-data/requirements-txt/semicolon.txt new file mode 100644 index 000000000000..2a72af0224e6 --- /dev/null +++ b/crates/requirements-txt/test-data/requirements-txt/semicolon.txt @@ -0,0 +1,2 @@ +# Disallowed (missing whitespace before colon) +-e ./editable; python_version >= "3.9" and os_name == "posix" diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index d156318e871a..cdda0fb35e5d 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -40,9 +40,7 @@ use distribution_types::{ use pep508_rs::{UnnamedRequirement, UnnamedRequirementUrl}; use pypi_types::Requirement; use pypi_types::VerbatimParsedUrl; -use requirements_txt::{ - EditableRequirement, FindLink, RequirementEntry, RequirementsTxt, RequirementsTxtRequirement, -}; +use requirements_txt::{FindLink, RequirementsTxt, RequirementsTxtRequirement}; use uv_client::BaseClientBuilder; use uv_configuration::{NoBinary, NoBuild}; use uv_distribution::pyproject::PyProjectToml; @@ -91,20 +89,17 @@ impl RequirementsSpecification { let requirement = RequirementsTxtRequirement::parse(name, std::env::current_dir()?) .with_context(|| format!("Failed to parse: `{name}`"))?; Self { - requirements: vec![UnresolvedRequirementSpecification::from( - RequirementEntry { - requirement, - hashes: vec![], - }, - )], + requirements: vec![UnresolvedRequirementSpecification::from(requirement)], ..Self::default() } } RequirementsSource::Editable(name) => { - let requirement = EditableRequirement::parse(name, None, std::env::current_dir()?) + let requirement = RequirementsTxtRequirement::parse(name, std::env::current_dir()?) .with_context(|| format!("Failed to parse: `{name}`"))?; Self { - requirements: vec![UnresolvedRequirementSpecification::from(requirement)], + requirements: vec![UnresolvedRequirementSpecification::from( + requirement.into_editable()?, + )], ..Self::default() } } diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 1320a09e4ac8..fcaa0f472b3f 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -1044,6 +1044,161 @@ fn install_editable_incompatible_constraint_url() -> Result<()> { Ok(()) } +#[test] +fn install_editable_pep_508_requirements_txt() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(&indoc::formatdoc! {r" + -e black[d] @ file://{workspace_root}/scripts/packages/black_editable + ", + workspace_root = context.workspace_root.simplified_display(), + })?; + + uv_snapshot!(context.filters(), context.install() + .arg("-r") + .arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + Downloaded 8 packages in [TIME] + Installed 8 packages in [TIME] + + aiohttp==3.9.3 + + aiosignal==1.3.1 + + attrs==23.2.0 + + black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + + frozenlist==1.4.1 + + idna==3.6 + + multidict==6.0.5 + + yarl==1.9.4 + "### + ); + + Ok(()) +} + +#[test] +fn install_editable_pep_508_cli() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.install() + .arg("-e") + .arg(format!("black[d] @ file://{workspace_root}/scripts/packages/black_editable", workspace_root = context.workspace_root.simplified_display())), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + Downloaded 8 packages in [TIME] + Installed 8 packages in [TIME] + + aiohttp==3.9.3 + + aiosignal==1.3.1 + + attrs==23.2.0 + + black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + + frozenlist==1.4.1 + + idna==3.6 + + multidict==6.0.5 + + yarl==1.9.4 + "### + ); +} + +#[test] +fn invalid_editable_no_version() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("-e black")?; + + uv_snapshot!(context.filters(), context.install() + .arg("-r") + .arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Unsupported editable requirement in `requirements.txt` + Caused by: Editable `black` must refer to a local directory + "### + ); + + Ok(()) +} + +#[test] +fn invalid_editable_no_url() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("-e black==0.1.0")?; + + uv_snapshot!(context.filters(), context.install() + .arg("-r") + .arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Unsupported editable requirement in `requirements.txt` + Caused by: Editable `black` must refer to a local directory, not a versioned package + "### + ); + + Ok(()) +} + +#[test] +fn invalid_editable_unnamed_https_url() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("-e https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl")?; + + uv_snapshot!(context.filters(), context.install() + .arg("-r") + .arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Unsupported editable requirement in `requirements.txt` + Caused by: Editable must refer to a local directory, not an HTTPS URL: `https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl` + "### + ); + + Ok(()) +} + +#[test] +fn invalid_editable_named_https_url() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("-e black @ https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl")?; + + uv_snapshot!(context.filters(), context.install() + .arg("-r") + .arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Unsupported editable requirement in `requirements.txt` + Caused by: Editable `black` must refer to a local directory, not an HTTPS URL: `https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl` + "### + ); + + Ok(()) +} + /// Install a source distribution that uses the `flit` build system, along with `flit` /// at the top-level, along with `--reinstall` to force a re-download after resolution, to ensure /// that the `flit` install and the source distribution build don't conflict.