diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index 6411b6203f181..d6cf18a5390a0 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -34,6 +34,7 @@ use crate::commands::project::{ validate_project_requires_python, }; use crate::commands::reporters::PythonDownloadReporter; +use crate::commands::workspace::list::find_scripts; use crate::commands::{ExitStatus, diagnostics, project}; use crate::printer::Printer; use crate::settings::{FrozenSource, LockCheck, ResolverInstallerSettings}; @@ -245,7 +246,7 @@ pub(crate) async fn check( // The most common case this is handling is a non-virtual workspace, where the root // package will almost always have the other packages nested under it, and we need a // way to select just the workspace root. - let excluded_targets = if let Some(project) = project.as_ref() + let mut excluded_targets = if let Some(project) = project.as_ref() && !defacto_all_packages { project @@ -269,6 +270,21 @@ pub(crate) async fn check( Vec::new() }; + // PEP 723 scripts have independent environments and must be checked explicitly with + // `uv check --script`. Reuse `uv workspace list --scripts` discovery so scripts nested in + // selected workspace members are not checked against the project environment. + if let Some(project) = project.as_ref() { + excluded_targets.extend( + find_scripts(project.workspace().install_path(), cache)? + .into_iter() + .filter(|script| { + check_targets + .iter() + .any(|target| script.starts_with(target)) + }), + ); + } + let groups = if let Some(project) = &project { groups.with_defaults(default_dependency_groups(project.pyproject_toml())?) } else { diff --git a/crates/uv/src/commands/workspace/list.rs b/crates/uv/src/commands/workspace/list.rs index 04d1d01a78a46..2ee938648bdcf 100644 --- a/crates/uv/src/commands/workspace/list.rs +++ b/crates/uv/src/commands/workspace/list.rs @@ -70,7 +70,7 @@ pub(crate) async fn list( /// /// Respects ignore files and excludes repository internals, virtual environments, and the uv cache /// from traversal. -fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result> { +pub(crate) fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result> { // Avoid descending into the cache when it is inside the workspace. If the workspace itself is // inside the cache, it is still the requested search root and must not be excluded. let cache_root = if cache.root().is_absolute() { diff --git a/crates/uv/tests/project/check.rs b/crates/uv/tests/project/check.rs index 6555a16273111..5ed55be9cd8e2 100644 --- a/crates/uv/tests/project/check.rs +++ b/crates/uv/tests/project/check.rs @@ -60,6 +60,92 @@ fn check_project() -> Result<()> { Ok(()) } +/// Check PEP 723 scripts only when explicitly selected, not as part of a workspace member. +#[test] +fn check_workspace_excludes_pep723_scripts() -> Result<()> { + let context = + uv_test::test_context!("3.12").with_filter((r"WARN Failed to fetch `ty`[^\n]*\n", "")); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [tool.uv.workspace] + members = ["packages/*"] + "#})?; + context + .temp_dir + .child("main.py") + .write_str("value: int = 'project'\n")?; + context.temp_dir.child("script.py").write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + value: int = "script" + "#})?; + + write_workspace_member(&context, "member", "value: int = 'member'\n")?; + context + .temp_dir + .child("packages/member/script.py") + .write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + value: int = "member-script" + "#})?; + + uv_snapshot!(context.filters(), workspace_check(&context), @r#" + exit_code: 1 (failure) + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["project"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + "#); + + uv_snapshot!(context.filters(), workspace_check(&context).arg("--package").arg("member"), @r#" + exit_code: 1 (failure) + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["member"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + "#); + + uv_snapshot!(context.filters(), workspace_check(&context).arg("--all-packages"), @r#" + exit_code: 1 (failure) + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["project"]` is not assignable to `int` + packages/member/main.py:1:14: error[invalid-assignment] Object of type `Literal["member"]` is not assignable to `int` + Found 2 diagnostics + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + "#); + + uv_snapshot!(context.filters(), workspace_check(&context).arg("--script").arg("script.py"), @r#" + exit_code: 1 (failure) + ----- stdout ----- + script.py:5:14: error[invalid-assignment] Object of type `Literal["script"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + "#); + + Ok(()) +} + /// Check only the selected workspace member, whether selected implicitly or explicitly. #[test] fn check_workspace_member_selection() -> Result<()> {