diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 9f02490c6a6c5..a7ab5102c9d52 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3633,7 +3633,8 @@ pub struct AddArgs { conflicts_with = "dev", conflicts_with = "optional", conflicts_with = "package", - conflicts_with = "workspace" + conflicts_with = "workspace", + conflicts_with = "no_workspace" )] pub script: Option, @@ -3654,8 +3655,15 @@ pub struct AddArgs { /// /// 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)] + #[arg(long, conflicts_with = "no_workspace")] pub workspace: bool, + + /// Prevent adding the dependency as a workspace member. + /// + /// By default, uv will add path dependencies within the same source tree as workspace members. + /// Use this flag to add the dependency as a regular path dependency instead. + #[arg(long, conflicts_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 959241b4b0a69..9ddf775e74bf9 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -84,6 +84,7 @@ pub(crate) async fn add( package: Option, python: Option, workspace: bool, + no_workspace: bool, install_mirrors: PythonInstallMirrors, settings: ResolverInstallerSettings, network_settings: NetworkSettings, @@ -495,16 +496,28 @@ 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 - // `pyproject.toml` file. - if workspace { + // Determine if we should add path dependencies as workspace members. + // This is the default behavior for path dependencies within the same source tree, + // unless explicitly disabled with `--no-workspace`. + let should_add_workspace_members = if workspace { + true + } else if no_workspace { + false + } else { + // Default: add as workspace members if the path is within the workspace root + true + }; + + // If we should add workspace members, add any path dependencies to the `workspace` section + // of the `pyproject.toml` file. + if should_add_workspace_members { let AddTarget::Project(project, python_target) = target else { unreachable!("`--workspace` and `--script` are conflicting options"); }; - let workspace = project.workspace(); + let workspace_obj = project.workspace(); let mut toml = PyProjectTomlMut::from_toml( - &workspace.pyproject_toml().raw, + &workspace_obj.pyproject_toml().raw, DependencyTarget::PyProjectToml, )?; @@ -517,20 +530,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() - )?; + // Check if the path is within the workspace root (same source tree) + let is_within_workspace = absolute_path + .strip_prefix(workspace_obj.install_path()) + .is_ok(); + + // Only add to workspace if: + // 1. The path is within the workspace root (same source tree), OR + // 2. --workspace was explicitly provided + let should_add_to_workspace = is_within_workspace || workspace; + + if should_add_to_workspace { + // Check if the path is not already included in the workspace. + if !workspace_obj.includes(&absolute_path)? { + let relative_path = absolute_path + .strip_prefix(workspace_obj.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() + )?; + } } } } @@ -540,7 +565,7 @@ pub(crate) async fn add( target = if modified { let workspace_content = toml.to_string(); fs_err::write( - workspace.install_path().join("pyproject.toml"), + workspace_obj.install_path().join("pyproject.toml"), &workspace_content, )?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index e22eb801f66bd..c49bbac542ab7 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1966,6 +1966,7 @@ async fn run_project( args.package, args.python, args.workspace, + args.no_workspace, args.install_mirrors, args.settings, globals.network_settings, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 673f76ebd5374..e46f7a3418e8f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1327,6 +1327,7 @@ pub(crate) struct AddSettings { pub(crate) script: Option, pub(crate) python: Option, pub(crate) workspace: bool, + pub(crate) no_workspace: bool, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, pub(crate) indexes: Vec, @@ -1365,6 +1366,7 @@ impl AddSettings { script, python, workspace, + no_workspace, } = args; let dependency_type = if let Some(extra) = optional { @@ -1466,6 +1468,7 @@ impl AddSettings { script, python: python.and_then(Maybe::into_option), workspace, + no_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 c1a74541fc865..68501e7583409 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13040,3 +13040,128 @@ fn add_path_with_workspace() -> Result<()> { Ok(()) } + +/// Add a path dependency within the same source tree - should default to workspace member +#[test] +fn add_path_default_workspace_same_source_tree() -> 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 = [] + "#})?; + + // Create a dependency package within the workspace (same source tree) + 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 without any flags - should default to workspace member + 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 same source tree with --no-workspace - should not add to workspace +#[test] +fn add_path_no_workspace_same_source_tree() -> 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 = [] + "#})?; + + // Create a dependency package within the workspace (same source tree) + 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 --no-workspace flag - should not add to workspace + 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.sources] + dep = { path = "dep" } + "# + ); + + Ok(()) +}