[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 <Alex.Waygood@Gmail.com>
This commit is contained in:
Brent Westbrook 2025-01-30 18:06:38 -05:00 committed by GitHub
parent 4f2aea8d50
commit fe516e24f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 136 additions and 3 deletions

View file

@ -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)

View file

@ -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();

View file

@ -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"))]

View file

@ -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 |

View file

@ -28,6 +28,7 @@ pub enum Callable {
NamedTuple,
TypedDict,
MypyExtension,
TypeAliasType,
}
#[derive(Debug, Copy, Clone)]