diff --git a/crates/uv-distribution/src/pyproject.rs b/crates/uv-distribution/src/pyproject.rs index 1892b7b75..144527d97 100644 --- a/crates/uv-distribution/src/pyproject.rs +++ b/crates/uv-distribution/src/pyproject.rs @@ -74,7 +74,11 @@ pub struct Tool { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ToolUv { pub sources: Option>, + /// The workspace definition for the project, if any. pub workspace: Option, + /// Whether the project is managed by `uv`. If `false`, `uv` will ignore the project when + /// `uv run` is invoked. + pub managed: Option, #[cfg_attr( feature = "schemars", schemars( diff --git a/crates/uv-distribution/src/workspace.rs b/crates/uv-distribution/src/workspace.rs index d1a192a71..a3d3c627b 100644 --- a/crates/uv-distribution/src/workspace.rs +++ b/crates/uv-distribution/src/workspace.rs @@ -25,6 +25,8 @@ pub enum WorkspaceError { MissingProject(PathBuf), #[error("No workspace found for: `{}`", _0.simplified_display())] MissingWorkspace(PathBuf), + #[error("The project is marked as unmanaged: `{}`", _0.simplified_display())] + NonWorkspace(PathBuf), #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")] DynamicNotAllowed(&'static str), #[error("Failed to find directories for glob: `{0}`")] @@ -83,6 +85,21 @@ impl Workspace { .map_err(WorkspaceError::Normalize)? .to_path_buf(); + // Check if the project is explicitly marked as unmanaged. + if pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.managed) + == Some(false) + { + debug!( + "Project `{}` is marked as unmanaged", + project_path.simplified_display() + ); + return Err(WorkspaceError::NonWorkspace(project_path)); + } + // Check if the current project is also an explicit workspace root. let explicit_root = pyproject_toml .tool @@ -326,6 +343,21 @@ impl Workspace { let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; + // Check if the current project is explicitly marked as unmanaged. + if pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.managed) + == Some(false) + { + debug!( + "Project `{}` is marked as unmanaged; omitting from workspace members", + pyproject_toml.project.as_ref().unwrap().name + ); + continue; + } + // Extract the package name. let Some(project) = pyproject_toml.project.clone() else { return Err(WorkspaceError::MissingProject(member_root)); @@ -586,6 +618,18 @@ impl ProjectWorkspace { .map_err(WorkspaceError::Normalize)? .to_path_buf(); + // Check if workspaces are explicitly disabled for the project. + if project_pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.managed) + == Some(false) + { + debug!("Project `{}` is marked as unmanaged", project.name); + return Err(WorkspaceError::NonWorkspace(project_path)); + } + // Check if the current project is also an explicit workspace root. let mut workspace = project_pyproject_toml .tool diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index ad57b1e80..52ddfa338 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -143,6 +143,7 @@ pub(crate) async fn run( match VirtualProject::discover(&std::env::current_dir()?, None).await { Ok(project) => Some(project), Err(WorkspaceError::MissingPyprojectToml) => None, + Err(WorkspaceError::NonWorkspace(_)) => None, Err(err) => return Err(err.into()), } }; diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index f82706755..fb321a107 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -284,3 +284,34 @@ fn run_script() -> Result<()> { Ok(()) } + +/// With `managed = false`, we should avoid installing the project itself. +#[test] +fn run_managed_false() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.8" + dependencies = ["anyio"] + + [tool.uv] + managed = false + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning. + "###); + + Ok(()) +} diff --git a/crates/uv/tests/workspace.rs b/crates/uv/tests/workspace.rs index 27c44f43c..cf8ed1d2a 100644 --- a/crates/uv/tests/workspace.rs +++ b/crates/uv/tests/workspace.rs @@ -167,6 +167,21 @@ fn test_albatross_project_in_excluded() { ); context.assert_file(current_dir.join("check_installed_bird_feeder.py")); + + let current_dir = workspaces_dir() + .join("albatross-project-in-excluded") + .join("packages") + .join("seeds"); + uv_snapshot!(context.filters(), install_workspace(&context, ¤t_dir), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to download and build: `seeds @ file://[WORKSPACE]/scripts/workspaces/albatross-project-in-excluded/packages/seeds` + Caused by: The project is marked as unmanaged: `[WORKSPACE]/scripts/workspaces/albatross-project-in-excluded/packages/seeds` + "### + ); } #[test] diff --git a/scripts/workspaces/albatross-project-in-excluded/packages/seeds/pyproject.toml b/scripts/workspaces/albatross-project-in-excluded/packages/seeds/pyproject.toml new file mode 100644 index 000000000..71d0272ed --- /dev/null +++ b/scripts/workspaces/albatross-project-in-excluded/packages/seeds/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "seeds" +version = "1.0.0" +requires-python = ">=3.12" +dependencies = ["idna==3.6"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +managed = false diff --git a/scripts/workspaces/albatross-project-in-excluded/packages/seeds/src/seeds/__init__.py b/scripts/workspaces/albatross-project-in-excluded/packages/seeds/src/seeds/__init__.py new file mode 100644 index 000000000..50efae439 --- /dev/null +++ b/scripts/workspaces/albatross-project-in-excluded/packages/seeds/src/seeds/__init__.py @@ -0,0 +1,5 @@ +import idna + + +def seeds(): + print("sunflower") diff --git a/uv.schema.json b/uv.schema.json index 041fb5cb9..d7c8b253a 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -104,6 +104,13 @@ } ] }, + "managed": { + "description": "Whether the project is managed by `uv`. If `false`, `uv` will ignore the project when `uv run` is invoked.", + "type": [ + "boolean", + "null" + ] + }, "native-tls": { "type": [ "boolean", @@ -254,6 +261,7 @@ } }, "workspace": { + "description": "The workspace definition for the project, if any.", "anyOf": [ { "$ref": "#/definitions/ToolUvWorkspace"