Skip to content

Commit bb6fb46

Browse files
ntBreAlexWaygood
andauthored
[pyupgrade] Add rules to use PEP 695 generics in classes and functions (UP046, UP047) (#15565)
## Summary This PR extends our [PEP 695](https://peps.python.org/pep-0695) handling from the type aliases handled by `UP040` to generic function and class parameters, as suggested in the latter two examples from #4617: ```python # Input T = TypeVar("T", bound=float) class A(Generic[T]): ... def f(t: T): ... # Output class A[T: float]: ... def f[T: float](t: T): ... ``` I first implemented this as part of `UP040`, but based on a brief discussion during a very helpful pairing session with @AlexWaygood, I opted to split them into rules separate from `UP040` and then also separate from each other. From a quick look, and based on [this issue](asottile/pyupgrade#836), I'm pretty sure neither of these rules is currently in pyupgrade, so I just took the next available codes, `UP046` and `UP047`. The last main TODO, noted in the rule file and in the fixture, is to handle generic method parameters not included in the class itself, `S` in this case: ```python T = TypeVar("T") S = TypeVar("S") class Foo(Generic[T]): def bar(self, x: T, y: S) -> S: ... ``` but Alex mentioned that that might be okay to leave for a follow-up PR. I also left a TODO about handling multiple subclasses instead of bailing out when more than one is present. I'm not sure how common that would be, but I can still handle it here, or follow up on that too. I think this is unrelated to the PR, but when I ran `cargo dev generate-all`, it removed the rule code `PLW0101` from `ruff.schema.json`. It seemed unrelated, so I left that out, but I wanted to mention it just in case. ## Test Plan New test fixture, `cargo nextest run` Closes #4617, closes #12542 --------- Co-authored-by: Alex Waygood <[email protected]>
1 parent b4877f1 commit bb6fb46

17 files changed

+1276
-229
lines changed

crates/ruff_linter/resources/test/fixtures/pyupgrade/UP040.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import typing
2-
from typing import TypeAlias
2+
from typing import Any, TypeAlias
33

44
# UP040
55
x: typing.TypeAlias = int
@@ -43,6 +43,10 @@ class Foo:
4343
T = typing.TypeVar(*args)
4444
x: typing.TypeAlias = list[T]
4545

46+
# `default` should be skipped for now, added in Python 3.13
47+
T = typing.TypeVar("T", default=Any)
48+
x: typing.TypeAlias = list[T]
49+
4650
# OK
4751
x: TypeAlias
4852
x: int = 1
@@ -85,3 +89,7 @@ class Foo:
8589
PositiveList = TypeAliasType(
8690
"PositiveList2", list[Annotated[T, Gt(0)]], type_params=(T,)
8791
)
92+
93+
# `default` should be skipped for now, added in Python 3.13
94+
T = typing.TypeVar("T", default=Any)
95+
AnyList = TypeAliasType("AnyList", list[T], typep_params=(T,))
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from typing import Any, AnyStr, Generic, ParamSpec, TypeVar, TypeVarTuple
2+
3+
from somewhere import SupportsRichComparisonT
4+
5+
S = TypeVar("S", str, bytes) # constrained type variable
6+
T = TypeVar("T", bound=float)
7+
Ts = TypeVarTuple("Ts")
8+
P = ParamSpec("P")
9+
10+
11+
class A(Generic[T]):
12+
# Comments in a class body are preserved
13+
var: T
14+
15+
16+
class B(Generic[*Ts]):
17+
var: tuple[*Ts]
18+
19+
20+
class C(Generic[P]):
21+
var: P
22+
23+
24+
class Constrained(Generic[S]):
25+
var: S
26+
27+
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]):
31+
var: T
32+
compare: SupportsRichComparisonT
33+
34+
35+
# typing.AnyStr is a common external type variable, so treat it specially as a
36+
# known TypeVar
37+
class MyStr(Generic[AnyStr]):
38+
s: AnyStr
39+
40+
41+
class MultipleGenerics(Generic[S, T, *Ts, P]):
42+
var: S
43+
typ: T
44+
tup: tuple[*Ts]
45+
pep: P
46+
47+
48+
class MultipleBaseClasses(list, Generic[T]):
49+
var: T
50+
51+
52+
# These cases are not handled
53+
class D(Generic[T, T]): # duplicate generic variable, runtime error
54+
pass
55+
56+
57+
# TODO(brent) we should also apply the fix to methods, but it will need a
58+
# little more work. these should be left alone for now but be fixed eventually.
59+
class NotGeneric:
60+
# -> generic_method[T: float](t: T)
61+
def generic_method(t: T) -> T:
62+
return t
63+
64+
65+
# This one is strange in particular because of the mix of old- and new-style
66+
# generics, but according to the PEP, this is okay "if the class, function, or
67+
# type alias does not use the new syntax." `more_generic` doesn't use the new
68+
# syntax, so it can use T from the module and U from the class scope.
69+
class MixedGenerics[U]:
70+
def more_generic(u: U, t: T) -> tuple[U, T]:
71+
return (u, t)
72+
73+
74+
# TODO(brent) we should also handle multiple base classes
75+
class Multiple(NotGeneric, Generic[T]):
76+
pass
77+
78+
79+
# TODO(brent) default requires 3.13
80+
V = TypeVar("V", default=Any, bound=str)
81+
82+
83+
class DefaultTypeVar(Generic[V]): # -> [V: str = Any]
84+
var: V
85+
86+
87+
# nested classes and functions are skipped
88+
class Outer:
89+
class Inner(Generic[T]):
90+
var: T
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Replacing AnyStr requires specifying the constraints `bytes` and `str`, so
2+
it can't be replaced if these have been shadowed. This test is in a separate
3+
fixture because it doesn't seem possible to restore `str` to its builtin state
4+
"""
5+
6+
from typing import AnyStr, Generic
7+
8+
str = "string"
9+
10+
11+
class BadStr(Generic[AnyStr]):
12+
var: AnyStr
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from collections.abc import Callable
2+
from typing import Any, AnyStr, ParamSpec, TypeVar, TypeVarTuple
3+
4+
from somewhere import Something
5+
6+
S = TypeVar("S", str, bytes) # constrained type variable
7+
T = TypeVar("T", bound=float)
8+
Ts = TypeVarTuple("Ts")
9+
P = ParamSpec("P")
10+
11+
12+
def f(t: T) -> T:
13+
return t
14+
15+
16+
def g(ts: tuple[*Ts]) -> tuple[*Ts]:
17+
return ts
18+
19+
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]:
26+
return p
27+
28+
29+
def i(s: S) -> S:
30+
return s
31+
32+
33+
# NOTE this case is the reason the fix is marked unsafe. If we can't confirm
34+
# that one of the type parameters (`Something` in this case) is a TypeVar,
35+
# which we can't do across module boundaries, we will not convert it to a
36+
# generic type parameter. This leads to code that mixes old-style standalone
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]:
40+
return (okay, bad)
41+
42+
43+
def any_str_param(s: AnyStr) -> AnyStr:
44+
return s
45+
46+
47+
# these cases are not handled
48+
49+
# TODO(brent) default requires 3.13
50+
V = TypeVar("V", default=Any, bound=str)
51+
52+
53+
def default_var(v: V) -> V:
54+
return v
55+
56+
57+
def outer():
58+
def inner(t: T) -> T:
59+
return t

crates/ruff_linter/src/checkers/ast/analyze/statement.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
376376
if checker.enabled(Rule::PytestParameterWithDefaultArgument) {
377377
flake8_pytest_style::rules::parameter_with_default_argument(checker, function_def);
378378
}
379+
if checker.enabled(Rule::NonPEP695GenericFunction) {
380+
pyupgrade::rules::non_pep695_generic_function(checker, function_def);
381+
}
379382
}
380383
Stmt::Return(_) => {
381384
if checker.enabled(Rule::ReturnOutsideFunction) {
@@ -554,6 +557,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
554557
if checker.enabled(Rule::DataclassEnum) {
555558
ruff::rules::dataclass_enum(checker, class_def);
556559
}
560+
if checker.enabled(Rule::NonPEP695GenericClass) {
561+
pyupgrade::rules::non_pep695_generic_class(checker, class_def);
562+
}
557563
}
558564
Stmt::Import(ast::StmtImport { names, range: _ }) => {
559565
if checker.enabled(Rule::MultipleImportsOnOneLine) {

crates/ruff_linter/src/codes.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
540540
(Pyupgrade, "043") => (RuleGroup::Stable, rules::pyupgrade::rules::UnnecessaryDefaultTypeArgs),
541541
(Pyupgrade, "044") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP646Unpack),
542542
(Pyupgrade, "045") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP604AnnotationOptional),
543+
(Pyupgrade, "046") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP695GenericClass),
544+
(Pyupgrade, "047") => (RuleGroup::Preview, rules::pyupgrade::rules::NonPEP695GenericFunction),
543545

544546
// pydocstyle
545547
(Pydocstyle, "100") => (RuleGroup::Stable, rules::pydocstyle::rules::UndocumentedPublicModule),

crates/ruff_linter/src/rules/pyupgrade/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ mod tests {
103103
#[test_case(Rule::YieldInForLoop, Path::new("UP028_1.py"))]
104104
#[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.py"))]
105105
#[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.pyi"))]
106+
#[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_0.py"))]
107+
#[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_1.py"))]
108+
#[test_case(Rule::NonPEP695GenericFunction, Path::new("UP047.py"))]
106109
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
107110
let snapshot = path.to_string_lossy().to_string();
108111
let diagnostics = test_path(

crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub(crate) use native_literals::*;
1414
pub(crate) use open_alias::*;
1515
pub(crate) use os_error_alias::*;
1616
pub(crate) use outdated_version_block::*;
17+
pub(crate) use pep695::*;
1718
pub(crate) use printf_string_formatting::*;
1819
pub(crate) use quoted_annotation::*;
1920
pub(crate) use redundant_open_modes::*;
@@ -36,7 +37,6 @@ pub(crate) use use_pep585_annotation::*;
3637
pub(crate) use use_pep604_annotation::*;
3738
pub(crate) use use_pep604_isinstance::*;
3839
pub(crate) use use_pep646_unpack::*;
39-
pub(crate) use use_pep695_type_alias::*;
4040
pub(crate) use useless_metaclass_type::*;
4141
pub(crate) use useless_object_inheritance::*;
4242
pub(crate) use yield_in_for_loop::*;
@@ -57,6 +57,7 @@ mod native_literals;
5757
mod open_alias;
5858
mod os_error_alias;
5959
mod outdated_version_block;
60+
mod pep695;
6061
mod printf_string_formatting;
6162
mod quoted_annotation;
6263
mod redundant_open_modes;
@@ -79,7 +80,6 @@ mod use_pep585_annotation;
7980
mod use_pep604_annotation;
8081
mod use_pep604_isinstance;
8182
mod use_pep646_unpack;
82-
mod use_pep695_type_alias;
8383
mod useless_metaclass_type;
8484
mod useless_object_inheritance;
8585
mod yield_in_for_loop;

0 commit comments

Comments
 (0)