diff --git a/crates/ty_python_semantic/resources/mdtest/import/basic.md b/crates/ty_python_semantic/resources/mdtest/import/basic.md index 60edbc392b..8e7538190e 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/import/basic.md @@ -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 + + + +```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 + + + +```toml +[environment] +python-version = "3.13" +``` + +```py +import aifc # error: [unresolved-import] +from distutils import sysconfig # error: [unresolved-import] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(2fcfcf567587a056).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(2fcfcf567587a056).snap new file mode 100644 index 0000000000..dd2756ab92 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(2fcfcf567587a056).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(c14954eefd15211f).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(c14954eefd15211f).snap new file mode 100644 index 0000000000..964ecc9e64 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import…_(c14954eefd15211f).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/src/module_resolver/resolver.rs b/crates/ty_python_semantic/src/module_resolver/resolver.rs index 6447c203cf..74cc931f89 100644 --- a/crates/ty_python_semantic/src/module_resolver/resolver.rs +++ b/crates/ty_python_semantic/src/module_resolver/resolver.rs @@ -369,7 +369,7 @@ impl SearchPaths { }) } - pub(super) fn typeshed_versions(&self) -> &TypeshedVersions { + pub(crate) fn typeshed_versions(&self) -> &TypeshedVersions { &self.typeshed_versions } diff --git a/crates/ty_python_semantic/src/module_resolver/typeshed.rs b/crates/ty_python_semantic/src/module_resolver/typeshed.rs index b1dcff7d06..850882106f 100644 --- a/crates/ty_python_semantic/src/module_resolver/typeshed.rs +++ b/crates/ty_python_semantic/src/module_resolver/typeshed.rs @@ -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); 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), AvailableWithin(RangeInclusive), } 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 { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 71de8708f4..f58bd2412b 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -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)); } } } diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index e931013755..1a0b13ef21 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -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"); +} diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index acbf27b881..1f046974cc 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -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> { 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> {