mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-01 12:24:15 +00:00
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:
parent
f82224124e
commit
d9f53cc83f
2 changed files with 414 additions and 3 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue