Fix bug where methods defined using lambdas were flagged by FURB118 (#14639)

This commit is contained in:
Alex Waygood
2024-11-28 12:58:23 +00:00
committed by GitHub
parent 6f1cf5b686
commit d8bca0d3a2
5 changed files with 173 additions and 23 deletions

View File

@@ -93,3 +93,53 @@ op_itemgetter = lambda x: x[1, :]
# Without a slice, trivia is retained
op_itemgetter = lambda x: x[1, 2]
# All methods in classes are ignored, even those defined using lambdas:
class Foo:
def x(self, other):
return self == other
class Bar:
y = lambda self, other: self == other
from typing import Callable
class Baz:
z: Callable = lambda self, other: self == other
# Lambdas wrapped in function calls could also still be method definitions!
# To avoid false positives, we shouldn't flag any of these either:
from typing import final, override, no_type_check
class Foo:
a = final(lambda self, other: self == other)
b = override(lambda self, other: self == other)
c = no_type_check(lambda self, other: self == other)
d = final(override(no_type_check(lambda self, other: self == other)))
# lambdas used in decorators do not constitute method definitions,
# so these *should* be flagged:
class TheLambdasHereAreNotMethods:
@pytest.mark.parametrize(
"slicer, expected",
[
(lambda x: x[-2:], "foo"),
(lambda x: x[-5:-3], "bar"),
],
)
def test_inlet_asset_alias_extra_slice(self, slicer, expected):
assert slice("whatever") == expected
class NotAMethodButHardToDetect:
# In an ideal world, perhaps we'd emit a diagnostic here,
# since this `lambda` is clearly not a method definition,
# and *could* be safely replaced with an `operator` function.
# Practically speaking, however, it's hard to see how we'd accurately determine
# that the `lambda` is *not* a method definition
# without risking false positives elsewhere or introducing complex heuristics
# that users would find surprising and confusing
FOO = sorted([x for x in BAR], key=lambda x: x.baz)

View File

@@ -2,7 +2,7 @@ use ruff_python_ast::Expr;
use crate::checkers::ast::Checker;
use crate::codes::Rule;
use crate::rules::{flake8_builtins, flake8_pie, pylint, refurb};
use crate::rules::{flake8_builtins, flake8_pie, pylint};
/// Run lint rules over all deferred lambdas in the [`SemanticModel`].
pub(crate) fn deferred_lambdas(checker: &mut Checker) {
@@ -21,9 +21,6 @@ pub(crate) fn deferred_lambdas(checker: &mut Checker) {
if checker.enabled(Rule::ReimplementedContainerBuiltin) {
flake8_pie::rules::reimplemented_container_builtin(checker, lambda);
}
if checker.enabled(Rule::ReimplementedOperator) {
refurb::rules::reimplemented_operator(checker, &lambda.into());
}
if checker.enabled(Rule::BuiltinLambdaArgumentShadowing) {
flake8_builtins::rules::builtin_lambda_argument_shadowing(checker, lambda);
}

View File

@@ -1650,6 +1650,11 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
ruff::rules::assignment_in_assert(checker, expr);
}
}
Expr::Lambda(lambda_expr) => {
if checker.enabled(Rule::ReimplementedOperator) {
refurb::rules::reimplemented_operator(checker, &lambda_expr.into());
}
}
_ => {}
};
}

View File

@@ -22,7 +22,7 @@ use crate::Locator;
///
/// ## Why is this bad?
/// The `operator` module provides functions that implement the same functionality as the
/// corresponding operators. For example, `operator.add` is equivalent to `lambda x, y: x + y`.
/// corresponding operators. For example, `operator.add` is often equivalent to `lambda x, y: x + y`.
/// Using the functions from the `operator` module is more concise and communicates the intent of
/// the code more clearly.
///
@@ -44,10 +44,30 @@ use crate::Locator;
/// ```
///
/// ## Fix safety
/// This fix is usually safe, but if the lambda is called with keyword arguments, e.g.,
/// `add = lambda x, y: x + y; add(x=1, y=2)`, replacing the lambda with an operator function, e.g.,
/// `operator.add`, will cause the call to raise a `TypeError`, as functions in `operator` do not allow
/// keyword arguments.
/// The fix offered by this rule is always marked as unsafe. While the changes the fix would make
/// would rarely break your code, there are two ways in which functions from the `operator` module
/// differ from user-defined functions. It would be non-trivial for Ruff to detect whether or not
/// these differences would matter in a specific situation where Ruff is emitting a diagnostic for
/// this rule.
///
/// The first difference is that `operator` functions cannot be called with keyword arguments, but
/// most user-defined functions can. If an `add` function is defined as `add = lambda x, y: x + y`,
/// replacing this function with `operator.add` will cause the later call to raise `TypeError` if
/// the function is later called with keyword arguments, e.g. `add(x=1, y=2)`.
///
/// The second difference is that user-defined functions are [descriptors], but this is not true of
/// the functions defined in the `operator` module. Practically speaking, this means that defining
/// a function in a class body (either by using a `def` statement or assigning a `lambda` function
/// to a variable) is a valid way of defining an instance method on that class; monkeypatching a
/// user-defined function onto a class after the class has been created also has the same effect.
/// The same is not true of an `operator` function: assigning an `operator` function to a variable
/// in a class body or monkeypatching one onto a class will not create a valid instance method.
/// Ruff will refrain from emitting diagnostics for this rule on function definitions in class
/// bodies; however, it does not currently have sophisticated enough type inference to avoid
/// emitting this diagnostic if a user-defined function is being monkeypatched onto a class after
/// the class has been constructed.
///
/// [descriptors]: https://docs.python.org/3/howto/descriptor.html
#[derive(ViolationMetadata)]
pub(crate) struct ReimplementedOperator {
operator: Operator,
@@ -60,14 +80,7 @@ impl Violation for ReimplementedOperator {
#[derive_message_formats]
fn message(&self) -> String {
let ReimplementedOperator { operator, target } = self;
match target {
FunctionLikeKind::Function => {
format!("Use `operator.{operator}` instead of defining a function")
}
FunctionLikeKind::Lambda => {
format!("Use `operator.{operator}` instead of defining a lambda")
}
}
format!("Use `operator.{operator}` instead of defining a {target}")
}
fn fix_title(&self) -> Option<String> {
@@ -79,10 +92,16 @@ impl Violation for ReimplementedOperator {
/// FURB118
pub(crate) fn reimplemented_operator(checker: &mut Checker, target: &FunctionLike) {
// Ignore methods.
if target.kind() == FunctionLikeKind::Function {
if checker.semantic().current_scope().kind.is_class() {
return;
}
// Methods can be defined via a `def` statement in a class scope,
// or via a lambda appearing on the right-hand side of an assignment in a class scope.
if checker.semantic().current_scope().kind.is_class()
&& (target.is_function_def()
|| checker
.semantic()
.current_statements()
.any(|stmt| matches!(stmt, Stmt::AnnAssign(_) | Stmt::Assign(_))))
{
return;
}
let Some(params) = target.parameters() else {
@@ -141,6 +160,10 @@ impl FunctionLike<'_> {
}
}
const fn is_function_def(&self) -> bool {
matches!(self, Self::Function(_))
}
/// Return the body of the function-like node.
///
/// If the node is a function definition that consists of more than a single return statement,
@@ -327,12 +350,27 @@ fn get_operator(expr: &Expr, params: &Parameters, locator: &Locator) -> Option<O
}
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum FunctionLikeKind {
Lambda,
Function,
}
impl FunctionLikeKind {
const fn as_str(self) -> &'static str {
match self {
Self::Lambda => "lambda",
Self::Function => "function",
}
}
}
impl Display for FunctionLikeKind {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Return the name of the `operator` implemented by the given unary expression.
fn unary_op(expr: &ast::ExprUnaryOp, params: &Parameters) -> Option<&'static str> {
let [arg] = params.args.as_slice() else {

View File

@@ -1,6 +1,5 @@
---
source: crates/ruff_linter/src/rules/refurb/mod.rs
snapshot_kind: text
---
FURB118.py:2:13: FURB118 [*] Use `operator.invert` instead of defining a lambda
|
@@ -947,3 +946,64 @@ FURB118.py:95:17: FURB118 [*] Use `operator.itemgetter((1, 2))` instead
94 95 | # Without a slice, trivia is retained
95 |-op_itemgetter = lambda x: x[1, 2]
96 |+op_itemgetter = operator.itemgetter((1, 2))
96 97 |
97 98 |
98 99 | # All methods in classes are ignored, even those defined using lambdas:
FURB118.py:129:14: FURB118 [*] Use `operator.itemgetter(slice(-2, None))` instead of defining a lambda
|
127 | "slicer, expected",
128 | [
129 | (lambda x: x[-2:], "foo"),
| ^^^^^^^^^^^^^^^^ FURB118
130 | (lambda x: x[-5:-3], "bar"),
131 | ],
|
= help: Replace with `operator.itemgetter(slice(-2, None))`
Unsafe fix
111 111 | # Lambdas wrapped in function calls could also still be method definitions!
112 112 | # To avoid false positives, we shouldn't flag any of these either:
113 113 | from typing import final, override, no_type_check
114 |+import operator
114 115 |
115 116 |
116 117 | class Foo:
--------------------------------------------------------------------------------
126 127 | @pytest.mark.parametrize(
127 128 | "slicer, expected",
128 129 | [
129 |- (lambda x: x[-2:], "foo"),
130 |+ (operator.itemgetter(slice(-2, None)), "foo"),
130 131 | (lambda x: x[-5:-3], "bar"),
131 132 | ],
132 133 | )
FURB118.py:130:14: FURB118 [*] Use `operator.itemgetter(slice(-5, -3))` instead of defining a lambda
|
128 | [
129 | (lambda x: x[-2:], "foo"),
130 | (lambda x: x[-5:-3], "bar"),
| ^^^^^^^^^^^^^^^^^^ FURB118
131 | ],
132 | )
|
= help: Replace with `operator.itemgetter(slice(-5, -3))`
Unsafe fix
111 111 | # Lambdas wrapped in function calls could also still be method definitions!
112 112 | # To avoid false positives, we shouldn't flag any of these either:
113 113 | from typing import final, override, no_type_check
114 |+import operator
114 115 |
115 116 |
116 117 | class Foo:
--------------------------------------------------------------------------------
127 128 | "slicer, expected",
128 129 | [
129 130 | (lambda x: x[-2:], "foo"),
130 |- (lambda x: x[-5:-3], "bar"),
131 |+ (operator.itemgetter(slice(-5, -3)), "bar"),
131 132 | ],
132 133 | )
133 134 | def test_inlet_asset_alias_extra_slice(self, slicer, expected):