Enable projects to opt-out of workspace management (#4565)

## Summary

You can now add `managed = false` under `[tool.uv]` in a
`pyproject.toml` to explicitly opt out of the project and workspace
APIs.

If a project sets `managed = false`, we will (1) _not_ discover it as a
workspace root, and (2) _not_ discover it as a workspace member (similar
to using `exclude` in the workspace parent).

Closes https://github.com/astral-sh/uv/issues/4551.
This commit is contained in:
Charlie Marsh 2024-07-01 16:17:43 -04:00 committed by GitHub
parent be2a67cd9b
commit a4417eba4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 120 additions and 0 deletions

View file

@ -74,7 +74,11 @@ pub struct Tool {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ToolUv { pub struct ToolUv {
pub sources: Option<BTreeMap<PackageName, Source>>, pub sources: Option<BTreeMap<PackageName, Source>>,
/// The workspace definition for the project, if any.
pub workspace: Option<ToolUvWorkspace>, pub workspace: Option<ToolUvWorkspace>,
/// Whether the project is managed by `uv`. If `false`, `uv` will ignore the project when
/// `uv run` is invoked.
pub managed: Option<bool>,
#[cfg_attr( #[cfg_attr(
feature = "schemars", feature = "schemars",
schemars( schemars(

View file

@ -25,6 +25,8 @@ pub enum WorkspaceError {
MissingProject(PathBuf), MissingProject(PathBuf),
#[error("No workspace found for: `{}`", _0.simplified_display())] #[error("No workspace found for: `{}`", _0.simplified_display())]
MissingWorkspace(PathBuf), 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}`")] #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
DynamicNotAllowed(&'static str), DynamicNotAllowed(&'static str),
#[error("Failed to find directories for glob: `{0}`")] #[error("Failed to find directories for glob: `{0}`")]
@ -83,6 +85,21 @@ impl Workspace {
.map_err(WorkspaceError::Normalize)? .map_err(WorkspaceError::Normalize)?
.to_path_buf(); .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. // Check if the current project is also an explicit workspace root.
let explicit_root = pyproject_toml let explicit_root = pyproject_toml
.tool .tool
@ -326,6 +343,21 @@ impl Workspace {
let pyproject_toml = PyProjectToml::from_string(contents) let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; .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. // Extract the package name.
let Some(project) = pyproject_toml.project.clone() else { let Some(project) = pyproject_toml.project.clone() else {
return Err(WorkspaceError::MissingProject(member_root)); return Err(WorkspaceError::MissingProject(member_root));
@ -586,6 +618,18 @@ impl ProjectWorkspace {
.map_err(WorkspaceError::Normalize)? .map_err(WorkspaceError::Normalize)?
.to_path_buf(); .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. // Check if the current project is also an explicit workspace root.
let mut workspace = project_pyproject_toml let mut workspace = project_pyproject_toml
.tool .tool

View file

@ -143,6 +143,7 @@ pub(crate) async fn run(
match VirtualProject::discover(&std::env::current_dir()?, None).await { match VirtualProject::discover(&std::env::current_dir()?, None).await {
Ok(project) => Some(project), Ok(project) => Some(project),
Err(WorkspaceError::MissingPyprojectToml) => None, Err(WorkspaceError::MissingPyprojectToml) => None,
Err(WorkspaceError::NonWorkspace(_)) => None,
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
} }
}; };

View file

@ -284,3 +284,34 @@ fn run_script() -> Result<()> {
Ok(()) 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(())
}

View file

@ -167,6 +167,21 @@ fn test_albatross_project_in_excluded() {
); );
context.assert_file(current_dir.join("check_installed_bird_feeder.py")); 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, &current_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] #[test]

View file

@ -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

View file

@ -0,0 +1,5 @@
import idna
def seeds():
print("sunflower")

8
uv.schema.json generated
View file

@ -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": { "native-tls": {
"type": [ "type": [
"boolean", "boolean",
@ -254,6 +261,7 @@
} }
}, },
"workspace": { "workspace": {
"description": "The workspace definition for the project, if any.",
"anyOf": [ "anyOf": [
{ {
"$ref": "#/definitions/ToolUvWorkspace" "$ref": "#/definitions/ToolUvWorkspace"