Skip to content
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 PYI029 #4851

Merged
merged 9 commits into from
Jun 5, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
57 changes: 57 additions & 0 deletions crates/ruff/resources/test/fixtures/flake8_pyi/PYI029.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import builtins
from abc import abstractmethod


def __repr__(self) -> str:
...


def __str__(self) -> builtins.str:
...


def __repr__(self, /, foo) -> str:
...


def __repr__(self, *, foo) -> str:
...


class ShouldRemoveSingle:
def __str__(self) -> builtins.str:
...


class ShouldRemove:
def __repr__(self) -> str:
...

def __str__(self) -> builtins.str:
...


class NoReturnSpecified:
def __str__(self):
...

def __repr__(self):
...


class NonMatchingArgs:
def __str__(self, *, extra) -> builtins.str:
...

def __repr__(self, /, extra) -> str:
...


class MatchingArgsButAbstract:
@abstractmethod
def __str__(self) -> builtins.str:
...

@abstractmethod
def __repr__(self) -> str:
...
28 changes: 28 additions & 0 deletions crates/ruff/resources/test/fixtures/flake8_pyi/PYI029.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import builtins
from abc import abstractmethod

def __repr__(self) -> str: ...
def __str__(self) -> builtins.str: ...
def __repr__(self, /, foo) -> str: ...
def __repr__(self, *, foo) -> str: ...

class ShouldRemoveSingle:
def __str__(self) -> builtins.str: ... # Error: PYI029

class ShouldRemove:
def __repr__(self) -> str: ... # Error: PYI029
def __str__(self) -> builtins.str: ... # Error: PYI029

class NoReturnSpecified:
def __str__(self): ...
def __repr__(self): ...

class NonMatchingArgs:
def __str__(self, *, extra) -> builtins.str: ...
def __repr__(self, /, extra) -> str: ...

class MatchingArgsButAbstract:
@abstractmethod
def __str__(self) -> builtins.str: ...
@abstractmethod
def __repr__(self) -> str: ...
3 changes: 3 additions & 0 deletions crates/ruff/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ where
stmt.is_async_function_def_stmt(),
);
}
if self.enabled(Rule::StrOrReprDefinedInStub) {
flake8_pyi::rules::str_or_repr_defined_in_stub(self, stmt);
}
}

if self.enabled(Rule::DunderFunctionName) {
Expand Down
1 change: 1 addition & 0 deletions crates/ruff/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8Pyi, "021") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::DocstringInStub),
(Flake8Pyi, "024") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::CollectionsNamedTuple),
(Flake8Pyi, "025") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::UnaliasedCollectionsAbcSetImport),
(Flake8Pyi, "029") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::StrOrReprDefinedInStub),
(Flake8Pyi, "032") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::AnyEqNeAnnotation),
(Flake8Pyi, "033") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::TypeCommentInStub),
(Flake8Pyi, "034") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::NonSelfReturnType),
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff/src/rules/flake8_pyi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ mod tests {
#[test_case(Rule::SnakeCaseTypeAlias, Path::new("PYI042.pyi"))]
#[test_case(Rule::UnassignedSpecialVariableInStub, Path::new("PYI035.py"))]
#[test_case(Rule::UnassignedSpecialVariableInStub, Path::new("PYI035.pyi"))]
#[test_case(Rule::StrOrReprDefinedInStub, Path::new("PYI029.py"))]
#[test_case(Rule::StrOrReprDefinedInStub, Path::new("PYI029.pyi"))]
#[test_case(Rule::StubBodyMultipleStatements, Path::new("PYI048.py"))]
#[test_case(Rule::StubBodyMultipleStatements, Path::new("PYI048.pyi"))]
#[test_case(Rule::TSuffixedTypeAlias, Path::new("PYI043.py"))]
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff/src/rules/flake8_pyi/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub(crate) use simple_defaults::{
unassigned_special_variable_in_stub, ArgumentDefaultInStub, AssignmentDefaultInStub,
TypedArgumentDefaultInStub, UnannotatedAssignmentInStub, UnassignedSpecialVariableInStub,
};
pub(crate) use str_or_repr_defined_in_stub::{str_or_repr_defined_in_stub, StrOrReprDefinedInStub};
pub(crate) use string_or_bytes_too_long::{string_or_bytes_too_long, StringOrBytesTooLong};
pub(crate) use stub_body_multiple_statements::{
stub_body_multiple_statements, StubBodyMultipleStatements,
Expand Down Expand Up @@ -54,6 +55,7 @@ mod pass_statement_stub_body;
mod prefix_type_params;
mod quoted_annotation_in_stub;
mod simple_defaults;
mod str_or_repr_defined_in_stub;
mod string_or_bytes_too_long;
mod stub_body_multiple_statements;
mod type_alias_naming;
Expand Down
114 changes: 114 additions & 0 deletions crates/ruff/src/rules/flake8_pyi/rules/str_or_repr_defined_in_stub.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use rustpython_parser::ast;
use rustpython_parser::ast::{Ranged, Stmt};

use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_semantic::analyze::visibility::is_abstract;

use crate::autofix::edits::delete_stmt;
use crate::checkers::ast::Checker;
use crate::registry::AsRule;

/// ## What it does
/// Checks for redundant definitions of `__str__` or `__repr__` in stubs.
///
/// ## Why is this bad?
/// These definitions are redundant with `object.__str__` or `object.__repr__`.
///
/// ## Example
/// ```python
/// class Foo:
/// def __repr__(self) -> str:
/// ...
/// ```
#[violation]
pub struct StrOrReprDefinedInStub {
name: String,
}

impl AlwaysAutofixableViolation for StrOrReprDefinedInStub {
#[derive_message_formats]
fn message(&self) -> String {
let StrOrReprDefinedInStub { name } = self;
format!("Defining `{name}` in a stub is almost always redundant")
}

fn autofix_title(&self) -> String {
let StrOrReprDefinedInStub { name } = self;
format!("Remove definition of `{name}`")
}
}

/// PYI029
pub(crate) fn str_or_repr_defined_in_stub(checker: &mut Checker, stmt: &Stmt) {
let Stmt::FunctionDef(ast::StmtFunctionDef {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this rule also apply for async function definitions?

If so, then the following could be useful (I thought we already had this, but I wasn't able to find it with a quick search).

In rustpython-ast define a new AnyFunctionDef node that is a union over sync and async function definitions

#[derive(Copy, Clone, Debug)]
pub enum AnyFunctionDefinition<'a> {
	FunctionDef(&'a StmtFunctionDef),
	AsyncFuncitonDef(&'a StmtAsyncFunctionDef)
}

impl AnyFunctionDefinition<'_> {
	pub fn body(&self) -> Suite {
		match self {
			Self::FunctionDef(StmtFunctionDef { body, .. }) | Self::AsyncFuncitonDef(StmtAsyncFunctionDef { body, .. }) => body
	}

	// other accessor methods

	pub fn into_async(self) -> Option<&StmtAsyncFunctionDef> {
		if let Self::AsyncFunctionDef(async) { Some(async) } else { None }
	}
}

impl AstNode for AnyFunctionDef<'_> {
	... 
}

impl<'a> From<&'a StmtFunctionDef> for AnyFunctionDefinition<'a> {
	fn from(value: &'a StmtFunctionDef) -> Self {
		Self::FunctionDef(value) 
	}
}

// Same for `Async`

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I can take a look at this separate from this PR.)

name,
decorator_list,
returns,
args,
..
}) = stmt else {
return
};

if !matches!(name.as_str(), "__str__" | "__repr__") {
return;
}

if !checker.semantic_model().scope().kind.is_class() {
return;
}

// It is a violation only if the method signature matches that of `object.__str__`
// or `object.__repr__` exactly and the method is not decorated as abstract.
if !args.kwonlyargs.is_empty() || (args.args.len() + args.posonlyargs.len()) > 1 {
return;
}

if is_abstract(checker.semantic_model(), decorator_list) {
return;
}

let Some(returns) = returns else {
return;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Moving this check to line 54 could speed up performance because it is cheaper than any semantic model check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Done


if checker
.semantic_model()
.resolve_call_path(returns)
.map_or(true, |call_path| {
!matches!(call_path.as_slice(), ["" | "builtins", "str"])
})
{
return;
}

let mut diagnostic = Diagnostic::new(
StrOrReprDefinedInStub {
name: name.to_string(),
},
stmt.range(),
);

if checker.patch(diagnostic.kind.rule()) {
let mut edit = delete_stmt(
stmt,
checker.semantic_model().stmt_parent(),
checker.locator,
checker.indexer,
checker.stylist,
);

// If we removed the last statement, replace it with `...` instead of `pass` since we're
// editing a stub.
if edit.content() == Some("pass") {
edit = Edit::range_replacement("...".to_string(), edit.range());
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@charliermarsh is this still necessary after your isolation changes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes although I'm tempted to just let it add pass and leave this to other PYI rules.


diagnostic.set_fix(
Fix::automatic(edit).isolate(checker.isolation(checker.semantic_model().stmt_parent())),
);
}

checker.diagnostics.push(diagnostic);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_pyi/mod.rs
---

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
source: crates/ruff/src/rules/flake8_pyi/mod.rs
---
PYI029.pyi:10:5: PYI029 [*] Defining `__str__` in a stub is almost always redundant
|
10 | class ShouldRemoveSingle:
11 | def __str__(self) -> builtins.str: ... # Error: PYI029
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI029
12 |
13 | class ShouldRemove:
|
= help: Remove definition of `str`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why the underscores are lost in __str__


ℹ Fix
7 7 | def __repr__(self, *, foo) -> str: ...
8 8 |
9 9 | class ShouldRemoveSingle:
10 |- def __str__(self) -> builtins.str: ... # Error: PYI029
10 |+ ... # Error: PYI029
11 11 |
12 12 | class ShouldRemove:
13 13 | def __repr__(self) -> str: ... # Error: PYI029

PYI029.pyi:13:5: PYI029 [*] Defining `__repr__` in a stub is almost always redundant
|
13 | class ShouldRemove:
14 | def __repr__(self) -> str: ... # Error: PYI029
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI029
15 | def __str__(self) -> builtins.str: ... # Error: PYI029
|
= help: Remove definition of `repr`

ℹ Fix
10 10 | def __str__(self) -> builtins.str: ... # Error: PYI029
11 11 |
12 12 | class ShouldRemove:
13 |- def __repr__(self) -> str: ... # Error: PYI029
14 13 | def __str__(self) -> builtins.str: ... # Error: PYI029
15 14 |
16 15 | class NoReturnSpecified:

PYI029.pyi:14:5: PYI029 [*] Defining `__str__` in a stub is almost always redundant
|
14 | class ShouldRemove:
15 | def __repr__(self) -> str: ... # Error: PYI029
16 | def __str__(self) -> builtins.str: ... # Error: PYI029
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI029
17 |
18 | class NoReturnSpecified:
|
= help: Remove definition of `str`

ℹ Fix
11 11 |
12 12 | class ShouldRemove:
13 13 | def __repr__(self) -> str: ... # Error: PYI029
14 |- def __str__(self) -> builtins.str: ... # Error: PYI029
15 14 |
16 15 | class NoReturnSpecified:
17 16 | def __str__(self): ...


1 change: 1 addition & 0 deletions ruff.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.