diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py index e107f8da25494..337b9b2f1dac7 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py @@ -1,5 +1,5 @@ import typing -from typing import TypeAlias +from typing import Any, TypeAlias # UP040 x: typing.TypeAlias = int @@ -43,6 +43,10 @@ class Foo: T = typing.TypeVar(*args) x: typing.TypeAlias = list[T] +# `default` should be skipped for now, added in Python 3.13 +T = typing.TypeVar("T", default=Any) +x: typing.TypeAlias = list[T] + # OK x: TypeAlias x: int = 1 @@ -85,3 +89,7 @@ class Foo: PositiveList = TypeAliasType( "PositiveList2", list[Annotated[T, Gt(0)]], type_params=(T,) ) + +# `default` should be skipped for now, added in Python 3.13 +T = typing.TypeVar("T", default=Any) +AnyList = TypeAliasType("AnyList", list[T], typep_params=(T,)) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py new file mode 100644 index 0000000000000..6af12c47f0df9 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_0.py @@ -0,0 +1,90 @@ +from typing import Any, AnyStr, Generic, ParamSpec, TypeVar, TypeVarTuple + +from somewhere import SupportsRichComparisonT + +S = TypeVar("S", str, bytes) # constrained type variable +T = TypeVar("T", bound=float) +Ts = TypeVarTuple("Ts") +P = ParamSpec("P") + + +class A(Generic[T]): + # Comments in a class body are preserved + var: T + + +class B(Generic[*Ts]): + var: tuple[*Ts] + + +class C(Generic[P]): + var: P + + +class Constrained(Generic[S]): + var: S + + +# This case gets a diagnostic but not a fix because we can't look up the bounds +# or constraints on the TypeVar imported from another module +class ExternalType(Generic[T, SupportsRichComparisonT]): + var: T + compare: SupportsRichComparisonT + + +# typing.AnyStr is a common external type variable, so treat it specially as a +# known TypeVar +class MyStr(Generic[AnyStr]): + s: AnyStr + + +class MultipleGenerics(Generic[S, T, *Ts, P]): + var: S + typ: T + tup: tuple[*Ts] + pep: P + + +class MultipleBaseClasses(list, Generic[T]): + var: T + + +# These cases are not handled +class D(Generic[T, T]): # duplicate generic variable, runtime error + pass + + +# TODO(brent) we should also apply the fix to methods, but it will need a +# little more work. these should be left alone for now but be fixed eventually. +class NotGeneric: + # -> generic_method[T: float](t: T) + def generic_method(t: T) -> T: + return t + + +# This one is strange in particular because of the mix of old- and new-style +# generics, but according to the PEP, this is okay "if the class, function, or +# type alias does not use the new syntax." `more_generic` doesn't use the new +# syntax, so it can use T from the module and U from the class scope. +class MixedGenerics[U]: + def more_generic(u: U, t: T) -> tuple[U, T]: + return (u, t) + + +# TODO(brent) we should also handle multiple base classes +class Multiple(NotGeneric, Generic[T]): + pass + + +# TODO(brent) default requires 3.13 +V = TypeVar("V", default=Any, bound=str) + + +class DefaultTypeVar(Generic[V]): # -> [V: str = Any] + var: V + + +# nested classes and functions are skipped +class Outer: + class Inner(Generic[T]): + var: T diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_1.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_1.py new file mode 100644 index 0000000000000..95aaf734606d1 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP046_1.py @@ -0,0 +1,12 @@ +"""Replacing AnyStr requires specifying the constraints `bytes` and `str`, so +it can't be replaced if these have been shadowed. This test is in a separate +fixture because it doesn't seem possible to restore `str` to its builtin state +""" + +from typing import AnyStr, Generic + +str = "string" + + +class BadStr(Generic[AnyStr]): + var: AnyStr diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py new file mode 100644 index 0000000000000..48b5d431de6fb --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP047.py @@ -0,0 +1,59 @@ +from collections.abc import Callable +from typing import Any, AnyStr, ParamSpec, TypeVar, TypeVarTuple + +from somewhere import Something + +S = TypeVar("S", str, bytes) # constrained type variable +T = TypeVar("T", bound=float) +Ts = TypeVarTuple("Ts") +P = ParamSpec("P") + + +def f(t: T) -> T: + return t + + +def g(ts: tuple[*Ts]) -> tuple[*Ts]: + return ts + + +def h( + p: Callable[P, T], + # Comment in the middle of a parameter list should be preserved + another_param, + and_another, +) -> Callable[P, T]: + return p + + +def i(s: S) -> S: + return s + + +# NOTE this case is the reason the fix is marked unsafe. If we can't confirm +# that one of the type parameters (`Something` in this case) is a TypeVar, +# which we can't do across module boundaries, we will not convert it to a +# generic type parameter. This leads to code that mixes old-style standalone +# TypeVars with the new-style generic syntax and will be rejected by type +# checkers +def broken_fix(okay: T, bad: Something) -> tuple[T, Something]: + return (okay, bad) + + +def any_str_param(s: AnyStr) -> AnyStr: + return s + + +# these cases are not handled + +# TODO(brent) default requires 3.13 +V = TypeVar("V", default=Any, bound=str) + + +def default_var(v: V) -> V: + return v + + +def outer(): + def inner(t: T) -> T: + return t diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 5611a307f44df..8482f47a6db94 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -376,6 +376,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.enabled(Rule::PytestParameterWithDefaultArgument) { flake8_pytest_style::rules::parameter_with_default_argument(checker, function_def); } + if checker.enabled(Rule::NonPEP695GenericFunction) { + pyupgrade::rules::non_pep695_generic_function(checker, function_def); + } } Stmt::Return(_) => { if checker.enabled(Rule::ReturnOutsideFunction) { @@ -554,6 +557,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.enabled(Rule::DataclassEnum) { ruff::rules::dataclass_enum(checker, class_def); } + if checker.enabled(Rule::NonPEP695GenericClass) { + pyupgrade::rules::non_pep695_generic_class(checker, class_def); + } } Stmt::Import(ast::StmtImport { names, range: _ }) => { if checker.enabled(Rule::MultipleImportsOnOneLine) { diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 91123b2724a21..7c6d393476b5a 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -540,6 +540,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Pyupgrade, "043") => (RuleGroup::Stable, rules::pyupgrade::rules::UnnecessaryDefaultTypeArgs), (Pyupgrade, "044") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP646Unpack), (Pyupgrade, "045") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP604AnnotationOptional), + (Pyupgrade, "046") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP695GenericClass), + (Pyupgrade, "047") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP695GenericFunction), // pydocstyle (Pydocstyle, "100") => (RuleGroup::Stable, rules::pydocstyle::rules::UndocumentedPublicModule), diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs index 315cafa5a7b1c..f10421e4ffaee 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs @@ -103,6 +103,9 @@ mod tests { #[test_case(Rule::YieldInForLoop, Path::new("UP028_1.py"))] #[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.py"))] #[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.pyi"))] + #[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_0.py"))] + #[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_1.py"))] + #[test_case(Rule::NonPEP695GenericFunction, Path::new("UP047.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = path.to_string_lossy().to_string(); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs index ac3ea97d30856..d15801edc36df 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs @@ -14,6 +14,7 @@ pub(crate) use native_literals::*; pub(crate) use open_alias::*; pub(crate) use os_error_alias::*; pub(crate) use outdated_version_block::*; +pub(crate) use pep695::*; pub(crate) use printf_string_formatting::*; pub(crate) use quoted_annotation::*; pub(crate) use redundant_open_modes::*; @@ -36,7 +37,6 @@ pub(crate) use use_pep585_annotation::*; pub(crate) use use_pep604_annotation::*; pub(crate) use use_pep604_isinstance::*; pub(crate) use use_pep646_unpack::*; -pub(crate) use use_pep695_type_alias::*; pub(crate) use useless_metaclass_type::*; pub(crate) use useless_object_inheritance::*; pub(crate) use yield_in_for_loop::*; @@ -57,6 +57,7 @@ mod native_literals; mod open_alias; mod os_error_alias; mod outdated_version_block; +mod pep695; mod printf_string_formatting; mod quoted_annotation; mod redundant_open_modes; @@ -79,7 +80,6 @@ mod use_pep585_annotation; mod use_pep604_annotation; mod use_pep604_isinstance; mod use_pep646_unpack; -mod use_pep695_type_alias; mod useless_metaclass_type; mod useless_object_inheritance; mod yield_in_for_loop; diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs new file mode 100644 index 0000000000000..860f2d60dcf81 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs @@ -0,0 +1,346 @@ +//! Shared code for [`non_pep695_type_alias`] (UP040), +//! [`non_pep695_generic_class`] (UP046), and [`non_pep695_generic_function`] +//! (UP047) + +use std::fmt::Display; + +use itertools::Itertools; +use ruff_python_ast::{ + self as ast, + name::Name, + visitor::{self, Visitor}, + Expr, ExprCall, ExprName, ExprSubscript, Identifier, Stmt, StmtAssign, TypeParam, + TypeParamParamSpec, TypeParamTypeVar, TypeParamTypeVarTuple, +}; +use ruff_python_semantic::SemanticModel; +use ruff_text_size::{Ranged, TextRange}; + +pub(crate) use non_pep695_generic_class::*; +pub(crate) use non_pep695_generic_function::*; +pub(crate) use non_pep695_type_alias::*; + +use crate::checkers::ast::Checker; + +mod non_pep695_generic_class; +mod non_pep695_generic_function; +mod non_pep695_type_alias; + +#[derive(Debug)] +enum TypeVarRestriction<'a> { + /// A type variable with a bound, e.g., `TypeVar("T", bound=int)`. + Bound(&'a Expr), + /// A type variable with constraints, e.g., `TypeVar("T", int, str)`. + Constraint(Vec<&'a Expr>), + /// `AnyStr` is a special case: the only public `TypeVar` defined in the standard library, + /// and thus the only one that we recognise when imported from another module. + AnyStr, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum TypeParamKind { + TypeVar, + TypeVarTuple, + ParamSpec, +} + +#[derive(Debug)] +struct TypeVar<'a> { + name: &'a str, + restriction: Option>, + kind: TypeParamKind, + default: Option<&'a Expr>, +} + +/// Wrapper for formatting a sequence of [`TypeVar`]s for use as a generic type parameter (e.g. `[T, +/// *Ts, **P]`). See [`DisplayTypeVar`] for further details. +struct DisplayTypeVars<'a> { + type_vars: &'a [TypeVar<'a>], + source: &'a str, +} + +impl Display for DisplayTypeVars<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[")?; + let nvars = self.type_vars.len(); + for (i, tv) in self.type_vars.iter().enumerate() { + write!(f, "{}", tv.display(self.source))?; + if i < nvars - 1 { + f.write_str(", ")?; + } + } + f.write_str("]")?; + + Ok(()) + } +} + +/// Used for displaying `type_var`. `source` is the whole file, which will be sliced to recover the +/// `TypeVarRestriction` values for generic bounds and constraints. +struct DisplayTypeVar<'a> { + type_var: &'a TypeVar<'a>, + source: &'a str, +} + +impl TypeVar<'_> { + fn display<'a>(&'a self, source: &'a str) -> DisplayTypeVar<'a> { + DisplayTypeVar { + type_var: self, + source, + } + } +} + +impl Display for DisplayTypeVar<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.type_var.kind { + TypeParamKind::TypeVar => {} + TypeParamKind::TypeVarTuple => f.write_str("*")?, + TypeParamKind::ParamSpec => f.write_str("**")?, + } + f.write_str(self.type_var.name)?; + if let Some(restriction) = &self.type_var.restriction { + f.write_str(": ")?; + match restriction { + TypeVarRestriction::Bound(bound) => { + f.write_str(&self.source[bound.range()])?; + } + TypeVarRestriction::AnyStr => f.write_str("(bytes, str)")?, + TypeVarRestriction::Constraint(vec) => { + let len = vec.len(); + f.write_str("(")?; + for (i, v) in vec.iter().enumerate() { + f.write_str(&self.source[v.range()])?; + if i < len - 1 { + f.write_str(", ")?; + } + } + f.write_str(")")?; + } + } + } + + Ok(()) + } +} + +impl<'a> From<&'a TypeVar<'a>> for TypeParam { + fn from( + TypeVar { + name, + restriction, + kind, + default: _, // TODO(brent) see below + }: &'a TypeVar<'a>, + ) -> Self { + match kind { + TypeParamKind::TypeVar => { + TypeParam::TypeVar(TypeParamTypeVar { + range: TextRange::default(), + name: Identifier::new(*name, TextRange::default()), + bound: match restriction { + 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.iter().map(|expr| (*expr).clone()).collect(), + ctx: ast::ExprContext::Load, + parenthesized: true, + }))) + } + Some(TypeVarRestriction::AnyStr) => { + Some(Box::new(Expr::Tuple(ast::ExprTuple { + range: TextRange::default(), + elts: vec![ + Expr::Name(ExprName { + range: TextRange::default(), + id: Name::from("str"), + ctx: ast::ExprContext::Load, + }), + Expr::Name(ExprName { + range: TextRange::default(), + id: Name::from("bytes"), + ctx: ast::ExprContext::Load, + }), + ], + ctx: ast::ExprContext::Load, + parenthesized: true, + }))) + } + None => None, + }, + // We don't handle defaults here yet. Should perhaps be a different rule since + // defaults are only valid in 3.13+. + default: None, + }) + } + TypeParamKind::TypeVarTuple => TypeParam::TypeVarTuple(TypeParamTypeVarTuple { + range: TextRange::default(), + name: Identifier::new(*name, TextRange::default()), + default: None, + }), + TypeParamKind::ParamSpec => TypeParam::ParamSpec(TypeParamParamSpec { + range: TextRange::default(), + name: Identifier::new(*name, TextRange::default()), + default: None, + }), + } + } +} + +struct TypeVarReferenceVisitor<'a> { + vars: Vec>, + semantic: &'a SemanticModel<'a>, + /// Tracks whether any non-TypeVars have been seen to avoid replacing generic parameters when an + /// unknown `TypeVar` is encountered. + any_skipped: bool, +} + +/// Recursively collects the names of type variable references present in an expression. +impl<'a> Visitor<'a> for TypeVarReferenceVisitor<'a> { + fn visit_expr(&mut self, expr: &'a Expr) { + // special case for typing.AnyStr, which is a commonly-imported type variable in the + // standard library with the definition: + // + // ```python + // AnyStr = TypeVar('AnyStr', bytes, str) + // ``` + // + // As of 01/2025, this line hasn't been modified in 8 years, so hopefully there won't be + // much to keep updated here. See + // https://github.com/python/cpython/blob/383af395af828f40d9543ee0a8fdc5cc011d43db/Lib/typing.py#L2806 + // + // to replace AnyStr with an annotation like [AnyStr: (bytes, str)], we also have to make + // sure that `bytes` and `str` have their builtin values and have not been shadowed + if self.semantic.match_typing_expr(expr, "AnyStr") + && self.semantic.has_builtin_binding("bytes") + && self.semantic.has_builtin_binding("str") + { + self.vars.push(TypeVar { + name: "AnyStr", + restriction: Some(TypeVarRestriction::AnyStr), + kind: TypeParamKind::TypeVar, + default: None, + }); + return; + } + + match expr { + Expr::Name(name) if name.ctx.is_load() => { + if let Some(var) = expr_name_to_type_var(self.semantic, name) { + self.vars.push(var); + } else { + self.any_skipped = true; + } + } + _ => visitor::walk_expr(self, expr), + } + } +} + +fn expr_name_to_type_var<'a>( + semantic: &'a SemanticModel, + name: &'a ExprName, +) -> Option> { + let 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))? + .as_assign_stmt()?; + + match value.as_ref() { + Expr::Subscript(ExprSubscript { + value: ref subscript_value, + .. + }) => { + if semantic.match_typing_expr(subscript_value, "TypeVar") { + return Some(TypeVar { + name: &name.id, + restriction: None, + kind: TypeParamKind::TypeVar, + default: None, + }); + } + } + Expr::Call(ExprCall { + func, arguments, .. + }) => { + let kind = if semantic.match_typing_expr(func, "TypeVar") { + TypeParamKind::TypeVar + } else if semantic.match_typing_expr(func, "TypeVarTuple") { + TypeParamKind::TypeVarTuple + } else if semantic.match_typing_expr(func, "ParamSpec") { + TypeParamKind::ParamSpec + } else { + return None; + }; + + if arguments + .args + .first() + .is_some_and(Expr::is_string_literal_expr) + { + // TODO(brent) `default` was added in PEP 696 and Python 3.13 but can't be used in + // generic type parameters before that + // + // ```python + // T = TypeVar("T", default=Any, bound=str) + // class slice(Generic[T]): ... + // ``` + // + // becomes + // + // ```python + // class slice[T: str = Any]: ... + // ``` + let default = arguments + .find_keyword("default") + .map(|default| &default.value); + 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 + }; + + return Some(TypeVar { + name: &name.id, + restriction, + kind, + default, + }); + } + } + _ => {} + } + None +} + +/// Check if the current statement is nested within another [`StmtClassDef`] or [`StmtFunctionDef`]. +fn in_nested_context(checker: &Checker) -> bool { + checker + .semantic() + .current_statements() + .skip(1) // skip the immediate parent, we only call this within a class or function + .any(|stmt| matches!(stmt, Stmt::ClassDef(_) | Stmt::FunctionDef(_))) +} + +/// Deduplicate `vars`, returning `None` if `vars` is empty or any duplicates are found. +fn check_type_vars(vars: Vec>) -> Option>> { + if vars.is_empty() { + return None; + } + + // If any type variables were not unique, just bail out here. this is a runtime error and we + // can't predict what the user wanted. also bail out if any Python 3.13+ default values are + // found on the type parameters + (vars + .iter() + .unique_by(|tvar| tvar.name) + .filter(|tvar| tvar.default.is_none()) + .count() + == vars.len()) + .then_some(vars) +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs new file mode 100644 index 0000000000000..3a46fa1521476 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_class.rs @@ -0,0 +1,190 @@ +use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; +use ruff_macros::{derive_message_formats, ViolationMetadata}; +use ruff_python_ast::visitor::Visitor; +use ruff_python_ast::StmtClassDef; +use ruff_python_ast::{Expr, ExprSubscript}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::settings::types::PythonVersion; + +use super::{check_type_vars, in_nested_context, DisplayTypeVars, TypeVarReferenceVisitor}; + +/// ## What it does +/// +/// Checks for use of standalone type variables and parameter specifications in generic classes. +/// +/// ## Why is this bad? +/// +/// Special type parameter syntax was introduced in Python 3.12 by [PEP 695] for defining generic +/// classes. This syntax is easier to read and provides cleaner support for generics. +/// +/// ## Known problems +/// +/// The rule currently skips generic classes with multiple base classes. It also skips +/// generic classes nested inside of other +/// functions or classes. Finally, this rule skips type parameters with the `default` argument +/// introduced in [PEP 696] and implemented in Python 3.13. +/// +/// This rule can only offer a fix if all of the generic types in the class definition are defined +/// in the current module. For external type parameters, a diagnostic is emitted without a suggested +/// fix. +/// +/// Not all type checkers fully support PEP 695 yet, so even valid fixes suggested by this rule may +/// cause type checking to fail. +/// +/// ## Fix safety +/// +/// This fix is marked as unsafe, as [PEP 695] uses inferred variance for type parameters, instead +/// of the `covariant` and `contravariant` keywords used by `TypeVar` variables. As such, replacing +/// a `TypeVar` variable with an inline type parameter may change its variance. +/// +/// ## Example +/// +/// ```python +/// from typing import TypeVar +/// +/// T = TypeVar("T") +/// +/// +/// class GenericClass(Generic[T]): +/// var: T +/// ``` +/// +/// Use instead: +/// +/// ```python +/// class GenericClass[T]: +/// var: T +/// ``` +/// +/// ## See also +/// +/// This rule replaces standalone type variables in classes but doesn't remove +/// the corresponding type variables even if they are unused after the fix. See +/// [`unused-private-type-var`](unused-private-type-var.md) for a rule to clean up unused +/// private type variables. +/// +/// This rule only applies to generic classes and does not include generic functions. See +/// [`non-pep695-generic-function`](non-pep695-generic-function.md) for the function version. +/// +/// [PEP 695]: https://peps.python.org/pep-0695/ +/// [PEP 696]: https://peps.python.org/pep-0696/ +#[derive(ViolationMetadata)] +pub(crate) struct NonPEP695GenericClass { + name: String, +} + +impl Violation for NonPEP695GenericClass { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + let NonPEP695GenericClass { name } = self; + format!("Generic class `{name}` uses `Generic` subclass instead of type parameters") + } + + fn fix_title(&self) -> Option { + Some("Use type parameters".to_string()) + } +} + +/// UP046 +pub(crate) fn non_pep695_generic_class(checker: &mut Checker, class_def: &StmtClassDef) { + // PEP-695 syntax is only available on Python 3.12+ + if checker.settings.target_version < PythonVersion::Py312 { + return; + } + + // don't try to handle generic classes inside other functions or classes + if in_nested_context(checker) { + return; + } + + let StmtClassDef { + name, + type_params, + arguments, + .. + } = class_def; + + // it's a runtime error to mix type_params and Generic, so bail out early if we see existing + // type_params + if type_params.is_some() { + return; + } + + let Some(arguments) = arguments.as_ref() else { + return; + }; + + // TODO(brent) only accept a single, Generic argument for now. I think it should be fine to have + // other arguments, but this simplifies the fix just to delete the argument list for now + let [Expr::Subscript(ExprSubscript { + value, + slice, + range, + .. + })] = arguments.args.as_ref() + else { + return; + }; + + if !checker.semantic().match_typing_expr(value, "Generic") { + return; + } + + let mut diagnostic = Diagnostic::new( + NonPEP695GenericClass { + name: name.to_string(), + }, + *range, + ); + + let mut visitor = TypeVarReferenceVisitor { + vars: vec![], + semantic: checker.semantic(), + any_skipped: false, + }; + visitor.visit_expr(slice); + + // if any of the parameters have been skipped, this indicates that we could not resolve the type + // to a `TypeVar`, `TypeVarTuple`, or `ParamSpec`, and thus our fix would remove it from the + // signature incorrectly. We can still offer the diagnostic created above without a Fix. For + // example, + // + // ```python + // from somewhere import SomethingElse + // + // T = TypeVar("T") + // + // class Class(Generic[T, SomethingElse]): ... + // ``` + // + // should not be converted to + // + // ```python + // class Class[T]: ... + // ``` + // + // just because we can't confirm that `SomethingElse` is a `TypeVar` + if !visitor.any_skipped { + let Some(type_vars) = check_type_vars(visitor.vars) else { + return; + }; + + // build the fix as a String to avoid removing comments from the entire function body + let type_params = DisplayTypeVars { + type_vars: &type_vars, + source: checker.source(), + }; + + diagnostic.set_fix(Fix::unsafe_edit(Edit::replacement( + type_params.to_string(), + name.end(), + arguments.end(), + ))); + } + + checker.diagnostics.push(diagnostic); +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs new file mode 100644 index 0000000000000..f1109dd8a0f27 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_generic_function.rs @@ -0,0 +1,171 @@ +use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; +use ruff_macros::{derive_message_formats, ViolationMetadata}; +use ruff_python_ast::visitor::Visitor; +use ruff_python_ast::StmtFunctionDef; +use ruff_text_size::{Ranged, TextRange}; + +use crate::checkers::ast::Checker; +use crate::settings::types::PythonVersion; + +use super::{check_type_vars, in_nested_context, DisplayTypeVars, TypeVarReferenceVisitor}; + +/// ## What it does +/// +/// Checks for use of standalone type variables and parameter specifications in generic functions. +/// +/// ## Why is this bad? +/// +/// Special type parameter syntax was introduced in Python 3.12 by [PEP 695] for defining generic +/// functions. This syntax is easier to read and provides cleaner support for generics. +/// +/// ## Known problems +/// +/// The rule currently skips generic functions nested inside of other functions or classes and those +/// with type parameters containing the `default` argument introduced in [PEP 696] and implemented +/// in Python 3.13. +/// +/// Not all type checkers fully support PEP 695 yet, so even valid fixes suggested by this rule may +/// cause type checking to fail. +/// +/// ## Fix safety +/// +/// This fix is marked unsafe, as [PEP 695] uses inferred variance for type parameters, instead of +/// the `covariant` and `contravariant` keywords used by `TypeVar` variables. As such, replacing a +/// `TypeVar` variable with an inline type parameter may change its variance. +/// +/// Additionally, if the rule cannot determine whether a parameter annotation corresponds to a type +/// variable (e.g. for a type imported from another module), it will not add the type to the generic +/// type parameter list. This causes the function to have a mix of old-style type variables and +/// new-style generic type parameters, which will be rejected by type checkers. +/// +/// ## Example +/// +/// ```python +/// from typing import TypeVar +/// +/// T = TypeVar("T") +/// +/// +/// def generic_function(var: T) -> T: +/// return var +/// ``` +/// +/// Use instead: +/// +/// ```python +/// def generic_function[T](var: T) -> T: +/// return var +/// ``` +/// +/// ## See also +/// +/// This rule replaces standalone type variables in function signatures but doesn't remove +/// the corresponding type variables even if they are unused after the fix. See +/// [`unused-private-type-var`](unused-private-type-var.md) for a rule to clean up unused +/// private type variables. +/// +/// This rule only applies to generic functions and does not include generic classes. See +/// [`non-pep695-generic-class`](non-pep695-generic-class.md) for the class version. +/// +/// [PEP 695]: https://peps.python.org/pep-0695/ +/// [PEP 696]: https://peps.python.org/pep-0696/ +#[derive(ViolationMetadata)] +pub(crate) struct NonPEP695GenericFunction { + name: String, +} + +impl Violation for NonPEP695GenericFunction { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always; + + #[derive_message_formats] + fn message(&self) -> String { + let NonPEP695GenericFunction { name } = self; + format!("Generic function `{name}` should use type parameters") + } + + fn fix_title(&self) -> Option { + Some("Use type parameters".to_string()) + } +} + +/// UP047 +pub(crate) fn non_pep695_generic_function(checker: &mut Checker, function_def: &StmtFunctionDef) { + // PEP-695 syntax is only available on Python 3.12+ + if checker.settings.target_version < PythonVersion::Py312 { + return; + } + + // don't try to handle generic functions inside other functions or classes + if in_nested_context(checker) { + return; + } + + let StmtFunctionDef { + name, + type_params, + parameters, + .. + } = function_def; + + // TODO(brent) handle methods, for now return early in a class body. For example, an additional + // generic parameter on the method needs to be handled separately from one already on the class + // + // ```python + // T = TypeVar("T") + // S = TypeVar("S") + // + // class Foo(Generic[T]): + // def bar(self, x: T, y: S) -> S: ... + // + // + // class Foo[T]: + // def bar[S](self, x: T, y: S) -> S: ... + // ``` + if checker.semantic().current_scope().kind.is_class() { + return; + } + + // invalid to mix old-style and new-style generics + if type_params.is_some() { + return; + } + + let mut type_vars = Vec::new(); + for parameter in parameters { + if let Some(annotation) = parameter.annotation() { + let vars = { + let mut visitor = TypeVarReferenceVisitor { + vars: vec![], + semantic: checker.semantic(), + any_skipped: false, + }; + visitor.visit_expr(annotation); + visitor.vars + }; + type_vars.extend(vars); + } + } + + let Some(type_vars) = check_type_vars(type_vars) else { + return; + }; + + // build the fix as a String to avoid removing comments from the entire function body + let type_params = DisplayTypeVars { + type_vars: &type_vars, + source: checker.source(), + }; + + checker.diagnostics.push( + Diagnostic::new( + NonPEP695GenericFunction { + name: name.to_string(), + }, + TextRange::new(name.start(), parameters.end()), + ) + .with_fix(Fix::unsafe_edit(Edit::insertion( + type_params.to_string(), + name.end(), + ))), + ); +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs similarity index 56% rename from crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs rename to crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs index a2b09b46ae72a..04843aea5f470 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs @@ -4,18 +4,17 @@ use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Vi use ruff_macros::{derive_message_formats, ViolationMetadata}; use ruff_python_ast::name::Name; use ruff_python_ast::{ - self as ast, - visitor::{self, Visitor}, - Expr, ExprCall, ExprName, ExprSubscript, Identifier, Keyword, Stmt, StmtAnnAssign, StmtAssign, - StmtTypeAlias, TypeParam, TypeParamTypeVar, + self as ast, visitor::Visitor, Expr, ExprCall, ExprName, Keyword, Stmt, StmtAnnAssign, + StmtAssign, StmtTypeAlias, TypeParam, }; use ruff_python_codegen::Generator; -use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; use crate::settings::types::PythonVersion; +use super::{expr_name_to_type_var, TypeParamKind, TypeVar, TypeVarReferenceVisitor}; + /// ## What it does /// Checks for use of `TypeAlias` annotations and `TypeAliasType` assignments /// for declaring type aliases. @@ -27,14 +26,17 @@ use crate::settings::types::PythonVersion; /// /// ## Known problems /// [PEP 695] uses inferred variance for type parameters, instead of the -/// `covariant` and `contravariant` keywords used by `TypeParam` variables. As -/// such, rewriting a `TypeParam` variable to a `type` alias may change its -/// variance. +/// `covariant` and `contravariant` keywords used by `TypeVar` variables. As +/// such, rewriting a type alias using a PEP-695 `type` statement may change +/// the variance of the alias's type parameters. /// -/// Unlike `TypeParam` variables, [PEP 695]-style `type` aliases cannot be used -/// at runtime. For example, calling `isinstance` on a `type` alias will throw -/// a `TypeError`. As such, rewriting a `TypeParam` via the `type` keyword will -/// cause issues for parameters that are used for such runtime checks. +/// Unlike type aliases that use simple assignments, definitions created using +/// [PEP 695] `type` statements cannot be used as drop-in replacements at +/// runtime for the value on the right-hand side of the statement. This means +/// that while for some simple old-style type aliases you can use them as the +/// second argument to an `isinstance()` call (for example), doing the same +/// with a [PEP 695] `type` statement will always raise `TypeError` at +/// runtime. /// /// ## Example /// ```python @@ -131,8 +133,10 @@ pub(crate) fn non_pep695_type_alias_type(checker: &mut Checker, stmt: &StmtAssig .map(|expr| { expr.as_name_expr().map(|name| { expr_name_to_type_var(checker.semantic(), name).unwrap_or(TypeVar { - name, + name: &name.id, restriction: None, + kind: TypeParamKind::TypeVar, + default: None, }) }) }) @@ -186,6 +190,7 @@ pub(crate) fn non_pep695_type_alias(checker: &mut Checker, stmt: &StmtAnnAssign) let mut visitor = TypeVarReferenceVisitor { vars: vec![], semantic: checker.semantic(), + any_skipped: false, }; visitor.visit_expr(value); visitor.vars @@ -194,9 +199,14 @@ pub(crate) fn non_pep695_type_alias(checker: &mut Checker, stmt: &StmtAnnAssign) // Type variables must be unique; filter while preserving order. let vars = vars .into_iter() - .unique_by(|TypeVar { name, .. }| name.id.as_str()) + .unique_by(|tvar| tvar.name) .collect::>(); + // TODO(brent) handle `default` arg for Python 3.13+ + if vars.iter().any(|tv| tv.default.is_some()) { + return; + } + checker.diagnostics.push(create_diagnostic( checker.generator(), stmt.range(), @@ -224,40 +234,6 @@ fn create_diagnostic( 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 - .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::Constraint(constraints)) => { - Some(Box::new(Expr::Tuple(ast::ExprTuple { - range: TextRange::default(), - elts: constraints.iter().map(|expr| (*expr).clone()).collect(), - ctx: ast::ExprContext::Load, - parenthesized: true, - }))) - } - None => None, - }, - // We don't handle defaults here yet. Should perhaps be a different rule since - // defaults are only valid in 3.13+. - default: None, - }) - }) - .collect(), - }) - }; - Diagnostic::new( NonPEP695TypeAlias { name: name.to_string(), @@ -274,7 +250,7 @@ fn create_diagnostic( id: name, ctx: ast::ExprContext::Load, })), - type_params, + type_params: create_type_params(vars), value: Box::new(value.clone()), })), stmt_range, @@ -283,88 +259,13 @@ fn create_diagnostic( )) } -#[derive(Debug)] -enum TypeVarRestriction<'a> { - /// A type variable with a bound, e.g., `TypeVar("T", bound=int)`. - Bound(&'a Expr), - /// A type variable with constraints, e.g., `TypeVar("T", int, str)`. - Constraint(Vec<&'a Expr>), -} - -#[derive(Debug)] -struct TypeVar<'a> { - name: &'a ExprName, - restriction: Option>, -} - -struct TypeVarReferenceVisitor<'a> { - vars: Vec>, - semantic: &'a SemanticModel<'a>, -} - -/// Recursively collects the names of type variable references present in an expression. -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() => { - 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 { +fn create_type_params(vars: &[TypeVar]) -> Option { + if vars.is_empty() { 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 - }; - - return Some(TypeVar { name, restriction }); - } - } - _ => {} } - None + + Some(ast::TypeParams { + range: TextRange::default(), + type_params: vars.iter().map(TypeParam::from).collect(), + }) } 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 0e68a81b0682d..b4b16d21ff3c9 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 @@ -1,5 +1,6 @@ --- source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +snapshot_kind: text --- UP040.py:5:1: UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of the `type` keyword | @@ -11,7 +12,7 @@ UP040.py:5:1: UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of th = help: Use the `type` keyword ℹ Unsafe fix -2 2 | from typing import TypeAlias +2 2 | from typing import Any, TypeAlias 3 3 | 4 4 | # UP040 5 |-x: typing.TypeAlias = int @@ -216,7 +217,7 @@ UP040.py:44:1: UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of t 44 | x: typing.TypeAlias = list[T] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 45 | -46 | # OK +46 | # `default` should be skipped for now, added in Python 3.13 | = help: Use the `type` keyword @@ -227,135 +228,135 @@ UP040.py:44:1: UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of t 44 |-x: typing.TypeAlias = list[T] 44 |+type x = list[T] 45 45 | -46 46 | # OK -47 47 | x: TypeAlias +46 46 | # `default` should be skipped for now, added in Python 3.13 +47 47 | T = typing.TypeVar("T", default=Any) -UP040.py:53:1: UP040 [*] Type alias `Decorator` uses `TypeAlias` annotation instead of the `type` keyword +UP040.py:57:1: UP040 [*] Type alias `Decorator` uses `TypeAlias` annotation instead of the `type` keyword | -51 | # type alias. -52 | T = typing.TypeVar["T"] -53 | Decorator: TypeAlias = typing.Callable[[T], T] +55 | # type alias. +56 | T = typing.TypeVar["T"] +57 | Decorator: TypeAlias = typing.Callable[[T], T] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 | = help: Use the `type` keyword ℹ Unsafe fix -50 50 | # Ensure that "T" appears only once in the type parameters for the modernized -51 51 | # type alias. -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 +54 54 | # Ensure that "T" appears only once in the type parameters for the modernized +55 55 | # type alias. +56 56 | T = typing.TypeVar["T"] +57 |-Decorator: TypeAlias = typing.Callable[[T], T] + 57 |+type Decorator[T] = typing.Callable[[T], T] +58 58 | +59 59 | +60 60 | from typing import TypeVar, Annotated, TypeAliasType + +UP040.py:67: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 | | ) +65 | # https://github.com/astral-sh/ruff/issues/11422 +66 | T = TypeVar("T") +67 | / PositiveList = TypeAliasType( +68 | | "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +69 | | ) | |_^ UP040 -66 | -67 | # Bound +70 | +71 | # 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 +64 64 | +65 65 | # https://github.com/astral-sh/ruff/issues/11422 +66 66 | T = TypeVar("T") +67 |-PositiveList = TypeAliasType( +68 |- "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +69 |-) + 67 |+type PositiveList[T] = list[Annotated[T, Gt(0)]] +70 68 | +71 69 | # Bound +72 70 | T = TypeVar("T", bound=SupportGt) + +UP040.py:73: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 | | ) +71 | # Bound +72 | T = TypeVar("T", bound=SupportGt) +73 | / PositiveList = TypeAliasType( +74 | | "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +75 | | ) | |_^ UP040 -72 | -73 | # Multiple bounds +76 | +77 | # 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 +70 70 | +71 71 | # Bound +72 72 | T = TypeVar("T", bound=SupportGt) +73 |-PositiveList = TypeAliasType( +74 |- "PositiveList", list[Annotated[T, Gt(0)]], type_params=(T,) +75 |-) + 73 |+type PositiveList[T: SupportGt] = list[Annotated[T, Gt(0)]] +76 74 | +77 75 | # Multiple bounds +78 76 | T1 = TypeVar("T1", bound=SupportGt) + +UP040.py:81: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)) +79 | T2 = TypeVar("T2") +80 | T3 = TypeVar("T3") +81 | Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 -78 | -79 | # No type_params +82 | +83 | # 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 +78 78 | T1 = TypeVar("T1", bound=SupportGt) +79 79 | T2 = TypeVar("T2") +80 80 | T3 = TypeVar("T3") +81 |-Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) + 81 |+type Tuple3[T1: SupportGt, T2, T3] = tuple[T1, T2, T3] +82 82 | +83 83 | # No type_params +84 84 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) + +UP040.py:84: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)]) +83 | # No type_params +84 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 -81 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) +85 | 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=()) +81 81 | Tuple3 = TypeAliasType("Tuple3", tuple[T1, T2, T3], type_params=(T1, T2, T3)) 82 82 | -83 83 | # OK: Other name - -UP040.py:81:1: UP040 [*] Type alias `PositiveInt` uses `TypeAliasType` assignment instead of the `type` keyword +83 83 | # No type_params +84 |-PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) + 84 |+type PositiveInt = Annotated[int, Gt(0)] +85 85 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) +86 86 | +87 87 | # OK: Other name + +UP040.py:85: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=()) +83 | # No type_params +84 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) +85 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP040 -82 | -83 | # OK: Other name +86 | +87 | # 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) +83 83 | # No type_params +84 84 | PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)]) +85 |-PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)], type_params=()) + 85 |+type PositiveInt = Annotated[int, Gt(0)] +86 86 | +87 87 | # OK: Other name +88 88 | T = TypeVar("T", bound=SupportGt) diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py.snap new file mode 100644 index 0000000000000..2b643d7ecd8ae --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_0.py.snap @@ -0,0 +1,126 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +snapshot_kind: text +--- +UP046_0.py:11:9: UP046 [*] Generic class `A` uses `Generic` subclass instead of type parameters + | +11 | class A(Generic[T]): + | ^^^^^^^^^^ UP046 +12 | # Comments in a class body are preserved +13 | var: T + | + = help: Use type parameters + +ℹ Unsafe fix +8 8 | P = ParamSpec("P") +9 9 | +10 10 | +11 |-class A(Generic[T]): + 11 |+class A[T: float]: +12 12 | # Comments in a class body are preserved +13 13 | var: T +14 14 | + +UP046_0.py:16:9: UP046 [*] Generic class `B` uses `Generic` subclass instead of type parameters + | +16 | class B(Generic[*Ts]): + | ^^^^^^^^^^^^ UP046 +17 | var: tuple[*Ts] + | + = help: Use type parameters + +ℹ Unsafe fix +13 13 | var: T +14 14 | +15 15 | +16 |-class B(Generic[*Ts]): + 16 |+class B[*Ts]: +17 17 | var: tuple[*Ts] +18 18 | +19 19 | + +UP046_0.py:20:9: UP046 [*] Generic class `C` uses `Generic` subclass instead of type parameters + | +20 | class C(Generic[P]): + | ^^^^^^^^^^ UP046 +21 | var: P + | + = help: Use type parameters + +ℹ Unsafe fix +17 17 | var: tuple[*Ts] +18 18 | +19 19 | +20 |-class C(Generic[P]): + 20 |+class C[**P]: +21 21 | var: P +22 22 | +23 23 | + +UP046_0.py:24:19: UP046 [*] Generic class `Constrained` uses `Generic` subclass instead of type parameters + | +24 | class Constrained(Generic[S]): + | ^^^^^^^^^^ UP046 +25 | var: S + | + = help: Use type parameters + +ℹ Unsafe fix +21 21 | var: P +22 22 | +23 23 | +24 |-class Constrained(Generic[S]): + 24 |+class Constrained[S: (str, bytes)]: +25 25 | var: S +26 26 | +27 27 | + +UP046_0.py:30:20: UP046 Generic class `ExternalType` uses `Generic` subclass instead of type parameters + | +28 | # This case gets a diagnostic but not a fix because we can't look up the bounds +29 | # or constraints on the TypeVar imported from another module +30 | class ExternalType(Generic[T, SupportsRichComparisonT]): + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP046 +31 | var: T +32 | compare: SupportsRichComparisonT + | + = help: Use type parameters + +UP046_0.py:37:13: UP046 [*] Generic class `MyStr` uses `Generic` subclass instead of type parameters + | +35 | # typing.AnyStr is a common external type variable, so treat it specially as a +36 | # known TypeVar +37 | class MyStr(Generic[AnyStr]): + | ^^^^^^^^^^^^^^^ UP046 +38 | s: AnyStr + | + = help: Use type parameters + +ℹ Unsafe fix +34 34 | +35 35 | # typing.AnyStr is a common external type variable, so treat it specially as a +36 36 | # known TypeVar +37 |-class MyStr(Generic[AnyStr]): + 37 |+class MyStr[AnyStr: (bytes, str)]: +38 38 | s: AnyStr +39 39 | +40 40 | + +UP046_0.py:41:24: UP046 [*] Generic class `MultipleGenerics` uses `Generic` subclass instead of type parameters + | +41 | class MultipleGenerics(Generic[S, T, *Ts, P]): + | ^^^^^^^^^^^^^^^^^^^^^ UP046 +42 | var: S +43 | typ: T + | + = help: Use type parameters + +ℹ Unsafe fix +38 38 | s: AnyStr +39 39 | +40 40 | +41 |-class MultipleGenerics(Generic[S, T, *Ts, P]): + 41 |+class MultipleGenerics[S: (str, bytes), T: float, *Ts, **P]: +42 42 | var: S +43 43 | typ: T +44 44 | tup: tuple[*Ts] diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py.snap new file mode 100644 index 0000000000000..95de85d97b991 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP046_1.py.snap @@ -0,0 +1,11 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +snapshot_kind: text +--- +UP046_1.py:11:14: UP046 Generic class `BadStr` uses `Generic` subclass instead of type parameters + | +11 | class BadStr(Generic[AnyStr]): + | ^^^^^^^^^^^^^^^ UP046 +12 | var: AnyStr + | + = help: Use type parameters diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py.snap new file mode 100644 index 0000000000000..fd3dde6026e33 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP047.py.snap @@ -0,0 +1,119 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +snapshot_kind: text +--- +UP047.py:12:5: UP047 [*] Generic function `f` should use type parameters + | +12 | def f(t: T) -> T: + | ^^^^^^^ UP047 +13 | return t + | + = help: Use type parameters + +ℹ Unsafe fix +9 9 | P = ParamSpec("P") +10 10 | +11 11 | +12 |-def f(t: T) -> T: + 12 |+def f[T: float](t: T) -> T: +13 13 | return t +14 14 | +15 15 | + +UP047.py:16:5: UP047 [*] Generic function `g` should use type parameters + | +16 | def g(ts: tuple[*Ts]) -> tuple[*Ts]: + | ^^^^^^^^^^^^^^^^^ UP047 +17 | return ts + | + = help: Use type parameters + +ℹ Unsafe fix +13 13 | return t +14 14 | +15 15 | +16 |-def g(ts: tuple[*Ts]) -> tuple[*Ts]: + 16 |+def g[*Ts](ts: tuple[*Ts]) -> tuple[*Ts]: +17 17 | return ts +18 18 | +19 19 | + +UP047.py:20:5: UP047 [*] Generic function `h` should use type parameters + | +20 | def h( + | _____^ +21 | | p: Callable[P, T], +22 | | # Comment in the middle of a parameter list should be preserved +23 | | another_param, +24 | | and_another, +25 | | ) -> Callable[P, T]: + | |_^ UP047 +26 | return p + | + = help: Use type parameters + +ℹ Unsafe fix +17 17 | return ts +18 18 | +19 19 | +20 |-def h( + 20 |+def h[**P, T: float]( +21 21 | p: Callable[P, T], +22 22 | # Comment in the middle of a parameter list should be preserved +23 23 | another_param, + +UP047.py:29:5: UP047 [*] Generic function `i` should use type parameters + | +29 | def i(s: S) -> S: + | ^^^^^^^ UP047 +30 | return s + | + = help: Use type parameters + +ℹ Unsafe fix +26 26 | return p +27 27 | +28 28 | +29 |-def i(s: S) -> S: + 29 |+def i[S: (str, bytes)](s: S) -> S: +30 30 | return s +31 31 | +32 32 | + +UP047.py:39:5: UP047 [*] Generic function `broken_fix` should use type parameters + | +37 | # TypeVars with the new-style generic syntax and will be rejected by type +38 | # checkers +39 | def broken_fix(okay: T, bad: Something) -> tuple[T, Something]: + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP047 +40 | return (okay, bad) + | + = help: Use type parameters + +ℹ Unsafe fix +36 36 | # generic type parameter. This leads to code that mixes old-style standalone +37 37 | # TypeVars with the new-style generic syntax and will be rejected by type +38 38 | # checkers +39 |-def broken_fix(okay: T, bad: Something) -> tuple[T, Something]: + 39 |+def broken_fix[T: float](okay: T, bad: Something) -> tuple[T, Something]: +40 40 | return (okay, bad) +41 41 | +42 42 | + +UP047.py:43:5: UP047 [*] Generic function `any_str_param` should use type parameters + | +43 | def any_str_param(s: AnyStr) -> AnyStr: + | ^^^^^^^^^^^^^^^^^^^^^^^^ UP047 +44 | return s + | + = help: Use type parameters + +ℹ Unsafe fix +40 40 | return (okay, bad) +41 41 | +42 42 | +43 |-def any_str_param(s: AnyStr) -> AnyStr: + 43 |+def any_str_param[AnyStr: (bytes, str)](s: AnyStr) -> AnyStr: +44 44 | return s +45 45 | +46 46 | diff --git a/ruff.schema.json b/ruff.schema.json index 7469867d0197c..c2e3b4d1c1009 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4187,6 +4187,8 @@ "UP043", "UP044", "UP045", + "UP046", + "UP047", "W", "W1", "W19",