-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[pyupgrade
] Add rules to use PEP 695 generics in classes and functions (UP046
, UP047
)
#15565
base: main
Are you sure you want to change the base?
Conversation
and then TypeVarTuple and ParamSpec just works for functions too
This reverts commit 7a8cd3d.
|
I'm not quite sure what's going wrong with I was also mildly surprised to see no changes in the ecosystem checks. Do those run with Python 3.12? |
I'm not exactly sure but making the change it suggested seems to work for me 😅 The script runs on the generated docs which means that you'll have to generate the docs every time you make a change in the Rust code. It's always preferable to include the
Yes, the ecosystem checks are run on Python 3.12 ruff/.github/workflows/ci.yaml Lines 404 to 420 in c6c434c
ruff/.github/workflows/ci.yaml Line 19 in c6c434c
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, this overall looks good. I didn't review pep695
in detail because it isn't clear what's copied over and what needs reviewing.
The title suggests to me that UP040 now handles generic functions and classes but reading the summary this doesn't seem to be true?
What I understand from the summary is that this PR simply introduces a new rule? We should change the title accordingly for a clear changelog entry.
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
@@ -0,0 +1,218 @@ | |||
//! Shared code for [`use_pep695_type_alias`] (UP040) and [`use_pep695_type_parameter`] (UP046) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you make any changes to the code in this module or did you copy it over as is?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I modified expr_name_to_type_var
to include the TypeVar.kind
field, factored out TypeParam::from
from its call site in use_pep695_type_alias.rs
and added the match kind
, and added the fmt_*
functions. The Visitor
code is untouched, and so is the TypeVarRestriction
struct.
Sorry for the confusing mix of changes. I modified it all in-place and then tried splitting it out to separate the rules.
UP046.py:9:7: UP046 [*] Generic class `A` uses `Generic` subclass instead of type parameters | ||
| | ||
9 | class A(Generic[T]): | ||
| ^^^^^^^^^^^^^ UP046 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we change the diagnostic range to only the Generic[T]
instead of the entire class header because the name or other base classes aren't a problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call. I thought this looked nice for the examples, but I think your suggestion makes more sense for longer, more realistic class names! And it will be even more important when we handle multiple base classes.
I wanted to make a similar change for functions, but I think it will require tracking the range
of the parameter in TypeVar
and possibly even emitting separate diagnostics for each affected parameter. I could cheat a little for classes because I only handle a single base class for now.
I'm picturing a function like this with non-contiguous, unique type parameters as a problematic case:
def h(p: T, another_param, and_another: U): ...
^^^^ ^^^^^^^^^^^^^^
I think that's the ideal kind of diagnostic, but I think it requires separate diagnostics and fixes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah that's an interesting case (CC @BurntSushi: While not directly related to diagnostics, it is an interesting problem how we may want multiple diagnostic ranges but a single fix).
Aren't we tracking the ranges for the fix (by only underlining the type?). although that still doesn't scale to multiple parameters unless we extend the range.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, in the generic class case, I'm using the assumption of a single base class to underline the type. This is the range
used later for the diagnostic:
ruff/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Lines 109 to 116 in 55be0d8
// 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() |
Once we add a loop to support multiple base classes, like multiple function parameters, I think we'd need to capture each range
from the parameters as we loop.
ruff/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Lines 209 to 221 in 55be0d8
for parameter in parameters { | |
if let Some(annotation) = parameter.annotation() { | |
let vars = { | |
let mut visitor = TypeVarReferenceVisitor { | |
vars: vec![], | |
semantic: checker.semantic(), | |
}; | |
visitor.visit_expr(annotation); | |
visitor.vars | |
}; | |
type_vars.extend(vars); | |
} | |
} |
But I could be misunderstanding or missing an easier way to do it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this is great!
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
#[derive(Debug, Copy, Clone, PartialEq, Eq)] | ||
enum GenericKind { | ||
GenericClass, | ||
GenericFunction, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if these should even be the same rule...? Generic functions and generic classes feel quite distinct.
I don't know how methods play into this, though -- we've obviously left them unimplemented for now, but we might want to try fixing them as a followup extension. We might have to fix methods in the same pass as the class definition...? Not sure...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was kind of wondering that myself, especially as I was splitting UP040 and this one. The rule function bodies are just different enough that I don't see an easy way of sharing much more code. Should I separate them?
I agree, not sure about methods either. Maybe they fit with functions if we can just subtract the enclosing class's type parameters from the function code (now I'm realizing we might have a problem with nesting in general?). But I could see it going with the class code, the function code, or being a third rule even.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, nesting is a very good point! I think it's fine to not offer a fix for generic functions or classes nested inside other generic functions or classes (they're rare, and complicated to get right!). You can check whether a class or function is nested inside any class or function scopes by using this method on the semantic model to iterate up through all parent statements and checking if any are a StmtClassDef
or a StmtFunctionDef
:
ruff/crates/ruff_python_semantic/src/model.rs
Lines 1243 to 1250 in 7379832
/// Returns an [`Iterator`] over the current statement hierarchy, from the current [`Stmt`] | |
/// through to any parents. | |
pub fn current_statements(&self) -> impl Iterator<Item = &'a Stmt> + '_ { | |
let id = self.node_id.expect("No current node"); | |
self.nodes | |
.ancestor_ids(id) | |
.filter_map(move |id| self.nodes[id].as_statement()) | |
} |
We should definitely add tests for nested functions and classes as well!
For methods -- after thinking about it more, I think we might be okay? Whether or not there's also a fix for the class statement being applied by a different rule simultaneously, I think the same fix needs to be applied for the method definition:
T = TypeVar("T")
S = TypeVar("S")
class Foo(Generic[T]):
- def bar(self, x: T, y: S) -> S: ...
+ def bar[S](self, x: T, y: S) -> S: ...
or, if the class has also been fixed...
S = TypeVar("S")
class Foo[T]:
- def bar(self, x: T, y: S) -> S: ...
+ def bar[S](self, x: T, y: S) -> S: ...
So I think it should be okay for methods to be their own rule -- which, I think, means that it would be good for free functions and classes to be separate rules too!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for being late to the party but can you elaborate a bit on the reason why we chose to split the rule? The reason for the split aren't clear to me as a user. What's so different between classes and functions that I'd only want this rule enabled for one or the other? I don't think it should matter how much code the implementations can share -- that would be an us problem and we shouldn't forward it to the users by "overwhelming" them with more rules".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another reason Alex mentioned down below was that the rules table in the docs uses the first format!
call from Violation::message
, so splitting them will give two more specialized descriptions in that table. Otherwise, we discussed that it's generally nice to lean toward more fine-grained rule selection, but I see what you mean about overwhelming users with rules. I also think you're right that users will probably want both of these together, but @AlexWaygood may have a different intuition here. I'm happy to go either way.
I think this is a side note related to rule categorization, but my ideal picture is something like a PEP 695 parent category that would activate all three of these (UP040, UP046, and UP047). That's why I put them all in UP040 initially, but the naming of UP040 (non-pep695-type-alias) was a bit too specific to include the others.
If we do leave them split, you've reminded me that I should add a See also
section to both of them pointing to the other at least.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw the format issue. While not ideal, I don't think it's reason enough to split a rule because I consider having to configure two rules more inconvenient than a slightly confusing message in the rules table (which we can fix when we refactor Ruff's rule infrastructure to Red Knot's)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally I think that these are doing distinct enough things that they deserve to be separate rules -- the documentation examples for them are completely different, for example. I do see Micha's point that there's maybe not really a reason for why somebody might want to enable one but leave the other disabled -- but in general, I feel like we see a lot of requests for fine-grained configuration on the issue tracker, but not much demand for coarser configuration. I think there are many confusing things about Ruff's configuration setup, but I don't think the sheer number of rules is one of them... but I know that this is somewhere where Micha and I disagree :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but I don't think the sheer number of rules is one of them
I don't think this is necessarily true, given that users ask for presets, better categorization etc. Users struggle with configuring Ruff and the sheer amount of rules is one big factor causing it. Just speaking for myself, finding if a rule already exists is no easy task (you can try pasting it in the playground but good luck if the rule requires configuration). You have to scroll through a sheer endless list of rules. And I think it's a problem we don't understand well yet because users simply tend to not enable rules because they're overwhelmed (or enable all of them) -- giving them a worse experience overall. That's why I keep pushing back on defaulting to more granular rules. There's a non zero cost.
Looking at the two rules and reading the documentation I think either works. I've a slight preference to just have one, but considering that we split them out from UP40, maybe it's makes sense to have them separate. I'd probably go with separate rules just to avoid extra work.
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_parameter.rs
Outdated
Show resolved
Hide resolved
I think we run them with Python 3.12 in CI, but when the ecosystem check is running on a project that has |
This reverts commit 450dfd4.
pyupgrade
] Expand UP040 to handle generic functions and classes (UP040
, UP046
)pyupgrade
] Add rules to use PEP 695 generics in classes and functions (UP046
, UP047
)
Okay, I think I covered all of the suggestions! For follow ups I have
Plus the separate, related rule for mixed old- and new-style generics in #15620. Should I open issues for each of these, or just track them here? |
I'd suggest creating a new issue. They all seem like extensions and can be implemented after this PR. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks very close!
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_function.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_function.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_function.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_function.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_class.rs
Outdated
Show resolved
Hide resolved
Agree -- I think a single new issue that tracks all these followups as sub-items would be perfect |
Co-authored-by: Alex Waygood <[email protected]>
Co-authored-by: Alex Waygood <[email protected]>
Thanks for all of the documentation fixes and the |
I updated the PR against my typeshed fork to run the autofixes from the latest version of this PR. The pyright CI there is flagging a couple of other issues:
|
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_generic_function.rs
Outdated
Show resolved
Hide resolved
the main motivation here was to differentiate early returns from `expr_name_to_type_var` caused by default values from those caused by the variable not being a TypeVar, but it has the nice side effect of setting us up to handle defaults correctly in the future
initially I thought the any_skipped check would be more invasive, but carrying around an extra bool in the cases where we don't care about it seems preferable to duplicating the AnyStr code especially
Thanks again for the thorough review and for your
The |
def generic_method(t: T): | ||
pass |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
micro-nit: it's nice to keep fixtures looking like "realistic" Python code where possible (this looks a bit strange to me, as there's generally no reason to use a TypeVar
in a function's annotations like this unless either two parameters are annotated with the TypeVar
or the return annotation uses the TypeVar
):
def generic_method(t: T): | |
pass | |
def generic_method(var: T) -> T: | |
return var |
this applies to a few other functions and classes in your fixtures, but I won't comment on all of them ;)
|
||
# This case gets a diagnostic but not a fix because we can't look up the bounds | ||
# or constraints on the generic type from another module | ||
class ExternalType(Generic[T, SupportsRichComparisonT]): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should SupportsRichComparisonT
be imported from another module at the top of this file?
// typing.AnyStr special case doesn't have a real range | ||
if let Expr::Name(name) = v { | ||
f.write_str(name.id.as_ref())?; | ||
} else { | ||
f.write_str(&self.source[v.range()])?; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hrm... I'm not a huge fan of the way that the special handling for AnyStr
is kind-of implicit here. It also seems a shame that we're now cloning all TypeVarRestriction::Constraint
Expr
s just to handle the AnyStr
special case.
I think we can solve this by:
- Adding a dedicated variant for
TypeVarRestriction
, and - Only storing an
&'a str
for thename
field onTypeVar
, rather than a wholeast::name::Name
.
Something like this? I think this patch also has the advantage that we'd recognise a fully qualified use of AnyStr
as an attribute expression, e.g. import typing; x: typing.AnyStr
:
Suggested patch
diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs
index 3de4228bc..de9a6407a 100644
--- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs
+++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/mod.rs
@@ -28,7 +28,10 @@ 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<Expr>),
+ 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)]
@@ -40,7 +43,7 @@ enum TypeParamKind {
#[derive(Debug)]
struct TypeVar<'a> {
- name: &'a ExprName,
+ name: &'a str,
restriction: Option<TypeVarRestriction<'a>>,
kind: TypeParamKind,
default: Option<&'a Expr>,
@@ -92,23 +95,19 @@ impl Display for DisplayTypeVar<'_> {
TypeParamKind::TypeVarTuple => f.write_str("*")?,
TypeParamKind::ParamSpec => f.write_str("**")?,
}
- f.write_str(&self.type_var.name.id)?;
+ 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() {
- // typing.AnyStr special case doesn't have a real range
- if let Expr::Name(name) = v {
- f.write_str(name.id.as_ref())?;
- } else {
- f.write_str(&self.source[v.range()])?;
- }
+ f.write_str(&self.source[v.range()])?;
if i < len - 1 {
f.write_str(", ")?;
}
@@ -135,7 +134,7 @@ impl<'a> From<&'a TypeVar<'a>> for TypeParam {
TypeParamKind::TypeVar => {
TypeParam::TypeVar(TypeParamTypeVar {
range: TextRange::default(),
- name: Identifier::new(name.id.clone(), TextRange::default()),
+ name: Identifier::new(*name, TextRange::default()),
bound: match restriction {
Some(TypeVarRestriction::Bound(bound)) => Some(Box::new((*bound).clone())),
Some(TypeVarRestriction::Constraint(constraints)) => {
@@ -146,6 +145,25 @@ impl<'a> From<&'a TypeVar<'a>> for TypeParam {
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
@@ -155,12 +173,12 @@ impl<'a> From<&'a TypeVar<'a>> for TypeParam {
}
TypeParamKind::TypeVarTuple => TypeParam::TypeVarTuple(TypeParamTypeVarTuple {
range: TextRange::default(),
- name: Identifier::new(name.id.clone(), TextRange::default()),
+ name: Identifier::new(*name, TextRange::default()),
default: None,
}),
TypeParamKind::ParamSpec => TypeParam::ParamSpec(TypeParamParamSpec {
range: TextRange::default(),
- name: Identifier::new(name.id.clone(), TextRange::default()),
+ name: Identifier::new(*name, TextRange::default()),
default: None,
}),
}
@@ -178,36 +196,27 @@ struct TypeVarReferenceVisitor<'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) {
+ // 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
+ if self.semantic.match_typing_expr(expr, "AnyStr") {
+ self.vars.push(TypeVar {
+ name: "AnyStr",
+ restriction: Some(TypeVarRestriction::AnyStr),
+ kind: TypeParamKind::TypeVar,
+ default: None,
+ });
+ return;
+ }
+
match 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
- e @ Expr::Name(name) if self.semantic.match_typing_expr(e, "AnyStr") => {
- self.vars.push(TypeVar {
- name,
- restriction: Some(TypeVarRestriction::Constraint(vec![
- Expr::Name(ExprName {
- range: TextRange::default(),
- id: Name::from("bytes"),
- ctx: ruff_python_ast::ExprContext::Load,
- }),
- Expr::Name(ExprName {
- range: TextRange::default(),
- id: Name::from("str"),
- ctx: ruff_python_ast::ExprContext::Load,
- }),
- ])),
- kind: TypeParamKind::TypeVar,
- default: None,
- });
- }
Expr::Name(name) if name.ctx.is_load() => {
if let Some(var) = expr_name_to_type_var(self.semantic, name) {
self.vars.push(var);
@@ -243,7 +252,7 @@ fn expr_name_to_type_var<'a>(
}) => {
if semantic.match_typing_expr(subscript_value, "TypeVar") {
return Some(TypeVar {
- name,
+ name: &name.id,
restriction: None,
kind: TypeParamKind::TypeVar,
default: None,
@@ -288,14 +297,14 @@ fn expr_name_to_type_var<'a>(
Some(TypeVarRestriction::Bound(&bound.value))
} else if arguments.args.len() > 1 {
Some(TypeVarRestriction::Constraint(
- arguments.args.iter().skip(1).cloned().collect(),
+ arguments.args.iter().skip(1).collect(),
))
} else {
None
};
return Some(TypeVar {
- name,
+ name: &name.id,
restriction,
kind,
default,
@@ -327,7 +336,7 @@ fn check_type_vars(vars: Vec<TypeVar<'_>>) -> Option<Vec<TypeVar<'_>>> {
// found on the type parameters
(vars
.iter()
- .unique_by(|tvar| &tvar.name.id)
+ .unique_by(|tvar| tvar.name)
.filter(|tvar| tvar.default.is_none())
.count()
== vars.len())
diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_alias.rs
index c2340c609..1fb63f8be 100644
--- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_alias.rs
+++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/use_pep695_type_alias.rs
@@ -133,7 +133,7 @@ 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,
@@ -199,7 +199,7 @@ 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::<Vec<_>>();
checker.diagnostics.push(create_diagnostic(
// We don't handle defaults here yet. Should perhaps be a different rule since | ||
// defaults are only valid in 3.13+. | ||
default: None, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also add a test for UP040 with a type alias that uses a TypeVar
with a default (since that rule now shares the logic from this module)?
We might want to see if we can convert the fix there to also use simple string interpolation rather than the ExprGenerator
... I feel like it makes sense for all three rules to use basically the same infrastructure. But not for this PR! Just another possible followup for you ;)
restriction: Some(TypeVarRestriction::Constraint(vec![ | ||
Expr::Name(ExprName { | ||
range: TextRange::default(), | ||
id: Name::from("bytes"), | ||
ctx: ruff_python_ast::ExprContext::Load, | ||
}), | ||
Expr::Name(ExprName { | ||
range: TextRange::default(), | ||
id: Name::from("str"), | ||
ctx: ruff_python_ast::ExprContext::Load, | ||
}), | ||
])), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we have to make sure here that str
and bytes
haven't been overridden in an enclosing scope before we add a type parameter that uses (str, bytes)
as constraints. E.g. https://mypy-play.net/?mypy=latest&python=3.12&gist=100810bb27581625b327bce54ec1b63b. If they have been overridden in an enclosing scope, we can't offer a fix to replace AnyStr
with an inline type parameter. You can check for this using the SemanticModel::is_available()
method.
ruff/crates/ruff_python_semantic/src/model.rs
Lines 327 to 331 in ef85c68
/// Return `true` if `member` is an "available" symbol, i.e., a symbol that has not been bound | |
/// in the current scope currently being visited, or in any containing scope. | |
pub fn is_available(&self, member: &str) -> bool { | |
self.is_available_in_scope(member, self.scope_id) | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs look fantastic now! Thanks again for your patience!
Summary
This PR extends our PEP 695 handling from the type aliases handled by
UP040
to generic function and class parameters, as suggested in the latter two examples from #4617: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 fromUP040
and then also separate from each other. From a quick look, and based on this issue, I'm pretty sure neither of these rules is currently in pyupgrade, so I just took the next available codes,UP046
andUP047
.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: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 codePLW0101
fromruff.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