Skip to content

Commit ba73231

Browse files
authored
add pip-compatible --group flag to uv pip install and uv pip compile (astral-sh#11686)
This is a minimal redux of astral-sh#10861 to be compatible with `uv pip`. This implements the interface described in: pypa/pip#13065 (comment) 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 astral-sh#8590 Fixes astral-sh#8969
1 parent 3c20ffe commit ba73231

File tree

26 files changed

+1999
-707
lines changed

26 files changed

+1999
-707
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use uv_configuration::{
1515
ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem,
1616
};
1717
use uv_distribution_types::{Index, IndexUrl, Origin, PipExtraIndex, PipFindLinks, PipIndex};
18-
use uv_normalize::{ExtraName, GroupName, PackageName};
18+
use uv_normalize::{ExtraName, GroupName, PackageName, PipGroupName};
1919
use uv_pep508::{MarkerTree, Requirement};
2020
use uv_pypi_types::VerbatimParsedUrl;
2121
use uv_python::{PythonDownloads, PythonPreference, PythonVersion};
@@ -949,6 +949,7 @@ fn parse_maybe_string(input: &str) -> Result<Maybe<String>, String> {
949949

950950
#[derive(Args)]
951951
#[allow(clippy::struct_excessive_bools)]
952+
#[command(group = clap::ArgGroup::new("sources").required(true).multiple(true))]
952953
pub struct PipCompileArgs {
953954
/// Include all packages listed in the given `requirements.in` files.
954955
///
@@ -959,7 +960,7 @@ pub struct PipCompileArgs {
959960
///
960961
/// The order of the requirements files and the requirements in them is used to determine
961962
/// priority during resolution.
962-
#[arg(required(true), value_parser = parse_file_path)]
963+
#[arg(group = "sources", value_parser = parse_file_path)]
963964
pub src_file: Vec<PathBuf>,
964965

965966
/// Constrain versions using the given requirements files.
@@ -1022,6 +1023,14 @@ pub struct PipCompileArgs {
10221023
#[arg(long, overrides_with("no_deps"), hide = true)]
10231024
pub deps: bool,
10241025

1026+
/// Install the specified dependency group from a `pyproject.toml`.
1027+
///
1028+
/// If no path is provided, the `pyproject.toml` in the working directory is used.
1029+
///
1030+
/// May be provided multiple times.
1031+
#[arg(long, group = "sources")]
1032+
pub group: Vec<PipGroupName>,
1033+
10251034
/// Write the compiled requirements to the given `requirements.txt` file.
10261035
///
10271036
/// If the file already exists, the existing versions will be preferred when resolving
@@ -1586,6 +1595,14 @@ pub struct PipInstallArgs {
15861595
#[arg(long, overrides_with("no_deps"), hide = true)]
15871596
pub deps: bool,
15881597

1598+
/// Install the specified dependency group from a `pyproject.toml`.
1599+
///
1600+
/// If no path is provided, the `pyproject.toml` in the working directory is used.
1601+
///
1602+
/// May be provided multiple times.
1603+
#[arg(long, group = "sources")]
1604+
pub group: Vec<PipGroupName>,
1605+
15891606
/// Require a matching hash for each requirement.
15901607
///
15911608
/// By default, uv will verify any available hashes in the requirements file, but will not

crates/uv-distribution-types/src/annotation.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ impl std::fmt::Display for SourceAnnotation {
2525
RequirementOrigin::Project(path, project_name) => {
2626
write!(f, "{project_name} ({})", path.portable_display())
2727
}
28+
RequirementOrigin::Group(path, project_name, group) => {
29+
write!(f, "{project_name} ({}:{group})", path.portable_display())
30+
}
2831
RequirementOrigin::Workspace => {
2932
write!(f, "(workspace)")
3033
}
@@ -40,6 +43,14 @@ impl std::fmt::Display for SourceAnnotation {
4043
// Project is not used for override
4144
write!(f, "--override {project_name} ({})", path.portable_display())
4245
}
46+
RequirementOrigin::Group(path, project_name, group) => {
47+
// Group is not used for override
48+
write!(
49+
f,
50+
"--override {project_name} ({}:{group})",
51+
path.portable_display()
52+
)
53+
}
4354
RequirementOrigin::Workspace => {
4455
write!(f, "--override (workspace)")
4556
}

crates/uv-normalize/src/group_name.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
use std::fmt::{Display, Formatter};
2+
use std::path::{Path, PathBuf};
13
use std::str::FromStr;
24
use std::sync::LazyLock;
35

46
use serde::{Deserialize, Deserializer, Serialize, Serializer};
57

68
use uv_small_str::SmallString;
79

8-
use crate::{validate_and_normalize_ref, InvalidNameError};
10+
use crate::{
11+
validate_and_normalize_ref, InvalidNameError, InvalidPipGroupError, InvalidPipGroupPathError,
12+
};
913

1014
/// The normalized name of a dependency group.
1115
///
@@ -82,6 +86,84 @@ impl AsRef<str> for GroupName {
8286
}
8387
}
8488

89+
/// The pip-compatible variant of a [`GroupName`].
90+
///
91+
/// Either <groupname> or <path>:<groupname>.
92+
/// If <path> is omitted it defaults to "pyproject.toml".
93+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
94+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
95+
pub struct PipGroupName {
96+
pub path: Option<PathBuf>,
97+
pub name: GroupName,
98+
}
99+
100+
impl PipGroupName {
101+
/// Gets the path to use, applying the default if it's missing
102+
pub fn path(&self) -> &Path {
103+
if let Some(path) = &self.path {
104+
path
105+
} else {
106+
Path::new("pyproject.toml")
107+
}
108+
}
109+
}
110+
111+
impl FromStr for PipGroupName {
112+
type Err = InvalidPipGroupError;
113+
114+
fn from_str(path_and_name: &str) -> Result<Self, Self::Err> {
115+
// The syntax is `<path>:<name>`.
116+
//
117+
// `:` isn't valid as part of a dependency-group name, but it can appear in a path.
118+
// Therefore we look for the first `:` starting from the end to find the delimiter.
119+
// If there is no `:` then there's no path and we use the default one.
120+
if let Some((path, name)) = path_and_name.rsplit_once(':') {
121+
// pip hard errors if the path does not end with pyproject.toml
122+
if !path.ends_with("pyproject.toml") {
123+
Err(InvalidPipGroupPathError(path.to_owned()))?;
124+
}
125+
126+
let name = GroupName::from_str(name)?;
127+
let path = Some(PathBuf::from(path));
128+
Ok(Self { path, name })
129+
} else {
130+
let name = GroupName::from_str(path_and_name)?;
131+
let path = None;
132+
Ok(Self { path, name })
133+
}
134+
}
135+
}
136+
137+
impl<'de> Deserialize<'de> for PipGroupName {
138+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139+
where
140+
D: Deserializer<'de>,
141+
{
142+
let s = String::deserialize(deserializer)?;
143+
Self::from_str(&s).map_err(serde::de::Error::custom)
144+
}
145+
}
146+
147+
impl Serialize for PipGroupName {
148+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
149+
where
150+
S: Serializer,
151+
{
152+
let string = self.to_string();
153+
string.serialize(serializer)
154+
}
155+
}
156+
157+
impl Display for PipGroupName {
158+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
159+
if let Some(path) = &self.path {
160+
write!(f, "{}:{}", path.display(), self.name)
161+
} else {
162+
self.name.fmt(f)
163+
}
164+
}
165+
}
166+
85167
/// The name of the global `dev-dependencies` group.
86168
///
87169
/// Internally, we model dependency groups as a generic concept; but externally, we only expose the

crates/uv-normalize/src/lib.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::fmt::{Display, Formatter};
33

44
pub use dist_info_name::DistInfoName;
55
pub use extra_name::ExtraName;
6-
pub use group_name::{GroupName, DEV_DEPENDENCIES};
6+
pub use group_name::{GroupName, PipGroupName, DEV_DEPENDENCIES};
77
pub use package_name::PackageName;
88

99
use uv_small_str::SmallString;
@@ -121,6 +121,55 @@ impl Display for InvalidNameError {
121121

122122
impl Error for InvalidNameError {}
123123

124+
/// Path didn't end with `pyproject.toml`
125+
#[derive(Clone, Debug, Eq, PartialEq)]
126+
pub struct InvalidPipGroupPathError(String);
127+
128+
impl InvalidPipGroupPathError {
129+
/// Returns the invalid path.
130+
pub fn as_str(&self) -> &str {
131+
&self.0
132+
}
133+
}
134+
135+
impl Display for InvalidPipGroupPathError {
136+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
137+
write!(
138+
f,
139+
"The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: {}",
140+
self.0,
141+
)
142+
}
143+
}
144+
impl Error for InvalidPipGroupPathError {}
145+
146+
/// Possible errors from reading a [`PipGroupName`].
147+
#[derive(Clone, Debug, Eq, PartialEq)]
148+
pub enum InvalidPipGroupError {
149+
Name(InvalidNameError),
150+
Path(InvalidPipGroupPathError),
151+
}
152+
153+
impl Display for InvalidPipGroupError {
154+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
155+
match self {
156+
InvalidPipGroupError::Name(e) => e.fmt(f),
157+
InvalidPipGroupError::Path(e) => e.fmt(f),
158+
}
159+
}
160+
}
161+
impl Error for InvalidPipGroupError {}
162+
impl From<InvalidNameError> for InvalidPipGroupError {
163+
fn from(value: InvalidNameError) -> Self {
164+
Self::Name(value)
165+
}
166+
}
167+
impl From<InvalidPipGroupPathError> for InvalidPipGroupError {
168+
fn from(value: InvalidPipGroupPathError) -> Self {
169+
Self::Path(value)
170+
}
171+
}
172+
124173
#[cfg(test)]
125174
mod tests {
126175
use super::*;

crates/uv-pep508/src/origin.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::{Path, PathBuf};
22

3-
use uv_normalize::PackageName;
3+
use uv_normalize::{GroupName, PackageName};
44

55
/// The origin of a dependency, e.g., a `-r requirements.txt` file.
66
#[derive(
@@ -12,6 +12,8 @@ pub enum RequirementOrigin {
1212
File(PathBuf),
1313
/// The requirement was provided via a local project (e.g., a `pyproject.toml` file).
1414
Project(PathBuf, PackageName),
15+
/// The requirement was provided via a local project (e.g., a `pyproject.toml` file).
16+
Group(PathBuf, PackageName, GroupName),
1517
/// The requirement was provided via a workspace.
1618
Workspace,
1719
}
@@ -22,6 +24,7 @@ impl RequirementOrigin {
2224
match self {
2325
RequirementOrigin::File(path) => path.as_path(),
2426
RequirementOrigin::Project(path, _) => path.as_path(),
27+
RequirementOrigin::Group(path, _, _) => path.as_path(),
2528
// Multiple toml are merged and difficult to track files where Requirement is defined. Returns a dummy path instead.
2629
RequirementOrigin::Workspace => Path::new("(workspace)"),
2730
}

crates/uv-requirements/src/source_tree.rs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
use std::borrow::Cow;
2-
use std::path::Path;
1+
use std::path::{Path, PathBuf};
32
use std::sync::Arc;
3+
use std::{borrow::Cow, collections::BTreeMap};
44

55
use anyhow::{Context, Result};
66
use futures::stream::FuturesOrdered;
@@ -18,7 +18,7 @@ use uv_pep508::RequirementOrigin;
1818
use uv_pypi_types::Requirement;
1919
use uv_resolver::{InMemoryIndex, MetadataResponse};
2020
use uv_types::{BuildContext, HashStrategy};
21-
use uv_warnings::warn_user_once;
21+
2222
#[derive(Debug, Clone)]
2323
pub struct SourceTreeResolution {
2424
/// The requirements sourced from the source trees.
@@ -37,7 +37,7 @@ pub struct SourceTreeResolver<'a, Context: BuildContext> {
3737
/// The extras to include when resolving requirements.
3838
extras: &'a ExtrasSpecification,
3939
/// The groups to include when resolving requirements.
40-
groups: &'a DependencyGroups,
40+
groups: &'a BTreeMap<PathBuf, DependencyGroups>,
4141
/// The hash policy to enforce.
4242
hasher: &'a HashStrategy,
4343
/// The in-memory index for resolving dependencies.
@@ -50,7 +50,7 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
5050
/// Instantiate a new [`SourceTreeResolver`] for a given set of `source_trees`.
5151
pub fn new(
5252
extras: &'a ExtrasSpecification,
53-
groups: &'a DependencyGroups,
53+
groups: &'a BTreeMap<PathBuf, DependencyGroups>,
5454
hasher: &'a HashStrategy,
5555
index: &'a InMemoryIndex,
5656
database: DistributionDatabase<'a, Context>,
@@ -100,9 +100,13 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
100100

101101
let mut requirements = Vec::new();
102102

103+
// Resolve any groups associated with this path
104+
let default_groups = DependencyGroups::default();
105+
let groups = self.groups.get(path).unwrap_or(&default_groups);
106+
103107
// Flatten any transitive extras and include dependencies
104108
// (unless something like --only-group was passed)
105-
if self.groups.prod() {
109+
if groups.prod() {
106110
requirements.extend(
107111
FlatRequiresDist::from_requirements(metadata.requires_dist, &metadata.name)
108112
.into_iter()
@@ -116,20 +120,24 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
116120

117121
// Apply dependency-groups
118122
for (group_name, group) in &metadata.dependency_groups {
119-
if self.groups.contains(group_name) {
120-
requirements.extend(group.iter().cloned());
123+
if groups.contains(group_name) {
124+
requirements.extend(group.iter().cloned().map(|group| Requirement {
125+
origin: Some(RequirementOrigin::Group(
126+
path.to_path_buf(),
127+
metadata.name.clone(),
128+
group_name.clone(),
129+
)),
130+
..group
131+
}));
121132
}
122133
}
123134
// Complain if dependency groups are named that don't appear.
124-
// This is only a warning because *technically* we support passing in
125-
// multiple pyproject.tomls, but at this level of abstraction we can't see them all,
126-
// so hard erroring on "no pyproject.toml mentions this" is a bit difficult.
127-
for name in self.groups.explicit_names() {
135+
for name in groups.explicit_names() {
128136
if !metadata.dependency_groups.contains_key(name) {
129-
warn_user_once!(
130-
"The dependency-group '{name}' is not defined in {}",
131-
path.display()
132-
);
137+
return Err(anyhow::anyhow!(
138+
"The dependency group '{name}' was not found in the project: {}",
139+
path.user_display()
140+
));
133141
}
134142
}
135143

0 commit comments

Comments
 (0)