Skip to content
Open
17 changes: 17 additions & 0 deletions crates/ruff_linter/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,16 @@ impl<'a> Visitor<'a> for Checker<'a> {
}
}
}
Expr::Attribute(ast::ExprAttribute {
value: _,
range: _,
ctx,
attr: _,
}) => {
if ctx == &ExprContext::Load {
self.handle_attribute_load(expr);
}
}
Expr::Name(ast::ExprName { id, ctx, range: _ }) => match ctx {
ExprContext::Load => self.handle_node_load(expr),
ExprContext::Store => self.handle_node_store(id, expr),
Expand Down Expand Up @@ -2046,6 +2056,13 @@ impl<'a> Checker<'a> {
self.semantic.resolve_load(expr);
}

fn handle_attribute_load(&mut self, expr: &Expr) {
let Expr::Attribute(expr) = expr else {
return;
};
self.semantic.resolve_attribute_load(expr);
}

fn handle_node_store(&mut self, id: &'a str, expr: &Expr) {
let parent = self.semantic.current_statement();

Expand Down
36 changes: 36 additions & 0 deletions crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,42 @@ mod tests {
",
"tc010_precedence_over_tc008"
)]
#[test_case(
r"
from __future__ import annotations
import importlib.abc
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import importlib.machinery
class Foo(importlib.abc.MetaPathFinder):
def bar(self) -> importlib.machinery.ModuleSpec: ...
",
"github_issue_15723_regression_test"
)]
#[test_case(
r"
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import importlib.abc
import importlib.machinery
class Foo(importlib.abc.MetaPathFinder):
def bar(self) -> importlib.machinery.ModuleSpec: ...
",
"github_issue_15723_false_negative"
)]
#[test_case(
r"
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import importlib.machinery
import importlib.abc
class Foo(importlib.abc.MetaPathFinder):
def bar(self) -> importlib.machinery.ModuleSpec: ...
",
"github_issue_15723_ideal_import_order"
)]
fn contents(contents: &str, snapshot: &str) {
let diagnostics = test_snippet(
contents,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::borrow::Cow;

use anyhow::Result;
use rustc_hash::FxHashMap;

use unicode_normalization::UnicodeNormalization;
use ruff_diagnostics::{Diagnostic, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_semantic::{Imported, NodeId, Scope};
Expand Down Expand Up @@ -106,74 +106,91 @@ pub(crate) fn runtime_import_in_type_checking_block(
let mut actions: FxHashMap<(NodeId, Action), Vec<ImportBinding>> = FxHashMap::default();

for binding_id in scope.binding_ids() {
let binding = checker.semantic().binding(binding_id);
let top_binding = checker.semantic().binding(binding_id);

let Some(import) = binding.as_any_import() else {
let Some(_) = top_binding.as_any_import() else {
continue;
};

let Some(reference_id) = binding.references.first().copied() else {
let Some(reference_id) = top_binding.references.first().copied() else {
continue;
};

if binding.context.is_typing()
&& binding.references().any(|reference_id| {
let binding_name = top_binding
.name(checker.source())
.split('.')
.next()
.unwrap_or("");

// NOTE: It’s necessary to go through all bindings, including shadowed ones,
// using the module name.
for binding in scope
.get_all(&binding_name.nfkc().collect::<String>())
.map(|binding_id| checker.semantic().binding(binding_id))
Comment on lines +119 to +129

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be more efficient to use the existing method on the semantic model to iterate through the shadowed bindings (unless your change makes those bindings not always shadow each other):

https://github.com/astral-sh/ruff/blob/91b7a570c2bd1c9e1cab894ded866e885f28946a/crates/ruff_python_semantic/src/model.rs#L2038-L2075

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or rather as pointed out above, you might be able to revert everything and only have to replace scope.binding_ids() with scope.all_bindings().

{
if binding.context.is_typing()
&& binding.references().any(|reference_id| {
checker
.semantic()
.reference(reference_id)
.in_runtime_context()
})
{
let Some(node_id) = binding.source else {
continue;
};
{
let Some(import) = binding.as_any_import() else {
continue;
};

let import = ImportBinding {
import,
reference_id,
binding,
range: binding.range(),
parent_range: binding.parent_range(checker.semantic()),
};
let Some(node_id) = binding.source else {
continue;
};

if checker.rule_is_ignored(Rule::RuntimeImportInTypeCheckingBlock, import.start())
|| import.parent_range.is_some_and(|parent_range| {
let import = ImportBinding {
import,
reference_id,
binding,
range: binding.range(),
parent_range: binding.parent_range(checker.semantic()),
};

if checker.rule_is_ignored(Rule::RuntimeImportInTypeCheckingBlock, import.start())
|| import.parent_range.is_some_and(|parent_range| {
checker.rule_is_ignored(
Rule::RuntimeImportInTypeCheckingBlock,
parent_range.start(),
)
})
{
actions
.entry((node_id, Action::Ignore))
.or_default()
.push(import);
} else {
// Determine whether the member should be fixed by moving the import out of the
// type-checking block, or by quoting its references.
// TODO: We should check `reference.in_annotated_type_alias()`
// as well to match the behavior of the flake8 plugin
// although maybe the best way forward is to add an
// additional setting to configure whether quoting
// or moving the import is preferred for type aliases
// since some people will consistently use their
// type aliases at runtimes, while others won't, so
// the best solution is unclear.
if checker.settings.flake8_type_checking.quote_annotations
&& binding.references().all(|reference_id| {
let reference = checker.semantic().reference(reference_id);
reference.in_typing_context() || reference.in_runtime_evaluated_annotation()
})
{
actions
.entry((node_id, Action::Quote))
.entry((node_id, Action::Ignore))
.or_default()
.push(import);
} else {
actions
.entry((node_id, Action::Move))
.or_default()
.push(import);
// Determine whether the member should be fixed by moving the import out of the
// type-checking block, or by quoting its references.
// TODO: We should check `reference.in_annotated_type_alias()`
// as well to match the behavior of the flake8 plugin
// although maybe the best way forward is to add an
// additional setting to configure whether quoting
// or moving the import is preferred for type aliases
// since some people will consistently use their
// type aliases at runtimes, while others won't, so
// the best solution is unclear.
if checker.settings.flake8_type_checking.quote_annotations
&& binding.references().all(|reference_id| {
let reference = checker.semantic().reference(reference_id);
reference.in_typing_context() || reference.in_runtime_evaluated_annotation()
})
{
actions
.entry((node_id, Action::Quote))
.or_default()
.push(import);
} else {
actions
.entry((node_id, Action::Move))
.or_default()
.push(import);
}
}
}
}
Expand Down

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you getting a false negative with your approach here as well or did you just copy over the snapshots from my PR?

I was kind of hoping your approach wouldn't lead to source order dependent false negatives.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't dug deep into this yet - just added some test cases and ran cargo insta review.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'd need to run cargo insta test once to get updated snapshots. cargo insta review doesn't really do anything on its own without the temporary diff snapshot files.

But you can also just merge this and let the CI tell you if some of the snapshots need to be updated. (Or enable GitHub actions on your fork and trigger the CI using a force push)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'll try to do it later.

Copy link
Owner Author

@gpilikin gpilikin May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello. Ah, I see. I was doing it by creating snapshots via cargo test and then running cargo insta review. I didn’t know you could just run cargo insta test to create snapshots.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a shame, but it's still better than without the fix, so I'll take what I can get. Maybe the source order dependency will be easier to fix as a follow-up with the additional contextual information your fix provides.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also cargo test does work, yes. But cargo insta test is more convenient, especially if there are multiple snapshots that need to be updated.

Copy link
Owner Author

@gpilikin gpilikin May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lol)

I actually ran into this when fixing a rule related to unused imports. The solution was to search for all bindings by explicitly using the module name.
From what I understand, the imports are shadowing each other, making them harder to access.

Check the last two commits.
If I understand correctly, is this like in your PR?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I used a similar approach in one of my attempts to fix this. I'm worried there are still other corner-cases that aren't handled correctly though, like the following example:

import a.b.c

TYPE_CHECKING = False
if TYPE_CHECKING:
    import a.b

class Foo(a.b.c.Bar):
    def baz() -> a.b.x: ...

Or the reverse or if you add more imports with different levels. Making each reference prioritize binding to the correct import for the purposes of this check is challenging.

It looks like a good start towards further improving results though.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, if what your fix does, is ensure that references point to the correct import, then you can probably make a much smaller patch and just iterate over all the bindings in the outermost loop, i.e. replace for binding_id in scope.binding_ids() with for _, binding_id in scope.all_bindings(), that should be faster than going from each binding back to its shadowed bindings.

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
<filename>:5:12: TC004 [*] Move import `importlib.abc` out of type-checking block. Import is used for more than type hinting.
|
3 | from typing import TYPE_CHECKING
4 | if TYPE_CHECKING:
5 | import importlib.abc
| ^^^^^^^^^^^^^ TC004
6 | import importlib.machinery
7 | class Foo(importlib.abc.MetaPathFinder):
|
= help: Move out of type-checking block

ℹ Unsafe fix
1 1 |
2 2 | from __future__ import annotations
3 3 | from typing import TYPE_CHECKING
4 |+import importlib.abc
4 5 | if TYPE_CHECKING:
5 |- import importlib.abc
6 6 | import importlib.machinery
7 7 | class Foo(importlib.abc.MetaPathFinder):
8 8 | def bar(self) -> importlib.machinery.ModuleSpec: ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
<filename>:6:12: TC004 [*] Move import `importlib.abc` out of type-checking block. Import is used for more than type hinting.
|
4 | if TYPE_CHECKING:
5 | import importlib.machinery
6 | import importlib.abc
| ^^^^^^^^^^^^^ TC004
7 | class Foo(importlib.abc.MetaPathFinder):
8 | def bar(self) -> importlib.machinery.ModuleSpec: ...
|
= help: Move out of type-checking block

ℹ Unsafe fix
1 1 |
2 2 | from __future__ import annotations
3 3 | from typing import TYPE_CHECKING
4 |+import importlib.abc
4 5 | if TYPE_CHECKING:
5 6 | import importlib.machinery
6 |- import importlib.abc
7 7 | class Foo(importlib.abc.MetaPathFinder):
8 8 | def bar(self) -> importlib.machinery.ModuleSpec: ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---

6 changes: 3 additions & 3 deletions crates/ruff_linter/src/rules/pyflakes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2654,7 +2654,7 @@ lambda: fu
import foo.baz as foo
foo
",
&[Rule::RedefinedWhileUnused],
&[Rule::UnusedImport, Rule::RedefinedWhileUnused],
);
}

Expand Down Expand Up @@ -2704,13 +2704,13 @@ lambda: fu

#[test]
fn unused_package_with_submodule_import() {
// When a package and its submodule are imported, only report once.
// When a package and its submodule are imported, report both.
flakes(
r"
import fu
import fu.bar
",
&[Rule::UnusedImport],
&[Rule::UnusedImport, Rule::UnusedImport],
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::string::ToString;

use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_semantic::BindingKind::SubmoduleImport;
use ruff_python_semantic::{Scope, ScopeId};
use ruff_text_size::Ranged;

Expand Down Expand Up @@ -61,7 +62,12 @@ pub(crate) fn undefined_local(
if let Some(range) = shadowed.references().find_map(|reference_id| {
let reference = checker.semantic().reference(reference_id);
if reference.scope_id() == scope_id {
Some(reference.range())
// FIXME: ignore submodules
if let SubmoduleImport(..) = shadowed.kind {
None
} else {
Some(reference.range())
}
} else {
None
}
Expand Down
Loading