[ty] Discover site-packages from the environment that ty is installed in (#21286)
Some checks are pending
CI / cargo clippy (push) Blocked by required conditions
CI / ty completion evaluation (push) Blocked by required conditions
CI / benchmarks walltime (small|large) (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (${{ github.repository == 'astral-sh/ruff' && 'depot-windows-2022-16' || 'windows-latest' }}) (push) Blocked by required conditions
CI / cargo test (macos-latest) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / test ruff-lsp (push) Blocked by required conditions
CI / benchmarks walltime (medium|multithreaded) (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

<!--
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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 417 additions and 18 deletions

View file

@ -62,6 +62,15 @@ impl SitePackagesPaths {
self.0.extend(other.0);
}
/// Concatenate two instances of [`SitePackagesPaths`].
#[must_use]
pub fn concatenate(mut self, other: Self) -> Self {
for path in other {
self.0.insert(path);
}
self
}
/// Tries to detect the version from the layout of the `site-packages` directory.
pub fn python_version_from_layout(&self) -> Option<PythonVersionWithSource> {
if cfg!(windows) {
@ -252,6 +261,13 @@ impl PythonEnvironment {
Self::System(env) => env.real_stdlib_directory(system),
}
}
pub fn origin(&self) -> &SysPrefixPathOrigin {
match self {
Self::Virtual(env) => &env.root_path.origin,
Self::System(env) => &env.root_path.origin,
}
}
}
/// Enumeration of the subdirectories of `sys.prefix` that could contain a
@ -1393,15 +1409,15 @@ impl SysPrefixPath {
) -> SitePackagesDiscoveryResult<Self> {
let sys_prefix = if !origin.must_point_directly_to_sys_prefix()
&& system.is_file(unvalidated_path)
&& unvalidated_path
.file_name()
.is_some_and(|name| name.starts_with("python"))
{
// It looks like they passed us a path to a Python executable, e.g. `.venv/bin/python3`.
// Try to figure out the `sys.prefix` value from the Python executable.
&& unvalidated_path.file_name().is_some_and(|name| {
name.starts_with("python")
|| name.eq_ignore_ascii_case(&format!("ty{}", std::env::consts::EXE_SUFFIX))
}) {
// It looks like they passed us a path to an executable, e.g. `.venv/bin/python3`. Try
// to figure out the `sys.prefix` value from the Python executable.
let sys_prefix = if cfg!(windows) {
// On Windows, the relative path to the Python executable from `sys.prefix`
// is different depending on whether it's a virtual environment or a system installation.
// On Windows, the relative path to the executable from `sys.prefix` is different
// depending on whether it's a virtual environment or a system installation.
// System installations have their executable at `<sys.prefix>/python.exe`,
// whereas virtual environments have their executable at `<sys.prefix>/Scripts/python.exe`.
unvalidated_path.parent().and_then(|parent| {
@ -1586,6 +1602,8 @@ pub enum SysPrefixPathOrigin {
/// A `.venv` directory was found in the current working directory,
/// and the `sys.prefix` path is the path to that virtual environment.
LocalVenv,
/// The `sys.prefix` path came from the environment ty is installed in.
SelfEnvironment,
}
impl SysPrefixPathOrigin {
@ -1599,6 +1617,13 @@ impl SysPrefixPathOrigin {
| Self::Editor
| Self::DerivedFromPyvenvCfg
| Self::CondaPrefixVar => false,
// It's not strictly true that the self environment must be virtual, e.g., ty could be
// installed in a system Python environment and users may expect us to respect
// dependencies installed alongside it. However, we're intentionally excluding support
// for this to start. Note a change here has downstream implications, i.e., we probably
// don't want the packages in a system environment to take precedence over those in a
// virtual environment and would need to reverse the ordering in that case.
Self::SelfEnvironment => true,
}
}
@ -1608,13 +1633,31 @@ impl SysPrefixPathOrigin {
/// the `sys.prefix` directory, e.g. the `--python` CLI flag.
pub(crate) const fn must_point_directly_to_sys_prefix(&self) -> bool {
match self {
Self::PythonCliFlag | Self::ConfigFileSetting(..) | Self::Editor => false,
Self::PythonCliFlag
| Self::ConfigFileSetting(..)
| Self::Editor
| Self::SelfEnvironment => false,
Self::VirtualEnvVar
| Self::CondaPrefixVar
| Self::DerivedFromPyvenvCfg
| Self::LocalVenv => true,
}
}
/// Whether paths with this origin should allow combination with paths with a
/// [`SysPrefixPathOrigin::SelfEnvironment`] origin.
pub const fn allows_concatenation_with_self_environment(&self) -> bool {
match self {
Self::SelfEnvironment
| Self::CondaPrefixVar
| Self::VirtualEnvVar
| Self::Editor
| Self::DerivedFromPyvenvCfg
| Self::ConfigFileSetting(..)
| Self::PythonCliFlag => false,
Self::LocalVenv => true,
}
}
}
impl std::fmt::Display for SysPrefixPathOrigin {
@ -1627,6 +1670,7 @@ impl std::fmt::Display for SysPrefixPathOrigin {
Self::DerivedFromPyvenvCfg => f.write_str("derived `sys.prefix` path"),
Self::LocalVenv => f.write_str("local virtual environment"),
Self::Editor => f.write_str("selected interpreter in your editor"),
Self::SelfEnvironment => f.write_str("ty environment"),
}
}
}