diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2f79f957471d..0ab93ca236e7 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2008,6 +2008,10 @@ pub struct SyncArgs { #[command(flatten)] pub refresh: RefreshArgs, + /// Sync a specific package in the workspace. + #[arg(long)] + pub package: Option, + /// The Python interpreter to use to build the run environment. /// /// By default, uv uses the virtual environment in the current working directory or any parent diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 7726cdb2a4d6..64c471fc387c 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use uv_auth::store_credentials_from_url; use uv_cache::Cache; @@ -10,11 +10,12 @@ use uv_dispatch::BuildDispatch; use uv_distribution::DEV_DEPENDENCIES; use uv_fs::CWD; use uv_installer::SitePackages; +use uv_normalize::PackageName; use uv_python::{PythonEnvironment, PythonFetch, PythonPreference, PythonRequest}; use uv_resolver::{FlatIndex, Lock}; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; -use uv_workspace::{DiscoveryOptions, VirtualProject}; +use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace}; use crate::commands::pip::operations::Modifications; use crate::commands::project::lock::do_safe_lock; @@ -28,6 +29,7 @@ use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings}; pub(crate) async fn sync( locked: bool, frozen: bool, + package: Option, extras: ExtrasSpecification, dev: bool, modifications: Modifications, @@ -46,8 +48,17 @@ pub(crate) async fn sync( warn_user_once!("`uv sync` is experimental and may change without warning"); } - // Identify the project - let project = VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await?; + // Identify the project. + let project = if let Some(package) = package { + VirtualProject::Project( + Workspace::discover(&CWD, &DiscoveryOptions::default()) + .await? + .with_current_project(package.clone()) + .with_context(|| format!("Package `{package}` not found in workspace"))?, + ) + } else { + VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await? + }; // Discover or create the virtual environment. let venv = project::get_or_init_environment( diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index e8ef30fe500e..8687d73f830a 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -979,6 +979,7 @@ async fn run_project( commands::sync( args.locked, args.frozen, + args.package, args.extras, args.dev, args.modifications, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 96ccd5850091..762ac215ee69 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -543,6 +543,7 @@ pub(crate) struct SyncSettings { pub(crate) extras: ExtrasSpecification, pub(crate) dev: bool, pub(crate) modifications: Modifications, + pub(crate) package: Option, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, @@ -564,6 +565,7 @@ impl SyncSettings { installer, build, refresh, + package, python, } = args; @@ -582,6 +584,7 @@ impl SyncSettings { ), dev: flag(dev, no_dev).unwrap_or(true), modifications, + package, python, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index 54c3a7a8fa54..55efff527f25 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -205,3 +205,69 @@ fn empty() -> Result<()> { Ok(()) } + +/// Sync an individual package within a workspace. +#[test] +fn package() -> 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", "anyio>3"] + + [tool.uv.sources] + child = { workspace = true } + + [tool.uv.workspace] + members = ["child"] + "#, + )?; + + let src = context.temp_dir.child("src").child("albatross"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + let child = context.temp_dir.child("child"); + fs_err::create_dir_all(&child)?; + + let pyproject_toml = child.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>1"] + "#, + )?; + + let src = child.child("src").child("albatross"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning + warning: `uv.sources` is experimental and may change without warning + Resolved 6 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + + iniconfig==2.0.0 + "###); + + Ok(()) +}