Add --no-group support to CLI (#8477)

## Summary

Now that `default-groups` can include more than just `"dev"`, it makes
sense to allow users to remove groups with `--no-group`.
This commit is contained in:
Charlie Marsh 2024-10-22 16:18:29 -04:00 committed by Zanie Blue
parent 291c4c496d
commit 810b430031
7 changed files with 278 additions and 26 deletions

View file

@ -2617,6 +2617,12 @@ pub struct RunArgs {
#[arg(long, conflicts_with("only_group"))]
pub group: Vec<GroupName>,
/// Exclude dependencies from the specified local dependency group.
///
/// May be provided multiple times.
#[arg(long)]
pub no_group: Vec<GroupName>,
/// Only include dependencies from the specified local dependency group.
///
/// May be provided multiple times.
@ -2808,6 +2814,12 @@ pub struct SyncArgs {
#[arg(long, conflicts_with("only_group"))]
pub group: Vec<GroupName>,
/// Exclude dependencies from the specified local dependency group.
///
/// May be provided multiple times.
#[arg(long)]
pub no_group: Vec<GroupName>,
/// Only include dependencies from the specified local dependency group.
///
/// May be provided multiple times.
@ -3205,6 +3217,12 @@ pub struct TreeArgs {
#[arg(long, conflicts_with("only_group"))]
pub group: Vec<GroupName>,
/// Exclude dependencies from the specified local dependency group.
///
/// May be provided multiple times.
#[arg(long)]
pub no_group: Vec<GroupName>,
/// Only include dependencies from the specified local dependency group.
///
/// May be provided multiple times.
@ -3320,6 +3338,12 @@ pub struct ExportArgs {
#[arg(long, conflicts_with("only_group"))]
pub group: Vec<GroupName>,
/// Exclude dependencies from the specified local dependency group.
///
/// May be provided multiple times.
#[arg(long)]
pub no_group: Vec<GroupName>,
/// Only include dependencies from the specified local dependency group.
///
/// May be provided multiple times.

View file

@ -68,38 +68,76 @@ pub struct DevGroupsSpecification {
#[derive(Debug, Clone)]
pub enum GroupsSpecification {
/// Include dependencies from the specified groups.
Include(Vec<GroupName>),
///
/// The `include` list is guaranteed to omit groups in the `exclude` list (i.e., they have an
/// empty intersection).
Include {
include: Vec<GroupName>,
exclude: Vec<GroupName>,
},
/// Only include dependencies from the specified groups, exclude all other dependencies.
Only(Vec<GroupName>),
///
/// The `include` list is guaranteed to omit groups in the `exclude` list (i.e., they have an
/// empty intersection).
Only {
include: Vec<GroupName>,
exclude: Vec<GroupName>,
},
}
impl GroupsSpecification {
/// Create a [`GroupsSpecification`] that includes the given group.
pub fn from_group(group: GroupName) -> Self {
Self::Include {
include: vec![group],
exclude: Vec::new(),
}
}
/// Returns `true` if the specification allows for production dependencies.
pub fn prod(&self) -> bool {
matches!(self, Self::Include(_))
matches!(self, Self::Include { .. })
}
/// Returns `true` if the specification is limited to a select set of groups.
pub fn only(&self) -> bool {
matches!(self, Self::Only(_))
matches!(self, Self::Only { .. })
}
/// Returns the option that was used to request the groups, if any.
pub fn as_flag(&self) -> Option<Cow<'_, str>> {
match self {
Self::Include(groups) => match groups.as_slice() {
[] => None,
Self::Include { include, exclude } => match include.as_slice() {
[] => match exclude.as_slice() {
[] => None,
[group] => Some(Cow::Owned(format!("--no-group {group}"))),
[..] => Some(Cow::Borrowed("--no-group")),
},
[group] => Some(Cow::Owned(format!("--group {group}"))),
[..] => Some(Cow::Borrowed("--group")),
},
Self::Only(groups) => match groups.as_slice() {
[] => None,
Self::Only { include, exclude } => match include.as_slice() {
[] => match exclude.as_slice() {
[] => None,
[group] => Some(Cow::Owned(format!("--no-group {group}"))),
[..] => Some(Cow::Borrowed("--no-group")),
},
[group] => Some(Cow::Owned(format!("--only-group {group}"))),
[..] => Some(Cow::Borrowed("--only-group")),
},
}
}
/// Iterate over all groups referenced in the [`DevGroupsSpecification`].
pub fn names(&self) -> impl Iterator<Item = &GroupName> {
match self {
GroupsSpecification::Include { include, exclude }
| GroupsSpecification::Only { include, exclude } => {
include.iter().chain(exclude.iter())
}
}
}
/// Iterate over the group names to include.
pub fn iter(&self) -> impl Iterator<Item = &GroupName> {
<&Self as IntoIterator>::into_iter(self)
@ -112,9 +150,14 @@ impl<'a> IntoIterator for &'a GroupsSpecification {
fn into_iter(self) -> Self::IntoIter {
match self {
GroupsSpecification::Include(groups) | GroupsSpecification::Only(groups) => {
groups.iter()
GroupsSpecification::Include {
include,
exclude: _,
}
| GroupsSpecification::Only {
include,
exclude: _,
} => include.iter(),
}
}
}
@ -125,8 +168,9 @@ impl DevGroupsSpecification {
dev: bool,
no_dev: bool,
only_dev: bool,
group: Vec<GroupName>,
only_group: Vec<GroupName>,
mut group: Vec<GroupName>,
no_group: Vec<GroupName>,
mut only_group: Vec<GroupName>,
) -> Self {
let dev = if only_dev {
Some(DevMode::Only)
@ -142,12 +186,31 @@ impl DevGroupsSpecification {
if matches!(dev, Some(DevMode::Only)) {
unreachable!("cannot specify both `--only-dev` and `--group`")
};
Some(GroupsSpecification::Include(group))
// Ensure that `--no-group` and `--group` are mutually exclusive.
group.retain(|group| !no_group.contains(group));
Some(GroupsSpecification::Include {
include: group,
exclude: no_group,
})
} else if !only_group.is_empty() {
if matches!(dev, Some(DevMode::Include)) {
unreachable!("cannot specify both `--dev` and `--only-group`")
};
Some(GroupsSpecification::Only(only_group))
// Ensure that `--no-group` and `--only-group` are mutually exclusive.
only_group.retain(|group| !no_group.contains(group));
Some(GroupsSpecification::Only {
include: only_group,
exclude: no_group,
})
} else if !no_group.is_empty() {
Some(GroupsSpecification::Include {
include: Vec::new(),
exclude: no_group,
})
} else {
None
};
@ -270,8 +333,24 @@ impl DevGroupsManifest {
.iter()
.chain(self.defaults.iter().filter(|default| {
// If `--no-dev` was provided, exclude the `dev` group from the list of defaults.
!matches!(self.spec.dev_mode(), Some(DevMode::Exclude))
|| *default != &*DEV_DEPENDENCIES
if matches!(self.spec.dev_mode(), Some(DevMode::Exclude)) {
if *default == &*DEV_DEPENDENCIES {
return false;
};
}
// If `--no-group` was provided, exclude the group from the list of defaults.
if let Some(GroupsSpecification::Include {
include: _,
exclude,
}) = self.spec.groups()
{
if exclude.contains(default) {
return false;
}
}
true
})),
)
}

View file

@ -827,9 +827,7 @@ async fn lock_and_sync(
DependencyType::Group(ref group_name) => {
let extras = ExtrasSpecification::None;
let dev =
DevGroupsSpecification::from(GroupsSpecification::Include(
vec![group_name.clone()],
));
DevGroupsSpecification::from(GroupsSpecification::from_group(group_name.clone()));
(extras, dev)
}
};

View file

@ -8,8 +8,8 @@ use tracing::debug;
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
Concurrency, Constraints, DevGroupsSpecification, ExtrasSpecification, LowerBound, Reinstall,
Upgrade,
Concurrency, Constraints, DevGroupsSpecification, ExtrasSpecification, GroupsSpecification,
LowerBound, Reinstall, Upgrade,
};
use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
@ -1370,7 +1370,11 @@ pub(crate) fn validate_dependency_groups(
pyproject_toml: &PyProjectToml,
dev: &DevGroupsSpecification,
) -> Result<(), ProjectError> {
for group in dev.groups().into_iter().flatten() {
for group in dev
.groups()
.into_iter()
.flat_map(GroupsSpecification::names)
{
if !pyproject_toml
.dependency_groups
.as_ref()

View file

@ -253,6 +253,7 @@ impl RunSettings {
dev,
no_dev,
group,
no_group,
only_group,
module: _,
only_dev,
@ -282,7 +283,9 @@ impl RunSettings {
flag(all_extras, no_all_extras).unwrap_or_default(),
extra.unwrap_or_default(),
),
dev: DevGroupsSpecification::from_args(dev, no_dev, only_dev, group, only_group),
dev: DevGroupsSpecification::from_args(
dev, no_dev, only_dev, group, no_group, only_group,
),
editable: EditableMode::from_args(no_editable),
with,
with_editable,
@ -718,6 +721,7 @@ impl SyncSettings {
only_dev,
group,
only_group,
no_group,
no_editable,
inexact,
exact,
@ -745,7 +749,9 @@ impl SyncSettings {
flag(all_extras, no_all_extras).unwrap_or_default(),
extra.unwrap_or_default(),
),
dev: DevGroupsSpecification::from_args(dev, no_dev, only_dev, group, only_group),
dev: DevGroupsSpecification::from_args(
dev, no_dev, only_dev, group, no_group, only_group,
),
editable: EditableMode::from_args(no_editable),
install_options: InstallOptions::new(
no_install_project,
@ -1028,6 +1034,7 @@ impl TreeSettings {
only_dev,
no_dev,
group,
no_group,
only_group,
locked,
frozen,
@ -1039,7 +1046,9 @@ impl TreeSettings {
} = args;
Self {
dev: DevGroupsSpecification::from_args(dev, no_dev, only_dev, group, only_group),
dev: DevGroupsSpecification::from_args(
dev, no_dev, only_dev, group, no_group, only_group,
),
locked,
frozen,
universal,
@ -1090,6 +1099,7 @@ impl ExportSettings {
no_dev,
only_dev,
group,
no_group,
only_group,
header,
no_header,
@ -1115,7 +1125,9 @@ impl ExportSettings {
flag(all_extras, no_all_extras).unwrap_or_default(),
extra.unwrap_or_default(),
),
dev: DevGroupsSpecification::from_args(dev, no_dev, only_dev, group, only_group),
dev: DevGroupsSpecification::from_args(
dev, no_dev, only_dev, group, no_group, only_group,
),
editable: EditableMode::from_args(no_editable),
hashes: flag(hashes, no_hashes).unwrap_or(true),
install_options: InstallOptions::new(

View file

@ -1184,6 +1184,84 @@ fn sync_include_group() -> Result<()> {
Ok(())
}
#[test]
fn sync_exclude_group() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[dependency-groups]
foo = ["anyio", {include-group = "bar"}]
bar = ["iniconfig"]
"#,
)?;
context.lock().assert().success();
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ iniconfig==2.0.0
+ sniffio==1.3.1
+ typing-extensions==4.10.0
"###);
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo").arg("--no-group").arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Uninstalled 4 packages in [TIME]
- anyio==4.3.0
- idna==3.6
- iniconfig==2.0.0
- sniffio==1.3.1
"###);
uv_snapshot!(context.filters(), context.sync().arg("--only-group").arg("bar"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
- typing-extensions==4.10.0
"###);
uv_snapshot!(context.filters(), context.sync().arg("--only-group").arg("bar").arg("--no-group").arg("bar"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Uninstalled 1 package in [TIME]
- iniconfig==2.0.0
"###);
Ok(())
}
#[test]
fn sync_dev_group() -> Result<()> {
let context = TestContext::new("3.12");
@ -1257,6 +1335,15 @@ fn sync_non_existent_group() -> Result<()> {
error: Group `baz` is not defined in the project's `dependency-group` table
"###);
uv_snapshot!(context.filters(), context.sync().arg("--no-group").arg("baz"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Group `baz` is not defined in the project's `dependency-group` table
"###);
// Requesting an empty group should succeed.
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r###"
success: true
@ -1407,6 +1494,38 @@ fn sync_default_groups() -> Result<()> {
+ sniffio==1.3.1
"###);
// `--no-group` should remove from the defaults.
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[dependency-groups]
dev = ["iniconfig"]
foo = ["anyio"]
bar = ["requests"]
[tool.uv]
default-groups = ["foo"]
"#,
)?;
uv_snapshot!(context.filters(), context.sync().arg("--no-group").arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 10 packages in [TIME]
Uninstalled 3 packages in [TIME]
- anyio==4.3.0
- idna==3.6
- sniffio==1.3.1
"###);
Ok(())
}

View file

@ -286,6 +286,10 @@ uv run [OPTIONS] [COMMAND]
</dd><dt><code>--no-editable</code></dt><dd><p>Install any editable dependencies, including the project and any workspace members, as non-editable</p>
</dd><dt><code>--no-group</code> <i>no-group</i></dt><dd><p>Exclude dependencies from the specified local dependency group.</p>
<p>May be provided multiple times.</p>
</dd><dt><code>--no-index</code></dt><dd><p>Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via <code>--find-links</code></p>
</dd><dt><code>--no-progress</code></dt><dd><p>Hide all progress outputs.</p>
@ -1539,6 +1543,10 @@ uv sync [OPTIONS]
</dd><dt><code>--no-editable</code></dt><dd><p>Install any editable dependencies, including the project and any workspace members, as non-editable</p>
</dd><dt><code>--no-group</code> <i>no-group</i></dt><dd><p>Exclude dependencies from the specified local dependency group.</p>
<p>May be provided multiple times.</p>
</dd><dt><code>--no-index</code></dt><dd><p>Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via <code>--find-links</code></p>
</dd><dt><code>--no-install-package</code> <i>no-install-package</i></dt><dd><p>Do not install the given package(s).</p>
@ -2189,6 +2197,10 @@ uv export [OPTIONS]
<p>By default, all workspace members and their dependencies are included in the exported requirements file, with all of their dependencies. The <code>--no-emit-workspace</code> option allows exclusion of all the workspace members while retaining their dependencies.</p>
</dd><dt><code>--no-group</code> <i>no-group</i></dt><dd><p>Exclude dependencies from the specified local dependency group.</p>
<p>May be provided multiple times.</p>
</dd><dt><code>--no-hashes</code></dt><dd><p>Omit hashes in the generated output</p>
</dd><dt><code>--no-header</code></dt><dd><p>Exclude the comment header at the top of the generated output file</p>
@ -2506,6 +2518,10 @@ uv tree [OPTIONS]
</dd><dt><code>--no-dev</code></dt><dd><p>Omit development dependencies</p>
</dd><dt><code>--no-group</code> <i>no-group</i></dt><dd><p>Exclude dependencies from the specified local dependency group.</p>
<p>May be provided multiple times.</p>
</dd><dt><code>--no-index</code></dt><dd><p>Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via <code>--find-links</code></p>
</dd><dt><code>--no-progress</code></dt><dd><p>Hide all progress outputs.</p>