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.
This commit is contained in:
Ibraheem Ahmed 2024-06-14 13:42:39 -04:00 committed by GitHub
parent accbb9b695
commit 042fdea087
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 241 additions and 22 deletions

View File

@ -43,6 +43,27 @@ impl Requirement {
}
}
impl From<Requirement> for pep508_rs::Requirement<VerbatimUrl> {
/// 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<pep508_rs::Requirement<VerbatimParsedUrl>> for Requirement {
/// Convert a [`pep508_rs::Requirement`] to a [`Requirement`].
fn from(requirement: pep508_rs::Requirement<VerbatimParsedUrl>) -> Self {

View File

@ -1602,6 +1602,15 @@ pub(crate) struct AddArgs {
#[arg(required = true)]
pub(crate) requirements: Vec<String>,
#[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

View File

@ -1,24 +1,29 @@
use std::str::FromStr;
use anyhow::Result;
use pep508_rs::Requirement;
use uv_cache::Cache;
use uv_client::Connectivity;
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode};
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
use uv_dispatch::BuildDispatch;
use uv_distribution::pyproject_mut::PyProjectTomlMut;
use uv_distribution::ProjectWorkspace;
use uv_git::GitResolver;
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder};
use uv_types::{BuildIsolation, HashStrategy, InFlight};
use uv_cache::Cache;
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<String>,
requirements: Vec<RequirementsSource>,
python: Option<String>,
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;

View File

@ -683,11 +683,18 @@ async fn run() -> Result<ExitStatus> {
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::<Vec<_>>();
commands::add(
args.requirements,
requirements,
args.python,
args.settings,
globals.preview,
globals.connectivity,
Concurrency::default(),

View File

@ -365,20 +365,30 @@ impl LockSettings {
pub(crate) struct AddSettings {
pub(crate) requirements: Vec<String>,
pub(crate) python: Option<String>,
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<FilesystemOptions>) -> Self {
pub(crate) fn resolve(args: AddArgs, filesystem: Option<FilesystemOptions>) -> 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,
),
}
}
}

View File

@ -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<()> {