[ty] Discover site-packages from the environment that ty is installed in (#21286)

<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Closes https://github.com/astral-sh/ty/issues/989

There are various situations where users expect the Python packages
installed in the same environment as ty itself to be considered during
type checking. A minimal example would look like:

```
uv venv my-env
uv pip install my-env ty httpx
echo "import httpx" > foo.py
./my-env/bin/ty check foo.py
```

or

```
uv tool install ty --with httpx
echo "import httpx" > foo.py
ty check foo.py
```

While these are a bit contrived, there are real-world situations where a
user would expect a similar behavior to work. Notably, all of the other
type checkers consider their own environment when determining search
paths (though I'll admit that I have not verified when they choose not
to do this).

One common situation where users are encountering this today is with
`uvx --with-requirements script.py ty check script.py` — which is
currently our "best" recommendation for type checking a PEP 723 script,
but it doesn't work.

Of the options discussed in
https://github.com/astral-sh/ty/issues/989#issuecomment-3307417985, I've
chosen (2) as our criteria for including ty's environment in the search
paths.

- If no virtual environment is discovered, we will always include ty's
environment.
- If a `.venv` is discovered in the working directory, we will _prepend_
ty's environment to the search paths. The dependencies in ty's
environment (e.g., from `uvx --with`) will take precedence.
- If a virtual environment is active, e.g., `VIRTUAL_ENV` (i.e.,
including conda prefixes) is set, we will not include ty's environment.

The reason we need to special case the `.venv` case is that we both

1.  Recommend `uvx ty` today as a way to check your project
2. Want to enable `uvx --with <...> ty`

And I don't want (2) to break when you _happen_ to be in a project
(i.e., if we only included ty's environment when _no_ environment is
found) and don't want to remove support for (1).

I think long-term, I want to make `uvx <cmd>` layer the environment on
_top_ of the project environment (in uv), which would obviate the need
for this change when you're using uv. However, that change is breaking
and I think users will expect this behavior in contexts where they're
not using uv, so I think we should handle it in ty regardless.

I've opted not to include the environment if it's non-virtual (i.e., a
system environment) for now. It seems better to start by being more
restrictive. I left a comment in the code.

## Test Plan

I did some manual testing with the initial commit, then subsequently
added some unit tests.

```
❯ echo "import httpx" > example.py
❯ uvx --with httpx ty check example.py
Installed 8 packages in 19ms
error[unresolved-import]: Cannot resolve imported module `httpx`
 --> foo/example.py:1:8
  |
1 | import httpx
  |        ^^^^^
  |
info: Searched in the following paths during module resolution:
info:   1. /Users/zb/workspace/ty/python (first-party code)
info:   2. /Users/zb/workspace/ty (first-party code)
info:   3. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default

Found 1 diagnostic
❯ uvx --from . --with httpx ty check example.py
All checks passed!
```

```
❯ uv init --script foo.py
Initialized script at `foo.py`
❯ uv add --script foo.py httpx
warning: The Python request from `.python-version` resolved to Python 3.13.8, which is incompatible with the script's Python requirement: `>=3.14`
Updated `foo.py`
❯ echo "import httpx" >> foo.py
❯ uvx --with-requirements foo.py ty check foo.py
error[unresolved-import]: Cannot resolve imported module `httpx`
  --> foo.py:15:8
   |
13 | if __name__ == "__main__":
14 |     main()
15 | import httpx
   |        ^^^^^
   |
info: Searched in the following paths during module resolution:
info:   1. /Users/zb/workspace/ty/python (first-party code)
info:   2. /Users/zb/workspace/ty (first-party code)
info:   3. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default

Found 1 diagnostic
❯ uvx --from . --with-requirements foo.py ty check foo.py
All checks passed!
```

Notice we do not include ty's environment if `VIRTUAL_ENV` is set

```
❯ VIRTUAL_ENV=.venv uvx --with httpx ty check foo/example.py
error[unresolved-import]: Cannot resolve imported module `httpx`
 --> foo/example.py:1:8
  |
1 | import httpx
  |        ^^^^^
  |
info: Searched in the following paths during module resolution:
info:   1. /Users/zb/workspace/ty/python (first-party code)
info:   2. /Users/zb/workspace/ty (first-party code)
info:   3. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info:   4. /Users/zb/workspace/ty/.venv/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default

Found 1 diagnostic
```
This commit is contained in:
Zanie Blue
2025-11-06 08:27:49 -06:00
committed by GitHub
parent f189aad6d2
commit 132d10fb6f
4 changed files with 417 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
use insta_cmd::assert_cmd_snapshot;
use ruff_python_ast::PythonVersion;
use crate::CliTest;
use crate::{CliTest, site_packages_filter};
/// Specifying an option on the CLI should take precedence over the same setting in the
/// project's configuration. Here, this is tested for the Python version.
@@ -1654,6 +1654,278 @@ home = ./
Ok(())
}
/// ty should include site packages from its own environment when no other environment is found.
#[test]
fn ty_environment_is_only_environment() -> anyhow::Result<()> {
let ty_venv_site_packages = if cfg!(windows) {
"ty-venv/Lib/site-packages"
} else {
"ty-venv/lib/python3.13/site-packages"
};
let ty_executable_path = if cfg!(windows) {
"ty-venv/Scripts/ty.exe"
} else {
"ty-venv/bin/ty"
};
let ty_package_path = format!("{ty_venv_site_packages}/ty_package/__init__.py");
let case = CliTest::with_files([
(ty_package_path.as_str(), "class TyEnvClass: ..."),
(
"ty-venv/pyvenv.cfg",
r"
home = ./
version = 3.13
",
),
(
"test.py",
r"
from ty_package import TyEnvClass
",
),
])?;
let case = case.with_ty_at(ty_executable_path)?;
assert_cmd_snapshot!(case.command(), @r###"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
"###);
Ok(())
}
/// ty should include site packages from both its own environment and a local `.venv`. The packages
/// from ty's environment should take precedence.
#[test]
fn ty_environment_and_discovered_venv() -> anyhow::Result<()> {
let ty_venv_site_packages = if cfg!(windows) {
"ty-venv/Lib/site-packages"
} else {
"ty-venv/lib/python3.13/site-packages"
};
let ty_executable_path = if cfg!(windows) {
"ty-venv/Scripts/ty.exe"
} else {
"ty-venv/bin/ty"
};
let local_venv_site_packages = if cfg!(windows) {
".venv/Lib/site-packages"
} else {
".venv/lib/python3.13/site-packages"
};
let ty_unique_package = format!("{ty_venv_site_packages}/ty_package/__init__.py");
let local_unique_package = format!("{local_venv_site_packages}/local_package/__init__.py");
let ty_conflicting_package = format!("{ty_venv_site_packages}/shared_package/__init__.py");
let local_conflicting_package =
format!("{local_venv_site_packages}/shared_package/__init__.py");
let case = CliTest::with_files([
(ty_unique_package.as_str(), "class TyEnvClass: ..."),
(local_unique_package.as_str(), "class LocalClass: ..."),
(ty_conflicting_package.as_str(), "class FromTyEnv: ..."),
(
local_conflicting_package.as_str(),
"class FromLocalVenv: ...",
),
(
"ty-venv/pyvenv.cfg",
r"
home = ./
version = 3.13
",
),
(
".venv/pyvenv.cfg",
r"
home = ./
version = 3.13
",
),
(
"test.py",
r"
# Should resolve from ty's environment
from ty_package import TyEnvClass
# Should resolve from local .venv
from local_package import LocalClass
# Should resolve from ty's environment (takes precedence)
from shared_package import FromTyEnv
# Should NOT resolve (shadowed by ty's environment version)
from shared_package import FromLocalVenv
",
),
])?
.with_ty_at(ty_executable_path)?;
assert_cmd_snapshot!(case.command(), @r###"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Module `shared_package` has no member `FromLocalVenv`
--> test.py:9:28
|
7 | from shared_package import FromTyEnv
8 | # Should NOT resolve (shadowed by ty's environment version)
9 | from shared_package import FromLocalVenv
| ^^^^^^^^^^^^^
|
info: rule `unresolved-import` is enabled by default
Found 1 diagnostic
----- stderr -----
"###);
Ok(())
}
/// When `VIRTUAL_ENV` is set, ty should *not* discover its own environment's site-packages.
#[test]
fn ty_environment_and_active_environment() -> anyhow::Result<()> {
let ty_venv_site_packages = if cfg!(windows) {
"ty-venv/Lib/site-packages"
} else {
"ty-venv/lib/python3.13/site-packages"
};
let ty_executable_path = if cfg!(windows) {
"ty-venv/Scripts/ty.exe"
} else {
"ty-venv/bin/ty"
};
let active_venv_site_packages = if cfg!(windows) {
"active-venv/Lib/site-packages"
} else {
"active-venv/lib/python3.13/site-packages"
};
let ty_package_path = format!("{ty_venv_site_packages}/ty_package/__init__.py");
let active_package_path = format!("{active_venv_site_packages}/active_package/__init__.py");
let case = CliTest::with_files([
(ty_package_path.as_str(), "class TyEnvClass: ..."),
(
"ty-venv/pyvenv.cfg",
r"
home = ./
version = 3.13
",
),
(active_package_path.as_str(), "class ActiveClass: ..."),
(
"active-venv/pyvenv.cfg",
r"
home = ./
version = 3.13
",
),
(
"test.py",
r"
from ty_package import TyEnvClass
from active_package import ActiveClass
",
),
])?
.with_ty_at(ty_executable_path)?
.with_filter(&site_packages_filter("3.13"), "<site-packages>");
assert_cmd_snapshot!(
case.command()
.env("VIRTUAL_ENV", case.root().join("active-venv")),
@r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `ty_package`
--> test.py:2:6
|
2 | from ty_package import TyEnvClass
| ^^^^^^^^^^
3 | from active_package import ActiveClass
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/ (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/active-venv/<site-packages> (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 1 diagnostic
----- stderr -----
"
);
Ok(())
}
/// When ty is installed in a system environment rather than a virtual environment, it should
/// not include the environment's site-packages in its search path.
#[test]
fn ty_environment_is_system_not_virtual() -> anyhow::Result<()> {
let ty_system_site_packages = if cfg!(windows) {
"system-python/Lib/site-packages"
} else {
"system-python/lib/python3.13/site-packages"
};
let ty_executable_path = if cfg!(windows) {
"system-python/Scripts/ty.exe"
} else {
"system-python/bin/ty"
};
let ty_package_path = format!("{ty_system_site_packages}/system_package/__init__.py");
let case = CliTest::with_files([
// Package in system Python installation (should NOT be discovered)
(ty_package_path.as_str(), "class SystemClass: ..."),
// Note: NO pyvenv.cfg - this is a system installation, not a venv
(
"test.py",
r"
from system_package import SystemClass
",
),
])?
.with_ty_at(ty_executable_path)?;
assert_cmd_snapshot!(case.command(), @r###"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `system_package`
--> test.py:2:6
|
2 | from system_package import SystemClass
| ^^^^^^^^^^^^^^
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/ (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 1 diagnostic
----- stderr -----
"###);
Ok(())
}
#[test]
fn src_root_deprecation_warning() -> anyhow::Result<()> {
let case = CliTest::with_files([