[`flake8-self`] Ignore attribute accesses on instance-like variables (`SLF001`) (#16149)

This commit is contained in:
InSync 2025-02-23 17:00:49 +07:00 committed by GitHub
parent aa88f2dbe5
commit c814745643
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 243 additions and 134 deletions

View File

@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Annotated
# https://github.com/astral-sh/ruff/issues/9022
class Lorem[T]:
def f(self):
lorem_1 = Lorem()
lorem_1._value = 1 # fine
lorem_2 = Lorem[bytes]()
lorem_2._value = 1 # fine
class Ipsum:
def __new__(cls):
instance = super().__new__(cls)
instance._value = 1 # fine
class Dolor[T]:
def f(
self,
a: Dolor,
b: Dolor[int],
c: Annotated[Dolor, ...],
d: Annotated[Dolor[str], ...]
):
a._value = 1 # fine
b._value = 1 # fine
c._value = 1 # fine
d._value = 1 # fine
@classmethod
def m(cls):
instance = cls()
instance._value = 1 # fine
class M(type):
@classmethod
def f(mcs):
cls = mcs()
cls._value = 1

View File

@ -16,6 +16,7 @@ mod tests {
use test_case::test_case;
#[test_case(Rule::PrivateMemberAccess, Path::new("SLF001.py"))]
#[test_case(Rule::PrivateMemberAccess, Path::new("SLF001_1.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy());
let diagnostics = test_path(

View File

@ -1,11 +1,15 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::helpers::{is_dunder, is_sunder};
use ruff_python_ast::name::UnqualifiedName;
use ruff_python_ast::{self as ast, Expr};
use ruff_python_semantic::{BindingKind, ScopeKind};
use ruff_python_semantic::analyze::typing;
use ruff_python_semantic::analyze::typing::TypeChecker;
use ruff_python_semantic::{BindingKind, ScopeKind, SemanticModel};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::rules::pylint::helpers::is_dunder_operator_method;
/// ## What it does
/// Checks for accesses on "private" class members.
@ -69,27 +73,14 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
return;
};
if checker.semantic().in_annotation() {
let semantic = checker.semantic();
let current_scope = semantic.current_scope();
if semantic.in_annotation() {
return;
}
// Ignore non-private accesses.
if !attr.starts_with('_') {
return;
}
// Ignore dunder accesses.
let is_dunder = attr.starts_with("__") && attr.ends_with("__");
if is_dunder {
return;
}
// Ignore sunder accesses.
let is_sunder = attr.starts_with('_')
&& attr.ends_with('_')
&& !attr.starts_with("__")
&& !attr.ends_with("__");
if is_sunder {
if !attr.starts_with('_') || is_dunder(attr) || is_sunder(attr) {
return;
}
@ -103,65 +94,14 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
}
// Ignore accesses on instances within special methods (e.g., `__eq__`).
if let ScopeKind::Function(ast::StmtFunctionDef { name, .. }) =
checker.semantic().current_scope().kind
{
if matches!(
name.as_str(),
"__lt__"
| "__le__"
| "__eq__"
| "__ne__"
| "__gt__"
| "__ge__"
| "__add__"
| "__sub__"
| "__mul__"
| "__matmul__"
| "__truediv__"
| "__floordiv__"
| "__mod__"
| "__divmod__"
| "__pow__"
| "__lshift__"
| "__rshift__"
| "__and__"
| "__xor__"
| "__or__"
| "__radd__"
| "__rsub__"
| "__rmul__"
| "__rmatmul__"
| "__rtruediv__"
| "__rfloordiv__"
| "__rmod__"
| "__rdivmod__"
| "__rpow__"
| "__rlshift__"
| "__rrshift__"
| "__rand__"
| "__rxor__"
| "__ror__"
| "__iadd__"
| "__isub__"
| "__imul__"
| "__imatmul__"
| "__itruediv__"
| "__ifloordiv__"
| "__imod__"
| "__ipow__"
| "__ilshift__"
| "__irshift__"
| "__iand__"
| "__ixor__"
| "__ior__"
) {
if let ScopeKind::Function(ast::StmtFunctionDef { name, .. }) = current_scope.kind {
if is_dunder_operator_method(name) {
return;
}
}
// Allow some documented private methods, like `os._exit()`.
if let Some(qualified_name) = checker.semantic().resolve_qualified_name(expr) {
if let Some(qualified_name) = semantic.resolve_qualified_name(expr) {
if matches!(qualified_name.segments(), ["os", "_exit"]) {
return;
}
@ -185,25 +125,23 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
if let Expr::Name(name) = value.as_ref() {
// Ignore accesses on class members from _within_ the class.
if checker
.semantic()
if semantic
.resolve_name(name)
.and_then(|id| {
if let BindingKind::ClassDefinition(scope) = checker.semantic().binding(id).kind {
if let BindingKind::ClassDefinition(scope) = semantic.binding(id).kind {
Some(scope)
} else {
None
}
})
.is_some_and(|scope| {
checker
.semantic()
.current_scope_ids()
.any(|parent| scope == parent)
})
.is_some_and(|scope| semantic.current_scope_ids().any(|parent| scope == parent))
{
return;
}
if is_same_class_instance(name, semantic) {
return;
}
}
checker.report_diagnostic(Diagnostic::new(
@ -213,3 +151,110 @@ pub(crate) fn private_member_access(checker: &Checker, expr: &Expr) {
expr.range(),
));
}
/// Check for the following cases:
///
/// * Parameter annotation:
///
/// ```python
/// class C[T]:
/// def f(self, other: C): ...
/// def f(self, other: C[...]): ...
/// def f(self, other: Annotated[C, ...]): ...
/// ```
///
/// * `super().__new__`/`cls` call:
///
/// ```python
/// class C:
/// def __new__(cls): ...
/// instance = super().__new__(cls)
/// @classmethod
/// def m(cls):
/// instance = cls()
/// ```
///
/// This function is intentionally naive and does not handle more complex cases.
/// It is expected to be expanded overtime, possibly when type-aware APIs are available.
fn is_same_class_instance(name: &ast::ExprName, semantic: &SemanticModel) -> bool {
let Some(binding_id) = semantic.resolve_name(name) else {
return false;
};
let binding = semantic.binding(binding_id);
typing::check_type::<SameClassInstanceChecker>(binding, semantic)
}
struct SameClassInstanceChecker;
impl SameClassInstanceChecker {
/// Whether `name` resolves to a class which the semantic model is traversing.
fn is_current_class_name(name: &ast::ExprName, semantic: &SemanticModel) -> bool {
semantic.current_scopes().any(|scope| {
let ScopeKind::Class(class) = scope.kind else {
return false;
};
class.name.id == name.id
})
}
}
impl TypeChecker for SameClassInstanceChecker {
/// `C`, `C[T]`, `Annotated[C, ...]`, `Annotated[C[T], ...]`
fn match_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
let Some(class_name) = find_class_name(annotation, semantic) else {
return false;
};
Self::is_current_class_name(class_name, semantic)
}
/// `cls()`, `C()`, `C[T]()`, `super().__new__()`
fn match_initializer(initializer: &Expr, semantic: &SemanticModel) -> bool {
let Expr::Call(call) = initializer else {
return false;
};
match &*call.func {
Expr::Subscript(_) => Self::match_annotation(&call.func, semantic),
Expr::Name(name) => {
matches!(&*name.id, "cls" | "mcs") || Self::is_current_class_name(name, semantic)
}
Expr::Attribute(ast::ExprAttribute { value, attr, .. }) => {
let Expr::Call(ast::ExprCall { func, .. }) = &**value else {
return false;
};
let Expr::Name(ast::ExprName { id: func, .. }) = &**func else {
return false;
};
func == "super" && attr == "__new__"
}
_ => false,
}
}
}
/// Convert `Annotated[C[T], ...]` to `C` (and similar) to `C` recursively.
fn find_class_name<'a>(expr: &'a Expr, semantic: &'a SemanticModel) -> Option<&'a ast::ExprName> {
match expr {
Expr::Name(name) => Some(name),
Expr::Subscript(ast::ExprSubscript { value, slice, .. }) => {
if semantic.match_typing_expr(value, "Annotated") {
let [expr, ..] = &slice.as_tuple_expr()?.elts[..] else {
return None;
};
return find_class_name(expr, semantic);
}
find_class_name(value, semantic)
}
_ => None,
}
}

View File

@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_self/mod.rs
---

View File

@ -166,16 +166,68 @@ impl Visitor<'_> for SequenceIndexVisitor<'_> {
}
}
/// Returns `true` if a method is a known dunder method.
pub(super) fn is_known_dunder_method(method: &str) -> bool {
pub(crate) fn is_dunder_operator_method(method: &str) -> bool {
matches!(
method,
"__abs__"
"__lt__"
| "__le__"
| "__eq__"
| "__ne__"
| "__gt__"
| "__ge__"
| "__add__"
| "__sub__"
| "__mul__"
| "__matmul__"
| "__truediv__"
| "__floordiv__"
| "__mod__"
| "__divmod__"
| "__pow__"
| "__lshift__"
| "__rshift__"
| "__and__"
| "__xor__"
| "__or__"
| "__radd__"
| "__rsub__"
| "__rmul__"
| "__rmatmul__"
| "__rtruediv__"
| "__rfloordiv__"
| "__rmod__"
| "__rdivmod__"
| "__rpow__"
| "__rlshift__"
| "__rrshift__"
| "__rand__"
| "__rxor__"
| "__ror__"
| "__iadd__"
| "__isub__"
| "__imul__"
| "__imatmul__"
| "__itruediv__"
| "__ifloordiv__"
| "__imod__"
| "__ipow__"
| "__ilshift__"
| "__irshift__"
| "__iand__"
| "__ixor__"
| "__ior__"
)
}
/// Returns `true` if a method is a known dunder method.
pub(super) fn is_known_dunder_method(method: &str) -> bool {
is_dunder_operator_method(method)
|| matches!(
method,
"__abs__"
| "__aenter__"
| "__aexit__"
| "__aiter__"
| "__and__"
| "__anext__"
| "__attrs_init__"
| "__attrs_post_init__"
@ -198,17 +250,13 @@ pub(super) fn is_known_dunder_method(method: &str) -> bool {
| "__delitem__"
| "__dict__"
| "__dir__"
| "__divmod__"
| "__doc__"
| "__enter__"
| "__eq__"
| "__exit__"
| "__float__"
| "__floor__"
| "__floordiv__"
| "__format__"
| "__fspath__"
| "__ge__"
| "__get__"
| "__getattr__"
| "__getattribute__"
@ -216,71 +264,33 @@ pub(super) fn is_known_dunder_method(method: &str) -> bool {
| "__getnewargs__"
| "__getnewargs_ex__"
| "__getstate__"
| "__gt__"
| "__hash__"
| "__html__"
| "__iadd__"
| "__iand__"
| "__ifloordiv__"
| "__ilshift__"
| "__imatmul__"
| "__imod__"
| "__imul__"
| "__index__"
| "__init__"
| "__init_subclass__"
| "__instancecheck__"
| "__int__"
| "__invert__"
| "__ior__"
| "__ipow__"
| "__irshift__"
| "__isub__"
| "__iter__"
| "__itruediv__"
| "__ixor__"
| "__le__"
| "__len__"
| "__length_hint__"
| "__lshift__"
| "__lt__"
| "__matmul__"
| "__missing__"
| "__mod__"
| "__module__"
| "__mro_entries__"
| "__mul__"
| "__ne__"
| "__neg__"
| "__new__"
| "__next__"
| "__or__"
| "__pos__"
| "__post_init__"
| "__pow__"
| "__prepare__"
| "__radd__"
| "__rand__"
| "__rdivmod__"
| "__reduce__"
| "__reduce_ex__"
| "__release_buffer__"
| "__replace__"
| "__repr__"
| "__reversed__"
| "__rfloordiv__"
| "__rlshift__"
| "__rmatmul__"
| "__rmod__"
| "__rmul__"
| "__ror__"
| "__round__"
| "__rpow__"
| "__rrshift__"
| "__rshift__"
| "__rsub__"
| "__rtruediv__"
| "__rxor__"
| "__set__"
| "__set_name__"
| "__setattr__"
@ -288,14 +298,11 @@ pub(super) fn is_known_dunder_method(method: &str) -> bool {
| "__setstate__"
| "__sizeof__"
| "__str__"
| "__sub__"
| "__subclasscheck__"
| "__subclasses__"
| "__subclasshook__"
| "__truediv__"
| "__trunc__"
| "__weakref__"
| "__xor__"
// Overridable sunder names from the `Enum` class.
// See: https://docs.python.org/3/library/enum.html#supported-sunder-names
| "_add_alias_"

View File

@ -1,5 +1,5 @@
//! Rules from [Pylint](https://pypi.org/project/pylint/).
mod helpers;
pub(crate) mod helpers;
pub(crate) mod rules;
pub mod settings;

View File

@ -548,6 +548,13 @@ pub fn is_dunder(id: &str) -> bool {
id.starts_with("__") && id.ends_with("__")
}
/// Whether a name starts and ends with a single underscore.
///
/// `_a__` is considered neither a dunder nor a sunder name.
pub fn is_sunder(id: &str) -> bool {
id.starts_with('_') && id.ends_with('_') && !id.starts_with("__") && !id.ends_with("__")
}
/// Return `true` if the [`Stmt`] is an assignment to a dunder (like `__all__`).
pub fn is_assignment_to_a_dunder(stmt: &Stmt) -> bool {
// Check whether it's an assignment to a dunder, with or without a type

View File

@ -572,7 +572,7 @@ pub trait TypeChecker {
/// NOTE: this function doesn't perform more serious type inference, so it won't be able
/// to understand if the value gets initialized from a call to a function always returning
/// lists. This also implies no interfile analysis.
fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bool {
pub fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bool {
match binding.kind {
BindingKind::Assignment => match binding.statement(semantic) {
// Given: