Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ruff_python_ast/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
86 changes: 53 additions & 33 deletions crates/ruff_python_ast/src/python_version.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::fmt;
use std::{fmt, str::FromStr};

/// Representation of a Python version.
///
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/ty/docs/cli.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions crates/ty/docs/configuration.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions crates/ty/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,

Expand Down
104 changes: 104 additions & 0 deletions crates/ty/tests/cli/python_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
4 changes: 2 additions & 2 deletions crates/ty_project/src/metadata/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 56 additions & 9 deletions crates/ty_python_semantic/src/module_resolver/resolver.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -304,7 +309,7 @@ impl SearchPaths {
static_paths,
site_packages,
typeshed_versions,
python_version,
python_version_from_pyvenv_cfg: python_version,
})
}

Expand All @@ -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 }))
}
}

Expand All @@ -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();
Expand Down
Loading
Loading