Enable unnamed requirements for direct URLs (#2569)

## Summary

This PR enables `uv pip install` to accept unnamed requirements, as long
as the requirement ends with the wheel or source distribution archive
name. For example: `cargo run pip install
~/Downloads/anyio-4.3.0.tar.gz`.

In subsequent PRs, I'll expand the scope of supported archives and
patterns.

Part of: https://github.com/astral-sh/uv/issues/313.
This commit is contained in:
Charlie Marsh 2024-03-20 23:39:02 -04:00 committed by GitHub
parent ee211b35bc
commit 4d96255ade
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 251 additions and 86 deletions

1
Cargo.lock generated
View file

@ -4357,6 +4357,7 @@ dependencies = [
"clap_complete_command",
"console",
"ctrlc",
"distribution-filename",
"distribution-types",
"filetime",
"flate2",

View file

@ -64,6 +64,7 @@ tracing-subscriber = { workspace = true, features = ["json"] }
tracing-tree = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
distribution-filename = { version = "0.0.1", path = "../distribution-filename" }
[target.'cfg(target_os = "windows")'.dependencies]
mimalloc = { version = "0.1.39" }

View file

@ -37,7 +37,8 @@ use crate::commands::reporters::{DownloadReporter, ResolverReporter};
use crate::commands::{elapsed, ExitStatus};
use crate::printer::Printer;
use crate::requirements::{
read_lockfile, ExtrasSpecification, RequirementsSource, RequirementsSpecification,
read_lockfile, ExtrasSpecification, NamedRequirements, RequirementsSource,
RequirementsSpecification,
};
/// Resolve a set of requirements into a set of pinned versions.
@ -89,18 +90,7 @@ pub(crate) async fn pip_compile(
}
// Read all requirements from the provided sources.
let RequirementsSpecification {
project,
requirements,
constraints,
overrides,
editables,
index_url,
extra_index_urls,
no_index,
find_links,
extras: used_extras,
} = RequirementsSpecification::from_sources(
let spec = RequirementsSpecification::from_sources(
requirements,
constraints,
overrides,
@ -113,7 +103,7 @@ pub(crate) async fn pip_compile(
if let ExtrasSpecification::Some(extras) = extras {
let mut unused_extras = extras
.iter()
.filter(|extra| !used_extras.contains(extra))
.filter(|extra| !spec.extras.contains(extra))
.collect::<Vec<_>>();
if !unused_extras.is_empty() {
unused_extras.sort_unstable();
@ -126,6 +116,19 @@ pub(crate) async fn pip_compile(
}
}
// Convert from unnamed to named requirements.
let NamedRequirements {
project,
requirements,
constraints,
overrides,
editables,
index_url,
extra_index_urls,
no_index,
find_links,
} = NamedRequirements::from_spec(spec)?;
// Read the lockfile, if present.
let preferences = read_lockfile(output_file, upgrade).await?;

View file

@ -40,7 +40,9 @@ use uv_warnings::warn_user;
use crate::commands::reporters::{DownloadReporter, InstallReporter, ResolverReporter};
use crate::commands::{compile_bytecode, elapsed, ChangeEvent, ChangeEventKind, ExitStatus};
use crate::printer::Printer;
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
use crate::requirements::{
ExtrasSpecification, NamedRequirements, RequirementsSource, RequirementsSpecification,
};
use super::{DryRunEvent, Upgrade};
@ -76,10 +78,10 @@ pub(crate) async fn pip_install(
dry_run: bool,
printer: Printer,
) -> Result<ExitStatus> {
let start = std::time::Instant::now();
let start = Instant::now();
// Read all requirements from the provided sources.
let RequirementsSpecification {
let NamedRequirements {
project,
requirements,
constraints,
@ -89,25 +91,7 @@ pub(crate) async fn pip_install(
extra_index_urls,
no_index,
find_links,
extras: used_extras,
} = specification(requirements, constraints, overrides, extras, connectivity).await?;
// Check that all provided extras are used
if let ExtrasSpecification::Some(extras) = extras {
let mut unused_extras = extras
.iter()
.filter(|extra| !used_extras.contains(extra))
.collect::<Vec<_>>();
if !unused_extras.is_empty() {
unused_extras.sort_unstable();
unused_extras.dedup();
let s = if unused_extras.len() == 1 { "" } else { "s" };
return Err(anyhow!(
"Requested extra{s} not found: {}",
unused_extras.iter().join(", ")
));
}
}
} = read_requirements(requirements, constraints, overrides, extras, connectivity).await?;
// Detect the current Python interpreter.
let venv = if let Some(python) = python.as_ref() {
@ -348,13 +332,13 @@ pub(crate) async fn pip_install(
}
/// Consolidate the requirements for an installation.
async fn specification(
async fn read_requirements(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
extras: &ExtrasSpecification<'_>,
connectivity: Connectivity,
) -> Result<RequirementsSpecification, Error> {
) -> Result<NamedRequirements, Error> {
// If the user requests `extras` but does not provide a pyproject toml source
if !matches!(extras, ExtrasSpecification::None)
&& !requirements
@ -392,6 +376,9 @@ async fn specification(
}
}
// Convert from unnamed to named requirements.
let spec = NamedRequirements::from_spec(spec)?;
Ok(spec)
}

View file

@ -26,7 +26,7 @@ use uv_warnings::warn_user;
use crate::commands::reporters::{DownloadReporter, FinderReporter, InstallReporter};
use crate::commands::{compile_bytecode, elapsed, ChangeEvent, ChangeEventKind, ExitStatus};
use crate::printer::Printer;
use crate::requirements::{RequirementsSource, RequirementsSpecification};
use crate::requirements::{NamedRequirements, RequirementsSource, RequirementsSpecification};
/// Install a set of locked requirements into the current Python environment.
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
@ -54,20 +54,10 @@ pub(crate) async fn pip_sync(
let start = std::time::Instant::now();
// Read all requirements from the provided sources.
let RequirementsSpecification {
project: _project,
requirements,
constraints: _constraints,
overrides: _overrides,
editables,
index_url,
extra_index_urls,
no_index,
find_links,
extras: _extras,
} = RequirementsSpecification::from_simple_sources(sources, connectivity).await?;
let spec = RequirementsSpecification::from_simple_sources(sources, connectivity).await?;
let num_requirements = requirements.len() + editables.len();
// Validate that the requirements are non-empty.
let num_requirements = spec.requirements.len() + spec.editables.len();
if num_requirements == 0 {
writeln!(printer.stderr(), "No requirements found")?;
return Ok(ExitStatus::Success);
@ -107,6 +97,19 @@ pub(crate) async fn pip_sync(
}
}
// Convert from unnamed to named requirements.
let NamedRequirements {
project: _project,
requirements,
constraints: _constraints,
overrides: _overrides,
editables,
index_url,
extra_index_urls,
no_index,
find_links,
} = NamedRequirements::from_spec(spec)?;
let _lock = venv.lock()?;
// Determine the current environment markers.

View file

@ -12,7 +12,7 @@ use uv_interpreter::PythonEnvironment;
use crate::commands::{elapsed, ExitStatus};
use crate::printer::Printer;
use crate::requirements::{RequirementsSource, RequirementsSpecification};
use crate::requirements::{NamedRequirements, RequirementsSource, RequirementsSpecification};
/// Uninstall packages from the current environment.
pub(crate) async fn pip_uninstall(
@ -27,18 +27,7 @@ pub(crate) async fn pip_uninstall(
let start = std::time::Instant::now();
// Read all requirements from the provided sources.
let RequirementsSpecification {
project: _project,
requirements,
constraints: _constraints,
overrides: _overrides,
editables,
index_url: _index_url,
extra_index_urls: _extra_index_urls,
no_index: _no_index,
find_links: _find_links,
extras: _extras,
} = RequirementsSpecification::from_simple_sources(sources, connectivity).await?;
let spec = RequirementsSpecification::from_simple_sources(sources, connectivity).await?;
// Detect the current Python interpreter.
let venv = if let Some(python) = python.as_ref() {
@ -74,6 +63,19 @@ pub(crate) async fn pip_uninstall(
}
}
// Convert from unnamed to named requirements.
let NamedRequirements {
project: _,
requirements,
constraints: _,
overrides: _,
editables,
index_url: _,
extra_index_urls: _,
no_index: _,
find_links: _,
} = NamedRequirements::from_spec(spec)?;
let _lock = venv.lock()?;
// Index the current `site-packages` directory.

View file

@ -3,14 +3,15 @@
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result};
use console::Term;
use distribution_filename::{SourceDistFilename, WheelFilename};
use indexmap::IndexMap;
use rustc_hash::FxHashSet;
use tracing::{instrument, Level};
use distribution_types::{FlatIndexLocation, IndexUrl};
use pep508_rs::{Requirement, RequirementsTxtRequirement};
use distribution_types::{FlatIndexLocation, IndexUrl, RemoteSource};
use pep508_rs::{Requirement, RequirementsTxtRequirement, UnnamedRequirement, VersionOrUrl};
use requirements_txt::{EditableRequirement, FindLink, RequirementsTxt};
use uv_client::Connectivity;
use uv_fs::Simplified;
@ -104,7 +105,7 @@ pub(crate) struct RequirementsSpecification {
/// The name of the project specifying requirements.
pub(crate) project: Option<PackageName>,
/// The requirements for the project.
pub(crate) requirements: Vec<Requirement>,
pub(crate) requirements: Vec<RequirementsTxtRequirement>,
/// The constraints for the project.
pub(crate) constraints: Vec<Requirement>,
/// The overrides for the project.
@ -133,7 +134,7 @@ impl RequirementsSpecification {
) -> Result<Self> {
Ok(match source {
RequirementsSource::Package(name) => {
let requirement = Requirement::parse(name, std::env::current_dir()?)
let requirement = RequirementsTxtRequirement::parse(name, std::env::current_dir()?)
.with_context(|| format!("Failed to parse `{name}`"))?;
Self {
project: None,
@ -172,13 +173,8 @@ impl RequirementsSpecification {
requirements: requirements_txt
.requirements
.into_iter()
.map(|entry| match entry.requirement {
RequirementsTxtRequirement::Pep508(requirement) => Ok(requirement),
RequirementsTxtRequirement::Unnamed(requirement) => Err(anyhow!(
"Unnamed URL requirements are not yet supported: {requirement}"
)),
})
.collect::<Result<Vec<_>>>()?,
.map(|entry| entry.requirement)
.collect(),
constraints: requirements_txt.constraints,
editables: requirements_txt.editables,
overrides: vec![],
@ -253,7 +249,10 @@ impl RequirementsSpecification {
Self {
project: project_name,
requirements,
requirements: requirements
.into_iter()
.map(RequirementsTxtRequirement::Pep508)
.collect(),
constraints: vec![],
overrides: vec![],
editables: vec![],
@ -309,7 +308,18 @@ impl RequirementsSpecification {
// Read all constraints, treating _everything_ as a constraint.
for source in constraints {
let source = Self::from_source(source, extras, connectivity).await?;
spec.constraints.extend(source.requirements);
for requirement in source.requirements {
match requirement {
RequirementsTxtRequirement::Pep508(requirement) => {
spec.constraints.push(requirement);
}
RequirementsTxtRequirement::Unnamed(requirement) => {
return Err(anyhow::anyhow!(
"Unnamed requirements are not allowed as constraints (found: `{requirement}`)"
));
}
}
}
spec.constraints.extend(source.constraints);
spec.constraints.extend(source.overrides);
@ -329,7 +339,18 @@ impl RequirementsSpecification {
// Read all overrides, treating both requirements _and_ constraints as overrides.
for source in overrides {
let source = Self::from_source(source, extras, connectivity).await?;
spec.overrides.extend(source.requirements);
for requirement in source.requirements {
match requirement {
RequirementsTxtRequirement::Pep508(requirement) => {
spec.overrides.push(requirement);
}
RequirementsTxtRequirement::Unnamed(requirement) => {
return Err(anyhow::anyhow!(
"Unnamed requirements are not allowed as overrides (found: `{requirement}`)"
));
}
}
}
spec.overrides.extend(source.constraints);
spec.overrides.extend(source.overrides);
@ -470,3 +491,93 @@ pub(crate) async fn read_lockfile(
.collect(),
})
}
/// Like [`RequirementsSpecification`], but with concrete names for all requirements.
#[derive(Debug, Default)]
pub(crate) struct NamedRequirements {
/// The name of the project specifying requirements.
pub(crate) project: Option<PackageName>,
/// The requirements for the project.
pub(crate) requirements: Vec<Requirement>,
/// The constraints for the project.
pub(crate) constraints: Vec<Requirement>,
/// The overrides for the project.
pub(crate) overrides: Vec<Requirement>,
/// Package to install as editable installs
pub(crate) editables: Vec<EditableRequirement>,
/// The index URL to use for fetching packages.
pub(crate) index_url: Option<IndexUrl>,
/// The extra index URLs to use for fetching packages.
pub(crate) extra_index_urls: Vec<IndexUrl>,
/// Whether to disallow index usage.
pub(crate) no_index: bool,
/// The `--find-links` locations to use for fetching packages.
pub(crate) find_links: Vec<FlatIndexLocation>,
}
impl NamedRequirements {
/// Convert a [`RequirementsSpecification`] into a [`NamedRequirements`].
pub(crate) fn from_spec(spec: RequirementsSpecification) -> Result<Self> {
Ok(Self {
project: spec.project,
requirements: spec
.requirements
.into_iter()
.map(|requirement| match requirement {
RequirementsTxtRequirement::Pep508(requirement) => Ok(requirement),
RequirementsTxtRequirement::Unnamed(requirement) => {
Self::name_requirement(requirement)
}
})
.collect::<Result<_>>()?,
constraints: spec.constraints,
overrides: spec.overrides,
editables: spec.editables,
index_url: spec.index_url,
extra_index_urls: spec.extra_index_urls,
no_index: spec.no_index,
find_links: spec.find_links,
})
}
/// Infer the package name for a given "unnamed" requirement.
fn name_requirement(requirement: UnnamedRequirement) -> Result<Requirement> {
// If the requirement is a wheel, extract the package name from the wheel filename.
//
// Ex) `anyio-4.3.0-py3-none-any.whl`
if Path::new(requirement.url.path())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
{
let filename = WheelFilename::from_str(&requirement.url.filename()?)?;
return Ok(Requirement {
name: filename.name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
});
}
// If the requirement is a source archive, try to extract the package name from the archive
// filename. This isn't guaranteed to work.
//
// Ex) `anyio-4.3.0.tar.gz`
if let Some(filename) = requirement
.url
.filename()
.ok()
.and_then(|filename| SourceDistFilename::parsed_normalized_filename(&filename).ok())
{
return Ok(Requirement {
name: filename.name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
});
}
Err(anyhow::anyhow!(
"Unable to infer package name for the unnamed requirement: {requirement}"
))
}
}

View file

@ -2485,12 +2485,29 @@ fn respect_unnamed_env_var() -> Result<()> {
uv_snapshot!(context.compile()
.arg("requirements.in")
.env("URL", "https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl"), @r###"
success: false
exit_code: 2
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in
blinker==1.7.0
# via flask
click==8.1.7
# via flask
flask @ ${URL}
itsdangerous==2.1.2
# via flask
jinja2==3.1.2
# via flask
markupsafe==2.1.3
# via
# jinja2
# werkzeug
werkzeug==3.0.1
# via flask
----- stderr -----
error: Unnamed URL requirements are not yet supported: https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
Resolved 7 packages in [TIME]
"###
);
@ -3444,13 +3461,53 @@ fn missing_editable_requirement() -> Result<()> {
Ok(())
}
/// Attempt to resolve a URL requirement without a package name.
/// Attempt to resolve a URL requirement without a package name. The package name can be extracted
/// from the URL.
#[test]
fn missing_package_name() -> Result<()> {
fn unnamed_requirement_with_package_name() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl")?;
uv_snapshot!(context.compile()
.arg("requirements.in"), @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in
blinker==1.7.0
# via flask
click==8.1.7
# via flask
flask @ https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
itsdangerous==2.1.2
# via flask
jinja2==3.1.2
# via flask
markupsafe==2.1.3
# via
# jinja2
# werkzeug
werkzeug==3.0.1
# via flask
----- stderr -----
Resolved 7 packages in [TIME]
"###
);
Ok(())
}
/// Attempt to resolve a URL requirement without a package name. The package name can't be extracted
/// from the URL.
#[test]
fn unnamed_requirement_ambiguous() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0")?;
uv_snapshot!(context.compile()
.arg("requirements.in"), @r###"
success: false
@ -3458,7 +3515,7 @@ fn missing_package_name() -> Result<()> {
----- stdout -----
----- stderr -----
error: Unnamed URL requirements are not yet supported: https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
error: Unable to infer package name for the unnamed requirement: https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0
"###
);