-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
[flake8-pyi
] Implement custom_type_var_return_type
(PYI019
)
#6204
Merged
Merged
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
0b73f34
Add boilerplate
qdegraaf e0b15c2
Add first port of Y019 logic
qdegraaf bcb0c8d
Change to Python 3.11 and lower implementation
qdegraaf 3c7ad6c
Revert "Change to Python 3.11 and lower implementation"
qdegraaf b0765b6
Fix PEP-695 heuristic
qdegraaf 9c3a9fc
Run test in Python312
qdegraaf 47565e6
cargo fmt
qdegraaf 8a4fe21
Merge branch 'main' into feature/PYI019
charliermarsh 1e79e8d
Tweaks
charliermarsh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
from typing import TypeVar, Self, Type | ||
|
||
_S = TypeVar("_S", bound=BadClass) | ||
_S2 = TypeVar("_S2", BadClass, GoodClass) | ||
|
||
class BadClass: | ||
def __new__(cls: type[_S], *args: str, **kwargs: int) -> _S: ... # Ok | ||
|
||
|
||
def bad_instance_method(self: _S, arg: bytes) -> _S: ... # Ok | ||
|
||
|
||
@classmethod | ||
def bad_class_method(cls: type[_S], arg: int) -> _S: ... # Ok | ||
|
||
|
||
@classmethod | ||
def excluded_edge_case(cls: Type[_S], arg: int) -> _S: ... # Ok | ||
|
||
|
||
class GoodClass: | ||
def __new__(cls: type[Self], *args: list[int], **kwargs: set[str]) -> Self: ... | ||
def good_instance_method_1(self: Self, arg: bytes) -> Self: ... | ||
def good_instance_method_2(self, arg1: _S2, arg2: _S2) -> _S2: ... | ||
@classmethod | ||
def good_cls_method_1(cls: type[Self], arg: int) -> Self: ... | ||
@classmethod | ||
def good_cls_method_2(cls, arg1: _S, arg2: _S) -> _S: ... | ||
@staticmethod | ||
def static_method(arg1: _S) -> _S: ... | ||
|
||
|
||
# Python > 3.12 | ||
class PEP695BadDunderNew[T]: | ||
def __new__[S](cls: type[S], *args: Any, ** kwargs: Any) -> S: ... # Ok | ||
|
||
|
||
def generic_instance_method[S](self: S) -> S: ... # Ok | ||
|
||
|
||
class PEP695GoodDunderNew[T]: | ||
def __new__(cls, *args: Any, **kwargs: Any) -> Self: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
from typing import TypeVar, Self, Type | ||
|
||
_S = TypeVar("_S", bound=BadClass) | ||
_S2 = TypeVar("_S2", BadClass, GoodClass) | ||
|
||
class BadClass: | ||
def __new__(cls: type[_S], *args: str, **kwargs: int) -> _S: ... # PYI019 | ||
|
||
|
||
def bad_instance_method(self: _S, arg: bytes) -> _S: ... # PYI019 | ||
|
||
|
||
@classmethod | ||
def bad_class_method(cls: type[_S], arg: int) -> _S: ... # PYI019 | ||
|
||
|
||
@classmethod | ||
def excluded_edge_case(cls: Type[_S], arg: int) -> _S: ... # Ok | ||
|
||
|
||
class GoodClass: | ||
def __new__(cls: type[Self], *args: list[int], **kwargs: set[str]) -> Self: ... | ||
def good_instance_method_1(self: Self, arg: bytes) -> Self: ... | ||
def good_instance_method_2(self, arg1: _S2, arg2: _S2) -> _S2: ... | ||
@classmethod | ||
def good_cls_method_1(cls: type[Self], arg: int) -> Self: ... | ||
@classmethod | ||
def good_cls_method_2(cls, arg1: _S, arg2: _S) -> _S: ... | ||
@staticmethod | ||
def static_method(arg1: _S) -> _S: ... | ||
|
||
|
||
# Python > 3.12 | ||
class PEP695BadDunderNew[T]: | ||
def __new__[S](cls: type[S], *args: Any, ** kwargs: Any) -> S: ... # PYI019 | ||
|
||
|
||
def generic_instance_method[S](self: S) -> S: ... # PYI019 | ||
|
||
|
||
class PEP695GoodDunderNew[T]: | ||
def __new__(cls, *args: Any, **kwargs: Any) -> Self: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
203 changes: 203 additions & 0 deletions
203
crates/ruff/src/rules/flake8_pyi/rules/custom_typevar_return.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
use crate::checkers::ast::Checker; | ||
use crate::settings::types::PythonVersion; | ||
use ruff_diagnostics::{Diagnostic, Violation}; | ||
use ruff_macros::{derive_message_formats, violation}; | ||
use ruff_python_ast as ast; | ||
use ruff_python_ast::{ArgWithDefault, Arguments, Decorator, Expr, Ranged, TypeParam}; | ||
use ruff_python_semantic::analyze::visibility::{ | ||
is_abstract, is_classmethod, is_overload, is_staticmethod, | ||
}; | ||
use ruff_python_semantic::ScopeKind; | ||
|
||
/// ## What it does | ||
/// Checks if certain methods define a custom `TypeVar`s for their return annotation instead of | ||
/// using `typing_extensions.Self`. This check currently applies for instance methods that return | ||
/// `self`, class methods that return an instance of `cls`, and `__new__` methods. | ||
/// | ||
/// ## Why is this bad? | ||
/// If certain methods are annotated with a custom `TypeVar` type, and the class is subclassed, | ||
/// type checkers will not be able to infer the correct return type. | ||
qdegraaf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// | ||
/// ## Example | ||
/// ```python | ||
/// class Foo: | ||
/// def __new__(cls: type[_S], *args: str, **kwargs: int) -> _S: | ||
/// ... | ||
/// | ||
/// def foo(self: _S, arg: bytes) -> _S: | ||
/// ... | ||
/// | ||
/// @classmethod | ||
/// def bar(cls: type[_S], arg: int) -> _S: | ||
/// ... | ||
/// ``` | ||
/// | ||
/// Use instead: | ||
/// ```python | ||
/// from typing import Self | ||
/// | ||
/// | ||
/// class Foo: | ||
/// def __new__(cls: type[Self], *args: str, **kwargs: int) -> Self: | ||
/// ... | ||
/// | ||
/// def foo(self: Self, arg: bytes) -> Self: | ||
/// ... | ||
/// | ||
/// @classmethod | ||
/// def bar(cls: type[Self], arg: int) -> Self: | ||
/// ... | ||
/// ``` | ||
#[violation] | ||
pub struct CustomTypeVarReturnType { | ||
method_name: String, | ||
} | ||
|
||
impl Violation for CustomTypeVarReturnType { | ||
#[derive_message_formats] | ||
fn message(&self) -> String { | ||
let CustomTypeVarReturnType { method_name } = self; | ||
format!( | ||
"Methods like {method_name} should return `typing.Self` instead of a custom TypeVar" | ||
) | ||
} | ||
} | ||
|
||
/// PYI019 | ||
pub(crate) fn custom_typevar_return_type( | ||
checker: &mut Checker, | ||
name: &str, | ||
decorator_list: &[Decorator], | ||
returns: Option<&Expr>, | ||
args: &Arguments, | ||
type_params: &[TypeParam], | ||
) { | ||
let ScopeKind::Class(_) = checker.semantic().scope().kind else { | ||
return; | ||
}; | ||
|
||
if args.args.is_empty() && args.posonlyargs.is_empty() { | ||
return; | ||
} | ||
|
||
let Some(returns) = returns else { | ||
return; | ||
}; | ||
|
||
let return_annotation = if let Expr::Subscript(ast::ExprSubscript { value, .. }) = returns { | ||
// Ex) `Type[T]` | ||
value | ||
} else { | ||
// Ex) `Type`, `typing.Type` | ||
returns | ||
}; | ||
|
||
// Skip any abstract, static and overloaded methods. | ||
if is_abstract(decorator_list, checker.semantic()) | ||
|| is_overload(decorator_list, checker.semantic()) | ||
|| is_staticmethod(decorator_list, checker.semantic()) | ||
{ | ||
return; | ||
} | ||
|
||
let is_violation: bool = | ||
if is_classmethod(decorator_list, checker.semantic()) || name == "__new__" { | ||
check_class_method_for_bad_typevars(checker, args, return_annotation, type_params) | ||
} else { | ||
// If not static, or a class method or __new__ we know it is an instance method | ||
check_instance_method_for_bad_typevars(checker, args, return_annotation, type_params) | ||
}; | ||
|
||
if is_violation { | ||
checker.diagnostics.push(Diagnostic::new( | ||
CustomTypeVarReturnType { | ||
method_name: name.to_string(), | ||
}, | ||
return_annotation.range(), | ||
)); | ||
} | ||
} | ||
|
||
fn check_class_method_for_bad_typevars( | ||
checker: &Checker, | ||
args: &Arguments, | ||
return_annotation: &Expr, | ||
type_params: &[TypeParam], | ||
) -> bool { | ||
let ArgWithDefault { def, .. } = &args.args[0]; | ||
|
||
let Some(annotation) = &def.annotation else { | ||
return false | ||
}; | ||
|
||
let Expr::Subscript(ast::ExprSubscript{slice, value, ..}) = annotation.as_ref() else { | ||
return false | ||
}; | ||
|
||
let Expr::Name(ast::ExprName{ id: id_value, .. }) = value.as_ref() else { | ||
return false | ||
}; | ||
|
||
// Don't error if the first argument is annotated with typing.Type[T] | ||
// These are edge cases, and it's hard to give good error messages for them. | ||
if id_value != "type" { | ||
return false; | ||
}; | ||
qdegraaf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
let Expr::Name(ast::ExprName { id: id_slice, .. }) = slice.as_ref() else { | ||
return false | ||
}; | ||
|
||
let Expr::Name(ast::ExprName { id: return_type, .. }) = return_annotation else { | ||
return false | ||
}; | ||
|
||
return_type == id_slice && is_likely_private_typevar(checker, id_slice, type_params) | ||
} | ||
|
||
fn check_instance_method_for_bad_typevars( | ||
checker: &Checker, | ||
args: &Arguments, | ||
return_annotation: &Expr, | ||
type_params: &[TypeParam], | ||
) -> bool { | ||
let ArgWithDefault { def, .. } = &args.args[0]; | ||
|
||
let Some(annotation) = &def.annotation else { | ||
return false | ||
}; | ||
|
||
let Expr::Name(ast::ExprName{id: first_arg_type,..}) = annotation.as_ref() else { | ||
return false | ||
}; | ||
|
||
let Expr::Name(ast::ExprName { id: return_type, .. }) = return_annotation else { | ||
return false | ||
}; | ||
|
||
if first_arg_type != return_type { | ||
return false; | ||
} | ||
|
||
is_likely_private_typevar(checker, first_arg_type, type_params) | ||
} | ||
|
||
fn is_likely_private_typevar( | ||
checker: &Checker, | ||
tvar_name: &str, | ||
type_params: &[TypeParam], | ||
) -> bool { | ||
if tvar_name.starts_with('_') { | ||
return true; | ||
} | ||
if checker.settings.target_version < PythonVersion::Py312 { | ||
return false; | ||
} | ||
|
||
for param in type_params { | ||
if let TypeParam::TypeVar(ast::TypeParamTypeVar { name, .. }) = param { | ||
return name == tvar_name; | ||
} | ||
} | ||
false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 4 additions & 0 deletions
4
...ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI019_PYI019.py.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
--- | ||
source: crates/ruff/src/rules/flake8_pyi/mod.rs | ||
--- | ||
|
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 there be coverage for PEP 695 declarations like
type _S = ...
? or is that not relevant?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 full heuristic, as derived from upsteam, right now is:
Which should catch
But will miss any TypeVar or type not prefixed by a "_" and not used with PEP-695 style type parameters regardless of Python version.
I tried letting go of upstream's implementation and replacing the heuristic with a check on the binding to see if it was assigned to a TypeVar or type but could not get it to work (and it was not pretty). If this is possible and doable in a performant manner with some input, I would be open to replacing the heuristic with that.
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.
cc @charliermarsh who will probably have a better suggestion than me
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 it's okay to use this heuristic for now.