Skip to content

Commit

Permalink
Support unnamed requirements in uv add (#4326)
Browse files Browse the repository at this point in the history
## Summary

Support unnamed URL requirements in `uv add`. For example, `uv add
git+https://github.com/pallets/flask`.

Part of #3959.
  • Loading branch information
ibraheemdev committed Jun 14, 2024
1 parent accbb9b commit 042fdea
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 21 deletions.
21 changes: 21 additions & 0 deletions crates/pypi-types/src/requirement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions crates/uv/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 98 additions & 18 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
requirements: Vec<RequirementsSource>,
python: Option<String>,
settings: ResolverInstallerSettings,
preview: PreviewMode,
connectivity: Connectivity,
concurrency: Concurrency,
Expand All @@ -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`.
Expand All @@ -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(),
Expand All @@ -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;

Expand Down
11 changes: 9 additions & 2 deletions crates/uv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 11 additions & 1 deletion crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
}
}
}
Expand Down
92 changes: 92 additions & 0 deletions crates/uv/tests/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]"]), @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/[email protected]",
]
"###
);
});

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<()> {
Expand Down

0 comments on commit 042fdea

Please sign in to comment.