Skip to content
Merged
33 changes: 27 additions & 6 deletions crates/ruff_linter/resources/test/fixtures/flake8_return/RET504.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,17 +410,38 @@ def foo():

# See: https://github.com/astral-sh/ruff/issues/10732
def func(a: dict[str, int]) -> list[dict[str, int]]:
services: list[dict[str, int]]
if "services" in a:
services = a["services"]
return services


# See: https://github.com/astral-sh/ruff/issues/10732
def func(a: dict[str, int]) -> list[dict[str, int]]:
if "services" in a:
services = a["services"]
return services
# See: https://github.com/astral-sh/ruff/issues/14052
def outer() -> list[object]:
@register
async def inner() -> None:
print(layout)

layout = [...]
return layout

def outer() -> list[object]:
with open("") as f:
async def inner() -> None:
print(layout)

layout = [...]
return layout


def outer() -> list[object]:
def inner():
with open("") as f:
async def inner_inner() -> None:
print(layout)

layout = [...]
return layout


# See: https://github.com/astral-sh/ruff/issues/18411
def f():
Expand Down
13 changes: 11 additions & 2 deletions crates/ruff_linter/src/checkers/ast/analyze/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use crate::Fix;
use crate::checkers::ast::Checker;
use crate::codes::Rule;
use crate::rules::{
flake8_import_conventions, flake8_pyi, flake8_pytest_style, flake8_type_checking, pyflakes,
pylint, pyupgrade, refurb, ruff,
flake8_import_conventions, flake8_pyi, flake8_pytest_style, flake8_return,
flake8_type_checking, pyflakes, pylint, pyupgrade, refurb, ruff,
};

/// Run lint rules over the [`Binding`]s.
Expand All @@ -25,11 +25,20 @@ pub(crate) fn bindings(checker: &Checker) {
Rule::ForLoopWrites,
Rule::CustomTypeVarForSelf,
Rule::PrivateTypeParameter,
Rule::UnnecessaryAssign,
]) {
return;
}

for (binding_id, binding) in checker.semantic.bindings.iter_enumerated() {
if checker.enabled(Rule::UnnecessaryAssign) {
if binding.kind.is_function_definition() {
flake8_return::rules::unnecessary_assign(
checker,
binding.statement(checker.semantic()).unwrap(),
);
}
}
if checker.enabled(Rule::UnusedVariable) {
if binding.kind.is_bound_exception()
&& binding.is_unused()
Expand Down
1 change: 0 additions & 1 deletion crates/ruff_linter/src/checkers/ast/analyze/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
Rule::UnnecessaryReturnNone,
Rule::ImplicitReturnValue,
Rule::ImplicitReturn,
Rule::UnnecessaryAssign,
Rule::SuperfluousElseReturn,
Rule::SuperfluousElseRaise,
Rule::SuperfluousElseContinue,
Expand Down
75 changes: 59 additions & 16 deletions crates/ruff_linter/src/rules/flake8_return/rules/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,18 @@ fn implicit_return(checker: &Checker, function_def: &ast::StmtFunctionDef, stmt:
}

/// RET504
fn unnecessary_assign(checker: &Checker, stack: &Stack) {
pub(crate) fn unnecessary_assign(checker: &Checker, function_stmt: &Stmt) {
let Stmt::FunctionDef(function_def) = function_stmt else {
unreachable!();
};
let Some(stack) = create_stack(checker, function_def) else {
return;
};

if !result_exists(&stack.returns) {
return;
}

for (assign, return_, stmt) in &stack.assignment_return {
// Identify, e.g., `return x`.
let Some(value) = return_.value.as_ref() else {
Expand Down Expand Up @@ -600,6 +611,25 @@ fn unnecessary_assign(checker: &Checker, stack: &Stack) {
continue;
}

let Some(assigned_binding) = checker
.semantic()
.bindings
.iter()
.filter(|binding| binding.kind.is_assignment())
.find(|binding| binding.name(checker.source()) == assigned_id.as_str())
else {
continue;
};
// Check if there's any reference made to `assigned_binding` in another scope, e.g, nested
// functions. If there is, ignore them.
if assigned_binding
.references()
.map(|reference_id| checker.semantic().reference(reference_id))
.any(|reference| reference.scope_id() != assigned_binding.scope)
{
continue;
}
Comment on lines +608 to +614
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about this, but I suspect that this is the cause of the new false negatives. Could this be checking other scopes not nested within the current function? Both of the false negative cases have instances of the same variable names in other functions in the same file. I don't know of an API off the top of my head, but we may need to restrict the search to children of the current scope.

Copy link
Contributor

Choose a reason for hiding this comment

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

This might actually be corroborated by your comment. I think other functions in the file might be leaking into the current check, but I could still be wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think the error is in the code above, where the search for assigned_binding is done.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've come up with this solution where it tries to find the scope of the function by looking up its name. Then it looks up assigned_binding in the function scope. It solves the false negatives, but the only problem is that it doesn't work if we have more than one function with the same name (the test file has multiple functions with the same name). I've run out of ideas for how to solve this problem.

diff --git a/crates/ruff_linter/src/rules/flake8_return/rules/function.rs b/crates/ruff_linter/src/rules/flake8_return/rules/function.rs
index 299acbc6c..29d759dcd 100644
--- a/crates/ruff_linter/src/rules/flake8_return/rules/function.rs
+++ b/crates/ruff_linter/src/rules/flake8_return/rules/function.rs
@@ -7,8 +7,8 @@ use ruff_python_ast::visitor::Visitor;
 use ruff_python_ast::whitespace::indentation;
 use ruff_python_ast::{self as ast, Decorator, ElifElseClause, Expr, Stmt};
 use ruff_python_parser::TokenKind;
-use ruff_python_semantic::SemanticModel;
 use ruff_python_semantic::analyze::visibility::is_property;
+use ruff_python_semantic::{BindingKind, SemanticModel};
 use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer, is_python_whitespace};
 use ruff_source_file::LineRanges;
 use ruff_text_size::{Ranged, TextRange, TextSize};
@@ -568,6 +568,19 @@ pub(crate) fn unnecessary_assign(checker: &Checker, function_stmt: &Stmt) {
         return;
     }
 
+    let Some(function_scope) = checker
+        .semantic()
+        .lookup_symbol(&function_def.name)
+        .map(|binding_id| checker.semantic().binding(binding_id))
+        .and_then(|binding| {
+            let BindingKind::FunctionDefinition(scope_id) = &binding.kind else {
+                return None;
+            };
+            Some(&checker.semantic().scopes[*scope_id])
+        })
+    else {
+        return;
+    };
     for (assign, return_, stmt) in &stack.assignment_return {
         // Identify, e.g., `return x`.
         let Some(value) = return_.value.as_ref() else {
@@ -611,12 +624,9 @@ pub(crate) fn unnecessary_assign(checker: &Checker, function_stmt: &Stmt) {
             continue;
         }
 
-        let Some(assigned_binding) = checker
-            .semantic()
-            .bindings
-            .iter()
-            .filter(|binding| binding.kind.is_assignment())
-            .find(|binding| binding.name(checker.source()) == assigned_id.as_str())
+        let Some(assigned_binding) = function_scope
+            .get(assigned_id)
+            .map(|binding_id| checker.semantic().binding(binding_id))
         else {
             continue;
         };

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure how helpful this is, but I spent some time minimizing the ecosystem hit that was causing problems:

# minimized from an ecosystem hit in
# https://github.com/astral-sh/ruff/pull/18433#issuecomment-2932216413
def foo():
    safe_dag_prefix = 1
    [safe_dag_prefix for _ in range(5)]


def bar():
    safe_dag_prefix = 2
    return safe_dag_prefix  # Supposed to trigger RET504

It seems that it might have something to do with the shared variable name appearing in a list comprehension. The Airflow example has it in a position like [... for x in ... if some_condition(safe_dag_prefix)] but it happens with the variable in the comprehension element position too.

I think you're on the right track with looking up the scope. Is there not a way to look them up by something other than their name? SemanticModel::current_scope sounds promising, but maybe it doesn't work for deferred checks like function bodies. It feels like there should be a way to get the scope from function_stmt, but I could be wrong.


let mut diagnostic = checker.report_diagnostic(
UnnecessaryAssign {
name: assigned_id.to_string(),
Expand Down Expand Up @@ -682,24 +712,21 @@ fn superfluous_elif_else(checker: &Checker, stack: &Stack) {
}
}

/// Run all checks from the `flake8-return` plugin.
pub(crate) fn function(checker: &Checker, function_def: &ast::StmtFunctionDef) {
let ast::StmtFunctionDef {
decorator_list,
returns,
body,
..
} = function_def;
fn create_stack<'a>(
checker: &'a Checker,
function_def: &'a ast::StmtFunctionDef,
) -> Option<Stack<'a>> {
let ast::StmtFunctionDef { body, .. } = function_def;

// Find the last statement in the function.
let Some(last_stmt) = body.last() else {
// Skip empty functions.
return;
return None;
};

// Skip functions that consist of a single return statement.
if body.len() == 1 && matches!(last_stmt, Stmt::Return(_)) {
return;
return None;
}

// Traverse the function body, to collect the stack.
Expand All @@ -713,9 +740,29 @@ pub(crate) fn function(checker: &Checker, function_def: &ast::StmtFunctionDef) {

// Avoid false positives for generators.
if stack.is_generator {
return;
return None;
}

Some(stack)
}

/// Run all checks from the `flake8-return` plugin, but `RET504` which is ran
/// after the semantic model is fully built.
pub(crate) fn function(checker: &Checker, function_def: &ast::StmtFunctionDef) {
let ast::StmtFunctionDef {
decorator_list,
returns,
body,
..
} = function_def;

let Some(stack) = create_stack(checker, function_def) else {
return;
};

// SAFETY: `create_stack` checks if the function has the last statement.
let last_stmt = body.last().unwrap();

if checker.any_enabled(&[
Rule::SuperfluousElseReturn,
Rule::SuperfluousElseRaise,
Expand All @@ -738,10 +785,6 @@ pub(crate) fn function(checker: &Checker, function_def: &ast::StmtFunctionDef) {
if checker.enabled(Rule::ImplicitReturn) {
implicit_return(checker, function_def, last_stmt);
}

if checker.enabled(Rule::UnnecessaryAssign) {
unnecessary_assign(checker, &stack);
}
} else {
if checker.enabled(Rule::UnnecessaryReturnNone) {
// Skip functions that have a return annotation that is not `None`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,65 +241,63 @@ RET504.py:400:12: RET504 [*] Unnecessary assignment to `y` before `return` state
402 401 |
403 402 | def foo():

RET504.py:423:16: RET504 [*] Unnecessary assignment to `services` before `return` statement
RET504.py:415:16: RET504 [*] Unnecessary assignment to `services` before `return` statement
|
421 | if "services" in a:
422 | services = a["services"]
423 | return services
413 | if "services" in a:
414 | services = a["services"]
415 | return services
| ^^^^^^^^ RET504
424 |
425 | # See: https://github.com/astral-sh/ruff/issues/18411
|
= help: Remove unnecessary assignment

ℹ Unsafe fix
419 419 | # See: https://github.com/astral-sh/ruff/issues/10732
420 420 | def func(a: dict[str, int]) -> list[dict[str, int]]:
421 421 | if "services" in a:
422 |- services = a["services"]
423 |- return services
422 |+ return a["services"]
424 423 |
425 424 | # See: https://github.com/astral-sh/ruff/issues/18411
426 425 | def f():
411 411 | # See: https://github.com/astral-sh/ruff/issues/10732
412 412 | def func(a: dict[str, int]) -> list[dict[str, int]]:
413 413 | if "services" in a:
414 |- services = a["services"]
415 |- return services
414 |+ return a["services"]
416 415 |
417 416 |
418 417 | # See: https://github.com/astral-sh/ruff/issues/14052

RET504.py:429:12: RET504 [*] Unnecessary assignment to `x` before `return` statement
RET504.py:450:12: RET504 [*] Unnecessary assignment to `x` before `return` statement
|
427 | (#=
428 | x) = 1
429 | return x
448 | (#=
449 | x) = 1
450 | return x
| ^ RET504
430 |
431 | def f():
451 |
452 | def f():
|
= help: Remove unnecessary assignment

ℹ Unsafe fix
424 424 |
425 425 | # See: https://github.com/astral-sh/ruff/issues/18411
426 426 | def f():
427 |- (#=
428 |- x) = 1
429 |- return x
427 |+ return 1
430 428 |
431 429 | def f():
432 430 | x = (1
445 445 |
446 446 | # See: https://github.com/astral-sh/ruff/issues/18411
447 447 | def f():
448 |- (#=
449 |- x) = 1
450 |- return x
448 |+ return 1
451 449 |
452 450 | def f():
453 451 | x = (1

RET504.py:434:12: RET504 [*] Unnecessary assignment to `x` before `return` statement
RET504.py:455:12: RET504 [*] Unnecessary assignment to `x` before `return` statement
|
432 | x = (1
433 | )
434 | return x
453 | x = (1
454 | )
455 | return x
| ^ RET504
|
= help: Remove unnecessary assignment

ℹ Unsafe fix
429 429 | return x
430 430 |
431 431 | def f():
432 |- x = (1
432 |+ return (1
433 433 | )
434 |- return x
450 450 | return x
451 451 |
452 452 | def f():
453 |- x = (1
453 |+ return (1
454 454 | )
455 |- return x
Loading