diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 63b59cf4e4951..d70fa9cefd045 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2622,6 +2622,11 @@ impl Package { pub fn provides_extras(&self) -> Option<&Vec> { self.metadata.provides_extras.as_ref() } + + /// Returns the dependency groups the package provides, if any. + pub fn dependency_groups(&self) -> &BTreeMap> { + &self.metadata.dependency_groups + } } /// Attempts to construct a `VerbatimUrl` from the given normalized `Path`. diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index db42408d69c89..b40bf488807ad 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1,6 +1,6 @@ //! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use glob::{glob, GlobError, PatternError}; @@ -19,8 +19,7 @@ use uv_warnings::warn_user_once; use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroups}; use crate::pyproject::{ - DependencyGroups, Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, - ToolUvWorkspace, + Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace, }; #[derive(thiserror::Error, Debug)] @@ -488,44 +487,6 @@ impl Workspace { constraints.clone() } - /// Returns the set of all dependency group names defined in the workspace. - pub fn groups(&self) -> BTreeSet<&GroupName> { - self.pyproject_toml - .dependency_groups - .iter() - .flat_map(DependencyGroups::keys) - .chain( - self.packages - .values() - .filter_map(|member| member.pyproject_toml.dependency_groups.as_ref()) - .flat_map(DependencyGroups::keys), - ) - .chain( - if self - .pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dev_dependencies.as_ref()) - .is_some() - || self.packages.values().any(|member| { - member - .pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dev_dependencies.as_ref()) - .is_some() - }) - { - Some(&*DEV_DEPENDENCIES) - } else { - None - }, - ) - .collect() - } - /// The path to the workspace root, the directory containing the top level `pyproject.toml` with /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project. pub fn install_path(&self) -> &PathBuf { diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 6f70322544728..1b64967bbe0b3 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -23,8 +23,8 @@ use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{do_safe_lock, LockMode}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError, - ProjectInterpreter, ScriptInterpreter, UniversalState, + default_dependency_groups, detect_conflicts, ProjectError, ProjectInterpreter, + ScriptInterpreter, UniversalState, }; use crate::commands::{diagnostics, ExitStatus, OutputWriter}; use crate::printer::Printer; @@ -107,24 +107,6 @@ pub(crate) async fn export( ExportTarget::Project(project) }; - // Validate that any referenced dependency groups are defined in the workspace. - if !frozen { - let target = match &target { - ExportTarget::Project(VirtualProject::Project(project)) => { - if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) - } else { - DependencyGroupsTarget::Project(project) - } - } - ExportTarget::Project(VirtualProject::NonProject(workspace)) => { - DependencyGroupsTarget::Workspace(workspace) - } - ExportTarget::Script(_) => DependencyGroupsTarget::Script, - }; - target.validate(&dev)?; - } - // Determine the default groups to include. let defaults = match &target { ExportTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, @@ -268,6 +250,10 @@ pub(crate) async fn export( }, }; + // Validate that the set of requested extras and development groups are defined in the lockfile. + target.validate_extras(&extras)?; + target.validate_groups(&dev)?; + // Write the resolved dependencies to the output channel. let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref()); diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 95980ea318802..37b2ecaa271a9 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use itertools::Either; use rustc_hash::FxHashSet; -use uv_configuration::ExtrasSpecification; +use uv_configuration::{DevGroupsManifest, ExtrasSpecification}; use uv_distribution_types::Index; use uv_normalize::PackageName; use uv_pypi_types::{LenientRequirement, VerbatimParsedUrl}; @@ -302,4 +302,66 @@ impl<'lock> InstallTarget<'lock> { Ok(()) } + + /// Validate the dependency groups requested by the [`DependencyGroupSpecifier`]. + #[allow(clippy::result_large_err)] + pub(crate) fn validate_groups(self, groups: &DevGroupsManifest) -> Result<(), ProjectError> { + // If no groups were specified, short-circuit. + if groups.explicit_names().next().is_none() { + return Ok(()); + } + + match self { + Self::Workspace { lock, workspace } | Self::NonProjectWorkspace { lock, workspace } => { + let roots = self.roots().collect::>(); + let member_packages: Vec<&Package> = lock + .packages() + .iter() + .filter(|package| roots.contains(package.name())) + .collect(); + + // Extract the dependency groups that are exclusive to the workspace root. + let known_groups = member_packages + .iter() + .flat_map(|package| package.dependency_groups().keys().map(Cow::Borrowed)) + .chain(workspace.dependency_groups().ok().into_iter().flat_map( + |dependency_groups| dependency_groups.into_keys().map(Cow::Owned), + )) + .collect::>(); + + for group in groups.explicit_names() { + if !known_groups.contains(group) { + return Err(ProjectError::MissingGroupWorkspace(group.clone())); + } + } + } + Self::Project { lock, .. } => { + let roots = self.roots().collect::>(); + let member_packages: Vec<&Package> = lock + .packages() + .iter() + .filter(|package| roots.contains(package.name())) + .collect(); + + // Extract the dependency groups defined in the relevant member. + let known_groups = member_packages + .iter() + .flat_map(|package| package.dependency_groups().keys()) + .collect::>(); + + for group in groups.explicit_names() { + if !known_groups.contains(group) { + return Err(ProjectError::MissingGroupProject(group.clone())); + } + } + } + Self::Script { .. } => { + if let Some(group) = groups.explicit_names().next() { + return Err(ProjectError::MissingGroupScript(group.clone())); + } + } + } + + Ok(()) + } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index cd78e99a1b375..182c7adbb7227 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -45,7 +45,7 @@ use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::pyproject::PyProjectToml; -use uv_workspace::{ProjectWorkspace, Workspace}; +use uv_workspace::Workspace; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::operations::{Changelog, Modifications}; @@ -2261,49 +2261,6 @@ pub(crate) async fn init_script_python_requirement( )) } -#[derive(Debug, Copy, Clone)] -pub(crate) enum DependencyGroupsTarget<'env> { - /// The dependency groups can be defined in any workspace member. - Workspace(&'env Workspace), - /// The dependency groups must be defined in the target project. - Project(&'env ProjectWorkspace), - /// The dependency groups must be defined in the target script. - Script, -} - -impl DependencyGroupsTarget<'_> { - /// Validate the dependency groups requested by the [`DevGroupsSpecification`]. - #[allow(clippy::result_large_err)] - pub(crate) fn validate(self, dev: &DevGroupsSpecification) -> Result<(), ProjectError> { - for group in dev.explicit_names() { - match self { - Self::Workspace(workspace) => { - // The group must be defined in the workspace. - if !workspace.groups().contains(group) { - return Err(ProjectError::MissingGroupWorkspace(group.clone())); - } - } - Self::Project(project) => { - // The group must be defined in the target project. - if !project - .current_project() - .pyproject_toml() - .dependency_groups - .as_ref() - .is_some_and(|groups| groups.contains_key(group)) - { - return Err(ProjectError::MissingGroupProject(group.clone())); - } - } - Self::Script => { - return Err(ProjectError::MissingGroupScript(group.clone())); - } - } - } - Ok(()) - } -} - /// Returns the default dependency groups from the [`PyProjectToml`]. #[allow(clippy::result_large_err)] pub(crate) fn default_dependency_groups( diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 9325d8cec92e4..21ecb85f62651 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -46,9 +46,8 @@ use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ default_dependency_groups, script_specification, update_environment, - validate_project_requires_python, DependencyGroupsTarget, EnvironmentSpecification, - ProjectEnvironment, ProjectError, ScriptEnvironment, ScriptInterpreter, UniversalState, - WorkspacePython, + validate_project_requires_python, EnvironmentSpecification, ProjectEnvironment, ProjectError, + ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::run::run_to_completion; @@ -648,21 +647,6 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl } } else { // Validate that any referenced dependency groups are defined in the workspace. - if !frozen { - let target = match &project { - VirtualProject::Project(project) => { - if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) - } else { - DependencyGroupsTarget::Project(project) - } - } - VirtualProject::NonProject(workspace) => { - DependencyGroupsTarget::Workspace(workspace) - } - }; - target.validate(&dev)?; - } // Determine the default groups to include. let defaults = default_dependency_groups(project.pyproject_toml())?; @@ -751,12 +735,17 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl }; let install_options = InstallOptions::default(); + let dev = dev.with_defaults(defaults); + + // Validate that the set of requested extras and development groups are defined in the lockfile. + target.validate_extras(&extras)?; + target.validate_groups(&dev)?; match project::sync::do_sync( target, &venv, &extras, - &dev.with_defaults(defaults), + &dev, editable, install_options, modifications, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index a87a62edee084..b265b08c3ea66 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -39,8 +39,7 @@ use crate::commands::project::lock::{do_safe_lock, LockMode, LockResult}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ default_dependency_groups, detect_conflicts, script_specification, update_environment, - DependencyGroupsTarget, PlatformState, ProjectEnvironment, ProjectError, ScriptEnvironment, - UniversalState, + PlatformState, ProjectEnvironment, ProjectError, ScriptEnvironment, UniversalState, }; use crate::commands::{diagnostics, ExitStatus}; use crate::printer::Printer; @@ -113,28 +112,6 @@ pub(crate) async fn sync( SyncTarget::Project(project) }; - // Validate that any referenced dependency groups are defined in the workspace. - if !frozen { - match &target { - SyncTarget::Project(project) => { - let target = match &project { - VirtualProject::Project(project) => { - if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) - } else { - DependencyGroupsTarget::Project(project) - } - } - VirtualProject::NonProject(workspace) => { - DependencyGroupsTarget::Workspace(workspace) - } - }; - target.validate(&dev)?; - } - SyncTarget::Script(..) => {} - } - } - // Determine the default groups to include. let defaults = match &target { SyncTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, @@ -587,9 +564,12 @@ pub(super) async fn do_sync( } // Validate that the set of requested extras and development groups are compatible. - target.validate_extras(extras)?; detect_conflicts(target.lock(), extras, dev)?; + // Validate that the set of requested extras and development groups are defined in the lockfile. + target.validate_extras(extras)?; + target.validate_groups(dev)?; + // Determine the markers to use for resolution. let marker_env = venv.interpreter().resolver_marker_environment(); diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 756e207ade712..e46b2761f20d3 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -24,8 +24,7 @@ use crate::commands::pip::resolution_markers; use crate::commands::project::lock::{do_safe_lock, LockMode}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - default_dependency_groups, DependencyGroupsTarget, ProjectError, ProjectInterpreter, - ScriptInterpreter, UniversalState, + default_dependency_groups, ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState, }; use crate::commands::reporters::LatestVersionReporter; use crate::commands::{diagnostics, ExitStatus}; @@ -72,15 +71,6 @@ pub(crate) async fn tree( LockTarget::Workspace(&workspace) }; - // Validate that any referenced dependency groups are defined in the target. - if !frozen { - let target = match &target { - LockTarget::Workspace(workspace) => DependencyGroupsTarget::Workspace(workspace), - LockTarget::Script(..) => DependencyGroupsTarget::Script, - }; - target.validate(&dev)?; - } - // Determine the default groups to include. let defaults = match target { LockTarget::Workspace(workspace) => default_dependency_groups(workspace.pyproject_toml())?, diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index b132238d9068e..ef716b78762f9 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -1917,6 +1917,7 @@ fn export_group() -> Result<()> { version = "0.1.0" requires-python = ">=3.12" dependencies = ["typing-extensions"] + [dependency-groups] foo = ["anyio ; sys_platform == 'darwin'"] bar = ["iniconfig"] @@ -2055,6 +2056,16 @@ fn export_group() -> Result<()> { Resolved 6 packages in [TIME] "###); + uv_snapshot!(context.filters(), context.export().arg("--all-groups").arg("--no-group").arg("baz"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + error: Group `baz` is not defined in the project's `dependency-groups` table + "###); + Ok(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index de933e0ad02b3..d5934ddf24481 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -617,6 +617,7 @@ fn sync_legacy_non_project_group() -> Result<()> { ----- stdout ----- ----- stderr ----- + Resolved 6 packages in [TIME] error: Group `bop` is not defined in any project's `dependency-groups` table "###); @@ -1759,6 +1760,7 @@ fn sync_non_existent_group() -> Result<()> { ----- stdout ----- ----- stderr ----- + Resolved 7 packages in [TIME] error: Group `baz` is not defined in the project's `dependency-groups` table "###); @@ -1768,6 +1770,7 @@ fn sync_non_existent_group() -> Result<()> { ----- stdout ----- ----- stderr ----- + Resolved 7 packages in [TIME] error: Group `baz` is not defined in the project's `dependency-groups` table "###); @@ -1784,6 +1787,55 @@ fn sync_non_existent_group() -> Result<()> { + typing-extensions==4.10.0 "###); + // Requesting with `--frozen` should respect the groups in the lockfile, rather than the + // `pyproject.toml`. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group").arg("bar"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + idna==3.6 + + requests==2.31.0 + + urllib3==2.2.1 + "###); + + // Replace `bar` with `baz`. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + baz = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group").arg("bar"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 6 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--group").arg("baz"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Group `baz` is not defined in the project's `dependency-groups` table + "###); + Ok(()) } @@ -5862,6 +5914,7 @@ fn sync_all_groups() -> Result<()> { [dependency-groups] types = ["sniffio>=1"] async = ["anyio>=3"] + empty = [] [tool.uv.workspace] members = ["child"] @@ -5944,9 +5997,22 @@ fn sync_all_groups() -> Result<()> { ----- stdout ----- ----- stderr ----- + Resolved 8 packages in [TIME] error: Group `foo` is not defined in any project's `dependency-groups` table "###); + // Sync an empty group. + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("empty"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + Uninstalled 1 package in [TIME] + - packaging==24.0 + "###); + Ok(()) }