diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index b6dfbf137..c100672bd 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2617,6 +2617,12 @@ pub struct RunArgs { #[arg(long, conflicts_with("only_group"))] pub group: Vec, + /// Exclude dependencies from the specified local dependency group. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_group: Vec, + /// 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, + /// Exclude dependencies from the specified local dependency group. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_group: Vec, + /// 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, + /// Exclude dependencies from the specified local dependency group. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_group: Vec, + /// 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, + /// Exclude dependencies from the specified local dependency group. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_group: Vec, + /// Only include dependencies from the specified local dependency group. /// /// May be provided multiple times. diff --git a/crates/uv-configuration/src/dev.rs b/crates/uv-configuration/src/dev.rs index 13a1f65d9..1c55eaa14 100644 --- a/crates/uv-configuration/src/dev.rs +++ b/crates/uv-configuration/src/dev.rs @@ -68,38 +68,76 @@ pub struct DevGroupsSpecification { #[derive(Debug, Clone)] pub enum GroupsSpecification { /// Include dependencies from the specified groups. - Include(Vec), + /// + /// The `include` list is guaranteed to omit groups in the `exclude` list (i.e., they have an + /// empty intersection). + Include { + include: Vec, + exclude: Vec, + }, /// Only include dependencies from the specified groups, exclude all other dependencies. - Only(Vec), + /// + /// The `include` list is guaranteed to omit groups in the `exclude` list (i.e., they have an + /// empty intersection). + Only { + include: Vec, + exclude: Vec, + }, } 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> { 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 { + 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 { <&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, - only_group: Vec, + mut group: Vec, + no_group: Vec, + mut only_group: Vec, ) -> 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 })), ) } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 20290a92b..772b03017 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -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) } }; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 07fae6273..b6f8c53ef 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -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() diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 6561dd27f..11237f6fc 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -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( diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 04ef7d210..baf0eeeba 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -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(()) } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1046ccde5..9e5f9f93e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -286,6 +286,10 @@ uv run [OPTIONS] [COMMAND]
--no-editable

Install any editable dependencies, including the project and any workspace members, as non-editable

+
--no-group no-group

Exclude dependencies from the specified local dependency group.

+ +

May be provided multiple times.

+
--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

--no-progress

Hide all progress outputs.

@@ -1539,6 +1543,10 @@ uv sync [OPTIONS]
--no-editable

Install any editable dependencies, including the project and any workspace members, as non-editable

+
--no-group no-group

Exclude dependencies from the specified local dependency group.

+ +

May be provided multiple times.

+
--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

--no-install-package no-install-package

Do not install the given package(s).

@@ -2189,6 +2197,10 @@ uv export [OPTIONS]

By default, all workspace members and their dependencies are included in the exported requirements file, with all of their dependencies. The --no-emit-workspace option allows exclusion of all the workspace members while retaining their dependencies.

+
--no-group no-group

Exclude dependencies from the specified local dependency group.

+ +

May be provided multiple times.

+
--no-hashes

Omit hashes in the generated output

--no-header

Exclude the comment header at the top of the generated output file

@@ -2506,6 +2518,10 @@ uv tree [OPTIONS]
--no-dev

Omit development dependencies

+
--no-group no-group

Exclude dependencies from the specified local dependency group.

+ +

May be provided multiple times.

+
--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

--no-progress

Hide all progress outputs.