diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py index 0368a34800db2..e107f8da25494 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py @@ -51,3 +51,37 @@ class Foo: # type alias. T = typing.TypeVar["T"] Decorator: TypeAlias = typing.Callable[[T], T] + + +from typing import TypeVar, Annotated, TypeAliasType + +from annotated_types import Gt, SupportGt + + +# https://github.com/astral-sh/ruff/issues/11422 +T = TypeVar("T") +PositiveList = TypeAliasType( + "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +) + +# Bound +T = TypeVar("T", bound=SupportGt) +PositiveList = TypeAliasType( + "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +) + +# Multiple bounds +T1 = TypeVar("T1", bound=SupportGt) +T2 = TypeVar("T2") +T3 = TypeVar("T3") +Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) + +# No type_params +PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) +PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) + +# OK: Other name +T = TypeVar("T", bound=SupportGt) +PositiveList = TypeAliasType( + "PositiveList2", list[Annotated[T, Gt(0)]], type_params=(T,) +) diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index ddaf1d382523b..375ce1aafab06 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -1558,6 +1558,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.enabled(Rule::ListReverseCopy) { refurb::rules::list_assign_reversed(checker, assign); } + if checker.enabled(Rule::NonPEP695TypeAlias) { + pyupgrade::rules::non_pep695_type_alias_type(checker, assign); + } } Stmt::AnnAssign( assign_stmt @ ast::StmtAnnAssign { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs index 0039194d21e63..670954e408f24 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs @@ -1,13 +1,14 @@ use itertools::Itertools; -use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; +use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; use ruff_python_ast::{ self as ast, visitor::{self, Visitor}, - Expr, ExprCall, ExprName, ExprSubscript, Identifier, Stmt, StmtAnnAssign, StmtAssign, + Expr, ExprCall, ExprName, ExprSubscript, Identifier, Keyword, Stmt, StmtAnnAssign, StmtAssign, StmtTypeAlias, TypeParam, TypeParamTypeVar, }; +use ruff_python_codegen::Generator; use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; @@ -15,7 +16,8 @@ use crate::checkers::ast::Checker; use crate::settings::types::PythonVersion; /// ## What it does -/// Checks for use of `TypeAlias` annotation for declaring type aliases. +/// Checks for use of `TypeAlias` annotations and `TypeAliasType` assignments +/// for declaring type aliases. /// /// ## Why is this bad? /// The `type` keyword was introduced in Python 3.12 by [PEP 695] for defining @@ -36,17 +38,26 @@ use crate::settings::types::PythonVersion; /// ## Example /// ```python /// ListOfInt: TypeAlias = list[int] +/// PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) /// ``` /// /// Use instead: /// ```python /// type ListOfInt = list[int] +/// type PositiveInt = Annotated[int, Gt(0)] /// ``` /// /// [PEP 695]: https://peps.python.org/pep-0695/ #[violation] pub struct NonPEP695TypeAlias { name: String, + type_alias_kind: TypeAliasKind, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum TypeAliasKind { + TypeAlias, + TypeAliasType, } impl Violation for NonPEP695TypeAlias { @@ -54,8 +65,15 @@ impl Violation for NonPEP695TypeAlias { #[derive_message_formats] fn message(&self) -> String { - let NonPEP695TypeAlias { name } = self; - format!("Type alias `{name}` uses `TypeAlias` annotation instead of the `type` keyword") + let NonPEP695TypeAlias { + name, + type_alias_kind, + } = self; + let type_alias_method = match type_alias_kind { + TypeAliasKind::TypeAlias => "`TypeAlias` annotation", + TypeAliasKind::TypeAliasType => "`TypeAliasType` assignment", + }; + format!("Type alias `{name}` uses {type_alias_method} instead of the `type` keyword") } fn fix_title(&self) -> Option { @@ -63,8 +81,82 @@ impl Violation for NonPEP695TypeAlias { } } +/// UP040 +pub(crate) fn non_pep695_type_alias_type(checker: &mut Checker, stmt: &StmtAssign) { + if checker.settings.target_version < PythonVersion::Py312 { + return; + } + + let StmtAssign { targets, value, .. } = stmt; + + let Expr::Call(ExprCall { + func, arguments, .. + }) = value.as_ref() + else { + return; + }; + + let [Expr::Name(target_name)] = targets.as_slice() else { + return; + }; + + let [Expr::StringLiteral(name), value] = arguments.args.as_ref() else { + return; + }; + + if name.value.to_str() != target_name.id { + return; + } + + let type_params = match arguments.keywords.as_ref() { + [] => &[], + [Keyword { + arg: Some(name), + value: Expr::Tuple(type_params), + .. + }] if name.as_str() == "type_params" => type_params.elts.as_slice(), + _ => return, + }; + + if !checker + .semantic() + .match_typing_expr(func.as_ref(), "TypeAliasType") + { + return; + } + + let Some(vars) = type_params + .iter() + .map(|expr| { + expr.as_name_expr().map(|name| { + expr_name_to_type_var(checker.semantic(), name).unwrap_or(TypeVar { + name, + restriction: None, + }) + }) + }) + .collect::>>() + else { + return; + }; + + checker.diagnostics.push(create_diagnostic( + checker.generator(), + stmt.range(), + &target_name.id, + value, + &vars, + Applicability::Safe, + TypeAliasKind::TypeAliasType, + )); +} + /// UP040 pub(crate) fn non_pep695_type_alias(checker: &mut Checker, stmt: &StmtAnnAssign) { + if checker.settings.target_version < PythonVersion::Py312 { + return; + } + let StmtAnnAssign { target, annotation, @@ -72,11 +164,6 @@ pub(crate) fn non_pep695_type_alias(checker: &mut Checker, stmt: &StmtAnnAssign) .. } = stmt; - // Syntax only available in 3.12+ - if checker.settings.target_version < PythonVersion::Py312 { - return; - } - if !checker .semantic() .match_typing_expr(annotation, "TypeAlias") @@ -109,23 +196,52 @@ pub(crate) fn non_pep695_type_alias(checker: &mut Checker, stmt: &StmtAnnAssign) .unique_by(|TypeVar { name, .. }| name.id.as_str()) .collect::>(); + checker.diagnostics.push(create_diagnostic( + checker.generator(), + stmt.range(), + name, + value, + &vars, + // The fix is only safe in a type stub because new-style aliases have different runtime behavior + // See https://github.com/astral-sh/ruff/issues/6434 + if checker.source_type.is_stub() { + Applicability::Safe + } else { + Applicability::Unsafe + }, + TypeAliasKind::TypeAlias, + )); +} + +/// Generate a [`Diagnostic`] for a non-PEP 695 type alias or type alias type. +fn create_diagnostic( + generator: Generator, + stmt_range: TextRange, + name: &str, + value: &Expr, + vars: &[TypeVar], + applicability: Applicability, + type_alias_kind: TypeAliasKind, +) -> Diagnostic { let type_params = if vars.is_empty() { None } else { Some(ast::TypeParams { range: TextRange::default(), type_params: vars - .into_iter() + .iter() .map(|TypeVar { name, restriction }| { TypeParam::TypeVar(TypeParamTypeVar { range: TextRange::default(), name: Identifier::new(name.id.clone(), TextRange::default()), bound: match restriction { - Some(TypeVarRestriction::Bound(bound)) => Some(Box::new(bound.clone())), + Some(TypeVarRestriction::Bound(bound)) => { + Some(Box::new((*bound).clone())) + } Some(TypeVarRestriction::Constraint(constraints)) => { Some(Box::new(Expr::Tuple(ast::ExprTuple { range: TextRange::default(), - elts: constraints.into_iter().cloned().collect(), + elts: constraints.iter().map(|expr| (*expr).clone()).collect(), ctx: ast::ExprContext::Load, parenthesized: true, }))) @@ -141,27 +257,29 @@ pub(crate) fn non_pep695_type_alias(checker: &mut Checker, stmt: &StmtAnnAssign) }) }; - let mut diagnostic = Diagnostic::new(NonPEP695TypeAlias { name: name.clone() }, stmt.range()); - - let edit = Edit::range_replacement( - checker.generator().stmt(&Stmt::from(StmtTypeAlias { - range: TextRange::default(), - name: target.clone(), - type_params, - value: value.clone(), - })), - stmt.range(), - ); - // The fix is only safe in a type stub because new-style aliases have different runtime behavior - // See https://github.com/astral-sh/ruff/issues/6434 - let fix = if checker.source_type.is_stub() { - Fix::safe_edit(edit) - } else { - Fix::unsafe_edit(edit) - }; - diagnostic.set_fix(fix); - - checker.diagnostics.push(diagnostic); + Diagnostic::new( + NonPEP695TypeAlias { + name: name.to_string(), + type_alias_kind, + }, + stmt_range, + ) + .with_fix(Fix::applicable_edit( + Edit::range_replacement( + generator.stmt(&Stmt::from(StmtTypeAlias { + range: TextRange::default(), + name: Box::new(Expr::Name(ExprName { + range: TextRange::default(), + id: name.to_string(), + ctx: ast::ExprContext::Load, + })), + type_params, + value: Box::new(value.clone()), + })), + stmt_range, + ), + applicability, + )) } #[derive(Debug)] @@ -188,57 +306,64 @@ impl<'a> Visitor<'a> for TypeVarReferenceVisitor<'a> { fn visit_expr(&mut self, expr: &'a Expr) { match expr { Expr::Name(name) if name.ctx.is_load() => { - let Some(Stmt::Assign(StmtAssign { value, .. })) = self - .semantic - .lookup_symbol(name.id.as_str()) - .and_then(|binding_id| { - self.semantic - .binding(binding_id) - .source - .map(|node_id| self.semantic.statement(node_id)) - }) - else { - return; + self.vars.extend(expr_name_to_type_var(self.semantic, name)); + } + _ => visitor::walk_expr(self, expr), + } + } +} + +fn expr_name_to_type_var<'a>( + semantic: &'a SemanticModel, + name: &'a ExprName, +) -> Option> { + let Some(Stmt::Assign(StmtAssign { value, .. })) = semantic + .lookup_symbol(name.id.as_str()) + .and_then(|binding_id| { + semantic + .binding(binding_id) + .source + .map(|node_id| semantic.statement(node_id)) + }) + else { + return None; + }; + + match value.as_ref() { + Expr::Subscript(ExprSubscript { + value: ref subscript_value, + .. + }) => { + if semantic.match_typing_expr(subscript_value, "TypeVar") { + return Some(TypeVar { + name, + restriction: None, + }); + } + } + Expr::Call(ExprCall { + func, arguments, .. + }) => { + if semantic.match_typing_expr(func, "TypeVar") + && arguments + .args + .first() + .is_some_and(Expr::is_string_literal_expr) + { + let restriction = if let Some(bound) = arguments.find_keyword("bound") { + Some(TypeVarRestriction::Bound(&bound.value)) + } else if arguments.args.len() > 1 { + Some(TypeVarRestriction::Constraint( + arguments.args.iter().skip(1).collect(), + )) + } else { + None }; - match value.as_ref() { - Expr::Subscript(ExprSubscript { - value: ref subscript_value, - .. - }) => { - if self.semantic.match_typing_expr(subscript_value, "TypeVar") { - self.vars.push(TypeVar { - name, - restriction: None, - }); - } - } - Expr::Call(ExprCall { - func, arguments, .. - }) => { - if self.semantic.match_typing_expr(func, "TypeVar") - && arguments - .args - .first() - .is_some_and(Expr::is_string_literal_expr) - { - let restriction = if let Some(bound) = arguments.find_keyword("bound") { - Some(TypeVarRestriction::Bound(&bound.value)) - } else if arguments.args.len() > 1 { - Some(TypeVarRestriction::Constraint( - arguments.args.iter().skip(1).collect(), - )) - } else { - None - }; - - self.vars.push(TypeVar { name, restriction }); - } - } - _ => {} - } + return Some(TypeVar { name, restriction }); } - _ => visitor::walk_expr(self, expr), } + _ => {} } + None } diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap index e7692cb305451..03c7ea34d3bb6 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap @@ -245,5 +245,117 @@ UP040.py:53:1: UP040 [*] Type alias `Decorator` uses `TypeAlias` annotation inst 52 52 | T = typing.TypeVar["T"] 53 |-Decorator: TypeAlias = typing.Callable[[T], T] 53 |+type Decorator[T] = typing.Callable[[T], T] +54 54 | +55 55 | +56 56 | from typing import TypeVar, Annotated, TypeAliasType +UP040.py:63:1: UP040 [*] Type alias `PositiveList` uses `TypeAliasType` assignment instead of the `type` keyword + | +61 | # https://github.com/astral-sh/ruff/issues/11422 +62 | T = TypeVar("T") +63 | / PositiveList = TypeAliasType( +64 | | "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +65 | | ) + | |_^ UP040 +66 | +67 | # Bound + | + = help: Use the `type` keyword + +ℹ Safe fix +60 60 | +61 61 | # https://github.com/astral-sh/ruff/issues/11422 +62 62 | T = TypeVar("T") +63 |-PositiveList = TypeAliasType( +64 |- "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +65 |-) + 63 |+type PositiveList[T] = list[Annotated[T, Gt(0)]] +66 64 | +67 65 | # Bound +68 66 | T = TypeVar("T", bound=SupportGt) + +UP040.py:69:1: UP040 [*] Type alias `PositiveList` uses `TypeAliasType` assignment instead of the `type` keyword + | +67 | # Bound +68 | T = TypeVar("T", bound=SupportGt) +69 | / PositiveList = TypeAliasType( +70 | | "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +71 | | ) + | |_^ UP040 +72 | +73 | # Multiple bounds + | + = help: Use the `type` keyword + +ℹ Safe fix +66 66 | +67 67 | # Bound +68 68 | T = TypeVar("T", bound=SupportGt) +69 |-PositiveList = TypeAliasType( +70 |- "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +71 |-) + 69 |+type PositiveList[T: SupportGt] = list[Annotated[T, Gt(0)]] +72 70 | +73 71 | # Multiple bounds +74 72 | T1 = TypeVar("T1", bound=SupportGt) + +UP040.py:77:1: UP040 [*] Type alias `Tuple3` uses `TypeAliasType` assignment instead of the `type` keyword + | +75 | T2 = TypeVar("T2") +76 | T3 = TypeVar("T3") +77 | Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 +78 | +79 | # No type_params + | + = help: Use the `type` keyword + +ℹ Safe fix +74 74 | T1 = TypeVar("T1", bound=SupportGt) +75 75 | T2 = TypeVar("T2") +76 76 | T3 = TypeVar("T3") +77 |-Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) + 77 |+type Tuple3[T1: SupportGt, T2, T3] = tuple[T1, T2, T3] +78 78 | +79 79 | # No type_params +80 80 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) + +UP040.py:80:1: UP040 [*] Type alias `PositiveInt` uses `TypeAliasType` assignment instead of the `type` keyword + | +79 | # No type_params +80 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 +81 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) + | + = help: Use the `type` keyword + +ℹ Safe fix +77 77 | Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) +78 78 | +79 79 | # No type_params +80 |-PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) + 80 |+type PositiveInt = Annotated[int, Gt(0)] +81 81 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) +82 82 | +83 83 | # OK: Other name + +UP040.py:81:1: UP040 [*] Type alias `PositiveInt` uses `TypeAliasType` assignment instead of the `type` keyword + | +79 | # No type_params +80 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) +81 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 +82 | +83 | # OK: Other name + | + = help: Use the `type` keyword +ℹ Safe fix +78 78 | +79 79 | # No type_params +80 80 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) +81 |-PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) + 81 |+type PositiveInt = Annotated[int, Gt(0)] +82 82 | +83 83 | # OK: Other name +84 84 | T = TypeVar("T", bound=SupportGt)