From 9c9510c9ef009310fda11cef9388ec6dffbfd8e2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 24 Jul 2024 12:21:34 -0400 Subject: [PATCH] Ignore hidden directories in workspace discovery (#5408) ## Summary This is surprisingly complex because we need to decide what happens if you run `uv run` from within a hidden folder, etc. For now, I did the simplest thing: we just ignore workspace members that are hidden directories if they lack a `pyproject.toml`, so you can still include hidden members, they're just ignored if they don't seem to be projects. Closes https://github.com/astral-sh/uv/issues/5403. --- crates/uv-workspace/src/workspace.rs | 52 +++++++-- crates/uv/tests/init.rs | 15 +++ crates/uv/tests/workspace.rs | 164 +++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 11 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 8f060dc5d..a8ee6acb2 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -21,6 +21,8 @@ pub enum WorkspaceError { // Workspace structure errors. #[error("No `pyproject.toml` found in current directory or any parent directory")] MissingPyprojectToml, + #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{1}`)", _0.simplified_display())] + MissingPyprojectTomlMember(PathBuf, String), #[error("No `project` table found in: `{}`", _0.simplified_display())] MissingProject(PathBuf), #[error("No workspace found for: `{}`", _0.simplified_display())] @@ -394,7 +396,7 @@ impl Workspace { .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; debug!( - "Adding root workspace member: {}", + "Adding root workspace member: `{}`", workspace_root.simplified_display() ); @@ -413,7 +415,7 @@ impl Workspace { // The current project is a workspace member, especially in a single project workspace. if let Some(root_member) = current_project { debug!( - "Adding current workspace member: {}", + "Adding current workspace member: `{}`", root_member.root.simplified_display() ); @@ -436,6 +438,11 @@ impl Workspace { if !seen.insert(member_root.clone()) { continue; } + let member_root = absolutize_path(&member_root) + .map_err(WorkspaceError::Normalize)? + .to_path_buf(); + + // If the directory is explicitly ignored, skip it. if options.ignore.contains(member_root.as_path()) { debug!( "Ignoring workspace member: `{}`", @@ -443,15 +450,38 @@ impl Workspace { ); continue; } - let member_root = absolutize_path(&member_root) - .map_err(WorkspaceError::Normalize)? - .to_path_buf(); - trace!("Processing workspace member {}", member_root.user_display()); + trace!( + "Processing workspace member: `{}`", + member_root.user_display() + ); // Read the member `pyproject.toml`. let pyproject_path = member_root.join("pyproject.toml"); - let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; + let contents = match fs_err::tokio::read_to_string(&pyproject_path).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // If the directory is hidden, skip it. + if member_root + .file_name() + .map(|name| name.as_encoded_bytes().starts_with(b".")) + .unwrap_or(false) + { + debug!( + "Ignoring hidden workspace member: `{}`", + member_root.simplified_display() + ); + continue; + } + + return Err(WorkspaceError::MissingPyprojectTomlMember( + member_root, + member_glob.to_string(), + )); + } + Err(err) => return Err(err.into()), + }; + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; @@ -476,7 +506,7 @@ impl Workspace { }; debug!( - "Adding discovered workspace member: {}", + "Adding discovered workspace member: `{}`", member_root.simplified_display() ); workspace_members.insert( @@ -869,7 +899,7 @@ async fn find_workspace( continue; } trace!( - "Found pyproject.toml: {}", + "Found `pyproject.toml` at: `{}`", pyproject_path.simplified_display() ); @@ -886,7 +916,7 @@ async fn find_workspace( { if is_excluded_from_workspace(project_root, workspace_root, workspace)? { debug!( - "Found workspace root `{}`, but project is excluded.", + "Found workspace root `{}`, but project is excluded", workspace_root.simplified_display() ); return Ok(None); @@ -925,7 +955,7 @@ async fn find_workspace( } else { // We require that a `project.toml` file either declares a workspace or a project. warn!( - "pyproject.toml does not contain `project` table: `{}`", + "`pyproject.toml` does not contain a `project` table: `{}`", pyproject_path.simplified_display() ); Ok(None) diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 9a61cb9e5..143ebb494 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -1045,3 +1045,18 @@ fn init_unmanaged() -> Result<()> { Ok(()) } + +#[test] +fn init_hidden() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.init().arg(".foo"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + error: Not a valid package or extra name: ".foo". Names must start and end with a letter or digit and may only contain -, _, ., and alphanumeric characters. + "###); +} diff --git a/crates/uv/tests/workspace.rs b/crates/uv/tests/workspace.rs index 0a243a8e6..83085fed2 100644 --- a/crates/uv/tests/workspace.rs +++ b/crates/uv/tests/workspace.rs @@ -690,3 +690,167 @@ fn workspace_to_workspace_paths_dependencies() -> Result<()> { Ok(()) } + +/// Ensure that workspace discovery errors if a member is missing a `pyproject.toml`. +#[test] +fn workspace_empty_member() -> Result<()> { + let context = TestContext::new("3.12"); + + // Build the main workspace ... + let workspace = context.temp_dir.child("workspace"); + workspace.child("pyproject.toml").write_str(indoc! {r#" + [tool.uv.workspace] + members = ["packages/*"] + "#})?; + + // ... with a ... + let deps = indoc! {r#" + dependencies = ["b"] + + [tool.uv.sources] + b = { workspace = true } + "#}; + make_project(&workspace.join("packages").join("a"), "a", deps)?; + + // ... and b. + let deps = indoc! {r" + "}; + make_project(&workspace.join("packages").join("b"), "b", deps)?; + + // ... and an empty c. + fs_err::create_dir_all(workspace.join("packages").join("c"))?; + + uv_snapshot!(context.filters(), context.lock().arg("--preview").current_dir(&workspace), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Workspace member `[TEMP_DIR]/workspace/packages/c` is missing a `pyproject.toml` (matches: `packages/*`) + "### + ); + + Ok(()) +} + +/// Ensure that workspace discovery ignores hidden directories. +#[test] +fn workspace_hidden_files() -> Result<()> { + let context = TestContext::new("3.12"); + + // Build the main workspace ... + let workspace = context.temp_dir.child("workspace"); + workspace.child("pyproject.toml").write_str(indoc! {r#" + [tool.uv.workspace] + members = ["packages/*"] + "#})?; + + // ... with a ... + let deps = indoc! {r#" + dependencies = ["b"] + + [tool.uv.sources] + b = { workspace = true } + "#}; + make_project(&workspace.join("packages").join("a"), "a", deps)?; + + // ... and b. + let deps = indoc! {r" + "}; + make_project(&workspace.join("packages").join("b"), "b", deps)?; + + // ... and a hidden c. + fs_err::create_dir_all(workspace.join("packages").join(".c"))?; + + uv_snapshot!(context.filters(), context.lock().arg("--preview").current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "### + ); + + let lock: SourceLock = toml::from_str(&fs_err::read_to_string(workspace.join("uv.lock"))?)?; + + assert_json_snapshot!(lock.sources(), @r###" + { + "a": { + "editable": "packages/a" + }, + "b": { + "editable": "packages/b" + } + } + "###); + + Ok(()) +} + +/// Ensure that workspace discovery accepts valid hidden directories. +#[test] +fn workspace_hidden_member() -> Result<()> { + let context = TestContext::new("3.12"); + + // Build the main workspace ... + let workspace = context.temp_dir.child("workspace"); + workspace.child("pyproject.toml").write_str(indoc! {r#" + [tool.uv.workspace] + members = ["packages/*"] + "#})?; + + // ... with a ... + let deps = indoc! {r#" + dependencies = ["b"] + + [tool.uv.sources] + b = { workspace = true } + "#}; + make_project(&workspace.join("packages").join("a"), "a", deps)?; + + // ... and b. + let deps = indoc! {r#" + dependencies = ["c"] + + [tool.uv.sources] + c = { workspace = true } + "#}; + make_project(&workspace.join("packages").join("b"), "b", deps)?; + + // ... and a hidden (but valid) .c. + let deps = indoc! {r" + dependencies = [] + "}; + make_project(&workspace.join("packages").join(".c"), "c", deps)?; + + uv_snapshot!(context.filters(), context.lock().arg("--preview").current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "### + ); + + let lock: SourceLock = toml::from_str(&fs_err::read_to_string(workspace.join("uv.lock"))?)?; + + assert_json_snapshot!(lock.sources(), @r###" + { + "a": { + "editable": "packages/a" + }, + "b": { + "editable": "packages/b" + }, + "c": { + "editable": "packages/.c" + } + } + "###); + + Ok(()) +}