Default to `--workspace` when adding subdirectories (#14529)

If `--workspace` is provided, we add all paths as workspace members.

If `--no-workspace` is provided, we add all paths as direct path
dependencies.

If neither is provided, then we add any paths that are under the
workspace root as workspace members, and the rest as direct path
dependencies.

Closes #14524.
This commit is contained in:
Charlie Marsh 2025-07-10 22:05:49 -04:00 committed by Zanie Blue
parent e4c04af32d
commit c3d7d3899c
5 changed files with 522 additions and 42 deletions

View File

@ -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)]

View File

@ -83,7 +83,7 @@ pub(crate) async fn add(
extras_of_dependency: Vec<ExtraName>,
package: Option<PackageName>,
python: Option<String>,
workspace: bool,
workspace: Option<bool>,
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),

View File

@ -1351,7 +1351,7 @@ pub(crate) struct AddSettings {
pub(crate) package: Option<PackageName>,
pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>,
pub(crate) workspace: bool,
pub(crate) workspace: Option<bool>,
pub(crate) install_mirrors: PythonInstallMirrors,
pub(crate) refresh: Refresh,
pub(crate) indexes: Vec<Index>,
@ -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),

View File

@ -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(())
}

View File

@ -535,7 +535,9 @@ uv add [OPTIONS] <PACKAGES|--requirements <REQUIREMENTS>>
<p>May also be set with the <code>UV_NO_PROGRESS</code> environment variable.</p></dd><dt id="uv-add--no-python-downloads"><a href="#uv-add--no-python-downloads"><code>--no-python-downloads</code></a></dt><dd><p>Disable automatic downloads of Python.</p>
</dd><dt id="uv-add--no-sources"><a href="#uv-add--no-sources"><code>--no-sources</code></a></dt><dd><p>Ignore the <code>tool.uv.sources</code> 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</p>
</dd><dt id="uv-add--no-sync"><a href="#uv-add--no-sync"><code>--no-sync</code></a></dt><dd><p>Avoid syncing the virtual environment</p>
<p>May also be set with the <code>UV_NO_SYNC</code> environment variable.</p></dd><dt id="uv-add--offline"><a href="#uv-add--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p>
<p>May also be set with the <code>UV_NO_SYNC</code> environment variable.</p></dd><dt id="uv-add--no-workspace"><a href="#uv-add--no-workspace"><code>--no-workspace</code></a></dt><dd><p>Don't add the dependency as a workspace member.</p>
<p>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 <code>--no-workspace</code> to add the package as direct path dependency instead.</p>
</dd><dt id="uv-add--offline"><a href="#uv-add--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p>
<p>When disabled, uv will only use locally cached data and locally available files.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-add--optional"><a href="#uv-add--optional"><code>--optional</code></a> <i>optional</i></dt><dd><p>Add the requirements to the package's optional dependencies for the specified extra.</p>
<p>The group may then be activated when installing the project with the <code>--extra</code> flag.</p>
@ -583,7 +585,7 @@ uv add [OPTIONS] <PACKAGES|--requirements <REQUIREMENTS>>
</dd><dt id="uv-add--verbose"><a href="#uv-add--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output.</p>
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
</dd><dt id="uv-add--workspace"><a href="#uv-add--workspace"><code>--workspace</code></a></dt><dd><p>Add the dependency as a workspace member.</p>
<p>When used with a path dependency, the package will be added to the workspace's <code>members</code> list in the root <code>pyproject.toml</code> file.</p>
<p>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 <code>members</code> list in the root <code>pyproject.toml</code> file.</p>
</dd></dl>
## uv remove
@ -1154,10 +1156,10 @@ environment in the project.</p>
<li><code>macos</code>: An alias for <code>aarch64-apple-darwin</code>, the default target for macOS</li>
<li><code>x86_64-pc-windows-msvc</code>: A 64-bit x86 Windows target</li>
<li><code>i686-pc-windows-msvc</code>: A 32-bit x86 Windows target</li>
<li><code>x86_64-unknown-linux-gnu</code>: An x86 Linux target. Equivalent to <code>x86_64-manylinux_2_17</code></li>
<li><code>x86_64-unknown-linux-gnu</code>: An x86 Linux target. Equivalent to <code>x86_64-manylinux_2_28</code></li>
<li><code>aarch64-apple-darwin</code>: An ARM-based macOS target, as seen on Apple Silicon devices</li>
<li><code>x86_64-apple-darwin</code>: An x86 macOS target</li>
<li><code>aarch64-unknown-linux-gnu</code>: An ARM64 Linux target. Equivalent to <code>aarch64-manylinux_2_17</code></li>
<li><code>aarch64-unknown-linux-gnu</code>: An ARM64 Linux target. Equivalent to <code>aarch64-manylinux_2_28</code></li>
<li><code>aarch64-unknown-linux-musl</code>: An ARM64 Linux target</li>
<li><code>x86_64-unknown-linux-musl</code>: An <code>x86_64</code> Linux target</li>
<li><code>x86_64-manylinux2014</code>: An <code>x86_64</code> target for the <code>manylinux2014</code> platform. Equivalent to <code>x86_64-manylinux_2_17</code></li>