diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2e8330692..7a60dff90 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -15,7 +15,7 @@ use uv_configuration::{ ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, }; use uv_distribution_types::{Index, IndexUrl, Origin, PipExtraIndex, PipFindLinks, PipIndex}; -use uv_normalize::{ExtraName, GroupName, PackageName}; +use uv_normalize::{ExtraName, GroupName, PackageName, PipGroupName}; use uv_pep508::{MarkerTree, Requirement}; use uv_pypi_types::VerbatimParsedUrl; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; @@ -949,6 +949,7 @@ fn parse_maybe_string(input: &str) -> Result, String> { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] +#[command(group = clap::ArgGroup::new("sources").required(true).multiple(true))] pub struct PipCompileArgs { /// Include all packages listed in the given `requirements.in` files. /// @@ -959,7 +960,7 @@ pub struct PipCompileArgs { /// /// The order of the requirements files and the requirements in them is used to determine /// priority during resolution. - #[arg(required(true), value_parser = parse_file_path)] + #[arg(group = "sources", value_parser = parse_file_path)] pub src_file: Vec, /// Constrain versions using the given requirements files. @@ -1022,6 +1023,14 @@ pub struct PipCompileArgs { #[arg(long, overrides_with("no_deps"), hide = true)] pub deps: bool, + /// Install the specified dependency group from a `pyproject.toml`. + /// + /// If no path is provided, the `pyproject.toml` in the working directory is used. + /// + /// May be provided multiple times. + #[arg(long, group = "sources")] + pub group: Vec, + /// Write the compiled requirements to the given `requirements.txt` file. /// /// If the file already exists, the existing versions will be preferred when resolving @@ -1586,6 +1595,14 @@ pub struct PipInstallArgs { #[arg(long, overrides_with("no_deps"), hide = true)] pub deps: bool, + /// Install the specified dependency group from a `pyproject.toml`. + /// + /// If no path is provided, the `pyproject.toml` in the working directory is used. + /// + /// May be provided multiple times. + #[arg(long, group = "sources")] + pub group: Vec, + /// Require a matching hash for each requirement. /// /// By default, uv will verify any available hashes in the requirements file, but will not diff --git a/crates/uv-distribution-types/src/annotation.rs b/crates/uv-distribution-types/src/annotation.rs index 1a6569549..673d23c17 100644 --- a/crates/uv-distribution-types/src/annotation.rs +++ b/crates/uv-distribution-types/src/annotation.rs @@ -25,6 +25,9 @@ impl std::fmt::Display for SourceAnnotation { RequirementOrigin::Project(path, project_name) => { write!(f, "{project_name} ({})", path.portable_display()) } + RequirementOrigin::Group(path, project_name, group) => { + write!(f, "{project_name} ({}:{group})", path.portable_display()) + } RequirementOrigin::Workspace => { write!(f, "(workspace)") } @@ -40,6 +43,14 @@ impl std::fmt::Display for SourceAnnotation { // Project is not used for override write!(f, "--override {project_name} ({})", path.portable_display()) } + RequirementOrigin::Group(path, project_name, group) => { + // Group is not used for override + write!( + f, + "--override {project_name} ({}:{group})", + path.portable_display() + ) + } RequirementOrigin::Workspace => { write!(f, "--override (workspace)") } diff --git a/crates/uv-normalize/src/group_name.rs b/crates/uv-normalize/src/group_name.rs index 458281323..2abbb8715 100644 --- a/crates/uv-normalize/src/group_name.rs +++ b/crates/uv-normalize/src/group_name.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::LazyLock; @@ -5,7 +7,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use uv_small_str::SmallString; -use crate::{validate_and_normalize_ref, InvalidNameError}; +use crate::{ + validate_and_normalize_ref, InvalidNameError, InvalidPipGroupError, InvalidPipGroupPathError, +}; /// The normalized name of a dependency group. /// @@ -82,6 +86,84 @@ impl AsRef for GroupName { } } +/// The pip-compatible variant of a [`GroupName`]. +/// +/// Either or :. +/// If is omitted it defaults to "pyproject.toml". +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct PipGroupName { + pub path: Option, + pub name: GroupName, +} + +impl PipGroupName { + /// Gets the path to use, applying the default if it's missing + pub fn path(&self) -> &Path { + if let Some(path) = &self.path { + path + } else { + Path::new("pyproject.toml") + } + } +} + +impl FromStr for PipGroupName { + type Err = InvalidPipGroupError; + + fn from_str(path_and_name: &str) -> Result { + // The syntax is `:`. + // + // `:` isn't valid as part of a dependency-group name, but it can appear in a path. + // Therefore we look for the first `:` starting from the end to find the delimiter. + // If there is no `:` then there's no path and we use the default one. + if let Some((path, name)) = path_and_name.rsplit_once(':') { + // pip hard errors if the path does not end with pyproject.toml + if !path.ends_with("pyproject.toml") { + Err(InvalidPipGroupPathError(path.to_owned()))?; + } + + let name = GroupName::from_str(name)?; + let path = Some(PathBuf::from(path)); + Ok(Self { path, name }) + } else { + let name = GroupName::from_str(path_and_name)?; + let path = None; + Ok(Self { path, name }) + } + } +} + +impl<'de> Deserialize<'de> for PipGroupName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PipGroupName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let string = self.to_string(); + string.serialize(serializer) + } +} + +impl Display for PipGroupName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(path) = &self.path { + write!(f, "{}:{}", path.display(), self.name) + } else { + self.name.fmt(f) + } + } +} + /// 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 06fd7a57f..46288325a 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, DEV_DEPENDENCIES}; +pub use group_name::{GroupName, PipGroupName, DEV_DEPENDENCIES}; pub use package_name::PackageName; use uv_small_str::SmallString; @@ -121,6 +121,55 @@ impl Display for InvalidNameError { impl Error for InvalidNameError {} +/// Path didn't end with `pyproject.toml` +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvalidPipGroupPathError(String); + +impl InvalidPipGroupPathError { + /// Returns the invalid path. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Display for InvalidPipGroupPathError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: {}", + self.0, + ) + } +} +impl Error for InvalidPipGroupPathError {} + +/// Possible errors from reading a [`PipGroupName`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InvalidPipGroupError { + Name(InvalidNameError), + Path(InvalidPipGroupPathError), +} + +impl Display for InvalidPipGroupError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + InvalidPipGroupError::Name(e) => e.fmt(f), + InvalidPipGroupError::Path(e) => e.fmt(f), + } + } +} +impl Error for InvalidPipGroupError {} +impl From for InvalidPipGroupError { + fn from(value: InvalidNameError) -> Self { + Self::Name(value) + } +} +impl From for InvalidPipGroupError { + fn from(value: InvalidPipGroupPathError) -> Self { + Self::Path(value) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/uv-pep508/src/origin.rs b/crates/uv-pep508/src/origin.rs index 7535aebae..91a88f59a 100644 --- a/crates/uv-pep508/src/origin.rs +++ b/crates/uv-pep508/src/origin.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use uv_normalize::PackageName; +use uv_normalize::{GroupName, PackageName}; /// The origin of a dependency, e.g., a `-r requirements.txt` file. #[derive( @@ -12,6 +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 workspace. Workspace, } @@ -22,6 +24,7 @@ impl RequirementOrigin { match self { RequirementOrigin::File(path) => path.as_path(), RequirementOrigin::Project(path, _) => path.as_path(), + RequirementOrigin::Group(path, _, _) => path.as_path(), // Multiple toml are merged and difficult to track files where Requirement is defined. Returns a dummy path instead. RequirementOrigin::Workspace => Path::new("(workspace)"), } diff --git a/crates/uv-requirements/src/source_tree.rs b/crates/uv-requirements/src/source_tree.rs index 2b077861a..2214aba35 100644 --- a/crates/uv-requirements/src/source_tree.rs +++ b/crates/uv-requirements/src/source_tree.rs @@ -1,6 +1,6 @@ -use std::borrow::Cow; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::{borrow::Cow, collections::BTreeMap}; use anyhow::{Context, Result}; use futures::stream::FuturesOrdered; @@ -18,7 +18,7 @@ use uv_pep508::RequirementOrigin; use uv_pypi_types::Requirement; use uv_resolver::{InMemoryIndex, MetadataResponse}; use uv_types::{BuildContext, HashStrategy}; -use uv_warnings::warn_user_once; + #[derive(Debug, Clone)] pub struct SourceTreeResolution { /// The requirements sourced from the source trees. @@ -37,7 +37,7 @@ 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 DependencyGroups, + groups: &'a BTreeMap, /// The hash policy to enforce. hasher: &'a HashStrategy, /// The in-memory index for resolving dependencies. @@ -50,7 +50,7 @@ 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 DependencyGroups, + groups: &'a BTreeMap, hasher: &'a HashStrategy, index: &'a InMemoryIndex, database: DistributionDatabase<'a, Context>, @@ -100,9 +100,13 @@ 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 self.groups.prod() { + if groups.prod() { requirements.extend( FlatRequiresDist::from_requirements(metadata.requires_dist, &metadata.name) .into_iter() @@ -116,20 +120,24 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { // Apply dependency-groups for (group_name, group) in &metadata.dependency_groups { - if self.groups.contains(group_name) { - requirements.extend(group.iter().cloned()); + 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. - // This is only a warning because *technically* we support passing in - // multiple pyproject.tomls, but at this level of abstraction we can't see them all, - // so hard erroring on "no pyproject.toml mentions this" is a bit difficult. - for name in self.groups.explicit_names() { + for name in groups.explicit_names() { if !metadata.dependency_groups.contains_key(name) { - warn_user_once!( - "The dependency-group '{name}' is not defined in {}", - path.display() - ); + return Err(anyhow::anyhow!( + "The dependency group '{name}' was not found in the project: {}", + path.user_display() + )); } } diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 2c8ec42a8..f4729d83a 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -27,6 +27,7 @@ //! * `setup.py` or `setup.cfg` instead of `pyproject.toml`: Directory is an entry in //! `source_trees`. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -34,13 +35,13 @@ use rustc_hash::FxHashSet; use tracing::instrument; use uv_cache_key::CanonicalUrl; use uv_client::BaseClientBuilder; -use uv_configuration::{NoBinary, NoBuild}; +use uv_configuration::{DependencyGroups, NoBinary, NoBuild}; use uv_distribution_types::{ IndexUrl, NameRequirementSpecification, UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_fs::{Simplified, CWD}; -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep508::{MarkerTree, UnnamedRequirement, UnnamedRequirementUrl}; use uv_pypi_types::Requirement; use uv_pypi_types::VerbatimParsedUrl; @@ -62,6 +63,8 @@ pub struct RequirementsSpecification { pub overrides: Vec, /// The source trees from which to extract requirements. pub source_trees: Vec, + /// The groups to use for `source_trees` + pub groups: BTreeMap, /// The extras used to collect requirements. pub extras: FxHashSet, /// The index URL to use for fetching packages. @@ -223,15 +226,75 @@ impl RequirementsSpecification { requirements: &[RequirementsSource], constraints: &[RequirementsSource], overrides: &[RequirementsSource], + groups: BTreeMap>, client_builder: &BaseClientBuilder<'_>, ) -> Result { let mut spec = Self::default(); + // Resolve sources into specifications so we know their `source_tree`s∂ + let mut requirement_sources = Vec::new(); + for source in requirements { + let source = Self::from_source(source, client_builder).await?; + requirement_sources.push(source); + } + + // pip `--group` flags specify their own sources, which we need to process here + 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(), + false, + only_group, + 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; + } + // Read all requirements, and keep track of all requirements _and_ constraints. // A `requirements.txt` can contain a `-c constraints.txt` directive within it, so reading // a requirements file can also add constraints. - for source in requirements { - let source = Self::from_source(source, client_builder).await?; + for source in requirement_sources { spec.requirements.extend(source.requirements); spec.constraints.extend(source.constraints); spec.overrides.extend(source.overrides); @@ -337,7 +400,7 @@ impl RequirementsSpecification { requirements: &[RequirementsSource], client_builder: &BaseClientBuilder<'_>, ) -> Result { - Self::from_sources(requirements, &[], &[], client_builder).await + Self::from_sources(requirements, &[], &[], BTreeMap::default(), client_builder).await } /// Initialize a [`RequirementsSpecification`] from a list of [`Requirement`]. diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index b47c5d6af..495bf06dc 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -13,7 +13,7 @@ use uv_distribution_types::{ }; use uv_install_wheel::LinkMode; use uv_macros::{CombineOptions, OptionsMetadata}; -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::{ExtraName, PackageName, PipGroupName}; use uv_pep508::Requirement; use uv_pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; @@ -1127,6 +1127,15 @@ pub struct PipOptions { "# )] pub no_deps: Option, + /// Include the following dependency groups. + #[option( + default = "None", + value_type = "list[str]", + example = r#" + group = ["dev", "docs"] + "# + )] + pub group: Option>, /// Allow `uv pip sync` with empty requirements, which will clear the environment of all /// packages. #[option( diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 823b601b8..4b9cdd1bc 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::env; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; @@ -13,8 +13,8 @@ use tracing::debug; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - BuildOptions, Concurrency, ConfigSettings, Constraints, DependencyGroups, ExtrasSpecification, - IndexStrategy, NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, Upgrade, + BuildOptions, Concurrency, ConfigSettings, Constraints, ExtrasSpecification, IndexStrategy, + NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, Upgrade, }; use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; @@ -24,7 +24,7 @@ use uv_distribution_types::{ }; use uv_fs::Simplified; use uv_install_wheel::LinkMode; -use uv_normalize::PackageName; +use uv_normalize::{GroupName, PackageName}; use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments}; use uv_python::{ EnvironmentPreference, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, @@ -60,7 +60,7 @@ pub(crate) async fn pip_compile( build_constraints_from_workspace: Vec, environments: SupportedEnvironments, extras: ExtrasSpecification, - groups: DependencyGroups, + groups: BTreeMap>, output_file: Option<&Path>, resolution_mode: ResolutionMode, prerelease_mode: PrereleaseMode, @@ -148,6 +148,7 @@ pub(crate) async fn pip_compile( constraints, overrides, source_trees, + groups, extras: used_extras, index_url, extra_index_urls, @@ -159,6 +160,7 @@ pub(crate) async fn pip_compile( requirements, constraints, overrides, + groups, &client_builder, ) .await?; diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index dc5116558..9b23f4808 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -1,5 +1,6 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; +use std::path::PathBuf; use std::sync::Arc; use itertools::Itertools; @@ -9,9 +10,8 @@ use tracing::{debug, enabled, Level}; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - BuildOptions, Concurrency, ConfigSettings, Constraints, DependencyGroups, DryRun, - ExtrasSpecification, HashCheckingMode, IndexStrategy, PreviewMode, Reinstall, SourceStrategy, - Upgrade, + BuildOptions, Concurrency, ConfigSettings, Constraints, DryRun, ExtrasSpecification, + HashCheckingMode, IndexStrategy, PreviewMode, Reinstall, SourceStrategy, Upgrade, }; use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; @@ -22,6 +22,7 @@ use uv_distribution_types::{ use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_installer::{SatisfiesResult, SitePackages}; +use uv_normalize::GroupName; use uv_pep508::PackageName; use uv_pypi_types::{Conflicts, Requirement}; use uv_python::{ @@ -55,7 +56,7 @@ pub(crate) async fn pip_install( overrides_from_workspace: Vec, build_constraints_from_workspace: Vec, extras: &ExtrasSpecification, - groups: &DependencyGroups, + groups: BTreeMap>, resolution_mode: ResolutionMode, prerelease_mode: PrereleaseMode, dependency_mode: DependencyMode, @@ -107,6 +108,7 @@ pub(crate) async fn pip_install( constraints, overrides, source_trees, + groups, index_url, extra_index_urls, no_index, @@ -424,7 +426,7 @@ pub(crate) async fn pip_install( project, BTreeSet::default(), extras, - groups, + &groups, preferences, site_packages.clone(), &hasher, diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 2f35af15e..e77444dee 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, Context}; use itertools::Itertools; use owo_colors::OwoColorize; -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fmt::Write; use std::path::PathBuf; use std::sync::Arc; @@ -28,7 +28,7 @@ use uv_distribution_types::{ use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_installer::{Plan, Planner, Preparer, SitePackages}; -use uv_normalize::PackageName; +use uv_normalize::{GroupName, PackageName}; use uv_platform_tags::Tags; use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment}; use uv_python::{PythonEnvironment, PythonInstallation}; @@ -54,7 +54,7 @@ pub(crate) async fn read_requirements( constraints: &[RequirementsSource], overrides: &[RequirementsSource], extras: &ExtrasSpecification, - groups: &DependencyGroups, + groups: BTreeMap>, client_builder: &BaseClientBuilder<'_>, ) -> Result { // If the user requests `extras` but does not provide a valid source (e.g., a `pyproject.toml`), @@ -75,19 +75,13 @@ pub(crate) async fn read_requirements( ) .into()); } - if !groups.is_empty() && !requirements.iter().any(RequirementsSource::allows_groups) { - let flags = groups.history().as_flags_pretty().join(" "); - return Err(anyhow!( - "Requesting groups requires a `pyproject.toml`. Requested via: {flags}" - ) - .into()); - } // Read all requirements from the provided sources. Ok(RequirementsSpecification::from_sources( requirements, constraints, overrides, + groups, client_builder, ) .await?) @@ -98,11 +92,15 @@ pub(crate) async fn read_constraints( constraints: &[RequirementsSource], client_builder: &BaseClientBuilder<'_>, ) -> Result, Error> { - Ok( - RequirementsSpecification::from_sources(&[], constraints, &[], client_builder) - .await? - .constraints, + Ok(RequirementsSpecification::from_sources( + &[], + constraints, + &[], + BTreeMap::default(), + client_builder, ) + .await? + .constraints) } /// Resolve a set of requirements, similar to running `pip compile`. @@ -114,7 +112,7 @@ pub(crate) async fn resolve( mut project: Option, workspace_members: BTreeSet, extras: &ExtrasSpecification, - groups: &DependencyGroups, + groups: &BTreeMap, preferences: Vec, installed_packages: InstalledPackages, hasher: &HashStrategy, diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index dfd0af29e..92c2c891f 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; use std::sync::Arc; @@ -9,9 +9,8 @@ use tracing::debug; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - BuildOptions, Concurrency, ConfigSettings, Constraints, DependencyGroups, DryRun, - ExtrasSpecification, HashCheckingMode, IndexStrategy, PreviewMode, Reinstall, SourceStrategy, - Upgrade, + BuildOptions, Concurrency, ConfigSettings, Constraints, DryRun, ExtrasSpecification, + HashCheckingMode, IndexStrategy, PreviewMode, Reinstall, SourceStrategy, Upgrade, }; use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; @@ -88,7 +87,7 @@ pub(crate) async fn pip_sync( // Initialize a few defaults. let overrides = &[]; let extras = ExtrasSpecification::default(); - let groups = DependencyGroups::default(); + let groups = BTreeMap::default(); let upgrade = Upgrade::default(); let resolution_mode = ResolutionMode::default(); let prerelease_mode = PrereleaseMode::default(); @@ -101,6 +100,7 @@ pub(crate) async fn pip_sync( constraints, overrides, source_trees, + groups, index_url, extra_index_urls, no_index, @@ -113,7 +113,7 @@ pub(crate) async fn pip_sync( constraints, overrides, &extras, - &groups, + groups, &client_builder, ) .await?; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 8853a27a8..e9a2557ea 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -13,8 +13,7 @@ use uv_auth::UrlAuthPolicies; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroups, DryRun, ExtrasSpecification, PreviewMode, - Reinstall, Upgrade, + Concurrency, Constraints, DryRun, ExtrasSpecification, PreviewMode, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -588,7 +587,7 @@ async fn do_lock( // optional on the downstream APIs. let build_hasher = HashStrategy::default(); let extras = ExtrasSpecification::default(); - let groups = DependencyGroups::default(); + let groups = BTreeMap::new(); // Resolve the flat indexes from `--find-links`. let flat_index = { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 6ccbb91b2..0966c2dd9 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -13,8 +13,8 @@ use uv_cache::{Cache, CacheBucket}; use uv_cache_key::cache_digest; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DryRun, - ExtrasSpecification, PreviewMode, Reinstall, SourceStrategy, Upgrade, + Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, + PreviewMode, Reinstall, SourceStrategy, Upgrade, }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::{DistributionDatabase, LoweredRequirement}; @@ -1727,7 +1727,7 @@ pub(crate) async fn resolve_environment( // TODO(charlie): These are all default values. We should consider whether we want to make them // optional on the downstream APIs. let extras = ExtrasSpecification::default(); - let groups = DependencyGroups::default(); + let groups = BTreeMap::new(); let hasher = HashStrategy::default(); let build_constraints = Constraints::default(); let build_hasher = HashStrategy::default(); @@ -2112,7 +2112,7 @@ pub(crate) async fn update_environment( let build_constraints = Constraints::default(); let build_hasher = HashStrategy::default(); let extras = ExtrasSpecification::default(); - let groups = DependencyGroups::default(); + let groups = BTreeMap::new(); let hasher = HashStrategy::default(); let preferences = Vec::default(); diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 8dd6c427d..93e27884d 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::fmt::Write; use std::str::FromStr; @@ -234,9 +235,14 @@ pub(crate) async fn install( }; // Read the `--with` requirements. - let spec = - RequirementsSpecification::from_sources(with, constraints, overrides, &client_builder) - .await?; + let spec = RequirementsSpecification::from_sources( + with, + constraints, + overrides, + BTreeMap::default(), + &client_builder, + ) + .await?; // Resolve the `--from` and `--with` requirements. let requirements = { diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 0d6c6e470..e3c88e2f9 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::fmt::Display; use std::fmt::Write; use std::path::Path; @@ -750,9 +751,14 @@ async fn get_or_create_environment( }; // Read the `--with` requirements. - let spec = - RequirementsSpecification::from_sources(with, constraints, overrides, &client_builder) - .await?; + let spec = RequirementsSpecification::from_sources( + with, + constraints, + overrides, + BTreeMap::default(), + &client_builder, + ) + .await?; // Resolve the `--from` and `--with` requirements. let requirements = { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c66489f5a..267b5629c 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::ffi::OsString; use std::fmt::Write; use std::io::stdout; @@ -391,6 +392,20 @@ async fn run(mut cli: Cli) -> Result { .map(RequirementsSource::from_constraints_txt) .collect::>(); + let mut groups = BTreeMap::new(); + for group in args.settings.groups { + // If there's no path provided, expect a pyproject.toml in the project-dir + // (Which is typically the current working directory, matching pip's behaviour) + let pyproject_path = group + .path + .clone() + .unwrap_or_else(|| project_dir.join("pyproject.toml")); + groups + .entry(pyproject_path) + .or_insert_with(Vec::new) + .push(group.name.clone()); + } + commands::pip_compile( &requirements, &constraints, @@ -401,7 +416,7 @@ async fn run(mut cli: Cli) -> Result { args.build_constraints_from_workspace, args.environments, args.settings.extras, - args.settings.groups, + groups, args.settings.output_file.as_deref(), args.settings.resolution, args.settings.prerelease, @@ -561,6 +576,20 @@ async fn run(mut cli: Cli) -> Result { .map(RequirementsSource::from_overrides_txt) .collect::>(); + let mut groups = BTreeMap::new(); + for group in args.settings.groups { + // If there's no path provided, expect a pyproject.toml in the project-dir + // (Which is typically the current working directory, matching pip's behaviour) + let pyproject_path = group + .path + .clone() + .unwrap_or_else(|| project_dir.join("pyproject.toml")); + groups + .entry(pyproject_path) + .or_insert_with(Vec::new) + .push(group.name.clone()); + } + commands::pip_install( &requirements, &constraints, @@ -570,7 +599,7 @@ async fn run(mut cli: Cli) -> Result { args.overrides_from_workspace, args.build_constraints_from_workspace, &args.settings.extras, - &args.settings.groups, + groups, args.settings.resolution, args.settings.prerelease, args.settings.dependency_mode, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 71e7db36d..a01b84e4d 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -30,7 +30,7 @@ use uv_configuration::{ }; use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, IndexUrl}; use uv_install_wheel::LinkMode; -use uv_normalize::PackageName; +use uv_normalize::{PackageName, PipGroupName}; use uv_pep508::{ExtraName, MarkerTree, RequirementOrigin}; use uv_pypi_types::{Requirement, SupportedEnvironments}; use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target}; @@ -1605,6 +1605,7 @@ impl PipCompileSettings { refresh, no_deps, deps, + group, output_file, no_strip_extras, strip_extras, @@ -1721,6 +1722,7 @@ impl PipCompileSettings { extra, all_extras: flag(all_extras, no_all_extras), no_deps: flag(no_deps, deps), + group: Some(group), output_file, no_strip_extras: flag(no_strip_extras, strip_extras), no_strip_markers: flag(no_strip_markers, strip_markers), @@ -1867,6 +1869,7 @@ impl PipInstallSettings { refresh, no_deps, deps, + group, require_hashes, no_require_hashes, verify_hashes, @@ -1973,6 +1976,7 @@ impl PipInstallSettings { strict: flag(strict, no_strict), extra, all_extras: flag(all_extras, no_all_extras), + group: Some(group), no_deps: flag(no_deps, deps), python_version, python_platform, @@ -2592,7 +2596,7 @@ pub(crate) struct PipSettings { pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) system: bool, pub(crate) extras: ExtrasSpecification, - pub(crate) groups: DependencyGroups, + pub(crate) groups: Vec, pub(crate) break_system_packages: bool, pub(crate) target: Option, pub(crate) prefix: Option, @@ -2669,6 +2673,7 @@ impl PipSettings { extra, all_extras, no_extra, + group, no_deps, allow_empty_requirements, resolution, @@ -2786,16 +2791,7 @@ impl PipSettings { args.no_extra.combine(no_extra).unwrap_or_default(), args.extra.combine(extra).unwrap_or_default(), ), - groups: DependencyGroups::from_args( - false, - false, - false, - Vec::new(), - Vec::new(), - false, - Vec::new(), - false, - ), + groups: args.group.combine(group).unwrap_or_default(), dependency_mode: if args.no_deps.combine(no_deps).unwrap_or_default() { DependencyMode::Direct } else { diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index c5cf64dfc..20797f791 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -1591,7 +1591,7 @@ fn compile_python_conflicts() -> Result<()> { .arg("--python") .arg("3.12") .arg("-p") - .arg("3.12"), @r###" + .arg("3.12"), @r" success: false exit_code: 2 ----- stdout ----- @@ -1599,10 +1599,10 @@ fn compile_python_conflicts() -> Result<()> { ----- stderr ----- error: the argument '--python ' cannot be used multiple times - Usage: uv pip compile [OPTIONS] ... + Usage: uv pip compile [OPTIONS] > For more information, try '--help'. - "### + " ); // `UV_PYTHON` should be usable with `-p` @@ -3263,7 +3263,7 @@ optional-dependencies.bar = [ .arg("--all-extras") .arg("--extra") .arg("foo"), - @r###" + @r" success: false exit_code: 2 ----- stdout ----- @@ -3271,10 +3271,10 @@ optional-dependencies.bar = [ ----- stderr ----- error: the argument '--all-extras' cannot be used with '--extra ' - Usage: uv pip compile --cache-dir [CACHE_DIR] --all-extras --exclude-newer ... + Usage: uv pip compile --cache-dir [CACHE_DIR] --all-extras --exclude-newer > For more information, try '--help'. - "### + " ); Ok(()) @@ -15261,6 +15261,733 @@ fn respect_index_preference() -> Result<()> { Ok(()) } +#[test] +fn dependency_group() -> Result<()> { + // uv pip compile --group tests, with a single pyproject.toml + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // Passing --group should add just that group's contents from ./pyproject.toml + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("bar"), @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 bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + // Passing a pyproject.toml and --group should include both its deps and the group + // (This is a "try to confuse the internals" test, as this file is logically + // imported twice, but with two different semantics.) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("pyproject.toml") + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] pyproject.toml --group bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + typing-extensions==4.10.0 + # via myproject (pyproject.toml) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // Another "try to confuse the internals" test with an absolute path + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg(context.temp_dir.child("pyproject.toml").path()) + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] [TEMP_DIR]/pyproject.toml --group bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + typing-extensions==4.10.0 + # via myproject (pyproject.toml) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // An explicit use of `:` syntax, here using what the default is anyway + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("pyproject.toml:bar"), @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 pyproject.toml:bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + // "try to confuse the internals" with an explicit path for the group + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("pyproject.toml") + .arg("--group").arg("pyproject.toml:bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] pyproject.toml --group pyproject.toml:bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + typing-extensions==4.10.0 + # via myproject (pyproject.toml) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // Let's check that the other group works fine individually + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("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 foo + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + // Now let's do both of the groups together + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("foo") + .arg("--group").arg("bar"), @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 foo --group bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // And finally put it all together + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("pyproject.toml") + .arg("--group").arg("foo") + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] pyproject.toml --group foo --group bar + iniconfig==2.0.0 + # via myproject (pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + typing-extensions==4.10.0 + # via myproject (pyproject.toml) + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +#[test] +fn many_pyproject_group() -> Result<()> { + // uv pip compile --group tests, with multiple pyproject.tomls at once + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // Use the 'foo' group from the main toml + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("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 pyproject.toml:foo + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + // Use the 'foo' group from the subtoml + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("subdir/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 subdir/pyproject.toml:foo + iniconfig==2.0.0 + # via mysubproject (subdir/pyproject.toml:foo) + + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + // Now try both together, where they happen to define a group with the same name + // (This does nothing special but the code shouldn't get confused.) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("pyproject.toml:foo") + .arg("--group").arg("subdir/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 pyproject.toml:foo --group subdir/pyproject.toml:foo + iniconfig==2.0.0 + # via mysubproject (subdir/pyproject.toml:foo) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + +#[test] +fn suspicious_group() -> Result<()> { + // uv pip compile --group tests, where the invocations are suspicious + // and we might want to add warnings + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // Another variant of "both" but with the path sugar applied to the one in cwd + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("foo") + .arg("--group").arg("subdir/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 foo --group subdir/pyproject.toml:foo + iniconfig==2.0.0 + # via mysubproject (subdir/pyproject.toml:foo) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // Using the path sugar for "foo" but requesting "bar" for the subtoml + // Although you would be forgiven for thinking "foo" should be used from + // the subtoml, that's not what should happen. + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("foo") + .arg("--group").arg("subdir/pyproject.toml:bar"), @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 foo --group subdir/pyproject.toml:bar + sniffio==1.3.1 + # via mysubproject (subdir/pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // Using the path sugar to request pyproject.toml:foo + // while also importing subdir/pyproject.toml's dependencies + // Although you would be forgiven for thinking "foo" should be used from + // the subtoml, that's not what should happen. + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("subdir/pyproject.toml") + .arg("--group").arg("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] subdir/pyproject.toml --group foo + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + typing-extensions==4.10.0 + # via mysubproject (subdir/pyproject.toml) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // An inversion of the previous -- this one isn't terribly ambiguous + // but we should have it in the suite too in case it should be distinguished! + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("pyproject.toml") + .arg("--group").arg("subdir/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] pyproject.toml --group subdir/pyproject.toml:foo + iniconfig==2.0.0 + # via mysubproject (subdir/pyproject.toml:foo) + typing-extensions==4.10.0 + # via myproject (pyproject.toml) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + +#[test] +fn invalid_group() -> Result<()> { + // uv pip compile --group tests, where the invocations should fail + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let context = new_context()?; + + // Hey you passed a path and not a group! + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("subdir/"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'subdir/' for '--group ': Not a valid package or extra name: "subdir/". Names must start and end with a letter or digit and may only contain -, _, ., and alphanumeric characters. + + For more information, try '--help'. + "#); + + // Hey this path needs to end with "pyproject.toml"! + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("./:foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value './:foo' for '--group ': The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: ./ + + For more information, try '--help'. + "); + + // Hey this path needs to end with "pyproject.toml"! + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("subdir/:foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'subdir/:foo' for '--group ': The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: subdir/ + + For more information, try '--help'. + "); + + // Another invocation that Looks Weird but is asking for bar from two + // different tomls. In this case the main one doesn't define it and + // we should error! + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("bar") + .arg("--group").arg("subdir/pyproject.toml:bar"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The dependency group 'bar' was not found in the project: pyproject.toml + "); + + Ok(()) +} + +#[test] +fn project_and_group() -> 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // 'foo' from subtoml, by implicit-sugar + --project + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--project").arg("subdir") + .arg("--group").arg("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] --project subdir --group foo + iniconfig==2.0.0 + # via mysubproject (subdir/pyproject.toml:foo) + + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + // 'foo' from subtoml, by implicit-sugar + --project + // 'bar' from subtoml, by explicit relpath from cwd + // (explicit relpaths are not affected by --project) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--project").arg("subdir") + .arg("--group").arg("subdir/pyproject.toml:bar") + .arg("--group").arg("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] --project subdir --group subdir/pyproject.toml:bar --group foo + iniconfig==2.0.0 + # via mysubproject (subdir/pyproject.toml:foo) + sniffio==1.3.1 + # via mysubproject (subdir/pyproject.toml:bar) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // 'bar' from subtoml, by implicit-sugar + --project + // 'foo' from main toml, by explicit relpath from cwd + // (explicit relpaths are not affected by --project) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--project").arg("subdir") + .arg("--group").arg("bar") + .arg("--group").arg("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] --project subdir --group bar --group pyproject.toml:foo + sniffio==1.3.1 + # via mysubproject (subdir/pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // 'bar' from subtoml, by explicit relpath from cwd + // 'foo' from main toml, by explicit relpath from cwd + // (explicit relpaths are not affected by --project) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--project").arg("subdir") + .arg("--group").arg("subdir/pyproject.toml:bar") + .arg("--group").arg("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] --project subdir --group subdir/pyproject.toml:bar --group pyproject.toml:foo + sniffio==1.3.1 + # via mysubproject (subdir/pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + +#[test] +fn directory_and_group() -> Result<()> { + // Checking that --directory 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // 'bar' from subtoml, by implicit-sugar + --directory + // 'foo' from main toml, by explicit relpath from --directory + // (explicit relpaths ARE affected by --directory) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--directory").arg("subdir") + .arg("--group").arg("bar") + .arg("--group").arg("../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] --directory subdir --group bar --group ../pyproject.toml:foo + sniffio==1.3.1 + # via mysubproject (pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (../pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // 'bar' from subtoml, by explicit relpath from --directory + // 'foo' from main toml, by explicit relpath from --directory + // (explicit relpaths ARE affected by --directory) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--directory").arg("subdir") + .arg("--group").arg("pyproject.toml:bar") + .arg("--group").arg("../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] --directory subdir --group pyproject.toml:bar --group ../pyproject.toml:foo + sniffio==1.3.1 + # via mysubproject (pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject (../pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // 'bar' from subtoml, by explicit relpath from --directory + // 'foo' from main toml, by implicit path + --project + --directory + // (explicit relpaths ARE affected by --directory) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--directory").arg("subdir") + .arg("--project").arg("../") + .arg("--group").arg("pyproject.toml:bar") + .arg("--group").arg("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] --directory subdir --project ../ --group pyproject.toml:bar --group foo + sniffio==1.3.1 + # via mysubproject (pyproject.toml:bar) + sortedcontainers==2.4.0 + # via myproject ([TEMP_DIR]/pyproject.toml:foo) + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + /// See: #[test] fn compile_preserve_requires_python_split() -> Result<()> { diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 7b8a702ea..dc2b977e0 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -9060,6 +9060,749 @@ fn direct_url_json_direct_url() -> Result<()> { Ok(()) } +#[test] +fn dependency_group() -> Result<()> { + // testing basic `uv pip install --group` functionality + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#, + )?; + + context.lock().assert().success(); + Ok(context) + } + + let mut context; + + // 'bar' using path sugar + 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 + "); + + // 'bar' using path sugar + // and also pulling in the same pyproject.toml with -r + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r").arg("pyproject.toml") + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + typing-extensions==4.10.0 + "); + + // 'bar' with an explicit path + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("pyproject.toml: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 + "); + + // 'bar' using explicit path + // and also pulling in the same pyproject.toml with -r + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r").arg("pyproject.toml") + .arg("--group").arg("pyproject.toml:bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + typing-extensions==4.10.0 + "); + + // 'bar' using path sugar + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sortedcontainers==2.4.0 + "); + + // 'foo' using path sugar + // 'bar' using path sugar + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("foo") + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + sortedcontainers==2.4.0 + "); + + // all together now! + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r").arg("pyproject.toml") + .arg("--group").arg("foo") + .arg("--group").arg("bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + iniconfig==2.0.0 + + sortedcontainers==2.4.0 + + typing-extensions==4.10.0 + "); + + Ok(()) +} + +#[test] +fn many_pyproject_group() -> Result<()> { + // `uv pip install --group` tests with multiple projects + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + context.lock().assert().success(); + Ok(context) + } + + let mut context; + + // 'foo' from main toml + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sortedcontainers==2.4.0 + "); + + // 'foo' from subtoml + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("subdir/pyproject.toml:foo"), @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 + "); + + // 'foo' from main toml + // 'foo' from sub toml + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("pyproject.toml:foo") + .arg("--group").arg("subdir/pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + sortedcontainers==2.4.0 + "); + + Ok(()) +} + +#[test] +fn other_sources_group() -> Result<()> { + // `uv pip install --group` tests just slamming random other sources like -e and . + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // 'foo' from main toml + // and install '.' + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg(".") + .arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + myproject==0.1.0 (from file://[TEMP_DIR]/) + + sortedcontainers==2.4.0 + + typing-extensions==4.10.0 + "); + + // 'foo' from main toml + // and install an editable + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("-e").arg(context.workspace_root.join("scripts/packages/poetry_editable")) + .arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + poetry-editable==0.1.0 (from file://[WORKSPACE]/scripts/packages/poetry_editable) + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + Ok(()) +} + +#[test] +fn suspicious_group() -> Result<()> { + // uv pip compile --group tests, where the invocations are suspicious + // and we might want to add warnings + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // Another variant of "both" but with the path sugar applied to the one in cwd + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("foo") + .arg("--group").arg("subdir/pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + sortedcontainers==2.4.0 + "); + + // Using the path sugar for "foo" but requesting "bar" for the subtoml + // Although you would be forgiven for thinking "foo" should be used from + // the subtoml, that's not what should happen. + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("foo") + .arg("--group").arg("subdir/pyproject.toml:bar"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + // Using the path sugar to request pyproject.toml:foo + // while also importing subdir/pyproject.toml's dependencies + // Although you would be forgiven for thinking "foo" should be used from + // the subtoml, that's not what should happen. + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r").arg("subdir/pyproject.toml") + .arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sortedcontainers==2.4.0 + + typing-extensions==4.10.0 + "); + + // An inversion of the previous -- this one isn't terribly ambiguous + // but we should have it in the suite too in case it should be distinguished! + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r").arg("pyproject.toml") + .arg("--group").arg("subdir/pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + typing-extensions==4.10.0 + "); + + Ok(()) +} + +#[test] +fn invalid_group() -> Result<()> { + // uv pip compile --group tests, where the invocations should fail + 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let context = new_context()?; + + // Hey you passed a path and not a group! + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("subdir/"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'subdir/' for '--group ': Not a valid package or extra name: "subdir/". Names must start and end with a letter or digit and may only contain -, _, ., and alphanumeric characters. + + For more information, try '--help'. + "#); + + // Hey this path needs to end with "pyproject.toml"! + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("./:foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value './:foo' for '--group ': The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: ./ + + For more information, try '--help'. + "); + + // Hey this path needs to end with "pyproject.toml"! + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("subdir/:foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'subdir/:foo' for '--group ': The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: subdir/ + + For more information, try '--help'. + "); + + // Another invocation that Looks Weird but is asking for bar from two + // different tomls. In this case the main one doesn't define it and + // we should error! + uv_snapshot!(context.filters(), context.pip_install() + .arg("--group").arg("bar") + .arg("--group").arg("subdir/pyproject.toml:bar"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The dependency group 'bar' was not found in the project: pyproject.toml + "); + + Ok(()) +} + +#[test] +fn project_and_group() -> 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // 'foo' from subtoml, by implicit-sugar + --project + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--project").arg("subdir") + .arg("--group").arg("foo"), @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 + "); + + // 'foo' from subtoml, by implicit-sugar + --project + // 'bar' from subtoml, by explicit relpath from cwd + // (explicit relpaths are not affected by --project) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--project").arg("subdir") + .arg("--group").arg("subdir/pyproject.toml:bar") + .arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + sniffio==1.3.1 + "); + + // 'bar' from subtoml, by implicit-sugar + --project + // 'foo' from main toml, by explicit relpath from cwd + // (explicit relpaths are not affected by --project) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--project").arg("subdir") + .arg("--group").arg("bar") + .arg("--group").arg("pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + // 'bar' from subtoml, by explicit relpath from cwd + // 'foo' from main toml, by explicit relpath from cwd + // (explicit relpaths are not affected by --project) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--project").arg("subdir") + .arg("--group").arg("subdir/pyproject.toml:bar") + .arg("--group").arg("pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + Ok(()) +} + +#[test] +fn directory_and_group() -> Result<()> { + // Checking that --directory 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" + dependencies = ["typing-extensions"] + [dependency-groups] + foo = ["sortedcontainers"] + "#, + )?; + + let subdir = context.temp_dir.child("subdir"); + subdir.create_dir_all()?; + let pyproject_toml2 = subdir.child("pyproject.toml"); + pyproject_toml2.write_str( + r#" + [project] + name = "mysubproject" + version = "0.1.0" + requires-python = ">=3.12" + [dependency-groups] + foo = ["iniconfig"] + bar = ["sniffio"] + "#, + )?; + + Ok(context) + } + + let mut context; + + // 'bar' from subtoml, by implicit-sugar + --directory + // 'foo' from main toml, by explicit relpath from --directory + // (explicit relpaths ARE affected by --directory) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--directory").arg("subdir") + .arg("--group").arg("bar") + .arg("--group").arg("../pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] environment at: [VENV]/ + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + // 'bar' from subtoml, by explicit relpath from --directory + // 'foo' from main toml, by explicit relpath from --directory + // (explicit relpaths ARE affected by --directory) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--directory").arg("subdir") + .arg("--group").arg("pyproject.toml:bar") + .arg("--group").arg("../pyproject.toml:foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] environment at: [VENV]/ + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + // 'bar' from subtoml, by explicit relpath from --directory + // 'foo' from main toml, by implicit path + --project + --directory + // (explicit relpaths ARE affected by --directory) + context = new_context()?; + uv_snapshot!(context.filters(), context.pip_install() + .arg("--directory").arg("subdir") + .arg("--project").arg("../") + .arg("--group").arg("pyproject.toml:bar") + .arg("--group").arg("foo"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] environment at: [VENV]/ + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + "); + + Ok(()) +} + /// Regression test that we don't discover workspaces with `--no-sources`. /// /// We have a workspace dependency shadowing a PyPI package and using this package's version to diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 65dff0eda..f790cae58 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -148,24 +148,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -323,24 +306,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -499,24 +465,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -707,24 +656,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -853,24 +785,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -1039,24 +954,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -1269,24 +1167,7 @@ fn resolve_index_url() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -1508,24 +1389,7 @@ fn resolve_index_url() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -1708,24 +1572,7 @@ fn resolve_find_links() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -1876,24 +1723,7 @@ fn resolve_top_level() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -2098,24 +1928,7 @@ fn resolve_top_level() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -2303,24 +2116,7 @@ fn resolve_top_level() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -2470,24 +2266,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -2621,24 +2400,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -2772,24 +2534,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -2925,24 +2670,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -3260,24 +2988,7 @@ fn resolve_poetry_toml() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -3470,24 +3181,7 @@ fn resolve_both() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -3770,24 +3464,7 @@ fn resolve_config_file() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -4015,24 +3692,7 @@ fn resolve_skip_empty() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -4169,24 +3829,7 @@ fn resolve_skip_empty() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -4342,24 +3985,7 @@ fn allow_insecure_host() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -4570,24 +4196,7 @@ fn index_priority() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -4777,24 +4386,7 @@ fn index_priority() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -4990,24 +4582,7 @@ fn index_priority() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -5198,24 +4773,7 @@ fn index_priority() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -5413,24 +4971,7 @@ fn index_priority() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -5621,24 +5162,7 @@ fn index_priority() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -5780,24 +5304,7 @@ fn verify_hashes() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -5925,24 +5432,7 @@ fn verify_hashes() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -6068,24 +5558,7 @@ fn verify_hashes() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -6213,24 +5686,7 @@ fn verify_hashes() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -6356,24 +5812,7 @@ fn verify_hashes() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, @@ -6500,24 +5939,7 @@ fn verify_hashes() -> anyhow::Result<()> { }, system: false, extras: None, - groups: DependencyGroups( - DependencyGroupsInner { - include: Some( - [], - ), - exclude: [], - only_groups: false, - history: DependencyGroupsHistory { - dev_mode: None, - group: [], - only_group: [], - no_group: [], - all_groups: false, - no_default_groups: false, - defaults: [], - }, - }, - ), + groups: [], break_system_packages: false, target: None, prefix: None, diff --git a/docs/pip/compile.md b/docs/pip/compile.md index 6451a2ae1..c25414270 100644 --- a/docs/pip/compile.md +++ b/docs/pip/compile.md @@ -60,6 +60,35 @@ $ uv pip compile pyproject.toml --all-extras Note extras are not supported with the `requirements.in` format. +To lock a dependency group in the current project directory's `pyproject.toml`, for example the +group `foo`: + +```console +$ uv pip compile --group foo +``` + +!!! important + + A `--group` flag has to be added to pip-tools' `pip compile`, [although they're considering it](https://github.com/jazzband/pip-tools/issues/2062). We expect to support whatever syntax and semantics they adopt. + +To specify the project directory where groups should be sourced from: + +```console +$ uv pip compile --project some/path/ --group foo --group bar +``` + +Alternatively, you can specify a path to a `pyproject.toml` for each group: + +```console +$ uv pip compile --group some/path/pyproject.toml:foo --group other/pyproject.toml:bar +``` + +!!! note + + `--group` flags do not apply to other specified sources. For instance, + `uv pip compile some/path/pyproject.toml --group foo` sources `foo` + from `./pyproject.toml` and **not** `some/path/pyproject.toml`. + ## Upgrading requirements When using an output file, uv will consider the versions pinned in an existing output file. If a diff --git a/docs/pip/packages.md b/docs/pip/packages.md index ab363105e..ccd4da19a 100644 --- a/docs/pip/packages.md +++ b/docs/pip/packages.md @@ -107,6 +107,31 @@ Install from a `pyproject.toml` file with all optional dependencies enabled: $ uv pip install -r pyproject.toml --all-extras ``` +To install dependency groups in the current project directory's `pyproject.toml`, for example the +group `foo`: + +```console +$ uv pip install --group foo +``` + +To specify the project directory where groups should be sourced from: + +```console +$ uv pip install --project some/path/ --group foo --group bar +``` + +Alternatively, you can specify a path to a `pyproject.toml` for each group: + +```console +$ uv pip install --group some/path/pyproject.toml:foo --group other/pyproject.toml:bar +``` + +!!! note + + As in pip, `--group` flags do not apply to other sources specified with flags like `-r` or -e`. + For instance, `uv pip install -r some/path/pyproject.toml --group foo` sources `foo` + from `./pyproject.toml` and **not** `some/path/pyproject.toml`. + ## Uninstalling a package To uninstall a package, e.g., Flask: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 31f4a0329..467bae097 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5577,7 +5577,7 @@ Compile a `requirements.in` file to a `requirements.txt` file

Usage

``` -uv pip compile [OPTIONS] ... +uv pip compile [OPTIONS] > ```

Arguments

@@ -5722,6 +5722,12 @@ uv pip compile [OPTIONS] ...
--generate-hashes

Include distribution hashes in the output file

+
--group group

Install the specified dependency group from a pyproject.toml.

+ +

If no path is provided, the pyproject.toml in the working directory is used.

+ +

May be provided multiple times.

+
--help, -h

Display the concise help for this command

--index index

The URLs to use when resolving dependencies, in addition to the default index.

@@ -6461,7 +6467,7 @@ Install packages into an environment

Usage

``` -uv pip install [OPTIONS] |--editable > +uv pip install [OPTIONS] |--editable |--group > ```

Arguments

@@ -6596,6 +6602,12 @@ uv pip install [OPTIONS] |--editable requires-python: Optimize for selecting latest supported version of each package, for each supported Python version +
--group group

Install the specified dependency group from a pyproject.toml.

+ +

If no path is provided, the pyproject.toml in the working directory is used.

+ +

May be provided multiple times.

+
--help, -h

Display the concise help for this command

--index index

The URLs to use when resolving dependencies, in addition to the default index.

diff --git a/docs/reference/settings.md b/docs/reference/settings.md index bf9ba6271..80a887b44 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -2308,6 +2308,32 @@ Include distribution hashes in the output file. --- +#### [`group`](#pip_group) {: #pip_group } + + +Include the following dependency groups. + +**Default value**: `None` + +**Type**: `list[str]` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv.pip] + group = ["dev", "docs"] + ``` +=== "uv.toml" + + ```toml + [pip] + group = ["dev", "docs"] + ``` + +--- + #### [`index-strategy`](#pip_index-strategy) {: #pip_index-strategy } diff --git a/uv.schema.json b/uv.schema.json index 06c87027f..4b8a0c24d 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -880,6 +880,24 @@ "type": "string", "pattern": "^(:none:|:all:|([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]))$" }, + "PipGroupName": { + "description": "The pip-compatible variant of a [`GroupName`].\n\nEither or :. If is omitted it defaults to \"pyproject.toml\".", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "$ref": "#/definitions/GroupName" + }, + "path": { + "type": [ + "string", + "null" + ] + } + } + }, "PipOptions": { "description": "Settings that are specific to the `uv pip` command-line interface.\n\nThese values will be ignored when running commands outside the `uv pip` namespace (e.g., `uv lock`, `uvx`).", "type": "object", @@ -1045,6 +1063,16 @@ "null" ] }, + "group": { + "description": "Include the following dependency groups.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/PipGroupName" + } + }, "index-strategy": { "description": "The strategy to use when resolving against multiple index URLs.\n\nBy default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (`first-index`). This prevents \"dependency confusion\" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.", "anyOf": [