Skip to content

Commit bbddb82

Browse files
committed
Support extras and dependency_groups markers on uv pip install and uv pip sync
1 parent bd4c7ff commit bbddb82

File tree

13 files changed

+487
-77
lines changed

13 files changed

+487
-77
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,14 @@ pub struct PipCompileArgs {
12021202
#[arg(long, overrides_with("all_extras"), hide = true)]
12031203
pub no_all_extras: bool,
12041204

1205+
/// Install the specified dependency group from a `pyproject.toml`.
1206+
///
1207+
/// If no path is provided, the `pyproject.toml` in the working directory is used.
1208+
///
1209+
/// May be provided multiple times.
1210+
#[arg(long, group = "sources")]
1211+
pub group: Vec<PipGroupName>,
1212+
12051213
#[command(flatten)]
12061214
pub resolver: ResolverArgs,
12071215

@@ -1216,14 +1224,6 @@ pub struct PipCompileArgs {
12161224
#[arg(long, overrides_with("no_deps"), hide = true)]
12171225
pub deps: bool,
12181226

1219-
/// Install the specified dependency group from a `pyproject.toml`.
1220-
///
1221-
/// If no path is provided, the `pyproject.toml` in the working directory is used.
1222-
///
1223-
/// May be provided multiple times.
1224-
#[arg(long, group = "sources")]
1225-
pub group: Vec<PipGroupName>,
1226-
12271227
/// Write the compiled requirements to the given `requirements.txt` or `pylock.toml` file.
12281228
///
12291229
/// If the file already exists, the existing versions will be preferred when resolving
@@ -1518,6 +1518,30 @@ pub struct PipSyncArgs {
15181518
#[arg(long, short, alias = "build-constraint", env = EnvVars::UV_BUILD_CONSTRAINT, value_delimiter = ' ', value_parser = parse_maybe_file_path)]
15191519
pub build_constraints: Vec<Maybe<PathBuf>>,
15201520

1521+
/// Include optional dependencies from the specified extra name; may be provided more than once.
1522+
///
1523+
/// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
1524+
#[arg(long, conflicts_with = "all_extras", value_parser = extra_name_with_clap_error)]
1525+
pub extra: Option<Vec<ExtraName>>,
1526+
1527+
/// Include all optional dependencies.
1528+
///
1529+
/// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
1530+
#[arg(long, conflicts_with = "extra", overrides_with = "no_all_extras")]
1531+
pub all_extras: bool,
1532+
1533+
#[arg(long, overrides_with("all_extras"), hide = true)]
1534+
pub no_all_extras: bool,
1535+
1536+
/// Install the specified dependency group from a `pylock.toml` or `pyproject.toml`.
1537+
///
1538+
/// If no path is provided, the `pylock.toml` or `pyproject.toml` in the working directory is
1539+
/// used.
1540+
///
1541+
/// May be provided multiple times.
1542+
#[arg(long, group = "sources")]
1543+
pub group: Vec<PipGroupName>,
1544+
15211545
#[command(flatten)]
15221546
pub installer: InstallerArgs,
15231547

@@ -1798,19 +1822,28 @@ pub struct PipInstallArgs {
17981822

17991823
/// Include optional dependencies from the specified extra name; may be provided more than once.
18001824
///
1801-
/// Only applies to `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
1825+
/// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
18021826
#[arg(long, conflicts_with = "all_extras", value_parser = extra_name_with_clap_error)]
18031827
pub extra: Option<Vec<ExtraName>>,
18041828

18051829
/// Include all optional dependencies.
18061830
///
1807-
/// Only applies to `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
1831+
/// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
18081832
#[arg(long, conflicts_with = "extra", overrides_with = "no_all_extras")]
18091833
pub all_extras: bool,
18101834

18111835
#[arg(long, overrides_with("all_extras"), hide = true)]
18121836
pub no_all_extras: bool,
18131837

1838+
/// Install the specified dependency group from a `pylock.toml` or `pyproject.toml`.
1839+
///
1840+
/// If no path is provided, the `pylock.toml` or `pyproject.toml` in the working directory is
1841+
/// used.
1842+
///
1843+
/// May be provided multiple times.
1844+
#[arg(long, group = "sources")]
1845+
pub group: Vec<PipGroupName>,
1846+
18141847
#[command(flatten)]
18151848
pub installer: ResolverInstallerArgs,
18161849

@@ -1825,14 +1858,6 @@ pub struct PipInstallArgs {
18251858
#[arg(long, overrides_with("no_deps"), hide = true)]
18261859
pub deps: bool,
18271860

1828-
/// Install the specified dependency group from a `pyproject.toml`.
1829-
///
1830-
/// If no path is provided, the `pyproject.toml` in the working directory is used.
1831-
///
1832-
/// May be provided multiple times.
1833-
#[arg(long, group = "sources")]
1834-
pub group: Vec<PipGroupName>,
1835-
18361861
/// Require a matching hash for each requirement.
18371862
///
18381863
/// By default, uv will verify any available hashes in the requirements file, but will not

crates/uv-configuration/src/dependency_groups.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,18 @@ impl DependencyGroupsInner {
186186
self.include.names().chain(&self.exclude)
187187
}
188188

189+
/// Returns an iterator over all groups that are included in the specification,
190+
/// assuming `all_names` is an iterator over all groups.
191+
pub fn group_names<'a, Names>(
192+
&'a self,
193+
all_names: Names,
194+
) -> impl Iterator<Item = &'a GroupName> + 'a
195+
where
196+
Names: Iterator<Item = &'a GroupName> + 'a,
197+
{
198+
all_names.filter(move |name| self.contains(name))
199+
}
200+
189201
/// Iterate over all groups the user explicitly asked for on the CLI
190202
pub fn explicit_names(&self) -> impl Iterator<Item = &GroupName> {
191203
let DependencyGroupsHistory {

crates/uv-pep508/src/marker/tree.rs

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,51 @@ impl Display for MarkerExpression {
739739
}
740740
}
741741

742+
/// The extra and dependency group names to use when evaluating a marker tree.
743+
#[derive(Debug, Copy, Clone)]
744+
enum ExtrasEnvironment<'a> {
745+
/// E.g., `extra == '...'`
746+
Extras(&'a [ExtraName]),
747+
/// E.g., `'...' in extras` or `'...' in dependency_groups`
748+
PEP751(&'a [ExtraName], &'a [GroupName]),
749+
}
750+
751+
impl<'a> ExtrasEnvironment<'a> {
752+
/// Creates a new [`ExtrasEnvironment`] for the given `extra` names.
753+
fn from_extras(extras: &'a [ExtraName]) -> Self {
754+
Self::Extras(extras)
755+
}
756+
757+
/// Creates a new [`ExtrasEnvironment`] for the given PEP 751 `extras` and `dependency_groups`.
758+
fn from_pep751(extras: &'a [ExtraName], dependency_groups: &'a [GroupName]) -> Self {
759+
Self::PEP751(extras, dependency_groups)
760+
}
761+
762+
/// Returns the `extra` names in this environment.
763+
fn extra(&self) -> &[ExtraName] {
764+
match self {
765+
ExtrasEnvironment::Extras(extra) => extra,
766+
ExtrasEnvironment::PEP751(..) => &[],
767+
}
768+
}
769+
770+
/// Returns the `extras` names in this environment, as in a PEP 751 lockfile.
771+
fn extras(&self) -> &[ExtraName] {
772+
match self {
773+
ExtrasEnvironment::Extras(..) => &[],
774+
ExtrasEnvironment::PEP751(extras, ..) => extras,
775+
}
776+
}
777+
778+
/// Returns the `dependency_group` group names in this environment, as in a PEP 751 lockfile.
779+
fn dependency_groups(&self) -> &[GroupName] {
780+
match self {
781+
ExtrasEnvironment::Extras(..) => &[],
782+
ExtrasEnvironment::PEP751(.., groups) => groups,
783+
}
784+
}
785+
}
786+
742787
/// Represents one or more nested marker expressions with and/or/parentheses.
743788
///
744789
/// Marker trees are canonical, meaning any two functionally equivalent markers
@@ -986,7 +1031,27 @@ impl MarkerTree {
9861031

9871032
/// Does this marker apply in the given environment?
9881033
pub fn evaluate(self, env: &MarkerEnvironment, extras: &[ExtraName]) -> bool {
989-
self.evaluate_reporter_impl(env, extras, &mut TracingReporter)
1034+
self.evaluate_reporter_impl(
1035+
env,
1036+
ExtrasEnvironment::from_extras(extras),
1037+
&mut TracingReporter,
1038+
)
1039+
}
1040+
1041+
/// Evaluate a marker in the context of a PEP 751 lockfile, which exposes several additional
1042+
/// markers (`extras` and `dependency_groups`) that are not available in any other context,
1043+
/// per the spec.
1044+
pub fn evaluate_pep751(
1045+
self,
1046+
env: &MarkerEnvironment,
1047+
extras: &[ExtraName],
1048+
groups: &[GroupName],
1049+
) -> bool {
1050+
self.evaluate_reporter_impl(
1051+
env,
1052+
ExtrasEnvironment::from_pep751(extras, groups),
1053+
&mut TracingReporter,
1054+
)
9901055
}
9911056

9921057
/// Evaluates this marker tree against an optional environment and a
@@ -1003,7 +1068,11 @@ impl MarkerTree {
10031068
) -> bool {
10041069
match env {
10051070
None => self.evaluate_extras(extras),
1006-
Some(env) => self.evaluate_reporter_impl(env, extras, &mut TracingReporter),
1071+
Some(env) => self.evaluate_reporter_impl(
1072+
env,
1073+
ExtrasEnvironment::from_extras(extras),
1074+
&mut TracingReporter,
1075+
),
10071076
}
10081077
}
10091078

@@ -1015,13 +1084,13 @@ impl MarkerTree {
10151084
extras: &[ExtraName],
10161085
reporter: &mut impl Reporter,
10171086
) -> bool {
1018-
self.evaluate_reporter_impl(env, extras, reporter)
1087+
self.evaluate_reporter_impl(env, ExtrasEnvironment::from_extras(extras), reporter)
10191088
}
10201089

10211090
fn evaluate_reporter_impl(
10221091
self,
10231092
env: &MarkerEnvironment,
1024-
extras: &[ExtraName],
1093+
extras: ExtrasEnvironment,
10251094
reporter: &mut impl Reporter,
10261095
) -> bool {
10271096
match self.kind() {
@@ -1073,12 +1142,18 @@ impl MarkerTree {
10731142
}
10741143
MarkerTreeKind::Extra(marker) => {
10751144
return marker
1076-
.edge(extras.contains(marker.name().extra()))
1145+
.edge(extras.extra().contains(marker.name().extra()))
1146+
.evaluate_reporter_impl(env, extras, reporter);
1147+
}
1148+
MarkerTreeKind::Extras(marker) => {
1149+
return marker
1150+
.edge(extras.extras().contains(marker.name().extra()))
10771151
.evaluate_reporter_impl(env, extras, reporter);
10781152
}
1079-
// TODO(charlie): Add support for evaluating container extras in PEP 751 lockfiles.
1080-
MarkerTreeKind::Extras(..) | MarkerTreeKind::DependencyGroups(..) => {
1081-
return false;
1153+
MarkerTreeKind::DependencyGroups(marker) => {
1154+
return marker
1155+
.edge(extras.dependency_groups().contains(marker.name().group()))
1156+
.evaluate_reporter_impl(env, extras, reporter);
10821157
}
10831158
}
10841159

crates/uv-requirements/src/sources.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,13 +273,13 @@ impl RequirementsSource {
273273
pub fn allows_extras(&self) -> bool {
274274
matches!(
275275
self,
276-
Self::PyprojectToml(_) | Self::SetupPy(_) | Self::SetupCfg(_)
276+
Self::PylockToml(_) | Self::PyprojectToml(_) | Self::SetupPy(_) | Self::SetupCfg(_)
277277
)
278278
}
279279

280280
/// Returns `true` if the source allows groups to be specified.
281281
pub fn allows_groups(&self) -> bool {
282-
matches!(self, Self::PyprojectToml(_))
282+
matches!(self, Self::PylockToml(_) | Self::PyprojectToml(_))
283283
}
284284
}
285285

crates/uv-requirements/src/specification.rs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,13 @@ impl RequirementsSpecification {
250250

251251
// If we have a `pylock.toml`, don't allow additional requirements, constraints, or
252252
// overrides.
253-
if requirements
254-
.iter()
255-
.any(|source| matches!(source, RequirementsSource::PylockToml(..)))
256-
{
253+
if let Some(pylock_toml) = requirements.iter().find_map(|source| {
254+
if let RequirementsSource::PylockToml(path) = source {
255+
Some(path)
256+
} else {
257+
None
258+
}
259+
}) {
257260
if requirements
258261
.iter()
259262
.any(|source| !matches!(source, RequirementsSource::PylockToml(..)))
@@ -272,22 +275,38 @@ impl RequirementsSpecification {
272275
"Cannot specify constraints with a `pylock.toml` file"
273276
));
274277
}
275-
if groups.is_some_and(|groups| !groups.groups.is_empty()) {
276-
return Err(anyhow::anyhow!(
277-
"Cannot specify groups with a `pylock.toml` file"
278-
));
279-
}
280-
}
281278

282-
// Resolve sources into specifications so we know their `source_tree`.
283-
let mut requirement_sources = Vec::new();
284-
for source in requirements {
285-
let source = Self::from_source(source, client_builder).await?;
286-
requirement_sources.push(source);
287-
}
279+
// If we have a `pylock.toml`, disallow specifying paths for groups; instead, require
280+
// that all groups refer to the `pylock.toml` file.
281+
if let Some(groups) = groups {
282+
let mut names = Vec::new();
283+
for group in &groups.groups {
284+
if group.path.is_some() {
285+
return Err(anyhow::anyhow!(
286+
"Cannot specify paths for groups with a `pylock.toml` file; all groups must refer to the `pylock.toml` file"
287+
));
288+
}
289+
names.push(group.name.clone());
290+
}
288291

289-
// pip `--group` flags specify their own sources, which we need to process here
290-
if let Some(groups) = groups {
292+
if !names.is_empty() {
293+
spec.groups.insert(
294+
pylock_toml.clone(),
295+
DependencyGroups::from_args(
296+
false,
297+
false,
298+
false,
299+
Vec::new(),
300+
Vec::new(),
301+
false,
302+
names,
303+
false,
304+
),
305+
);
306+
}
307+
}
308+
} else if let Some(groups) = groups {
309+
// pip `--group` flags specify their own sources, which we need to process here.
291310
// First, we collect all groups by their path.
292311
let mut groups_by_path = BTreeMap::new();
293312
for group in &groups.groups {
@@ -320,6 +339,13 @@ impl RequirementsSpecification {
320339
spec.groups = group_specs;
321340
}
322341

342+
// Resolve sources into specifications so we know their `source_tree`.
343+
let mut requirement_sources = Vec::new();
344+
for source in requirements {
345+
let source = Self::from_source(source, client_builder).await?;
346+
requirement_sources.push(source);
347+
}
348+
323349
// Read all requirements, and keep track of all requirements _and_ constraints.
324350
// A `requirements.txt` can contain a `-c constraints.txt` directive within it, so reading
325351
// a requirements file can also add constraints.

crates/uv-resolver/src/lock/export/pylock_toml.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,11 @@ pub struct PylockToml {
188188
#[serde(skip_serializing_if = "Option::is_none")]
189189
requires_python: Option<RequiresPython>,
190190
#[serde(skip_serializing_if = "Vec::is_empty", default)]
191-
extras: Vec<ExtraName>,
191+
pub extras: Vec<ExtraName>,
192192
#[serde(skip_serializing_if = "Vec::is_empty", default)]
193-
dependency_groups: Vec<GroupName>,
193+
pub dependency_groups: Vec<GroupName>,
194194
#[serde(skip_serializing_if = "Vec::is_empty", default)]
195-
default_groups: Vec<GroupName>,
195+
pub default_groups: Vec<GroupName>,
196196
#[serde(skip_serializing_if = "Vec::is_empty", default)]
197197
pub packages: Vec<PylockTomlPackage>,
198198
#[serde(skip_serializing_if = "Vec::is_empty", default)]
@@ -966,9 +966,12 @@ impl<'lock> PylockToml {
966966
self,
967967
install_path: &Path,
968968
markers: &MarkerEnvironment,
969+
extras: &[ExtraName],
970+
groups: &[GroupName],
969971
tags: &Tags,
970972
build_options: &BuildOptions,
971973
) -> Result<Resolution, PylockTomlError> {
974+
// Convert the extras and dependency groups specifications to a concrete environment.
972975
let mut graph =
973976
petgraph::graph::DiGraph::with_capacity(self.packages.len(), self.packages.len());
974977

@@ -977,7 +980,7 @@ impl<'lock> PylockToml {
977980

978981
for package in self.packages {
979982
// Omit packages that aren't relevant to the current environment.
980-
if !package.marker.evaluate(markers, &[]) {
983+
if !package.marker.evaluate_pep751(markers, extras, groups) {
981984
continue;
982985
}
983986

0 commit comments

Comments
 (0)