From ba732311641f6eeda8a2f09fb83ac1b81a5e17db Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Mon, 17 Mar 2025 14:44:11 -0400 Subject: [PATCH] add pip-compatible `--group` flag to `uv pip install` and `uv pip compile` (#11686) This is a minimal redux of #10861 to be compatible with `uv pip`. This implements the interface described in: https://github.com/pypa/pip/pull/13065#issuecomment-2544000876 for `uv pip install` and `uv pip compile`. Namely `--group <[path:]name>`, where `path` when not defined defaults to `pyproject.toml`. In that interface they add `--group` to `pip install`, `pip download`, and `pip wheel`. Notably we do not define `uv pip download` and `uv pip wheel`, so for parity we only need to implement `uv pip install`. However, we also support `uv pip compile` which is not part of pip itself, and `--group` makes sense there too. ---- The behaviour of `--group` for `uv pip` commands makes sense for the cases upstream pip supports, but has confusing meanings in cases that only we support (because reading pyproject.tomls is New Tech to them but heavily supported by us). **Specifically case (h) below is a concerning footgun, and case (e) below may get complaints from people who aren't well-versed in dependency-groups-as-they-pertain-to-wheels.** ## Only Group Flags Group flags on their own work reasonably and uncontroversially, except perhaps that they don't do very clever automatic project discovery. a) `uv pip install --group path/to/pyproject.toml:mygroup` pulls up `path/to/project.toml` and installs all the packages listed by its `mygroup` dependency-group (essentially treating it like another kind of requirements.txt). In this regard it functions similarly to `--only-group` in the rest of uv's interface. b) `uv pip install --group mygroup` is just sugar for `uv pip install --group pyproject.toml:mygroup` (**note that no project discovery occurs**, upstream pip simply hardcodes the path "pyproject.toml" here and we reproduce that.) c) `uv pip install --group a/pyproject.toml:groupx --group b/pyproject.toml:groupy`, and any other instance of multiple `--group` flags, can be understood as completely independent requests for the given groups at the given files. ## Groups With Named Packages Groups being mixed with named packages also work in a fairly unsurprising way, especially if you understand that things like dependency-groups are not really supposed to exist on pypi, they're just for local development. d) `uv pip install mypackage --group path/to/pyproject.toml:mygroup` much like multiple instances of `--group` the two requests here are essentially completely independent: pleases install `mypackage`, and please also install `path/to/pyproject.toml:mygroup`. e) `uv pip install mypackage --group mygroup` is exactly the same, but this is where it becomes possible for someone to be a little confused, as you might think `mygroup` is supposed to refer to `mypackage` in some way (it can't). But no, it's sourcing `pyproject.toml:mygroup` from the current working directory. ## Groups With Requirements/Sourcetrees/Editables Requirements and sourcetrees are where I expect users to get confused. It behaves *exactly* the same as it does in the previous sections but you would absolutely be forgiven for expecting a different behaviour. *Especially* because `--group` with the rest of uv *does* do something different. f) `uv pip install -r a/pyproject.toml --group b/pyproject.toml:mygroup` is again just two independent requests (install `a/pyproject.toml`'s dependencies, and `b/pyproject.toml`'s `mygroup`). g) `uv pip install -r pyproject.toml --group mygroup` is exactly like the previous case but *incidentally* the two requests refer to the same file. What the user wanted to happen is almost certainly happening, but they are likely getting "lucky" here that they're requesting something simple. h) `uv pip install -r a/pyproject.toml --group mygroup` is again exactly the same but the user is likely to get surprised and upset as this invocation actually sources two different files (install `a/pyproject.toml`'s dependencies, and `pyproject.toml`'s `mygroup`)! I would expect most people to assume the `--group` flag here is covering all applicable requirements/sourcetrees/editables, but no, it continues to be a totally independent reference to a file with a hardcoded relative path. ------ Fixes https://github.com/astral-sh/uv/issues/8590 Fixes https://github.com/astral-sh/uv/issues/8969 --- crates/uv-cli/src/lib.rs | 21 +- .../uv-distribution-types/src/annotation.rs | 11 + crates/uv-normalize/src/group_name.rs | 84 +- crates/uv-normalize/src/lib.rs | 51 +- crates/uv-pep508/src/origin.rs | 5 +- crates/uv-requirements/src/source_tree.rs | 40 +- crates/uv-requirements/src/specification.rs | 73 +- crates/uv-settings/src/settings.rs | 11 +- crates/uv/src/commands/pip/compile.rs | 14 +- crates/uv/src/commands/pip/install.rs | 14 +- crates/uv/src/commands/pip/operations.rs | 28 +- crates/uv/src/commands/pip/sync.rs | 12 +- crates/uv/src/commands/project/lock.rs | 5 +- crates/uv/src/commands/project/mod.rs | 8 +- crates/uv/src/commands/tool/install.rs | 12 +- crates/uv/src/commands/tool/run.rs | 12 +- crates/uv/src/lib.rs | 33 +- crates/uv/src/settings.rs | 20 +- crates/uv/tests/it/pip_compile.rs | 739 ++++++++++++++++- crates/uv/tests/it/pip_install.rs | 743 ++++++++++++++++++ crates/uv/tests/it/show_settings.rs | 646 +-------------- docs/pip/compile.md | 29 + docs/pip/packages.md | 25 + docs/reference/cli.md | 16 +- docs/reference/settings.md | 26 + uv.schema.json | 28 + 26 files changed, 1999 insertions(+), 707 deletions(-) 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": [