mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 02:38:25 +00:00
[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
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:
parent
384e80ec80
commit
e2d96df501
8 changed files with 300 additions and 38 deletions
|
@ -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]
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
||||
```
|
|
@ -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
|
||||
|
||||
```
|
|
@ -369,7 +369,7 @@ impl SearchPaths {
|
|||
})
|
||||
}
|
||||
|
||||
pub(super) fn typeshed_versions(&self) -> &TypeshedVersions {
|
||||
pub(crate) fn typeshed_versions(&self) -> &TypeshedVersions {
|
||||
&self.typeshed_versions
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue