diff --git a/Cargo.lock b/Cargo.lock index 765a97c6aee..8b16e2fb581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4357,6 +4357,7 @@ dependencies = [ "clap_complete_command", "console", "ctrlc", + "distribution-filename", "distribution-types", "filetime", "flate2", diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 6528853b645..c68b44f10f3 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -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" } diff --git a/crates/uv/src/commands/pip_compile.rs b/crates/uv/src/commands/pip_compile.rs index 13c214567f7..9711bbeed86 100644 --- a/crates/uv/src/commands/pip_compile.rs +++ b/crates/uv/src/commands/pip_compile.rs @@ -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::>(); 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?; diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index f56611fb4f2..bebeedeb443 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -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 { - 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::>(); - 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 { +) -> Result { // 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) } diff --git a/crates/uv/src/commands/pip_sync.rs b/crates/uv/src/commands/pip_sync.rs index a2141d7e84b..dddf82d83ee 100644 --- a/crates/uv/src/commands/pip_sync.rs +++ b/crates/uv/src/commands/pip_sync.rs @@ -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. diff --git a/crates/uv/src/commands/pip_uninstall.rs b/crates/uv/src/commands/pip_uninstall.rs index 99a0d5365f1..910c2737fcd 100644 --- a/crates/uv/src/commands/pip_uninstall.rs +++ b/crates/uv/src/commands/pip_uninstall.rs @@ -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. diff --git a/crates/uv/src/requirements.rs b/crates/uv/src/requirements.rs index a42bbbccf86..1c2a5ba422d 100644 --- a/crates/uv/src/requirements.rs +++ b/crates/uv/src/requirements.rs @@ -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, /// The requirements for the project. - pub(crate) requirements: Vec, + pub(crate) requirements: Vec, /// The constraints for the project. pub(crate) constraints: Vec, /// The overrides for the project. @@ -133,7 +134,7 @@ impl RequirementsSpecification { ) -> Result { 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::>>()?, + .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, + /// The requirements for the project. + pub(crate) requirements: Vec, + /// The constraints for the project. + pub(crate) constraints: Vec, + /// The overrides for the project. + pub(crate) overrides: Vec, + /// Package to install as editable installs + pub(crate) editables: Vec, + /// The index URL to use for fetching packages. + pub(crate) index_url: Option, + /// The extra index URLs to use for fetching packages. + pub(crate) extra_index_urls: Vec, + /// Whether to disallow index usage. + pub(crate) no_index: bool, + /// The `--find-links` locations to use for fetching packages. + pub(crate) find_links: Vec, +} + +impl NamedRequirements { + /// Convert a [`RequirementsSpecification`] into a [`NamedRequirements`]. + pub(crate) fn from_spec(spec: RequirementsSpecification) -> Result { + 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::>()?, + 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 { + // 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}" + )) + } +} diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 7a5aee51d77..980b4078b79 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -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 "### );