Skip to content

Commit 14ea91b

Browse files
committed
[ty] Infer the Python version from --python=<system installation> on Unix
1 parent 86e5a31 commit 14ea91b

File tree

7 files changed

+221
-23
lines changed

7 files changed

+221
-23
lines changed

crates/ruff_python_ast/src/python_version.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::fmt;
1+
use std::{fmt, str::FromStr};
22

33
/// Representation of a Python version.
44
///
@@ -123,6 +123,15 @@ impl fmt::Display for PythonVersion {
123123
}
124124
}
125125

126+
impl FromStr for PythonVersion {
127+
type Err = ();
128+
129+
fn from_str(s: &str) -> Result<Self, Self::Err> {
130+
let (major, minor) = s.split_once('.').ok_or(())?;
131+
Self::try_from((major, minor)).map_err(|_| ())
132+
}
133+
}
134+
126135
#[cfg(feature = "serde")]
127136
mod serde {
128137
use super::PythonVersion;

crates/ty/docs/cli.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty/src/args.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ pub(crate) struct CheckCommand {
8282
/// type definitions of first- and third-party modules that are conditional on the Python version.
8383
///
8484
/// By default, the Python version is inferred as the lower bound of the project's
85-
/// `requires-python` field from the `pyproject.toml`, if available. Otherwise, if a virtual
86-
/// environment has been configured or detected and a Python version can be inferred from the
87-
/// virtual environment's metadata, that version will be used. If neither of these applies, ty
88-
/// will fall back to the latest stable Python version supported by ty (currently 3.13).
85+
/// `requires-python` field from the `pyproject.toml`, if available. Otherwise, if a Python
86+
/// installation has been configured or detected and a Python version can be inferred from the
87+
/// metadata or layout of that Python installation, that version will be used. If neither of
88+
/// these applies, ty will fall back to the latest stable Python version supported by ty
89+
/// (currently 3.13).
8990
#[arg(long, value_name = "VERSION", alias = "target-version")]
9091
pub(crate) python_version: Option<PythonVersion>,
9192

crates/ty/tests/cli/python_environment.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,102 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any
188188
Ok(())
189189
}
190190

191+
// We can't infer the Python version from the system installation on Windows,
192+
// because `site-packages` on Windows are located at `<sys.prefix>/Lib/site-packages`
193+
// rather than `<sys.prefix>/lib/pythonX.Y/site-packages`.
194+
#[cfg(not(windows))]
195+
#[test]
196+
fn python_version_inferred_from_system_installation() -> anyhow::Result<()> {
197+
let cpython_case = CliTest::with_files([
198+
("pythons/Python3.8/bin/python", ""),
199+
("pythons/Python3.8/lib/python3.8/site-packages/foo.py", ""),
200+
("test.py", "aiter"),
201+
])?;
202+
203+
assert_cmd_snapshot!(cpython_case.command().arg("--python").arg("pythons/Python3.8/bin/python"), @r"
204+
success: false
205+
exit_code: 1
206+
----- stdout -----
207+
error[unresolved-reference]: Name `aiter` used when not defined
208+
--> test.py:1:1
209+
|
210+
1 | aiter
211+
| ^^^^^
212+
|
213+
info: `aiter` was added as a builtin in Python 3.10
214+
info: Python 3.8 was assumed when resolving types because of the layout of your Python installation
215+
info: The primary `site-packages` directory of your installation was found at `lib/python3.8/site-packages/`
216+
info: No Python version was specified on the command line or in a configuration file
217+
info: rule `unresolved-reference` is enabled by default
218+
219+
Found 1 diagnostic
220+
221+
----- stderr -----
222+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
223+
");
224+
225+
let pypy_case = CliTest::with_files([
226+
("pythons/pypy3.8/bin/python", ""),
227+
("pythons/pypy3.8/lib/pypy3.8/site-packages/foo.py", ""),
228+
("test.py", "aiter"),
229+
])?;
230+
231+
assert_cmd_snapshot!(pypy_case.command().arg("--python").arg("pythons/pypy3.8/bin/python"), @r"
232+
success: false
233+
exit_code: 1
234+
----- stdout -----
235+
error[unresolved-reference]: Name `aiter` used when not defined
236+
--> test.py:1:1
237+
|
238+
1 | aiter
239+
| ^^^^^
240+
|
241+
info: `aiter` was added as a builtin in Python 3.10
242+
info: Python 3.8 was assumed when resolving types because of the layout of your Python installation
243+
info: The primary `site-packages` directory of your installation was found at `lib/pypy3.8/site-packages/`
244+
info: No Python version was specified on the command line or in a configuration file
245+
info: rule `unresolved-reference` is enabled by default
246+
247+
Found 1 diagnostic
248+
249+
----- stderr -----
250+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
251+
");
252+
253+
let free_threaded_case = CliTest::with_files([
254+
("pythons/Python3.13t/bin/python", ""),
255+
(
256+
"pythons/Python3.13t/lib/python3.13t/site-packages/foo.py",
257+
"",
258+
),
259+
("test.py", "import string.templatelib"),
260+
])?;
261+
262+
assert_cmd_snapshot!(free_threaded_case.command().arg("--python").arg("pythons/Python3.13t/bin/python"), @r"
263+
success: false
264+
exit_code: 1
265+
----- stdout -----
266+
error[unresolved-import]: Cannot resolve imported module `string.templatelib`
267+
--> test.py:1:8
268+
|
269+
1 | import string.templatelib
270+
| ^^^^^^^^^^^^^^^^^^
271+
|
272+
info: The stdlib module `string.templatelib` is only available on Python 3.14+
273+
info: Python 3.13 was assumed when resolving modules because of the layout of your Python installation
274+
info: The primary `site-packages` directory of your installation was found at `lib/python3.13t/site-packages/`
275+
info: No Python version was specified on the command line or in a configuration file
276+
info: rule `unresolved-import` is enabled by default
277+
278+
Found 1 diagnostic
279+
280+
----- stderr -----
281+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
282+
");
283+
284+
Ok(())
285+
}
286+
191287
#[test]
192288
fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Result<()> {
193289
let case = CliTest::with_files([

crates/ty_python_semantic/src/module_resolver/resolver.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::borrow::Cow;
22
use std::fmt;
33
use std::iter::FusedIterator;
4-
use std::str::Split;
4+
use std::str::{FromStr, Split};
55

66
use compact_str::format_compact;
77
use rustc_hash::{FxBuildHasher, FxHashSet};
@@ -15,7 +15,9 @@ use crate::db::Db;
1515
use crate::module_name::ModuleName;
1616
use crate::module_resolver::typeshed::{TypeshedVersions, vendored_typeshed_versions};
1717
use crate::site_packages::{PythonEnvironment, SitePackagesPaths, SysPrefixPathOrigin};
18-
use crate::{Program, PythonPath, PythonVersionWithSource, SearchPathSettings};
18+
use crate::{
19+
Program, PythonPath, PythonVersionSource, PythonVersionWithSource, SearchPathSettings,
20+
};
1921

2022
use super::module::{Module, ModuleKind};
2123
use super::path::{ModulePath, SearchPath, SearchPathValidationError};
@@ -158,7 +160,7 @@ pub struct SearchPaths {
158160
/// The Python version for the search paths, if any.
159161
///
160162
/// This is read from the `pyvenv.cfg` if present.
161-
python_version: Option<PythonVersionWithSource>,
163+
python_version_from_metadata: Option<PythonVersionWithSource>,
162164
}
163165

164166
impl SearchPaths {
@@ -304,7 +306,7 @@ impl SearchPaths {
304306
static_paths,
305307
site_packages,
306308
typeshed_versions,
307-
python_version,
309+
python_version_from_metadata: python_version,
308310
})
309311
}
310312

@@ -330,8 +332,42 @@ impl SearchPaths {
330332
&self.typeshed_versions
331333
}
332334

333-
pub fn python_version(&self) -> Option<&PythonVersionWithSource> {
334-
self.python_version.as_ref()
335+
pub fn python_version(&self) -> Option<Cow<PythonVersionWithSource>> {
336+
if let Some(version) = self.python_version_from_metadata.as_ref() {
337+
return Some(Cow::Borrowed(version));
338+
}
339+
340+
if cfg!(windows) {
341+
// The path to `site-packages` on Unix is
342+
// `<sys.prefix>/lib/pythonX.Y/site-packages`,
343+
// but on Windows it's `<sys.prefix>/Lib/site-packages`.
344+
return None;
345+
}
346+
347+
let primary_site_packages = self.site_packages.first()?.as_system_path()?;
348+
349+
let mut site_packages_ancestor_components = primary_site_packages
350+
.components()
351+
.rev()
352+
.skip(1)
353+
.map(|c| c.as_str());
354+
355+
let parent_component = site_packages_ancestor_components.next()?;
356+
357+
if site_packages_ancestor_components.next()? != "lib" {
358+
return None;
359+
}
360+
361+
let version = parent_component
362+
.strip_prefix("python")
363+
.or_else(|| parent_component.strip_prefix("pypy"))?
364+
.trim_end_matches('t');
365+
366+
let version = PythonVersion::from_str(version).ok()?;
367+
let source = PythonVersionSource::InstallationDirectoryLayout {
368+
site_packages_parent_dir: Box::from(parent_component),
369+
};
370+
Some(Cow::Owned(PythonVersionWithSource { version, source }))
335371
}
336372
}
337373

@@ -351,7 +387,7 @@ pub(crate) fn dynamic_resolution_paths(db: &dyn Db) -> Vec<SearchPath> {
351387
static_paths,
352388
site_packages,
353389
typeshed_versions: _,
354-
python_version: _,
390+
python_version_from_metadata: _,
355391
} = Program::get(db).search_paths(db);
356392

357393
let mut dynamic_paths = Vec::new();

crates/ty_python_semantic/src/program.rs

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
use std::sync::Arc;
23

34
use crate::Db;
@@ -37,8 +38,10 @@ impl Program {
3738
let search_paths = SearchPaths::from_settings(db, &search_paths)
3839
.with_context(|| "Invalid search path settings")?;
3940

40-
let python_version_with_source =
41-
Self::resolve_python_version(python_version_with_source, search_paths.python_version());
41+
let python_version_with_source = Self::resolve_python_version(
42+
python_version_with_source,
43+
search_paths.python_version().as_deref(),
44+
);
4245

4346
tracing::info!(
4447
"Python version: Python {python_version}, platform: {python_platform}",
@@ -78,8 +81,10 @@ impl Program {
7881

7982
let search_paths = SearchPaths::from_settings(db, &search_paths)?;
8083

81-
let new_python_version =
82-
Self::resolve_python_version(python_version_with_source, search_paths.python_version());
84+
let new_python_version = Self::resolve_python_version(
85+
python_version_with_source,
86+
search_paths.python_version().as_deref(),
87+
);
8388

8489
if self.search_paths(db) != &search_paths {
8590
tracing::debug!("Updating search paths");
@@ -112,8 +117,11 @@ impl Program {
112117
let search_paths = SearchPaths::from_settings(db, search_path_settings)?;
113118

114119
let current_python_version = self.python_version_with_source(db);
115-
let python_version_from_environment =
116-
search_paths.python_version().cloned().unwrap_or_default();
120+
121+
let python_version_from_environment = search_paths
122+
.python_version()
123+
.map(Cow::into_owned)
124+
.unwrap_or_default();
117125

118126
if current_python_version != &python_version_from_environment
119127
&& current_python_version.source.priority()
@@ -153,6 +161,13 @@ pub enum PythonVersionSource {
153161
/// The virtual environment might have been configured, activated or inferred.
154162
PyvenvCfgFile(PythonVersionFileSource),
155163

164+
/// Value inferred from the layout of the Python installation.
165+
///
166+
/// This only ever applies on Unix. On Unix, the `site-packages` directory
167+
/// will always be at `sys.prefix/lib/pythonX.Y/site-packages`,
168+
/// so we can infer the Python version from the parent directory of `site-packages`.
169+
InstallationDirectoryLayout { site_packages_parent_dir: Box<str> },
170+
156171
/// The value comes from a CLI argument, while it's left open if specified using a short argument,
157172
/// long argument (`--extra-paths`) or `--config key=value`.
158173
Cli,
@@ -169,6 +184,9 @@ impl PythonVersionSource {
169184
PythonVersionSource::PyvenvCfgFile(_) => PythonSourcePriority::PyvenvCfgFile,
170185
PythonVersionSource::ConfigFile(_) => PythonSourcePriority::ConfigFile,
171186
PythonVersionSource::Cli => PythonSourcePriority::Cli,
187+
PythonVersionSource::InstallationDirectoryLayout { .. } => {
188+
PythonSourcePriority::InstallationDirectoryLayout
189+
}
172190
}
173191
}
174192
}
@@ -182,9 +200,10 @@ impl PythonVersionSource {
182200
#[cfg_attr(test, derive(strum_macros::EnumIter))]
183201
enum PythonSourcePriority {
184202
Default = 0,
185-
PyvenvCfgFile = 1,
186-
ConfigFile = 2,
187-
Cli = 3,
203+
InstallationDirectoryLayout = 1,
204+
PyvenvCfgFile = 2,
205+
ConfigFile = 3,
206+
Cli = 4,
188207
}
189208

190209
/// Information regarding the file and [`TextRange`] of the configuration
@@ -312,7 +331,9 @@ mod tests {
312331
match other {
313332
PythonSourcePriority::Cli => assert!(other > priority, "{other:?}"),
314333
PythonSourcePriority::ConfigFile => assert_eq!(priority, other),
315-
PythonSourcePriority::PyvenvCfgFile | PythonSourcePriority::Default => {
334+
PythonSourcePriority::PyvenvCfgFile
335+
| PythonSourcePriority::Default
336+
| PythonSourcePriority::InstallationDirectoryLayout => {
316337
assert!(priority > other, "{other:?}");
317338
}
318339
}
@@ -327,6 +348,24 @@ mod tests {
327348
assert!(other > priority, "{other:?}");
328349
}
329350
PythonSourcePriority::PyvenvCfgFile => assert_eq!(priority, other),
351+
PythonSourcePriority::Default
352+
| PythonSourcePriority::InstallationDirectoryLayout => {
353+
assert!(priority > other, "{other:?}");
354+
}
355+
}
356+
}
357+
}
358+
PythonSourcePriority::InstallationDirectoryLayout => {
359+
for other in PythonSourcePriority::iter() {
360+
match other {
361+
PythonSourcePriority::Cli
362+
| PythonSourcePriority::ConfigFile
363+
| PythonSourcePriority::PyvenvCfgFile => {
364+
assert!(other > priority, "{other:?}");
365+
}
366+
PythonSourcePriority::InstallationDirectoryLayout => {
367+
assert_eq!(priority, other);
368+
}
330369
PythonSourcePriority::Default => assert!(priority > other, "{other:?}"),
331370
}
332371
}

crates/ty_python_semantic/src/util/diagnostics.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ pub fn add_inferred_python_version_hint_to_diagnostic(
6161
or in a configuration file",
6262
);
6363
}
64+
crate::PythonVersionSource::InstallationDirectoryLayout {
65+
site_packages_parent_dir,
66+
} => {
67+
// TODO: it would also be nice to tell them how we resolved this Python installation...
68+
diagnostic.info(format_args!(
69+
"Python {version} was assumed when {action} \
70+
because of the layout of your Python installation"
71+
));
72+
diagnostic.info(format_args!(
73+
"The primary `site-packages` directory of your installation was found \
74+
at `lib/{site_packages_parent_dir}/site-packages/`"
75+
));
76+
diagnostic.info(
77+
"No Python version was specified on the command line \
78+
or in a configuration file",
79+
);
80+
}
6481
crate::PythonVersionSource::Default => {
6582
diagnostic.info(format_args!(
6683
"Python {version} was assumed when {action} \

0 commit comments

Comments
 (0)