From e485dfd7f1984537374676e422691c2327c4e24e Mon Sep 17 00:00:00 2001 From: Li-Lun Lin <70696274+alan910127@users.noreply.github.com> Date: Sun, 24 Nov 2024 10:25:09 +0800 Subject: [PATCH] feat: add support for `--no-extra` flag and setting (#9387) ## Summary Resolves #9333 This pull request introduces support for the `--no-extra` command-line flag and the corresponding `no-extra` UV setting. ### Behavior - When `--all-extras` is supplied, the specified extras in `--no-extra` will be excluded from the installation. - If `--all-extras` is not supplied, `--no-extra` has no effect and is safely ignored. ## Test Plan Since `ExtrasSpecification::from_args` and `ExtrasSpecification::extra_names` are the most important parts in the implementation, I added the following tests in the `uv-configuration/src/extras.rs` module: - **`test_no_extra_full`**: Verifies behavior when `no_extra` includes the entire list of extras. - **`test_no_extra_partial`**: Tests partial exclusion, ensuring only specified extras are excluded. - **`test_no_extra_empty`**: Confirms that no extras are excluded if `no_extra` is empty. - **`test_no_extra_excessive`**: Ensures the implementation ignores `no_extra` values that don't match any available extras. - **`test_no_extra_without_all_extras`**: Validates that `no_extra` has no effect when `--all-extras` is not supplied. - **`test_no_extra_without_package_extras`**: Confirms correct behavior when no extras are available in the package. - **`test_no_extra_duplicates`**: Verifies that duplicate entries in `pkg_extras` or `no_extra` do not cause errors. --------- Co-authored-by: Charlie Marsh --- crates/uv-cli/src/lib.rs | 18 +++ crates/uv-configuration/src/extras.rs | 143 +++++++++++++++++- crates/uv-requirements/src/source_tree.rs | 12 +- .../uv-resolver/src/lock/requirements_txt.rs | 14 +- crates/uv-resolver/src/lock/target.rs | 14 +- crates/uv-settings/src/settings.rs | 10 ++ crates/uv/src/settings.rs | 8 + crates/uv/tests/it/export.rs | 48 ++++++ crates/uv/tests/it/sync.rs | 12 ++ docs/reference/cli.md | 12 ++ docs/reference/settings.md | 28 ++++ uv.schema.json | 10 ++ 12 files changed, 297 insertions(+), 32 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 48255c611fac..a6f67b1f9028 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2599,6 +2599,12 @@ pub struct RunArgs { #[arg(long, conflicts_with = "extra")] pub all_extras: bool, + /// Exclude the specified optional dependencies, if `--all-extras` is supplied. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_extra: Vec, + #[arg(long, overrides_with("all_extras"), hide = true)] pub no_all_extras: bool, @@ -2836,6 +2842,12 @@ pub struct SyncArgs { #[arg(long, conflicts_with = "extra")] pub all_extras: bool, + /// Exclude the specified optional dependencies, if `--all-extras` is supplied. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_extra: Vec, + #[arg(long, overrides_with("all_extras"), hide = true)] pub no_all_extras: bool, @@ -3418,6 +3430,12 @@ pub struct ExportArgs { #[arg(long, conflicts_with = "extra")] pub all_extras: bool, + /// Exclude the specified optional dependencies, if `--all-extras` is supplied. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_extra: Vec, + #[arg(long, overrides_with("all_extras"), hide = true)] pub no_all_extras: bool, diff --git a/crates/uv-configuration/src/extras.rs b/crates/uv-configuration/src/extras.rs index 7704ff2fc3e0..79cdc2714107 100644 --- a/crates/uv-configuration/src/extras.rs +++ b/crates/uv-configuration/src/extras.rs @@ -1,3 +1,4 @@ +use rustc_hash::FxHashSet; use uv_normalize::ExtraName; #[derive(Debug, Default, Clone)] @@ -6,16 +7,25 @@ pub enum ExtrasSpecification { None, All, Some(Vec), + Exclude(FxHashSet), } impl ExtrasSpecification { /// Determine the extras specification to use based on the command-line arguments. - pub fn from_args(all_extras: bool, extra: Vec) -> Self { - if all_extras { + pub fn from_args( + all_extras: bool, + no_extra: Vec, + mut extra: Vec, + ) -> Self { + if all_extras && !no_extra.is_empty() { + ExtrasSpecification::Exclude(FxHashSet::from_iter(no_extra)) + } else if all_extras { ExtrasSpecification::All } else if extra.is_empty() { ExtrasSpecification::None } else { + // If a package is included in both `no_extra` and `extra`, it should be excluded. + extra.retain(|name| !no_extra.contains(name)); ExtrasSpecification::Some(extra) } } @@ -26,10 +36,139 @@ impl ExtrasSpecification { ExtrasSpecification::All => true, ExtrasSpecification::None => false, ExtrasSpecification::Some(extras) => extras.contains(name), + ExtrasSpecification::Exclude(excluded) => !excluded.contains(name), } } pub fn is_empty(&self) -> bool { matches!(self, ExtrasSpecification::None) } + + pub fn extra_names<'a, Names>(&'a self, all_names: Names) -> ExtrasIter<'a, Names> + where + Names: Iterator, + { + match self { + ExtrasSpecification::All => ExtrasIter::All(all_names), + ExtrasSpecification::None => ExtrasIter::None, + ExtrasSpecification::Some(extras) => ExtrasIter::Some(extras.iter()), + ExtrasSpecification::Exclude(excluded) => ExtrasIter::Exclude(all_names, excluded), + } + } +} + +/// An iterator over the extra names to include. +#[derive(Debug)] +pub enum ExtrasIter<'a, Names: Iterator> { + None, + All(Names), + Some(std::slice::Iter<'a, ExtraName>), + Exclude(Names, &'a FxHashSet), +} + +impl<'a, Names: Iterator> Iterator for ExtrasIter<'a, Names> { + type Item = &'a ExtraName; + + fn next(&mut self) -> Option { + match self { + Self::All(names) => names.next(), + Self::None => None, + Self::Some(extras) => extras.next(), + Self::Exclude(names, excluded) => { + for name in names.by_ref() { + if !excluded.contains(name) { + return Some(name); + } + } + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! extras { + () => ( + Vec::new() + ); + ($($x:expr),+ $(,)?) => ( + vec![$(ExtraName::new($x.into()).unwrap()),+] + ) + } + + #[test] + fn test_no_extra_full() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-2"]; + let no_extra = extras!["dev", "docs", "extra-1", "extra-2"]; + let spec = ExtrasSpecification::from_args(true, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras![]); + } + + #[test] + fn test_no_extra_partial() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-2"]; + let no_extra = extras!["extra-1", "extra-2"]; + let spec = ExtrasSpecification::from_args(true, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras!["dev", "docs"]); + } + + #[test] + fn test_no_extra_empty() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-2"]; + let no_extra = extras![]; + let spec = ExtrasSpecification::from_args(true, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras!["dev", "docs", "extra-1", "extra-2"]); + } + + #[test] + fn test_no_extra_excessive() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-2"]; + let no_extra = extras!["does-not-exists"]; + let spec = ExtrasSpecification::from_args(true, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras!["dev", "docs", "extra-1", "extra-2"]); + } + + #[test] + fn test_no_extra_without_all_extras() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-2"]; + let no_extra = extras!["extra-1", "extra-2"]; + let spec = ExtrasSpecification::from_args(false, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras![]); + } + + #[test] + fn test_no_extra_without_package_extras() { + let pkg_extras = extras![]; + let no_extra = extras!["extra-1", "extra-2"]; + let spec = ExtrasSpecification::from_args(true, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras![]); + } + + #[test] + fn test_no_extra_duplicates() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-1", "extra-2"]; + let no_extra = extras!["extra-1", "extra-2"]; + let spec = ExtrasSpecification::from_args(true, no_extra, vec![]); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras!["dev", "docs"]); + } + + #[test] + fn test_no_extra_extra() { + let pkg_extras = extras!["dev", "docs", "extra-1", "extra-2"]; + let no_extra = extras!["extra-1", "extra-2"]; + let extra = extras!["extra-1", "extra-2", "docs"]; + let spec = ExtrasSpecification::from_args(false, no_extra, extra); + let result: Vec<_> = spec.extra_names(pkg_extras.iter()).cloned().collect(); + assert_eq!(result, extras!["docs"]); + } } diff --git a/crates/uv-requirements/src/source_tree.rs b/crates/uv-requirements/src/source_tree.rs index 54777611da61..c88411949ce9 100644 --- a/crates/uv-requirements/src/source_tree.rs +++ b/crates/uv-requirements/src/source_tree.rs @@ -89,11 +89,11 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { let origin = RequirementOrigin::Project(path.to_path_buf(), metadata.name.clone()); // Determine the extras to include when resolving the requirements. - let extras = match self.extras { - ExtrasSpecification::All => metadata.provides_extras.as_slice(), - ExtrasSpecification::None => &[], - ExtrasSpecification::Some(extras) => extras, - }; + let extras: Vec<_> = self + .extras + .extra_names(metadata.provides_extras.iter()) + .cloned() + .collect(); // Determine the appropriate requirements to return based on the extras. This involves // evaluating the `extras` expression in any markers, but preserving the remaining marker @@ -103,7 +103,7 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { .into_iter() .map(|requirement| Requirement { origin: Some(origin.clone()), - marker: requirement.marker.simplify_extras(extras), + marker: requirement.marker.simplify_extras(&extras), ..requirement }) .collect(); diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/requirements_txt.rs index 1e7e70218ee2..8f1d2fbcdf84 100644 --- a/crates/uv-resolver/src/lock/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/requirements_txt.rs @@ -77,18 +77,8 @@ impl<'lock> RequirementsTxtExport<'lock> { // Push its dependencies on the queue. queue.push_back((dist, None)); - match extras { - ExtrasSpecification::None => {} - ExtrasSpecification::All => { - for extra in dist.optional_dependencies.keys() { - queue.push_back((dist, Some(extra))); - } - } - ExtrasSpecification::Some(extras) => { - for extra in extras { - queue.push_back((dist, Some(extra))); - } - } + for extra in extras.extra_names(dist.optional_dependencies.keys()) { + queue.push_back((dist, Some(extra))); } } diff --git a/crates/uv-resolver/src/lock/target.rs b/crates/uv-resolver/src/lock/target.rs index 1871893d5110..347cc2dfe33a 100644 --- a/crates/uv-resolver/src/lock/target.rs +++ b/crates/uv-resolver/src/lock/target.rs @@ -193,18 +193,8 @@ impl<'env> InstallTarget<'env> { if dev.prod() { // Push its dependencies on the queue. queue.push_back((dist, None)); - match extras { - ExtrasSpecification::None => {} - ExtrasSpecification::All => { - for extra in dist.optional_dependencies.keys() { - queue.push_back((dist, Some(extra))); - } - } - ExtrasSpecification::Some(extras) => { - for extra in extras { - queue.push_back((dist, Some(extra))); - } - } + for extra in extras.extra_names(dist.optional_dependencies.keys()) { + queue.push_back((dist, Some(extra))); } } diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 7d02685d6217..7d898d46af60 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -1009,6 +1009,16 @@ pub struct PipOptions { "# )] pub all_extras: Option, + /// Exclude the specified optional dependencies if `all-extras` is supplied. + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + all-extras = true + no-extra = ["dev", "docs"] + "# + )] + pub no_extra: Option>, /// Ignore package dependencies, instead only add those packages explicitly listed /// on the command line to the resulting the requirements file. #[option( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b9d82bae209e..3b7b9ff60de4 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -282,6 +282,7 @@ impl RunSettings { let RunArgs { extra, all_extras, + no_extra, no_all_extras, dev, no_dev, @@ -323,6 +324,7 @@ impl RunSettings { frozen, extras: ExtrasSpecification::from_args( flag(all_extras, no_all_extras).unwrap_or_default(), + no_extra, extra.unwrap_or_default(), ), dev: DevGroupsSpecification::from_args( @@ -884,6 +886,7 @@ impl SyncSettings { let SyncArgs { extra, all_extras, + no_extra, no_all_extras, dev, no_dev, @@ -922,6 +925,7 @@ impl SyncSettings { frozen, extras: ExtrasSpecification::from_args( flag(all_extras, no_all_extras).unwrap_or_default(), + no_extra, extra.unwrap_or_default(), ), dev: DevGroupsSpecification::from_args( @@ -1306,6 +1310,7 @@ impl ExportSettings { prune, extra, all_extras, + no_extra, no_all_extras, dev, no_dev, @@ -1342,6 +1347,7 @@ impl ExportSettings { prune, extras: ExtrasSpecification::from_args( flag(all_extras, no_all_extras).unwrap_or_default(), + no_extra, extra.unwrap_or_default(), ), dev: DevGroupsSpecification::from_args( @@ -2490,6 +2496,7 @@ impl PipSettings { strict, extra, all_extras, + no_extra, no_deps, allow_empty_requirements, resolution, @@ -2601,6 +2608,7 @@ impl PipSettings { ), extras: ExtrasSpecification::from_args( args.all_extras.combine(all_extras).unwrap_or_default(), + args.no_extra.combine(no_extra).unwrap_or_default(), args.extra.combine(extra).unwrap_or_default(), ), dependency_mode: if args.no_deps.combine(no_deps).unwrap_or_default() { diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 0ae9a41561a1..e07ef64491f0 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -206,6 +206,30 @@ fn project_extra() -> Result<()> { Resolved 6 packages in [TIME] "###); + uv_snapshot!(context.filters(), context.export().arg("--extra").arg("pytest").arg("--extra").arg("async").arg("--no-extra").arg("pytest"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --extra pytest --extra async --no-extra pytest + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + typing-extensions==4.10.0 \ + --hash=sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475 \ + --hash=sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb + + ----- stderr ----- + Resolved 6 packages in [TIME] + "###); + uv_snapshot!(context.filters(), context.export().arg("--extra").arg("pytest"), @r###" success: true exit_code: 0 @@ -251,6 +275,30 @@ fn project_extra() -> Result<()> { Resolved 6 packages in [TIME] "###); + uv_snapshot!(context.filters(), context.export().arg("--all-extras").arg("--no-extra").arg("pytest"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --all-extras --no-extra pytest + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + typing-extensions==4.10.0 \ + --hash=sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475 \ + --hash=sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb + + ----- stderr ----- + Resolved 6 packages in [TIME] + "###); + Ok(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 770042d9fb08..985b7e0ef150 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -4349,6 +4349,18 @@ fn sync_all_extras() -> Result<()> { + typing-extensions==4.10.0 "###); + // Sync all extras. + uv_snapshot!(context.filters(), context.sync().arg("--all-packages").arg("--all-extras").arg("--no-extra").arg("types"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + Uninstalled 1 package in [TIME] + - typing-extensions==4.10.0 + "###); + Ok(()) } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 57e1835f11f2..5ff6e145446a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -308,6 +308,10 @@ uv run [OPTIONS] [COMMAND]
--no-env-file

Avoid reading environment variables from a .env file

May also be set with the UV_NO_ENV_FILE environment variable.

+
--no-extra no-extra

Exclude the specified optional dependencies, if --all-extras is supplied.

+ +

May be provided multiple times.

+
--no-group no-group

Exclude dependencies from the specified dependency group.

May be provided multiple times.

@@ -1612,6 +1616,10 @@ uv sync [OPTIONS]
--no-editable

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

+
--no-extra no-extra

Exclude the specified optional dependencies, if --all-extras is supplied.

+ +

May be provided multiple times.

+
--no-group no-group

Exclude dependencies from the specified dependency group.

May be provided multiple times.

@@ -2286,6 +2294,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-extra no-extra

Exclude the specified optional dependencies, if --all-extras is supplied.

+ +

May be provided multiple times.

+
--no-group no-group

Exclude dependencies from the specified dependency group.

May be provided multiple times.

diff --git a/docs/reference/settings.md b/docs/reference/settings.md index ef7a63ef542b..20f0b1ef6c85 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -2431,6 +2431,34 @@ included in the resolution. Equivalent to pip-compile's `--unsafe-package` optio --- +#### [`no-extra`](#pip_no-extra) {: #pip_no-extra } + + +Exclude the specified optional dependencies if `all-extras` is supplied. + +**Default value**: `[]` + +**Type**: `list[str]` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv.pip] + all-extras = true + no-extra = ["dev", "docs"] + ``` +=== "uv.toml" + + ```toml + [pip] + all-extras = true + no-extra = ["dev", "docs"] + ``` + +--- + #### [`no-header`](#pip_no-header) {: #pip_no-header } diff --git a/uv.schema.json b/uv.schema.json index 9995ea244d94..092cf3ad3c36 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1037,6 +1037,16 @@ "$ref": "#/definitions/PackageName" } }, + "no-extra": { + "description": "Exclude the specified optional dependencies if `all-extras` is supplied.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ExtraName" + } + }, "no-header": { "description": "Exclude the comment header at the top of output file generated by `uv pip compile`.", "type": [