Skip to content

Commit 71c663c

Browse files
zaniebcharliermarsh
authored andcommitted
Add --group support to uv add and uv remove (#8108)
Part of #8090 Adds the ability to add and remove dependencies from arbitrary groups using `uv add` and `uv remove`. Does not include resolving with the new dependencies — tackling that in #8110. Additionally, this does not yet resolve interactions with the existing `dev` group — we'll tackle that separately as well. I probably won't merge the stack until that design is resolved.
1 parent a8038e5 commit 71c663c

File tree

22 files changed

+653
-124
lines changed

22 files changed

+653
-124
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use uv_configuration::{
1515
ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem,
1616
};
1717
use uv_distribution_types::{Index, IndexUrl, Origin, PipExtraIndex, PipFindLinks, PipIndex};
18-
use uv_normalize::{ExtraName, PackageName};
18+
use uv_normalize::{ExtraName, GroupName, PackageName};
1919
use uv_pep508::Requirement;
2020
use uv_pypi_types::VerbatimParsedUrl;
2121
use uv_python::{PythonDownloads, PythonPreference, PythonVersion};
@@ -2946,7 +2946,7 @@ pub struct AddArgs {
29462946
pub requirements: Vec<PathBuf>,
29472947

29482948
/// Add the requirements as development dependencies.
2949-
#[arg(long, conflicts_with("optional"))]
2949+
#[arg(long, conflicts_with("optional"), conflicts_with("group"))]
29502950
pub dev: bool,
29512951

29522952
/// Add the requirements to the specified optional dependency group.
@@ -2956,9 +2956,15 @@ pub struct AddArgs {
29562956
///
29572957
/// To enable an optional dependency group for this requirement instead, see
29582958
/// `--extra`.
2959-
#[arg(long, conflicts_with("dev"))]
2959+
#[arg(long, conflicts_with("dev"), conflicts_with("group"))]
29602960
pub optional: Option<ExtraName>,
29612961

2962+
/// Add the requirements to the specified local dependency group.
2963+
///
2964+
/// These requirements will not be included in the published metadata for the project.
2965+
#[arg(long, conflicts_with("dev"), conflicts_with("optional"))]
2966+
pub group: Option<GroupName>,
2967+
29622968
/// Add the requirements as editable.
29632969
#[arg(long, overrides_with = "no_editable")]
29642970
pub editable: bool,
@@ -3064,13 +3070,17 @@ pub struct RemoveArgs {
30643070
pub packages: Vec<PackageName>,
30653071

30663072
/// Remove the packages from the development dependencies.
3067-
#[arg(long, conflicts_with("optional"))]
3073+
#[arg(long, conflicts_with("optional"), conflicts_with("group"))]
30683074
pub dev: bool,
30693075

30703076
/// Remove the packages from the specified optional dependency group.
3071-
#[arg(long, conflicts_with("dev"))]
3077+
#[arg(long, conflicts_with("dev"), conflicts_with("group"))]
30723078
pub optional: Option<ExtraName>,
30733079

3080+
/// Remove the packages from the specified local dependency group.
3081+
#[arg(long, conflicts_with("dev"), conflicts_with("optional"))]
3082+
pub group: Option<GroupName>,
3083+
30743084
/// Avoid syncing the virtual environment after re-locking the project.
30753085
#[arg(long, env = EnvVars::UV_NO_SYNC, value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "frozen")]
30763086
pub no_sync: bool,

crates/uv-configuration/src/dev.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use either::Either;
2-
use uv_normalize::GroupName;
2+
use uv_normalize::{GroupName, DEV_DEPENDENCIES};
33

44
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
55
pub enum DevMode {
@@ -27,17 +27,17 @@ impl DevMode {
2727
}
2828
}
2929

30-
#[derive(Debug, Copy, Clone)]
31-
pub enum DevSpecification<'group> {
30+
#[derive(Debug, Clone)]
31+
pub enum DevSpecification {
3232
/// Include dev dependencies from the specified group.
33-
Include(&'group [GroupName]),
33+
Include(Vec<GroupName>),
3434
/// Do not include dev dependencies.
3535
Exclude,
36-
/// Include dev dependencies from the specified group, and exclude all non-dev dependencies.
37-
Only(&'group [GroupName]),
36+
/// Include dev dependencies from the specified groups, and exclude all non-dev dependencies.
37+
Only(Vec<GroupName>),
3838
}
3939

40-
impl<'group> DevSpecification<'group> {
40+
impl DevSpecification {
4141
/// Returns an [`Iterator`] over the group names to include.
4242
pub fn iter(&self) -> impl Iterator<Item = &GroupName> {
4343
match self {
@@ -51,3 +51,13 @@ impl<'group> DevSpecification<'group> {
5151
matches!(self, Self::Exclude | Self::Include(_))
5252
}
5353
}
54+
55+
impl From<DevMode> for DevSpecification {
56+
fn from(mode: DevMode) -> Self {
57+
match mode {
58+
DevMode::Include => Self::Include(vec![DEV_DEPENDENCIES.clone()]),
59+
DevMode::Exclude => Self::Exclude,
60+
DevMode::Only => Self::Only(vec![DEV_DEPENDENCIES.clone()]),
61+
}
62+
}
63+
}

crates/uv-distribution/src/metadata/mod.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use uv_configuration::{LowerBound, SourceStrategy};
77
use uv_distribution_types::IndexLocations;
88
use uv_normalize::{ExtraName, GroupName, PackageName};
99
use uv_pep440::{Version, VersionSpecifiers};
10-
use uv_pypi_types::{HashDigest, ResolutionMetadata};
10+
use uv_pep508::Pep508Error;
11+
use uv_pypi_types::{HashDigest, ResolutionMetadata, VerbatimParsedUrl};
1112
use uv_workspace::WorkspaceError;
1213

1314
pub use crate::metadata::lowering::LoweredRequirement;
@@ -21,10 +22,16 @@ mod requires_dist;
2122
pub enum MetadataError {
2223
#[error(transparent)]
2324
Workspace(#[from] WorkspaceError),
24-
#[error("Failed to parse entry for: `{0}`")]
25-
LoweringError(PackageName, #[source] LoweringError),
26-
#[error(transparent)]
27-
Lower(#[from] LoweringError),
25+
#[error("Failed to parse entry: `{0}`")]
26+
LoweringError(PackageName, #[source] Box<LoweringError>),
27+
#[error("Failed to parse entry in `{0}`: `{1}`")]
28+
GroupLoweringError(GroupName, PackageName, #[source] Box<LoweringError>),
29+
#[error("Failed to parse entry in `{0}`: `{1}`")]
30+
GroupParseError(
31+
GroupName,
32+
String,
33+
#[source] Box<Pep508Error<VerbatimParsedUrl>>,
34+
),
2835
}
2936

3037
#[derive(Debug, Clone)]

crates/uv-distribution/src/metadata/requires_dist.rs

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ use crate::Metadata;
33

44
use std::collections::BTreeMap;
55
use std::path::Path;
6+
use std::str::FromStr;
7+
68
use uv_configuration::{LowerBound, SourceStrategy};
79
use uv_distribution_types::IndexLocations;
810
use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES};
9-
use uv_workspace::pyproject::ToolUvSources;
11+
use uv_pypi_types::VerbatimParsedUrl;
12+
use uv_workspace::pyproject::{Sources, ToolUvSources};
1013
use uv_workspace::{DiscoveryOptions, ProjectWorkspace};
1114

1215
#[derive(Debug, Clone)]
@@ -97,48 +100,71 @@ impl RequiresDist {
97100
};
98101

99102
let dev_dependencies = {
103+
// First, collect `tool.uv.dev_dependencies`
100104
let dev_dependencies = project_workspace
101105
.current_project()
102106
.pyproject_toml()
103107
.tool
104108
.as_ref()
105109
.and_then(|tool| tool.uv.as_ref())
106-
.and_then(|uv| uv.dev_dependencies.as_ref())
107-
.into_iter()
110+
.and_then(|uv| uv.dev_dependencies.as_ref());
111+
112+
// Then, collect `dependency-groups`
113+
let dependency_groups = project_workspace
114+
.current_project()
115+
.pyproject_toml()
116+
.dependency_groups
117+
.iter()
108118
.flatten()
109-
.cloned();
110-
let dev_dependencies = match source_strategy {
111-
SourceStrategy::Enabled => dev_dependencies
112-
.flat_map(|requirement| {
113-
let requirement_name = requirement.name.clone();
114-
LoweredRequirement::from_requirement(
115-
requirement,
116-
&metadata.name,
117-
project_workspace.project_root(),
119+
.map(|(name, requirements)| {
120+
(
121+
name.clone(),
122+
requirements
123+
.iter()
124+
.map(|requirement| {
125+
match uv_pep508::Requirement::<VerbatimParsedUrl>::from_str(
126+
requirement,
127+
) {
128+
Ok(requirement) => Ok(requirement),
129+
Err(err) => Err(MetadataError::GroupParseError(
130+
name.clone(),
131+
requirement.clone(),
132+
Box::new(err),
133+
)),
134+
}
135+
})
136+
.collect::<Result<Vec<_>, _>>(),
137+
)
138+
})
139+
.chain(
140+
// Only add the `dev` group if `dev-dependencies` is defined
141+
dev_dependencies
142+
.into_iter()
143+
.map(|requirements| (DEV_DEPENDENCIES.clone(), Ok(requirements.clone()))),
144+
)
145+
.map(|(name, requirements)| {
146+
// Apply sources to the requirements
147+
match requirements {
148+
Ok(requirements) => match apply_source_strategy(
149+
source_strategy,
150+
requirements,
151+
&metadata,
118152
project_sources,
119153
project_indexes,
120154
locations,
121-
project_workspace.workspace(),
155+
project_workspace,
122156
lower_bound,
123-
)
124-
.map(move |requirement| match requirement {
125-
Ok(requirement) => Ok(requirement.into_inner()),
126-
Err(err) => {
127-
Err(MetadataError::LoweringError(requirement_name.clone(), err))
128-
}
129-
})
130-
})
131-
.collect::<Result<Vec<_>, _>>()?,
132-
SourceStrategy::Disabled => dev_dependencies
133-
.into_iter()
134-
.map(uv_pypi_types::Requirement::from)
135-
.collect(),
136-
};
137-
if dev_dependencies.is_empty() {
138-
BTreeMap::default()
139-
} else {
140-
BTreeMap::from([(DEV_DEPENDENCIES.clone(), dev_dependencies)])
141-
}
157+
&name,
158+
) {
159+
Ok(requirements) => Ok((name, requirements)),
160+
Err(err) => Err(err),
161+
},
162+
Err(err) => Err(err),
163+
}
164+
})
165+
.collect::<Result<Vec<_>, _>>()?;
166+
167+
dependency_groups.into_iter().collect::<BTreeMap<_, _>>()
142168
};
143169

144170
let requires_dist = metadata.requires_dist.into_iter();
@@ -158,9 +184,10 @@ impl RequiresDist {
158184
)
159185
.map(move |requirement| match requirement {
160186
Ok(requirement) => Ok(requirement.into_inner()),
161-
Err(err) => {
162-
Err(MetadataError::LoweringError(requirement_name.clone(), err))
163-
}
187+
Err(err) => Err(MetadataError::LoweringError(
188+
requirement_name.clone(),
189+
Box::new(err),
190+
)),
164191
})
165192
})
166193
.collect::<Result<Vec<_>, _>>()?,
@@ -190,6 +217,49 @@ impl From<Metadata> for RequiresDist {
190217
}
191218
}
192219

220+
fn apply_source_strategy(
221+
source_strategy: SourceStrategy,
222+
requirements: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
223+
metadata: &uv_pypi_types::RequiresDist,
224+
project_sources: &BTreeMap<PackageName, Sources>,
225+
project_indexes: &[uv_distribution_types::Index],
226+
locations: &IndexLocations,
227+
project_workspace: &ProjectWorkspace,
228+
lower_bound: LowerBound,
229+
group_name: &GroupName,
230+
) -> Result<Vec<uv_pypi_types::Requirement>, MetadataError> {
231+
match source_strategy {
232+
SourceStrategy::Enabled => requirements
233+
.into_iter()
234+
.flat_map(|requirement| {
235+
let requirement_name = requirement.name.clone();
236+
LoweredRequirement::from_requirement(
237+
requirement,
238+
&metadata.name,
239+
project_workspace.project_root(),
240+
project_sources,
241+
project_indexes,
242+
locations,
243+
project_workspace.workspace(),
244+
lower_bound,
245+
)
246+
.map(move |requirement| match requirement {
247+
Ok(requirement) => Ok(requirement.into_inner()),
248+
Err(err) => Err(MetadataError::GroupLoweringError(
249+
group_name.clone(),
250+
requirement_name.clone(),
251+
Box::new(err),
252+
)),
253+
})
254+
})
255+
.collect::<Result<Vec<_>, _>>(),
256+
SourceStrategy::Disabled => Ok(requirements
257+
.into_iter()
258+
.map(uv_pypi_types::Requirement::from)
259+
.collect()),
260+
}
261+
}
262+
193263
#[cfg(test)]
194264
mod test {
195265
use std::path::Path;
@@ -255,7 +325,7 @@ mod test {
255325
"#};
256326

257327
assert_snapshot!(format_err(input).await, @r###"
258-
error: Failed to parse entry for: `tqdm`
328+
error: Failed to parse entry: `tqdm`
259329
Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
260330
"###);
261331
}
@@ -422,7 +492,7 @@ mod test {
422492
"#};
423493

424494
assert_snapshot!(format_err(input).await, @r###"
425-
error: Failed to parse entry for: `tqdm`
495+
error: Failed to parse entry: `tqdm`
426496
Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
427497
"###);
428498
}
@@ -441,7 +511,7 @@ mod test {
441511
"#};
442512

443513
assert_snapshot!(format_err(input).await, @r###"
444-
error: Failed to parse entry for: `tqdm`
514+
error: Failed to parse entry: `tqdm`
445515
Caused by: Package is not included as workspace package in `tool.uv.workspace`
446516
"###);
447517
}

crates/uv-normalize/src/group_name.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::fmt::{Display, Formatter};
33
use std::str::FromStr;
44
use std::sync::LazyLock;
55

6-
use serde::{Deserialize, Deserializer};
6+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
77

88
use crate::{validate_and_normalize_owned, validate_and_normalize_ref, InvalidNameError};
99

@@ -41,6 +41,15 @@ impl<'de> Deserialize<'de> for GroupName {
4141
}
4242
}
4343

44+
impl Serialize for GroupName {
45+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
46+
where
47+
S: Serializer,
48+
{
49+
self.0.serialize(serializer)
50+
}
51+
}
52+
4453
impl Display for GroupName {
4554
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
4655
self.0.fmt(f)

crates/uv-resolver/src/lock/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ impl Lock {
575575
marker_env: &ResolverMarkerEnvironment,
576576
tags: &Tags,
577577
extras: &ExtrasSpecification,
578-
dev: DevSpecification<'_>,
578+
dev: &DevSpecification,
579579
build_options: &BuildOptions,
580580
install_options: &InstallOptions,
581581
) -> Result<Resolution, LockError> {

crates/uv-resolver/src/lock/requirements_txt.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ impl<'lock> RequirementsTxtExport<'lock> {
4343
lock: &'lock Lock,
4444
root_name: &PackageName,
4545
extras: &ExtrasSpecification,
46-
dev: DevSpecification<'_>,
46+
dev: &DevSpecification,
4747
editable: EditableMode,
4848
hashes: bool,
4949
install_options: &'lock InstallOptions,

0 commit comments

Comments
 (0)