Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/import/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```
Original file line number Diff line number Diff line change
@@ -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

```
Original file line number Diff line number Diff line change
@@ -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

```
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ impl SearchPaths {
})
}

pub(super) fn typeshed_versions(&self) -> &TypeshedVersions {
pub(crate) fn typeshed_versions(&self) -> &TypeshedVersions {
&self.typeshed_versions
}

Expand Down
33 changes: 29 additions & 4 deletions crates/ty_python_semantic/src/module_resolver/typeshed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down Expand Up @@ -8194,7 +8194,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));
}
}
}
Expand Down
50 changes: 49 additions & 1 deletion crates/ty_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -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");
}
Loading
Loading