From 70f3ec15c673523a43c3f46fd66fd3858e243a6f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 31 Oct 2025 20:48:10 -0400 Subject: [PATCH] Allow multiple packages in uv sync --- crates/uv-cli/src/lib.rs | 8 +- .../uv/src/commands/project/install_target.rs | 134 +++++++++++++++-- crates/uv/src/commands/project/sync.rs | 80 +++++++--- crates/uv/src/settings.rs | 2 +- crates/uv/tests/it/sync.rs | 137 ++++++++++++++++++ 5 files changed, 326 insertions(+), 35 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c8c4c6637..db91e93a4 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3669,14 +3669,14 @@ pub struct SyncArgs { #[arg(long, conflicts_with = "package")] pub all_packages: bool, - /// Sync for a specific package in the workspace. + /// Sync for specific packages in the workspace. /// /// The workspace's environment (`.venv`) is updated to reflect the subset of dependencies - /// declared by the specified workspace member package. + /// declared by the specified workspace member packages. /// - /// If the workspace member does not exist, uv will exit with an error. + /// If any workspace member does not exist, uv will exit with an error. #[arg(long, conflicts_with = "all_packages")] - pub package: Option, + pub package: Vec, /// Sync the environment for a Python script, rather than the current project. /// diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index dc7f08658..77dec80cb 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -26,6 +26,12 @@ pub(crate) enum InstallTarget<'lock> { name: &'lock PackageName, lock: &'lock Lock, }, + /// Multiple specific projects in a workspace. + Projects { + workspace: &'lock Workspace, + names: &'lock [PackageName], + lock: &'lock Lock, + }, /// An entire workspace. Workspace { workspace: &'lock Workspace, @@ -47,6 +53,7 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> { fn install_path(&self) -> &'lock Path { match self { Self::Project { workspace, .. } => workspace.install_path(), + Self::Projects { workspace, .. } => workspace.install_path(), Self::Workspace { workspace, .. } => workspace.install_path(), Self::NonProjectWorkspace { workspace, .. } => workspace.install_path(), Self::Script { script, .. } => script.path.parent().unwrap(), @@ -56,17 +63,20 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> { fn lock(&self) -> &'lock Lock { match self { Self::Project { lock, .. } => lock, + Self::Projects { lock, .. } => lock, Self::Workspace { lock, .. } => lock, Self::NonProjectWorkspace { lock, .. } => lock, Self::Script { lock, .. } => lock, } } - fn roots(&self) -> impl Iterator { + #[allow(refining_impl_trait)] + fn roots(&self) -> Box + '_> { match self { - Self::Project { name, .. } => Either::Left(Either::Left(std::iter::once(*name))), + Self::Project { name, .. } => Box::new(std::iter::once(*name)), + Self::Projects { names, .. } => Box::new(names.iter()), Self::NonProjectWorkspace { lock, .. } => { - Either::Left(Either::Right(lock.members().iter())) + Box::new(lock.members().iter()) } Self::Workspace { lock, .. } => { // Identify the workspace members. @@ -74,18 +84,19 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> { // The members are encoded directly in the lockfile, unless the workspace contains a // single member at the root, in which case, we identify it by its source. if lock.members().is_empty() { - Either::Right(Either::Left(lock.root().into_iter().map(Package::name))) + Box::new(lock.root().into_iter().map(Package::name)) } else { - Either::Left(Either::Right(lock.members().iter())) + Box::new(lock.members().iter()) } } - Self::Script { .. } => Either::Right(Either::Right(std::iter::empty())), + Self::Script { .. } => Box::new(std::iter::empty()), } } fn project_name(&self) -> Option<&PackageName> { match self { Self::Project { name, .. } => Some(name), + Self::Projects { .. } => None, Self::Workspace { .. } => None, Self::NonProjectWorkspace { .. } => None, Self::Script { .. } => None, @@ -98,6 +109,7 @@ impl<'lock> InstallTarget<'lock> { pub(crate) fn indexes(self) -> impl Iterator { match self { Self::Project { workspace, .. } + | Self::Projects { workspace, .. } | Self::Workspace { workspace, .. } | Self::NonProjectWorkspace { workspace, .. } => { Either::Left(workspace.indexes().iter().chain( @@ -130,6 +142,7 @@ impl<'lock> InstallTarget<'lock> { pub(crate) fn sources(&self) -> impl Iterator { match self { Self::Project { workspace, .. } + | Self::Projects { workspace, .. } | Self::Workspace { workspace, .. } | Self::NonProjectWorkspace { workspace, .. } => { Either::Left(workspace.sources().values().flat_map(Sources::iter).chain( @@ -158,6 +171,7 @@ impl<'lock> InstallTarget<'lock> { ) -> impl Iterator>> { match self { Self::Project { workspace, .. } + | Self::Projects { workspace, .. } | Self::Workspace { workspace, .. } | Self::NonProjectWorkspace { workspace, .. } => { Either::Left( @@ -256,6 +270,7 @@ impl<'lock> InstallTarget<'lock> { } match self { Self::Project { lock, .. } + | Self::Projects { lock, .. } | Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { if !lock.supports_provides_extra() { @@ -281,6 +296,9 @@ impl<'lock> InstallTarget<'lock> { Self::Project { .. } => { Err(ProjectError::MissingExtraProject(extra.clone())) } + Self::Projects { .. } => { + Err(ProjectError::MissingExtraWorkspace(extra.clone())) + } _ => Err(ProjectError::MissingExtraWorkspace(extra.clone())), }; } @@ -341,7 +359,7 @@ impl<'lock> InstallTarget<'lock> { } } } - Self::Project { lock, .. } => { + Self::Project { lock, .. } | Self::Projects { lock, .. } => { let roots = self.roots().collect::>(); let member_packages: Vec<&Package> = lock .packages() @@ -349,7 +367,7 @@ impl<'lock> InstallTarget<'lock> { .filter(|package| roots.contains(package.name())) .collect(); - // Extract the dependency groups defined in the relevant member. + // Extract the dependency groups defined in the relevant member(s). let known_groups = member_packages .iter() .flat_map(|package| package.dependency_groups().keys()) @@ -357,7 +375,11 @@ impl<'lock> InstallTarget<'lock> { for group in groups.explicit_names() { if !known_groups.contains(group) { - return Err(ProjectError::MissingGroupProject(group.clone())); + return match self { + Self::Project { .. } => Err(ProjectError::MissingGroupProject(group.clone())), + Self::Projects { .. } => Err(ProjectError::MissingGroupWorkspace(group.clone())), + _ => unreachable!(), + }; } } } @@ -381,7 +403,7 @@ impl<'lock> InstallTarget<'lock> { ) -> BTreeSet<&PackageName> { match self { Self::Project { name, lock, .. } => { - // Collect the packages by name for efficient lookup + // Collect the packages by name for efficient lookup. let packages = lock .packages() .iter() @@ -463,6 +485,98 @@ impl<'lock> InstallTarget<'lock> { required_members } + Self::Projects { names, lock, .. } => { + // Collect the packages by name for efficient lookup. + let packages = lock + .packages() + .iter() + .map(|p| (p.name(), p)) + .collect::>(); + + // We'll include all specified projects + let mut required_members = BTreeSet::new(); + for name in names.iter() { + required_members.insert(name); + } + + // Find all workspace member dependencies recursively for all specified packages + let mut queue: VecDeque<(&PackageName, Option<&ExtraName>)> = VecDeque::new(); + let mut seen: FxHashSet<(&PackageName, Option<&ExtraName>)> = FxHashSet::default(); + + for name in names.iter() { + let Some(root_package) = packages.get(name) else { + continue; + }; + + if groups.prod() { + // Add the root package + if seen.insert((name, None)) { + queue.push_back((name, None)); + } + + // Add explicitly activated extras for the root package + for extra in extras.extra_names(root_package.optional_dependencies().keys()) { + if seen.insert((name, Some(extra))) { + queue.push_back((name, Some(extra))); + } + } + } + + // Add activated dependency groups for the root package + for (group_name, dependencies) in root_package.resolved_dependency_groups() { + if !groups.contains(group_name) { + continue; + } + for dependency in dependencies { + let dep_name = dependency.package_name(); + if seen.insert((dep_name, None)) { + queue.push_back((dep_name, None)); + } + for extra in dependency.extra() { + if seen.insert((dep_name, Some(extra))) { + queue.push_back((dep_name, Some(extra))); + } + } + } + } + } + + while let Some((pkg_name, extra)) = queue.pop_front() { + if lock.members().contains(pkg_name) { + required_members.insert(pkg_name); + } + + let Some(package) = packages.get(pkg_name) else { + continue; + }; + + let Some(dependencies) = extra + .map(|extra_name| { + package + .optional_dependencies() + .get(extra_name) + .map(Vec::as_slice) + }) + .unwrap_or(Some(package.dependencies())) + else { + continue; + }; + + for dependency in dependencies { + let name = dependency.package_name(); + if seen.insert((name, None)) { + queue.push_back((name, None)); + } + for extra in dependency.extra() { + if seen.insert((name, Some(extra))) { + queue.push_back((name, Some(extra))); + } + } + } + } + + required_members + } Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => { // Return all workspace members lock.members().iter().collect() diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 1225f3757..f2598daf7 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -63,7 +63,7 @@ pub(crate) async fn sync( dry_run: DryRun, active: Option, all_packages: bool, - package: Option, + package: Vec, extras: ExtrasSpecification, groups: DependencyGroups, editable: Option, @@ -109,13 +109,37 @@ pub(crate) async fn sync( &workspace_cache, ) .await? - } else if let Some(package) = package.as_ref() { - VirtualProject::Project( - Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) + } else if !package.is_empty() { + // If a single package is specified, use it as the current project + if package.len() == 1 { + VirtualProject::Project( + Workspace::discover( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) .await? - .with_current_project(package.clone()) - .with_context(|| format!("Package `{package}` not found in workspace"))?, - ) + .with_current_project(package[0].clone()) + .with_context(|| format!("Package `{}` not found in workspace", package[0]))?, + ) + } else { + // Multiple packages specified - discover the workspace and validate all packages exist + let workspace = Workspace::discover( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) + .await?; + + // Validate that all specified packages exist in the workspace + for pkg in &package { + if !workspace.packages().contains_key(pkg) { + anyhow::bail!("Package `{pkg}` not found in workspace"); + } + } + + VirtualProject::NonProject(workspace) + } } else { VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) .await? @@ -379,8 +403,7 @@ pub(crate) async fn sync( } // Identify the installation target. - let sync_target = - identify_installation_target(&target, outcome.lock(), all_packages, package.as_ref()); + let sync_target = identify_installation_target(&target, outcome.lock(), all_packages, &package); let state = state.fork(); @@ -459,7 +482,7 @@ fn identify_installation_target<'a>( target: &'a SyncTarget, lock: &'a Lock, all_packages: bool, - package: Option<&'a PackageName>, + package: &'a [PackageName], ) -> InstallTarget<'a> { match &target { SyncTarget::Project(project) => { @@ -470,11 +493,19 @@ fn identify_installation_target<'a>( workspace: project.workspace(), lock, } - } else if let Some(package) = package { - InstallTarget::Project { - workspace: project.workspace(), - name: package, - lock, + } else if !package.is_empty() { + if package.len() == 1 { + InstallTarget::Project { + workspace: project.workspace(), + name: &package[0], + lock, + } + } else { + InstallTarget::Projects { + workspace: project.workspace(), + names: package, + lock, + } } } else { // By default, install the root package. @@ -488,11 +519,19 @@ fn identify_installation_target<'a>( VirtualProject::NonProject(workspace) => { if all_packages { InstallTarget::NonProjectWorkspace { workspace, lock } - } else if let Some(package) = package { - InstallTarget::Project { - workspace, - name: package, - lock, + } else if !package.is_empty() { + if package.len() == 1 { + InstallTarget::Project { + workspace, + name: &package[0], + lock, + } + } else { + InstallTarget::Projects { + workspace, + names: package, + lock, + } } } else { // By default, install the entire workspace. @@ -613,6 +652,7 @@ pub(super) async fn do_sync( let extra_build_requires = match &target { InstallTarget::Workspace { workspace, .. } | InstallTarget::Project { workspace, .. } + | InstallTarget::Projects { workspace, .. } | InstallTarget::NonProjectWorkspace { workspace, .. } => { LoweredExtraBuildDependencies::from_workspace( extra_build_dependencies.clone(), diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 4f44b36eb..e2a8e7d92 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1311,7 +1311,7 @@ pub(crate) struct SyncSettings { pub(crate) install_options: InstallOptions, pub(crate) modifications: Modifications, pub(crate) all_packages: bool, - pub(crate) package: Option, + pub(crate) package: Vec, pub(crate) python: Option, pub(crate) python_platform: Option, pub(crate) install_mirrors: PythonInstallMirrors, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 3178edc1f..298078f02 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -268,6 +268,143 @@ fn package() -> Result<()> { Ok(()) } +/// Sync multiple packages within a workspace. +#[test] +fn multiple_packages() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "root" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child-a", "child-b", "child-c"] + + [tool.uv.sources] + child-a = { workspace = true } + child-b = { workspace = true } + child-c = { workspace = true } + + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + let src = context.temp_dir.child("src").child("root"); + src.create_dir_all()?; + src.child("__init__.py").touch()?; + + // Create child-a with requests dependency + let child_a = context.temp_dir.child("packages").child("child-a"); + fs_err::create_dir_all(&child_a)?; + child_a.child("pyproject.toml").write_str( + r#" + [project] + name = "child-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["requests"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + let src = child_a.child("src").child("child_a"); + src.create_dir_all()?; + src.child("__init__.py").touch()?; + + // Create child-b with httpx dependency + let child_b = context.temp_dir.child("packages").child("child-b"); + fs_err::create_dir_all(&child_b)?; + child_b.child("pyproject.toml").write_str( + r#" + [project] + name = "child-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + let src = child_b.child("src").child("child_b"); + src.create_dir_all()?; + src.child("__init__.py").touch()?; + + // Create child-c with pytest dependency + let child_c = context.temp_dir.child("packages").child("child-c"); + fs_err::create_dir_all(&child_c)?; + child_c.child("pyproject.toml").write_str( + r#" + [project] + name = "child-c" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pytest"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + let src = child_c.child("src").child("child_c"); + src.create_dir_all()?; + src.child("__init__.py").touch()?; + + // Sync only child-a and child-b + uv_snapshot!(context.filters(), context.sync() + .arg("--package").arg("child-a") + .arg("--package").arg("child-b"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 19 packages in [TIME] + Prepared 12 packages in [TIME] + Installed 12 packages in [TIME] + + anyio==4.3.0 + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + child-a==0.1.0 (from file://[TEMP_DIR]/packages/child-a) + + child-b==0.1.0 (from file://[TEMP_DIR]/packages/child-b) + + h11==0.14.0 + + httpcore==1.0.4 + + httpx==0.27.0 + + idna==3.6 + + requests==2.31.0 + + sniffio==1.3.1 + + urllib3==2.2.1 + "); + + // Now sync all three packages + uv_snapshot!(context.filters(), context.sync() + .arg("--package").arg("child-a") + .arg("--package").arg("child-b") + .arg("--package").arg("child-c"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 19 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + child-c==0.1.0 (from file://[TEMP_DIR]/packages/child-c) + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.1.1 + "); + + Ok(()) +} + /// Test json output #[test] fn sync_json() -> Result<()> {