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-downloadsDisable automatic downloads of Python.
--no-sourcesIgnore 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-syncAvoid syncing the virtual environment
-May also be set with the UV_NO_SYNC environment variable.
--offlineDisable network access.
+May also be set with the UV_NO_SYNC environment variable.
--no-workspaceDon'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.
+--offlineDisable 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 optionalAdd 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, -vUse 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)
--workspaceAdd 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