diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index 17e1c7c671b1..0e36d2620262 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -849,12 +849,12 @@ impl FromStr for Version { /// `min, .devN, aN, bN, rcN, , .postN, max`. /// Its representation is thus: /// * The most significant 3 bits of Byte 2 corresponds to a value in -/// the range 0-7 inclusive, corresponding to min, dev, pre-a, pre-b, pre-rc, -/// no-suffix or post releases, respectively. `min` is a special version that -/// does not exist in PEP 440, but is used here to represent the smallest -/// possible version, preceding any `dev`, `pre`, `post` or releases. `max` is -/// an analogous concept for the largest possible version, following any `post` -/// or local releases. +/// the range 0-7 inclusive, corresponding to min, dev, pre-a, pre-b, +/// pre-rc, no-suffix, post or max releases, respectively. `min` is a +/// special version that does not exist in PEP 440, but is used here to +/// represent the smallest possible version, preceding any `dev`, `pre`, +/// `post` or releases. `max` is an analogous concept for the largest +/// possible version, following any `post` or local releases. /// * The low 5 bits combined with the bits in bytes 1 and 0 correspond /// to the release number of the suffix, if one exists. If there is no /// suffix, then these bits are always 0. @@ -913,6 +913,20 @@ struct VersionSmall { } impl VersionSmall { + // Constants for each suffix kind. They form an enumeration. + // + // The specific values are assigned in a way that provides the suffix kinds + // their ordering. i.e., No suffix should sort after a dev suffix but + // before a post suffix. + // + // The maximum possible suffix value is SUFFIX_KIND_MASK. If you need to + // add another suffix value and you're at the max, then the mask must gain + // another bit. And adding another bit to the mask will require taking it + // from somewhere else. (Usually the suffix version.) + // + // NOTE: If you do change the bit format here, you'll need to bump any + // cache versions in uv that use rkyv with `Version` in them. That includes + // *at least* the "simple" cache. const SUFFIX_MIN: u64 = 0; const SUFFIX_DEV: u64 = 1; const SUFFIX_PRE_ALPHA: u64 = 2; @@ -921,12 +935,29 @@ impl VersionSmall { const SUFFIX_NONE: u64 = 5; const SUFFIX_POST: u64 = 6; const SUFFIX_MAX: u64 = 7; - const SUFFIX_MAX_VERSION: u64 = 0x001F_FFFF; + + // The mask to get only the release segment bits. + // + // NOTE: If you change the release mask to have more or less bits, + // then you'll also need to change `push_release` below and also + // `Parser::parse_fast`. + const SUFFIX_RELEASE_MASK: u64 = 0xFFFF_FFFF_FF00_0000; + // The mask to get the version suffix. + const SUFFIX_VERSION_MASK: u64 = 0x001F_FFFF; + // The number of bits used by the version suffix. Shifting the `repr` + // right by this number of bits should put the suffix kind in the least + // significant bits. + const SUFFIX_VERSION_BIT_LEN: u64 = 21; + // The mask to get only the suffix kind, after shifting right by the + // version bits. If you need to add a bit here, then you'll probably need + // to take a bit from the suffix version. (Which requires a change to both + // the mask and the bit length above.) + const SUFFIX_KIND_MASK: u64 = 0b111; #[inline] fn new() -> Self { Self { - repr: 0x0000_0000_00A0_0000, + repr: Self::SUFFIX_NONE << Self::SUFFIX_VERSION_BIT_LEN, release: [0, 0, 0, 0], len: 0, } @@ -954,7 +985,7 @@ impl VersionSmall { #[inline] fn clear_release(&mut self) { - self.repr &= !0xFFFF_FFFF_FF00_0000; + self.repr &= !Self::SUFFIX_RELEASE_MASK; self.release = [0, 0, 0, 0]; self.len = 0; } @@ -1007,7 +1038,7 @@ impl VersionSmall { self.set_suffix_kind(Self::SUFFIX_NONE); } Some(number) => { - if number > Self::SUFFIX_MAX_VERSION { + if number > Self::SUFFIX_VERSION_MASK { return false; } self.set_suffix_kind(Self::SUFFIX_POST); @@ -1054,7 +1085,7 @@ impl VersionSmall { self.set_suffix_kind(Self::SUFFIX_NONE); } Some(Prerelease { kind, number }) => { - if number > Self::SUFFIX_MAX_VERSION { + if number > Self::SUFFIX_VERSION_MASK { return false; } match kind { @@ -1097,7 +1128,7 @@ impl VersionSmall { self.set_suffix_kind(Self::SUFFIX_NONE); } Some(number) => { - if number > Self::SUFFIX_MAX_VERSION { + if number > Self::SUFFIX_VERSION_MASK { return false; } self.set_suffix_kind(Self::SUFFIX_DEV); @@ -1130,7 +1161,7 @@ impl VersionSmall { self.set_suffix_kind(Self::SUFFIX_NONE); } Some(number) => { - if number > Self::SUFFIX_MAX_VERSION { + if number > Self::SUFFIX_VERSION_MASK { return false; } self.set_suffix_kind(Self::SUFFIX_MIN); @@ -1163,7 +1194,7 @@ impl VersionSmall { self.set_suffix_kind(Self::SUFFIX_NONE); } Some(number) => { - if number > Self::SUFFIX_MAX_VERSION { + if number > Self::SUFFIX_VERSION_MASK { return false; } self.set_suffix_kind(Self::SUFFIX_MAX); @@ -1183,7 +1214,7 @@ impl VersionSmall { #[inline] fn suffix_kind(&self) -> u64 { - let kind = (self.repr >> 21) & 0b111; + let kind = (self.repr >> Self::SUFFIX_VERSION_BIT_LEN) & Self::SUFFIX_KIND_MASK; debug_assert!(kind <= Self::SUFFIX_MAX); kind } @@ -1191,8 +1222,8 @@ impl VersionSmall { #[inline] fn set_suffix_kind(&mut self, kind: u64) { debug_assert!(kind <= Self::SUFFIX_MAX); - self.repr &= !0x00E0_0000; - self.repr |= kind << 21; + self.repr &= !(Self::SUFFIX_KIND_MASK << Self::SUFFIX_VERSION_BIT_LEN); + self.repr |= kind << Self::SUFFIX_VERSION_BIT_LEN; if kind == Self::SUFFIX_NONE { self.set_suffix_version(0); } @@ -1200,13 +1231,13 @@ impl VersionSmall { #[inline] fn suffix_version(&self) -> u64 { - self.repr & Self::SUFFIX_MAX_VERSION + self.repr & Self::SUFFIX_VERSION_MASK } #[inline] fn set_suffix_version(&mut self, value: u64) { - debug_assert!(value <= Self::SUFFIX_MAX_VERSION); - self.repr &= !Self::SUFFIX_MAX_VERSION; + debug_assert!(value <= Self::SUFFIX_VERSION_MASK); + self.repr &= !Self::SUFFIX_VERSION_MASK; self.repr |= value; } } @@ -1675,17 +1706,11 @@ impl<'a> Parser<'a> { *release.get_mut(usize::from(len))? = cur; len += 1; let small = VersionSmall { - // Clippy warns about no-ops like `(0x00 << 16)`, but I - // think it makes the bit logic much clearer, and makes it - // explicit that nothing was forgotten. - #[allow(clippy::identity_op)] repr: (u64::from(release[0]) << 48) | (u64::from(release[1]) << 40) | (u64::from(release[2]) << 32) | (u64::from(release[3]) << 24) - | (0xA0 << 16) - | (0x00 << 8) - | (0x00 << 0), + | (VersionSmall::SUFFIX_NONE << VersionSmall::SUFFIX_VERSION_BIT_LEN), release: [ u64::from(release[0]), u64::from(release[1]), diff --git a/crates/uv-pep508/src/lib.rs b/crates/uv-pep508/src/lib.rs index c1319b288653..d9683c5e759b 100644 --- a/crates/uv-pep508/src/lib.rs +++ b/crates/uv-pep508/src/lib.rs @@ -709,18 +709,17 @@ fn parse_url( len += c.len_utf8(); // If we see a top-level semicolon or hash followed by whitespace, we're done. - match c { - ';' if cursor.peek_char().is_some_and(char::is_whitespace) => { - break; - } - '#' if cursor.peek_char().is_some_and(char::is_whitespace) => { + if cursor.peek_char().is_some_and(|c| matches!(c, ';' | '#')) { + let mut cursor = cursor.clone(); + cursor.next(); + if cursor.peek_char().is_some_and(char::is_whitespace) { break; } - _ => {} } } (start, len) }; + let url = cursor.slice(start, len); if url.is_empty() { return Err(Pep508Error { @@ -927,8 +926,6 @@ fn parse_pep508_requirement( } }; - let requirement_end = cursor.pos(); - // If the requirement consists solely of a package name, and that name appears to be an archive, // treat it as a URL requirement, for consistency and security. (E.g., `requests-2.26.0.tar.gz` // is a valid Python package name, but we should treat it as a reference to a file.) @@ -959,25 +956,13 @@ fn parse_pep508_requirement( // wsp* cursor.eat_whitespace(); - if let Some((pos, char)) = cursor.next() { - if marker.is_none() { - if let Some(VersionOrUrl::Url(url)) = requirement_kind { - let url = url.to_string(); - for c in [';', '#'] { - if url.ends_with(c) { - return Err(Pep508Error { - message: Pep508ErrorSource::String(format!( - "Missing space before '{c}', the end of the URL is ambiguous" - )), - start: requirement_end - c.len_utf8(), - len: c.len_utf8(), - input: cursor.to_string(), - }); - } - } - } - } - let message = if marker.is_none() { + + if let Some((pos, char)) = cursor.next().filter(|(_, c)| *c != '#') { + let message = if char == '#' { + format!( + r#"Expected end of input or `;`, found `{char}`; comments must be preceded by a leading space"# + ) + } else if marker.is_none() { format!(r#"Expected end of input or `;`, found `{char}`"#) } else { format!(r#"Expected end of input, found `{char}`"#) diff --git a/crates/uv-pep508/src/tests.rs b/crates/uv-pep508/src/tests.rs index 8ac718847d86..f07553bf612f 100644 --- a/crates/uv-pep508/src/tests.rs +++ b/crates/uv-pep508/src/tests.rs @@ -545,18 +545,6 @@ fn error_extras_not_closed() { ); } -#[test] -fn error_no_space_after_url() { - assert_snapshot!( - parse_pep508_err(r"name @ https://example.com/; extra == 'example'"), - @r#" - Missing space before ';', the end of the URL is ambiguous - name @ https://example.com/; extra == 'example' - ^ - "# - ); -} - #[test] fn error_name_at_nothing() { assert_snapshot!( diff --git a/crates/uv-pep508/src/unnamed.rs b/crates/uv-pep508/src/unnamed.rs index 1f97f50c198d..2e4361b940cc 100644 --- a/crates/uv-pep508/src/unnamed.rs +++ b/crates/uv-pep508/src/unnamed.rs @@ -160,7 +160,6 @@ fn parse_unnamed_requirement( // Parse the URL itself, along with any extras. let (url, extras) = parse_unnamed_url::(cursor, working_dir)?; - let requirement_end = cursor.pos(); // wsp* cursor.eat_whitespace(); @@ -175,23 +174,11 @@ fn parse_unnamed_requirement( // wsp* cursor.eat_whitespace(); if let Some((pos, char)) = cursor.next() { - if marker.is_none() { - if let Some(given) = url.given() { - for c in [';', '#'] { - if given.ends_with(c) { - return Err(Pep508Error { - message: Pep508ErrorSource::String(format!( - "Missing space before '{c}', the end of the URL is ambiguous" - )), - start: requirement_end - c.len_utf8(), - len: c.len_utf8(), - input: cursor.to_string(), - }); - } - } - } - } - let message = if marker.is_none() { + let message = if char == '#' { + format!( + r#"Expected end of input or `;`, found `{char}`; comments must be preceded by a leading space"# + ) + } else if marker.is_none() { format!(r#"Expected end of input or `;`, found `{char}`"#) } else { format!(r#"Expected end of input, found `{char}`"#) @@ -405,15 +392,11 @@ fn parse_unnamed_url( len += c.len_utf8(); // If we see a top-level semicolon or hash followed by whitespace, we're done. - if depth == 0 { - match c { - ';' if cursor.peek_char().is_some_and(char::is_whitespace) => { - break; - } - '#' if cursor.peek_char().is_some_and(char::is_whitespace) => { - break; - } - _ => {} + if depth == 0 && cursor.peek_char().is_some_and(|c| matches!(c, ';' | '#')) { + let mut cursor = cursor.clone(); + cursor.next(); + if cursor.peek_char().is_some_and(char::is_whitespace) { + break; } } } diff --git a/crates/uv-pep508/src/verbatim_url.rs b/crates/uv-pep508/src/verbatim_url.rs index 5d2f62597cbd..467bea6a0545 100644 --- a/crates/uv-pep508/src/verbatim_url.rs +++ b/crates/uv-pep508/src/verbatim_url.rs @@ -413,20 +413,12 @@ fn split_fragment(path: &Path) -> (Cow, Option<&str>) { } /// A supported URL scheme for PEP 508 direct-URL requirements. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Scheme { /// `file://...` File, - /// `git+git://...` - GitGit, - /// `git+http://...` - GitHttp, - /// `git+file://...` - GitFile, - /// `git+ssh://...` - GitSsh, - /// `git+https://...` - GitHttps, + /// `git+{transport}://...` as git supports arbitrary transports through gitremote-helpers + Git(String), /// `bzr+http://...` BzrHttp, /// `bzr+https://...` @@ -470,13 +462,11 @@ pub enum Scheme { impl Scheme { /// Determine the [`Scheme`] from the given string, if possible. pub fn parse(s: &str) -> Option { + if let Some(("git", transport)) = s.split_once('+') { + return Some(Self::Git(transport.into())); + } match s { "file" => Some(Self::File), - "git+git" => Some(Self::GitGit), - "git+http" => Some(Self::GitHttp), - "git+file" => Some(Self::GitFile), - "git+ssh" => Some(Self::GitSsh), - "git+https" => Some(Self::GitHttps), "bzr+http" => Some(Self::BzrHttp), "bzr+https" => Some(Self::BzrHttps), "bzr+ssh" => Some(Self::BzrSsh), @@ -510,11 +500,7 @@ impl std::fmt::Display for Scheme { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::File => write!(f, "file"), - Self::GitGit => write!(f, "git+git"), - Self::GitHttp => write!(f, "git+http"), - Self::GitFile => write!(f, "git+file"), - Self::GitSsh => write!(f, "git+ssh"), - Self::GitHttps => write!(f, "git+https"), + Self::Git(transport) => write!(f, "git+{transport}"), Self::BzrHttp => write!(f, "bzr+http"), Self::BzrHttps => write!(f, "bzr+https"), Self::BzrSsh => write!(f, "bzr+ssh"), diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-editable.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-editable.txt.snap index 4971b1e2ed01..86f25edf6b0b 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-editable.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-editable.txt.snap @@ -334,6 +334,106 @@ RequirementsTxt { ), hashes: [], }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Directory( + ParsedDirectoryUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, + }, + install_path: "/editable", + editable: true, + virtual: false, + }, + ), + 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: true, + origin: Some( + File( + "/editable.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Directory( + ParsedDirectoryUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/editable", + query: None, + fragment: None, + }, + install_path: "/editable", + editable: true, + virtual: false, + }, + ), + 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: true, + origin: Some( + File( + "/editable.txt", + ), + ), + }, + ), + hashes: [], + }, ], index_url: None, extra_index_urls: [], diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-hash.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-hash.txt.snap index 3efe6a9a91b6..1d1da54c8129 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-hash.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-hash.txt.snap @@ -7,7 +7,7 @@ RequirementsTxtFileError { error: Pep508 { source: Pep508Error { message: String( - "Missing space before '#', the end of the URL is ambiguous", + "Expected end of input or `;`, found `#`; comments must be preceded by a leading space", ), start: 10, len: 1, diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-semicolon.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-semicolon.txt.snap index ef175cff2c49..310807dad52a 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-semicolon.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-unix-semicolon.txt.snap @@ -6,14 +6,17 @@ RequirementsTxtFileError { file: "/semicolon.txt", error: Pep508 { source: Pep508Error { - message: String( - "Missing space before ';', the end of the URL is ambiguous", + message: UrlError( + MissingExtensionPath( + "./editable;python_version >= \"3.9\" and os_name == \"posix\"", + Dist, + ), ), - start: 10, - len: 1, - input: "./editable; python_version >= \"3.9\" and os_name == \"posix\"", + start: 0, + len: 57, + input: "./editable;python_version >= \"3.9\" and os_name == \"posix\"", }, start: 50, - end: 108, + end: 107, }, } diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-windows-editable.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-windows-editable.txt.snap index e6d3f1daa6d8..12909f7e802a 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-windows-editable.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-windows-editable.txt.snap @@ -1,5 +1,5 @@ --- -source: crates/requirements-txt/src/lib.rs +source: crates/uv-requirements-txt/src/lib.rs expression: actual --- RequirementsTxt { @@ -334,6 +334,106 @@ RequirementsTxt { ), hashes: [], }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Directory( + ParsedDirectoryUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, + }, + install_path: "/editable", + editable: true, + virtual: false, + }, + ), + 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: true, + origin: Some( + File( + "/editable.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Directory( + ParsedDirectoryUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "//editable", + query: None, + fragment: None, + }, + install_path: "/editable", + editable: true, + virtual: false, + }, + ), + 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: true, + origin: Some( + File( + "/editable.txt", + ), + ), + }, + ), + hashes: [], + }, ], index_url: None, extra_index_urls: [], diff --git a/crates/uv-requirements-txt/test-data/requirements-txt/editable.txt b/crates/uv-requirements-txt/test-data/requirements-txt/editable.txt index aaef8781619c..75cbd1d114da 100644 --- a/crates/uv-requirements-txt/test-data/requirements-txt/editable.txt +++ b/crates/uv-requirements-txt/test-data/requirements-txt/editable.txt @@ -15,3 +15,9 @@ # OK (unterminated) -e ./editable[d + +# OK +-e ./editable # comment + +# OK +-e ./editable #comment diff --git a/crates/uv-requirements-txt/test-data/requirements-txt/semicolon.txt b/crates/uv-requirements-txt/test-data/requirements-txt/semicolon.txt index 2a72af0224e6..004cc36fc44b 100644 --- a/crates/uv-requirements-txt/test-data/requirements-txt/semicolon.txt +++ b/crates/uv-requirements-txt/test-data/requirements-txt/semicolon.txt @@ -1,2 +1,2 @@ # Disallowed (missing whitespace before colon) --e ./editable; python_version >= "3.9" and os_name == "posix" +-e ./editable;python_version >= "3.9" and os_name == "posix" diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index 80ce9fd16f62..5534dbaebe42 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -2,8 +2,6 @@ use std::env; use std::ops::Deref; use std::path::{Path, PathBuf}; -use tracing::debug; - use uv_fs::Simplified; use uv_static::EnvVars; use uv_warnings::warn_user; @@ -42,16 +40,16 @@ impl FilesystemOptions { let root = dir.join("uv"); let file = root.join("uv.toml"); - debug!("Searching for user configuration in: `{}`", file.display()); + tracing::debug!("Searching for user configuration in: `{}`", file.display()); match read_file(&file) { Ok(options) => { - debug!("Found user configuration in: `{}`", file.display()); + tracing::debug!("Found user configuration in: `{}`", file.display()); Ok(Some(Self(options))) } Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(_) if !dir.is_dir() => { // Ex) `XDG_CONFIG_HOME=/dev/null` - debug!( + tracing::debug!( "User configuration directory `{}` does not exist or is not a directory", dir.display() ); @@ -65,7 +63,7 @@ impl FilesystemOptions { let Some(file) = system_config_file() else { return Ok(None); }; - debug!("Found system configuration in: `{}`", file.display()); + tracing::debug!("Found system configuration in: `{}`", file.display()); Ok(Some(Self(read_file(&file)?))) } @@ -123,7 +121,7 @@ impl FilesystemOptions { } } - debug!("Found workspace configuration at `{}`", path.display()); + tracing::debug!("Found workspace configuration at `{}`", path.display()); return Ok(Some(Self(options))); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} @@ -138,21 +136,21 @@ impl FilesystemOptions { let pyproject: PyProjectToml = toml::from_str(&content) .map_err(|err| Error::PyprojectToml(path.user_display().to_string(), err))?; let Some(tool) = pyproject.tool else { - debug!( + tracing::debug!( "Skipping `pyproject.toml` in `{}` (no `[tool]` section)", dir.display() ); return Ok(None); }; let Some(options) = tool.uv else { - debug!( + tracing::debug!( "Skipping `pyproject.toml` in `{}` (no `[tool.uv]` section)", dir.display() ); return Ok(None); }; - debug!("Found workspace configuration at `{}`", path.display()); + tracing::debug!("Found workspace configuration at `{}`", path.display()); return Ok(Some(Self(options))); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} @@ -241,7 +239,14 @@ fn system_config_file() -> Option { // Fallback to `/etc/uv/uv.toml` if `XDG_CONFIG_DIRS` is not set or no valid // path is found. let candidate = Path::new("/etc/uv/uv.toml"); - candidate.is_file().then(|| candidate.to_path_buf()) + match candidate.try_exists() { + Ok(true) => Some(candidate.to_path_buf()), + Ok(false) => None, + Err(err) => { + tracing::warn!("Failed to query system configuration file: {err}"); + None + } + } } } diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index f0144b0706d1..8498a85fd8b9 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -643,7 +643,7 @@ impl InitProjectKind { let mut pyproject = pyproject_project(name, requires_python, author.as_ref(), no_readme); // Include additional project configuration for packaged applications - if package { + if package || build_backend.is_some() { // Since it'll be packaged, we can add a `[project.scripts]` entry pyproject.push('\n'); pyproject.push_str(&pyproject_project_scripts(name, name.as_str(), "main")); diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index 920f2125121b..a632d0eb8151 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -5638,3 +5638,53 @@ fn sanitize() -> Result<()> { Ok(()) } + +/// Allow semicolons attached to markers, as long as they're preceded by a space. +#[test] +fn semicolon_trailing_space() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements = context.temp_dir.child("requirements.txt"); + requirements.write_str("iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl; python_version > '3.10'")?; + + uv_snapshot!(context.pip_sync() + .arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 (from https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl) + "### + ); + + Ok(()) +} + +/// Treat a semicolon that's not whitespace-separated as a part of the URL. +#[test] +fn semicolon_no_space() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements = context.temp_dir.child("requirements.txt"); + requirements.write_str("iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl;python_version > '3.10'")?; + + uv_snapshot!(context.pip_sync() + .arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Couldn't parse requirement in `requirements.txt` at position 0 + Caused by: Expected direct URL (`https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl;python_version%20%3E%20'3.10'`) to end in a supported file extension: `.whl`, `.tar.gz`, `.zip`, `.tar.bz2`, `.tar.lz`, `.tar.lzma`, `.tar.xz`, `.tar.zst`, `.tar`, `.tbz`, `.tgz`, `.tlz`, or `.txz` + iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl;python_version > '3.10' + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + "### + ); + + Ok(()) +} diff --git a/docs/pip/compatibility.md b/docs/pip/compatibility.md index 1be873ba3760..dbfdeafb45c1 100644 --- a/docs/pip/compatibility.md +++ b/docs/pip/compatibility.md @@ -386,8 +386,8 @@ installs, pass the `--compile-bytecode` flag to `uv pip install` or `uv pip sync ## Strictness and spec enforcement uv tends to be stricter than `pip`, and will often reject packages that `pip` would install. For -example, uv omits packages with invalid version specifiers in its metadata, which `pip` similarly -plans to exclude in a [future release](https://github.com/pypa/pip/issues/12063). +example, uv rejects HTML indexes with invalid URL fragments (see: +[PEP 503](https://peps.python.org/pep-0503/)), while `pip` will ignore such fragments. In some cases, uv implements lenient behavior for popular packages that are known to have specific spec compliance issues.