From fe516e24f54bfc33b7b480e8438b9393630f05b1 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 30 Jan 2025 18:06:38 -0500 Subject: [PATCH] [`pyflakes`] Visit forward annotations in `TypeAliasType` as types (`F401`) (#15829) ## Summary Fixes https://github.com/astral-sh/ruff/issues/15812 by visiting the second argument as a type definition. ## Test Plan New F401 tests based on the report. --------- Co-authored-by: Alex Waygood --- .../test/fixtures/pyflakes/F401_34.py | 67 +++++++++++++++++++ crates/ruff_linter/src/checkers/ast/mod.rs | 29 +++++++- crates/ruff_linter/src/rules/pyflakes/mod.rs | 1 + ...les__pyflakes__tests__F401_F401_34.py.snap | 41 ++++++++++++ .../src/analyze/typing.rs | 1 + 5 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/pyflakes/F401_34.py create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_34.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_34.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_34.py new file mode 100644 index 00000000000000..c59d4f8c10a153 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_34.py @@ -0,0 +1,67 @@ +"""Regression tests for https://github.com/astral-sh/ruff/issues/15812""" + + +def f(): + from typing import Union + + from typing_extensions import TypeAliasType + + Json = TypeAliasType( + "Json", + "Union[dict[str, Json], list[Json], str, int, float, bool, None]", + ) + + +def f(): + from typing import Union + + from typing_extensions import TypeAliasType, TypeVar + + T = TypeVar("T") + V = TypeVar("V") + Json = TypeAliasType( + "Json", + "Union[dict[str, Json], list[Json], str, int, float, bool, T, V, None]", + type_params=(T, V), + ) + + +def f(): + from typing import Union + + from typing_extensions import TypeAliasType + + Json = TypeAliasType( + value="Union[dict[str, Json], list[Json], str, int, float, bool, None]", + name="Json", + ) + + +# strictly speaking it's a false positive to emit F401 for both of these, but +# we can't really be expected to understand that the strings here are type +# expressions (and type checkers probably wouldn't understand them as type +# expressions either!) +def f(): + from typing import Union + + from typing_extensions import TypeAliasType + + args = [ + "Json", + "Union[dict[str, Json], list[Json], str, int, float, bool, None]", + ] + + Json = TypeAliasType(*args) + + +def f(): + from typing import Union + + from typing_extensions import TypeAliasType + + kwargs = { + "name": "Json", + "value": "Union[dict[str, Json], list[Json], str, int, float, bool, None]", + } + + Json = TypeAliasType(**kwargs) diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 476b525638e482..f440f84ef8aa19 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -41,9 +41,9 @@ use ruff_python_ast::name::QualifiedName; use ruff_python_ast::str::Quote; use ruff_python_ast::visitor::{walk_except_handler, walk_pattern, Visitor}; use ruff_python_ast::{ - self as ast, AnyParameterRef, Comprehension, ElifElseClause, ExceptHandler, Expr, ExprContext, - FStringElement, Keyword, MatchCase, ModModule, Parameter, Parameters, Pattern, Stmt, Suite, - UnaryOp, + self as ast, AnyParameterRef, ArgOrKeyword, Comprehension, ElifElseClause, ExceptHandler, Expr, + ExprContext, FStringElement, Keyword, MatchCase, ModModule, Parameter, Parameters, Pattern, + Stmt, Suite, UnaryOp, }; use ruff_python_ast::{helpers, str, visitor, PySourceType}; use ruff_python_codegen::{Generator, Stylist}; @@ -1269,6 +1269,11 @@ impl<'a> Visitor<'a> for Checker<'a> { .match_typing_qualified_name(&qualified_name, "TypeVar") { Some(typing::Callable::TypeVar) + } else if self + .semantic + .match_typing_qualified_name(&qualified_name, "TypeAliasType") + { + Some(typing::Callable::TypeAliasType) } else if self .semantic .match_typing_qualified_name(&qualified_name, "NamedTuple") @@ -1354,6 +1359,24 @@ impl<'a> Visitor<'a> for Checker<'a> { } } } + Some(typing::Callable::TypeAliasType) => { + // Ex) TypeAliasType("Json", "Union[dict[str, Json]]", type_params=()) + for (i, arg) in arguments.arguments_source_order().enumerate() { + match (i, arg) { + (1, ArgOrKeyword::Arg(arg)) => self.visit_type_definition(arg), + (_, ArgOrKeyword::Arg(arg)) => self.visit_non_type_definition(arg), + (_, ArgOrKeyword::Keyword(Keyword { arg, value, .. })) => { + if let Some(id) = arg { + if matches!(&**id, "value" | "type_params") { + self.visit_type_definition(value); + } else { + self.visit_non_type_definition(value); + } + } + } + } + } + } Some(typing::Callable::NamedTuple) => { // Ex) NamedTuple("a", [("a", int)]) let mut args = arguments.args.iter(); diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index a3fc5d81a7d391..70e520fff8967c 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -56,6 +56,7 @@ mod tests { #[test_case(Rule::UnusedImport, Path::new("F401_22.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_23.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_32.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_34.py"))] #[test_case(Rule::ImportShadowedByLoopVar, Path::new("F402.py"))] #[test_case(Rule::ImportShadowedByLoopVar, Path::new("F402.ipynb"))] #[test_case(Rule::UndefinedLocalWithImportStar, Path::new("F403.py"))] diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_34.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_34.py.snap new file mode 100644 index 00000000000000..519e21f21a11dd --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_34.py.snap @@ -0,0 +1,41 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401_34.py:45:24: F401 [*] `typing.Union` imported but unused + | +43 | # expressions either!) +44 | def f(): +45 | from typing import Union + | ^^^^^ F401 +46 | +47 | from typing_extensions import TypeAliasType + | + = help: Remove unused import: `typing.Union` + +ℹ Safe fix +42 42 | # expressions (and type checkers probably wouldn't understand them as type +43 43 | # expressions either!) +44 44 | def f(): +45 |- from typing import Union +46 45 | +47 46 | from typing_extensions import TypeAliasType +48 47 | + +F401_34.py:58:24: F401 [*] `typing.Union` imported but unused + | +57 | def f(): +58 | from typing import Union + | ^^^^^ F401 +59 | +60 | from typing_extensions import TypeAliasType + | + = help: Remove unused import: `typing.Union` + +ℹ Safe fix +55 55 | +56 56 | +57 57 | def f(): +58 |- from typing import Union +59 58 | +60 59 | from typing_extensions import TypeAliasType +61 60 | diff --git a/crates/ruff_python_semantic/src/analyze/typing.rs b/crates/ruff_python_semantic/src/analyze/typing.rs index f8ba9296b819ba..d9c918c36b9eea 100644 --- a/crates/ruff_python_semantic/src/analyze/typing.rs +++ b/crates/ruff_python_semantic/src/analyze/typing.rs @@ -28,6 +28,7 @@ pub enum Callable { NamedTuple, TypedDict, MypyExtension, + TypeAliasType, } #[derive(Debug, Copy, Clone)]