Validate dependency groups even when --frozen is present (#11499)

## Summary

We now use the same strategy as for extras, validating against the
lockfile instead of the `pyproject.toml`.

Closes https://github.com/astral-sh/uv/issues/10882.
This commit is contained in:
Charlie Marsh 2025-02-14 10:54:28 -05:00 committed by GitHub
parent 71bda82b08
commit f001605505
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 168 additions and 161 deletions

View file

@ -2622,6 +2622,11 @@ impl Package {
pub fn provides_extras(&self) -> Option<&Vec<ExtraName>> {
self.metadata.provides_extras.as_ref()
}
/// Returns the dependency groups the package provides, if any.
pub fn dependency_groups(&self) -> &BTreeMap<GroupName, BTreeSet<Requirement>> {
&self.metadata.dependency_groups
}
}
/// Attempts to construct a `VerbatimUrl` from the given normalized `Path`.

View file

@ -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 {

View file

@ -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());

View file

@ -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::<FxHashSet<_>>();
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::<FxHashSet<_>>();
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::<FxHashSet<_>>();
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::<FxHashSet<_>>();
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(())
}
}

View file

@ -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(

View file

@ -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,

View file

@ -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();

View file

@ -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())?,

View file

@ -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(())
}

View file

@ -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(())
}