diff --git a/crates/ruff/resources/test/fixtures/flake8_type_checking/TCH200_0.py b/crates/ruff/resources/test/fixtures/flake8_type_checking/TCH200_0.py new file mode 100644 index 0000000000..76dc40d091 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_type_checking/TCH200_0.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import module + from module import Class + + +def f(var: Class) -> Class: + x: Class + + +def f(var: module.Class) -> module.Class: + x: module.Class + + +def f(): + print(Class) + + +def f(): + print(module.Class) diff --git a/crates/ruff/resources/test/fixtures/flake8_type_checking/TCH200_1.py b/crates/ruff/resources/test/fixtures/flake8_type_checking/TCH200_1.py new file mode 100644 index 0000000000..f6dab7318e --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_type_checking/TCH200_1.py @@ -0,0 +1,6 @@ +from typing import TYPE_CHECKING + +Class = ... + +if TYPE_CHECKING: + from module import Class diff --git a/crates/ruff/src/checkers/ast/mod.rs b/crates/ruff/src/checkers/ast/mod.rs index 5af47439ee..b8c8e979c7 100644 --- a/crates/ruff/src/checkers/ast/mod.rs +++ b/crates/ruff/src/checkers/ast/mod.rs @@ -4329,6 +4329,11 @@ impl<'a> Checker<'a> { ResolvedRead::Resolved(_) | ResolvedRead::ImplicitGlobal => { // Nothing to do. } + ResolvedRead::TypingOnly(binding_id) => { + if self.enabled(Rule::UnquotedAnnotation) { + flake8_type_checking::rules::unquoted_annotation(self, binding_id, expr); + } + } ResolvedRead::WildcardImport => { // F405 if self.enabled(Rule::UndefinedLocalWithImportStarUsage) { diff --git a/crates/ruff/src/codes.rs b/crates/ruff/src/codes.rs index 3a2d13a41e..7bb6fb308e 100644 --- a/crates/ruff/src/codes.rs +++ b/crates/ruff/src/codes.rs @@ -707,6 +707,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8TypeChecking, "003") => (RuleGroup::Unspecified, rules::flake8_type_checking::rules::TypingOnlyStandardLibraryImport), (Flake8TypeChecking, "004") => (RuleGroup::Unspecified, rules::flake8_type_checking::rules::RuntimeImportInTypeCheckingBlock), (Flake8TypeChecking, "005") => (RuleGroup::Unspecified, rules::flake8_type_checking::rules::EmptyTypeCheckingBlock), + (Flake8TypeChecking, "200") => (RuleGroup::Unspecified, rules::flake8_type_checking::rules::UnquotedAnnotation), // tryceratops (Tryceratops, "002") => (RuleGroup::Unspecified, rules::tryceratops::rules::RaiseVanillaClass), diff --git a/crates/ruff/src/rules/flake8_type_checking/mod.rs b/crates/ruff/src/rules/flake8_type_checking/mod.rs index 01aa8c60e4..f09706262f 100644 --- a/crates/ruff/src/rules/flake8_type_checking/mod.rs +++ b/crates/ruff/src/rules/flake8_type_checking/mod.rs @@ -15,10 +15,13 @@ mod tests { use crate::test::{test_path, test_snippet}; use crate::{assert_messages, settings}; - #[test_case(Rule::TypingOnlyFirstPartyImport, Path::new("TCH001.py"))] - #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("TCH002.py"))] - #[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TCH003.py"))] + #[test_case(Rule::EmptyTypeCheckingBlock, Path::new("TCH005.py"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_1.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_10.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_11.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_12.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_13.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_14.pyi"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_2.py"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_3.py"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_4.py"))] @@ -27,13 +30,12 @@ mod tests { #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_7.py"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_8.py"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_9.py"))] - #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_10.py"))] - #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_11.py"))] - #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_12.py"))] - #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_13.py"))] - #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_14.pyi"))] - #[test_case(Rule::EmptyTypeCheckingBlock, Path::new("TCH005.py"))] + #[test_case(Rule::TypingOnlyFirstPartyImport, Path::new("TCH001.py"))] + #[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TCH003.py"))] + #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("TCH002.py"))] #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("strict.py"))] + #[test_case(Rule::UnquotedAnnotation, Path::new("TCH200_0.py"))] + #[test_case(Rule::UnquotedAnnotation, Path::new("TCH200_1.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff/src/rules/flake8_type_checking/rules/mod.rs b/crates/ruff/src/rules/flake8_type_checking/rules/mod.rs index 15ceb3ddf1..e3196f1695 100644 --- a/crates/ruff/src/rules/flake8_type_checking/rules/mod.rs +++ b/crates/ruff/src/rules/flake8_type_checking/rules/mod.rs @@ -1,7 +1,9 @@ pub(crate) use empty_type_checking_block::*; +pub(crate) use quoted_annotation::*; pub(crate) use runtime_import_in_type_checking_block::*; pub(crate) use typing_only_runtime_import::*; mod empty_type_checking_block; +mod quoted_annotation; mod runtime_import_in_type_checking_block; mod typing_only_runtime_import; diff --git a/crates/ruff/src/rules/flake8_type_checking/rules/quoted_annotation.rs b/crates/ruff/src/rules/flake8_type_checking/rules/quoted_annotation.rs new file mode 100644 index 0000000000..c1ccce4152 --- /dev/null +++ b/crates/ruff/src/rules/flake8_type_checking/rules/quoted_annotation.rs @@ -0,0 +1,100 @@ +use rustpython_parser::ast::{Expr, Ranged}; + +use ruff_diagnostics::{AutofixKind, Diagnostic, Edit, Fix, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_semantic::BindingId; + +use crate::checkers::ast::Checker; +use crate::registry::AsRule; + +/// ## What it does +/// Checks for the presence of unnecessary quotes in type annotations. +/// +/// ## Why is this bad? +/// In Python, type annotations can be quoted to avoid forward references. +/// However, if `from __future__ import annotations` is present, Python +/// will always evaluate type annotations in a deferred manner, making +/// the quotes unnecessary. +/// +/// ## Example +/// ```python +/// from __future__ import annotations +/// +/// +/// def foo(bar: "Bar") -> "Bar": +/// ... +/// ``` +/// +/// Use instead: +/// ```python +/// from __future__ import annotations +/// +/// +/// def foo(bar: Bar) -> Bar: +/// ... +/// ``` +/// +/// ## References +/// - [PEP 563](https://peps.python.org/pep-0563/) +/// - [Python documentation: `__future__`](https://docs.python.org/3/library/__future__.html#module-__future__) +#[violation] +pub struct UnquotedAnnotation { + name: String, +} + +impl Violation for UnquotedAnnotation { + const AUTOFIX: AutofixKind = AutofixKind::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + let UnquotedAnnotation { name } = self; + format!("Typing-only variable referenced in runtime annotation: `{name}`") + } + + fn autofix_title(&self) -> Option { + Some("Add quotes".to_string()) + } +} + +/// TCH200 +pub(crate) fn unquoted_annotation(checker: &mut Checker, binding_id: BindingId, expr: &Expr) { + // If we're already in a quoted annotation, skip. + if checker.semantic().in_deferred_type_definition() { + return; + } + + // If we're in a typing-only context, skip. + if checker.semantic().execution_context().is_typing() { + return; + } + + // If the reference resolved to a typing-only import, flag. + if checker.semantic().bindings[binding_id].context.is_typing() { + // Expand any attribute chains (e.g., flag `typing.List` in `typing.List[int]`). + let mut expr = expr; + for parent in checker.semantic().expr_ancestors() { + if parent.is_attribute_expr() { + expr = parent; + } else { + break; + } + } + + let mut diagnostic = Diagnostic::new( + UnquotedAnnotation { + name: checker.locator.slice(expr.range()).to_string(), + }, + expr.range(), + ); + if checker.patch(diagnostic.kind.rule()) { + // We can only _fix_ this if we're in a type annotation. + if checker.semantic().in_runtime_annotation() { + diagnostic.set_fix(Fix::automatic(Edit::range_replacement( + format!("\"{}\"", checker.locator.slice(expr.range()).to_string()), + expr.range(), + ))); + } + } + checker.diagnostics.push(diagnostic); + } +} diff --git a/crates/ruff/src/rules/flake8_type_checking/snapshots/ruff__rules__flake8_type_checking__tests__unquoted-annotation_TCH200_0.py.snap b/crates/ruff/src/rules/flake8_type_checking/snapshots/ruff__rules__flake8_type_checking__tests__unquoted-annotation_TCH200_0.py.snap new file mode 100644 index 0000000000..3474cfe270 --- /dev/null +++ b/crates/ruff/src/rules/flake8_type_checking/snapshots/ruff__rules__flake8_type_checking__tests__unquoted-annotation_TCH200_0.py.snap @@ -0,0 +1,92 @@ +--- +source: crates/ruff/src/rules/flake8_type_checking/mod.rs +--- +TCH200_0.py:8:12: TCH200 [*] Typing-only variable referenced in runtime annotation: `Class` + | +8 | def f(var: Class) -> Class: + | ^^^^^ TCH200 +9 | x: Class + | + = help: Add quotes + +ℹ Fix +5 5 | from module import Class +6 6 | +7 7 | +8 |-def f(var: Class) -> Class: + 8 |+def f(var: "Class") -> Class: +9 9 | x: Class +10 10 | +11 11 | + +TCH200_0.py:8:22: TCH200 [*] Typing-only variable referenced in runtime annotation: `Class` + | +8 | def f(var: Class) -> Class: + | ^^^^^ TCH200 +9 | x: Class + | + = help: Add quotes + +ℹ Fix +5 5 | from module import Class +6 6 | +7 7 | +8 |-def f(var: Class) -> Class: + 8 |+def f(var: Class) -> "Class": +9 9 | x: Class +10 10 | +11 11 | + +TCH200_0.py:12:12: TCH200 [*] Typing-only variable referenced in runtime annotation: `module.Class` + | +12 | def f(var: module.Class) -> module.Class: + | ^^^^^^^^^^^^ TCH200 +13 | x: module.Class + | + = help: Add quotes + +ℹ Fix +9 9 | x: Class +10 10 | +11 11 | +12 |-def f(var: module.Class) -> module.Class: + 12 |+def f(var: "module.Class") -> module.Class: +13 13 | x: module.Class +14 14 | +15 15 | + +TCH200_0.py:12:29: TCH200 [*] Typing-only variable referenced in runtime annotation: `module.Class` + | +12 | def f(var: module.Class) -> module.Class: + | ^^^^^^^^^^^^ TCH200 +13 | x: module.Class + | + = help: Add quotes + +ℹ Fix +9 9 | x: Class +10 10 | +11 11 | +12 |-def f(var: module.Class) -> module.Class: + 12 |+def f(var: module.Class) -> "module.Class": +13 13 | x: module.Class +14 14 | +15 15 | + +TCH200_0.py:17:11: TCH200 Typing-only variable referenced in runtime annotation: `Class` + | +16 | def f(): +17 | print(Class) + | ^^^^^ TCH200 + | + = help: Add quotes + +TCH200_0.py:21:11: TCH200 Typing-only variable referenced in runtime annotation: `module.Class` + | +20 | def f(): +21 | print(module.Class) + | ^^^^^^^^^^^^ TCH200 + | + = help: Add quotes + + diff --git a/crates/ruff/src/rules/flake8_type_checking/snapshots/ruff__rules__flake8_type_checking__tests__unquoted-annotation_TCH200_1.py.snap b/crates/ruff/src/rules/flake8_type_checking/snapshots/ruff__rules__flake8_type_checking__tests__unquoted-annotation_TCH200_1.py.snap new file mode 100644 index 0000000000..abbbb06448 --- /dev/null +++ b/crates/ruff/src/rules/flake8_type_checking/snapshots/ruff__rules__flake8_type_checking__tests__unquoted-annotation_TCH200_1.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff/src/rules/flake8_type_checking/mod.rs +--- + diff --git a/crates/ruff/src/rules/pyupgrade/snapshots/ruff__rules__pyupgrade__tests__UP037.py.snap b/crates/ruff/src/rules/pyupgrade/snapshots/ruff__rules__pyupgrade__tests__UP037.py.snap deleted file mode 100644 index 8d3af3b967..0000000000 --- a/crates/ruff/src/rules/pyupgrade/snapshots/ruff__rules__pyupgrade__tests__UP037.py.snap +++ /dev/null @@ -1,559 +0,0 @@ ---- -source: crates/ruff/src/rules/pyupgrade/mod.rs ---- -UP037.py:18:14: UP037 [*] Remove quotes from type annotation - | -18 | def foo(var: "MyClass") -> "MyClass": - | ^^^^^^^^^ UP037 -19 | x: "MyClass" - | - = help: Remove quotes - -ℹ Fix -15 15 | from mypy_extensions import Arg, DefaultArg, DefaultNamedArg, NamedArg, VarArg -16 16 | -17 17 | -18 |-def foo(var: "MyClass") -> "MyClass": - 18 |+def foo(var: MyClass) -> "MyClass": -19 19 | x: "MyClass" -20 20 | -21 21 | - -UP037.py:18:28: UP037 [*] Remove quotes from type annotation - | -18 | def foo(var: "MyClass") -> "MyClass": - | ^^^^^^^^^ UP037 -19 | x: "MyClass" - | - = help: Remove quotes - -ℹ Fix -15 15 | from mypy_extensions import Arg, DefaultArg, DefaultNamedArg, NamedArg, VarArg -16 16 | -17 17 | -18 |-def foo(var: "MyClass") -> "MyClass": - 18 |+def foo(var: "MyClass") -> MyClass: -19 19 | x: "MyClass" -20 20 | -21 21 | - -UP037.py:19:8: UP037 [*] Remove quotes from type annotation - | -18 | def foo(var: "MyClass") -> "MyClass": -19 | x: "MyClass" - | ^^^^^^^^^ UP037 - | - = help: Remove quotes - -ℹ Fix -16 16 | -17 17 | -18 18 | def foo(var: "MyClass") -> "MyClass": -19 |- x: "MyClass" - 19 |+ x: MyClass -20 20 | -21 21 | -22 22 | def foo(*, inplace: "bool"): - -UP037.py:22:21: UP037 [*] Remove quotes from type annotation - | -22 | def foo(*, inplace: "bool"): - | ^^^^^^ UP037 -23 | pass - | - = help: Remove quotes - -ℹ Fix -19 19 | x: "MyClass" -20 20 | -21 21 | -22 |-def foo(*, inplace: "bool"): - 22 |+def foo(*, inplace: bool): -23 23 | pass -24 24 | -25 25 | - -UP037.py:26:16: UP037 [*] Remove quotes from type annotation - | -26 | def foo(*args: "str", **kwargs: "int"): - | ^^^^^ UP037 -27 | pass - | - = help: Remove quotes - -ℹ Fix -23 23 | pass -24 24 | -25 25 | -26 |-def foo(*args: "str", **kwargs: "int"): - 26 |+def foo(*args: str, **kwargs: "int"): -27 27 | pass -28 28 | -29 29 | - -UP037.py:26:33: UP037 [*] Remove quotes from type annotation - | -26 | def foo(*args: "str", **kwargs: "int"): - | ^^^^^ UP037 -27 | pass - | - = help: Remove quotes - -ℹ Fix -23 23 | pass -24 24 | -25 25 | -26 |-def foo(*args: "str", **kwargs: "int"): - 26 |+def foo(*args: "str", **kwargs: int): -27 27 | pass -28 28 | -29 29 | - -UP037.py:30:10: UP037 [*] Remove quotes from type annotation - | -30 | x: Tuple["MyClass"] - | ^^^^^^^^^ UP037 -31 | -32 | x: Callable[["MyClass"], None] - | - = help: Remove quotes - -ℹ Fix -27 27 | pass -28 28 | -29 29 | -30 |-x: Tuple["MyClass"] - 30 |+x: Tuple[MyClass] -31 31 | -32 32 | x: Callable[["MyClass"], None] -33 33 | - -UP037.py:32:14: UP037 [*] Remove quotes from type annotation - | -30 | x: Tuple["MyClass"] -31 | -32 | x: Callable[["MyClass"], None] - | ^^^^^^^^^ UP037 - | - = help: Remove quotes - -ℹ Fix -29 29 | -30 30 | x: Tuple["MyClass"] -31 31 | -32 |-x: Callable[["MyClass"], None] - 32 |+x: Callable[[MyClass], None] -33 33 | -34 34 | -35 35 | class Foo(NamedTuple): - -UP037.py:36:8: UP037 [*] Remove quotes from type annotation - | -35 | class Foo(NamedTuple): -36 | x: "MyClass" - | ^^^^^^^^^ UP037 - | - = help: Remove quotes - -ℹ Fix -33 33 | -34 34 | -35 35 | class Foo(NamedTuple): -36 |- x: "MyClass" - 36 |+ x: MyClass -37 37 | -38 38 | -39 39 | class D(TypedDict): - -UP037.py:40:27: UP037 [*] Remove quotes from type annotation - | -39 | class D(TypedDict): -40 | E: TypedDict("E", foo="int", total=False) - | ^^^^^ UP037 - | - = help: Remove quotes - -ℹ Fix -37 37 | -38 38 | -39 39 | class D(TypedDict): -40 |- E: TypedDict("E", foo="int", total=False) - 40 |+ E: TypedDict("E", foo=int, total=False) -41 41 | -42 42 | -43 43 | class D(TypedDict): - -UP037.py:44:31: UP037 [*] Remove quotes from type annotation - | -43 | class D(TypedDict): -44 | E: TypedDict("E", {"foo": "int"}) - | ^^^^^ UP037 - | - = help: Remove quotes - -ℹ Fix -41 41 | -42 42 | -43 43 | class D(TypedDict): -44 |- E: TypedDict("E", {"foo": "int"}) - 44 |+ E: TypedDict("E", {"foo": int}) -45 45 | -46 46 | -47 47 | x: Annotated["str", "metadata"] - -UP037.py:47:14: UP037 [*] Remove quotes from type annotation - | -47 | x: Annotated["str", "metadata"] - | ^^^^^ UP037 -48 | -49 | x: Arg("str", "name") - | - = help: Remove quotes - -ℹ Fix -44 44 | E: TypedDict("E", {"foo": "int"}) -45 45 | -46 46 | -47 |-x: Annotated["str", "metadata"] - 47 |+x: Annotated[str, "metadata"] -48 48 | -49 49 | x: Arg("str", "name") -50 50 | - -UP037.py:49:8: UP037 [*] Remove quotes from type annotation - | -47 | x: Annotated["str", "metadata"] -48 | -49 | x: Arg("str", "name") - | ^^^^^ UP037 -50 | -51 | x: DefaultArg("str", "name") - | - = help: Remove quotes - -ℹ Fix -46 46 | -47 47 | x: Annotated["str", "metadata"] -48 48 | -49 |-x: Arg("str", "name") - 49 |+x: Arg(str, "name") -50 50 | -51 51 | x: DefaultArg("str", "name") -52 52 | - -UP037.py:51:15: UP037 [*] Remove quotes from type annotation - | -49 | x: Arg("str", "name") -50 | -51 | x: DefaultArg("str", "name") - | ^^^^^ UP037 -52 | -53 | x: NamedArg("str", "name") - | - = help: Remove quotes - -ℹ Fix -48 48 | -49 49 | x: Arg("str", "name") -50 50 | -51 |-x: DefaultArg("str", "name") - 51 |+x: DefaultArg(str, "name") -52 52 | -53 53 | x: NamedArg("str", "name") -54 54 | - -UP037.py:53:13: UP037 [*] Remove quotes from type annotation - | -51 | x: DefaultArg("str", "name") -52 | -53 | x: NamedArg("str", "name") - | ^^^^^ UP037 -54 | -55 | x: DefaultNamedArg("str", "name") - | - = help: Remove quotes - -ℹ Fix -50 50 | -51 51 | x: DefaultArg("str", "name") -52 52 | -53 |-x: NamedArg("str", "name") - 53 |+x: NamedArg(str, "name") -54 54 | -55 55 | x: DefaultNamedArg("str", "name") -56 56 | - -UP037.py:55:20: UP037 [*] Remove quotes from type annotation - | -53 | x: NamedArg("str", "name") -54 | -55 | x: DefaultNamedArg("str", "name") - | ^^^^^ UP037 -56 | -57 | x: DefaultNamedArg("str", name="name") - | - = help: Remove quotes - -ℹ Fix -52 52 | -53 53 | x: NamedArg("str", "name") -54 54 | -55 |-x: DefaultNamedArg("str", "name") - 55 |+x: DefaultNamedArg(str, "name") -56 56 | -57 57 | x: DefaultNamedArg("str", name="name") -58 58 | - -UP037.py:57:20: UP037 [*] Remove quotes from type annotation - | -55 | x: DefaultNamedArg("str", "name") -56 | -57 | x: DefaultNamedArg("str", name="name") - | ^^^^^ UP037 -58 | -59 | x: VarArg("str") - | - = help: Remove quotes - -ℹ Fix -54 54 | -55 55 | x: DefaultNamedArg("str", "name") -56 56 | -57 |-x: DefaultNamedArg("str", name="name") - 57 |+x: DefaultNamedArg(str, name="name") -58 58 | -59 59 | x: VarArg("str") -60 60 | - -UP037.py:59:11: UP037 [*] Remove quotes from type annotation - | -57 | x: DefaultNamedArg("str", name="name") -58 | -59 | x: VarArg("str") - | ^^^^^ UP037 -60 | -61 | x: List[List[List["MyClass"]]] - | - = help: Remove quotes - -ℹ Fix -56 56 | -57 57 | x: DefaultNamedArg("str", name="name") -58 58 | -59 |-x: VarArg("str") - 59 |+x: VarArg(str) -60 60 | -61 61 | x: List[List[List["MyClass"]]] -62 62 | - -UP037.py:61:19: UP037 [*] Remove quotes from type annotation - | -59 | x: VarArg("str") -60 | -61 | x: List[List[List["MyClass"]]] - | ^^^^^^^^^ UP037 -62 | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) - | - = help: Remove quotes - -ℹ Fix -58 58 | -59 59 | x: VarArg("str") -60 60 | -61 |-x: List[List[List["MyClass"]]] - 61 |+x: List[List[List[MyClass]]] -62 62 | -63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 64 | - -UP037.py:63:29: UP037 [*] Remove quotes from type annotation - | -61 | x: List[List[List["MyClass"]]] -62 | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) - | ^^^^^ UP037 -64 | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - | - = help: Remove quotes - -ℹ Fix -60 60 | -61 61 | x: List[List[List["MyClass"]]] -62 62 | -63 |-x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) - 63 |+x: NamedTuple("X", [("foo", int), ("bar", "str")]) -64 64 | -65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 66 | - -UP037.py:63:45: UP037 [*] Remove quotes from type annotation - | -61 | x: List[List[List["MyClass"]]] -62 | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) - | ^^^^^ UP037 -64 | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - | - = help: Remove quotes - -ℹ Fix -60 60 | -61 61 | x: List[List[List["MyClass"]]] -62 62 | -63 |-x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) - 63 |+x: NamedTuple("X", [("foo", "int"), ("bar", str)]) -64 64 | -65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 66 | - -UP037.py:65:29: UP037 [*] Remove quotes from type annotation - | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - | ^^^^^ UP037 -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | - = help: Remove quotes - -ℹ Fix -62 62 | -63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 64 | -65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - 65 |+x: NamedTuple("X", fields=[(foo, "int"), ("bar", "str")]) -66 66 | -67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) -68 68 | - -UP037.py:65:36: UP037 [*] Remove quotes from type annotation - | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - | ^^^^^ UP037 -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | - = help: Remove quotes - -ℹ Fix -62 62 | -63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 64 | -65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - 65 |+x: NamedTuple("X", fields=[("foo", int), ("bar", "str")]) -66 66 | -67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) -68 68 | - -UP037.py:65:45: UP037 [*] Remove quotes from type annotation - | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - | ^^^^^ UP037 -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | - = help: Remove quotes - -ℹ Fix -62 62 | -63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 64 | -65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - 65 |+x: NamedTuple("X", fields=[("foo", "int"), (bar, "str")]) -66 66 | -67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) -68 68 | - -UP037.py:65:52: UP037 [*] Remove quotes from type annotation - | -63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - | ^^^^^ UP037 -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | - = help: Remove quotes - -ℹ Fix -62 62 | -63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) -64 64 | -65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) - 65 |+x: NamedTuple("X", fields=[("foo", "int"), ("bar", str)]) -66 66 | -67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) -68 68 | - -UP037.py:67:24: UP037 [*] Remove quotes from type annotation - | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | ^^^ UP037 -68 | -69 | X: MyCallable("X") - | - = help: Remove quotes - -ℹ Fix -64 64 | -65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 66 | -67 |-x: NamedTuple(typename="X", fields=[("foo", "int")]) - 67 |+x: NamedTuple(typename=X, fields=[("foo", "int")]) -68 68 | -69 69 | X: MyCallable("X") -70 70 | - -UP037.py:67:38: UP037 [*] Remove quotes from type annotation - | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | ^^^^^ UP037 -68 | -69 | X: MyCallable("X") - | - = help: Remove quotes - -ℹ Fix -64 64 | -65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 66 | -67 |-x: NamedTuple(typename="X", fields=[("foo", "int")]) - 67 |+x: NamedTuple(typename="X", fields=[(foo, "int")]) -68 68 | -69 69 | X: MyCallable("X") -70 70 | - -UP037.py:67:45: UP037 [*] Remove quotes from type annotation - | -65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 | -67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) - | ^^^^^ UP037 -68 | -69 | X: MyCallable("X") - | - = help: Remove quotes - -ℹ Fix -64 64 | -65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) -66 66 | -67 |-x: NamedTuple(typename="X", fields=[("foo", "int")]) - 67 |+x: NamedTuple(typename="X", fields=[("foo", int)]) -68 68 | -69 69 | X: MyCallable("X") -70 70 | - - diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 38bd3c4783..c3fac793f1 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -275,6 +275,7 @@ impl<'a> SemanticModel<'a> { let mut seen_function = false; let mut import_starred = false; + let mut annotation_id = None; for (index, scope_id) in self.scopes.ancestor_ids(self.scope_id).enumerate() { let scope = &self.scopes[scope_id]; if scope.kind.is_class() { @@ -293,7 +294,7 @@ impl<'a> SemanticModel<'a> { } } - if let Some(binding_id) = scope.get(symbol) { + for binding_id in scope.get_all(symbol) { // Mark the binding as used. let context = self.execution_context(); let reference_id = self.references.push(self.scope_id, range, context); @@ -305,6 +306,27 @@ impl<'a> SemanticModel<'a> { self.bindings[binding_id].references.push(reference_id); } + // If we're in a runtime context, but the binding is typing-only, don't treat + // it as resolved. For example, given: + // + // ```python + // from typing import TYPE_CHECKING + // + // if TYPE_CHECKING: + // from foo import Foo + // + // Foo() + // ``` + // + // The `Foo` in `Foo()` should be treated as unresolved at runtime, but the `Foo` in + // `from foo import Foo` should be treated as used. + if self.bindings[binding_id].context.is_typing() { + if self.execution_context().is_runtime() { + annotation_id = Some(binding_id); + continue; + } + } + match self.bindings[binding_id].kind { // If it's a type annotation, don't treat it as resolved. For example, given: // @@ -405,7 +427,9 @@ impl<'a> SemanticModel<'a> { import_starred = import_starred || scope.uses_star_imports(); } - if import_starred { + if let Some(annotation_id) = annotation_id { + ResolvedRead::TypingOnly(annotation_id) + } else if import_starred { ResolvedRead::WildcardImport } else { ResolvedRead::NotFound @@ -1380,6 +1404,23 @@ pub enum ResolvedRead { /// The `x` in `print(x)` is resolved to the binding of `x` in `x = 1`. Resolved(BindingId), + /// The read reference is resolved to a specific binding, but the binding is only visible + /// at type-checking time. + /// + /// For example, given: + /// ```python + /// from typing import TYPE_CHECKING + /// + /// if TYPE_CHECKING: + /// from foo import Foo + /// + /// Foo() + /// ``` + /// + /// The `Foo` in `Foo()` is resolved to the binding of `Foo` in `from foo import Foo`, but + /// the binding is only visible at type-checking time. + TypingOnly(BindingId), + /// The read reference is resolved to a context-specific, implicit global (e.g., `__class__` /// within a class scope). ///