Skip to content

Commit d8bca0d

Browse files
authored
Fix bug where methods defined using lambdas were flagged by FURB118 (#14639)
1 parent 6f1cf5b commit d8bca0d

File tree

5 files changed

+173
-23
lines changed

5 files changed

+173
-23
lines changed

crates/ruff_linter/resources/test/fixtures/refurb/FURB118.py

+50
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,53 @@ def add(x, y):
9393

9494
# Without a slice, trivia is retained
9595
op_itemgetter = lambda x: x[1, 2]
96+
97+
98+
# All methods in classes are ignored, even those defined using lambdas:
99+
class Foo:
100+
def x(self, other):
101+
return self == other
102+
103+
class Bar:
104+
y = lambda self, other: self == other
105+
106+
from typing import Callable
107+
class Baz:
108+
z: Callable = lambda self, other: self == other
109+
110+
111+
# Lambdas wrapped in function calls could also still be method definitions!
112+
# To avoid false positives, we shouldn't flag any of these either:
113+
from typing import final, override, no_type_check
114+
115+
116+
class Foo:
117+
a = final(lambda self, other: self == other)
118+
b = override(lambda self, other: self == other)
119+
c = no_type_check(lambda self, other: self == other)
120+
d = final(override(no_type_check(lambda self, other: self == other)))
121+
122+
123+
# lambdas used in decorators do not constitute method definitions,
124+
# so these *should* be flagged:
125+
class TheLambdasHereAreNotMethods:
126+
@pytest.mark.parametrize(
127+
"slicer, expected",
128+
[
129+
(lambda x: x[-2:], "foo"),
130+
(lambda x: x[-5:-3], "bar"),
131+
],
132+
)
133+
def test_inlet_asset_alias_extra_slice(self, slicer, expected):
134+
assert slice("whatever") == expected
135+
136+
137+
class NotAMethodButHardToDetect:
138+
# In an ideal world, perhaps we'd emit a diagnostic here,
139+
# since this `lambda` is clearly not a method definition,
140+
# and *could* be safely replaced with an `operator` function.
141+
# Practically speaking, however, it's hard to see how we'd accurately determine
142+
# that the `lambda` is *not* a method definition
143+
# without risking false positives elsewhere or introducing complex heuristics
144+
# that users would find surprising and confusing
145+
FOO = sorted([x for x in BAR], key=lambda x: x.baz)

crates/ruff_linter/src/checkers/ast/analyze/deferred_lambdas.rs

+1-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use ruff_python_ast::Expr;
22

33
use crate::checkers::ast::Checker;
44
use crate::codes::Rule;
5-
use crate::rules::{flake8_builtins, flake8_pie, pylint, refurb};
5+
use crate::rules::{flake8_builtins, flake8_pie, pylint};
66

77
/// Run lint rules over all deferred lambdas in the [`SemanticModel`].
88
pub(crate) fn deferred_lambdas(checker: &mut Checker) {
@@ -21,9 +21,6 @@ pub(crate) fn deferred_lambdas(checker: &mut Checker) {
2121
if checker.enabled(Rule::ReimplementedContainerBuiltin) {
2222
flake8_pie::rules::reimplemented_container_builtin(checker, lambda);
2323
}
24-
if checker.enabled(Rule::ReimplementedOperator) {
25-
refurb::rules::reimplemented_operator(checker, &lambda.into());
26-
}
2724
if checker.enabled(Rule::BuiltinLambdaArgumentShadowing) {
2825
flake8_builtins::rules::builtin_lambda_argument_shadowing(checker, lambda);
2926
}

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

+5
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,11 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
16501650
ruff::rules::assignment_in_assert(checker, expr);
16511651
}
16521652
}
1653+
Expr::Lambda(lambda_expr) => {
1654+
if checker.enabled(Rule::ReimplementedOperator) {
1655+
refurb::rules::reimplemented_operator(checker, &lambda_expr.into());
1656+
}
1657+
}
16531658
_ => {}
16541659
};
16551660
}

crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs

+56-18
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use crate::Locator;
2222
///
2323
/// ## Why is this bad?
2424
/// The `operator` module provides functions that implement the same functionality as the
25-
/// corresponding operators. For example, `operator.add` is equivalent to `lambda x, y: x + y`.
25+
/// corresponding operators. For example, `operator.add` is often equivalent to `lambda x, y: x + y`.
2626
/// Using the functions from the `operator` module is more concise and communicates the intent of
2727
/// the code more clearly.
2828
///
@@ -44,10 +44,30 @@ use crate::Locator;
4444
/// ```
4545
///
4646
/// ## Fix safety
47-
/// This fix is usually safe, but if the lambda is called with keyword arguments, e.g.,
48-
/// `add = lambda x, y: x + y; add(x=1, y=2)`, replacing the lambda with an operator function, e.g.,
49-
/// `operator.add`, will cause the call to raise a `TypeError`, as functions in `operator` do not allow
50-
/// keyword arguments.
47+
/// The fix offered by this rule is always marked as unsafe. While the changes the fix would make
48+
/// would rarely break your code, there are two ways in which functions from the `operator` module
49+
/// differ from user-defined functions. It would be non-trivial for Ruff to detect whether or not
50+
/// these differences would matter in a specific situation where Ruff is emitting a diagnostic for
51+
/// this rule.
52+
///
53+
/// The first difference is that `operator` functions cannot be called with keyword arguments, but
54+
/// most user-defined functions can. If an `add` function is defined as `add = lambda x, y: x + y`,
55+
/// replacing this function with `operator.add` will cause the later call to raise `TypeError` if
56+
/// the function is later called with keyword arguments, e.g. `add(x=1, y=2)`.
57+
///
58+
/// The second difference is that user-defined functions are [descriptors], but this is not true of
59+
/// the functions defined in the `operator` module. Practically speaking, this means that defining
60+
/// a function in a class body (either by using a `def` statement or assigning a `lambda` function
61+
/// to a variable) is a valid way of defining an instance method on that class; monkeypatching a
62+
/// user-defined function onto a class after the class has been created also has the same effect.
63+
/// The same is not true of an `operator` function: assigning an `operator` function to a variable
64+
/// in a class body or monkeypatching one onto a class will not create a valid instance method.
65+
/// Ruff will refrain from emitting diagnostics for this rule on function definitions in class
66+
/// bodies; however, it does not currently have sophisticated enough type inference to avoid
67+
/// emitting this diagnostic if a user-defined function is being monkeypatched onto a class after
68+
/// the class has been constructed.
69+
///
70+
/// [descriptors]: https://docs.python.org/3/howto/descriptor.html
5171
#[derive(ViolationMetadata)]
5272
pub(crate) struct ReimplementedOperator {
5373
operator: Operator,
@@ -60,14 +80,7 @@ impl Violation for ReimplementedOperator {
6080
#[derive_message_formats]
6181
fn message(&self) -> String {
6282
let ReimplementedOperator { operator, target } = self;
63-
match target {
64-
FunctionLikeKind::Function => {
65-
format!("Use `operator.{operator}` instead of defining a function")
66-
}
67-
FunctionLikeKind::Lambda => {
68-
format!("Use `operator.{operator}` instead of defining a lambda")
69-
}
70-
}
83+
format!("Use `operator.{operator}` instead of defining a {target}")
7184
}
7285

7386
fn fix_title(&self) -> Option<String> {
@@ -79,10 +92,16 @@ impl Violation for ReimplementedOperator {
7992
/// FURB118
8093
pub(crate) fn reimplemented_operator(checker: &mut Checker, target: &FunctionLike) {
8194
// Ignore methods.
82-
if target.kind() == FunctionLikeKind::Function {
83-
if checker.semantic().current_scope().kind.is_class() {
84-
return;
85-
}
95+
// Methods can be defined via a `def` statement in a class scope,
96+
// or via a lambda appearing on the right-hand side of an assignment in a class scope.
97+
if checker.semantic().current_scope().kind.is_class()
98+
&& (target.is_function_def()
99+
|| checker
100+
.semantic()
101+
.current_statements()
102+
.any(|stmt| matches!(stmt, Stmt::AnnAssign(_) | Stmt::Assign(_))))
103+
{
104+
return;
86105
}
87106

88107
let Some(params) = target.parameters() else {
@@ -141,6 +160,10 @@ impl FunctionLike<'_> {
141160
}
142161
}
143162

163+
const fn is_function_def(&self) -> bool {
164+
matches!(self, Self::Function(_))
165+
}
166+
144167
/// Return the body of the function-like node.
145168
///
146169
/// 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
327350
}
328351
}
329352

330-
#[derive(Debug, PartialEq, Eq)]
353+
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
331354
enum FunctionLikeKind {
332355
Lambda,
333356
Function,
334357
}
335358

359+
impl FunctionLikeKind {
360+
const fn as_str(self) -> &'static str {
361+
match self {
362+
Self::Lambda => "lambda",
363+
Self::Function => "function",
364+
}
365+
}
366+
}
367+
368+
impl Display for FunctionLikeKind {
369+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
370+
f.write_str(self.as_str())
371+
}
372+
}
373+
336374
/// Return the name of the `operator` implemented by the given unary expression.
337375
fn unary_op(expr: &ast::ExprUnaryOp, params: &Parameters) -> Option<&'static str> {
338376
let [arg] = params.args.as_slice() else {

crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap

+61-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---
22
source: crates/ruff_linter/src/rules/refurb/mod.rs
3-
snapshot_kind: text
43
---
54
FURB118.py:2:13: FURB118 [*] Use `operator.invert` instead of defining a lambda
65
|
@@ -947,3 +946,64 @@ FURB118.py:95:17: FURB118 [*] Use `operator.itemgetter((1, 2))` instead
947946
94 95 | # Without a slice, trivia is retained
948947
95 |-op_itemgetter = lambda x: x[1, 2]
949948
96 |+op_itemgetter = operator.itemgetter((1, 2))
949+
96 97 |
950+
97 98 |
951+
98 99 | # All methods in classes are ignored, even those defined using lambdas:
952+
953+
FURB118.py:129:14: FURB118 [*] Use `operator.itemgetter(slice(-2, None))` instead of defining a lambda
954+
|
955+
127 | "slicer, expected",
956+
128 | [
957+
129 | (lambda x: x[-2:], "foo"),
958+
| ^^^^^^^^^^^^^^^^ FURB118
959+
130 | (lambda x: x[-5:-3], "bar"),
960+
131 | ],
961+
|
962+
= help: Replace with `operator.itemgetter(slice(-2, None))`
963+
964+
ℹ Unsafe fix
965+
111 111 | # Lambdas wrapped in function calls could also still be method definitions!
966+
112 112 | # To avoid false positives, we shouldn't flag any of these either:
967+
113 113 | from typing import final, override, no_type_check
968+
114 |+import operator
969+
114 115 |
970+
115 116 |
971+
116 117 | class Foo:
972+
--------------------------------------------------------------------------------
973+
126 127 | @pytest.mark.parametrize(
974+
127 128 | "slicer, expected",
975+
128 129 | [
976+
129 |- (lambda x: x[-2:], "foo"),
977+
130 |+ (operator.itemgetter(slice(-2, None)), "foo"),
978+
130 131 | (lambda x: x[-5:-3], "bar"),
979+
131 132 | ],
980+
132 133 | )
981+
982+
FURB118.py:130:14: FURB118 [*] Use `operator.itemgetter(slice(-5, -3))` instead of defining a lambda
983+
|
984+
128 | [
985+
129 | (lambda x: x[-2:], "foo"),
986+
130 | (lambda x: x[-5:-3], "bar"),
987+
| ^^^^^^^^^^^^^^^^^^ FURB118
988+
131 | ],
989+
132 | )
990+
|
991+
= help: Replace with `operator.itemgetter(slice(-5, -3))`
992+
993+
ℹ Unsafe fix
994+
111 111 | # Lambdas wrapped in function calls could also still be method definitions!
995+
112 112 | # To avoid false positives, we shouldn't flag any of these either:
996+
113 113 | from typing import final, override, no_type_check
997+
114 |+import operator
998+
114 115 |
999+
115 116 |
1000+
116 117 | class Foo:
1001+
--------------------------------------------------------------------------------
1002+
127 128 | "slicer, expected",
1003+
128 129 | [
1004+
129 130 | (lambda x: x[-2:], "foo"),
1005+
130 |- (lambda x: x[-5:-3], "bar"),
1006+
131 |+ (operator.itemgetter(slice(-5, -3)), "bar"),
1007+
131 132 | ],
1008+
132 133 | )
1009+
133 134 | def test_inlet_asset_alias_extra_slice(self, slicer, expected):

0 commit comments

Comments
 (0)