From 6fd10e2fe7d84774c079d6563b965a15bcd8132c Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 27 Nov 2024 09:51:20 +0100 Subject: [PATCH] [`flake8-type-checking`] Adds implementation for TC007 and TC008 (#12927) Co-authored-by: Simon Brugman Co-authored-by: Carl Meyer --- .../fixtures/flake8_type_checking/TC007.py | 31 ++ .../fixtures/flake8_type_checking/TC008.py | 52 +++ .../fixtures/flake8_type_checking/quote.py | 9 + .../src/checkers/ast/analyze/bindings.rs | 12 +- .../checkers/ast/analyze/deferred_scopes.rs | 4 + crates/ruff_linter/src/checkers/ast/mod.rs | 72 ++- crates/ruff_linter/src/codes.rs | 2 + .../src/rules/flake8_type_checking/mod.rs | 40 ++ .../rules/flake8_type_checking/rules/mod.rs | 2 + .../runtime_import_in_type_checking_block.rs | 8 + .../rules/runtime_string_union.rs | 2 +- .../rules/type_alias_quotes.rs | 309 +++++++++++++ ...mport-in-type-checking-block_quote.py.snap | 27 +- ...ng__tests__quoted-type-alias_TC008.py.snap | 420 ++++++++++++++++++ ...mport-in-type-checking-block_quote.py.snap | 25 +- ...g__tests__tc004_precedence_over_tc007.snap | 24 + ...g__tests__tc010_precedence_over_tc008.snap | 27 ++ ...__tests__unquoted-type-alias_TC007.py.snap | 192 ++++++++ crates/ruff_python_semantic/src/binding.rs | 42 ++ crates/ruff_python_semantic/src/model.rs | 183 ++++++++ crates/ruff_python_semantic/src/reference.rs | 8 + ruff.schema.json | 2 + 22 files changed, 1483 insertions(+), 10 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC007.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC008.py create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quoted-type-alias_TC008.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc004_precedence_over_tc007.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc010_precedence_over_tc008.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__unquoted-type-alias_TC007.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC007.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC007.py new file mode 100644 index 0000000000..8ad60ef55c --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC007.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Dict, TypeAlias, TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict + + from foo import Foo + + OptStr: TypeAlias = str | None + Bar: TypeAlias = Foo[int] + +a: TypeAlias = int # OK +b: TypeAlias = Dict # OK +c: TypeAlias = Foo # TC007 +d: TypeAlias = Foo | None # TC007 +e: TypeAlias = OptStr # TC007 +f: TypeAlias = Bar # TC007 +g: TypeAlias = Foo | Bar # TC007 x2 +h: TypeAlias = Foo[str] # TC007 +i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) + Bar) + +type C = Foo # OK +type D = Foo | None # OK +type E = OptStr # OK +type F = Bar # OK +type G = Foo | Bar # OK +type H = Foo[str] # OK +type I = (Foo | # OK + Bar) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC008.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC008.py new file mode 100644 index 0000000000..b6ca0e8e5d --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC008.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import TypeAlias, TYPE_CHECKING + +from foo import Foo + +if TYPE_CHECKING: + from typing import Dict + + OptStr: TypeAlias = str | None + Bar: TypeAlias = Foo[int] +else: + Bar = Foo + +a: TypeAlias = 'int' # TC008 +b: TypeAlias = 'Dict' # OK +c: TypeAlias = 'Foo' # TC008 +d: TypeAlias = 'Foo[str]' # OK +e: TypeAlias = 'Foo.bar' # OK +f: TypeAlias = 'Foo | None' # TC008 +g: TypeAlias = 'OptStr' # OK +h: TypeAlias = 'Bar' # TC008 +i: TypeAlias = Foo['str'] # TC008 +j: TypeAlias = 'Baz' # OK +k: TypeAlias = 'k | None' # OK +l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) +m: TypeAlias = ('int' # TC008 + | None) +n: TypeAlias = ('int' # TC008 (fix removes comment currently) + ' | None') + +type B = 'Dict' # TC008 +type D = 'Foo[str]' # TC008 +type E = 'Foo.bar' # TC008 +type G = 'OptStr' # TC008 +type I = Foo['str'] # TC008 +type J = 'Baz' # TC008 +type K = 'K | None' # TC008 +type L = 'int' | None # TC008 (because TC010 is not enabled) +type M = ('int' # TC008 + | None) +type N = ('int' # TC008 (fix removes comment currently) + ' | None') + + +class Baz: + a: TypeAlias = 'Baz' # OK + type A = 'Baz' # TC008 + + class Nested: + a: TypeAlias = 'Baz' # OK + type A = 'Baz' # TC008 diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py index d344b0cfac..c41b8db622 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py @@ -101,3 +101,12 @@ def f(): def test_annotated_non_typing_reference(user: Annotated[str, Depends(get_foo)]): pass + + +def f(): + from typing import TypeAlias, TYPE_CHECKING + + if TYPE_CHECKING: + from pandas import DataFrame + + x: TypeAlias = DataFrame | None diff --git a/crates/ruff_linter/src/checkers/ast/analyze/bindings.rs b/crates/ruff_linter/src/checkers/ast/analyze/bindings.rs index 586fa7ada0..6413de436b 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/bindings.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/bindings.rs @@ -3,7 +3,9 @@ use ruff_text_size::Ranged; use crate::checkers::ast::Checker; use crate::codes::Rule; -use crate::rules::{flake8_import_conventions, flake8_pyi, pyflakes, pylint, ruff}; +use crate::rules::{ + flake8_import_conventions, flake8_pyi, flake8_type_checking, pyflakes, pylint, ruff, +}; /// Run lint rules over the [`Binding`]s. pub(crate) fn bindings(checker: &mut Checker) { @@ -15,6 +17,7 @@ pub(crate) fn bindings(checker: &mut Checker) { Rule::UnconventionalImportAlias, Rule::UnsortedDunderSlots, Rule::UnusedVariable, + Rule::UnquotedTypeAlias, ]) { return; } @@ -72,6 +75,13 @@ pub(crate) fn bindings(checker: &mut Checker) { checker.diagnostics.push(diagnostic); } } + if checker.enabled(Rule::UnquotedTypeAlias) { + if let Some(diagnostics) = + flake8_type_checking::rules::unquoted_type_alias(checker, binding) + { + checker.diagnostics.extend(diagnostics); + } + } if checker.enabled(Rule::UnsortedDunderSlots) { if let Some(diagnostic) = ruff::rules::sort_dunder_slots(checker, binding) { checker.diagnostics.push(diagnostic); diff --git a/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs b/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs index 003db1d741..1b1bfcd489 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs @@ -52,6 +52,8 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) { // Identify any valid runtime imports. If a module is imported at runtime, and // used at runtime, then by default, we avoid flagging any other // imports from that model as typing-only. + // FIXME: This does not seem quite right, if only TC004 is enabled + // then we don't need to collect the runtime imports let enforce_typing_imports = !checker.source_type.is_stub() && checker.any_enabled(&[ Rule::RuntimeImportInTypeCheckingBlock, @@ -375,6 +377,8 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) { } if matches!(scope.kind, ScopeKind::Function(_) | ScopeKind::Module) { + // FIXME: This does not seem quite right, if only TC004 is enabled + // then we don't need to collect the runtime imports if enforce_typing_imports { let runtime_imports: Vec<&Binding> = checker .semantic diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 97154f4fcc..ca5afee26c 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -888,9 +888,7 @@ impl<'a> Visitor<'a> for Checker<'a> { if let Some(type_params) = type_params { self.visit_type_params(type_params); } - self.visit - .type_param_definitions - .push((value, self.semantic.snapshot())); + self.visit_deferred_type_alias_value(value); self.semantic.pop_scope(); self.visit_expr(name); } @@ -961,7 +959,7 @@ impl<'a> Visitor<'a> for Checker<'a> { if let Some(expr) = value { if self.semantic.match_typing_expr(annotation, "TypeAlias") { - self.visit_type_definition(expr); + self.visit_annotated_type_alias_value(expr); } else { self.visit_expr(expr); } @@ -1855,6 +1853,45 @@ impl<'a> Checker<'a> { self.semantic.flags = snapshot; } + /// Visit an [`Expr`], and treat it as the value expression + /// of a [PEP 613] type alias. + /// + /// For example: + /// ```python + /// from typing import TypeAlias + /// + /// OptStr: TypeAlias = str | None # We're visiting the RHS + /// ``` + /// + /// [PEP 613]: https://peps.python.org/pep-0613/ + fn visit_annotated_type_alias_value(&mut self, expr: &'a Expr) { + let snapshot = self.semantic.flags; + self.semantic.flags |= SemanticModelFlags::ANNOTATED_TYPE_ALIAS; + self.visit_type_definition(expr); + self.semantic.flags = snapshot; + } + + /// Visit an [`Expr`], and treat it as the value expression + /// of a [PEP 695] type alias. + /// + /// For example: + /// ```python + /// type OptStr = str | None # We're visiting the RHS + /// ``` + /// + /// [PEP 695]: https://peps.python.org/pep-0695/#generic-type-alias + fn visit_deferred_type_alias_value(&mut self, expr: &'a Expr) { + let snapshot = self.semantic.flags; + // even though we don't visit these nodes immediately we need to + // modify the semantic flags before we push the expression and its + // corresponding semantic snapshot + self.semantic.flags |= SemanticModelFlags::DEFERRED_TYPE_ALIAS; + self.visit + .type_param_definitions + .push((expr, self.semantic.snapshot())); + self.semantic.flags = snapshot; + } + /// Visit an [`Expr`], and treat it as a type definition. fn visit_type_definition(&mut self, expr: &'a Expr) { if self.semantic.in_no_type_check() { @@ -2017,6 +2054,21 @@ impl<'a> Checker<'a> { flags.insert(BindingFlags::UNPACKED_ASSIGNMENT); } + match parent { + Stmt::TypeAlias(_) => flags.insert(BindingFlags::DEFERRED_TYPE_ALIAS), + Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. }) => { + // TODO: It is a bit unfortunate that we do this check twice + // maybe we should change how we visit this statement + // so the semantic flag for the type alias sticks around + // until after we've handled this store, so we can check + // the flag instead of duplicating this check + if self.semantic.match_typing_expr(annotation, "TypeAlias") { + flags.insert(BindingFlags::ANNOTATED_TYPE_ALIAS); + } + } + _ => {} + } + let scope = self.semantic.current_scope(); if scope.kind.is_module() @@ -2272,7 +2324,17 @@ impl<'a> Checker<'a> { self.semantic.flags |= SemanticModelFlags::TYPE_DEFINITION | type_definition_flag; - self.visit_expr(parsed_annotation.expression()); + let parsed_expr = parsed_annotation.expression(); + self.visit_expr(parsed_expr); + if self.semantic.in_type_alias_value() { + if self.enabled(Rule::QuotedTypeAlias) { + flake8_type_checking::rules::quoted_type_alias( + self, + parsed_expr, + string_expr, + ); + } + } self.parsed_type_annotation = None; } else { if self.enabled(Rule::ForwardAnnotationSyntaxError) { diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 1c0fb68f73..1ce84c6a2b 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -858,6 +858,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8TypeChecking, "004") => (RuleGroup::Stable, rules::flake8_type_checking::rules::RuntimeImportInTypeCheckingBlock), (Flake8TypeChecking, "005") => (RuleGroup::Stable, rules::flake8_type_checking::rules::EmptyTypeCheckingBlock), (Flake8TypeChecking, "006") => (RuleGroup::Preview, rules::flake8_type_checking::rules::RuntimeCastValue), + (Flake8TypeChecking, "007") => (RuleGroup::Preview, rules::flake8_type_checking::rules::UnquotedTypeAlias), + (Flake8TypeChecking, "008") => (RuleGroup::Preview, rules::flake8_type_checking::rules::QuotedTypeAlias), (Flake8TypeChecking, "010") => (RuleGroup::Stable, rules::flake8_type_checking::rules::RuntimeStringUnion), // tryceratops diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs index c4ee167951..ab95fe3ac5 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs @@ -63,6 +63,23 @@ mod tests { Ok(()) } + // we test these rules as a pair, since they're opposites of one another + // so we want to make sure their fixes are not going around in circles. + #[test_case(Rule::UnquotedTypeAlias, Path::new("TC007.py"))] + #[test_case(Rule::QuotedTypeAlias, Path::new("TC008.py"))] + fn type_alias_rules(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_type_checking").join(path).as_path(), + &settings::LinterSettings::for_rules(vec![ + Rule::UnquotedTypeAlias, + Rule::QuotedTypeAlias, + ]), + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) + } + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote.py"))] #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))] #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote2.py"))] @@ -431,6 +448,29 @@ mod tests { ", "multiple_modules_different_types" )] + #[test_case( + r" + from __future__ import annotations + + from typing import TYPE_CHECKING, TypeAlias + if TYPE_CHECKING: + from foo import Foo # TC004 + + a: TypeAlias = Foo | None # OK + ", + "tc004_precedence_over_tc007" + )] + #[test_case( + r" + from __future__ import annotations + + from typing import TypeAlias + + a: TypeAlias = 'int | None' # TC008 + b: TypeAlias = 'int' | None # TC010 + ", + "tc010_precedence_over_tc008" + )] fn contents(contents: &str, snapshot: &str) { let diagnostics = test_snippet( contents, diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/mod.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/mod.rs index f3a41b66a8..5cefae4b2e 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/mod.rs @@ -2,10 +2,12 @@ pub(crate) use empty_type_checking_block::*; pub(crate) use runtime_cast_value::*; pub(crate) use runtime_import_in_type_checking_block::*; pub(crate) use runtime_string_union::*; +pub(crate) use type_alias_quotes::*; pub(crate) use typing_only_runtime_import::*; mod empty_type_checking_block; mod runtime_cast_value; mod runtime_import_in_type_checking_block; mod runtime_string_union; +mod type_alias_quotes; mod typing_only_runtime_import; diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs index 3444dfc503..b7ef64f96f 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs @@ -151,6 +151,14 @@ pub(crate) fn runtime_import_in_type_checking_block( } 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); diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs index ddeb010770..900d6f4cce 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_string_union.rs @@ -51,7 +51,7 @@ impl Violation for RuntimeStringUnion { } } -/// TC006 +/// TC010 pub(crate) fn runtime_string_union(checker: &mut Checker, expr: &Expr) { if !checker.semantic().in_type_definition() { return; diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs new file mode 100644 index 0000000000..e2f9fbc7f2 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/type_alias_quotes.rs @@ -0,0 +1,309 @@ +use crate::registry::Rule; +use ast::{ExprContext, Operator}; +use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix, FixAvailability, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast as ast; +use ruff_python_ast::{Expr, Stmt}; +use ruff_python_semantic::{Binding, SemanticModel}; +use ruff_python_stdlib::typing::{is_pep_593_generic_type, is_standard_library_literal}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::rules::flake8_type_checking::helpers::quote_type_expression; + +/// ## What it does +/// Checks if [PEP 613] explicit type aliases contain references to +/// symbols that are not available at runtime. +/// +/// ## Why is this bad? +/// Referencing type-checking only symbols results in a `NameError` at runtime. +/// +/// ## Example +/// ```python +/// from typing import TYPE_CHECKING, TypeAlias +/// +/// if TYPE_CHECKING: +/// from foo import Foo +/// OptFoo: TypeAlias = Foo | None +/// ``` +/// +/// Use instead: +/// ```python +/// from typing import TYPE_CHECKING, TypeAlias +/// +/// if TYPE_CHECKING: +/// from foo import Foo +/// OptFoo: TypeAlias = "Foo | None" +/// ``` +/// +/// ## Fix safety +/// This rule's fix is currently always marked as unsafe, since runtime +/// typing libraries may try to access/resolve the type alias in a way +/// that we can't statically determine during analysis and relies on the +/// type alias not containing any forward references. +/// +/// ## References +/// - [PEP 613 – Explicit Type Aliases](https://peps.python.org/pep-0613/) +/// +/// [PEP 613]: https://peps.python.org/pep-0613/ +#[violation] +pub struct UnquotedTypeAlias; + +impl Violation for UnquotedTypeAlias { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + "Add quotes to type alias".to_string() + } + + fn fix_title(&self) -> Option { + Some("Add quotes".to_string()) + } +} + +/// ## What it does +/// Checks for unnecessary quotes in [PEP 613] explicit type aliases +/// and [PEP 695] type statements. +/// +/// ## Why is this bad? +/// Unnecessary string forward references can lead to additional overhead +/// in runtime libraries making use of type hints, as well as lead to bad +/// interactions with other runtime uses like [PEP 604] type unions. +/// +/// For explicit type aliases the quotes are only considered redundant +/// if the type expression contains no subscripts or attribute accesses +/// this is because of stubs packages. Some types will only be subscriptable +/// at type checking time, similarly there may be some module-level +/// attributes like type aliases that are only available in the stubs. +/// +/// ## Example +/// Given: +/// ```python +/// OptInt: TypeAlias = "int | None" +/// ``` +/// +/// Use instead: +/// ```python +/// OptInt: TypeAlias = int | None +/// ``` +/// +/// Given: +/// ```python +/// type OptInt = "int | None" +/// ``` +/// +/// Use instead: +/// ```python +/// type OptInt = int | None +/// ``` +/// +/// ## Fix safety +/// This rule's fix is marked as safe, unless the type annotation contains comments. +/// +/// ## References +/// - [PEP 613 – Explicit Type Aliases](https://peps.python.org/pep-0613/) +/// - [PEP 695: Generic Type Alias](https://peps.python.org/pep-0695/#generic-type-alias) +/// - [PEP 604 – Allow writing union types as `X | Y`](https://peps.python.org/pep-0604/) +/// +/// [PEP 604]: https://peps.python.org/pep-0604/ +/// [PEP 613]: https://peps.python.org/pep-0613/ +/// [PEP 695]: https://peps.python.org/pep-0695/#generic-type-alias +#[violation] +pub struct QuotedTypeAlias; + +impl AlwaysFixableViolation for QuotedTypeAlias { + #[derive_message_formats] + fn message(&self) -> String { + "Remove quotes from type alias".to_string() + } + + fn fix_title(&self) -> String { + "Remove quotes".to_string() + } +} + +/// TC007 +pub(crate) fn unquoted_type_alias(checker: &Checker, binding: &Binding) -> Option> { + if binding.context.is_typing() { + return None; + } + + if !binding.is_annotated_type_alias() { + return None; + } + + let Some(Stmt::AnnAssign(ast::StmtAnnAssign { + value: Some(expr), .. + })) = binding.statement(checker.semantic()) + else { + return None; + }; + + let mut names = Vec::new(); + collect_typing_references(checker, expr, &mut names); + if names.is_empty() { + return None; + } + + // We generate a diagnostic for every name that needs to be quoted + // but we currently emit a single shared fix that quotes the entire + // expression. + // + // Eventually we may try to be more clever and come up with the + // minimal set of subexpressions that need to be quoted. + let parent = expr.range().start(); + let edit = quote_type_expression(expr, checker.semantic(), checker.stylist()); + let mut diagnostics = Vec::with_capacity(names.len()); + for name in names { + let mut diagnostic = Diagnostic::new(UnquotedTypeAlias, name.range()); + diagnostic.set_parent(parent); + if let Ok(edit) = edit.as_ref() { + diagnostic.set_fix(Fix::unsafe_edit(edit.clone())); + } + diagnostics.push(diagnostic); + } + Some(diagnostics) +} + +/// Traverses the type expression and collects `[Expr::Name]` nodes that are +/// not available at runtime and thus need to be quoted, unless they would +/// become available through `[Rule::RuntimeImportInTypeCheckingBlock]`. +fn collect_typing_references<'a>( + checker: &Checker, + expr: &'a Expr, + names: &mut Vec<&'a ast::ExprName>, +) { + match expr { + Expr::BinOp(ast::ExprBinOp { left, right, .. }) => { + collect_typing_references(checker, left, names); + collect_typing_references(checker, right, names); + } + Expr::Starred(ast::ExprStarred { + value, + ctx: ExprContext::Load, + .. + }) + | Expr::Attribute(ast::ExprAttribute { value, .. }) => { + collect_typing_references(checker, value, names); + } + Expr::Subscript(ast::ExprSubscript { value, slice, .. }) => { + collect_typing_references(checker, value, names); + if let Some(qualified_name) = checker.semantic().resolve_qualified_name(value) { + if is_standard_library_literal(qualified_name.segments()) { + return; + } + if is_pep_593_generic_type(qualified_name.segments()) { + // First argument is a type (including forward references); the + // rest are arbitrary Python objects. + if let Expr::Tuple(ast::ExprTuple { elts, .. }) = slice.as_ref() { + let mut iter = elts.iter(); + if let Some(expr) = iter.next() { + collect_typing_references(checker, expr, names); + } + } + return; + } + } + collect_typing_references(checker, slice, names); + } + Expr::List(ast::ExprList { elts, .. }) | Expr::Tuple(ast::ExprTuple { elts, .. }) => { + for elt in elts { + collect_typing_references(checker, elt, names); + } + } + Expr::Name(name) => { + let Some(binding_id) = checker.semantic().resolve_name(name) else { + return; + }; + if checker.semantic().simulate_runtime_load(name).is_some() { + return; + } + + // if TC004 is enabled we shouldn't emit a TC007 for a reference to + // a binding that would emit a TC004, otherwise the fixes will never + // stabilize and keep going in circles + if checker.enabled(Rule::RuntimeImportInTypeCheckingBlock) + && checker + .semantic() + .binding(binding_id) + .references() + .any(|id| checker.semantic().reference(id).in_runtime_context()) + { + return; + } + names.push(name); + } + _ => {} + } +} + +/// TC008 +pub(crate) fn quoted_type_alias( + checker: &mut Checker, + expr: &Expr, + annotation_expr: &ast::ExprStringLiteral, +) { + if checker.enabled(Rule::RuntimeStringUnion) { + // this should return a TC010 error instead + if let Some(Expr::BinOp(ast::ExprBinOp { + op: Operator::BitOr, + .. + })) = checker.semantic().current_expression_parent() + { + return; + } + } + + // explicit type aliases require some additional checks to avoid false positives + if checker.semantic().in_annotated_type_alias_value() + && quotes_are_unremovable(checker.semantic(), expr) + { + return; + } + + let range = annotation_expr.range(); + let mut diagnostic = Diagnostic::new(QuotedTypeAlias, range); + let edit = Edit::range_replacement(annotation_expr.value.to_string(), range); + if checker + .comment_ranges() + .has_comments(expr, checker.source()) + { + diagnostic.set_fix(Fix::unsafe_edit(edit)); + } else { + diagnostic.set_fix(Fix::safe_edit(edit)); + } + checker.diagnostics.push(diagnostic); +} + +/// Traverses the type expression and checks if the expression can safely +/// be unquoted +fn quotes_are_unremovable(semantic: &SemanticModel, expr: &Expr) -> bool { + match expr { + Expr::BinOp(ast::ExprBinOp { left, right, .. }) => { + quotes_are_unremovable(semantic, left) || quotes_are_unremovable(semantic, right) + } + Expr::Starred(ast::ExprStarred { + value, + ctx: ExprContext::Load, + .. + }) => quotes_are_unremovable(semantic, value), + // for subscripts and attributes we don't know whether it's safe + // to do at runtime, since the operation may only be available at + // type checking time. E.g. stubs only generics. Or stubs only + // type aliases. + Expr::Subscript(_) | Expr::Attribute(_) => true, + Expr::List(ast::ExprList { elts, .. }) | Expr::Tuple(ast::ExprTuple { elts, .. }) => { + for elt in elts { + if quotes_are_unremovable(semantic, elt) { + return true; + } + } + false + } + Expr::Name(name) => { + semantic.resolve_name(name).is_some() && semantic.simulate_runtime_load(name).is_none() + } + _ => false, + } +} diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap index dc77edcf61..c782c1ec08 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap @@ -1,6 +1,5 @@ --- source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs -snapshot_kind: text --- quote.py:57:28: TC004 [*] Quote references to `pandas.DataFrame`. Import is in a type-checking block. | @@ -20,4 +19,28 @@ quote.py:57:28: TC004 [*] Quote references to `pandas.DataFrame`. Import is in a 59 |+ def func(value: "DataFrame"): 60 60 | ... 61 61 | -62 62 | +62 62 | + +quote.py:110:28: TC004 [*] Move import `pandas.DataFrame` out of type-checking block. Import is used for more than type hinting. + | +109 | if TYPE_CHECKING: +110 | from pandas import DataFrame + | ^^^^^^^^^ TC004 +111 | +112 | x: TypeAlias = DataFrame | None + | + = help: Move out of type-checking block + +ℹ Unsafe fix + 1 |+from pandas import DataFrame +1 2 | def f(): +2 3 | from pandas import DataFrame +3 4 | +-------------------------------------------------------------------------------- +107 108 | from typing import TypeAlias, TYPE_CHECKING +108 109 | +109 110 | if TYPE_CHECKING: +110 |- from pandas import DataFrame + 111 |+ pass +111 112 | +112 113 | x: TypeAlias = DataFrame | None diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quoted-type-alias_TC008.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quoted-type-alias_TC008.py.snap new file mode 100644 index 0000000000..76545b56fd --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quoted-type-alias_TC008.py.snap @@ -0,0 +1,420 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC008.py:15:16: TC008 [*] Remove quotes from type alias + | +13 | Bar = Foo +14 | +15 | a: TypeAlias = 'int' # TC008 + | ^^^^^ TC008 +16 | b: TypeAlias = 'Dict' # OK +17 | c: TypeAlias = 'Foo' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +12 12 | else: +13 13 | Bar = Foo +14 14 | +15 |-a: TypeAlias = 'int' # TC008 + 15 |+a: TypeAlias = int # TC008 +16 16 | b: TypeAlias = 'Dict' # OK +17 17 | c: TypeAlias = 'Foo' # TC008 +18 18 | d: TypeAlias = 'Foo[str]' # OK + +TC008.py:17:16: TC008 [*] Remove quotes from type alias + | +15 | a: TypeAlias = 'int' # TC008 +16 | b: TypeAlias = 'Dict' # OK +17 | c: TypeAlias = 'Foo' # TC008 + | ^^^^^ TC008 +18 | d: TypeAlias = 'Foo[str]' # OK +19 | e: TypeAlias = 'Foo.bar' # OK + | + = help: Remove quotes + +ℹ Safe fix +14 14 | +15 15 | a: TypeAlias = 'int' # TC008 +16 16 | b: TypeAlias = 'Dict' # OK +17 |-c: TypeAlias = 'Foo' # TC008 + 17 |+c: TypeAlias = Foo # TC008 +18 18 | d: TypeAlias = 'Foo[str]' # OK +19 19 | e: TypeAlias = 'Foo.bar' # OK +20 20 | f: TypeAlias = 'Foo | None' # TC008 + +TC008.py:20:16: TC008 [*] Remove quotes from type alias + | +18 | d: TypeAlias = 'Foo[str]' # OK +19 | e: TypeAlias = 'Foo.bar' # OK +20 | f: TypeAlias = 'Foo | None' # TC008 + | ^^^^^^^^^^^^ TC008 +21 | g: TypeAlias = 'OptStr' # OK +22 | h: TypeAlias = 'Bar' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +17 17 | c: TypeAlias = 'Foo' # TC008 +18 18 | d: TypeAlias = 'Foo[str]' # OK +19 19 | e: TypeAlias = 'Foo.bar' # OK +20 |-f: TypeAlias = 'Foo | None' # TC008 + 20 |+f: TypeAlias = Foo | None # TC008 +21 21 | g: TypeAlias = 'OptStr' # OK +22 22 | h: TypeAlias = 'Bar' # TC008 +23 23 | i: TypeAlias = Foo['str'] # TC008 + +TC008.py:22:16: TC008 [*] Remove quotes from type alias + | +20 | f: TypeAlias = 'Foo | None' # TC008 +21 | g: TypeAlias = 'OptStr' # OK +22 | h: TypeAlias = 'Bar' # TC008 + | ^^^^^ TC008 +23 | i: TypeAlias = Foo['str'] # TC008 +24 | j: TypeAlias = 'Baz' # OK + | + = help: Remove quotes + +ℹ Safe fix +19 19 | e: TypeAlias = 'Foo.bar' # OK +20 20 | f: TypeAlias = 'Foo | None' # TC008 +21 21 | g: TypeAlias = 'OptStr' # OK +22 |-h: TypeAlias = 'Bar' # TC008 + 22 |+h: TypeAlias = Bar # TC008 +23 23 | i: TypeAlias = Foo['str'] # TC008 +24 24 | j: TypeAlias = 'Baz' # OK +25 25 | k: TypeAlias = 'k | None' # OK + +TC008.py:23:20: TC008 [*] Remove quotes from type alias + | +21 | g: TypeAlias = 'OptStr' # OK +22 | h: TypeAlias = 'Bar' # TC008 +23 | i: TypeAlias = Foo['str'] # TC008 + | ^^^^^ TC008 +24 | j: TypeAlias = 'Baz' # OK +25 | k: TypeAlias = 'k | None' # OK + | + = help: Remove quotes + +ℹ Safe fix +20 20 | f: TypeAlias = 'Foo | None' # TC008 +21 21 | g: TypeAlias = 'OptStr' # OK +22 22 | h: TypeAlias = 'Bar' # TC008 +23 |-i: TypeAlias = Foo['str'] # TC008 + 23 |+i: TypeAlias = Foo[str] # TC008 +24 24 | j: TypeAlias = 'Baz' # OK +25 25 | k: TypeAlias = 'k | None' # OK +26 26 | l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) + +TC008.py:26:16: TC008 [*] Remove quotes from type alias + | +24 | j: TypeAlias = 'Baz' # OK +25 | k: TypeAlias = 'k | None' # OK +26 | l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) + | ^^^^^ TC008 +27 | m: TypeAlias = ('int' # TC008 +28 | | None) + | + = help: Remove quotes + +ℹ Safe fix +23 23 | i: TypeAlias = Foo['str'] # TC008 +24 24 | j: TypeAlias = 'Baz' # OK +25 25 | k: TypeAlias = 'k | None' # OK +26 |-l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) + 26 |+l: TypeAlias = int | None # TC008 (because TC010 is not enabled) +27 27 | m: TypeAlias = ('int' # TC008 +28 28 | | None) +29 29 | n: TypeAlias = ('int' # TC008 (fix removes comment currently) + +TC008.py:27:17: TC008 [*] Remove quotes from type alias + | +25 | k: TypeAlias = 'k | None' # OK +26 | l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) +27 | m: TypeAlias = ('int' # TC008 + | ^^^^^ TC008 +28 | | None) +29 | n: TypeAlias = ('int' # TC008 (fix removes comment currently) + | + = help: Remove quotes + +ℹ Safe fix +24 24 | j: TypeAlias = 'Baz' # OK +25 25 | k: TypeAlias = 'k | None' # OK +26 26 | l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) +27 |-m: TypeAlias = ('int' # TC008 + 27 |+m: TypeAlias = (int # TC008 +28 28 | | None) +29 29 | n: TypeAlias = ('int' # TC008 (fix removes comment currently) +30 30 | ' | None') + +TC008.py:29:17: TC008 [*] Remove quotes from type alias + | +27 | m: TypeAlias = ('int' # TC008 +28 | | None) +29 | n: TypeAlias = ('int' # TC008 (fix removes comment currently) + | _________________^ +30 | | ' | None') + | |_____________^ TC008 +31 | +32 | type B = 'Dict' # TC008 + | + = help: Remove quotes + +ℹ Unsafe fix +26 26 | l: TypeAlias = 'int' | None # TC008 (because TC010 is not enabled) +27 27 | m: TypeAlias = ('int' # TC008 +28 28 | | None) +29 |-n: TypeAlias = ('int' # TC008 (fix removes comment currently) +30 |- ' | None') + 29 |+n: TypeAlias = (int | None) +31 30 | +32 31 | type B = 'Dict' # TC008 +33 32 | type D = 'Foo[str]' # TC008 + +TC008.py:32:10: TC008 [*] Remove quotes from type alias + | +30 | ' | None') +31 | +32 | type B = 'Dict' # TC008 + | ^^^^^^ TC008 +33 | type D = 'Foo[str]' # TC008 +34 | type E = 'Foo.bar' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +29 29 | n: TypeAlias = ('int' # TC008 (fix removes comment currently) +30 30 | ' | None') +31 31 | +32 |-type B = 'Dict' # TC008 + 32 |+type B = Dict # TC008 +33 33 | type D = 'Foo[str]' # TC008 +34 34 | type E = 'Foo.bar' # TC008 +35 35 | type G = 'OptStr' # TC008 + +TC008.py:33:10: TC008 [*] Remove quotes from type alias + | +32 | type B = 'Dict' # TC008 +33 | type D = 'Foo[str]' # TC008 + | ^^^^^^^^^^ TC008 +34 | type E = 'Foo.bar' # TC008 +35 | type G = 'OptStr' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +30 30 | ' | None') +31 31 | +32 32 | type B = 'Dict' # TC008 +33 |-type D = 'Foo[str]' # TC008 + 33 |+type D = Foo[str] # TC008 +34 34 | type E = 'Foo.bar' # TC008 +35 35 | type G = 'OptStr' # TC008 +36 36 | type I = Foo['str'] # TC008 + +TC008.py:34:10: TC008 [*] Remove quotes from type alias + | +32 | type B = 'Dict' # TC008 +33 | type D = 'Foo[str]' # TC008 +34 | type E = 'Foo.bar' # TC008 + | ^^^^^^^^^ TC008 +35 | type G = 'OptStr' # TC008 +36 | type I = Foo['str'] # TC008 + | + = help: Remove quotes + +ℹ Safe fix +31 31 | +32 32 | type B = 'Dict' # TC008 +33 33 | type D = 'Foo[str]' # TC008 +34 |-type E = 'Foo.bar' # TC008 + 34 |+type E = Foo.bar # TC008 +35 35 | type G = 'OptStr' # TC008 +36 36 | type I = Foo['str'] # TC008 +37 37 | type J = 'Baz' # TC008 + +TC008.py:35:10: TC008 [*] Remove quotes from type alias + | +33 | type D = 'Foo[str]' # TC008 +34 | type E = 'Foo.bar' # TC008 +35 | type G = 'OptStr' # TC008 + | ^^^^^^^^ TC008 +36 | type I = Foo['str'] # TC008 +37 | type J = 'Baz' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +32 32 | type B = 'Dict' # TC008 +33 33 | type D = 'Foo[str]' # TC008 +34 34 | type E = 'Foo.bar' # TC008 +35 |-type G = 'OptStr' # TC008 + 35 |+type G = OptStr # TC008 +36 36 | type I = Foo['str'] # TC008 +37 37 | type J = 'Baz' # TC008 +38 38 | type K = 'K | None' # TC008 + +TC008.py:36:14: TC008 [*] Remove quotes from type alias + | +34 | type E = 'Foo.bar' # TC008 +35 | type G = 'OptStr' # TC008 +36 | type I = Foo['str'] # TC008 + | ^^^^^ TC008 +37 | type J = 'Baz' # TC008 +38 | type K = 'K | None' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +33 33 | type D = 'Foo[str]' # TC008 +34 34 | type E = 'Foo.bar' # TC008 +35 35 | type G = 'OptStr' # TC008 +36 |-type I = Foo['str'] # TC008 + 36 |+type I = Foo[str] # TC008 +37 37 | type J = 'Baz' # TC008 +38 38 | type K = 'K | None' # TC008 +39 39 | type L = 'int' | None # TC008 (because TC010 is not enabled) + +TC008.py:37:10: TC008 [*] Remove quotes from type alias + | +35 | type G = 'OptStr' # TC008 +36 | type I = Foo['str'] # TC008 +37 | type J = 'Baz' # TC008 + | ^^^^^ TC008 +38 | type K = 'K | None' # TC008 +39 | type L = 'int' | None # TC008 (because TC010 is not enabled) + | + = help: Remove quotes + +ℹ Safe fix +34 34 | type E = 'Foo.bar' # TC008 +35 35 | type G = 'OptStr' # TC008 +36 36 | type I = Foo['str'] # TC008 +37 |-type J = 'Baz' # TC008 + 37 |+type J = Baz # TC008 +38 38 | type K = 'K | None' # TC008 +39 39 | type L = 'int' | None # TC008 (because TC010 is not enabled) +40 40 | type M = ('int' # TC008 + +TC008.py:38:10: TC008 [*] Remove quotes from type alias + | +36 | type I = Foo['str'] # TC008 +37 | type J = 'Baz' # TC008 +38 | type K = 'K | None' # TC008 + | ^^^^^^^^^^ TC008 +39 | type L = 'int' | None # TC008 (because TC010 is not enabled) +40 | type M = ('int' # TC008 + | + = help: Remove quotes + +ℹ Safe fix +35 35 | type G = 'OptStr' # TC008 +36 36 | type I = Foo['str'] # TC008 +37 37 | type J = 'Baz' # TC008 +38 |-type K = 'K | None' # TC008 + 38 |+type K = K | None # TC008 +39 39 | type L = 'int' | None # TC008 (because TC010 is not enabled) +40 40 | type M = ('int' # TC008 +41 41 | | None) + +TC008.py:39:10: TC008 [*] Remove quotes from type alias + | +37 | type J = 'Baz' # TC008 +38 | type K = 'K | None' # TC008 +39 | type L = 'int' | None # TC008 (because TC010 is not enabled) + | ^^^^^ TC008 +40 | type M = ('int' # TC008 +41 | | None) + | + = help: Remove quotes + +ℹ Safe fix +36 36 | type I = Foo['str'] # TC008 +37 37 | type J = 'Baz' # TC008 +38 38 | type K = 'K | None' # TC008 +39 |-type L = 'int' | None # TC008 (because TC010 is not enabled) + 39 |+type L = int | None # TC008 (because TC010 is not enabled) +40 40 | type M = ('int' # TC008 +41 41 | | None) +42 42 | type N = ('int' # TC008 (fix removes comment currently) + +TC008.py:40:11: TC008 [*] Remove quotes from type alias + | +38 | type K = 'K | None' # TC008 +39 | type L = 'int' | None # TC008 (because TC010 is not enabled) +40 | type M = ('int' # TC008 + | ^^^^^ TC008 +41 | | None) +42 | type N = ('int' # TC008 (fix removes comment currently) + | + = help: Remove quotes + +ℹ Safe fix +37 37 | type J = 'Baz' # TC008 +38 38 | type K = 'K | None' # TC008 +39 39 | type L = 'int' | None # TC008 (because TC010 is not enabled) +40 |-type M = ('int' # TC008 + 40 |+type M = (int # TC008 +41 41 | | None) +42 42 | type N = ('int' # TC008 (fix removes comment currently) +43 43 | ' | None') + +TC008.py:42:11: TC008 [*] Remove quotes from type alias + | +40 | type M = ('int' # TC008 +41 | | None) +42 | type N = ('int' # TC008 (fix removes comment currently) + | ___________^ +43 | | ' | None') + | |_____________^ TC008 + | + = help: Remove quotes + +ℹ Unsafe fix +39 39 | type L = 'int' | None # TC008 (because TC010 is not enabled) +40 40 | type M = ('int' # TC008 +41 41 | | None) +42 |-type N = ('int' # TC008 (fix removes comment currently) +43 |- ' | None') + 42 |+type N = (int | None) +44 43 | +45 44 | +46 45 | class Baz: + +TC008.py:48:14: TC008 [*] Remove quotes from type alias + | +46 | class Baz: +47 | a: TypeAlias = 'Baz' # OK +48 | type A = 'Baz' # TC008 + | ^^^^^ TC008 +49 | +50 | class Nested: + | + = help: Remove quotes + +ℹ Safe fix +45 45 | +46 46 | class Baz: +47 47 | a: TypeAlias = 'Baz' # OK +48 |- type A = 'Baz' # TC008 + 48 |+ type A = Baz # TC008 +49 49 | +50 50 | class Nested: +51 51 | a: TypeAlias = 'Baz' # OK + +TC008.py:52:18: TC008 [*] Remove quotes from type alias + | +50 | class Nested: +51 | a: TypeAlias = 'Baz' # OK +52 | type A = 'Baz' # TC008 + | ^^^^^ TC008 + | + = help: Remove quotes + +ℹ Safe fix +49 49 | +50 50 | class Nested: +51 51 | a: TypeAlias = 'Baz' # OK +52 |- type A = 'Baz' # TC008 + 52 |+ type A = Baz # TC008 diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap index 7d6b17588e..4d4f252faf 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap @@ -1,6 +1,5 @@ --- source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs -snapshot_kind: text --- quote.py:57:28: TC004 [*] Move import `pandas.DataFrame` out of type-checking block. Import is used for more than type hinting. | @@ -26,3 +25,27 @@ quote.py:57:28: TC004 [*] Move import `pandas.DataFrame` out of type-checking bl 58 59 | 59 60 | def func(value: DataFrame): 60 61 | ... + +quote.py:110:28: TC004 [*] Move import `pandas.DataFrame` out of type-checking block. Import is used for more than type hinting. + | +109 | if TYPE_CHECKING: +110 | from pandas import DataFrame + | ^^^^^^^^^ TC004 +111 | +112 | x: TypeAlias = DataFrame | None + | + = help: Move out of type-checking block + +ℹ Unsafe fix + 1 |+from pandas import DataFrame +1 2 | def f(): +2 3 | from pandas import DataFrame +3 4 | +-------------------------------------------------------------------------------- +107 108 | from typing import TypeAlias, TYPE_CHECKING +108 109 | +109 110 | if TYPE_CHECKING: +110 |- from pandas import DataFrame + 111 |+ pass +111 112 | +112 113 | x: TypeAlias = DataFrame | None diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc004_precedence_over_tc007.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc004_precedence_over_tc007.snap new file mode 100644 index 0000000000..41b0f9408f --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc004_precedence_over_tc007.snap @@ -0,0 +1,24 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +:6:21: TC004 [*] Move import `foo.Foo` out of type-checking block. Import is used for more than type hinting. + | +4 | from typing import TYPE_CHECKING, TypeAlias +5 | if TYPE_CHECKING: +6 | from foo import Foo # TC004 + | ^^^ TC004 +7 | +8 | a: TypeAlias = Foo | None # OK + | + = help: Move out of type-checking block + +ℹ Unsafe fix +2 2 | from __future__ import annotations +3 3 | +4 4 | from typing import TYPE_CHECKING, TypeAlias + 5 |+from foo import Foo +5 6 | if TYPE_CHECKING: +6 |- from foo import Foo # TC004 + 7 |+ pass # TC004 +7 8 | +8 9 | a: TypeAlias = Foo | None # OK diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc010_precedence_over_tc008.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc010_precedence_over_tc008.snap new file mode 100644 index 0000000000..2d30b115db --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__tc010_precedence_over_tc008.snap @@ -0,0 +1,27 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +:6:16: TC008 [*] Remove quotes from type alias + | +4 | from typing import TypeAlias +5 | +6 | a: TypeAlias = 'int | None' # TC008 + | ^^^^^^^^^^^^ TC008 +7 | b: TypeAlias = 'int' | None # TC010 + | + = help: Remove quotes + +ℹ Safe fix +3 3 | +4 4 | from typing import TypeAlias +5 5 | +6 |-a: TypeAlias = 'int | None' # TC008 + 6 |+a: TypeAlias = int | None # TC008 +7 7 | b: TypeAlias = 'int' | None # TC010 + +:7:16: TC010 Invalid string member in `X | Y`-style union type + | +6 | a: TypeAlias = 'int | None' # TC008 +7 | b: TypeAlias = 'int' | None # TC010 + | ^^^^^ TC010 + | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__unquoted-type-alias_TC007.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__unquoted-type-alias_TC007.py.snap new file mode 100644 index 0000000000..ddc70534e7 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__unquoted-type-alias_TC007.py.snap @@ -0,0 +1,192 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC007.py:15:16: TC007 [*] Add quotes to type alias + | +13 | a: TypeAlias = int # OK +14 | b: TypeAlias = Dict # OK +15 | c: TypeAlias = Foo # TC007 + | ^^^ TC007 +16 | d: TypeAlias = Foo | None # TC007 +17 | e: TypeAlias = OptStr # TC007 + | + = help: Add quotes + +ℹ Unsafe fix +12 12 | +13 13 | a: TypeAlias = int # OK +14 14 | b: TypeAlias = Dict # OK +15 |-c: TypeAlias = Foo # TC007 + 15 |+c: TypeAlias = "Foo" # TC007 +16 16 | d: TypeAlias = Foo | None # TC007 +17 17 | e: TypeAlias = OptStr # TC007 +18 18 | f: TypeAlias = Bar # TC007 + +TC007.py:16:16: TC007 [*] Add quotes to type alias + | +14 | b: TypeAlias = Dict # OK +15 | c: TypeAlias = Foo # TC007 +16 | d: TypeAlias = Foo | None # TC007 + | ^^^ TC007 +17 | e: TypeAlias = OptStr # TC007 +18 | f: TypeAlias = Bar # TC007 + | + = help: Add quotes + +ℹ Unsafe fix +13 13 | a: TypeAlias = int # OK +14 14 | b: TypeAlias = Dict # OK +15 15 | c: TypeAlias = Foo # TC007 +16 |-d: TypeAlias = Foo | None # TC007 + 16 |+d: TypeAlias = "Foo | None" # TC007 +17 17 | e: TypeAlias = OptStr # TC007 +18 18 | f: TypeAlias = Bar # TC007 +19 19 | g: TypeAlias = Foo | Bar # TC007 x2 + +TC007.py:17:16: TC007 [*] Add quotes to type alias + | +15 | c: TypeAlias = Foo # TC007 +16 | d: TypeAlias = Foo | None # TC007 +17 | e: TypeAlias = OptStr # TC007 + | ^^^^^^ TC007 +18 | f: TypeAlias = Bar # TC007 +19 | g: TypeAlias = Foo | Bar # TC007 x2 + | + = help: Add quotes + +ℹ Unsafe fix +14 14 | b: TypeAlias = Dict # OK +15 15 | c: TypeAlias = Foo # TC007 +16 16 | d: TypeAlias = Foo | None # TC007 +17 |-e: TypeAlias = OptStr # TC007 + 17 |+e: TypeAlias = "OptStr" # TC007 +18 18 | f: TypeAlias = Bar # TC007 +19 19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 20 | h: TypeAlias = Foo[str] # TC007 + +TC007.py:18:16: TC007 [*] Add quotes to type alias + | +16 | d: TypeAlias = Foo | None # TC007 +17 | e: TypeAlias = OptStr # TC007 +18 | f: TypeAlias = Bar # TC007 + | ^^^ TC007 +19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 | h: TypeAlias = Foo[str] # TC007 + | + = help: Add quotes + +ℹ Unsafe fix +15 15 | c: TypeAlias = Foo # TC007 +16 16 | d: TypeAlias = Foo | None # TC007 +17 17 | e: TypeAlias = OptStr # TC007 +18 |-f: TypeAlias = Bar # TC007 + 18 |+f: TypeAlias = "Bar" # TC007 +19 19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 20 | h: TypeAlias = Foo[str] # TC007 +21 21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) + +TC007.py:19:16: TC007 [*] Add quotes to type alias + | +17 | e: TypeAlias = OptStr # TC007 +18 | f: TypeAlias = Bar # TC007 +19 | g: TypeAlias = Foo | Bar # TC007 x2 + | ^^^ TC007 +20 | h: TypeAlias = Foo[str] # TC007 +21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) + | + = help: Add quotes + +ℹ Unsafe fix +16 16 | d: TypeAlias = Foo | None # TC007 +17 17 | e: TypeAlias = OptStr # TC007 +18 18 | f: TypeAlias = Bar # TC007 +19 |-g: TypeAlias = Foo | Bar # TC007 x2 + 19 |+g: TypeAlias = "Foo | Bar" # TC007 x2 +20 20 | h: TypeAlias = Foo[str] # TC007 +21 21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 22 | Bar) + +TC007.py:19:22: TC007 [*] Add quotes to type alias + | +17 | e: TypeAlias = OptStr # TC007 +18 | f: TypeAlias = Bar # TC007 +19 | g: TypeAlias = Foo | Bar # TC007 x2 + | ^^^ TC007 +20 | h: TypeAlias = Foo[str] # TC007 +21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) + | + = help: Add quotes + +ℹ Unsafe fix +16 16 | d: TypeAlias = Foo | None # TC007 +17 17 | e: TypeAlias = OptStr # TC007 +18 18 | f: TypeAlias = Bar # TC007 +19 |-g: TypeAlias = Foo | Bar # TC007 x2 + 19 |+g: TypeAlias = "Foo | Bar" # TC007 x2 +20 20 | h: TypeAlias = Foo[str] # TC007 +21 21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 22 | Bar) + +TC007.py:20:16: TC007 [*] Add quotes to type alias + | +18 | f: TypeAlias = Bar # TC007 +19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 | h: TypeAlias = Foo[str] # TC007 + | ^^^ TC007 +21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 | Bar) + | + = help: Add quotes + +ℹ Unsafe fix +17 17 | e: TypeAlias = OptStr # TC007 +18 18 | f: TypeAlias = Bar # TC007 +19 19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 |-h: TypeAlias = Foo[str] # TC007 + 20 |+h: TypeAlias = "Foo[str]" # TC007 +21 21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 22 | Bar) +23 23 | + +TC007.py:21:17: TC007 [*] Add quotes to type alias + | +19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 | h: TypeAlias = Foo[str] # TC007 +21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) + | ^^^ TC007 +22 | Bar) + | + = help: Add quotes + +ℹ Unsafe fix +18 18 | f: TypeAlias = Bar # TC007 +19 19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 20 | h: TypeAlias = Foo[str] # TC007 +21 |-i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 |- Bar) + 21 |+i: TypeAlias = ("Foo | Bar") +23 22 | +24 23 | type C = Foo # OK +25 24 | type D = Foo | None # OK + +TC007.py:22:5: TC007 [*] Add quotes to type alias + | +20 | h: TypeAlias = Foo[str] # TC007 +21 | i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 | Bar) + | ^^^ TC007 +23 | +24 | type C = Foo # OK + | + = help: Add quotes + +ℹ Unsafe fix +18 18 | f: TypeAlias = Bar # TC007 +19 19 | g: TypeAlias = Foo | Bar # TC007 x2 +20 20 | h: TypeAlias = Foo[str] # TC007 +21 |-i: TypeAlias = (Foo | # TC007 x2 (fix removes comment currently) +22 |- Bar) + 21 |+i: TypeAlias = ("Foo | Bar") +23 22 | +24 23 | type C = Foo # OK +25 24 | type D = Foo | None # OK diff --git a/crates/ruff_python_semantic/src/binding.rs b/crates/ruff_python_semantic/src/binding.rs index 687faf1f41..790a6d556b 100644 --- a/crates/ruff_python_semantic/src/binding.rs +++ b/crates/ruff_python_semantic/src/binding.rs @@ -135,6 +135,35 @@ impl<'a> Binding<'a> { self.flags.contains(BindingFlags::IN_EXCEPT_HANDLER) } + /// Return `true` if this [`Binding`] represents a [PEP 613] type alias + /// e.g. `OptString` in: + /// ```python + /// from typing import TypeAlias + /// + /// OptString: TypeAlias = str | None + /// ``` + /// + /// [PEP 613]: https://peps.python.org/pep-0613/ + pub const fn is_annotated_type_alias(&self) -> bool { + self.flags.intersects(BindingFlags::ANNOTATED_TYPE_ALIAS) + } + + /// Return `true` if this [`Binding`] represents a [PEP 695] type alias + /// e.g. `OptString` in: + /// ```python + /// type OptString = str | None + /// ``` + /// + /// [PEP 695]: https://peps.python.org/pep-0695/#generic-type-alias + pub const fn is_deferred_type_alias(&self) -> bool { + self.flags.intersects(BindingFlags::DEFERRED_TYPE_ALIAS) + } + + /// Return `true` if this [`Binding`] represents either kind of type alias + pub const fn is_type_alias(&self) -> bool { + self.flags.intersects(BindingFlags::TYPE_ALIAS) + } + /// Return `true` if this binding "redefines" the given binding, as per Pyflake's definition of /// redefinition. pub fn redefines(&self, existing: &Binding) -> bool { @@ -366,6 +395,19 @@ bitflags! { /// y = 42 /// ``` const IN_EXCEPT_HANDLER = 1 << 10; + + /// The binding represents a [PEP 613] explicit type alias. + /// + /// [PEP 613]: https://peps.python.org/pep-0613/ + const ANNOTATED_TYPE_ALIAS = 1 << 11; + + /// The binding represents a [PEP 695] type statement + /// + /// [PEP 695]: https://peps.python.org/pep-0695/#generic-type-alias + const DEFERRED_TYPE_ALIAS = 1 << 12; + + /// The binding represents any type alias. + const TYPE_ALIAS = Self::ANNOTATED_TYPE_ALIAS.bits() | Self::DEFERRED_TYPE_ALIAS.bits(); } } diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index d2d4907881..9c4d33c129 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -662,12 +662,135 @@ impl<'a> SemanticModel<'a> { } } + // FIXME: Shouldn't this happen above where `class_variables_visible` is set? seen_function |= scope.kind.is_function(); } None } + /// Simulates a runtime load of a given [`ast::ExprName`]. + /// + /// This should not be run until after all the bindings have been visited. + /// + /// The main purpose of this method and what makes this different + /// from methods like [`SemanticModel::lookup_symbol`] and + /// [`SemanticModel::resolve_name`] is that it may be used + /// to perform speculative name lookups. + /// + /// In most cases a load can be accurately modeled simply by calling + /// [`SemanticModel::resolve_name`] at the right time during semantic + /// analysis, however for speculative lookups this is not the case, + /// since we're aiming to change the semantic meaning of our load. + /// E.g. we want to check what would happen if we changed a forward + /// reference to an immediate load or vice versa. + /// + /// Use caution when utilizing this method, since it was primarily designed + /// to work for speculative lookups from within type definitions, which + /// happen to share some nice properties, where attaching each binding + /// to a range in the source code and ordering those bindings based on + /// that range is a good enough approximation of which bindings are + /// available at runtime for which reference. + /// + /// References from within an [`ast::Comprehension`] can produce incorrect + /// results when referring to a [`BindingKind::NamedExprAssignment`]. + pub fn simulate_runtime_load(&self, name: &ast::ExprName) -> Option { + let symbol = name.id.as_str(); + let range = name.range; + let mut seen_function = false; + let mut class_variables_visible = true; + let mut source_order_sensitive_lookup = true; + for (index, scope_id) in self.scopes.ancestor_ids(self.scope_id).enumerate() { + let scope = &self.scopes[scope_id]; + + // Only once we leave a function scope and its enclosing type scope should + // we stop doing source-order lookups. We could e.g. have nested classes + // where we lookup symbols from the innermost class scope, which can only see + // things from the outer class(es) that have been defined before the inner + // class. Source-order lookups take advantage of the fact that most of the + // bindings are created sequentially in source order, so if we want to + // determine whether or not a given reference can refer to another binding + // we can look at their text ranges to check whether or not the binding + // could actually be referred to. This is not as robust as back-tracking + // the AST, since that can properly take care of the few out-of order + // corner-cases, but back-tracking the AST from the reference to the binding + // is a lot more expensive than comparing a pair of text ranges. + if seen_function && !scope.kind.is_type() { + source_order_sensitive_lookup = false; + } + + if scope.kind.is_class() { + if seen_function && matches!(symbol, "__class__") { + return None; + } + if !class_variables_visible { + continue; + } + } + + class_variables_visible = scope.kind.is_type() && index == 0; + seen_function |= scope.kind.is_function(); + + if let Some(binding_id) = scope.get(symbol) { + if source_order_sensitive_lookup { + // we need to look through all the shadowed bindings + // since we may be shadowing a source-order accurate + // runtime binding with a source-order inaccurate one + for shadowed_id in scope.shadowed_bindings(binding_id) { + let binding = &self.bindings[shadowed_id]; + if binding.context.is_typing() { + continue; + } + if let BindingKind::Annotation + | BindingKind::Deletion + | BindingKind::UnboundException(..) + | BindingKind::ConditionalDeletion(..) = binding.kind + { + continue; + } + + // This ensures we perform the correct source-order lookup, + // since the ranges for these two types of bindings are trimmed + // to just the target, but the name is not available until the + // end of the entire statement + let binding_range = match binding.statement(self) { + Some(Stmt::Assign(stmt)) => stmt.range(), + Some(Stmt::AnnAssign(stmt)) => stmt.range(), + Some(Stmt::ClassDef(stmt)) => stmt.range(), + _ => binding.range, + }; + + if binding_range.ordering(range).is_lt() { + return Some(shadowed_id); + } + } + } else { + let candidate_id = match self.bindings[binding_id].kind { + BindingKind::Annotation => continue, + BindingKind::Deletion | BindingKind::UnboundException(None) => return None, + BindingKind::ConditionalDeletion(binding_id) => binding_id, + BindingKind::UnboundException(Some(binding_id)) => binding_id, + _ => binding_id, + }; + + if self.bindings[candidate_id].context.is_typing() { + continue; + } + + return Some(candidate_id); + } + } + + if index == 0 && scope.kind.is_class() { + if matches!(symbol, "__module__" | "__qualname__") { + return None; + } + } + } + + None + } + /// Lookup a qualified attribute in the current scope. /// /// For example, given `["Class", "method"`], resolve the `BindingKind::ClassDefinition` @@ -1667,6 +1790,42 @@ impl<'a> SemanticModel<'a> { || (self.in_future_type_definition() && self.in_typing_only_annotation()) } + /// Return `true` if the model is visiting the value expression + /// of a [PEP 613] type alias. + /// + /// For example: + /// ```python + /// from typing import TypeAlias + /// + /// OptStr: TypeAlias = str | None # We're visiting the RHS + /// ``` + /// + /// [PEP 613]: https://peps.python.org/pep-0613/ + pub const fn in_annotated_type_alias_value(&self) -> bool { + self.flags + .intersects(SemanticModelFlags::ANNOTATED_TYPE_ALIAS) + } + + /// Return `true` if the model is visiting the value expression + /// of a [PEP 695] type alias. + /// + /// For example: + /// ```python + /// type OptStr = str | None # We're visiting the RHS + /// ``` + /// + /// [PEP 695]: https://peps.python.org/pep-0695/#generic-type-alias + pub const fn in_deferred_type_alias_value(&self) -> bool { + self.flags + .intersects(SemanticModelFlags::DEFERRED_TYPE_ALIAS) + } + + /// Return `true` if the model is visiting the value expression of + /// either kind of type alias. + pub const fn in_type_alias_value(&self) -> bool { + self.flags.intersects(SemanticModelFlags::TYPE_ALIAS) + } + /// Return `true` if the model is in an exception handler. pub const fn in_exception_handler(&self) -> bool { self.flags.intersects(SemanticModelFlags::EXCEPTION_HANDLER) @@ -2243,6 +2402,27 @@ bitflags! { /// [no_type_check]: https://docs.python.org/3/library/typing.html#typing.no_type_check /// [#13824]: https://github.com/astral-sh/ruff/issues/13824 const NO_TYPE_CHECK = 1 << 26; + /// The model is in the value expression of a [PEP 613] explicit type alias. + /// + /// For example: + /// ```python + /// from typing import TypeAlias + /// + /// OptStr: TypeAlias = str | None # We're visiting the RHS + /// ``` + /// + /// [PEP 613]: https://peps.python.org/pep-0613/ + const ANNOTATED_TYPE_ALIAS = 1 << 27; + + /// The model is in the value expression of a [PEP 695] type statement. + /// + /// For example: + /// ```python + /// type OptStr = str | None # We're visiting the RHS + /// ``` + /// + /// [PEP 695]: https://peps.python.org/pep-0695/#generic-type-alias + const DEFERRED_TYPE_ALIAS = 1 << 28; /// The context is in any type annotation. const ANNOTATION = Self::TYPING_ONLY_ANNOTATION.bits() | Self::RUNTIME_EVALUATED_ANNOTATION.bits() | Self::RUNTIME_REQUIRED_ANNOTATION.bits(); @@ -2260,6 +2440,9 @@ bitflags! { /// The context is in a typing-only context. const TYPING_CONTEXT = Self::TYPE_CHECKING_BLOCK.bits() | Self::TYPING_ONLY_ANNOTATION.bits() | Self::STRING_TYPE_DEFINITION.bits() | Self::TYPE_PARAM_DEFINITION.bits(); + + /// The context is in any type alias. + const TYPE_ALIAS = Self::ANNOTATED_TYPE_ALIAS.bits() | Self::DEFERRED_TYPE_ALIAS.bits(); } } diff --git a/crates/ruff_python_semantic/src/reference.rs b/crates/ruff_python_semantic/src/reference.rs index 6799e76893..c5d92bfbca 100644 --- a/crates/ruff_python_semantic/src/reference.rs +++ b/crates/ruff_python_semantic/src/reference.rs @@ -85,6 +85,14 @@ impl ResolvedReference { self.flags .intersects(SemanticModelFlags::DUNDER_ALL_DEFINITION) } + + /// Return `true` if the context is in the r.h.s. of a [PEP 613] type alias. + /// + /// [PEP 613]: https://peps.python.org/pep-0613/ + pub const fn in_annotated_type_alias_value(&self) -> bool { + self.flags + .intersects(SemanticModelFlags::ANNOTATED_TYPE_ALIAS) + } } impl Ranged for ResolvedReference { diff --git a/ruff.schema.json b/ruff.schema.json index ff98107582..fee16aaec8 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4011,6 +4011,8 @@ "TC004", "TC005", "TC006", + "TC007", + "TC008", "TC01", "TC010", "TD",