[ty] Improve diagnostics if the user attempts to import a stdlib module that does not exist on their configured Python version (#18403)
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 / mkdocs (push) Waiting to run
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 / 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-02 11:52:26 +01:00 committed by GitHub
parent 384e80ec80
commit e2d96df501
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 300 additions and 38 deletions

View file

@ -176,3 +176,32 @@ emitted for the `import from` statement:
# error: [unresolved-import]
from does_not_exist import foo, bar, baz
```
## Attempting to import a stdlib module that's not yet been added
<!-- snapshot-diagnostics -->
```toml
[environment]
python-version = "3.10"
```
```py
import tomllib # error: [unresolved-import]
from string.templatelib import Template # error: [unresolved-import]
from importlib.resources import abc # error: [unresolved-import]
```
## Attempting to import a stdlib module that was previously removed
<!-- snapshot-diagnostics -->
```toml
[environment]
python-version = "3.13"
```
```py
import aifc # error: [unresolved-import]
from distutils import sysconfig # error: [unresolved-import]
```

View file

@ -0,0 +1,65 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: basic.md - Structures - Attempting to import a stdlib module that's not yet been added
mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md
---
# Python source files
## mdtest_snippet.py
```
1 | import tomllib # error: [unresolved-import]
2 | from string.templatelib import Template # error: [unresolved-import]
3 | from importlib.resources import abc # error: [unresolved-import]
```
# Diagnostics
```
error[unresolved-import]: Cannot resolve imported module `tomllib`
--> src/mdtest_snippet.py:1:8
|
1 | import tomllib # error: [unresolved-import]
| ^^^^^^^
2 | from string.templatelib import Template # error: [unresolved-import]
3 | from importlib.resources import abc # error: [unresolved-import]
|
info: The stdlib module `tomllib` is only available on Python 3.11+
info: Python 3.10 was assumed when resolving modules because it was specified on the command line
info: rule `unresolved-import` is enabled by default
```
```
error[unresolved-import]: Cannot resolve imported module `string.templatelib`
--> src/mdtest_snippet.py:2:6
|
1 | import tomllib # error: [unresolved-import]
2 | from string.templatelib import Template # error: [unresolved-import]
| ^^^^^^^^^^^^^^^^^^
3 | from importlib.resources import abc # error: [unresolved-import]
|
info: The stdlib module `string.templatelib` is only available on Python 3.14+
info: Python 3.10 was assumed when resolving modules because it was specified on the command line
info: rule `unresolved-import` is enabled by default
```
```
error[unresolved-import]: Module `importlib.resources` has no member `abc`
--> src/mdtest_snippet.py:3:33
|
1 | import tomllib # error: [unresolved-import]
2 | from string.templatelib import Template # error: [unresolved-import]
3 | from importlib.resources import abc # error: [unresolved-import]
| ^^^
|
info: The stdlib module `importlib.resources` only has a `abc` submodule on Python 3.11+
info: Python 3.10 was assumed when resolving modules because it was specified on the command line
info: rule `unresolved-import` is enabled by default
```

View file

@ -0,0 +1,47 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: basic.md - Structures - Attempting to import a stdlib module that was previously removed
mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md
---
# Python source files
## mdtest_snippet.py
```
1 | import aifc # error: [unresolved-import]
2 | from distutils import sysconfig # error: [unresolved-import]
```
# Diagnostics
```
error[unresolved-import]: Cannot resolve imported module `aifc`
--> src/mdtest_snippet.py:1:8
|
1 | import aifc # error: [unresolved-import]
| ^^^^
2 | from distutils import sysconfig # error: [unresolved-import]
|
info: The stdlib module `aifc` is only available on Python <=3.12
info: Python 3.13 was assumed when resolving modules because it was specified on the command line
info: rule `unresolved-import` is enabled by default
```
```
error[unresolved-import]: Cannot resolve imported module `distutils`
--> src/mdtest_snippet.py:2:6
|
1 | import aifc # error: [unresolved-import]
2 | from distutils import sysconfig # error: [unresolved-import]
| ^^^^^^^^^
|
info: The stdlib module `distutils` is only available on Python <=3.11
info: Python 3.13 was assumed when resolving modules because it was specified on the command line
info: rule `unresolved-import` is enabled by default
```

View file

@ -369,7 +369,7 @@ impl SearchPaths {
})
}
pub(super) fn typeshed_versions(&self) -> &TypeshedVersions {
pub(crate) fn typeshed_versions(&self) -> &TypeshedVersions {
&self.typeshed_versions
}

View file

@ -58,7 +58,7 @@ impl std::error::Error for TypeshedVersionsParseError {
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub(super) enum TypeshedVersionsParseErrorKind {
pub(crate) enum TypeshedVersionsParseErrorKind {
TooManyLines(NonZeroUsize),
UnexpectedNumberOfColons,
InvalidModuleName(String),
@ -105,7 +105,7 @@ pub(crate) struct TypeshedVersions(FxHashMap<ModuleName, PyVersionRange>);
impl TypeshedVersions {
#[must_use]
fn exact(&self, module_name: &ModuleName) -> Option<&PyVersionRange> {
pub(crate) fn exact(&self, module_name: &ModuleName) -> Option<&PyVersionRange> {
self.0.get(module_name)
}
@ -257,19 +257,44 @@ impl fmt::Display for TypeshedVersions {
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
enum PyVersionRange {
pub(crate) enum PyVersionRange {
AvailableFrom(RangeFrom<PythonVersion>),
AvailableWithin(RangeInclusive<PythonVersion>),
}
impl PyVersionRange {
#[must_use]
fn contains(&self, version: PythonVersion) -> bool {
pub(crate) fn contains(&self, version: PythonVersion) -> bool {
match self {
Self::AvailableFrom(inner) => inner.contains(&version),
Self::AvailableWithin(inner) => inner.contains(&version),
}
}
/// Display the version range in a way that is suitable for rendering in user-facing diagnostics.
pub(crate) fn diagnostic_display(&self) -> impl std::fmt::Display {
struct DiagnosticDisplay<'a>(&'a PyVersionRange);
impl fmt::Display for DiagnosticDisplay<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
PyVersionRange::AvailableFrom(range_from) => write!(f, "{}+", range_from.start),
PyVersionRange::AvailableWithin(range_inclusive) => {
// Don't trust the start Python version if it's 3.0 or lower.
// Typeshed doesn't attempt to give accurate start versions if a module was added
// in the Python 2 era.
if range_inclusive.start() <= &(PythonVersion { major: 3, minor: 0 }) {
write!(f, "<={}", range_inclusive.end())
} else {
write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end())
}
}
}
}
}
DiagnosticDisplay(self)
}
}
impl FromStr for PyVersionRange {

View file

@ -843,7 +843,7 @@ impl<'db> Type<'db> {
matches!(self, Type::PropertyInstance(..))
}
pub fn module_literal(db: &'db dyn Db, importing_file: File, submodule: Module) -> Self {
pub fn module_literal(db: &'db dyn Db, importing_file: File, submodule: &Module) -> Self {
Self::ModuleLiteral(ModuleLiteralType::new(db, importing_file, submodule))
}
@ -8201,7 +8201,7 @@ impl<'db> ModuleLiteralType<'db> {
full_submodule_name.extend(&submodule_name);
if imported_submodules.contains(&full_submodule_name) {
if let Some(submodule) = resolve_module(db, &full_submodule_name) {
return Symbol::bound(Type::module_literal(db, importing_file, submodule));
return Symbol::bound(Type::module_literal(db, importing_file, &submodule));
}
}
}

View file

@ -5,7 +5,6 @@ use super::{
CallArgumentTypes, CallDunderError, ClassBase, ClassLiteral, KnownClass,
add_inferred_python_version_hint_to_diagnostic,
};
use crate::declare_lint;
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
use crate::suppression::FileSuppressionId;
use crate::types::LintDiagnosticGuard;
@ -15,6 +14,7 @@ use crate::types::string_annotation::{
RAW_STRING_TYPE_ANNOTATION,
};
use crate::types::{KnownFunction, SpecialFormType, Type, protocol_class::ProtocolClassLiteral};
use crate::{Db, Module, ModuleName, Program, declare_lint};
use itertools::Itertools;
use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, SubDiagnostic};
use ruff_python_ast::{self as ast, AnyNodeRef};
@ -2139,3 +2139,51 @@ fn report_invalid_base<'ctx, 'db>(
));
Some(diagnostic)
}
/// This function receives an unresolved `from foo import bar` import,
/// where `foo` can be resolved to a module but that module does not
/// have a `bar` member or submdoule.
///
/// If the `foo` module originates from the standard library and `foo.bar`
/// *does* exist as a submodule in the standard library on *other* Python
/// versions, we add a hint to the diagnostic that the user may have
/// misconfigured their Python version.
pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
db: &dyn Db,
mut diagnostic: LintDiagnosticGuard,
full_submodule_name: &ModuleName,
parent_module: &Module,
) {
let Some(search_path) = parent_module.search_path() else {
return;
};
if !search_path.is_standard_library() {
return;
}
let program = Program::get(db);
let typeshed_versions = program.search_paths(db).typeshed_versions();
let Some(version_range) = typeshed_versions.exact(full_submodule_name) else {
return;
};
let python_version = program.python_version(db);
if version_range.contains(python_version) {
return;
}
diagnostic.info(format_args!(
"The stdlib module `{module_name}` only has a `{name}` \
submodule on Python {version_range}",
module_name = parent_module.name(),
name = full_submodule_name
.components()
.next_back()
.expect("A `ModuleName` always has at least one component"),
version_range = version_range.diagnostic_display(),
));
add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules");
}

View file

@ -102,7 +102,8 @@ use crate::{Db, FxOrderSet, Program};
use super::context::{InNoTypeCheck, InferContext};
use super::diagnostic::{
INVALID_METACLASS, INVALID_OVERLOAD, INVALID_PROTOCOL, REDUNDANT_CAST, STATIC_ASSERT_ERROR,
SUBCLASS_OF_FINAL_CLASS, TYPE_ASSERTION_FAILURE, report_attempted_protocol_instantiation,
SUBCLASS_OF_FINAL_CLASS, TYPE_ASSERTION_FAILURE,
hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation,
report_bad_argument_to_get_protocol_members, report_duplicate_bases,
report_index_out_of_bounds, report_invalid_exception_caught, report_invalid_exception_cause,
report_invalid_exception_raised, report_invalid_or_unsupported_base,
@ -3899,8 +3900,32 @@ impl<'db> TypeInferenceBuilder<'db> {
module.unwrap_or_default()
));
if level == 0 {
if let Some(module_name) = module.and_then(ModuleName::new) {
let program = Program::get(self.db());
let typeshed_versions = program.search_paths(self.db()).typeshed_versions();
if let Some(version_range) = typeshed_versions.exact(&module_name) {
// We know it is a stdlib module on *some* Python versions...
let python_version = program.python_version(self.db());
if !version_range.contains(python_version) {
// ...But not on *this* Python version.
diagnostic.info(format_args!(
"The stdlib module `{module_name}` is only available on Python {version_range}",
version_range = version_range.diagnostic_display(),
));
add_inferred_python_version_hint_to_diagnostic(
self.db(),
&mut diagnostic,
"resolving modules",
);
return;
}
}
}
diagnostic.info(
"make sure your Python environment is properly configured: https://github.com/astral-sh/ty/blob/main/docs/README.md#python-environment"
"make sure your Python environment is properly configured: \
https://github.com/astral-sh/ty/blob/main/docs/README.md#python-environment",
);
}
}
@ -4098,11 +4123,13 @@ impl<'db> TypeInferenceBuilder<'db> {
return;
};
let Some(module_ty) = self.module_type_from_name(&module_name) else {
let Some(module) = resolve_module(self.db(), &module_name) else {
self.add_unknown_declaration_with_binding(alias.into(), definition);
return;
};
let module_ty = Type::module_literal(self.db(), self.file(), &module);
// The indirection of having `star_import_info` as a separate variable
// is required in order to make the borrow checker happy.
let star_import_info = definition
@ -4151,6 +4178,15 @@ impl<'db> TypeInferenceBuilder<'db> {
}
}
// Evaluate whether `X.Y` would constitute a valid submodule name,
// given a `from X import Y` statement. If it is valid, this will be `Some()`;
// else, it will be `None`.
let full_submodule_name = ModuleName::new(name).map(|final_part| {
let mut ret = module_name.clone();
ret.extend(&final_part);
ret
});
// If the module doesn't bind the symbol, check if it's a submodule. This won't get
// handled by the `Type::member` call because it relies on the semantic index's
// `imported_modules` set. The semantic index does not include information about
@ -4166,35 +4202,47 @@ impl<'db> TypeInferenceBuilder<'db> {
//
// Regardless, for now, we sidestep all of that by repeating the submodule-or-attribute
// check here when inferring types for a `from...import` statement.
if let Some(submodule_name) = ModuleName::new(name) {
let mut full_submodule_name = module_name.clone();
full_submodule_name.extend(&submodule_name);
if let Some(submodule_ty) = self.module_type_from_name(&full_submodule_name) {
self.add_declaration_with_binding(
alias.into(),
definition,
&DeclaredAndInferredType::AreTheSame(submodule_ty),
);
return;
}
}
if &alias.name != "*" {
let is_import_reachable = self.is_reachable(import_from);
if is_import_reachable {
if let Some(builder) = self
.context
.report_lint(&UNRESOLVED_IMPORT, AnyNodeRef::Alias(alias))
{
builder.into_diagnostic(format_args!(
"Module `{module_name}` has no member `{name}`"
));
}
}
if let Some(submodule_type) = full_submodule_name
.as_ref()
.and_then(|submodule_name| self.module_type_from_name(submodule_name))
{
self.add_declaration_with_binding(
alias.into(),
definition,
&DeclaredAndInferredType::AreTheSame(submodule_type),
);
return;
}
self.add_unknown_declaration_with_binding(alias.into(), definition);
if &alias.name == "*" {
return;
}
if !self.is_reachable(import_from) {
return;
}
let Some(builder) = self
.context
.report_lint(&UNRESOLVED_IMPORT, AnyNodeRef::Alias(alias))
else {
return;
};
let diagnostic = builder.into_diagnostic(format_args!(
"Module `{module_name}` has no member `{name}`"
));
if let Some(full_submodule_name) = full_submodule_name {
hint_if_stdlib_submodule_exists_on_other_versions(
self.db(),
diagnostic,
&full_submodule_name,
&module,
);
}
}
fn infer_return_statement(&mut self, ret: &ast::StmtReturn) {
@ -4218,7 +4266,7 @@ impl<'db> TypeInferenceBuilder<'db> {
fn module_type_from_name(&self, module_name: &ModuleName) -> Option<Type<'db>> {
resolve_module(self.db(), module_name)
.map(|module| Type::module_literal(self.db(), self.file(), module))
.map(|module| Type::module_literal(self.db(), self.file(), &module))
}
fn infer_decorator(&mut self, decorator: &ast::Decorator) -> Type<'db> {