Skip to content

Commit 0232b15

Browse files
committed
Add --workspace flag to uv add
1 parent 1d20530 commit 0232b15

6 files changed

Lines changed: 242 additions & 3 deletions

File tree

crates/uv-cli/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3632,7 +3632,8 @@ pub struct AddArgs {
36323632
long,
36333633
conflicts_with = "dev",
36343634
conflicts_with = "optional",
3635-
conflicts_with = "package"
3635+
conflicts_with = "package",
3636+
conflicts_with = "workspace"
36363637
)]
36373638
pub script: Option<PathBuf>,
36383639

@@ -3648,6 +3649,13 @@ pub struct AddArgs {
36483649
value_parser = parse_maybe_string,
36493650
)]
36503651
pub python: Option<Maybe<String>>,
3652+
3653+
/// Add the dependency as a workspace member.
3654+
///
3655+
/// When used with a path dependency, the package will be added to the workspace's `members`
3656+
/// list in the root `pyproject.toml` file.
3657+
#[arg(long)]
3658+
pub workspace: bool,
36513659
}
36523660

36533661
#[derive(Args)]

crates/uv/src/commands/project/add.rs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ pub(crate) async fn add(
8383
extras_of_dependency: Vec<ExtraName>,
8484
package: Option<PackageName>,
8585
python: Option<String>,
86+
workspace: bool,
8687
install_mirrors: PythonInstallMirrors,
8788
settings: ResolverInstallerSettings,
8889
network_settings: NetworkSettings,
@@ -151,7 +152,7 @@ pub(crate) async fn add(
151152
// Default groups we need the actual project for, interpreter discovery will use this!
152153
let defaulted_groups;
153154

154-
let target = if let Some(script) = script {
155+
let mut target = if let Some(script) = script {
155156
// If we found a PEP 723 script and the user provided a project-only setting, warn.
156157
if package.is_some() {
157158
warn_user_once!(
@@ -488,7 +489,69 @@ pub(crate) async fn add(
488489
debug!("Pinning all requirements to index: `{index}`");
489490
});
490491

491-
// Add the requirements to the `pyproject.toml` or script.
492+
// If `--workspace` is provided, add any members to the `workspace` section of the
493+
// `pyproject.toml` file.
494+
if workspace {
495+
let AddTarget::Project(project, python_target) = target else {
496+
unreachable!("`--workspace` and `--script` are conflicting options");
497+
};
498+
499+
let workspace = project.workspace();
500+
let mut toml = PyProjectTomlMut::from_toml(
501+
&workspace.pyproject_toml().raw,
502+
DependencyTarget::PyProjectToml,
503+
)?;
504+
let mut modified = false;
505+
506+
// Check each requirement to see if it's a path dependency
507+
for requirement in &requirements {
508+
if let RequirementSource::Directory { install_path, .. } = &requirement.source {
509+
let absolute_path = if install_path.is_absolute() {
510+
install_path.to_path_buf()
511+
} else {
512+
project.root().join(install_path)
513+
};
514+
515+
// Check if the path is not already included in the workspace.
516+
if !workspace.includes(&absolute_path)? {
517+
let relative_path = absolute_path
518+
.strip_prefix(workspace.install_path())
519+
.unwrap_or(&absolute_path);
520+
521+
toml.add_workspace(relative_path)?;
522+
modified = true;
523+
524+
writeln!(
525+
printer.stderr(),
526+
"Added `{}` to workspace members",
527+
relative_path.user_display().cyan()
528+
)?;
529+
}
530+
}
531+
}
532+
533+
// If we modified the workspace, reload it.
534+
target = if modified {
535+
let workspace_content = toml.to_string();
536+
fs_err::write(
537+
workspace.install_path().join("pyproject.toml"),
538+
&workspace_content,
539+
)?;
540+
541+
AddTarget::Project(
542+
VirtualProject::discover(
543+
project.root(),
544+
&DiscoveryOptions::default(),
545+
&WorkspaceCache::default(),
546+
)
547+
.await?,
548+
python_target,
549+
)
550+
} else {
551+
AddTarget::Project(project, python_target)
552+
}
553+
}
554+
492555
let mut toml = match &target {
493556
AddTarget::Script(script, _) => {
494557
PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script)
@@ -498,6 +561,7 @@ pub(crate) async fn add(
498561
DependencyTarget::PyProjectToml,
499562
),
500563
}?;
564+
501565
let edits = edits(
502566
requirements,
503567
&target,

crates/uv/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1965,6 +1965,7 @@ async fn run_project(
19651965
args.extras,
19661966
args.package,
19671967
args.python,
1968+
args.workspace,
19681969
args.install_mirrors,
19691970
args.settings,
19701971
globals.network_settings,

crates/uv/src/settings.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,7 @@ pub(crate) struct AddSettings {
13261326
pub(crate) package: Option<PackageName>,
13271327
pub(crate) script: Option<PathBuf>,
13281328
pub(crate) python: Option<String>,
1329+
pub(crate) workspace: bool,
13291330
pub(crate) install_mirrors: PythonInstallMirrors,
13301331
pub(crate) refresh: Refresh,
13311332
pub(crate) indexes: Vec<Index>,
@@ -1363,6 +1364,7 @@ impl AddSettings {
13631364
package,
13641365
script,
13651366
python,
1367+
workspace,
13661368
} = args;
13671369

13681370
let dependency_type = if let Some(extra) = optional {
@@ -1463,6 +1465,7 @@ impl AddSettings {
14631465
package,
14641466
script,
14651467
python: python.and_then(Maybe::into_option),
1468+
workspace,
14661469
editable: flag(editable, no_editable, "editable"),
14671470
extras: extra.unwrap_or_default(),
14681471
refresh: Refresh::from(refresh),

crates/uv/tests/it/edit.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12629,3 +12629,164 @@ fn add_bounds_requirement_over_bounds_kind() -> Result<()> {
1262912629

1263012630
Ok(())
1263112631
}
12632+
12633+
/// Add a path dependency with `--workspace` flag to add it to workspace members. The root already
12634+
/// contains a workspace definition, so the package should be added to the workspace members.
12635+
#[test]
12636+
fn add_path_with_existing_workspace() -> Result<()> {
12637+
let context = TestContext::new("3.12");
12638+
12639+
let workspace_toml = context.temp_dir.child("pyproject.toml");
12640+
workspace_toml.write_str(indoc! {r#"
12641+
[project]
12642+
name = "parent"
12643+
version = "0.1.0"
12644+
requires-python = ">=3.12"
12645+
12646+
[tool.uv.workspace]
12647+
members = ["project"]
12648+
"#})?;
12649+
12650+
// Create a project within the workspace.
12651+
let project_dir = context.temp_dir.child("project");
12652+
project_dir.create_dir_all()?;
12653+
12654+
let project_toml = project_dir.child("pyproject.toml");
12655+
project_toml.write_str(indoc! {r#"
12656+
[project]
12657+
name = "project"
12658+
version = "0.1.0"
12659+
requires-python = ">=3.12"
12660+
dependencies = []
12661+
"#})?;
12662+
12663+
// Create a dependency package outside the workspace members.
12664+
let dep_dir = context.temp_dir.child("dep");
12665+
dep_dir.create_dir_all()?;
12666+
12667+
let dep_toml = dep_dir.child("pyproject.toml");
12668+
dep_toml.write_str(indoc! {r#"
12669+
[project]
12670+
name = "dep"
12671+
version = "0.1.0"
12672+
requires-python = ">=3.12"
12673+
dependencies = []
12674+
"#})?;
12675+
12676+
// Add the dependency with `--workspace` flag from the project directory.
12677+
uv_snapshot!(context.filters(), context
12678+
.add()
12679+
.current_dir(&project_dir)
12680+
.arg("../dep")
12681+
.arg("--workspace"), @r"
12682+
success: true
12683+
exit_code: 0
12684+
----- stdout -----
12685+
Adding path dependencies to workspace members
12686+
12687+
----- stderr -----
12688+
Added `dep` to workspace members
12689+
Resolved 3 packages in [TIME]
12690+
Audited in [TIME]
12691+
");
12692+
12693+
let pyproject_toml = context.read("pyproject.toml");
12694+
assert_snapshot!(
12695+
pyproject_toml, @r#"
12696+
[project]
12697+
name = "parent"
12698+
version = "0.1.0"
12699+
requires-python = ">=3.12"
12700+
12701+
[tool.uv.workspace]
12702+
members = [
12703+
"project",
12704+
"dep",
12705+
]
12706+
"#
12707+
);
12708+
12709+
let pyproject_toml = context.read("project/pyproject.toml");
12710+
assert_snapshot!(
12711+
pyproject_toml, @r#"
12712+
[project]
12713+
name = "project"
12714+
version = "0.1.0"
12715+
requires-python = ">=3.12"
12716+
dependencies = [
12717+
"dep",
12718+
]
12719+
12720+
[tool.uv.sources]
12721+
dep = { workspace = true }
12722+
"#
12723+
);
12724+
12725+
Ok(())
12726+
}
12727+
12728+
/// Add a path dependency with `--workspace` flag to add it to workspace members. The root doesn't
12729+
/// contain a workspace definition, so `uv add` should create one.
12730+
#[test]
12731+
fn add_path_with_workspace() -> Result<()> {
12732+
let context = TestContext::new("3.12");
12733+
12734+
let workspace_toml = context.temp_dir.child("pyproject.toml");
12735+
workspace_toml.write_str(indoc! {r#"
12736+
[project]
12737+
name = "parent"
12738+
version = "0.1.0"
12739+
requires-python = ">=3.12"
12740+
"#})?;
12741+
12742+
// Create a dependency package outside the workspace members.
12743+
let dep_dir = context.temp_dir.child("dep");
12744+
dep_dir.create_dir_all()?;
12745+
12746+
let dep_toml = dep_dir.child("pyproject.toml");
12747+
dep_toml.write_str(indoc! {r#"
12748+
[project]
12749+
name = "dep"
12750+
version = "0.1.0"
12751+
requires-python = ">=3.12"
12752+
dependencies = []
12753+
"#})?;
12754+
12755+
// Add the dependency with `--workspace` flag from the project directory.
12756+
uv_snapshot!(context.filters(), context
12757+
.add()
12758+
.arg("./dep")
12759+
.arg("--workspace"), @r"
12760+
success: true
12761+
exit_code: 0
12762+
----- stdout -----
12763+
12764+
----- stderr -----
12765+
Added `dep` to workspace members
12766+
Resolved 2 packages in [TIME]
12767+
Audited in [TIME]
12768+
");
12769+
12770+
let pyproject_toml = context.read("pyproject.toml");
12771+
assert_snapshot!(
12772+
pyproject_toml, @r#"
12773+
[project]
12774+
name = "parent"
12775+
version = "0.1.0"
12776+
requires-python = ">=3.12"
12777+
dependencies = [
12778+
"dep",
12779+
]
12780+
12781+
[tool.uv.workspace]
12782+
members = [
12783+
"dep",
12784+
]
12785+
12786+
[tool.uv.sources]
12787+
dep = { workspace = true }
12788+
"#
12789+
);
12790+
12791+
Ok(())
12792+
}

docs/reference/cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,8 @@ uv add [OPTIONS] <PACKAGES|--requirements <REQUIREMENTS>>
582582
</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>
583583
</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>
584584
<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>
585+
</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>
586+
<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>
585587
</dd></dl>
586588

587589
## uv remove

0 commit comments

Comments
 (0)