mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-03 13:14:41 +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.
|
// Expand any environment variables in the path.
|
||||||
let expanded = expand_env_vars(url);
|
let expanded = expand_env_vars(url);
|
||||||
|
|
||||||
|
// Strip extras.
|
||||||
|
let url = split_extras(&expanded)
|
||||||
|
.map(|(url, _)| url)
|
||||||
|
.unwrap_or(&expanded);
|
||||||
|
|
||||||
// Analyze the path.
|
// Analyze the path.
|
||||||
let mut chars = expanded.chars();
|
let mut chars = url.chars();
|
||||||
|
|
||||||
let Some(first_char) = chars.next() else {
|
let Some(first_char) = chars.next() else {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -670,18 +675,47 @@ fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ex) `https://` or `C:`
|
// Ex) `https://` or `C:`
|
||||||
if split_scheme(&expanded).is_some() {
|
if split_scheme(url).is_some() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ex) `foo/bar`
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
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`
|
/// parses extras in the `[extra1,extra2] format`
|
||||||
fn parse_extras_cursor<T: Pep508Url>(
|
fn parse_extras_cursor<T: Pep508Url>(
|
||||||
cursor: &mut Cursor,
|
cursor: &mut Cursor,
|
||||||
|
|
@ -970,7 +1004,9 @@ fn parse_pep508_requirement<T: Pep508Url>(
|
||||||
// wsp*
|
// wsp*
|
||||||
cursor.eat_whitespace();
|
cursor.eat_whitespace();
|
||||||
// name
|
// name
|
||||||
|
let name_start = cursor.pos();
|
||||||
let name = parse_name(cursor)?;
|
let name = parse_name(cursor)?;
|
||||||
|
let name_end = cursor.pos();
|
||||||
// wsp*
|
// wsp*
|
||||||
cursor.eat_whitespace();
|
cursor.eat_whitespace();
|
||||||
// extras?
|
// extras?
|
||||||
|
|
@ -1018,6 +1054,23 @@ fn parse_pep508_requirement<T: Pep508Url>(
|
||||||
|
|
||||||
let requirement_end = cursor.pos();
|
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*
|
// wsp*
|
||||||
cursor.eat_whitespace();
|
cursor.eat_whitespace();
|
||||||
// quoted_marker?
|
// quoted_marker?
|
||||||
|
|
|
||||||
|
|
@ -2163,6 +2163,364 @@ mod test {
|
||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn parser_error_line_and_column() -> Result<()> {
|
async fn parser_error_line_and_column() -> Result<()> {
|
||||||
let temp_dir = assert_fs::TempDir::new()?;
|
let temp_dir = assert_fs::TempDir::new()?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue