Skip to content

Commit 8bc456c

Browse files
committed
Allow multiple packages in uv sync
1 parent 82aa0d0 commit 8bc456c

File tree

5 files changed

+326
-35
lines changed

5 files changed

+326
-35
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3657,14 +3657,14 @@ pub struct SyncArgs {
36573657
#[arg(long, conflicts_with = "package")]
36583658
pub all_packages: bool,
36593659

3660-
/// Sync for a specific package in the workspace.
3660+
/// Sync for specific packages in the workspace.
36613661
///
36623662
/// The workspace's environment (`.venv`) is updated to reflect the subset of dependencies
3663-
/// declared by the specified workspace member package.
3663+
/// declared by the specified workspace member packages.
36643664
///
3665-
/// If the workspace member does not exist, uv will exit with an error.
3665+
/// If any workspace member does not exist, uv will exit with an error.
36663666
#[arg(long, conflicts_with = "all_packages")]
3667-
pub package: Option<PackageName>,
3667+
pub package: Vec<PackageName>,
36683668

36693669
/// Sync the environment for a Python script, rather than the current project.
36703670
///

crates/uv/src/commands/project/install_target.rs

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ pub(crate) enum InstallTarget<'lock> {
2626
name: &'lock PackageName,
2727
lock: &'lock Lock,
2828
},
29+
/// Multiple specific projects in a workspace.
30+
Projects {
31+
workspace: &'lock Workspace,
32+
names: &'lock [PackageName],
33+
lock: &'lock Lock,
34+
},
2935
/// An entire workspace.
3036
Workspace {
3137
workspace: &'lock Workspace,
@@ -47,6 +53,7 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> {
4753
fn install_path(&self) -> &'lock Path {
4854
match self {
4955
Self::Project { workspace, .. } => workspace.install_path(),
56+
Self::Projects { workspace, .. } => workspace.install_path(),
5057
Self::Workspace { workspace, .. } => workspace.install_path(),
5158
Self::NonProjectWorkspace { workspace, .. } => workspace.install_path(),
5259
Self::Script { script, .. } => script.path.parent().unwrap(),
@@ -56,36 +63,40 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> {
5663
fn lock(&self) -> &'lock Lock {
5764
match self {
5865
Self::Project { lock, .. } => lock,
66+
Self::Projects { lock, .. } => lock,
5967
Self::Workspace { lock, .. } => lock,
6068
Self::NonProjectWorkspace { lock, .. } => lock,
6169
Self::Script { lock, .. } => lock,
6270
}
6371
}
6472

65-
fn roots(&self) -> impl Iterator<Item = &PackageName> {
73+
#[allow(refining_impl_trait)]
74+
fn roots(&self) -> Box<dyn Iterator<Item = &PackageName> + '_> {
6675
match self {
67-
Self::Project { name, .. } => Either::Left(Either::Left(std::iter::once(*name))),
76+
Self::Project { name, .. } => Box::new(std::iter::once(*name)),
77+
Self::Projects { names, .. } => Box::new(names.iter()),
6878
Self::NonProjectWorkspace { lock, .. } => {
69-
Either::Left(Either::Right(lock.members().iter()))
79+
Box::new(lock.members().iter())
7080
}
7181
Self::Workspace { lock, .. } => {
7282
// Identify the workspace members.
7383
//
7484
// The members are encoded directly in the lockfile, unless the workspace contains a
7585
// single member at the root, in which case, we identify it by its source.
7686
if lock.members().is_empty() {
77-
Either::Right(Either::Left(lock.root().into_iter().map(Package::name)))
87+
Box::new(lock.root().into_iter().map(Package::name))
7888
} else {
79-
Either::Left(Either::Right(lock.members().iter()))
89+
Box::new(lock.members().iter())
8090
}
8191
}
82-
Self::Script { .. } => Either::Right(Either::Right(std::iter::empty())),
92+
Self::Script { .. } => Box::new(std::iter::empty()),
8393
}
8494
}
8595

8696
fn project_name(&self) -> Option<&PackageName> {
8797
match self {
8898
Self::Project { name, .. } => Some(name),
99+
Self::Projects { .. } => None,
89100
Self::Workspace { .. } => None,
90101
Self::NonProjectWorkspace { .. } => None,
91102
Self::Script { .. } => None,
@@ -98,6 +109,7 @@ impl<'lock> InstallTarget<'lock> {
98109
pub(crate) fn indexes(self) -> impl Iterator<Item = &'lock Index> {
99110
match self {
100111
Self::Project { workspace, .. }
112+
| Self::Projects { workspace, .. }
101113
| Self::Workspace { workspace, .. }
102114
| Self::NonProjectWorkspace { workspace, .. } => {
103115
Either::Left(workspace.indexes().iter().chain(
@@ -130,6 +142,7 @@ impl<'lock> InstallTarget<'lock> {
130142
pub(crate) fn sources(&self) -> impl Iterator<Item = &Source> {
131143
match self {
132144
Self::Project { workspace, .. }
145+
| Self::Projects { workspace, .. }
133146
| Self::Workspace { workspace, .. }
134147
| Self::NonProjectWorkspace { workspace, .. } => {
135148
Either::Left(workspace.sources().values().flat_map(Sources::iter).chain(
@@ -158,6 +171,7 @@ impl<'lock> InstallTarget<'lock> {
158171
) -> impl Iterator<Item = Cow<'lock, uv_pep508::Requirement<VerbatimParsedUrl>>> {
159172
match self {
160173
Self::Project { workspace, .. }
174+
| Self::Projects { workspace, .. }
161175
| Self::Workspace { workspace, .. }
162176
| Self::NonProjectWorkspace { workspace, .. } => {
163177
Either::Left(
@@ -256,6 +270,7 @@ impl<'lock> InstallTarget<'lock> {
256270
}
257271
match self {
258272
Self::Project { lock, .. }
273+
| Self::Projects { lock, .. }
259274
| Self::Workspace { lock, .. }
260275
| Self::NonProjectWorkspace { lock, .. } => {
261276
if !lock.supports_provides_extra() {
@@ -281,6 +296,9 @@ impl<'lock> InstallTarget<'lock> {
281296
Self::Project { .. } => {
282297
Err(ProjectError::MissingExtraProject(extra.clone()))
283298
}
299+
Self::Projects { .. } => {
300+
Err(ProjectError::MissingExtraWorkspace(extra.clone()))
301+
}
284302
_ => Err(ProjectError::MissingExtraWorkspace(extra.clone())),
285303
};
286304
}
@@ -341,23 +359,27 @@ impl<'lock> InstallTarget<'lock> {
341359
}
342360
}
343361
}
344-
Self::Project { lock, .. } => {
362+
Self::Project { lock, .. } | Self::Projects { lock, .. } => {
345363
let roots = self.roots().collect::<FxHashSet<_>>();
346364
let member_packages: Vec<&Package> = lock
347365
.packages()
348366
.iter()
349367
.filter(|package| roots.contains(package.name()))
350368
.collect();
351369

352-
// Extract the dependency groups defined in the relevant member.
370+
// Extract the dependency groups defined in the relevant member(s).
353371
let known_groups = member_packages
354372
.iter()
355373
.flat_map(|package| package.dependency_groups().keys())
356374
.collect::<FxHashSet<_>>();
357375

358376
for group in groups.explicit_names() {
359377
if !known_groups.contains(group) {
360-
return Err(ProjectError::MissingGroupProject(group.clone()));
378+
return match self {
379+
Self::Project { .. } => Err(ProjectError::MissingGroupProject(group.clone())),
380+
Self::Projects { .. } => Err(ProjectError::MissingGroupWorkspace(group.clone())),
381+
_ => unreachable!(),
382+
};
361383
}
362384
}
363385
}
@@ -381,7 +403,7 @@ impl<'lock> InstallTarget<'lock> {
381403
) -> BTreeSet<&PackageName> {
382404
match self {
383405
Self::Project { name, lock, .. } => {
384-
// Collect the packages by name for efficient lookup
406+
// Collect the packages by name for efficient lookup.
385407
let packages = lock
386408
.packages()
387409
.iter()
@@ -463,6 +485,98 @@ impl<'lock> InstallTarget<'lock> {
463485

464486
required_members
465487
}
488+
Self::Projects { names, lock, .. } => {
489+
// Collect the packages by name for efficient lookup.
490+
let packages = lock
491+
.packages()
492+
.iter()
493+
.map(|p| (p.name(), p))
494+
.collect::<BTreeMap<_, _>>();
495+
496+
// We'll include all specified projects
497+
let mut required_members = BTreeSet::new();
498+
for name in names.iter() {
499+
required_members.insert(name);
500+
}
501+
502+
// Find all workspace member dependencies recursively for all specified packages
503+
let mut queue: VecDeque<(&PackageName, Option<&ExtraName>)> = VecDeque::new();
504+
let mut seen: FxHashSet<(&PackageName, Option<&ExtraName>)> = FxHashSet::default();
505+
506+
for name in names.iter() {
507+
let Some(root_package) = packages.get(name) else {
508+
continue;
509+
};
510+
511+
if groups.prod() {
512+
// Add the root package
513+
if seen.insert((name, None)) {
514+
queue.push_back((name, None));
515+
}
516+
517+
// Add explicitly activated extras for the root package
518+
for extra in extras.extra_names(root_package.optional_dependencies().keys()) {
519+
if seen.insert((name, Some(extra))) {
520+
queue.push_back((name, Some(extra)));
521+
}
522+
}
523+
}
524+
525+
// Add activated dependency groups for the root package
526+
for (group_name, dependencies) in root_package.resolved_dependency_groups() {
527+
if !groups.contains(group_name) {
528+
continue;
529+
}
530+
for dependency in dependencies {
531+
let dep_name = dependency.package_name();
532+
if seen.insert((dep_name, None)) {
533+
queue.push_back((dep_name, None));
534+
}
535+
for extra in dependency.extra() {
536+
if seen.insert((dep_name, Some(extra))) {
537+
queue.push_back((dep_name, Some(extra)));
538+
}
539+
}
540+
}
541+
}
542+
}
543+
544+
while let Some((pkg_name, extra)) = queue.pop_front() {
545+
if lock.members().contains(pkg_name) {
546+
required_members.insert(pkg_name);
547+
}
548+
549+
let Some(package) = packages.get(pkg_name) else {
550+
continue;
551+
};
552+
553+
let Some(dependencies) = extra
554+
.map(|extra_name| {
555+
package
556+
.optional_dependencies()
557+
.get(extra_name)
558+
.map(Vec::as_slice)
559+
})
560+
.unwrap_or(Some(package.dependencies()))
561+
else {
562+
continue;
563+
};
564+
565+
for dependency in dependencies {
566+
let name = dependency.package_name();
567+
if seen.insert((name, None)) {
568+
queue.push_back((name, None));
569+
}
570+
for extra in dependency.extra() {
571+
if seen.insert((name, Some(extra))) {
572+
queue.push_back((name, Some(extra)));
573+
}
574+
}
575+
}
576+
}
577+
578+
required_members
579+
}
466580
Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => {
467581
// Return all workspace members
468582
lock.members().iter().collect()

crates/uv/src/commands/project/sync.rs

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ pub(crate) async fn sync(
6363
dry_run: DryRun,
6464
active: Option<bool>,
6565
all_packages: bool,
66-
package: Option<PackageName>,
66+
package: Vec<PackageName>,
6767
extras: ExtrasSpecification,
6868
groups: DependencyGroups,
6969
editable: Option<EditableMode>,
@@ -109,13 +109,37 @@ pub(crate) async fn sync(
109109
&workspace_cache,
110110
)
111111
.await?
112-
} else if let Some(package) = package.as_ref() {
113-
VirtualProject::Project(
114-
Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache)
112+
} else if !package.is_empty() {
113+
// If a single package is specified, use it as the current project
114+
if package.len() == 1 {
115+
VirtualProject::Project(
116+
Workspace::discover(
117+
project_dir,
118+
&DiscoveryOptions::default(),
119+
&workspace_cache,
120+
)
115121
.await?
116-
.with_current_project(package.clone())
117-
.with_context(|| format!("Package `{package}` not found in workspace"))?,
118-
)
122+
.with_current_project(package[0].clone())
123+
.with_context(|| format!("Package `{}` not found in workspace", package[0]))?,
124+
)
125+
} else {
126+
// Multiple packages specified - discover the workspace and validate all packages exist
127+
let workspace = Workspace::discover(
128+
project_dir,
129+
&DiscoveryOptions::default(),
130+
&workspace_cache,
131+
)
132+
.await?;
133+
134+
// Validate that all specified packages exist in the workspace
135+
for pkg in &package {
136+
if !workspace.packages().contains_key(pkg) {
137+
anyhow::bail!("Package `{pkg}` not found in workspace");
138+
}
139+
}
140+
141+
VirtualProject::NonProject(workspace)
142+
}
119143
} else {
120144
VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache)
121145
.await?
@@ -379,8 +403,7 @@ pub(crate) async fn sync(
379403
}
380404

381405
// Identify the installation target.
382-
let sync_target =
383-
identify_installation_target(&target, outcome.lock(), all_packages, package.as_ref());
406+
let sync_target = identify_installation_target(&target, outcome.lock(), all_packages, &package);
384407

385408
let state = state.fork();
386409

@@ -459,7 +482,7 @@ fn identify_installation_target<'a>(
459482
target: &'a SyncTarget,
460483
lock: &'a Lock,
461484
all_packages: bool,
462-
package: Option<&'a PackageName>,
485+
package: &'a [PackageName],
463486
) -> InstallTarget<'a> {
464487
match &target {
465488
SyncTarget::Project(project) => {
@@ -470,11 +493,19 @@ fn identify_installation_target<'a>(
470493
workspace: project.workspace(),
471494
lock,
472495
}
473-
} else if let Some(package) = package {
474-
InstallTarget::Project {
475-
workspace: project.workspace(),
476-
name: package,
477-
lock,
496+
} else if !package.is_empty() {
497+
if package.len() == 1 {
498+
InstallTarget::Project {
499+
workspace: project.workspace(),
500+
name: &package[0],
501+
lock,
502+
}
503+
} else {
504+
InstallTarget::Projects {
505+
workspace: project.workspace(),
506+
names: package,
507+
lock,
508+
}
478509
}
479510
} else {
480511
// By default, install the root package.
@@ -488,11 +519,19 @@ fn identify_installation_target<'a>(
488519
VirtualProject::NonProject(workspace) => {
489520
if all_packages {
490521
InstallTarget::NonProjectWorkspace { workspace, lock }
491-
} else if let Some(package) = package {
492-
InstallTarget::Project {
493-
workspace,
494-
name: package,
495-
lock,
522+
} else if !package.is_empty() {
523+
if package.len() == 1 {
524+
InstallTarget::Project {
525+
workspace,
526+
name: &package[0],
527+
lock,
528+
}
529+
} else {
530+
InstallTarget::Projects {
531+
workspace,
532+
names: package,
533+
lock,
534+
}
496535
}
497536
} else {
498537
// By default, install the entire workspace.
@@ -613,6 +652,7 @@ pub(super) async fn do_sync(
613652
let extra_build_requires = match &target {
614653
InstallTarget::Workspace { workspace, .. }
615654
| InstallTarget::Project { workspace, .. }
655+
| InstallTarget::Projects { workspace, .. }
616656
| InstallTarget::NonProjectWorkspace { workspace, .. } => {
617657
LoweredExtraBuildDependencies::from_workspace(
618658
extra_build_dependencies.clone(),

0 commit comments

Comments
 (0)