From 042fdea087d7e90a03f2a02922e0bf49a138f5f4 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Fri, 14 Jun 2024 13:42:39 -0400 Subject: [PATCH] Support unnamed requirements in `uv add` (#4326) ## Summary Support unnamed URL requirements in `uv add`. For example, `uv add git+https://github.com/pallets/flask`. Part of https://github.com/astral-sh/uv/issues/3959. --- crates/pypi-types/src/requirement.rs | 21 +++++ crates/uv/src/cli.rs | 9 ++ crates/uv/src/commands/project/add.rs | 116 ++++++++++++++++++++++---- crates/uv/src/main.rs | 11 ++- crates/uv/src/settings.rs | 12 ++- crates/uv/tests/edit.rs | 92 ++++++++++++++++++++ 6 files changed, 240 insertions(+), 21 deletions(-) diff --git a/crates/pypi-types/src/requirement.rs b/crates/pypi-types/src/requirement.rs index c4f9512d653..0c3d0ce8629 100644 --- a/crates/pypi-types/src/requirement.rs +++ b/crates/pypi-types/src/requirement.rs @@ -43,6 +43,27 @@ impl Requirement { } } +impl From for pep508_rs::Requirement { + /// Convert a [`Requirement`] to a [`pep508_rs::Requirement`]. + fn from(requirement: Requirement) -> Self { + pep508_rs::Requirement { + name: requirement.name, + extras: requirement.extras, + marker: requirement.marker, + origin: requirement.origin, + version_or_url: match requirement.source { + RequirementSource::Registry { specifier, .. } => { + Some(VersionOrUrl::VersionSpecifier(specifier)) + } + RequirementSource::Url { url, .. } + | RequirementSource::Git { url, .. } + | RequirementSource::Path { url, .. } + | RequirementSource::Directory { url, .. } => Some(VersionOrUrl::Url(url)), + }, + } + } +} + impl From> for Requirement { /// Convert a [`pep508_rs::Requirement`] to a [`Requirement`]. fn from(requirement: pep508_rs::Requirement) -> Self { diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index 7fe3f2cdf3b..c443682743d 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -1602,6 +1602,15 @@ pub(crate) struct AddArgs { #[arg(required = true)] pub(crate) requirements: Vec, + #[command(flatten)] + pub(crate) installer: ResolverInstallerArgs, + + #[command(flatten)] + pub(crate) build: BuildArgs, + + #[command(flatten)] + pub(crate) refresh: RefreshArgs, + /// The Python interpreter into which packages should be installed. /// /// By default, `uv` installs into the virtual environment in the current working directory or diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 224af3f8673..61080922bee 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -1,24 +1,29 @@ -use std::str::FromStr; - use anyhow::Result; +use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; +use uv_dispatch::BuildDispatch; +use uv_distribution::pyproject_mut::PyProjectTomlMut; +use uv_git::GitResolver; +use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; +use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder}; +use uv_types::{BuildIsolation, HashStrategy, InFlight}; -use pep508_rs::Requirement; use uv_cache::Cache; -use uv_client::Connectivity; -use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode}; -use uv_distribution::pyproject_mut::PyProjectTomlMut; -use uv_distribution::ProjectWorkspace; +use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode, SetupPyStrategy}; +use uv_distribution::{DistributionDatabase, ProjectWorkspace}; use uv_warnings::warn_user; +use crate::commands::pip::resolution_environment; +use crate::commands::reporters::ResolverReporter; use crate::commands::{project, ExitStatus}; use crate::printer::Printer; -use crate::settings::{InstallerSettings, ResolverSettings}; +use crate::settings::ResolverInstallerSettings; /// Add one or more packages to the project requirements. #[allow(clippy::too_many_arguments)] pub(crate) async fn add( - requirements: Vec, + requirements: Vec, python: Option, + settings: ResolverInstallerSettings, preview: PreviewMode, connectivity: Connectivity, concurrency: Concurrency, @@ -33,10 +38,92 @@ pub(crate) async fn add( // Find the project requirements. let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?; + // Discover or create the virtual environment. + let venv = project::init_environment(project.workspace(), python.as_deref(), cache, printer)?; + + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls) + .keyring(settings.keyring_provider); + + // Read the requirements. + let RequirementsSpecification { requirements, .. } = + RequirementsSpecification::from_sources(&requirements, &[], &[], &client_builder).await?; + + // TODO(charlie): These are all default values. We should consider whether we want to make them + // optional on the downstream APIs. + let python_version = None; + let python_platform = None; + let hasher = HashStrategy::default(); + let setup_py = SetupPyStrategy::default(); + let build_isolation = BuildIsolation::default(); + + // Determine the environment for the resolution. + let (tags, markers) = + resolution_environment(python_version, python_platform, venv.interpreter())?; + + // Initialize the registry client. + let client = RegistryClientBuilder::new(cache.clone()) + .native_tls(native_tls) + .connectivity(connectivity) + .index_urls(settings.index_locations.index_urls()) + .index_strategy(settings.index_strategy) + .keyring(settings.keyring_provider) + .markers(&markers) + .platform(venv.interpreter().platform()) + .build(); + + // Initialize any shared state. + let git = GitResolver::default(); + let in_flight = InFlight::default(); + let index = InMemoryIndex::default(); + + // Resolve the flat indexes from `--find-links`. + let flat_index = { + let client = FlatIndexClient::new(&client, cache); + let entries = client.fetch(settings.index_locations.flat_index()).await?; + FlatIndex::from_entries(entries, Some(&tags), &hasher, &settings.build_options) + }; + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + cache, + venv.interpreter(), + &settings.index_locations, + &flat_index, + &index, + &git, + &in_flight, + setup_py, + &settings.config_setting, + build_isolation, + settings.link_mode, + &settings.build_options, + concurrency, + preview, + ) + .with_options( + OptionsBuilder::new() + .exclude_newer(settings.exclude_newer) + .build(), + ); + + // Resolve any unnamed requirements. + let requirements = NamedRequirementsResolver::new( + requirements, + &hasher, + &index, + DistributionDatabase::new(&client, &build_dispatch, concurrency.downloads, preview), + ) + .with_reporter(ResolverReporter::from(printer)) + .resolve() + .await?; + + // Add the requirements to the `pyproject.toml`. let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; for req in requirements { - let req = Requirement::from_str(&req)?; - pyproject.add_dependency(&req)?; + pyproject.add_dependency(&pep508_rs::Requirement::from(req))?; } // Save the modified `pyproject.toml`. @@ -45,12 +132,6 @@ pub(crate) async fn add( pyproject.to_string(), )?; - // Discover or create the virtual environment. - let venv = project::init_environment(project.workspace(), python.as_deref(), cache, printer)?; - - // Use the default settings. - let settings = ResolverSettings::default(); - // Lock and sync the environment. let lock = project::lock::do_lock( project.workspace(), @@ -76,7 +157,6 @@ pub(crate) async fn add( // Perform a full sync, because we don't know what exactly is affected by the removal. // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? - let settings = InstallerSettings::default(); let extras = ExtrasSpecification::All; let dev = true; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 7760cc8a67c..8f2af18b746 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -683,11 +683,18 @@ async fn run() -> Result { show_settings!(args); // Initialize the cache. - let cache = cache.init()?; + let cache = cache.init()?.with_refresh(args.refresh); + + let requirements = args + .requirements + .into_iter() + .map(RequirementsSource::Package) + .collect::>(); commands::add( - args.requirements, + requirements, args.python, + args.settings, globals.preview, globals.connectivity, Concurrency::default(), diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 82795bc6021..6ee160c9c13 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -365,20 +365,30 @@ impl LockSettings { pub(crate) struct AddSettings { pub(crate) requirements: Vec, pub(crate) python: Option, + pub(crate) refresh: Refresh, + pub(crate) settings: ResolverInstallerSettings, } impl AddSettings { /// Resolve the [`AddSettings`] from the CLI and filesystem configuration. #[allow(clippy::needless_pass_by_value)] - pub(crate) fn resolve(args: AddArgs, _filesystem: Option) -> Self { + pub(crate) fn resolve(args: AddArgs, filesystem: Option) -> Self { let AddArgs { requirements, + installer, + build, + refresh, python, } = args; Self { requirements, python, + refresh: Refresh::from(refresh), + settings: ResolverInstallerSettings::combine( + resolver_installer_options(installer, build), + filesystem, + ), } } } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index e8574811374..553f83a6431 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -282,6 +282,98 @@ fn add_git() -> Result<()> { Ok(()) } +/// Add an unnamed requirement. +#[test] +fn add_unnamed() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["git+https://github.com/astral-test/uv-public-pypackage@0.0.1"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + Resolved 2 packages in [TIME] + Downloaded 2 packages in [TIME] + Installed 2 packages in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1", + ] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + + [[distribution.dependencies]] + name = "uv-public-pypackage" + version = "0.1.0" + source = "git+https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" + + [[distribution]] + name = "uv-public-pypackage" + version = "0.1.0" + source = "git+https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" + sdist = { url = "https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" } + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 2 packages in [TIME] + "###); + + Ok(()) +} + /// Update a PyPI requirement. #[test] fn update_registry() -> Result<()> {