diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index e1540a27d9a1c..1758aa4480575 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -1,6 +1,6 @@ use std::{borrow::Cow, sync::Arc}; -use uv_normalize::{GroupName, DEV_DEPENDENCIES}; +use uv_normalize::{DefaultGroups, GroupName, DEV_DEPENDENCIES}; /// Manager of all dependency-group decisions and settings history. /// @@ -63,10 +63,18 @@ impl DependencyGroups { } else { // Merge all these lists, they're equivalent now group.append(&mut only_group); + // Resolve default groups potentially also setting All if default_groups { - group.append(&mut defaults); + match &mut defaults { + DefaultGroups::All => IncludeGroups::All, + DefaultGroups::List(defaults) => { + group.append(defaults); + IncludeGroups::Some(group) + } + } + } else { + IncludeGroups::Some(group) } - IncludeGroups::Some(group) }; Self(Arc::new(DependencyGroupsInner { @@ -112,7 +120,7 @@ impl DependencyGroups { all_groups, no_default_groups, // This is unknown at CLI-time, use `.with_defaults(...)` to apply this later! - defaults: Vec::new(), + defaults: DefaultGroups::default(), }) } @@ -135,7 +143,7 @@ impl DependencyGroups { /// Apply defaults to a base [`DependencyGroups`]. /// /// This is appropriate in projects, where the `dev` group is synced by default. - pub fn with_defaults(&self, defaults: Vec) -> DependencyGroupsWithDefaults { + pub fn with_defaults(&self, defaults: DefaultGroups) -> DependencyGroupsWithDefaults { // Explicitly clone the inner history and set the defaults, then remake the result. let mut history = self.0.history.clone(); history.defaults = defaults; @@ -220,7 +228,7 @@ pub struct DependencyGroupsHistory { pub no_group: Vec, pub all_groups: bool, pub no_default_groups: bool, - pub defaults: Vec, + pub defaults: DefaultGroups, } impl DependencyGroupsHistory { diff --git a/crates/uv-normalize/src/group_name.rs b/crates/uv-normalize/src/group_name.rs index 2abbb8715aa42..7fc4f0e3b5bbf 100644 --- a/crates/uv-normalize/src/group_name.rs +++ b/crates/uv-normalize/src/group_name.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::LazyLock; +use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use uv_small_str::SmallString; @@ -164,6 +165,87 @@ impl Display for PipGroupName { } } +/// Either the literal "all" or a list of groups +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum DefaultGroups { + /// All groups are defaulted + All, + /// A list of groups + List(Vec), +} + +/// Serialize a [`DefaultGroups`] struct into a list of marker strings. +impl serde::Serialize for DefaultGroups { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + DefaultGroups::All => serializer.serialize_str("all"), + DefaultGroups::List(groups) => { + let mut seq = serializer.serialize_seq(Some(groups.len()))?; + for group in groups { + seq.serialize_element(&group)?; + } + seq.end() + } + } + } +} + +/// Deserialize a "all" or list of [`GroupName`] into a [`DefaultGroups`] enum. +impl<'de> serde::Deserialize<'de> for DefaultGroups { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct StringOrVecVisitor; + + impl<'de> serde::de::Visitor<'de> for StringOrVecVisitor { + type Value = DefaultGroups; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str(r#"the string "all" or a list of strings"#) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if value != "all" { + return Err(serde::de::Error::custom( + r#"default-groups must be "all" or a ["list", "of", "groups"]"#, + )); + } + Ok(DefaultGroups::All) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut groups = Vec::new(); + + while let Some(elem) = seq.next_element::()? { + groups.push(elem); + } + + Ok(DefaultGroups::List(groups)) + } + } + + deserializer.deserialize_any(StringOrVecVisitor) + } +} + +impl Default for DefaultGroups { + /// Note this is an "empty" default unlike other contexts where `["dev"]` is the default + fn default() -> Self { + DefaultGroups::List(Vec::new()) + } +} + /// The name of the global `dev-dependencies` group. /// /// Internally, we model dependency groups as a generic concept; but externally, we only expose the diff --git a/crates/uv-normalize/src/lib.rs b/crates/uv-normalize/src/lib.rs index 46288325af8e5..f325b77e211c5 100644 --- a/crates/uv-normalize/src/lib.rs +++ b/crates/uv-normalize/src/lib.rs @@ -3,7 +3,7 @@ use std::fmt::{Display, Formatter}; pub use dist_info_name::DistInfoName; pub use extra_name::ExtraName; -pub use group_name::{GroupName, PipGroupName, DEV_DEPENDENCIES}; +pub use group_name::{DefaultGroups, GroupName, PipGroupName, DEV_DEPENDENCIES}; pub use package_name::PackageName; use uv_small_str::SmallString; diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 41c674c9c472f..d529dc7d5f051 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -23,7 +23,7 @@ use uv_distribution_types::{Index, IndexName}; use uv_fs::{relative_to, PortablePathBuf}; use uv_git_types::GitReference; use uv_macros::OptionsMetadata; -use uv_normalize::{ExtraName, GroupName, PackageName}; +use uv_normalize::{DefaultGroups, ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_pypi_types::{ @@ -343,14 +343,16 @@ pub struct ToolUv { pub package: Option, /// The list of `dependency-groups` to install by default. + /// + /// Can also be the literal "all" to default enable all groups. #[option( default = r#"["dev"]"#, - value_type = "list[str]", + value_type = r#"str | list[str]"#, example = r#" default-groups = ["docs"] "# )] - pub default_groups: Option>, + pub default_groups: Option, /// The project's development dependencies. /// diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 6d6ec6ff59301..c9db765e9635b 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -28,7 +28,7 @@ use uv_distribution_types::{ use uv_fs::Simplified; use uv_git::GIT_STORE; use uv_git_types::GitReference; -use uv_normalize::{PackageName, DEV_DEPENDENCIES}; +use uv_normalize::{DefaultGroups, PackageName, DEV_DEPENDENCIES}; use uv_pep508::{ExtraName, MarkerTree, Requirement, UnnamedRequirement, VersionOrUrl}; use uv_pypi_types::{redact_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl}; use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; @@ -897,7 +897,7 @@ async fn lock_and_sync( target, venv, &extras, - &dev.with_defaults(Vec::new()), + &dev.with_defaults(DefaultGroups::default()), EditableMode::Editable, InstallOptions::default(), Modifications::Sufficient, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index d707b5a0f548c..061aa6f692e90 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -11,7 +11,7 @@ use uv_configuration::{ Concurrency, DependencyGroups, EditableMode, ExportFormat, ExtrasSpecification, InstallOptions, PreviewMode, }; -use uv_normalize::PackageName; +use uv_normalize::{DefaultGroups, PackageName}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_resolver::RequirementsTxtExport; use uv_scripts::{Pep723ItemRef, Pep723Script}; @@ -110,7 +110,7 @@ pub(crate) async fn export( // Determine the default groups to include. let defaults = match &target { ExportTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, - ExportTarget::Script(_) => vec![], + ExportTarget::Script(_) => DefaultGroups::default(), }; let dev = dev.with_defaults(defaults); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 0966c2dd98543..578b9b3da2223 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -24,7 +24,7 @@ use uv_distribution_types::{ use uv_fs::{LockedFile, Simplified, CWD}; use uv_git::ResolvedRepositoryReference; use uv_installer::{SatisfiesResult, SitePackages}; -use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_normalize::{DefaultGroups, ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts, Requirement}; @@ -2276,24 +2276,26 @@ pub(crate) async fn init_script_python_requirement( #[allow(clippy::result_large_err)] pub(crate) fn default_dependency_groups( pyproject_toml: &PyProjectToml, -) -> Result, ProjectError> { +) -> Result { if let Some(defaults) = pyproject_toml .tool .as_ref() .and_then(|tool| tool.uv.as_ref().and_then(|uv| uv.default_groups.as_ref())) { - for group in defaults { - if !pyproject_toml - .dependency_groups - .as_ref() - .is_some_and(|groups| groups.contains_key(group)) - { - return Err(ProjectError::MissingDefaultGroup(group.clone())); + if let DefaultGroups::List(defaults) = defaults { + for group in defaults { + if !pyproject_toml + .dependency_groups + .as_ref() + .is_some_and(|groups| groups.contains_key(group)) + { + return Err(ProjectError::MissingDefaultGroup(group.clone())); + } } } Ok(defaults.clone()) } else { - Ok(vec![DEV_DEPENDENCIES.clone()]) + Ok(DefaultGroups::List(vec![DEV_DEPENDENCIES.clone()])) } } diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 4314364f7d7aa..ded2fc2f787e3 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -23,7 +23,7 @@ use uv_configuration::{ use uv_fs::which::is_executable; use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; -use uv_normalize::PackageName; +use uv_normalize::{DefaultGroups, PackageName}; use uv_python::{ EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, @@ -284,7 +284,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl target, &environment, &extras, - &dev.with_defaults(Vec::new()), + &dev.with_defaults(DefaultGroups::default()), editable, install_options, modifications, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index a487c34e67aa5..001f256001209 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -20,7 +20,7 @@ use uv_distribution_types::{ }; use uv_fs::Simplified; use uv_installer::SitePackages; -use uv_normalize::PackageName; +use uv_normalize::{DefaultGroups, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; @@ -117,7 +117,7 @@ pub(crate) async fn sync( // Determine the default groups to include. let defaults = match &target { SyncTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, - SyncTarget::Script(..) => Vec::new(), + SyncTarget::Script(..) => DefaultGroups::default(), }; // Discover or create the virtual environment. diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index c30071d560a10..f0633db7ecd2e 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -10,6 +10,7 @@ use uv_cache_info::Timestamp; use uv_client::RegistryClientBuilder; use uv_configuration::{Concurrency, DependencyGroups, PreviewMode, TargetTriple}; use uv_distribution_types::IndexCapabilities; +use uv_normalize::DefaultGroups; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest, PythonVersion}; use uv_resolver::{PackageMap, TreeDisplay}; @@ -74,7 +75,7 @@ pub(crate) async fn tree( // Determine the default groups to include. let defaults = match target { LockTarget::Workspace(workspace) => default_dependency_groups(workspace.pyproject_toml())?, - LockTarget::Script(_) => vec![], + LockTarget::Script(_) => DefaultGroups::default(), }; let native_tls = network_settings.native_tls; diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 0a81b9f60fbd6..edd69abec35e5 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -2334,6 +2334,176 @@ fn sync_default_groups() -> Result<()> { Ok(()) } +/// default-groups = "all" sugar works +#[test] +fn sync_default_groups_all() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + dev = ["iniconfig"] + foo = ["anyio"] + bar = ["requests"] + + [tool.uv] + default-groups = "all" + "#, + )?; + + context.lock().assert().success(); + + // groups = "all" should behave like --all-groups in contexts where defaults exist + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 10 packages in [TIME] + Prepared 9 packages in [TIME] + Installed 9 packages in [TIME] + + anyio==4.3.0 + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + idna==3.6 + + iniconfig==2.0.0 + + requests==2.31.0 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + + urllib3==2.2.1 + "); + + // Using `--no-default-groups` should still work + uv_snapshot!(context.filters(), context.sync().arg("--no-default-groups"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 10 packages in [TIME] + Uninstalled 8 packages in [TIME] + - anyio==4.3.0 + - certifi==2024.2.2 + - charset-normalizer==3.3.2 + - idna==3.6 + - iniconfig==2.0.0 + - requests==2.31.0 + - sniffio==1.3.1 + - urllib3==2.2.1 + "); + + // Using `--all-groups` should be redundant and work fine + uv_snapshot!(context.filters(), context.sync().arg("--all-groups"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 10 packages in [TIME] + Installed 8 packages in [TIME] + + anyio==4.3.0 + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + idna==3.6 + + iniconfig==2.0.0 + + requests==2.31.0 + + sniffio==1.3.1 + + urllib3==2.2.1 + "###); + + // Using `--no-dev` should exclude just the dev group + uv_snapshot!(context.filters(), context.sync().arg("--no-dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 10 packages in [TIME] + Uninstalled 1 package in [TIME] + - iniconfig==2.0.0 + "); + + // Using `--group` should be redundant and still work fine + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 10 packages in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + // Using `--only-group` should still disable defaults + uv_snapshot!(context.filters(), context.sync().arg("--only-group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 10 packages in [TIME] + Uninstalled 6 packages in [TIME] + - certifi==2024.2.2 + - charset-normalizer==3.3.2 + - iniconfig==2.0.0 + - requests==2.31.0 + - typing-extensions==4.10.0 + - urllib3==2.2.1 + "); + + Ok(()) +} + +/// default-groups = "gibberish" error +#[test] +fn sync_default_groups_gibberish() -> 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] + dev = ["iniconfig"] + foo = ["anyio"] + bar = ["requests"] + + [tool.uv] + default-groups = "gibberish" + "#, + )?; + + uv_snapshot!(context.filters(), context.sync(), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `pyproject.toml` + Caused by: TOML parse error at line 14, column 26 + | + 14 | default-groups = "gibberish" + | ^^^^^^^^^^^ + default-groups must be "all" or a ["list", "of", "groups"] + "#); + + Ok(()) +} + /// Sync with `--only-group`, where the group includes a workspace member. #[test] fn sync_group_member() -> Result<()> { diff --git a/docs/concepts/projects/dependencies.md b/docs/concepts/projects/dependencies.md index e731cc0ed6b8a..f6780b6387820 100644 --- a/docs/concepts/projects/dependencies.md +++ b/docs/concepts/projects/dependencies.md @@ -683,6 +683,13 @@ By default, uv includes the `dev` dependency group in the environment (e.g., dur default-groups = ["dev", "foo"] ``` +To enable all dependencies groups by default, use `"all"` instead of listing group names: + +```toml title="pyproject.toml" +[tool.uv] +default-groups = "all" +``` + !!! tip To disable this behaviour during `uv run` or `uv sync`, use `--no-default-groups`. diff --git a/docs/reference/settings.md b/docs/reference/settings.md index f005354df2693..171036e5d67f6 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -112,9 +112,11 @@ constraint-dependencies = ["grpcio<1.65"] The list of `dependency-groups` to install by default. +Can also be the literal "all" to default enable all groups. + **Default value**: `["dev"]` -**Type**: `list[str]` +**Type**: `str | list[str]` **Example usage**: diff --git a/uv.schema.json b/uv.schema.json index d051d05fe6265..15d8cecbc1428 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -119,14 +119,15 @@ } }, "default-groups": { - "description": "The list of `dependency-groups` to install by default.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/GroupName" - } + "description": "The list of `dependency-groups` to install by default.\n\nCan also be the literal \"all\" to default enable all groups.", + "anyOf": [ + { + "$ref": "#/definitions/DefaultGroups" + }, + { + "type": "null" + } + ] }, "dependency-metadata": { "description": "Pre-defined static metadata for dependencies of the project (direct or transitive). When provided, enables the resolver to use the specified metadata instead of querying the registry or building the relevant package from source.\n\nMetadata should be provided in adherence with the [Metadata 2.3](https://packaging.python.org/en/latest/specifications/core-metadata/) standard, though only the following fields are respected:\n\n- `name`: The name of the package. - (Optional) `version`: The version of the package. If omitted, the metadata will be applied to all versions of the package. - (Optional) `requires-dist`: The dependencies of the package (e.g., `werkzeug>=0.14`). - (Optional) `requires-python`: The Python version required by the package (e.g., `>=3.10`). - (Optional) `provides-extras`: The extras provided by the package.", @@ -683,6 +684,34 @@ "$ref": "#/definitions/ConfigSettingValue" } }, + "DefaultGroups": { + "description": "Either the literal \"all\" or a list of groups", + "oneOf": [ + { + "description": "All groups are defaulted", + "type": "string", + "enum": [ + "All" + ] + }, + { + "description": "A list of groups", + "type": "object", + "required": [ + "List" + ], + "properties": { + "List": { + "type": "array", + "items": { + "$ref": "#/definitions/GroupName" + } + } + }, + "additionalProperties": false + } + ] + }, "ExcludeNewer": { "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).", "type": "string",