From af73c077aabe27fdedb88ed3f88862568274c26e Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 17 Sep 2026 19:09:35 +0200 Subject: [PATCH] [ty] Only skip uv workspace metadata for a single script (#28581) --- crates/ty/Cargo.toml | 2 +- crates/ty/docs/rules.md | 2 - crates/ty/src/lib.rs | 30 ++-- crates/ty/tests/cli/scripts.rs | 3 +- crates/ty/tests/cli/uv_workspace.rs | 138 ++++++++++++++++-- crates/ty_project/src/metadata.rs | 9 -- .../lint_docs/missing-direct-dependency.md | 2 - ty.schema.json | 2 +- 8 files changed, 148 insertions(+), 40 deletions(-) diff --git a/crates/ty/Cargo.toml b/crates/ty/Cargo.toml index 7a550d1ddd..f3a5df2627 100644 --- a/crates/ty/Cargo.toml +++ b/crates/ty/Cargo.toml @@ -20,6 +20,7 @@ doctest = false [dependencies] ruff_db = { workspace = true, features = ["os", "cache", "junit"] } ruff_diagnostics = { workspace = true } +ruff_python_ast = { workspace = true } ruff_ranged_value = { workspace = true } ty_combine = { workspace = true } ty_project = { workspace = true, features = ["zstd", "junit"] } @@ -52,7 +53,6 @@ tikv-jemallocator = { workspace = true } [dev-dependencies] ruff_db = { workspace = true, features = ["testing"] } -ruff_python_ast = { workspace = true } ruff_python_trivia = { workspace = true } ty_module_resolver = { workspace = true } ty_python_core = { workspace = true } diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 0a45a281c4..18cca5c745 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -3963,8 +3963,6 @@ and environments from the enclosing workspace or other scripts do not apply. **Known limitations** -Explicit file arguments bypass uv workspace discovery unless `--config-file` is specified. - Imports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They can use development-only dependencies, such as type stub packages, without requiring those packages as runtime dependencies. diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index a9d2e31061..0202c300ae 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -25,6 +25,7 @@ use ruff_db::files::File; use ruff_db::system::{OsSystem, System, SystemPath, SystemPathBuf}; use ruff_db::{STACK_SIZE, max_parallelism}; use ruff_diagnostics::Applicability; +use ruff_python_ast::script::ScriptTag; use salsa::Database; use ty_project::metadata::settings::TerminalSettings; use ty_project::watch::ProjectWatcher; @@ -147,19 +148,26 @@ fn run_check(args: CheckCommand) -> anyhow::Result { .map(|path| SystemPath::absolute(path, &cwd)); let force_exclude = args.force_exclude(); + let use_uv = UseUv::from_system(&system); + let use_uv = if use_uv == UseUv::On + && let [path] = check_paths.as_slice() + && system.is_file(path) + && system + .read_to_string(path) + .is_ok_and(|source| ScriptTag::parse(source.as_bytes()).is_some()) + { + // A single PEP 723 script uses its own uv environment, even with an explicit ty + // configuration file. The other explicit paths still discover workspace metadata. + UseUv::Scripts + } else { + use_uv + }; + let mut project_metadata = match &config_file { - Some(config_file) => ProjectMetadata::from_config_file( - config_file.clone(), - &project_path, - &system, - UseUv::from_system(&system), - )?, - None if check_paths.iter().any(|path| system.is_file(path)) => { - // `uv check --script` passes a file as its check path. Standalone scripts must not - // inherit the enclosing workspace; their environments are synchronized separately. - ProjectMetadata::discover_without_uv(&project_path, &system)? + Some(config_file) => { + ProjectMetadata::from_config_file(config_file.clone(), &project_path, &system, use_uv)? } - None => ProjectMetadata::discover(&project_path, &system)?, + None => ProjectMetadata::discover_with_uv(&project_path, &system, use_uv)?, }; project_metadata.apply_configuration_files(&system)?; diff --git a/crates/ty/tests/cli/scripts.rs b/crates/ty/tests/cli/scripts.rs index 05bedac928..1e3a7ca5e7 100644 --- a/crates/ty/tests/cli/scripts.rs +++ b/crates/ty/tests/cli/scripts.rs @@ -1364,7 +1364,7 @@ fn ordinary_files_do_not_initialize_scripts() -> anyhow::Result<()> { assert_cmd_snapshot!( case.command() .arg("ordinary.py") - .env(EnvVars::TY_UV, "1") + .env(EnvVars::TY_UV, "scripts") .env(EnvVars::UV, "missing-uv-executable"), @" success: true @@ -1769,6 +1769,7 @@ mod uv_metadata { assert_cmd_snapshot!( command_with_script_uv(&case) .args(["first.py", "second.py"]) + .env(EnvVars::TY_UV, "scripts") .env(EnvVars::TY_MAX_PARALLELISM, "1"), @" success: true diff --git a/crates/ty/tests/cli/uv_workspace.rs b/crates/ty/tests/cli/uv_workspace.rs index d32d714453..3c13ae2869 100644 --- a/crates/ty/tests/cli/uv_workspace.rs +++ b/crates/ty/tests/cli/uv_workspace.rs @@ -82,6 +82,12 @@ pub(super) fn uv_sync_command( ); let mut command = case.command(); + set_uv_envs(&mut command, case, virtual_env); + Ok(command) +} + +#[cfg(feature = "test-uv")] +fn set_uv_envs(command: &mut Command, case: &CliTest, virtual_env: Option<&Path>) { command .envs(uv_test_env_vars()) .env("TY_UV", "1") @@ -93,8 +99,6 @@ pub(super) fn uv_sync_command( if let Some(virtual_env) = virtual_env { command.env("VIRTUAL_ENV", virtual_env); } - - Ok(command) } #[cfg(feature = "test-uv")] @@ -400,29 +404,72 @@ fn uses_uv_workspace_root_without_checking_siblings() -> anyhow::Result<()> { Ok(()) } -/// An explicit file is treated as a script, so workspace discovery stays disabled even when -/// `TY_UV` is set. +/// Checking a script uses its own environment; the project's uv metadata +/// is not queried. #[cfg(feature = "test-uv")] #[test] -fn explicit_file_path_disables_uv_workspace_discovery() -> anyhow::Result<()> { - let case = workspace_case()?; +fn explicit_script_path_disables_uv_workspace_discovery() -> anyhow::Result<()> { + // uv's formatting of resolution failures varies by version; retain the missing dependency. + let case = workspace_case()? + .with_filter(r"exit code: 1", "exit status: 1") + .with_filter( + concat!( + r"(?s)[ \t]*(?:×|error:) No solution found when resolving dependencies:?", + r".*?missing-workspace-dependency==99\.0\.0", + r".*?hint: Packages were unavailable because the network was disabled\.[^\n]*", + ), + " ", + ); + case.write_file( + "packages/member/pyproject.toml", + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["missing-workspace-dependency==99.0.0"] + "#, + )?; + + let member_directory = case.root().join("packages/member"); + let mut command = case.command(); + set_uv_envs(&mut command, &case, None); + + assert_cmd_snapshot!(command.current_dir(&member_directory).arg("member.py"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + member.py:1:14: error[invalid-assignment] Object of type `Literal["selected-member"]` is not assignable to `int` + pyproject.toml: warning[uv-metadata] `uv workspace metadata` failed with status exit status: 1: + + Found 2 diagnostics + + ----- stderr ----- + "#); + case.write_file("shared.py", "value: int = 'unselected-workspace-root'")?; case.write_file( "packages/member/member.py", - "import shared\nvalue: int = 'selected-script'", + r#" + # /// script + # dependencies = [] + # /// + import shared + value: int = 'selected-script' + "#, )?; - let mut command = uv_sync_command(&case, None)?; - command - .current_dir(case.root().join("packages/member")) - .arg("member.py"); + // Checking a script reports its own diagnostics without querying the project's uv metadata. + let mut command = case.command(); + set_uv_envs(&mut command, &case, None); + command.current_dir(&member_directory).arg("member.py"); assert_cmd_snapshot!(command, @r#" success: false exit_code: 1 ----- stdout ----- - member.py:1:8: error[unresolved-import] Cannot resolve imported module `shared` - member.py:2:14: error[invalid-assignment] Object of type `Literal["selected-script"]` is not assignable to `int` + member.py:5:8: error[unresolved-import] Cannot resolve imported module `shared` + member.py:6:14: error[invalid-assignment] Object of type `Literal["selected-script"]` is not assignable to `int` Found 2 diagnostics ----- stderr ----- @@ -431,6 +478,71 @@ fn explicit_file_path_disables_uv_workspace_discovery() -> anyhow::Result<()> { Ok(()) } +/// Checking an ordinary file in a uv workspace uses the project's uv metadata. +#[cfg(feature = "test-uv")] +#[test] +fn explicit_ordinary_file_uses_uv_workspace_discovery() -> anyhow::Result<()> { + let case = dependency_workspace_case()?; + + let mut command = uv_sync_command(&case, None)?; + command + .current_dir(case.root().join("packages/member")) + .args(["member.py", "--error", "missing-direct-dependency"]); + + assert_cmd_snapshot!(command, @" + success: false + exit_code: 1 + ----- stdout ----- + member.py:3:6: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + member.py:4:8: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + Found 2 diagnostics + + ----- stderr ----- + "); + + Ok(()) +} + +/// When ty directly checks a script alongside another file, the other file still gets dependency +/// diagnostics from uv workspace metadata. +#[cfg(feature = "test-uv")] +#[test] +fn multiple_explicit_files_use_uv_workspace_discovery() -> anyhow::Result<()> { + let case = dependency_workspace_case()?; + case.write_file( + "packages/member/script.py", + r#" + # /// script + # dependencies = [] + # /// + value = 1 + "#, + )?; + + let mut command = uv_sync_command(&case, None)?; + command + .current_dir(case.root().join("packages/member")) + .args([ + "script.py", + "member.py", + "--error", + "missing-direct-dependency", + ]); + + assert_cmd_snapshot!(command, @" + success: false + exit_code: 1 + ----- stdout ----- + member.py:3:6: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + member.py:4:8: error[missing-direct-dependency] Import of `indirect_module` requires a direct dependency on `indirect-dependency` + Found 2 diagnostics + + ----- stderr ----- + "); + + Ok(()) +} + /// An explicitly selected member inherits ty rule configuration from the uv workspace root. #[cfg(feature = "test-uv")] #[test] diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index d6c1b5b9c7..95e0ba704f 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -239,15 +239,6 @@ impl ProjectMetadata { } } - /// Discovers the closest project without considering uv workspace metadata. - pub fn discover_without_uv( - path: &SystemPath, - system: &dyn System, - ) -> Result { - Self::discover_with_uv_workspace(path, system, ProjectEnvironment::default()) - .map(|metadata| metadata.with_use_uv(UseUv::from_system(system))) - } - fn discover_with_uv_workspace( path: &SystemPath, system: &dyn System, diff --git a/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md b/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md index cb671ee46d..f009b2c2ab 100644 --- a/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md +++ b/crates/ty_python_semantic/resources/lint_docs/missing-direct-dependency.md @@ -36,8 +36,6 @@ and environments from the enclosing workspace or other scripts do not apply. ## Known limitations -Explicit file arguments bypass uv workspace discovery unless `--config-file` is specified. - Imports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They can use development-only dependencies, such as type stub packages, without requiring those packages as runtime dependencies. diff --git a/ty.schema.json b/ty.schema.json index a3f1256a0e..8192214148 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1166,7 +1166,7 @@ }, "missing-direct-dependency": { "title": "detects imports of dependencies that are not declared directly", - "description": "## What it does\n\nChecks for imports from installable packages that the current project or PEP 723 script does not\ndeclare as direct dependencies.\n\nThe name used in dependency declarations can differ from the import name: for example, the `pillow`\npackage is imported as `PIL`.\n\n## Why is this bad?\n\nA dependency can be installed because another package requires it. Importing that dependency without\ndeclaring it makes your code rely on another package's dependency list. If that package removes the\ndependency, your imports can fail.\n\nDeclare the packages that provide your imports in `project.dependencies` or\n`project.optional-dependencies` in `pyproject.toml`. Non-package files, such as tests and\ndevelopment scripts, can also use dependencies declared in dependency groups.\n\nSee uv's [guide to managing dependencies](https://docs.astral.sh/uv/concepts/projects/dependencies/)\nfor how to add these declarations.\n\n## Rule status\n\nThis rule is disabled by default and requires uv integration.\n\nFor projects, enable uv workspace integration (`TY_UV=1`) and use an existing, synchronized\nenvironment. Running [`uv check`](https://docs.astral.sh/uv/reference/cli/#uv-check) synchronizes\nthe environment automatically before invoking ty, unless `--no-sync` is passed. For these checks, ty\nreads the dependency graph and module ownership returned by `uv workspace metadata` without changing\ninstalled packages. uv may update the lockfile to match the current dependency declarations. uv\n0.12.3 or later is required.\n\nFor PEP 723 scripts, enable uv script integration with `TY_UV=scripts` or `TY_UV=1`. ty synchronizes\neach script's environment and checks imports against its inline `dependencies` list. Declarations\nand environments from the enclosing workspace or other scripts do not apply.\n\n## Known limitations\n\nExplicit file arguments bypass uv workspace discovery unless `--config-file` is specified.\n\nImports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They\ncan use development-only dependencies, such as type stub packages, without requiring those packages\nas runtime dependencies.\n\nStandard-library imports and imports whose owning package cannot be identified unambiguously are\nalso not reported.\n\nImports of [namespace packages](https://docs.python.org/3/reference/import.html#namespace-packages)\nthemselves, such as `import ns`, are not reported: the namespace can contain modules from several\ninstallable packages. Imports of their submodules, such as `import ns.child`, are checked when the\nowning package is known. An `__init__.pyi` stub does not change this distinction.\n\nNative packages that ty can resolve only as namespace packages at runtime are also skipped. For\nother native modules, ty can use stubs to resolve the import and uv's ownership map to identify\nwhich package to declare.\n\nSome editable installations add the whole project directory to Python's import path, making both\npackage code and files such as `tests/test_app.py` importable. If uv does not identify which modules\nbelong to the installable package, ty allows dependency-group imports throughout that directory,\nincluding in package code, to avoid incorrectly flagging imports in tests and scripts.\n\n## Examples\n\nWith `requests` as a direct dependency, `urllib3` may also be installed because `requests` depends\non it:\n\n```python {data-mdtest=\"ignore\"}\nimport requests\nimport urllib3 # error: [missing-direct-dependency]\n```\n\nAdd `urllib3` to `project.dependencies` if your code imports it directly.", + "description": "## What it does\n\nChecks for imports from installable packages that the current project or PEP 723 script does not\ndeclare as direct dependencies.\n\nThe name used in dependency declarations can differ from the import name: for example, the `pillow`\npackage is imported as `PIL`.\n\n## Why is this bad?\n\nA dependency can be installed because another package requires it. Importing that dependency without\ndeclaring it makes your code rely on another package's dependency list. If that package removes the\ndependency, your imports can fail.\n\nDeclare the packages that provide your imports in `project.dependencies` or\n`project.optional-dependencies` in `pyproject.toml`. Non-package files, such as tests and\ndevelopment scripts, can also use dependencies declared in dependency groups.\n\nSee uv's [guide to managing dependencies](https://docs.astral.sh/uv/concepts/projects/dependencies/)\nfor how to add these declarations.\n\n## Rule status\n\nThis rule is disabled by default and requires uv integration.\n\nFor projects, enable uv workspace integration (`TY_UV=1`) and use an existing, synchronized\nenvironment. Running [`uv check`](https://docs.astral.sh/uv/reference/cli/#uv-check) synchronizes\nthe environment automatically before invoking ty, unless `--no-sync` is passed. For these checks, ty\nreads the dependency graph and module ownership returned by `uv workspace metadata` without changing\ninstalled packages. uv may update the lockfile to match the current dependency declarations. uv\n0.12.3 or later is required.\n\nFor PEP 723 scripts, enable uv script integration with `TY_UV=scripts` or `TY_UV=1`. ty synchronizes\neach script's environment and checks imports against its inline `dependencies` list. Declarations\nand environments from the enclosing workspace or other scripts do not apply.\n\n## Known limitations\n\nImports guarded by `TYPE_CHECKING` are not reported because they are not executed at runtime. They\ncan use development-only dependencies, such as type stub packages, without requiring those packages\nas runtime dependencies.\n\nStandard-library imports and imports whose owning package cannot be identified unambiguously are\nalso not reported.\n\nImports of [namespace packages](https://docs.python.org/3/reference/import.html#namespace-packages)\nthemselves, such as `import ns`, are not reported: the namespace can contain modules from several\ninstallable packages. Imports of their submodules, such as `import ns.child`, are checked when the\nowning package is known. An `__init__.pyi` stub does not change this distinction.\n\nNative packages that ty can resolve only as namespace packages at runtime are also skipped. For\nother native modules, ty can use stubs to resolve the import and uv's ownership map to identify\nwhich package to declare.\n\nSome editable installations add the whole project directory to Python's import path, making both\npackage code and files such as `tests/test_app.py` importable. If uv does not identify which modules\nbelong to the installable package, ty allows dependency-group imports throughout that directory,\nincluding in package code, to avoid incorrectly flagging imports in tests and scripts.\n\n## Examples\n\nWith `requests` as a direct dependency, `urllib3` may also be installed because `requests` depends\non it:\n\n```python {data-mdtest=\"ignore\"}\nimport requests\nimport urllib3 # error: [missing-direct-dependency]\n```\n\nAdd `urllib3` to `project.dependencies` if your code imports it directly.", "default": "ignore", "oneOf": [ {