Always treat archive-like requirements as local files (#7364)

## Summary

`uv pip install foo.tar.gz` will now always treat `foo.tar.gz` as a
local file. This matches pip's behavior.

Closes https://github.com/astral-sh/uv/issues/7309.
This commit is contained in:
Charlie Marsh 2024-09-13 12:01:25 -04:00 committed by GitHub
parent f82224124e
commit d9f53cc83f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 414 additions and 3 deletions

View file

@ -657,8 +657,13 @@ fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool {
// Expand any environment variables in the path.
let expanded = expand_env_vars(url);
// Strip extras.
let url = split_extras(&expanded)
.map(|(url, _)| url)
.unwrap_or(&expanded);
// Analyze the path.
let mut chars = expanded.chars();
let mut chars = url.chars();
let Some(first_char) = chars.next() else {
return false;
@ -670,18 +675,47 @@ fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool {
}
// Ex) `https://` or `C:`
if split_scheme(&expanded).is_some() {
if split_scheme(url).is_some() {
return true;
}
// Ex) `foo/bar`
if expanded.contains('/') || expanded.contains('\\') {
if url.contains('/') || url.contains('\\') {
return true;
}
// Ex) `foo.tar.gz`
if looks_like_archive(url) {
return true;
}
false
}
/// Returns `true` if a file looks like an archive.
///
/// See <https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_internal/utils/filetypes.py#L8>
/// for the list of supported archive extensions.
fn looks_like_archive(file: impl AsRef<Path>) -> bool {
let file = file.as_ref();
// E.g., `gz` in `foo.tar.gz`
let Some(extension) = file.extension().and_then(|ext| ext.to_str()) else {
return false;
};
// E.g., `tar` in `foo.tar.gz`
let pre_extension = file
.file_stem()
.and_then(|stem| Path::new(stem).extension().and_then(|ext| ext.to_str()));
matches!(
(pre_extension, extension),
(_, "whl" | "tbz" | "txz" | "tlz" | "zip" | "tgz" | "tar")
| (Some("tar"), "bz2" | "xz" | "lz" | "lzma" | "gz")
)
}
/// parses extras in the `[extra1,extra2] format`
fn parse_extras_cursor<T: Pep508Url>(
cursor: &mut Cursor,
@ -970,7 +1004,9 @@ fn parse_pep508_requirement<T: Pep508Url>(
// wsp*
cursor.eat_whitespace();
// name
let name_start = cursor.pos();
let name = parse_name(cursor)?;
let name_end = cursor.pos();
// wsp*
cursor.eat_whitespace();
// extras?
@ -1018,6 +1054,23 @@ fn parse_pep508_requirement<T: Pep508Url>(
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.)
//
// See: https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_internal/utils/filetypes.py#L8
if requirement_kind.is_none() {
if looks_like_archive(cursor.slice(name_start, name_end)) {
let clone = cursor.clone().at(start);
return Err(Pep508Error {
message: Pep508ErrorSource::UnsupportedRequirement("URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ https://...`).".to_string()),
start,
len: clone.pos() - start,
input: clone.to_string(),
});
}
}
// wsp*
cursor.eat_whitespace();
// quoted_marker?

View file

@ -2163,6 +2163,364 @@ mod test {
Ok(())
}
#[tokio::test]
#[cfg(not(windows))]
async fn archive_requirement() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.write_str(indoc! {r"
# Archive name that's also a valid Python package name.
importlib_metadata-8.3.0-py3-none-any.whl
# Archive name that's also a valid Python package name, with markers.
importlib_metadata-8.2.0-py3-none-any.whl ; sys_platform == 'win32'
# Archive name that's also a valid Python package name, with extras.
importlib_metadata-8.2.0-py3-none-any.whl[extra]
# Archive name that's not a valid Python package name.
importlib_metadata-8.2.0+local-py3-none-any.whl
# Archive name that's not a valid Python package name, with markers.
importlib_metadata-8.2.0+local-py3-none-any.whl ; sys_platform == 'win32'
# Archive name that's not a valid Python package name, with extras.
importlib_metadata-8.2.0+local-py3-none-any.whl[extra]
"})?;
let requirements = RequirementsTxt::parse(
requirements_txt.path(),
temp_dir.path(),
&BaseClientBuilder::new(),
)
.await
.unwrap();
insta::with_settings!({
filters => path_filters(&path_filter(temp_dir.path())),
}, {
insta::assert_debug_snapshot!(requirements, @r###"
RequirementsTxt {
requirements: [
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: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
ext: Wheel,
},
),
verbatim: VerbatimUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
query: None,
fragment: None,
},
given: Some(
"importlib_metadata-8.3.0-py3-none-any.whl",
),
},
},
extras: [],
marker: true,
origin: Some(
File(
"<REQUIREMENTS_DIR>/requirements.txt",
),
),
},
),
hashes: [],
},
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: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
ext: Wheel,
},
),
verbatim: VerbatimUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
query: None,
fragment: None,
},
given: Some(
"importlib_metadata-8.2.0-py3-none-any.whl",
),
},
},
extras: [],
marker: sys_platform == 'win32',
origin: Some(
File(
"<REQUIREMENTS_DIR>/requirements.txt",
),
),
},
),
hashes: [],
},
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: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
ext: Wheel,
},
),
verbatim: VerbatimUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
query: None,
fragment: None,
},
given: Some(
"importlib_metadata-8.2.0-py3-none-any.whl",
),
},
},
extras: [
ExtraName(
"extra",
),
],
marker: true,
origin: Some(
File(
"<REQUIREMENTS_DIR>/requirements.txt",
),
),
},
),
hashes: [],
},
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: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
ext: Wheel,
},
),
verbatim: VerbatimUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
query: None,
fragment: None,
},
given: Some(
"importlib_metadata-8.2.0+local-py3-none-any.whl",
),
},
},
extras: [],
marker: true,
origin: Some(
File(
"<REQUIREMENTS_DIR>/requirements.txt",
),
),
},
),
hashes: [],
},
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: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
ext: Wheel,
},
),
verbatim: VerbatimUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
query: None,
fragment: None,
},
given: Some(
"importlib_metadata-8.2.0+local-py3-none-any.whl",
),
},
},
extras: [],
marker: sys_platform == 'win32',
origin: Some(
File(
"<REQUIREMENTS_DIR>/requirements.txt",
),
),
},
),
hashes: [],
},
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: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
query: None,
fragment: None,
},
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
ext: Wheel,
},
),
verbatim: VerbatimUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
username: "",
password: None,
host: None,
port: None,
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
query: None,
fragment: None,
},
given: Some(
"importlib_metadata-8.2.0+local-py3-none-any.whl",
),
},
},
extras: [
ExtraName(
"extra",
),
],
marker: true,
origin: Some(
File(
"<REQUIREMENTS_DIR>/requirements.txt",
),
),
},
),
hashes: [],
},
],
constraints: [],
editables: [],
index_url: None,
extra_index_urls: [],
find_links: [],
no_index: false,
no_binary: None,
only_binary: None,
}
"###);
});
Ok(())
}
#[tokio::test]
async fn parser_error_line_and_column() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;