Update --python to accept paths to executables in virtual environments (#17954)

## Summary

Updates the `--python` flag to accept Python executables in virtual
environments. Notably, we do not query the executable and it _must_ be
in a canonical location in a virtual environment. This is pretty naive,
but solves for the trivial case of `ty check --python .venv/bin/python3`
which will be a common mistake (and `ty check --python $(which python)`)

I explored this while trying to understand Python discovery in ty in
service of https://github.com/astral-sh/ty/issues/272, I'm not attached
to it, but figure it's worth sharing.

As an alternative, we can add more variants to the
`SearchPathValidationError` and just improve the _error_ message, i.e.,
by hinting that this looks like a virtual environment and suggesting the
concrete alternative path they should provide. We'll probably want to do
that for some other cases anyway (e.g., `3.13` as described in the
linked issue)

This functionality is also briefly mentioned in
https://github.com/astral-sh/ty/issues/193

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

## Test Plan

e.g.,

```
uv run ty check --python .venv/bin/python3
```

needs test coverage still
This commit is contained in:
Zanie Blue 2025-05-12 15:39:04 -05:00 committed by GitHub
parent d545b5bfd2
commit 6b64630635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 56 additions and 14 deletions

View file

@ -56,10 +56,11 @@ ty check [OPTIONS] [PATH]...
</ul></dd><dt id="ty-check--project"><a href="#ty-check--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code> files will be discovered by walking up the directory tree from the given project directory, as will the project's virtual environment (<code>.venv</code>) unless the <code>venv-path</code> option is set.</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>
</dd><dt id="ty-check--python"><a href="#ty-check--python"><code>--python</code></a> <i>path</i></dt><dd><p>Path to the Python installation from which ty resolves type information and third-party dependencies.</p>
<p>If not specified, ty will look at the <code>VIRTUAL_ENV</code> environment variable.</p>
<p>ty will search in the path's <code>site-packages</code> directories for type information and third-party imports.</p>
<p>This option is commonly used to specify the path to a virtual environment.</p>
</dd><dt id="ty-check--python"><a href="#ty-check--python"><code>--python</code></a> <i>path</i></dt><dd><p>Path to the Python environment.</p>
<p>ty uses the Python environment to resolve type information and third-party dependencies.</p>
<p>If not specified, ty will attempt to infer it from the <code>VIRTUAL_ENV</code> environment variable or discover a <code>.venv</code> directory in the project root or working directory.</p>
<p>If a path to a Python interpreter is provided, e.g., <code>.venv/bin/python3</code>, ty will attempt to find an environment two directories up from the interpreter's path, e.g., <code>.venv</code>. At this time, ty does not invoke the interpreter to determine the location of the environment. This means that ty will not resolve dynamic executables such as a shim.</p>
<p>ty will search in the resolved environments's <code>site-packages</code> directories for type information and third-party imports.</p>
</dd><dt id="ty-check--python-platform"><a href="#ty-check--python-platform"><code>--python-platform</code></a>, <code>--platform</code> <i>platform</i></dt><dd><p>Target platform to assume when resolving types.</p>
<p>This is used to specialize the type of <code>sys.platform</code> and will affect the visibility of platform-specific functions and attributes. If the value is set to <code>all</code>, no assumptions are made about the target platform. If unspecified, the current system's platform will be used.</p>
</dd><dt id="ty-check--python-version"><a href="#ty-check--python-version"><code>--python-version</code></a>, <code>--target-version</code> <i>version</i></dt><dd><p>Python version to assume when resolving types</p>

View file

@ -51,14 +51,20 @@ pub(crate) struct CheckCommand {
#[arg(long, value_name = "PROJECT")]
pub(crate) project: Option<SystemPathBuf>,
/// Path to the Python installation from which ty resolves type information and third-party dependencies.
/// Path to the Python environment.
///
/// If not specified, ty will look at the `VIRTUAL_ENV` environment variable.
/// ty uses the Python environment to resolve type information and third-party dependencies.
///
/// ty will search in the path's `site-packages` directories for type information and
/// third-party imports.
/// If not specified, ty will attempt to infer it from the `VIRTUAL_ENV` environment variable or
/// discover a `.venv` directory in the project root or working directory.
///
/// This option is commonly used to specify the path to a virtual environment.
/// If a path to a Python interpreter is provided, e.g., `.venv/bin/python3`, ty will attempt to
/// find an environment two directories up from the interpreter's path, e.g., `.venv`. At this
/// time, ty does not invoke the interpreter to determine the location of the environment. This
/// means that ty will not resolve dynamic executables such as a shim.
///
/// ty will search in the resolved environments's `site-packages` directories for type
/// information and third-party imports.
#[arg(long, value_name = "PATH")]
pub(crate) python: Option<SystemPathBuf>,

View file

@ -247,6 +247,33 @@ impl SearchPaths {
.and_then(|env| env.site_packages_directories(system))?
}
PythonPath::Resolve(target, origin) => {
tracing::debug!("Resolving {origin}: {target}");
let root = system
// If given a file, assume it's a Python executable, e.g., `.venv/bin/python3`,
// and search for a virtual environment in the root directory. Ideally, we'd
// invoke the target to determine `sys.prefix` here, but that's more complicated
// and may be deferred to uv.
.is_file(target)
.then(|| target.as_path())
.take_if(|target| {
// Avoid using the target if it doesn't look like a Python executable, e.g.,
// to deny cases like `.venv/bin/foo`
target
.file_name()
.is_some_and(|name| name.starts_with("python"))
})
.and_then(SystemPath::parent)
.and_then(SystemPath::parent)
// If not a file, use the path as given and allow let `PythonEnvironment::new`
// handle the error.
.unwrap_or(target);
PythonEnvironment::new(root, *origin, system)
.and_then(|venv| venv.site_packages_directories(system))?
}
PythonPath::Discover(root) => {
tracing::debug!("Discovering virtual environment in `{root}`");
let virtual_env_path = discover_venv_in(db.system(), root);

View file

@ -145,6 +145,9 @@ pub enum PythonPath {
/// [`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix
SysPrefix(SystemPathBuf, SysPrefixPathOrigin),
/// Resolve a path to an executable (or environment directory) into a usable environment.
Resolve(SystemPathBuf, SysPrefixPathOrigin),
/// Tries to discover a virtual environment in the given path.
Discover(SystemPathBuf),
@ -161,6 +164,6 @@ impl PythonPath {
}
pub fn from_cli_flag(path: SystemPathBuf) -> Self {
Self::SysPrefix(path, SysPrefixPathOrigin::PythonCliFlag)
Self::Resolve(path, SysPrefixPathOrigin::PythonCliFlag)
}
}

View file

@ -68,12 +68,17 @@ pub(crate) struct Environment {
/// Additional search paths to consider when resolving modules.
pub(crate) extra_paths: Option<Vec<SystemPathBuf>>,
/// Path to the Python installation from which ty resolves type information and third-party dependencies.
/// Path to the Python environment.
///
/// ty will search in the path's `site-packages` directories for type information and
/// third-party imports.
/// ty uses the Python environment to resolve type information and third-party dependencies.
///
/// This option is commonly used to specify the path to a virtual environment.
/// If a path to a Python interpreter is provided, e.g., `.venv/bin/python3`, ty will attempt to
/// find an environment two directories up from the interpreter's path, e.g., `.venv`. At this
/// time, ty does not invoke the interpreter to determine the location of the environment. This
/// means that ty will not resolve dynamic executables such as a shim.
///
/// ty will search in the resolved environment's `site-packages` directories for type
/// information and third-party imports.
#[serde(skip_serializing_if = "Option::is_none")]
pub python: Option<SystemPathBuf>,
}