Skip to content

Commit f6afd5c

Browse files
committed
[ty] Improve diagnostics if the user attempts to import a stdlib module that does not exist on their configured Python version
1 parent aa1fad6 commit f6afd5c

File tree

7 files changed

+143
-6
lines changed

7 files changed

+143
-6
lines changed

crates/ruff_python_ast/src/python_version.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct PythonVersion {
1111
}
1212

1313
impl PythonVersion {
14+
pub const PY30: PythonVersion = PythonVersion { major: 3, minor: 0 };
1415
pub const PY37: PythonVersion = PythonVersion { major: 3, minor: 7 };
1516
pub const PY38: PythonVersion = PythonVersion { major: 3, minor: 8 };
1617
pub const PY39: PythonVersion = PythonVersion { major: 3, minor: 9 };

crates/ty_python_semantic/resources/mdtest/import/basic.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,29 @@ emitted for the `import from` statement:
176176
# error: [unresolved-import]
177177
from does_not_exist import foo, bar, baz
178178
```
179+
180+
## Attempting to import a stdlib module that's not yet been added
181+
182+
<!-- snapshot-diagnostics -->
183+
184+
```toml
185+
[environment]
186+
python-version = "3.10"
187+
```
188+
189+
```py
190+
import tomllib
191+
```
192+
193+
## Attempting to import a stdlib module that was previously removed
194+
195+
<!-- snapshot-diagnostics -->
196+
197+
```toml
198+
[environment]
199+
python-version = "3.13"
200+
```
201+
202+
```py
203+
import aifc
204+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: basic.md - Structures - Attempting to import a stdlib module that's not yet been added
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md
8+
---
9+
10+
# Python source files
11+
12+
## mdtest_snippet.py
13+
14+
```
15+
1 | import tomllib
16+
```
17+
18+
# Diagnostics
19+
20+
```
21+
error[unresolved-import]: Cannot resolve imported module `tomllib`
22+
--> src/mdtest_snippet.py:1:8
23+
|
24+
1 | import tomllib
25+
| ^^^^^^^
26+
|
27+
info: The stdlib module `tomllib` is only available on Python 3.11+
28+
info: Python 3.10 was assumed when resolving modules because it was specified on the command line
29+
info: rule `unresolved-import` is enabled by default
30+
31+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: basic.md - Structures - Attempting to import a stdlib module that was previously removed
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md
8+
---
9+
10+
# Python source files
11+
12+
## mdtest_snippet.py
13+
14+
```
15+
1 | import aifc
16+
```
17+
18+
# Diagnostics
19+
20+
```
21+
error[unresolved-import]: Cannot resolve imported module `aifc`
22+
--> src/mdtest_snippet.py:1:8
23+
|
24+
1 | import aifc
25+
| ^^^^
26+
|
27+
info: The stdlib module `aifc` is only available on Python <=3.12
28+
info: Python 3.13 was assumed when resolving modules because it was specified on the command line
29+
info: rule `unresolved-import` is enabled by default
30+
31+
```

crates/ty_python_semantic/src/module_resolver/resolver.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ impl SearchPaths {
369369
})
370370
}
371371

372-
pub(super) fn typeshed_versions(&self) -> &TypeshedVersions {
372+
pub(crate) fn typeshed_versions(&self) -> &TypeshedVersions {
373373
&self.typeshed_versions
374374
}
375375

crates/ty_python_semantic/src/module_resolver/typeshed.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ impl std::error::Error for TypeshedVersionsParseError {
5858
}
5959

6060
#[derive(Debug, PartialEq, Eq, Clone)]
61-
pub(super) enum TypeshedVersionsParseErrorKind {
61+
pub(crate) enum TypeshedVersionsParseErrorKind {
6262
TooManyLines(NonZeroUsize),
6363
UnexpectedNumberOfColons,
6464
InvalidModuleName(String),
@@ -105,7 +105,7 @@ pub(crate) struct TypeshedVersions(FxHashMap<ModuleName, PyVersionRange>);
105105

106106
impl TypeshedVersions {
107107
#[must_use]
108-
fn exact(&self, module_name: &ModuleName) -> Option<&PyVersionRange> {
108+
pub(crate) fn exact(&self, module_name: &ModuleName) -> Option<&PyVersionRange> {
109109
self.0.get(module_name)
110110
}
111111

@@ -257,19 +257,43 @@ impl fmt::Display for TypeshedVersions {
257257
}
258258

259259
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
260-
enum PyVersionRange {
260+
pub(crate) enum PyVersionRange {
261261
AvailableFrom(RangeFrom<PythonVersion>),
262262
AvailableWithin(RangeInclusive<PythonVersion>),
263263
}
264264

265265
impl PyVersionRange {
266266
#[must_use]
267-
fn contains(&self, version: PythonVersion) -> bool {
267+
pub(crate) fn contains(&self, version: PythonVersion) -> bool {
268268
match self {
269269
Self::AvailableFrom(inner) => inner.contains(&version),
270270
Self::AvailableWithin(inner) => inner.contains(&version),
271271
}
272272
}
273+
274+
pub(crate) fn diagnostic_display(&self) -> impl std::fmt::Display {
275+
struct DiagnosticDisplay<'a>(&'a PyVersionRange);
276+
277+
impl fmt::Display for DiagnosticDisplay<'_> {
278+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279+
match self.0 {
280+
PyVersionRange::AvailableFrom(range_from) => write!(f, "{}+", range_from.start),
281+
PyVersionRange::AvailableWithin(range_inclusive) => {
282+
// Don't trust the start Python version if it's 3.0 or lower.
283+
// Typeshed doesn't attempt to give accurate start versions if a module was added
284+
// in the Python 2 era.
285+
if range_inclusive.start() <= &PythonVersion::PY30 {
286+
write!(f, "<={}", range_inclusive.end())
287+
} else {
288+
write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end())
289+
}
290+
}
291+
}
292+
}
293+
}
294+
295+
DiagnosticDisplay(self)
296+
}
273297
}
274298

275299
impl FromStr for PyVersionRange {

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3899,8 +3899,32 @@ impl<'db> TypeInferenceBuilder<'db> {
38993899
module.unwrap_or_default()
39003900
));
39013901
if level == 0 {
3902+
if let Some(module_name) = module.and_then(ModuleName::new) {
3903+
let program = Program::get(self.db());
3904+
let typeshed_versions = program.search_paths(self.db()).typeshed_versions();
3905+
3906+
if let Some(version_range) = typeshed_versions.exact(&module_name) {
3907+
// We know it is a stdlib module on *some* Python versions...
3908+
let python_version = program.python_version(self.db());
3909+
if !version_range.contains(python_version) {
3910+
// ...But not on *this* Python version.
3911+
diagnostic.info(format_args!(
3912+
"The stdlib module `{module_name}` is only available on Python {version_range}",
3913+
version_range = version_range.diagnostic_display(),
3914+
));
3915+
add_inferred_python_version_hint_to_diagnostic(
3916+
self.db(),
3917+
&mut diagnostic,
3918+
"resolving modules",
3919+
);
3920+
return;
3921+
}
3922+
}
3923+
}
3924+
39023925
diagnostic.info(
3903-
"make sure your Python environment is properly configured: https://github.com/astral-sh/ty/blob/main/docs/README.md#python-environment"
3926+
"make sure your Python environment is properly configured: \
3927+
https://github.com/astral-sh/ty/blob/main/docs/README.md#python-environment",
39043928
);
39053929
}
39063930
}

0 commit comments

Comments
 (0)