Add `--workspace` flag to `uv add` (#14496)

## Summary

You can now pass `--workspace` to `uv add` to add a path dependency as a
workspace member.

Closes https://github.com/astral-sh/uv/issues/14464.
This commit is contained in:
Charlie Marsh 2025-07-09 11:46:53 -04:00 committed by GitHub
parent b1dc2b71a3
commit 4d061a6fc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 509 additions and 7 deletions

View File

@ -3632,7 +3632,8 @@ pub struct AddArgs {
long, long,
conflicts_with = "dev", conflicts_with = "dev",
conflicts_with = "optional", conflicts_with = "optional",
conflicts_with = "package" conflicts_with = "package",
conflicts_with = "workspace"
)] )]
pub script: Option<PathBuf>, pub script: Option<PathBuf>,
@ -3648,6 +3649,13 @@ pub struct AddArgs {
value_parser = parse_maybe_string, value_parser = parse_maybe_string,
)] )]
pub python: Option<Maybe<String>>, pub python: Option<Maybe<String>>,
/// 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)] #[derive(Args)]

View File

@ -83,6 +83,7 @@ pub(crate) async fn add(
extras_of_dependency: Vec<ExtraName>, extras_of_dependency: Vec<ExtraName>,
package: Option<PackageName>, package: Option<PackageName>,
python: Option<String>, python: Option<String>,
workspace: bool,
install_mirrors: PythonInstallMirrors, install_mirrors: PythonInstallMirrors,
settings: ResolverInstallerSettings, settings: ResolverInstallerSettings,
network_settings: NetworkSettings, 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! // Default groups we need the actual project for, interpreter discovery will use this!
let defaulted_groups; 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 we found a PEP 723 script and the user provided a project-only setting, warn.
if package.is_some() { if package.is_some() {
warn_user_once!( 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. // If the user provides a single, named index, pin all requirements to that index.
let index = indexes let index = indexes
.first() .first()
@ -488,7 +492,72 @@ pub(crate) async fn add(
debug!("Pinning all requirements to index: `{index}`"); 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 { let mut toml = match &target {
AddTarget::Script(script, _) => { AddTarget::Script(script, _) => {
PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script) PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script)
@ -498,6 +567,7 @@ pub(crate) async fn add(
DependencyTarget::PyProjectToml, DependencyTarget::PyProjectToml,
), ),
}?; }?;
let edits = edits( let edits = edits(
requirements, requirements,
&target, &target,
@ -543,7 +613,7 @@ pub(crate) async fn add(
let content = toml.to_string(); let content = toml.to_string();
// Save the modified `pyproject.toml` or script. // 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` // If `--frozen`, exit early. There's no reason to lock and sync, since we don't need a `uv.lock`
// to exist at all. // 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. // Update the `pypackage.toml` in-memory.
let target = target.update(&content)?; let target = target.update(&content)?;
@ -1296,6 +1363,16 @@ impl AddTargetSnapshot {
Ok(()) Ok(())
} }
Self::Project(project, lock) => { 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. // Write the `pyproject.toml` back to disk.
debug!("Reverting changes to `pyproject.toml`"); debug!("Reverting changes to `pyproject.toml`");
fs_err::write( fs_err::write(

View File

@ -1965,6 +1965,7 @@ async fn run_project(
args.extras, args.extras,
args.package, args.package,
args.python, args.python,
args.workspace,
args.install_mirrors, args.install_mirrors,
args.settings, args.settings,
globals.network_settings, globals.network_settings,

View File

@ -1326,6 +1326,7 @@ pub(crate) struct AddSettings {
pub(crate) package: Option<PackageName>, pub(crate) package: Option<PackageName>,
pub(crate) script: Option<PathBuf>, pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) workspace: bool,
pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) install_mirrors: PythonInstallMirrors,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) indexes: Vec<Index>, pub(crate) indexes: Vec<Index>,
@ -1363,6 +1364,7 @@ impl AddSettings {
package, package,
script, script,
python, python,
workspace,
} = args; } = args;
let dependency_type = if let Some(extra) = optional { let dependency_type = if let Some(extra) = optional {
@ -1463,6 +1465,7 @@ impl AddSettings {
package, package,
script, script,
python: python.and_then(Maybe::into_option), python: python.and_then(Maybe::into_option),
workspace,
editable: flag(editable, no_editable, "editable"), editable: flag(editable, no_editable, "editable"),
extras: extra.unwrap_or_default(), extras: extra.unwrap_or_default(),
refresh: Refresh::from(refresh), refresh: Refresh::from(refresh),

View File

@ -7210,6 +7210,7 @@ fn remove_include_default_groups() -> Result<()> {
Ok(()) Ok(())
} }
/// Revert changes to the `pyproject.toml` and `uv.lock` when the `add` operation fails. /// Revert changes to the `pyproject.toml` and `uv.lock` when the `add` operation fails.
#[test] #[test]
fn fail_to_add_revert_project() -> Result<()> { fn fail_to_add_revert_project() -> Result<()> {
@ -7401,6 +7402,256 @@ fn fail_to_edit_revert_project() -> Result<()> {
Ok(()) 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 "<string>", line 14, in <module>
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 "<string>", line 1, in <module>
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 "<string>", line 14, in <module>
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 "<string>", line 1, in <module>
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 /// Ensure that the added dependencies are sorted if the dependency list was already sorted prior
/// to the operation. /// to the operation.
#[test] #[test]
@ -12629,3 +12880,163 @@ fn add_bounds_requirement_over_bounds_kind() -> Result<()> {
Ok(()) 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(())
}

View File

@ -582,6 +582,8 @@ uv add [OPTIONS] <PACKAGES|--requirements <REQUIREMENTS>>
</dd><dt id="uv-add--upgrade-package"><a href="#uv-add--upgrade-package"><code>--upgrade-package</code></a>, <code>-P</code> <i>upgrade-package</i></dt><dd><p>Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies <code>--refresh-package</code></p> </dd><dt id="uv-add--upgrade-package"><a href="#uv-add--upgrade-package"><code>--upgrade-package</code></a>, <code>-P</code> <i>upgrade-package</i></dt><dd><p>Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies <code>--refresh-package</code></p>
</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> </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> <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>
</dd></dl> </dd></dl>
## uv remove ## uv remove