From ff9c2c35d7bb35670115571f6b2e6e3213ed1d27 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 18:16:48 -0400 Subject: [PATCH] Support reading dependency-groups from pyproject.tomls with no project (#13742) (or legacy tool.uv.workspace). This cleaves out a dedicated SourcedDependencyGroups type based on RequiresDist but with only the DependencyGroup handling implemented. This allows `uv pip` to read `dependency-groups` from pyproject.tomls that only have that table defined, per PEP 735, and as implemented by `pip`. However we want our implementation to respect various uv features when they're available: * `tool.uv.sources` * `tool.uv.index` * `tool.uv.dependency-groups.mygroup.requires-python` (#13735) As such we want to opportunistically detect "as much as possible" while doing as little as possible when things are missing. The issue with the old RequiresDist path was that it fundamentally wanted to build the package, and if `[project]` was missing it would try to desperately run setuptools on the pyproject.toml to try to find metadata and make a hash of things. At the same time, the old code also put in a lot of effort to try to pretend that `uv pip` dependency-groups worked like `uv` dependency-groups with defaults and non-only semantics, only to separate them back out again. By explicitly separating them out, we confidently get the expected behaviour. Note that dependency-group support is still included in RequiresDist, as some `uv` paths still use it. It's unclear to me if those paths want this same treatment -- for now I conclude no. Fixes #13138 --- .../uv-distribution-types/src/annotation.rs | 20 +- crates/uv-distribution/src/lib.rs | 2 +- .../src/metadata/dependency_groups.rs | 208 ++++++++++++++++++ crates/uv-distribution/src/metadata/mod.rs | 2 + crates/uv-pep508/src/origin.rs | 4 +- crates/uv-requirements/src/source_tree.rs | 57 +---- crates/uv-requirements/src/specification.rs | 38 +--- crates/uv-workspace/src/workspace.rs | 45 ++++ crates/uv/src/commands/pip/install.rs | 1 + crates/uv/src/commands/pip/operations.rs | 52 ++++- crates/uv/tests/it/pip_compile.rs | 101 ++++++++- crates/uv/tests/it/pip_install.rs | 37 ++++ 12 files changed, 470 insertions(+), 97 deletions(-) create mode 100644 crates/uv-distribution/src/metadata/dependency_groups.rs diff --git a/crates/uv-distribution-types/src/annotation.rs b/crates/uv-distribution-types/src/annotation.rs index 673d23c17..398bcb6b4 100644 --- a/crates/uv-distribution-types/src/annotation.rs +++ b/crates/uv-distribution-types/src/annotation.rs @@ -26,7 +26,11 @@ impl std::fmt::Display for SourceAnnotation { write!(f, "{project_name} ({})", path.portable_display()) } RequirementOrigin::Group(path, project_name, group) => { - write!(f, "{project_name} ({}:{group})", path.portable_display()) + if let Some(project_name) = project_name { + write!(f, "{project_name} ({}:{group})", path.portable_display()) + } else { + write!(f, "({}:{group})", path.portable_display()) + } } RequirementOrigin::Workspace => { write!(f, "(workspace)") @@ -45,11 +49,15 @@ impl std::fmt::Display for SourceAnnotation { } RequirementOrigin::Group(path, project_name, group) => { // Group is not used for override - write!( - f, - "--override {project_name} ({}:{group})", - path.portable_display() - ) + if let Some(project_name) = project_name { + write!( + f, + "--override {project_name} ({}:{group})", + path.portable_display() + ) + } else { + write!(f, "--override ({}:{group})", path.portable_display()) + } } RequirementOrigin::Workspace => { write!(f, "--override (workspace)") diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index d7679a5fb..07958f715 100644 --- a/crates/uv-distribution/src/lib.rs +++ b/crates/uv-distribution/src/lib.rs @@ -4,7 +4,7 @@ pub use error::Error; pub use index::{BuiltWheelIndex, RegistryWheelIndex}; pub use metadata::{ ArchiveMetadata, BuildRequires, FlatRequiresDist, LoweredRequirement, LoweringError, Metadata, - MetadataError, RequiresDist, + MetadataError, RequiresDist, SourcedDependencyGroups, }; pub use reporter::Reporter; pub use source::prune; diff --git a/crates/uv-distribution/src/metadata/dependency_groups.rs b/crates/uv-distribution/src/metadata/dependency_groups.rs new file mode 100644 index 000000000..7fb69b516 --- /dev/null +++ b/crates/uv-distribution/src/metadata/dependency_groups.rs @@ -0,0 +1,208 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uv_configuration::SourceStrategy; +use uv_distribution_types::{IndexLocations, Requirement}; +use uv_normalize::{GroupName, PackageName}; +use uv_workspace::dependency_groups::FlatDependencyGroups; +use uv_workspace::pyproject::{Sources, ToolUvSources}; +use uv_workspace::{ + DiscoveryOptions, MemberDiscovery, VirtualProject, WorkspaceCache, WorkspaceError, +}; + +use crate::metadata::{GitWorkspaceMember, LoweredRequirement, MetadataError}; + +/// Like [`crate::RequiresDist`] but only supporting dependency-groups. +/// +/// PEP 735 says: +/// +/// > A pyproject.toml file with only `[dependency-groups]` and no other tables is valid. +/// +/// This is a special carveout to enable users to adopt dependency-groups without having +/// to learn about projects. It is supported by `pip install --group`, and thus interfaces +/// like `uv pip install --group` must also support it for interop and conformance. +/// +/// On paper this is trivial to support because dependency-groups are so self-contained +/// that they're basically a `requirements.txt` embedded within a pyproject.toml, so it's +/// fine to just grab that section and handle it independently. +/// +/// However several uv extensions make this complicated, notably, as of this writing: +/// +/// * tool.uv.sources +/// * tool.uv.index +/// +/// These fields may also be present in the pyproject.toml, and, critically, +/// may be defined and inherited in a parent workspace pyproject.toml. +/// +/// Therefore, we need to gracefully degrade from a full workspacey situation all +/// the way down to one of these stub pyproject.tomls the PEP defines. This is why +/// we avoid going through `RequiresDist` -- we don't want to muddy up the "compile a package" +/// logic with support for non-project/workspace pyproject.tomls, and we don't want to +/// muddy this logic up with setuptools fallback modes that `RequiresDist` wants. +/// +/// (We used to shove this feature into that path, and then we would see there's no metadata +/// and try to run setuptools to try to desperately find any metadata, and then error out.) +#[derive(Debug, Clone)] +pub struct SourcedDependencyGroups { + pub name: Option, + pub dependency_groups: BTreeMap>, +} + +impl SourcedDependencyGroups { + /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory + /// dependencies. + pub async fn from_virtual_project( + pyproject_path: &Path, + git_member: Option<&GitWorkspaceMember<'_>>, + locations: &IndexLocations, + source_strategy: SourceStrategy, + cache: &WorkspaceCache, + ) -> Result { + let discovery = DiscoveryOptions { + stop_discovery_at: git_member.map(|git_member| { + git_member + .fetch_root + .parent() + .expect("git checkout has a parent") + .to_path_buf() + }), + members: match source_strategy { + SourceStrategy::Enabled => MemberDiscovery::default(), + SourceStrategy::Disabled => MemberDiscovery::None, + }, + }; + + // The subsequent API takes an absolute path to the dir the pyproject is in + let empty = PathBuf::new(); + let absolute_pyproject_path = + std::path::absolute(pyproject_path).map_err(WorkspaceError::Normalize)?; + let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty); + let project = VirtualProject::discover_defaulted(project_dir, &discovery, cache).await?; + + // Collect the dependency groups. + let dependency_groups = + FlatDependencyGroups::from_pyproject_toml(project.root(), project.pyproject_toml())?; + + // If sources/indexes are disabled we can just stop here + let SourceStrategy::Enabled = source_strategy else { + return Ok(Self { + name: project.project_name().cloned(), + dependency_groups: dependency_groups + .into_iter() + .map(|(name, group)| { + let requirements = group + .requirements + .into_iter() + .map(Requirement::from) + .collect(); + (name, requirements) + }) + .collect(), + }); + }; + + // Collect any `tool.uv.index` entries. + let empty = vec![]; + let project_indexes = project + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.index.as_deref()) + .unwrap_or(&empty); + + // Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`. + let empty = BTreeMap::default(); + let project_sources = project + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .map(ToolUvSources::inner) + .unwrap_or(&empty); + + // Now that we've resolved the dependency groups, we can validate that each source references + // a valid extra or group, if present. + Self::validate_sources(project_sources, &dependency_groups)?; + + // Lower the dependency groups. + let dependency_groups = dependency_groups + .into_iter() + .map(|(name, group)| { + let requirements = group + .requirements + .into_iter() + .flat_map(|requirement| { + let requirement_name = requirement.name.clone(); + let group = name.clone(); + let extra = None; + LoweredRequirement::from_requirement( + requirement, + project.project_name(), + project.root(), + project_sources, + project_indexes, + extra, + Some(&group), + locations, + project.workspace(), + git_member, + ) + .map(move |requirement| match requirement { + Ok(requirement) => Ok(requirement.into_inner()), + Err(err) => Err(MetadataError::GroupLoweringError( + group.clone(), + requirement_name.clone(), + Box::new(err), + )), + }) + }) + .collect::, _>>()?; + Ok::<(GroupName, Box<_>), MetadataError>((name, requirements)) + }) + .collect::, _>>()?; + + Ok(Self { + name: project.project_name().cloned(), + dependency_groups, + }) + } + + /// Validate the sources. + /// + /// If a source is requested with `group`, ensure that the relevant dependency is + /// present in the relevant `dependency-groups` section. + fn validate_sources( + sources: &BTreeMap, + dependency_groups: &FlatDependencyGroups, + ) -> Result<(), MetadataError> { + for (name, sources) in sources { + for source in sources.iter() { + if let Some(group) = source.group() { + // If the group doesn't exist at all, error. + let Some(flat_group) = dependency_groups.get(group) else { + return Err(MetadataError::MissingSourceGroup( + name.clone(), + group.clone(), + )); + }; + + // If there is no such requirement with the group, error. + if !flat_group + .requirements + .iter() + .any(|requirement| requirement.name == *name) + { + return Err(MetadataError::IncompleteSourceGroup( + name.clone(), + group.clone(), + )); + } + } + } + } + + Ok(()) + } +} diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 85c55666e..a56a1c354 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -12,11 +12,13 @@ use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::{WorkspaceCache, WorkspaceError}; pub use crate::metadata::build_requires::BuildRequires; +pub use crate::metadata::dependency_groups::SourcedDependencyGroups; pub use crate::metadata::lowering::LoweredRequirement; pub use crate::metadata::lowering::LoweringError; pub use crate::metadata::requires_dist::{FlatRequiresDist, RequiresDist}; mod build_requires; +mod dependency_groups; mod lowering; mod requires_dist; diff --git a/crates/uv-pep508/src/origin.rs b/crates/uv-pep508/src/origin.rs index 91a88f59a..4619e6f2e 100644 --- a/crates/uv-pep508/src/origin.rs +++ b/crates/uv-pep508/src/origin.rs @@ -12,8 +12,8 @@ pub enum RequirementOrigin { File(PathBuf), /// The requirement was provided via a local project (e.g., a `pyproject.toml` file). Project(PathBuf, PackageName), - /// The requirement was provided via a local project (e.g., a `pyproject.toml` file). - Group(PathBuf, PackageName, GroupName), + /// The requirement was provided via a local project's group (e.g., a `pyproject.toml` file). + Group(PathBuf, Option, GroupName), /// The requirement was provided via a workspace. Workspace, } diff --git a/crates/uv-requirements/src/source_tree.rs b/crates/uv-requirements/src/source_tree.rs index a540e4642..39fbe453b 100644 --- a/crates/uv-requirements/src/source_tree.rs +++ b/crates/uv-requirements/src/source_tree.rs @@ -1,13 +1,13 @@ -use std::path::{Path, PathBuf}; +use std::borrow::Cow; +use std::path::Path; use std::sync::Arc; -use std::{borrow::Cow, collections::BTreeMap}; use anyhow::{Context, Result}; use futures::TryStreamExt; use futures::stream::FuturesOrdered; use url::Url; -use uv_configuration::{DependencyGroups, ExtrasSpecification}; +use uv_configuration::ExtrasSpecification; use uv_distribution::{DistributionDatabase, FlatRequiresDist, Reporter, RequiresDist}; use uv_distribution_types::Requirement; use uv_distribution_types::{ @@ -37,8 +37,6 @@ pub struct SourceTreeResolution { pub struct SourceTreeResolver<'a, Context: BuildContext> { /// The extras to include when resolving requirements. extras: &'a ExtrasSpecification, - /// The groups to include when resolving requirements. - groups: &'a BTreeMap, /// The hash policy to enforce. hasher: &'a HashStrategy, /// The in-memory index for resolving dependencies. @@ -51,14 +49,12 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { /// Instantiate a new [`SourceTreeResolver`] for a given set of `source_trees`. pub fn new( extras: &'a ExtrasSpecification, - groups: &'a BTreeMap, hasher: &'a HashStrategy, index: &'a InMemoryIndex, database: DistributionDatabase<'a, Context>, ) -> Self { Self { extras, - groups, hasher, index, database, @@ -101,46 +97,17 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { let mut requirements = Vec::new(); - // Resolve any groups associated with this path - let default_groups = DependencyGroups::default(); - let groups = self.groups.get(path).unwrap_or(&default_groups); - // Flatten any transitive extras and include dependencies // (unless something like --only-group was passed) - if groups.prod() { - requirements.extend( - FlatRequiresDist::from_requirements(metadata.requires_dist, &metadata.name) - .into_iter() - .map(|requirement| Requirement { - origin: Some(origin.clone()), - marker: requirement.marker.simplify_extras(&extras), - ..requirement - }), - ); - } - - // Apply dependency-groups - for (group_name, group) in &metadata.dependency_groups { - if groups.contains(group_name) { - requirements.extend(group.iter().cloned().map(|group| Requirement { - origin: Some(RequirementOrigin::Group( - path.to_path_buf(), - metadata.name.clone(), - group_name.clone(), - )), - ..group - })); - } - } - // Complain if dependency groups are named that don't appear. - for name in groups.explicit_names() { - if !metadata.dependency_groups.contains_key(name) { - return Err(anyhow::anyhow!( - "The dependency group '{name}' was not found in the project: {}", - path.user_display() - )); - } - } + requirements.extend( + FlatRequiresDist::from_requirements(metadata.requires_dist, &metadata.name) + .into_iter() + .map(|requirement| Requirement { + origin: Some(origin.clone()), + marker: requirement.marker.simplify_extras(&extras), + ..requirement + }), + ); let requirements = requirements.into_boxed_slice(); let project = metadata.name; diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index a0b122de8..4c5741392 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -290,52 +290,18 @@ impl RequirementsSpecification { if !groups.is_empty() { let mut group_specs = BTreeMap::new(); for (path, groups) in groups { - // Conceptually pip `--group` flags just add the group referred to by the file. - // In uv semantics this would be like `--only-group`, however if you do this: - // - // uv pip install -r pyproject.toml --group pyproject.toml:foo - // - // We don't want to discard the package listed by `-r` in the way `--only-group` - // would. So we check to see if any other source wants to add this path, and use - // that to determine if we're doing `--group` or `--only-group` semantics. - // - // Note that it's fine if a file gets referred to multiple times by - // different-looking paths (like `./pyproject.toml` vs `pyproject.toml`). We're - // specifically trying to disambiguate in situations where the `--group` *happens* - // to match with an unrelated argument, and `--only-group` would be overzealous! - let source_exists_without_group = requirement_sources - .iter() - .any(|source| source.source_trees.contains(&path)); - let (group, only_group) = if source_exists_without_group { - (groups, Vec::new()) - } else { - (Vec::new(), groups) - }; let group_spec = DependencyGroups::from_args( false, false, false, - group, + Vec::new(), Vec::new(), false, - only_group, + groups, false, ); - - // If we're doing `--only-group` semantics it's because only `--group` flags referred - // to this file, and so we need to make sure to add it to the list of sources! - if !source_exists_without_group { - let source = Self::from_source( - &RequirementsSource::PyprojectToml(path.clone()), - client_builder, - ) - .await?; - requirement_sources.push(source); - } - group_specs.insert(path, group_spec); } - spec.groups = group_specs; } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index ed99d3d30..1349d739c 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1434,6 +1434,33 @@ impl VirtualProject { path: &Path, options: &DiscoveryOptions, cache: &WorkspaceCache, + ) -> Result { + Self::discover_impl(path, options, cache, false).await + } + + /// Equivalent to [`VirtualProject::discover`] but consider it acceptable for + /// both `[project]` and `[tool.uv.workspace]` to be missing. + /// + /// If they are, we act as if an empty `[tool.uv.workspace]` was found. + pub async fn discover_defaulted( + path: &Path, + options: &DiscoveryOptions, + cache: &WorkspaceCache, + ) -> Result { + Self::discover_impl(path, options, cache, true).await + } + + /// Find the current project or virtual workspace root, given the current directory. + /// + /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`], + /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow). + /// + /// This method requires an absolute path and panics otherwise. + async fn discover_impl( + path: &Path, + options: &DiscoveryOptions, + cache: &WorkspaceCache, + default_missing_workspace: bool, ) -> Result { assert!( path.is_absolute(), @@ -1497,6 +1524,24 @@ impl VirtualProject { ) .await?; + Ok(Self::NonProject(workspace)) + } else if default_missing_workspace { + // Otherwise it's a pyproject.toml that maybe contains dependency-groups + // that we want to treat like a project/workspace to handle those uniformly + let project_path = std::path::absolute(project_root) + .map_err(WorkspaceError::Normalize)? + .clone(); + + let workspace = Workspace::collect_members( + project_path, + ToolUvWorkspace::default(), + pyproject_toml, + None, + options, + cache, + ) + .await?; + Ok(Self::NonProject(workspace)) } else { Err(WorkspaceError::MissingProject(pyproject_path)) diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index eb9c1cd2b..5fc9a66f4 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -254,6 +254,7 @@ pub(crate) async fn pip_install( if reinstall.is_none() && upgrade.is_none() && source_trees.is_empty() + && groups.is_empty() && pylock.is_none() && matches!(modifications, Modifications::Sufficient) { diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 907f79075..55ab2aa1b 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -8,7 +8,6 @@ use std::fmt::Write; use std::path::PathBuf; use std::sync::Arc; use tracing::debug; -use uv_tool::InstalledTools; use uv_cache::Cache; use uv_client::{BaseClientBuilder, RegistryClient}; @@ -17,9 +16,9 @@ use uv_configuration::{ ExtrasSpecification, Overrides, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; -use uv_distribution::DistributionDatabase; +use uv_distribution::{DistributionDatabase, SourcedDependencyGroups}; use uv_distribution_types::{ - CachedDist, Diagnostic, InstalledDist, LocalDist, NameRequirementSpecification, + CachedDist, Diagnostic, InstalledDist, LocalDist, NameRequirementSpecification, Requirement, ResolutionDiagnostic, UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_distribution_types::{ @@ -29,7 +28,7 @@ use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_installer::{Plan, Planner, Preparer, SitePackages}; use uv_normalize::{GroupName, PackageName}; -use uv_pep508::MarkerEnvironment; +use uv_pep508::{MarkerEnvironment, RequirementOrigin}; use uv_platform_tags::Tags; use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment}; use uv_python::{PythonEnvironment, PythonInstallation}; @@ -41,7 +40,8 @@ use uv_resolver::{ DependencyMode, Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, Preference, Preferences, PythonRequirement, Resolver, ResolverEnvironment, ResolverOutput, }; -use uv_types::{HashStrategy, InFlight, InstalledPackagesProvider}; +use uv_tool::InstalledTools; +use uv_types::{BuildContext, HashStrategy, InFlight, InstalledPackagesProvider}; use uv_warnings::warn_user; use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger, ResolveLogger}; @@ -166,7 +166,6 @@ pub(crate) async fn resolve( if !source_trees.is_empty() { let resolutions = SourceTreeResolver::new( extras, - groups, hasher, index, DistributionDatabase::new(client, build_dispatch, concurrency.downloads), @@ -212,6 +211,47 @@ pub(crate) async fn resolve( ); } + for (pyproject_path, groups) in groups { + let metadata = SourcedDependencyGroups::from_virtual_project( + pyproject_path, + None, + build_dispatch.locations(), + build_dispatch.sources(), + build_dispatch.workspace_cache(), + ) + .await + .map_err(|e| { + anyhow!( + "Failed to read dependency groups from: {}\n{}", + pyproject_path.display(), + e + ) + })?; + + // Complain if dependency groups are named that don't appear. + for name in groups.explicit_names() { + if !metadata.dependency_groups.contains_key(name) { + return Err(anyhow!( + "The dependency group '{name}' was not found in the project: {}", + pyproject_path.user_display() + ))?; + } + } + // Apply dependency-groups + for (group_name, group) in &metadata.dependency_groups { + if groups.contains(group_name) { + requirements.extend(group.iter().cloned().map(|group| Requirement { + origin: Some(RequirementOrigin::Group( + pyproject_path.clone(), + metadata.name.clone(), + group_name.clone(), + )), + ..group + })); + } + } + } + requirements }; diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index efb51e47d..49e78a5c9 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -15783,7 +15783,106 @@ fn invalid_group() -> Result<()> { } #[test] -fn project_and_group() -> Result<()> { +fn project_and_group_workspace_inherit() -> Result<()> { + // Checking that --project is handled properly with --group + fn new_context() -> 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" + + [tool.uv.workspace] + members = ["packages/*"] + + [tool.uv.sources] + pytest = { workspace = true } + "#, + )?; + + let subdir = context.temp_dir.child("packages"); + subdir.create_dir_all()?; + + let pytest_dir = subdir.child("pytest"); + pytest_dir.create_dir_all()?; + let pytest_toml = pytest_dir.child("pyproject.toml"); + pytest_toml.write_str( + r#" + [project] + name = "pytest" + version = "4.0.0" + requires-python = ">=3.12" + "#, + )?; + + let sniffio_dir = subdir.child("sniffio"); + sniffio_dir.create_dir_all()?; + let sniffio_toml = sniffio_dir.child("pyproject.toml"); + sniffio_toml.write_str( + r#" + [project] + name = "sniffio" + version = "1.3.1" + requires-python = ">=3.12" + "#, + )?; + + let subproject_dir = subdir.child("mysubproject"); + subproject_dir.create_dir_all()?; + let subproject_toml = subproject_dir.child("pyproject.toml"); + subproject_toml.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.sources] + sniffio = { workspace = true } + + [dependency-groups] + foo = ["iniconfig", "anyio", "sniffio", "pytest"] + "#, + )?; + + Ok(context) + } + + // Check that the workspace's sources are discovered and consulted + let context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("packages/mysubproject/pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --group packages/mysubproject/pyproject.toml:foo + anyio==4.3.0 + # via mysubproject (packages/mysubproject/pyproject.toml:foo) + idna==3.6 + # via anyio + iniconfig==2.0.0 + # via mysubproject (packages/mysubproject/pyproject.toml:foo) + pytest @ file://[TEMP_DIR]/packages/pytest + # via mysubproject (packages/mysubproject/pyproject.toml:foo) + sniffio @ file://[TEMP_DIR]/packages/sniffio + # via + # mysubproject (packages/mysubproject/pyproject.toml:foo) + # anyio + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + Ok(()) +} + +#[test] +fn project_and_group_workspace() -> Result<()> { // Checking that --project is handled properly with --group fn new_context() -> Result { let context = TestContext::new("3.12"); diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 815cbac1f..569edf00e 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -9632,6 +9632,43 @@ fn dependency_group() -> Result<()> { Ok(()) } +#[test] +fn virtual_dependency_group() -> Result<()> { + // testing basic `uv pip install --group` functionality + // when the pyproject.toml is virtual + fn new_context() -> Result { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#, + )?; + Ok(context) + } + + // 'bar' using path sugar + let context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + Ok(()) +} + #[test] fn many_pyproject_group() -> Result<()> { // `uv pip install --group` tests with multiple projects