diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bf605198f..9f02490c6 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3632,7 +3632,8 @@ pub struct AddArgs { long, conflicts_with = "dev", conflicts_with = "optional", - conflicts_with = "package" + conflicts_with = "package", + conflicts_with = "workspace" )] pub script: Option, @@ -3648,6 +3649,13 @@ pub struct AddArgs { value_parser = parse_maybe_string, )] pub python: Option>, + + /// Add the dependency as a workspace member. + /// + /// When used with a path dependency, the package will be added to the workspace's `members` + /// list in the root `pyproject.toml` file. + #[arg(long)] + pub workspace: bool, } #[derive(Args)] diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 04fd7d822..959241b4b 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -83,6 +83,7 @@ pub(crate) async fn add( extras_of_dependency: Vec, package: Option, python: Option, + workspace: bool, install_mirrors: PythonInstallMirrors, settings: ResolverInstallerSettings, network_settings: NetworkSettings, @@ -151,7 +152,7 @@ pub(crate) async fn add( // Default groups we need the actual project for, interpreter discovery will use this! let defaulted_groups; - let target = if let Some(script) = script { + let mut target = if let Some(script) = script { // If we found a PEP 723 script and the user provided a project-only setting, warn. if package.is_some() { warn_user_once!( @@ -478,6 +479,9 @@ pub(crate) async fn add( } } + // Store the content prior to any modifications. + let snapshot = target.snapshot().await?; + // If the user provides a single, named index, pin all requirements to that index. let index = indexes .first() @@ -488,7 +492,72 @@ pub(crate) async fn add( debug!("Pinning all requirements to index: `{index}`"); }); - // Add the requirements to the `pyproject.toml` or script. + // Track modification status, for reverts. + let mut modified = false; + + // If `--workspace` is provided, add any members to the `workspace` section of the + // `pyproject.toml` file. + if workspace { + let AddTarget::Project(project, python_target) = target else { + unreachable!("`--workspace` and `--script` are conflicting options"); + }; + + let workspace = project.workspace(); + let mut toml = PyProjectTomlMut::from_toml( + &workspace.pyproject_toml().raw, + DependencyTarget::PyProjectToml, + )?; + + // Check each requirement to see if it's a path dependency + for requirement in &requirements { + if let RequirementSource::Directory { install_path, .. } = &requirement.source { + let absolute_path = if install_path.is_absolute() { + install_path.to_path_buf() + } else { + project.root().join(install_path) + }; + + // Check if the path is not already included in the workspace. + if !workspace.includes(&absolute_path)? { + let relative_path = absolute_path + .strip_prefix(workspace.install_path()) + .unwrap_or(&absolute_path); + + toml.add_workspace(relative_path)?; + modified |= true; + + writeln!( + printer.stderr(), + "Added `{}` to workspace members", + relative_path.user_display().cyan() + )?; + } + } + } + + // If we modified the workspace root, we need to reload it entirely, since this can impact + // the discovered members, etc. + target = if modified { + let workspace_content = toml.to_string(); + fs_err::write( + workspace.install_path().join("pyproject.toml"), + &workspace_content, + )?; + + AddTarget::Project( + VirtualProject::discover( + project.root(), + &DiscoveryOptions::default(), + &WorkspaceCache::default(), + ) + .await?, + python_target, + ) + } else { + AddTarget::Project(project, python_target) + } + } + let mut toml = match &target { AddTarget::Script(script, _) => { PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script) @@ -498,6 +567,7 @@ pub(crate) async fn add( DependencyTarget::PyProjectToml, ), }?; + let edits = edits( requirements, &target, @@ -543,7 +613,7 @@ pub(crate) async fn add( let content = toml.to_string(); // Save the modified `pyproject.toml` or script. - let modified = target.write(&content)?; + modified |= target.write(&content)?; // If `--frozen`, exit early. There's no reason to lock and sync, since we don't need a `uv.lock` // to exist at all. @@ -563,9 +633,6 @@ pub(crate) async fn add( } } - // Store the content prior to any modifications. - let snapshot = target.snapshot().await?; - // Update the `pypackage.toml` in-memory. let target = target.update(&content)?; @@ -1296,6 +1363,16 @@ impl AddTargetSnapshot { Ok(()) } Self::Project(project, lock) => { + // Write the workspace `pyproject.toml` back to disk. + let workspace = project.workspace(); + if workspace.install_path() != project.root() { + debug!("Reverting changes to workspace `pyproject.toml`"); + fs_err::write( + workspace.install_path().join("pyproject.toml"), + workspace.pyproject_toml().as_ref(), + )?; + } + // Write the `pyproject.toml` back to disk. debug!("Reverting changes to `pyproject.toml`"); fs_err::write( diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ab4aee9e9..e22eb801f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1965,6 +1965,7 @@ async fn run_project( args.extras, args.package, args.python, + args.workspace, args.install_mirrors, args.settings, globals.network_settings, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 004ce5053..673f76ebd 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1326,6 +1326,7 @@ pub(crate) struct AddSettings { pub(crate) package: Option, pub(crate) script: Option, pub(crate) python: Option, + pub(crate) workspace: bool, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, pub(crate) indexes: Vec, @@ -1363,6 +1364,7 @@ impl AddSettings { package, script, python, + workspace, } = args; let dependency_type = if let Some(extra) = optional { @@ -1463,6 +1465,7 @@ impl AddSettings { package, script, python: python.and_then(Maybe::into_option), + workspace, editable: flag(editable, no_editable, "editable"), extras: extra.unwrap_or_default(), refresh: Refresh::from(refresh), diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 2aa5b651b..c1a74541f 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -7210,6 +7210,7 @@ fn remove_include_default_groups() -> Result<()> { Ok(()) } + /// Revert changes to the `pyproject.toml` and `uv.lock` when the `add` operation fails. #[test] fn fail_to_add_revert_project() -> Result<()> { @@ -7401,6 +7402,256 @@ fn fail_to_edit_revert_project() -> Result<()> { Ok(()) } +/// Revert changes to the root `pyproject.toml` and `uv.lock` when the `add` operation fails. +#[test] +fn fail_to_add_revert_workspace_root() -> Result<()> { + let context = TestContext::new("3.12"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Add a dependency on a package that declares static metadata (so can always resolve), but + // can't be installed. + let pyproject_toml = context.temp_dir.child("child/pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + "#})?; + context + .temp_dir + .child("child") + .child("setup.py") + .write_str("1/0")?; + + // Add a dependency on a package that declares static metadata (so can always resolve), but + // can't be installed. + let pyproject_toml = context.temp_dir.child("broken").child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "broken" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + "#})?; + context + .temp_dir + .child("broken") + .child("setup.py") + .write_str("1/0")?; + + uv_snapshot!(context.filters(), context.add().arg("--workspace").arg("./broken"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Added `broken` to workspace members + Resolved 3 packages in [TIME] + × Failed to build `broken @ file://[TEMP_DIR]/broken` + ├─▶ The build backend returned an error + ╰─▶ Call to `setuptools.build_meta.build_editable` failed (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 448, in get_requires_for_build_editable + return self.get_requires_for_build_wheel(config_settings) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 1, in + ZeroDivisionError: division by zero + + hint: This usually indicates a problem with the package or the build environment. + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + "#); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "# + ); + }); + + // The lockfile should not exist, even though resolution succeeded. + assert!(!context.temp_dir.join("uv.lock").exists()); + + Ok(()) +} + +/// Revert changes to the root `pyproject.toml` and `uv.lock` when the `add` operation fails. +#[test] +fn fail_to_add_revert_workspace_member() -> Result<()> { + let context = TestContext::new("3.12"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + "#})?; + + // Add a workspace dependency. + let project = context.temp_dir.child("child"); + project.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#})?; + project + .child("src") + .child("child") + .child("__init__.py") + .touch()?; + + // Add a dependency on a package that declares static metadata (so can always resolve), but + // can't be installed. + let pyproject_toml = context.temp_dir.child("broken/pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "broken" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + "#})?; + context + .temp_dir + .child("broken") + .child("setup.py") + .write_str("1/0")?; + + uv_snapshot!(context.filters(), context.add().current_dir(&project).arg("--workspace").arg("../broken"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Added `broken` to workspace members + Resolved 4 packages in [TIME] + × Failed to build `broken @ file://[TEMP_DIR]/broken` + ├─▶ The build backend returned an error + ╰─▶ Call to `setuptools.build_meta.build_editable` failed (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 448, in get_requires_for_build_editable + return self.get_requires_for_build_wheel(config_settings) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 1, in + ZeroDivisionError: division by zero + + hint: This usually indicates a problem with the package or the build environment. + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + "#); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + "# + ); + }); + + let pyproject_toml = + fs_err::read_to_string(context.temp_dir.join("child").join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "# + ); + }); + + // The lockfile should not exist, even though resolution succeeded. + assert!(!context.temp_dir.join("uv.lock").exists()); + + Ok(()) +} + /// Ensure that the added dependencies are sorted if the dependency list was already sorted prior /// to the operation. #[test] @@ -12629,3 +12880,163 @@ fn add_bounds_requirement_over_bounds_kind() -> Result<()> { Ok(()) } + +/// Add a path dependency with `--workspace` flag to add it to workspace members. The root already +/// contains a workspace definition, so the package should be added to the workspace members. +#[test] +fn add_path_with_existing_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let workspace_toml = context.temp_dir.child("pyproject.toml"); + workspace_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = ["project"] + "#})?; + + // Create a project within the workspace. + let project_dir = context.temp_dir.child("project"); + project_dir.create_dir_all()?; + + let project_toml = project_dir.child("pyproject.toml"); + project_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create a dependency package outside the workspace members. + let dep_dir = context.temp_dir.child("dep"); + dep_dir.create_dir_all()?; + + let dep_toml = dep_dir.child("pyproject.toml"); + dep_toml.write_str(indoc! {r#" + [project] + name = "dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Add the dependency with `--workspace` flag from the project directory. + uv_snapshot!(context.filters(), context + .add() + .current_dir(&project_dir) + .arg("../dep") + .arg("--workspace"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Added `dep` to workspace members + Resolved 3 packages in [TIME] + Audited in [TIME] + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = [ + "project", + "dep", + ] + "# + ); + + let pyproject_toml = context.read("project/pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "dep", + ] + + [tool.uv.sources] + dep = { workspace = true } + "# + ); + + Ok(()) +} + +/// Add a path dependency with `--workspace` flag to add it to workspace members. The root doesn't +/// contain a workspace definition, so `uv add` should create one. +#[test] +fn add_path_with_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let workspace_toml = context.temp_dir.child("pyproject.toml"); + workspace_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + "#})?; + + // Create a dependency package outside the workspace members. + let dep_dir = context.temp_dir.child("dep"); + dep_dir.create_dir_all()?; + + let dep_toml = dep_dir.child("pyproject.toml"); + dep_toml.write_str(indoc! {r#" + [project] + name = "dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Add the dependency with `--workspace` flag from the project directory. + uv_snapshot!(context.filters(), context + .add() + .arg("./dep") + .arg("--workspace"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Added `dep` to workspace members + Resolved 2 packages in [TIME] + Audited in [TIME] + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "dep", + ] + + [tool.uv.workspace] + members = [ + "dep", + ] + + [tool.uv.sources] + dep = { workspace = true } + "# + ); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 82fe0fa3d..17fe6cfce 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -582,6 +582,8 @@ uv add [OPTIONS] >
--upgrade-package, -P upgrade-package

Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package

--verbose, -v

Use verbose output.

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+
--workspace

Add the dependency as a workspace member.

+

When used with a path dependency, the package will be added to the workspace's members list in the root pyproject.toml file.

## uv remove