Skip to content

Support unnamed requirements in uv add #4237

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little weird because we lose the distinction between empty version specifiers and None, but that doesn't seem relevant for formatting at least.

}
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) resolver: ResolverArgs,

#[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: 100 additions & 16 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
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, ConfigSettings, 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};

/// 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: ResolverSettings,
preview: PreviewMode,
connectivity: Connectivity,
concurrency: Concurrency,
Expand All @@ -33,10 +40,93 @@ 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 exclude_newer = None;
let python_version = None;
let python_platform = None;
let hasher = HashStrategy::default();
let setup_py = SetupPyStrategy::default();
let config_settings = ConfigSettings::default();
let build_isolation = BuildIsolation::default();

// Use the default settings.
let settings = ResolverSettings::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,
&config_settings,
build_isolation,
settings.link_mode,
&settings.build_options,
concurrency,
preview,
)
.with_options(OptionsBuilder::new().exclude_newer(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 +135,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 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
9 changes: 8 additions & 1 deletion crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,20 +347,27 @@ impl LockSettings {
pub(crate) struct AddSettings {
pub(crate) requirements: Vec<String>,
pub(crate) python: Option<String>,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverSettings,
}

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,
resolver,
build,
refresh,
python,
} = args;

Self {
requirements,
python,
refresh: Refresh::from(refresh),
settings: ResolverSettings::combine(resolver_options(resolver, 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
Loading