diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 94b79558d..4c01fd780 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3726,10 +3726,19 @@ pub struct AddArgs { /// 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)] + /// By default, uv will add path dependencies that are within the workspace directory + /// as workspace members. 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, overrides_with = "no_workspace")] pub workspace: bool, + + /// Don't add the dependency as a workspace member. + /// + /// By default, when adding a dependency that's a local path and is within the workspace + /// directory, uv will add it as a workspace member; pass `--no-workspace` to add the package + /// as direct path dependency instead. + #[arg(long, overrides_with = "workspace")] + pub no_workspace: bool, } #[derive(Args)] diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index d65866483..28cc2dcd5 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -83,7 +83,7 @@ pub(crate) async fn add( extras_of_dependency: Vec, package: Option, python: Option, - workspace: bool, + workspace: Option, install_mirrors: PythonInstallMirrors, settings: ResolverInstallerSettings, network_settings: NetworkSettings, @@ -497,16 +497,41 @@ pub(crate) async fn add( // Track modification status, for reverts. let mut modified = false; - // If `--workspace` is provided, add any members to the `workspace` section of the + // Determine whether to use workspace mode. + let use_workspace = match workspace { + Some(workspace) => workspace, + None => { + // Check if we're in a project (not a script), and if any requirements are path + // dependencies within the workspace. + if let AddTarget::Project(ref project, _) = target { + let workspace_root = project.workspace().install_path(); + requirements.iter().any(|req| { + if let RequirementSource::Directory { install_path, .. } = &req.source { + let absolute_path = if install_path.is_absolute() { + install_path.to_path_buf() + } else { + project.root().join(install_path) + }; + absolute_path.starts_with(workspace_root) + } else { + false + } + }) + } else { + false + } + } + }; + + // If workspace mode is enabled, add any members to the `workspace` section of the // `pyproject.toml` file. - if workspace { + if use_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, + &project.workspace().pyproject_toml().raw, DependencyTarget::PyProjectToml, )?; @@ -519,21 +544,32 @@ pub(crate) async fn add( 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() - )?; + // Either `--workspace` was provided explicitly, or it was omitted but the path is + // within the workspace root. + let use_workspace = workspace.unwrap_or_else(|| { + absolute_path.starts_with(project.workspace().install_path()) + }); + if !use_workspace { + continue; } + + // If the project is already a member of the workspace, skip it. + if project.workspace().includes(&absolute_path)? { + continue; + } + + let relative_path = absolute_path + .strip_prefix(project.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() + )?; } } @@ -542,7 +578,7 @@ pub(crate) async fn add( target = if modified { let workspace_content = toml.to_string(); fs_err::write( - workspace.install_path().join("pyproject.toml"), + project.workspace().install_path().join("pyproject.toml"), &workspace_content, )?; @@ -747,13 +783,13 @@ fn edits( .and_then(|tool| tool.uv.as_ref()) .and_then(|uv| uv.sources.as_ref()) .map(ToolUvSources::inner); - let workspace = project + let is_workspace_member = project .workspace() .packages() .contains_key(&requirement.name); resolve_requirement( requirement, - workspace, + is_workspace_member, editable, index.cloned(), rev.map(ToString::to_string), diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b246f228f..bf3bca4a4 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1351,7 +1351,7 @@ pub(crate) struct AddSettings { pub(crate) package: Option, pub(crate) script: Option, pub(crate) python: Option, - pub(crate) workspace: bool, + pub(crate) workspace: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, pub(crate) indexes: Vec, @@ -1390,6 +1390,7 @@ impl AddSettings { script, python, workspace, + no_workspace, } = args; let dependency_type = if let Some(extra) = optional { @@ -1490,7 +1491,7 @@ impl AddSettings { package, script, python: python.and_then(Maybe::into_option), - workspace, + workspace: flag(workspace, no_workspace, "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 ddaed434f..ccc0cabf2 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -2491,9 +2491,9 @@ fn add_workspace_path() -> Result<()> { Ok(()) } -/// Add a path dependency. +/// Add a path dependency, which should be implicitly added to the workspace. #[test] -fn add_path() -> Result<()> { +fn add_path_implicit_workspace() -> Result<()> { let context = TestContext::new("3.12"); let workspace = context.temp_dir.child("workspace"); @@ -2533,6 +2533,7 @@ fn add_path() -> Result<()> { ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Creating virtual environment at: .venv + Added `packages/child` to workspace members Resolved 2 packages in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -2545,7 +2546,134 @@ fn add_path() -> Result<()> { filters => context.filters(), }, { assert_snapshot!( - pyproject_toml, @r###" + pyproject_toml, @r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "child", + ] + + [tool.uv.workspace] + members = [ + "packages/child", + ] + + [tool.uv.sources] + child = { workspace = true } + "# + ); + }); + + // `uv add` implies a full lock and sync, including development dependencies. + let lock = fs_err::read_to_string(workspace.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "child", + "parent", + ] + + [[package]] + name = "child" + version = "0.1.0" + source = { editable = "packages/child" } + + [[package]] + name = "parent" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "child" }, + ] + + [package.metadata] + requires-dist = [{ name = "child", editable = "packages/child" }] + "# + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").current_dir(workspace.path()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + "); + + Ok(()) +} + +/// Add a path dependency with `--no-workspace`, which should not be added to the workspace. +#[test] +fn add_path_no_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let workspace = context.temp_dir.child("workspace"); + workspace.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + let child = workspace.child("packages").child("child"); + child.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#})?; + workspace + .child("packages") + .child("child") + .child("src") + .child("child") + .child("__init__.py") + .touch()?; + + uv_snapshot!(context.filters(), context.add().arg(Path::new("packages").join("child")).current_dir(workspace.path()).arg("--no-workspace"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/workspace/packages/child) + "); + + let pyproject_toml = fs_err::read_to_string(workspace.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" [project] name = "parent" version = "0.1.0" @@ -2556,7 +2684,7 @@ fn add_path() -> Result<()> { [tool.uv.sources] child = { path = "packages/child" } - "### + "# ); }); @@ -2607,6 +2735,110 @@ fn add_path() -> Result<()> { Ok(()) } +/// Add a path dependency in an adjacent directory, which should not be added to the workspace. +#[test] +fn add_path_adjacent_directory() -> Result<()> { + let context = TestContext::new("3.12"); + + let project = context.temp_dir.child("project"); + project.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + let dependency = context.temp_dir.child("dependency"); + dependency.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "dependency" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#})?; + dependency + .child("src") + .child("dependency") + .child("__init__.py") + .touch()?; + + uv_snapshot!(context.filters(), context.add().arg(dependency.path()).current_dir(project.path()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + dependency==0.1.0 (from file://[TEMP_DIR]/dependency) + "); + + let pyproject_toml = fs_err::read_to_string(project.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "dependency", + ] + + [tool.uv.sources] + dependency = { path = "../dependency" } + "# + ); + }); + + // `uv add` implies a full lock and sync, including development dependencies. + let lock = fs_err::read_to_string(project.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "dependency" + version = "0.1.0" + source = { directory = "../dependency" } + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "dependency" }, + ] + + [package.metadata] + requires-dist = [{ name = "dependency", directory = "../dependency" }] + "# + ); + }); + + Ok(()) +} + /// Update a requirement, modifying the source and extras. #[test] #[cfg(feature = "git")] @@ -7249,7 +7481,7 @@ fn fail_to_add_revert_project() -> Result<()> { .child("setup.py") .write_str("1/0")?; - uv_snapshot!(context.filters(), context.add().arg("./child"), @r#" + uv_snapshot!(context.filters(), context.add().arg("./child").arg("--no-workspace"), @r#" success: false exit_code: 1 ----- stdout ----- @@ -7351,7 +7583,7 @@ fn fail_to_edit_revert_project() -> Result<()> { .child("setup.py") .write_str("1/0")?; - uv_snapshot!(context.filters(), context.add().arg("./child"), @r#" + uv_snapshot!(context.filters(), context.add().arg("./child").arg("--no-workspace"), @r#" success: false exit_code: 1 ----- stdout ----- @@ -7460,7 +7692,7 @@ fn fail_to_add_revert_workspace_root() -> Result<()> { .child("setup.py") .write_str("1/0")?; - uv_snapshot!(context.filters(), context.add().arg("--workspace").arg("./broken"), @r#" + uv_snapshot!(context.filters(), context.add().arg("./broken"), @r#" success: false exit_code: 1 ----- stdout ----- @@ -7575,7 +7807,7 @@ fn fail_to_add_revert_workspace_member() -> Result<()> { .child("setup.py") .write_str("1/0")?; - uv_snapshot!(context.filters(), context.add().current_dir(&project).arg("--workspace").arg("../broken"), @r#" + uv_snapshot!(context.filters(), context.add().current_dir(&project).arg("../broken"), @r#" success: false exit_code: 1 ----- stdout ----- @@ -12928,12 +13160,12 @@ fn add_path_with_existing_workspace() -> Result<()> { dependencies = [] "#})?; - // Add the dependency with `--workspace` flag from the project directory. + // Add the dependency from the project directory. It should automatically be added as a + // workspace member, since it's in the same directory as the workspace. uv_snapshot!(context.filters(), context .add() .current_dir(&project_dir) - .arg("../dep") - .arg("--workspace"), @r" + .arg("../dep"), @r" success: true exit_code: 0 ----- stdout ----- @@ -13044,3 +13276,203 @@ fn add_path_with_workspace() -> Result<()> { Ok(()) } + +/// Add a path dependency within the workspace directory without --workspace flag. +/// It should automatically be added as a workspace member. +#[test] +fn add_path_within_workspace_defaults_to_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" + dependencies = [] + + [tool.uv.workspace] + members = [] + "#})?; + + let dep_dir = context.temp_dir.child("dep"); + dep_dir.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Add the dependency without --workspace flag - it should still be added as workspace member + // since it's within the workspace directory. + uv_snapshot!(context.filters(), context + .add() + .arg("./dep"), @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(()) +} + +/// Add a path dependency within the workspace directory with --no-workspace flag. +/// It should be added as a direct path dependency. +#[test] +fn add_path_with_no_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" + dependencies = [] + + [tool.uv.workspace] + members = [] + "#})?; + + let dep_dir = context.temp_dir.child("dep"); + dep_dir.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Add the dependency with --no-workspace flag - it should be added as direct path dependency. + uv_snapshot!(context.filters(), context + .add() + .arg("./dep") + .arg("--no-workspace"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + 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 = [] + + [tool.uv.sources] + dep = { path = "dep" } + "# + ); + + Ok(()) +} + +/// Add a path dependency outside the workspace directory. +/// It should be added as a direct path dependency, not a workspace member. +#[test] +fn add_path_outside_workspace_no_default() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a workspace directory + let workspace_dir = context.temp_dir.child("workspace"); + workspace_dir.create_dir_all()?; + + let workspace_toml = workspace_dir.child("pyproject.toml"); + workspace_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [tool.uv.workspace] + members = [] + "#})?; + + // Create a dependency outside the workspace + let dep_dir = context.temp_dir.child("external_dep"); + dep_dir.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Add the dependency without --workspace flag - it should be a direct path dependency + // since it's outside the workspace directory. + uv_snapshot!(context.filters(), context + .add() + .current_dir(&workspace_dir) + .arg("../external_dep"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Audited in [TIME] + "); + + let pyproject_toml = fs_err::read_to_string(workspace_toml)?; + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "dep", + ] + + [tool.uv.workspace] + members = [] + + [tool.uv.sources] + dep = { path = "../external_dep" } + "# + ); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index aa6213eff..881c96697 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -535,7 +535,9 @@ uv add [OPTIONS] >

May also be set with the UV_NO_PROGRESS environment variable.

--no-python-downloads

Disable automatic downloads of Python.

--no-sources

Ignore the tool.uv.sources table when resolving dependencies. Used to lock against the standards-compliant, publishable package metadata, as opposed to using any workspace, Git, URL, or local path sources

--no-sync

Avoid syncing the virtual environment

-

May also be set with the UV_NO_SYNC environment variable.

--offline

Disable network access.

+

May also be set with the UV_NO_SYNC environment variable.

--no-workspace

Don't add the dependency as a workspace member.

+

By default, when adding a dependency that's a local path and is within the workspace directory, uv will add it as a workspace member; pass --no-workspace to add the package as direct path dependency instead.

+
--offline

Disable network access.

When disabled, uv will only use locally cached data and locally available files.

May also be set with the UV_OFFLINE environment variable.

--optional optional

Add the requirements to the package's optional dependencies for the specified extra.

The group may then be activated when installing the project with the --extra flag.

@@ -583,7 +585,7 @@ uv add [OPTIONS] >
--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.

+

By default, uv will add path dependencies that are within the workspace directory as workspace members. 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 @@ -1154,10 +1156,10 @@ environment in the project.

  • macos: An alias for aarch64-apple-darwin, the default target for macOS
  • x86_64-pc-windows-msvc: A 64-bit x86 Windows target
  • i686-pc-windows-msvc: A 32-bit x86 Windows target
  • -
  • x86_64-unknown-linux-gnu: An x86 Linux target. Equivalent to x86_64-manylinux_2_17
  • +
  • x86_64-unknown-linux-gnu: An x86 Linux target. Equivalent to x86_64-manylinux_2_28
  • aarch64-apple-darwin: An ARM-based macOS target, as seen on Apple Silicon devices
  • x86_64-apple-darwin: An x86 macOS target
  • -
  • aarch64-unknown-linux-gnu: An ARM64 Linux target. Equivalent to aarch64-manylinux_2_17
  • +
  • aarch64-unknown-linux-gnu: An ARM64 Linux target. Equivalent to aarch64-manylinux_2_28
  • aarch64-unknown-linux-musl: An ARM64 Linux target
  • x86_64-unknown-linux-musl: An x86_64 Linux target
  • x86_64-manylinux2014: An x86_64 target for the manylinux2014 platform. Equivalent to x86_64-manylinux_2_17