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;
#[cfg(feature = "non-pep508-extensions")]
pub use crate::unnamed::{UnnamedRequirement, UnnamedRequirementUrl};
pub use crate::unnamed::{PathHint, UnnamedRequirement, UnnamedRequirementUrl};
pub use crate::verbatim_url::{
Scheme, VerbatimUrl, VerbatimUrlError, expand_env_vars, looks_like_git_repository,
split_scheme, strip_host,

View File

@ -13,6 +13,15 @@ use crate::{
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.
///
/// 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>)
-> 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.
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.
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>,
reporter: &mut impl Reporter,
) -> 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),
Some(working_dir.as_ref()),
reporter,
hint,
)
}
}
@ -155,11 +211,22 @@ fn parse_unnamed_requirement<Url: UnnamedRequirementUrl>(
cursor: &mut Cursor,
working_dir: Option<&Path>,
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>> {
cursor.eat_whitespace();
// 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*
cursor.eat_whitespace();
@ -213,6 +280,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
cursor: &Cursor,
start: usize,
len: usize,
#[cfg_attr(not(feature = "non-pep508-extensions"), allow(unused))] hint: Option<PathHint>,
) -> Result<(Url, Vec<ExtraName>), Pep508Error<Url>> {
// 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
@ -251,7 +319,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
#[cfg(feature = "non-pep508-extensions")]
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 {
message: Pep508ErrorSource::UrlError(err),
start,
@ -262,7 +330,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
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 {
message: Pep508ErrorSource::UrlError(err),
start,
@ -289,7 +357,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
// Ex) `C:\Users\ferris\wheel-0.42.0.tar.gz`
_ => {
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 {
message: Pep508ErrorSource::UrlError(err),
start,
@ -300,7 +368,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
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 {
message: Pep508ErrorSource::UrlError(err),
start,
@ -314,7 +382,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
} else {
// Ex) `../editable/`
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 {
message: Pep508ErrorSource::UrlError(err),
start,
@ -325,7 +393,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
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 {
message: Pep508ErrorSource::UrlError(err),
start,
@ -357,6 +425,7 @@ fn preprocess_unnamed_url<Url: UnnamedRequirementUrl>(
fn parse_unnamed_url<Url: UnnamedRequirementUrl>(
cursor: &mut Cursor,
working_dir: Option<&Path>,
hint: Option<PathHint>,
) -> Result<(Url, Vec<ExtraName>), Pep508Error<Url>> {
// wsp*
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)
}

View File

@ -8,7 +8,8 @@ use uv_cache_key::{CacheKey, CacheKeyHasher};
use uv_distribution_filename::{DistExtension, ExtensionError};
use uv_git_types::{GitUrl, GitUrlParseError};
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};
@ -79,11 +80,26 @@ impl UnnamedRequirementUrl for VerbatimParsedUrl {
fn parse_path(
path: 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> {
let verbatim = VerbatimUrl::from_path(&path, &working_dir)?;
let verbatim_path = verbatim.as_path()?;
let is_dir = if let Ok(metadata) = verbatim_path.metadata() {
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 {
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> {
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_path = verbatim.as_path()?;
let is_dir = if let Ok(metadata) = verbatim_path.metadata() {
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 {
verbatim_path.extension().is_none()
};

View File

@ -1643,6 +1643,10 @@ mod test {
#[cfg(unix)]
#[test_case(Path::new("bare-url.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]
async fn parse_unix(path: &Path) {
let working_dir = workspace_test_data_dir().join("requirements-txt");
@ -1662,7 +1666,6 @@ mod test {
}
#[cfg(unix)]
#[test_case(Path::new("semicolon.txt"))]
#[test_case(Path::new("hash.txt"))]
#[tokio::test]
async fn parse_err(path: &Path) {

View File

@ -3,7 +3,8 @@ use std::path::Path;
use uv_normalize::PackageName;
use uv_pep508::{
Pep508Error, Pep508ErrorSource, RequirementOrigin, TracingReporter, UnnamedRequirement,
PathHint, Pep508Error, Pep508ErrorSource, RequirementOrigin, TracingReporter,
UnnamedRequirement,
};
use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl, VerbatimParsedUrl};
@ -132,15 +133,26 @@ impl RequirementsTxtRequirement {
working_dir: impl AsRef<Path>,
editable: bool,
) -> 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.
match uv_pep508::Requirement::parse(input, &working_dir) {
Ok(requirement) => {
// As a special-case, interpret `dagster` as `./dagster` if we're in editable mode.
if editable && requirement.version_or_url.is_none() {
Ok(Self::Unnamed(UnnamedRequirement::parse(
Ok(Self::Unnamed(UnnamedRequirement::parse_with_hint(
input,
&working_dir,
&mut TracingReporter,
hint,
)?))
} else {
Ok(Self::Named(requirement))
@ -149,10 +161,11 @@ impl RequirementsTxtRequirement {
Err(err) => match err.message {
Pep508ErrorSource::UnsupportedRequirement(_) => {
// If that fails, attempt to parse as a direct URL requirement.
Ok(Self::Unnamed(UnnamedRequirement::parse(
Ok(Self::Unnamed(UnnamedRequirement::parse_with_hint(
input,
&working_dir,
&mut TracingReporter,
hint,
)?))
}
_ => Err(err),

View File

@ -2,21 +2,67 @@
source: crates/uv-requirements-txt/src/lib.rs
expression: actual
---
RequirementsTxtFileError {
file: "<REQUIREMENTS_DIR>/semicolon.txt",
error: Pep508 {
source: Pep508Error {
message: UrlError(
MissingExtensionPath(
"./editable;python_version >= \"3.9\" and os_name == \"posix\"",
Dist,
),
RequirementsTxt {
requirements: [],
constraints: [],
editables: [
RequirementEntry {
requirement: Unnamed(
UnnamedRequirement {
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,
len: 57,
input: "./editable;python_version >= \"3.9\" and os_name == \"posix\"",
hashes: [],
},
start: 50,
end: 107,
},
],
index_url: None,
extra_index_urls: [],
find_links: [],
no_index: false,
no_binary: None,
only_binary: None,
}