[ty] Infer the Python version from --python=<system installation> on Unix (#18550)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
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 / python package (push) Waiting to run
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 / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

This commit is contained in:
Alex Waygood 2025-06-11 15:32:33 +01:00 committed by GitHub
parent a863000cbc
commit e84406d8be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 334 additions and 137 deletions

1
Cargo.lock generated
View file

@ -2857,6 +2857,7 @@ dependencies = [
"salsa",
"schemars",
"serde",
"thiserror 2.0.12",
]
[[package]]

View file

@ -29,6 +29,7 @@ rustc-hash = { workspace = true }
salsa = { workspace = true, optional = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
thiserror = { workspace = true }
[features]
schemars = ["dep:schemars"]

View file

@ -1,4 +1,4 @@
use std::fmt;
use std::{fmt, str::FromStr};
/// Representation of a Python version.
///
@ -97,18 +97,6 @@ impl Default for PythonVersion {
}
}
impl TryFrom<(&str, &str)> for PythonVersion {
type Error = std::num::ParseIntError;
fn try_from(value: (&str, &str)) -> Result<Self, Self::Error> {
let (major, minor) = value;
Ok(Self {
major: major.parse()?,
minor: minor.parse()?,
})
}
}
impl From<(u8, u8)> for PythonVersion {
fn from(value: (u8, u8)) -> Self {
let (major, minor) = value;
@ -123,6 +111,55 @@ impl fmt::Display for PythonVersion {
}
}
#[derive(thiserror::Error, Debug, PartialEq, Eq, Clone)]
pub enum PythonVersionDeserializationError {
#[error("Invalid python version `{0}`: expected `major.minor`")]
WrongPeriodNumber(Box<str>),
#[error("Invalid major version `{0}`: {1}")]
InvalidMajorVersion(Box<str>, #[source] std::num::ParseIntError),
#[error("Invalid minor version `{0}`: {1}")]
InvalidMinorVersion(Box<str>, #[source] std::num::ParseIntError),
}
impl TryFrom<(&str, &str)> for PythonVersion {
type Error = PythonVersionDeserializationError;
fn try_from(value: (&str, &str)) -> Result<Self, Self::Error> {
let (major, minor) = value;
Ok(Self {
major: major.parse().map_err(|err| {
PythonVersionDeserializationError::InvalidMajorVersion(Box::from(major), err)
})?,
minor: minor.parse().map_err(|err| {
PythonVersionDeserializationError::InvalidMinorVersion(Box::from(minor), err)
})?,
})
}
}
impl FromStr for PythonVersion {
type Err = PythonVersionDeserializationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (major, minor) = s
.split_once('.')
.ok_or_else(|| PythonVersionDeserializationError::WrongPeriodNumber(Box::from(s)))?;
Self::try_from((major, minor)).map_err(|err| {
// Give a better error message for something like `3.8.5` or `3..8`
if matches!(
err,
PythonVersionDeserializationError::InvalidMinorVersion(_, _)
) && minor.contains('.')
{
PythonVersionDeserializationError::WrongPeriodNumber(Box::from(s))
} else {
err
}
})
}
}
#[cfg(feature = "serde")]
mod serde {
use super::PythonVersion;
@ -132,26 +169,9 @@ mod serde {
where
D: serde::Deserializer<'de>,
{
let as_str = String::deserialize(deserializer)?;
if let Some((major, minor)) = as_str.split_once('.') {
let major = major.parse().map_err(|err| {
serde::de::Error::custom(format!("invalid major version: {err}"))
})?;
let minor = minor.parse().map_err(|err| {
serde::de::Error::custom(format!("invalid minor version: {err}"))
})?;
Ok((major, minor).into())
} else {
let major = as_str.parse().map_err(|err| {
serde::de::Error::custom(format!(
"invalid python-version: {err}, expected: `major.minor`"
))
})?;
Ok((major, 0).into())
}
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}

2
crates/ty/docs/cli.md generated
View file

@ -72,7 +72,7 @@ over all configuration files.</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>
<p>The Python version affects allowed syntax, type definitions of the standard library, and type definitions of first- and third-party modules that are conditional on the Python version.</p>
<p>By default, the Python version is inferred as the lower bound of the project's <code>requires-python</code> field from the <code>pyproject.toml</code>, if available. Otherwise, if a virtual environment has been configured or detected and a Python version can be inferred from the virtual environment's metadata, that version will be used. If neither of these applies, ty will fall back to the latest stable Python version supported by ty (currently 3.13).</p>
<p>If a version is not specified on the command line or in a configuration file, ty will try the following techniques in order of preference to determine a value: 1. Check for the <code>project.requires-python</code> setting in a <code>pyproject.toml</code> file and use the minimum version from the specified range 2. Check for an activated or configured Python environment and attempt to infer the Python version of that environment 3. Fall back to the latest stable Python version supported by ty (currently Python 3.13)</p>
<p>Possible values:</p>
<ul>
<li><code>3.7</code></li>

View file

@ -111,8 +111,8 @@ If a version is not specified, ty will try the following techniques in order of
to determine a value:
1. Check for the `project.requires-python` setting in a `pyproject.toml` file
and use the minimum version from the specified range
2. Check for an activated or configured virtual environment
and use the Python version of that environment
2. Check for an activated or configured Python environment
and attempt to infer the Python version of that environment
3. Fall back to the default value (see below)
For some language features, ty can also understand conditionals based on comparisons

View file

@ -82,11 +82,13 @@ pub(crate) struct CheckCommand {
/// The Python version affects allowed syntax, type definitions of the standard library, and
/// type definitions of first- and third-party modules that are conditional on the Python version.
///
/// By default, the Python version is inferred as the lower bound of the project's
/// `requires-python` field from the `pyproject.toml`, if available. Otherwise, if a virtual
/// environment has been configured or detected and a Python version can be inferred from the
/// virtual environment's metadata, that version will be used. If neither of these applies, ty
/// will fall back to the latest stable Python version supported by ty (currently 3.13).
/// If a version is not specified on the command line or in a configuration file,
/// ty will try the following techniques in order of preference to determine a value:
/// 1. Check for the `project.requires-python` setting in a `pyproject.toml` file
/// and use the minimum version from the specified range
/// 2. Check for an activated or configured Python environment
/// and attempt to infer the Python version of that environment
/// 3. Fall back to the latest stable Python version supported by ty (currently Python 3.13)
#[arg(long, value_name = "VERSION", alias = "target-version")]
pub(crate) python_version: Option<PythonVersion>,

View file

@ -188,6 +188,110 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any
Ok(())
}
/// This tests that, even if no Python *version* has been specified on the CLI or in a config file,
/// ty is still able to infer the Python version from a `--python` argument on the CLI,
/// *even if* the `--python` argument points to a system installation.
///
/// We currently cannot infer the Python version from a system installation on Windows:
/// on Windows, we can only infer the Python version from a virtual environment.
/// This is because we use the layout of the Python installation to infer the Python version:
/// on Unix, the `site-packages` directory of an installation will be located at
/// `<sys.prefix>/lib/pythonX.Y/site-packages`. On Windows, however, the `site-packages`
/// directory will be located at `<sys.prefix>/Lib/site-packages`, which doesn't give us the
/// same information.
#[cfg(not(windows))]
#[test]
fn python_version_inferred_from_system_installation() -> anyhow::Result<()> {
let cpython_case = CliTest::with_files([
("pythons/Python3.8/bin/python", ""),
("pythons/Python3.8/lib/python3.8/site-packages/foo.py", ""),
("test.py", "aiter"),
])?;
assert_cmd_snapshot!(cpython_case.command().arg("--python").arg("pythons/Python3.8/bin/python"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `aiter` used when not defined
--> test.py:1:1
|
1 | aiter
| ^^^^^
|
info: `aiter` was added as a builtin in Python 3.10
info: Python 3.8 was assumed when resolving types because of the layout of your Python installation
info: The primary `site-packages` directory of your installation was found at `lib/python3.8/site-packages/`
info: No Python version was specified on the command line or in a configuration file
info: rule `unresolved-reference` is enabled by default
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
let pypy_case = CliTest::with_files([
("pythons/pypy3.8/bin/python", ""),
("pythons/pypy3.8/lib/pypy3.8/site-packages/foo.py", ""),
("test.py", "aiter"),
])?;
assert_cmd_snapshot!(pypy_case.command().arg("--python").arg("pythons/pypy3.8/bin/python"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `aiter` used when not defined
--> test.py:1:1
|
1 | aiter
| ^^^^^
|
info: `aiter` was added as a builtin in Python 3.10
info: Python 3.8 was assumed when resolving types because of the layout of your Python installation
info: The primary `site-packages` directory of your installation was found at `lib/pypy3.8/site-packages/`
info: No Python version was specified on the command line or in a configuration file
info: rule `unresolved-reference` is enabled by default
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
let free_threaded_case = CliTest::with_files([
("pythons/Python3.13t/bin/python", ""),
(
"pythons/Python3.13t/lib/python3.13t/site-packages/foo.py",
"",
),
("test.py", "import string.templatelib"),
])?;
assert_cmd_snapshot!(free_threaded_case.command().arg("--python").arg("pythons/Python3.13t/bin/python"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `string.templatelib`
--> test.py:1:8
|
1 | import string.templatelib
| ^^^^^^^^^^^^^^^^^^
|
info: The stdlib module `string.templatelib` is only available on Python 3.14+
info: Python 3.13 was assumed when resolving modules because of the layout of your Python installation
info: The primary `site-packages` directory of your installation was found at `lib/python3.13t/site-packages/`
info: No Python version was specified on the command line or in a configuration file
info: rule `unresolved-import` is enabled by default
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
#[test]
fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Result<()> {
let case = CliTest::with_files([

View file

@ -322,8 +322,8 @@ pub struct EnvironmentOptions {
/// to determine a value:
/// 1. Check for the `project.requires-python` setting in a `pyproject.toml` file
/// and use the minimum version from the specified range
/// 2. Check for an activated or configured virtual environment
/// and use the Python version of that environment
/// 2. Check for an activated or configured Python environment
/// and attempt to infer the Python version of that environment
/// 3. Fall back to the default value (see below)
///
/// For some language features, ty can also understand conditionals based on comparisons

View file

@ -1,8 +1,9 @@
use std::borrow::Cow;
use std::fmt;
use std::iter::FusedIterator;
use std::str::Split;
use std::str::{FromStr, Split};
use camino::Utf8Component;
use compact_str::format_compact;
use rustc_hash::{FxBuildHasher, FxHashSet};
@ -15,7 +16,9 @@ use crate::db::Db;
use crate::module_name::ModuleName;
use crate::module_resolver::typeshed::{TypeshedVersions, vendored_typeshed_versions};
use crate::site_packages::{PythonEnvironment, SitePackagesPaths, SysPrefixPathOrigin};
use crate::{Program, PythonPath, PythonVersionWithSource, SearchPathSettings};
use crate::{
Program, PythonPath, PythonVersionSource, PythonVersionWithSource, SearchPathSettings,
};
use super::module::{Module, ModuleKind};
use super::path::{ModulePath, SearchPath, SearchPathValidationError};
@ -155,10 +158,12 @@ pub struct SearchPaths {
typeshed_versions: TypeshedVersions,
/// The Python version for the search paths, if any.
/// The Python version implied by the virtual environment.
///
/// This is read from the `pyvenv.cfg` if present.
python_version: Option<PythonVersionWithSource>,
/// If this environment was a system installation or the `pyvenv.cfg` file
/// of the virtual environment did not contain a `version` or `version_info` key,
/// this field will be `None`.
python_version_from_pyvenv_cfg: Option<PythonVersionWithSource>,
}
impl SearchPaths {
@ -304,7 +309,7 @@ impl SearchPaths {
static_paths,
site_packages,
typeshed_versions,
python_version,
python_version_from_pyvenv_cfg: python_version,
})
}
@ -330,8 +335,50 @@ impl SearchPaths {
&self.typeshed_versions
}
pub fn python_version(&self) -> Option<&PythonVersionWithSource> {
self.python_version.as_ref()
pub fn try_resolve_installation_python_version(&self) -> Option<Cow<PythonVersionWithSource>> {
if let Some(version) = self.python_version_from_pyvenv_cfg.as_ref() {
return Some(Cow::Borrowed(version));
}
if cfg!(windows) {
// The path to `site-packages` on Unix is
// `<sys.prefix>/lib/pythonX.Y/site-packages`,
// but on Windows it's `<sys.prefix>/Lib/site-packages`.
return None;
}
let primary_site_packages = self.site_packages.first()?.as_system_path()?;
let mut site_packages_ancestor_components =
primary_site_packages.components().rev().skip(1).map(|c| {
// This should have all been validated in `site_packages.rs`
// when we resolved the search paths for the project.
debug_assert!(
matches!(c, Utf8Component::Normal(_)),
"Unexpected component in site-packages path `{c:?}` \
(expected `site-packages` to be an absolute path with symlinks resolved, \
located at `<sys.prefix>/lib/pythonX.Y/site-packages`)"
);
c.as_str()
});
let parent_component = site_packages_ancestor_components.next()?;
if site_packages_ancestor_components.next()? != "lib" {
return None;
}
let version = parent_component
.strip_prefix("python")
.or_else(|| parent_component.strip_prefix("pypy"))?
.trim_end_matches('t');
let version = PythonVersion::from_str(version).ok()?;
let source = PythonVersionSource::InstallationDirectoryLayout {
site_packages_parent_dir: Box::from(parent_component),
};
Some(Cow::Owned(PythonVersionWithSource { version, source }))
}
}
@ -351,7 +398,7 @@ pub(crate) fn dynamic_resolution_paths(db: &dyn Db) -> Vec<SearchPath> {
static_paths,
site_packages,
typeshed_versions: _,
python_version: _,
python_version_from_pyvenv_cfg: _,
} = Program::get(db).search_paths(db);
let mut dynamic_paths = Vec::new();

View file

@ -4,7 +4,7 @@ use std::num::{NonZeroU16, NonZeroUsize};
use std::ops::{RangeFrom, RangeInclusive};
use std::str::FromStr;
use ruff_python_ast::PythonVersion;
use ruff_python_ast::{PythonVersion, PythonVersionDeserializationError};
use rustc_hash::FxHashMap;
use crate::Program;
@ -49,55 +49,26 @@ impl fmt::Display for TypeshedVersionsParseError {
impl std::error::Error for TypeshedVersionsParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
if let TypeshedVersionsParseErrorKind::IntegerParsingFailure { err, .. } = &self.reason {
Some(err)
if let TypeshedVersionsParseErrorKind::VersionParseError(err) = &self.reason {
err.source()
} else {
None
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]
pub(crate) enum TypeshedVersionsParseErrorKind {
#[error("File has too many lines ({0}); maximum allowed is {max_allowed}", max_allowed = NonZeroU16::MAX)]
TooManyLines(NonZeroUsize),
#[error("Expected every non-comment line to have exactly one colon")]
UnexpectedNumberOfColons,
#[error("Expected all components of '{0}' to be valid Python identifiers")]
InvalidModuleName(String),
#[error("Expected every non-comment line to have exactly one '-' character")]
UnexpectedNumberOfHyphens,
UnexpectedNumberOfPeriods(String),
IntegerParsingFailure {
version: String,
err: std::num::ParseIntError,
},
}
impl fmt::Display for TypeshedVersionsParseErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TooManyLines(num_lines) => write!(
f,
"File has too many lines ({num_lines}); maximum allowed is {}",
NonZeroU16::MAX
),
Self::UnexpectedNumberOfColons => {
f.write_str("Expected every non-comment line to have exactly one colon")
}
Self::InvalidModuleName(name) => write!(
f,
"Expected all components of '{name}' to be valid Python identifiers"
),
Self::UnexpectedNumberOfHyphens => {
f.write_str("Expected every non-comment line to have exactly one '-' character")
}
Self::UnexpectedNumberOfPeriods(format) => write!(
f,
"Expected all versions to be in the form {{MAJOR}}.{{MINOR}}; got '{format}'"
),
Self::IntegerParsingFailure { version, err } => write!(
f,
"Failed to convert '{version}' to a pair of integers due to {err}",
),
}
}
#[error("{0}")]
VersionParseError(#[from] PythonVersionDeserializationError),
}
#[derive(Debug, PartialEq, Eq)]
@ -304,12 +275,12 @@ impl FromStr for PyVersionRange {
let mut parts = s.split('-').map(str::trim);
match (parts.next(), parts.next(), parts.next()) {
(Some(lower), Some(""), None) => {
let lower = python_version_from_versions_file_string(lower)?;
let lower = PythonVersion::from_str(lower)?;
Ok(Self::AvailableFrom(lower..))
}
(Some(lower), Some(upper), None) => {
let lower = python_version_from_versions_file_string(lower)?;
let upper = python_version_from_versions_file_string(upper)?;
let lower = PythonVersion::from_str(lower)?;
let upper = PythonVersion::from_str(upper)?;
Ok(Self::AvailableWithin(lower..=upper))
}
_ => Err(TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens),
@ -328,23 +299,6 @@ impl fmt::Display for PyVersionRange {
}
}
fn python_version_from_versions_file_string(
s: &str,
) -> Result<PythonVersion, TypeshedVersionsParseErrorKind> {
let mut parts = s.split('.').map(str::trim);
let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
return Err(TypeshedVersionsParseErrorKind::UnexpectedNumberOfPeriods(
s.to_string(),
));
};
PythonVersion::try_from((major, minor)).map_err(|int_parse_error| {
TypeshedVersionsParseErrorKind::IntegerParsingFailure {
version: s.to_string(),
err: int_parse_error,
}
})
}
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
@ -681,15 +635,17 @@ foo: 3.8- # trailing comment
TypeshedVersions::from_str("foo: 38-"),
Err(TypeshedVersionsParseError {
line_number: ONE,
reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfPeriods("38".to_string())
reason: TypeshedVersionsParseErrorKind::VersionParseError(
PythonVersionDeserializationError::WrongPeriodNumber(Box::from("38"))
)
})
);
assert_eq!(
TypeshedVersions::from_str("foo: 3..8-"),
Err(TypeshedVersionsParseError {
line_number: ONE,
reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfPeriods(
"3..8".to_string()
reason: TypeshedVersionsParseErrorKind::VersionParseError(
PythonVersionDeserializationError::WrongPeriodNumber(Box::from("3..8"))
)
})
);
@ -697,8 +653,8 @@ foo: 3.8- # trailing comment
TypeshedVersions::from_str("foo: 3.8-3..11"),
Err(TypeshedVersionsParseError {
line_number: ONE,
reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfPeriods(
"3..11".to_string()
reason: TypeshedVersionsParseErrorKind::VersionParseError(
PythonVersionDeserializationError::WrongPeriodNumber(Box::from("3..11"))
)
})
);
@ -708,20 +664,30 @@ foo: 3.8- # trailing comment
fn invalid_typeshed_versions_non_digits() {
let err = TypeshedVersions::from_str("foo: 1.two-").unwrap_err();
assert_eq!(err.line_number, ONE);
let TypeshedVersionsParseErrorKind::IntegerParsingFailure { version, err } = err.reason
let TypeshedVersionsParseErrorKind::VersionParseError(
PythonVersionDeserializationError::InvalidMinorVersion(invalid_minor, parse_error),
) = err.reason
else {
panic!()
panic!(
"Expected an invalid-minor-version parse error, got `{}`",
err.reason
)
};
assert_eq!(version, "1.two".to_string());
assert_eq!(*err.kind(), IntErrorKind::InvalidDigit);
assert_eq!(&*invalid_minor, "two");
assert_eq!(*parse_error.kind(), IntErrorKind::InvalidDigit);
let err = TypeshedVersions::from_str("foo: 3.8-four.9").unwrap_err();
assert_eq!(err.line_number, ONE);
let TypeshedVersionsParseErrorKind::IntegerParsingFailure { version, err } = err.reason
let TypeshedVersionsParseErrorKind::VersionParseError(
PythonVersionDeserializationError::InvalidMajorVersion(invalid_major, parse_error),
) = err.reason
else {
panic!()
panic!(
"Expected an invalid-major-version parse error, got `{}`",
err.reason
)
};
assert_eq!(version, "four.9".to_string());
assert_eq!(*err.kind(), IntErrorKind::InvalidDigit);
assert_eq!(&*invalid_major, "four");
assert_eq!(*parse_error.kind(), IntErrorKind::InvalidDigit);
}
}

View file

@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::sync::Arc;
use crate::Db;
@ -38,7 +39,7 @@ impl Program {
.with_context(|| "Invalid search path settings")?;
let python_version_with_source =
Self::resolve_python_version(python_version_with_source, search_paths.python_version());
Self::resolve_python_version(python_version_with_source, &search_paths);
tracing::info!(
"Python version: Python {python_version}, platform: {python_platform}",
@ -58,10 +59,14 @@ impl Program {
fn resolve_python_version(
config_value: Option<PythonVersionWithSource>,
environment_value: Option<&PythonVersionWithSource>,
search_paths: &SearchPaths,
) -> PythonVersionWithSource {
config_value
.or_else(|| environment_value.cloned())
.or_else(|| {
search_paths
.try_resolve_installation_python_version()
.map(Cow::into_owned)
})
.unwrap_or_default()
}
@ -79,7 +84,7 @@ impl Program {
let search_paths = SearchPaths::from_settings(db, &search_paths)?;
let new_python_version =
Self::resolve_python_version(python_version_with_source, search_paths.python_version());
Self::resolve_python_version(python_version_with_source, &search_paths);
if self.search_paths(db) != &search_paths {
tracing::debug!("Updating search paths");
@ -112,8 +117,11 @@ impl Program {
let search_paths = SearchPaths::from_settings(db, search_path_settings)?;
let current_python_version = self.python_version_with_source(db);
let python_version_from_environment =
search_paths.python_version().cloned().unwrap_or_default();
let python_version_from_environment = search_paths
.try_resolve_installation_python_version()
.map(Cow::into_owned)
.unwrap_or_default();
if current_python_version != &python_version_from_environment
&& current_python_version.source.priority()
@ -153,6 +161,13 @@ pub enum PythonVersionSource {
/// The virtual environment might have been configured, activated or inferred.
PyvenvCfgFile(PythonVersionFileSource),
/// Value inferred from the layout of the Python installation.
///
/// This only ever applies on Unix. On Unix, the `site-packages` directory
/// will always be at `sys.prefix/lib/pythonX.Y/site-packages`,
/// so we can infer the Python version from the parent directory of `site-packages`.
InstallationDirectoryLayout { site_packages_parent_dir: Box<str> },
/// The value comes from a CLI argument, while it's left open if specified using a short argument,
/// long argument (`--extra-paths`) or `--config key=value`.
Cli,
@ -169,22 +184,26 @@ impl PythonVersionSource {
PythonVersionSource::PyvenvCfgFile(_) => PythonSourcePriority::PyvenvCfgFile,
PythonVersionSource::ConfigFile(_) => PythonSourcePriority::ConfigFile,
PythonVersionSource::Cli => PythonSourcePriority::Cli,
PythonVersionSource::InstallationDirectoryLayout { .. } => {
PythonSourcePriority::InstallationDirectoryLayout
}
}
}
}
/// The priority in which Python version sources are considered.
/// A higher value means a higher priority.
/// The lower down the variant appears in this enum, the higher its priority.
///
/// For example, if a Python version is specified in a pyproject.toml file
/// but *also* via a CLI argument, the CLI argument will take precedence.
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
#[cfg_attr(test, derive(strum_macros::EnumIter))]
enum PythonSourcePriority {
Default = 0,
PyvenvCfgFile = 1,
ConfigFile = 2,
Cli = 3,
Default,
InstallationDirectoryLayout,
PyvenvCfgFile,
ConfigFile,
Cli,
}
/// Information regarding the file and [`TextRange`] of the configuration
@ -312,7 +331,9 @@ mod tests {
match other {
PythonSourcePriority::Cli => assert!(other > priority, "{other:?}"),
PythonSourcePriority::ConfigFile => assert_eq!(priority, other),
PythonSourcePriority::PyvenvCfgFile | PythonSourcePriority::Default => {
PythonSourcePriority::PyvenvCfgFile
| PythonSourcePriority::Default
| PythonSourcePriority::InstallationDirectoryLayout => {
assert!(priority > other, "{other:?}");
}
}
@ -327,6 +348,24 @@ mod tests {
assert!(other > priority, "{other:?}");
}
PythonSourcePriority::PyvenvCfgFile => assert_eq!(priority, other),
PythonSourcePriority::Default
| PythonSourcePriority::InstallationDirectoryLayout => {
assert!(priority > other, "{other:?}");
}
}
}
}
PythonSourcePriority::InstallationDirectoryLayout => {
for other in PythonSourcePriority::iter() {
match other {
PythonSourcePriority::Cli
| PythonSourcePriority::ConfigFile
| PythonSourcePriority::PyvenvCfgFile => {
assert!(other > priority, "{other:?}");
}
PythonSourcePriority::InstallationDirectoryLayout => {
assert_eq!(priority, other);
}
PythonSourcePriority::Default => assert!(priority > other, "{other:?}"),
}
}

View file

@ -61,6 +61,23 @@ pub fn add_inferred_python_version_hint_to_diagnostic(
or in a configuration file",
);
}
crate::PythonVersionSource::InstallationDirectoryLayout {
site_packages_parent_dir,
} => {
// TODO: it would also be nice to tell them how we resolved this Python installation...
diagnostic.info(format_args!(
"Python {version} was assumed when {action} \
because of the layout of your Python installation"
));
diagnostic.info(format_args!(
"The primary `site-packages` directory of your installation was found \
at `lib/{site_packages_parent_dir}/site-packages/`"
));
diagnostic.info(
"No Python version was specified on the command line \
or in a configuration file",
);
}
crate::PythonVersionSource::Default => {
diagnostic.info(format_args!(
"Python {version} was assumed when {action} \

2
ty.schema.json generated
View file

@ -99,7 +99,7 @@
]
},
"python-version": {
"description": "Specifies the version of Python that will be used to analyze the source code. The version should be specified as a string in the format `M.m` where `M` is the major version and `m` is the minor (e.g. `\"3.0\"` or `\"3.6\"`). If a version is provided, ty will generate errors if the source code makes use of language features that are not supported in that version.\n\nIf a version is not specified, ty will try the following techniques in order of preference to determine a value: 1. Check for the `project.requires-python` setting in a `pyproject.toml` file and use the minimum version from the specified range 2. Check for an activated or configured virtual environment and use the Python version of that environment 3. Fall back to the default value (see below)\n\nFor some language features, ty can also understand conditionals based on comparisons with `sys.version_info`. These are commonly found in typeshed, for example, to reflect the differing contents of the standard library across Python versions.",
"description": "Specifies the version of Python that will be used to analyze the source code. The version should be specified as a string in the format `M.m` where `M` is the major version and `m` is the minor (e.g. `\"3.0\"` or `\"3.6\"`). If a version is provided, ty will generate errors if the source code makes use of language features that are not supported in that version.\n\nIf a version is not specified, ty will try the following techniques in order of preference to determine a value: 1. Check for the `project.requires-python` setting in a `pyproject.toml` file and use the minimum version from the specified range 2. Check for an activated or configured Python environment and attempt to infer the Python version of that environment 3. Fall back to the default value (see below)\n\nFor some language features, ty can also understand conditionals based on comparisons with `sys.version_info`. These are commonly found in typeshed, for example, to reflect the differing contents of the standard library across Python versions.",
"anyOf": [
{
"$ref": "#/definitions/PythonVersion"