From d232bfea006d4eeebc1cd91f8ab2e1d22fdc7d12 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Tue, 23 Jul 2024 07:48:40 +0800 Subject: [PATCH] `uv init` should not create nested workspace (#5293) ## Summary Resolves #5251 --- crates/uv-workspace/src/workspace.rs | 87 ++++++++++++- crates/uv/src/commands/project/init.rs | 25 ++-- crates/uv/tests/init.rs | 174 ++++++++++++++++++++++++- 3 files changed, 265 insertions(+), 21 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 829f5d09e070..ca1a51047d7e 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -62,6 +62,8 @@ pub struct Workspace { /// /// This table is overridden by the project sources. sources: BTreeMap, + /// The `pyproject.toml` of the workspace root. + pyproject_toml: PyProjectToml, } impl Workspace { @@ -323,6 +325,11 @@ impl Workspace { &self.sources } + /// The `pyproject.toml` of the workspace. + pub fn pyproject_toml(&self) -> &PyProjectToml { + &self.pyproject_toml + } + /// Collect the workspace member projects from the `members` and `excludes` entries. async fn collect_members( workspace_root: PathBuf, @@ -440,6 +447,7 @@ impl Workspace { } let workspace_sources = workspace_pyproject_toml .tool + .clone() .and_then(|tool| tool.uv) .and_then(|uv| uv.sources) .unwrap_or_default(); @@ -451,6 +459,7 @@ impl Workspace { lock_path, packages: workspace_members, sources: workspace_sources, + pyproject_toml: workspace_pyproject_toml, }) } } @@ -753,6 +762,7 @@ impl ProjectWorkspace { // There may be package sources, but we don't need to duplicate them into the // workspace sources. sources: BTreeMap::default(), + pyproject_toml: project_pyproject_toml.clone(), }, }); }; @@ -1150,7 +1160,15 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, - "sources": {} + "sources": {}, + "pyproject_toml": { + "project": { + "name": "bird-feeder", + "requires-python": ">=3.12", + "optional-dependencies": null + }, + "tool": null + } } } "###); @@ -1186,7 +1204,15 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, - "sources": {} + "sources": {}, + "pyproject_toml": { + "project": { + "name": "bird-feeder", + "requires-python": ">=3.12", + "optional-dependencies": null + }, + "tool": null + } } } "###); @@ -1244,6 +1270,33 @@ mod tests { "workspace": true, "editable": null } + }, + "pyproject_toml": { + "project": { + "name": "albatross", + "requires-python": ">=3.12", + "optional-dependencies": null + }, + "tool": { + "uv": { + "sources": { + "bird-feeder": { + "workspace": true, + "editable": null + } + }, + "workspace": { + "members": [ + "packages/*" + ], + "exclude": null + }, + "managed": null, + "dev-dependencies": null, + "override-dependencies": null, + "constraint-dependencies": null + } + } } } } @@ -1298,7 +1351,25 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, - "sources": {} + "sources": {}, + "pyproject_toml": { + "project": null, + "tool": { + "uv": { + "sources": null, + "workspace": { + "members": [ + "packages/*" + ], + "exclude": null + }, + "managed": null, + "dev-dependencies": null, + "override-dependencies": null, + "constraint-dependencies": null + } + } + } } } "###); @@ -1333,7 +1404,15 @@ mod tests { "pyproject_toml": "[PYPROJECT_TOML]" } }, - "sources": {} + "sources": {}, + "pyproject_toml": { + "project": { + "name": "albatross", + "requires-python": ">=3.12", + "optional-dependencies": null + }, + "tool": null + } } } "###); diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 2887a5dd9cf9..3b97a0f2d36c 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -3,12 +3,13 @@ use std::path::PathBuf; use anyhow::Result; use owo_colors::OwoColorize; + use pep508_rs::PackageName; use uv_configuration::PreviewMode; use uv_fs::Simplified; use uv_warnings::warn_user_once; use uv_workspace::pyproject_mut::PyProjectTomlMut; -use uv_workspace::{ProjectWorkspace, WorkspaceError}; +use uv_workspace::{Workspace, WorkspaceError}; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -53,7 +54,7 @@ pub(crate) async fn init( .unwrap_or_else(|_| path.simplified().to_path_buf()); anyhow::bail!( - "Project is already initialized in {}", + "Project is already initialized in `{}`", path.display().cyan() ); } @@ -69,8 +70,9 @@ pub(crate) async fn init( let workspace = if isolated { None } else { - match ProjectWorkspace::discover(&path, None).await { - Ok(project) => Some(project), + // Attempt to find a workspace root. + match Workspace::discover(&path, None).await { + Ok(workspace) => Some(workspace), Err(WorkspaceError::MissingPyprojectToml) => None, Err(err) => return Err(err.into()), } @@ -114,25 +116,20 @@ pub(crate) async fn init( if let Some(workspace) = workspace { // Add the package to the workspace. - let mut pyproject = - PyProjectTomlMut::from_toml(workspace.current_project().pyproject_toml())?; - pyproject.add_workspace(path.strip_prefix(workspace.project_root())?)?; + let mut pyproject = PyProjectTomlMut::from_toml(workspace.pyproject_toml())?; + pyproject.add_workspace(path.strip_prefix(workspace.install_path())?)?; // Save the modified `pyproject.toml`. fs_err::write( - workspace.current_project().root().join("pyproject.toml"), + workspace.install_path().join("pyproject.toml"), pyproject.to_string(), )?; writeln!( printer.stderr(), - "Adding {} as member of workspace {}", + "Adding `{}` as member of workspace `{}`", name.cyan(), - workspace - .workspace() - .install_path() - .simplified_display() - .cyan() + workspace.install_path().simplified_display().cyan() )?; } diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 32e10f2aa471..91f055bb4944 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -200,7 +200,7 @@ fn init_workspace() -> Result<()> { ----- stderr ----- warning: `uv init` is experimental and may change without warning - Adding foo as member of workspace [TEMP_DIR]/ + Adding `foo` as member of workspace `[TEMP_DIR]/` Initialized project `foo` "###); @@ -295,7 +295,7 @@ fn init_workspace_relative_sub_package() -> Result<()> { ----- stderr ----- warning: `uv init` is experimental and may change without warning - Adding foo as member of workspace [TEMP_DIR]/ + Adding `foo` as member of workspace `[TEMP_DIR]/` Initialized project `foo` at `[TEMP_DIR]/foo` "###); @@ -391,7 +391,7 @@ fn init_workspace_outside() -> Result<()> { ----- stderr ----- warning: `uv init` is experimental and may change without warning - Adding foo as member of workspace [TEMP_DIR]/ + Adding `foo` as member of workspace `[TEMP_DIR]/` Initialized project `foo` at `[TEMP_DIR]/foo` "###); @@ -556,3 +556,171 @@ fn init_workspace_isolated() -> Result<()> { Ok(()) } + +#[test] +fn init_nested_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + "#, + })?; + + // Create a child from the workspace root. + let child = context.temp_dir.join("foo"); + uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `foo` as member of workspace `[TEMP_DIR]/` + Initialized project `foo` at `[TEMP_DIR]/foo` + "###); + + // Create a grandchild from the child directory. + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("bar"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `bar` as member of workspace `[TEMP_DIR]/` + Initialized project `bar` at `[TEMP_DIR]/foo/bar` + "###); + + let workspace = fs_err::read_to_string(pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + workspace, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = ["foo", "foo/bar"] + "### + ); + }); + + let pyproject = fs_err::read_to_string(child.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + dependencies = [] + + [tool.uv] + dev-dependencies = [] + "### + ); + }); + + Ok(()) +} + +/// Run `uv init` from within a workspace with an explicit root. +#[test] +fn init_explicit_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = [] + "#, + })?; + + let child = context.temp_dir.join("foo"); + uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `foo` as member of workspace `[TEMP_DIR]/` + Initialized project `foo` at `[TEMP_DIR]/foo` + "###); + + let workspace = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + workspace, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = ["foo"] + "### + ); + }); + + Ok(()) +} + +/// Run `uv init` from within a virtual workspace. +#[test] +fn init_virtual_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { + r" + [tool.uv.workspace] + members = [] + ", + })?; + + let child = context.temp_dir.join("foo"); + uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `foo` as member of workspace `[TEMP_DIR]/` + Initialized project `foo` at `[TEMP_DIR]/foo` + "###); + + let workspace = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + workspace, @r###" + [tool.uv.workspace] + members = ["foo"] + "### + ); + }); + + Ok(()) +}