Skip to content

Commit 221f364

Browse files
committed
support unnamed requirements in uv add
1 parent 74c0568 commit 221f364

File tree

6 files changed

+239
-19
lines changed

6 files changed

+239
-19
lines changed

crates/pypi-types/src/requirement.rs

+21
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,27 @@ impl Requirement {
4343
}
4444
}
4545

46+
impl From<Requirement> for pep508_rs::Requirement<VerbatimUrl> {
47+
/// Convert a [`Requirement`] to a [`pep508_rs::Requirement`].
48+
fn from(requirement: Requirement) -> Self {
49+
pep508_rs::Requirement {
50+
name: requirement.name,
51+
extras: requirement.extras,
52+
marker: requirement.marker,
53+
origin: requirement.origin,
54+
version_or_url: match requirement.source {
55+
RequirementSource::Registry { specifier, .. } => {
56+
Some(VersionOrUrl::VersionSpecifier(specifier))
57+
}
58+
RequirementSource::Url { url, .. }
59+
| RequirementSource::Git { url, .. }
60+
| RequirementSource::Path { url, .. }
61+
| RequirementSource::Directory { url, .. } => Some(VersionOrUrl::Url(url)),
62+
},
63+
}
64+
}
65+
}
66+
4667
impl From<pep508_rs::Requirement<VerbatimParsedUrl>> for Requirement {
4768
/// Convert a [`pep508_rs::Requirement`] to a [`Requirement`].
4869
fn from(requirement: pep508_rs::Requirement<VerbatimParsedUrl>) -> Self {

crates/uv/src/cli.rs

+9
Original file line numberDiff line numberDiff line change
@@ -1602,6 +1602,15 @@ pub(crate) struct AddArgs {
16021602
#[arg(required = true)]
16031603
pub(crate) requirements: Vec<String>,
16041604

1605+
#[command(flatten)]
1606+
pub(crate) resolver: ResolverArgs,
1607+
1608+
#[command(flatten)]
1609+
pub(crate) build: BuildArgs,
1610+
1611+
#[command(flatten)]
1612+
pub(crate) refresh: RefreshArgs,
1613+
16051614
/// The Python interpreter into which packages should be installed.
16061615
///
16071616
/// By default, `uv` installs into the virtual environment in the current working directory or

crates/uv/src/commands/project/add.rs

+100-16
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
1-
use std::str::FromStr;
2-
31
use anyhow::Result;
2+
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
3+
use uv_dispatch::BuildDispatch;
4+
use uv_distribution::pyproject_mut::PyProjectTomlMut;
5+
use uv_git::GitResolver;
6+
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
7+
use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder};
8+
use uv_types::{BuildIsolation, HashStrategy, InFlight};
49

5-
use pep508_rs::Requirement;
610
use uv_cache::Cache;
7-
use uv_client::Connectivity;
8-
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode};
9-
use uv_distribution::pyproject_mut::PyProjectTomlMut;
10-
use uv_distribution::ProjectWorkspace;
11+
use uv_configuration::{
12+
Concurrency, ConfigSettings, ExtrasSpecification, PreviewMode, SetupPyStrategy,
13+
};
14+
use uv_distribution::{DistributionDatabase, ProjectWorkspace};
1115
use uv_warnings::warn_user;
1216

17+
use crate::commands::pip::resolution_environment;
18+
use crate::commands::reporters::ResolverReporter;
1319
use crate::commands::{project, ExitStatus};
1420
use crate::printer::Printer;
1521
use crate::settings::{InstallerSettings, ResolverSettings};
1622

1723
/// Add one or more packages to the project requirements.
1824
#[allow(clippy::too_many_arguments)]
1925
pub(crate) async fn add(
20-
requirements: Vec<String>,
26+
requirements: Vec<RequirementsSource>,
2127
python: Option<String>,
28+
settings: ResolverSettings,
2229
preview: PreviewMode,
2330
connectivity: Connectivity,
2431
concurrency: Concurrency,
@@ -33,10 +40,93 @@ pub(crate) async fn add(
3340
// Find the project requirements.
3441
let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?;
3542

43+
// Discover or create the virtual environment.
44+
let venv = project::init_environment(project.workspace(), python.as_deref(), cache, printer)?;
45+
46+
let client_builder = BaseClientBuilder::new()
47+
.connectivity(connectivity)
48+
.native_tls(native_tls)
49+
.keyring(settings.keyring_provider);
50+
51+
// Read the requirements.
52+
let RequirementsSpecification { requirements, .. } =
53+
RequirementsSpecification::from_sources(&requirements, &[], &[], &client_builder).await?;
54+
55+
// TODO(charlie): These are all default values. We should consider whether we want to make them
56+
// optional on the downstream APIs.
57+
let exclude_newer = None;
58+
let python_version = None;
59+
let python_platform = None;
60+
let hasher = HashStrategy::default();
61+
let setup_py = SetupPyStrategy::default();
62+
let config_settings = ConfigSettings::default();
63+
let build_isolation = BuildIsolation::default();
64+
65+
// Use the default settings.
66+
let settings = ResolverSettings::default();
67+
68+
// Determine the environment for the resolution.
69+
let (tags, markers) =
70+
resolution_environment(python_version, python_platform, venv.interpreter())?;
71+
72+
// Initialize the registry client.
73+
let client = RegistryClientBuilder::new(cache.clone())
74+
.native_tls(native_tls)
75+
.connectivity(connectivity)
76+
.index_urls(settings.index_locations.index_urls())
77+
.index_strategy(settings.index_strategy)
78+
.keyring(settings.keyring_provider)
79+
.markers(&markers)
80+
.platform(venv.interpreter().platform())
81+
.build();
82+
83+
// Initialize any shared state.
84+
let git = GitResolver::default();
85+
let in_flight = InFlight::default();
86+
let index = InMemoryIndex::default();
87+
88+
// Resolve the flat indexes from `--find-links`.
89+
let flat_index = {
90+
let client = FlatIndexClient::new(&client, cache);
91+
let entries = client.fetch(settings.index_locations.flat_index()).await?;
92+
FlatIndex::from_entries(entries, Some(&tags), &hasher, &settings.build_options)
93+
};
94+
95+
// Create a build dispatch.
96+
let build_dispatch = BuildDispatch::new(
97+
&client,
98+
cache,
99+
venv.interpreter(),
100+
&settings.index_locations,
101+
&flat_index,
102+
&index,
103+
&git,
104+
&in_flight,
105+
setup_py,
106+
&config_settings,
107+
build_isolation,
108+
settings.link_mode,
109+
&settings.build_options,
110+
concurrency,
111+
preview,
112+
)
113+
.with_options(OptionsBuilder::new().exclude_newer(exclude_newer).build());
114+
115+
// Resolve any unnamed requirements.
116+
let requirements = NamedRequirementsResolver::new(
117+
requirements,
118+
&hasher,
119+
&index,
120+
DistributionDatabase::new(&client, &build_dispatch, concurrency.downloads, preview),
121+
)
122+
.with_reporter(ResolverReporter::from(printer))
123+
.resolve()
124+
.await?;
125+
126+
// Add the requirements to the `pyproject.toml`.
36127
let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?;
37128
for req in requirements {
38-
let req = Requirement::from_str(&req)?;
39-
pyproject.add_dependency(&req)?;
129+
pyproject.add_dependency(&pep508_rs::Requirement::from(req))?;
40130
}
41131

42132
// Save the modified `pyproject.toml`.
@@ -45,12 +135,6 @@ pub(crate) async fn add(
45135
pyproject.to_string(),
46136
)?;
47137

48-
// Discover or create the virtual environment.
49-
let venv = project::init_environment(project.workspace(), python.as_deref(), cache, printer)?;
50-
51-
// Use the default settings.
52-
let settings = ResolverSettings::default();
53-
54138
// Lock and sync the environment.
55139
let lock = project::lock::do_lock(
56140
project.workspace(),

crates/uv/src/main.rs

+9-2
Original file line numberDiff line numberDiff line change
@@ -683,11 +683,18 @@ async fn run() -> Result<ExitStatus> {
683683
show_settings!(args);
684684

685685
// Initialize the cache.
686-
let cache = cache.init()?;
686+
let cache = cache.init()?.with_refresh(args.refresh);
687+
688+
let requirements = args
689+
.requirements
690+
.into_iter()
691+
.map(RequirementsSource::Package)
692+
.collect::<Vec<_>>();
687693

688694
commands::add(
689-
args.requirements,
695+
requirements,
690696
args.python,
697+
args.settings,
691698
globals.preview,
692699
globals.connectivity,
693700
Concurrency::default(),

crates/uv/src/settings.rs

+8-1
Original file line numberDiff line numberDiff line change
@@ -347,20 +347,27 @@ impl LockSettings {
347347
pub(crate) struct AddSettings {
348348
pub(crate) requirements: Vec<String>,
349349
pub(crate) python: Option<String>,
350+
pub(crate) refresh: Refresh,
351+
pub(crate) settings: ResolverSettings,
350352
}
351353

352354
impl AddSettings {
353355
/// Resolve the [`AddSettings`] from the CLI and filesystem configuration.
354356
#[allow(clippy::needless_pass_by_value)]
355-
pub(crate) fn resolve(args: AddArgs, _filesystem: Option<FilesystemOptions>) -> Self {
357+
pub(crate) fn resolve(args: AddArgs, filesystem: Option<FilesystemOptions>) -> Self {
356358
let AddArgs {
357359
requirements,
360+
resolver,
361+
build,
362+
refresh,
358363
python,
359364
} = args;
360365

361366
Self {
362367
requirements,
363368
python,
369+
refresh: Refresh::from(refresh),
370+
settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
364371
}
365372
}
366373
}

crates/uv/tests/edit.rs

+92
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,98 @@ fn add_git() -> Result<()> {
282282
Ok(())
283283
}
284284

285+
/// Add an unnamed requirement.
286+
#[test]
287+
fn add_unnamed() -> Result<()> {
288+
let context = TestContext::new("3.12");
289+
290+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
291+
pyproject_toml.write_str(indoc! {r#"
292+
[project]
293+
name = "project"
294+
version = "0.1.0"
295+
# ...
296+
requires-python = ">=3.12"
297+
dependencies = []
298+
"#})?;
299+
300+
uv_snapshot!(context.filters(), context.add(&["git+https://github.com/astral-test/[email protected]"]), @r###"
301+
success: true
302+
exit_code: 0
303+
----- stdout -----
304+
305+
----- stderr -----
306+
warning: `uv add` is experimental and may change without warning.
307+
Resolved 2 packages in [TIME]
308+
Downloaded 2 packages in [TIME]
309+
Installed 2 packages in [TIME]
310+
+ project==0.1.0 (from file://[TEMP_DIR]/)
311+
+ uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979)
312+
"###);
313+
314+
let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?;
315+
316+
insta::with_settings!({
317+
filters => context.filters(),
318+
}, {
319+
assert_snapshot!(
320+
pyproject_toml, @r###"
321+
[project]
322+
name = "project"
323+
version = "0.1.0"
324+
# ...
325+
requires-python = ">=3.12"
326+
dependencies = [
327+
"uv-public-pypackage @ git+https://github.com/astral-test/[email protected]",
328+
]
329+
"###
330+
);
331+
});
332+
333+
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?;
334+
335+
insta::with_settings!({
336+
filters => context.filters(),
337+
}, {
338+
assert_snapshot!(
339+
lock, @r###"
340+
version = 1
341+
requires-python = ">=3.12"
342+
343+
[[distribution]]
344+
name = "project"
345+
version = "0.1.0"
346+
source = "editable+."
347+
sdist = { path = "." }
348+
349+
[[distribution.dependencies]]
350+
name = "uv-public-pypackage"
351+
version = "0.1.0"
352+
source = "git+https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979"
353+
354+
[[distribution]]
355+
name = "uv-public-pypackage"
356+
version = "0.1.0"
357+
source = "git+https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979"
358+
sdist = { url = "https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" }
359+
"###
360+
);
361+
});
362+
363+
// Install from the lockfile.
364+
uv_snapshot!(context.filters(), context.sync(), @r###"
365+
success: true
366+
exit_code: 0
367+
----- stdout -----
368+
369+
----- stderr -----
370+
warning: `uv sync` is experimental and may change without warning.
371+
Audited 2 packages in [TIME]
372+
"###);
373+
374+
Ok(())
375+
}
376+
285377
/// Update a PyPI requirement.
286378
#[test]
287379
fn update_registry() -> Result<()> {

0 commit comments

Comments
 (0)