Always treat paths after -e as directories

This commit is contained in:
Charlie Marsh 2025-12-02 22:21:54 -08:00
parent 20ab80ad8f
commit 2688a12609
6 changed files with 191 additions and 30 deletions

View File

@ -40,7 +40,7 @@ pub use crate::marker::{
}; };
pub use crate::origin::RequirementOrigin; pub use crate::origin::RequirementOrigin;
#[cfg(feature = "non-pep508-extensions")] #[cfg(feature = "non-pep508-extensions")]
pub use crate::unnamed::{UnnamedRequirement, UnnamedRequirementUrl}; pub use crate::unnamed::{PathHint, UnnamedRequirement, UnnamedRequirementUrl};
pub use crate::verbatim_url::{ pub use crate::verbatim_url::{
Scheme, VerbatimUrl, VerbatimUrlError, expand_env_vars, looks_like_git_repository, Scheme, VerbatimUrl, VerbatimUrlError, expand_env_vars, looks_like_git_repository,
split_scheme, strip_host, split_scheme, strip_host,

View File

@ -13,6 +13,15 @@ use crate::{
parse_extras_cursor, split_extras, split_scheme, strip_host, parse_extras_cursor, split_extras, split_scheme, strip_host,
}; };
/// A hint about whether a path refers to a file or directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathHint {
/// The path is known to be a file (e.g., a wheel or source distribution).
File,
/// The path is known to be a directory (e.g., an editable install).
Directory,
}
/// An extension over [`Pep508Url`] that also supports parsing unnamed requirements, namely paths. /// An extension over [`Pep508Url`] that also supports parsing unnamed requirements, namely paths.
/// ///
/// The error type is fixed to the same as the [`Pep508Url`] impl error. /// The error type is fixed to the same as the [`Pep508Url`] impl error.
@ -21,9 +30,40 @@ pub trait UnnamedRequirementUrl: Pep508Url {
fn parse_path(path: impl AsRef<Path>, working_dir: impl AsRef<Path>) fn parse_path(path: impl AsRef<Path>, working_dir: impl AsRef<Path>)
-> Result<Self, Self::Err>; -> Result<Self, Self::Err>;
/// Parse a URL from a relative or absolute path, with a hint about whether the path is a file
/// or directory.
///
/// The hint is used to determine whether the path should be parsed as a file or directory. If
/// no such hint is provided, we'll query for the path kind; if the path doesn't exist, it'll be
/// treated as a directory if and only if it is extensionless.
fn parse_path_with_hint(
path: impl AsRef<Path>,
working_dir: impl AsRef<Path>,
hint: Option<PathHint>,
) -> Result<Self, Self::Err> {
// Default implementation ignores the hint.
let _ = hint;
Self::parse_path(path, working_dir)
}
/// Parse a URL from an absolute path. /// Parse a URL from an absolute path.
fn parse_absolute_path(path: impl AsRef<Path>) -> Result<Self, Self::Err>; fn parse_absolute_path(path: impl AsRef<Path>) -> Result<Self, Self::Err>;
/// Parse a URL from an absolute path, with a hint about whether the path is a file or
/// directory.
///
/// The hint is used to determine whether the path should be parsed as a file or directory. If
/// no such hint is provided, we'll query for the path kind; if the path doesn't exist, it'll be
/// treated as a directory if and only if it is extensionless.
fn parse_absolute_path_with_hint(
path: impl AsRef<Path>,
hint: Option<PathHint>,
) -> Result<Self, Self::Err> {
// Default implementation ignores the hint.
let _ = hint;
Self::parse_absolute_path(path)
}
/// Parse a URL from a string. /// Parse a URL from a string.
fn parse_unnamed_url(given: impl AsRef<str>) -> Result<Self, Self::Err>; fn parse_unnamed_url(given: impl AsRef<str>) -> Result<Self, Self::Err>;
@ -110,10 +150,26 @@ impl<Url: UnnamedRequirementUrl> UnnamedRequirement<Url> {
working_dir: impl AsRef<Path>, working_dir: impl AsRef<Path>,
reporter: &mut impl Reporter, reporter: &mut impl Reporter,
) -> Result<Self, Pep508Error<Url>> { ) -> Result<Self, Pep508Error<Url>> {
parse_unnamed_requirement( Self::parse_with_hint(input, working_dir, reporter, None)
}
/// Parse a PEP 508-like direct URL requirement without a package name, with a hint about
/// whether the path is a file or directory.
///
/// The hint is used to determine whether the path should be parsed as a file or directory. If
/// no such hint is provided, we'll query for the path kind; if the path doesn't exist, it'll be
/// treated as a directory if and only if it is extensionless.
pub fn parse_with_hint(
input: &str,
working_dir: impl AsRef<Path>,
reporter: &mut impl Reporter,
hint: Option<PathHint>,
) -> Result<Self, Pep508Error<Url>> {
parse_unnamed_requirement_with_hint(
&mut Cursor::new(input), &mut Cursor::new(input),
Some(working_dir.as_ref()), Some(working_dir.as_ref()),
reporter, reporter,
hint,
) )
} }
} }
@ -155,11 +211,22 @@ fn parse_unnamed_requirement<Url: UnnamedRequirementUrl>(
cursor: &mut Cursor, cursor: &mut Cursor,
working_dir: Option<&Path>, working_dir: Option<&Path>,
reporter: &mut impl Reporter, reporter: &mut impl Reporter,
) -> Result<UnnamedRequirement<Url>, Pep508Error<Url>> {
parse_unnamed_requirement_with_hint(cursor, working_dir, reporter, None)
}
/// Parse a PEP 508-like direct URL specifier without a package name, with a hint about whether
/// the path is a file or directory.
fn parse_unnamed_requirement_with_hint<Url: UnnamedRequirementUrl>(
cursor: &mut Cursor,
working_dir: Option<&Path>,
reporter: &mut impl Reporter,
hint: Option<PathHint>,
) -> Result<UnnamedRequirement<Url>, Pep508Error<Url>> { ) -> Result<UnnamedRequirement<Url>, Pep508Error<Url>> {
cursor.eat_whitespace(); cursor.eat_whitespace();
// Parse the URL itself, along with any extras. // Parse the URL itself, along with any extras.
let (url, extras) = parse_unnamed_url::<Url>(cursor, working_dir)?; let (url, extras) = parse_unnamed_url::<Url>(cursor, working_dir, hint)?;
// wsp* // wsp*
cursor.eat_whitespace(); cursor.eat_whitespace();
@ -213,6 +280,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
cursor: &Cursor, cursor: &Cursor,
start: usize, start: usize,
len: usize, len: usize,
#[cfg_attr(not(feature = "non-pep508-extensions"), allow(unused))] hint: Option<PathHint>,
) -> Result<(Url, Vec<ExtraName>), Pep508Error<Url>> { ) -> Result<(Url, Vec<ExtraName>), Pep508Error<Url>> {
// Split extras _before_ expanding the URL. We assume that the extras are not environment // Split extras _before_ expanding the URL. We assume that the extras are not environment
// variables. If we parsed the extras after expanding the URL, then the verbatim representation // variables. If we parsed the extras after expanding the URL, then the verbatim representation
@ -251,7 +319,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
#[cfg(feature = "non-pep508-extensions")] #[cfg(feature = "non-pep508-extensions")]
if let Some(working_dir) = working_dir { if let Some(working_dir) = working_dir {
let url = Url::parse_path(path.as_ref(), working_dir) let url = Url::parse_path_with_hint(path.as_ref(), working_dir, hint)
.map_err(|err| Pep508Error { .map_err(|err| Pep508Error {
message: Pep508ErrorSource::UrlError(err), message: Pep508ErrorSource::UrlError(err),
start, start,
@ -262,7 +330,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
return Ok((url, extras)); return Ok((url, extras));
} }
let url = Url::parse_absolute_path(path.as_ref()) let url = Url::parse_absolute_path_with_hint(path.as_ref(), hint)
.map_err(|err| Pep508Error { .map_err(|err| Pep508Error {
message: Pep508ErrorSource::UrlError(err), message: Pep508ErrorSource::UrlError(err),
start, start,
@ -289,7 +357,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
// Ex) `C:\Users\ferris\wheel-0.42.0.tar.gz` // Ex) `C:\Users\ferris\wheel-0.42.0.tar.gz`
_ => { _ => {
if let Some(working_dir) = working_dir { if let Some(working_dir) = working_dir {
let url = Url::parse_path(expanded.as_ref(), working_dir) let url = Url::parse_path_with_hint(expanded.as_ref(), working_dir, hint)
.map_err(|err| Pep508Error { .map_err(|err| Pep508Error {
message: Pep508ErrorSource::UrlError(err), message: Pep508ErrorSource::UrlError(err),
start, start,
@ -300,7 +368,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
return Ok((url, extras)); return Ok((url, extras));
} }
let url = Url::parse_absolute_path(expanded.as_ref()) let url = Url::parse_absolute_path_with_hint(expanded.as_ref(), hint)
.map_err(|err| Pep508Error { .map_err(|err| Pep508Error {
message: Pep508ErrorSource::UrlError(err), message: Pep508ErrorSource::UrlError(err),
start, start,
@ -314,7 +382,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
} else { } else {
// Ex) `../editable/` // Ex) `../editable/`
if let Some(working_dir) = working_dir { if let Some(working_dir) = working_dir {
let url = Url::parse_path(expanded.as_ref(), working_dir) let url = Url::parse_path_with_hint(expanded.as_ref(), working_dir, hint)
.map_err(|err| Pep508Error { .map_err(|err| Pep508Error {
message: Pep508ErrorSource::UrlError(err), message: Pep508ErrorSource::UrlError(err),
start, start,
@ -325,7 +393,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
return Ok((url, extras)); return Ok((url, extras));
} }
let url = Url::parse_absolute_path(expanded.as_ref()) let url = Url::parse_absolute_path_with_hint(expanded.as_ref(), hint)
.map_err(|err| Pep508Error { .map_err(|err| Pep508Error {
message: Pep508ErrorSource::UrlError(err), message: Pep508ErrorSource::UrlError(err),
start, start,
@ -357,6 +425,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
fn parse_unnamed_url<Url: UnnamedRequirementUrl>( fn parse_unnamed_url<Url: UnnamedRequirementUrl>(
cursor: &mut Cursor, cursor: &mut Cursor,
working_dir: Option<&Path>, working_dir: Option<&Path>,
hint: Option<PathHint>,
) -> Result<(Url, Vec<ExtraName>), Pep508Error<Url>> { ) -> Result<(Url, Vec<ExtraName>), Pep508Error<Url>> {
// wsp* // wsp*
cursor.eat_whitespace(); cursor.eat_whitespace();
@ -413,7 +482,7 @@ fn parse_unnamed_url<Url: UnnamedRequirementUrl>(
}); });
} }
let url = preprocess_unnamed_url(url, working_dir, cursor, start, len)?; let url = preprocess_unnamed_url(url, working_dir, cursor, start, len, hint)?;
Ok(url) Ok(url)
} }

View File

@ -8,7 +8,8 @@ use uv_cache_key::{CacheKey, CacheKeyHasher};
use uv_distribution_filename::{DistExtension, ExtensionError}; use uv_distribution_filename::{DistExtension, ExtensionError};
use uv_git_types::{GitUrl, GitUrlParseError}; use uv_git_types::{GitUrl, GitUrlParseError};
use uv_pep508::{ use uv_pep508::{
Pep508Url, UnnamedRequirementUrl, VerbatimUrl, VerbatimUrlError, looks_like_git_repository, PathHint, Pep508Url, UnnamedRequirementUrl, VerbatimUrl, VerbatimUrlError,
looks_like_git_repository,
}; };
use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
@ -79,11 +80,26 @@ impl UnnamedRequirementUrl for VerbatimParsedUrl {
fn parse_path( fn parse_path(
path: impl AsRef<Path>, path: impl AsRef<Path>,
working_dir: impl AsRef<Path>, working_dir: impl AsRef<Path>,
) -> Result<Self, Self::Err> {
Self::parse_path_with_hint(path, working_dir, None)
}
fn parse_path_with_hint(
path: impl AsRef<Path>,
working_dir: impl AsRef<Path>,
hint: Option<PathHint>,
) -> Result<Self, Self::Err> { ) -> Result<Self, Self::Err> {
let verbatim = VerbatimUrl::from_path(&path, &working_dir)?; let verbatim = VerbatimUrl::from_path(&path, &working_dir)?;
let verbatim_path = verbatim.as_path()?; let verbatim_path = verbatim.as_path()?;
let is_dir = if let Ok(metadata) = verbatim_path.metadata() { let is_dir = if let Ok(metadata) = verbatim_path.metadata() {
metadata.is_dir() metadata.is_dir()
} else if DistExtension::from_path(&path).is_ok() {
// If the path has a recognized distribution extension (like `.whl` or `.tar.gz`),
// treat it as a file regardless of the hint.
false
} else if let Some(hint) = hint {
// Use the hint for ambiguous paths (paths with unrecognized extensions like `.bar`).
hint == PathHint::Directory
} else { } else {
verbatim_path.extension().is_none() verbatim_path.extension().is_none()
}; };
@ -112,10 +128,24 @@ impl UnnamedRequirementUrl for VerbatimParsedUrl {
} }
fn parse_absolute_path(path: impl AsRef<Path>) -> Result<Self, Self::Err> { fn parse_absolute_path(path: impl AsRef<Path>) -> Result<Self, Self::Err> {
Self::parse_absolute_path_with_hint(path, None)
}
fn parse_absolute_path_with_hint(
path: impl AsRef<Path>,
hint: Option<PathHint>,
) -> Result<Self, Self::Err> {
let verbatim = VerbatimUrl::from_absolute_path(&path)?; let verbatim = VerbatimUrl::from_absolute_path(&path)?;
let verbatim_path = verbatim.as_path()?; let verbatim_path = verbatim.as_path()?;
let is_dir = if let Ok(metadata) = verbatim_path.metadata() { let is_dir = if let Ok(metadata) = verbatim_path.metadata() {
metadata.is_dir() metadata.is_dir()
} else if DistExtension::from_path(&path).is_ok() {
// If the path has a recognized distribution extension (like `.whl` or `.tar.gz`),
// treat it as a file regardless of the hint.
false
} else if let Some(hint) = hint {
// Use the hint for ambiguous paths (paths with unrecognized extensions like `.bar`).
hint == PathHint::Directory
} else { } else {
verbatim_path.extension().is_none() verbatim_path.extension().is_none()
}; };

View File

@ -1643,6 +1643,10 @@ mod test {
#[cfg(unix)] #[cfg(unix)]
#[test_case(Path::new("bare-url.txt"))] #[test_case(Path::new("bare-url.txt"))]
#[test_case(Path::new("editable.txt"))] #[test_case(Path::new("editable.txt"))]
// Note: `semicolon.txt` contains a syntax error (missing whitespace before `;`), but since
// it's an editable requirement, we parse it as a directory path. The error will occur later
// when the path doesn't exist.
#[test_case(Path::new("semicolon.txt"))]
#[tokio::test] #[tokio::test]
async fn parse_unix(path: &Path) { async fn parse_unix(path: &Path) {
let working_dir = workspace_test_data_dir().join("requirements-txt"); let working_dir = workspace_test_data_dir().join("requirements-txt");
@ -1662,7 +1666,6 @@ mod test {
} }
#[cfg(unix)] #[cfg(unix)]
#[test_case(Path::new("semicolon.txt"))]
#[test_case(Path::new("hash.txt"))] #[test_case(Path::new("hash.txt"))]
#[tokio::test] #[tokio::test]
async fn parse_err(path: &Path) { async fn parse_err(path: &Path) {

View File

@ -3,7 +3,8 @@ use std::path::Path;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pep508::{ use uv_pep508::{
Pep508Error, Pep508ErrorSource, RequirementOrigin, TracingReporter, UnnamedRequirement, PathHint, Pep508Error, Pep508ErrorSource, RequirementOrigin, TracingReporter,
UnnamedRequirement,
}; };
use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl, VerbatimParsedUrl}; use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl, VerbatimParsedUrl};
@ -132,15 +133,26 @@ impl RequirementsTxtRequirement {
working_dir: impl AsRef<Path>, working_dir: impl AsRef<Path>,
editable: bool, editable: bool,
) -> Result<Self, Box<Pep508Error<VerbatimParsedUrl>>> { ) -> Result<Self, Box<Pep508Error<VerbatimParsedUrl>>> {
// When parsing an editable requirement, hint that the path is a directory. Editable
// requirements always refer to local directories (containing a `pyproject.toml` or
// `setup.py`), so when the path doesn't exist, we should assume it's a directory rather
// than a file with an unrecognized extension (like `foo.bar`).
let hint = if editable {
Some(PathHint::Directory)
} else {
None
};
// Attempt to parse as a PEP 508-compliant requirement. // Attempt to parse as a PEP 508-compliant requirement.
match uv_pep508::Requirement::parse(input, &working_dir) { match uv_pep508::Requirement::parse(input, &working_dir) {
Ok(requirement) => { Ok(requirement) => {
// As a special-case, interpret `dagster` as `./dagster` if we're in editable mode. // As a special-case, interpret `dagster` as `./dagster` if we're in editable mode.
if editable && requirement.version_or_url.is_none() { if editable && requirement.version_or_url.is_none() {
Ok(Self::Unnamed(UnnamedRequirement::parse( Ok(Self::Unnamed(UnnamedRequirement::parse_with_hint(
input, input,
&working_dir, &working_dir,
&mut TracingReporter, &mut TracingReporter,
hint,
)?)) )?))
} else { } else {
Ok(Self::Named(requirement)) Ok(Self::Named(requirement))
@ -149,10 +161,11 @@ impl RequirementsTxtRequirement {
Err(err) => match err.message { Err(err) => match err.message {
Pep508ErrorSource::UnsupportedRequirement(_) => { Pep508ErrorSource::UnsupportedRequirement(_) => {
// If that fails, attempt to parse as a direct URL requirement. // If that fails, attempt to parse as a direct URL requirement.
Ok(Self::Unnamed(UnnamedRequirement::parse( Ok(Self::Unnamed(UnnamedRequirement::parse_with_hint(
input, input,
&working_dir, &working_dir,
&mut TracingReporter, &mut TracingReporter,
hint,
)?)) )?))
} }
_ => Err(err), _ => Err(err),

View File

@ -2,21 +2,67 @@
source: crates/uv-requirements-txt/src/lib.rs source: crates/uv-requirements-txt/src/lib.rs
expression: actual expression: actual
--- ---
RequirementsTxtFileError { RequirementsTxt {
file: "<REQUIREMENTS_DIR>/semicolon.txt", requirements: [],
error: Pep508 { constraints: [],
source: Pep508Error { editables: [
message: UrlError( RequirementEntry {
MissingExtensionPath( requirement: Unnamed(
"./editable;python_version >= \"3.9\" and os_name == \"posix\"", UnnamedRequirement {
Dist, url: VerbatimParsedUrl {
), parsed_url: Directory(
ParsedDirectoryUrl {
url: DisplaySafeUrl {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/editable;python_version%20%3E=%20%223.9%22%20and%20os_name%20==%20%22posix%22",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/editable;python_version >= \"3.9\" and os_name == \"posix\"",
editable: Some(
true,
),
virtual: None,
},
),
verbatim: VerbatimUrl {
url: DisplaySafeUrl {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/editable;python_version%20%3E=%20%223.9%22%20and%20os_name%20==%20%22posix%22",
query: None,
fragment: None,
},
given: Some(
"./editable;python_version >= \"3.9\" and os_name == \"posix\"",
),
},
},
extras: [],
marker: true,
origin: Some(
File(
"<REQUIREMENTS_DIR>/semicolon.txt",
),
),
},
), ),
start: 0, hashes: [],
len: 57,
input: "./editable;python_version >= \"3.9\" and os_name == \"posix\"",
}, },
start: 50, ],
end: 107, index_url: None,
}, extra_index_urls: [],
find_links: [],
no_index: false,
no_binary: None,
only_binary: None,
} }