From 6e77e55a23314e6451e2239a7fa43d4aac70b4a5 Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Mon, 19 Oct 2026 21:46:09 -0300 Subject: [PATCH 1/2] B018: report generator expressions and lambdas used as statements A generator expression is lazy, so `(print(x) for x in items)` on its own line never runs its body, and a lambda that is neither assigned nor passed anywhere can never be called. Both are now reported like the other useless expressions. A generator expression directly in a `try` or `with` block is left alone, like operations are, since it calls `iter()` on its first iterable right away and a test may build one only to see that raise. Refs #452 --- README.rst | 12 ++++--- bugbear.py | 16 +++++++-- tests/eval_files/b018_generators_lambdas.py | 38 +++++++++++++++++++++ tests/eval_files/b023.py | 2 +- 4 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 tests/eval_files/b018_generators_lambdas.py diff --git a/README.rst b/README.rst index 5d59892..6bdd663 100644 --- a/README.rst +++ b/README.rst @@ -175,10 +175,11 @@ using ``pytest.raises``), or use the context manager form with a target **B018**: Found useless expression. Either assign it to a variable or remove it. The check also considers function calls without side-effects such as ``isinstance``, -and unary and arithmetic operations such as ``-x`` or ``a * b``. Shifts, bitwise -operators and ``@`` are left alone, since libraries overload them for their side -effects, and so is an operation directly in a ``try`` or ``with`` block, where it may be -run only to see it raise. +unary and arithmetic operations such as ``-x`` or ``a * b``, generator expressions +(which are lazy, so their body never runs) and lambdas (which nothing can call). +Shifts, bitwise operators and ``@`` are left alone, since libraries overload them for +their side effects, and so is an operation or a generator expression directly in a +``try`` or ``with`` block, where it may be run only to see it raise. Note that dangling commas will cause things to be interpreted as useless tuples. For example, in the statement ``print(".."),`` is the same as ``(print(".."),)`` which is an unassigned tuple. Simply remove the comma to clear the error. @@ -595,6 +596,9 @@ UNRELEASED * B909: also check loops over ``d.keys()``, ``d.values()`` and ``d.items()``, which are views of ``d``, so ``for k in d.keys(): d.pop(k)`` is reported like ``for k in d: d.pop(k)`` +* B018: also report generator expressions and lambdas used as statements. A + statement like ``(print(x) for x in items)`` never runs its body, and a bare + ``lambda x: True`` is usually an assignment that lost its target (#452) 26.9.30 ~~~~~~~ diff --git a/bugbear.py b/bugbear.py index 5908204..e9ca3b7 100644 --- a/bugbear.py +++ b/bugbear.py @@ -2403,11 +2403,21 @@ def check_for_b018(self, node: ast.AST) -> None: and isinstance(node.value.func, ast.Name) and node.value.func.id in FUNCTIONS_WITHOUT_SIDE_EFFECTS ) + # a lambda that is neither assigned nor passed anywhere is never called + or isinstance(node.value, ast.Lambda) or ( - isinstance(node.value, (ast.UnaryOp, ast.BinOp)) - and _is_useless_operation(node.value) + ( + ( + isinstance(node.value, (ast.UnaryOp, ast.BinOp)) + and _is_useless_operation(node.value) + ) + # a generator expression is lazy, so its body never runs + or isinstance(node.value, ast.GeneratorExp) + ) # directly in a `try` or `with` block, an operation is often run - # only to see it raise: `with pytest.raises(TypeError): "1" + 1` + # only to see it raise: `with pytest.raises(TypeError): "1" + 1`. + # A generator expression calls `iter()` on its first iterable + # right away, so the same goes for `(x for x in 1)` and not ( self.node_stack and isinstance(self.node_stack[-1], B018_RAISING_BLOCKS) diff --git a/tests/eval_files/b018_generators_lambdas.py b/tests/eval_files/b018_generators_lambdas.py new file mode 100644 index 0000000..d2bd937 --- /dev/null +++ b/tests/eval_files/b018_generators_lambdas.py @@ -0,0 +1,38 @@ +""" +Should emit: +B018 - on lines 11-17 +""" + + +def discarded(items, log, debug_func): + # a generator expression is lazy: as a statement, its body never runs (#452) + # and a lambda that isn't assigned or passed anywhere can never be called + log("start") + (print(item) for item in items) # B018: 4, "GeneratorExp" + (item.close() for item in items if item) # B018: 4, "GeneratorExp" + (x for x in items) # B018: 4, "GeneratorExp" + lambda: log("done") # B018: 4, "Lambda" + lambda item, /, *rest, key=None, **kwargs: item # B018: 4, "Lambda" + if not debug_func: + lambda x: True # B018: 8, "Lambda" + return debug_func + + +def used(items, log): + consume = list(print(item) for item in items) + log(lambda: "done") + total = sum(x for x in items) + callback = lambda: log("done") # noqa: E731 + [print(item) for item in items] + return consume, total, callback + + +def expected_to_raise(raises): + # directly in a `try` or `with` block, a generator expression may be built + # only to see `iter()` raise on its first iterable + with raises(TypeError): + (x for x in 1) + try: + (x for x in None) + except TypeError: + pass diff --git a/tests/eval_files/b023.py b/tests/eval_files/b023.py index ba40293..6d755c2 100644 --- a/tests/eval_files/b023.py +++ b/tests/eval_files/b023.py @@ -59,7 +59,7 @@ async def container_for_problems(): # Nested loops should not duplicate reports for j in range(2): for k in range(3): - lambda: j * k # error # B023: 16, "j" # B023: 20, "k" + lambda: j * k # error # B018: 8, "Lambda" # B023: 16, "j" # B023: 20, "k" for j, k, l in [(1, 2, 3)]: From d97dad003df175cae54af1c18a76bc1176611bcf Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Wed, 21 Oct 2026 22:09:49 -0300 Subject: [PATCH 2/2] B018: don't report generator expressions and lambdas that call right away A generator expression evaluates its first iterable as soon as it's created, and a lambda its defaults, so `(x for x in refresh())` still calls `refresh()` and `lambda arg=record(): None` still calls `record()`. Check those parts the same way operands of an operation are checked, and leave a lambda with defaults directly in a `try` or `with` block alone, since a default may be there only to see it raise. --- README.rst | 10 ++++--- bugbear.py | 31 +++++++++++++++++---- tests/eval_files/b018_generators_lambdas.py | 26 +++++++++++++++-- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 6bdd663..4408664 100644 --- a/README.rst +++ b/README.rst @@ -176,10 +176,12 @@ using ``pytest.raises``), or use the context manager form with a target **B018**: Found useless expression. Either assign it to a variable or remove it. The check also considers function calls without side-effects such as ``isinstance``, unary and arithmetic operations such as ``-x`` or ``a * b``, generator expressions -(which are lazy, so their body never runs) and lambdas (which nothing can call). -Shifts, bitwise operators and ``@`` are left alone, since libraries overload them for -their side effects, and so is an operation or a generator expression directly in a -``try`` or ``with`` block, where it may be run only to see it raise. +(which are lazy, so their body never runs) and lambdas (which nothing can call), as +long as what they evaluate right away doesn't call a function, like +``(x for x in f())`` or ``lambda x=f(): x``. Shifts, bitwise operators and ``@`` are +left alone, since libraries overload them for their side effects, and so is an +operation, a generator expression or a lambda with defaults directly in a ``try`` or +``with`` block, where it may be run only to see it raise. Note that dangling commas will cause things to be interpreted as useless tuples. For example, in the statement ``print(".."),`` is the same as ``(print(".."),)`` which is an unassigned tuple. Simply remove the comma to clear the error. diff --git a/bugbear.py b/bugbear.py index e9ca3b7..c371f67 100644 --- a/bugbear.py +++ b/bugbear.py @@ -2404,20 +2404,33 @@ def check_for_b018(self, node: ast.AST) -> None: and node.value.func.id in FUNCTIONS_WITHOUT_SIDE_EFFECTS ) # a lambda that is neither assigned nor passed anywhere is never called - or isinstance(node.value, ast.Lambda) + or (isinstance(node.value, ast.Lambda) and not _lambda_defaults(node.value)) or ( ( ( isinstance(node.value, (ast.UnaryOp, ast.BinOp)) and _is_useless_operation(node.value) ) - # a generator expression is lazy, so its body never runs - or isinstance(node.value, ast.GeneratorExp) + # a generator expression is lazy, so its body never runs, but + # its first iterable is evaluated right away: `(x for x in f())` + # still calls `f` + or ( + isinstance(node.value, ast.GeneratorExp) + and _is_useless_operation(node.value.generators[0].iter) + ) + # and so are the defaults of a lambda: `lambda x=f(): x` + or ( + isinstance(node.value, ast.Lambda) + and all( + map(_is_useless_operation, _lambda_defaults(node.value)) + ) + ) ) # directly in a `try` or `with` block, an operation is often run # only to see it raise: `with pytest.raises(TypeError): "1" + 1`. - # A generator expression calls `iter()` on its first iterable - # right away, so the same goes for `(x for x in 1)` + # The same goes for the first iterable of a generator expression, + # which `iter()` is called on, and the defaults of a lambda: + # `(x for x in 1)` or `lambda x=1 / 0: x` and not ( self.node_stack and isinstance(self.node_stack[-1], B018_RAISING_BLOCKS) @@ -3237,7 +3250,8 @@ def _is_useless_operation(node: ast.expr) -> bool: """Whether a unary or arithmetic operation, used as a statement, does nothing. Every binary operator in it has to be arithmetic, and no operand may call, - await, yield or assign: `f() + 1` still calls `f`. + await, yield or assign: `f() + 1` still calls `f`. This also checks what a + generator expression or a lambda evaluates right away. """ for child in ast.walk(node): if isinstance(child, ast.BinOp) and not isinstance( @@ -3251,6 +3265,11 @@ def _is_useless_operation(node: ast.expr) -> bool: return True +def _lambda_defaults(node: ast.Lambda) -> list[ast.expr]: + """The defaults of a lambda, which are evaluated when it is created.""" + return [d for d in node.args.defaults + node.args.kw_defaults if d is not None] + + def _dotted_name(node: ast.AST) -> str | None: """Return `"self.a.b"` for an attribute chain rooted in a name, else None.""" parts = [] diff --git a/tests/eval_files/b018_generators_lambdas.py b/tests/eval_files/b018_generators_lambdas.py index d2bd937..96b2b7d 100644 --- a/tests/eval_files/b018_generators_lambdas.py +++ b/tests/eval_files/b018_generators_lambdas.py @@ -1,6 +1,6 @@ """ Should emit: -B018 - on lines 11-17 +B018 - on lines 11-19 and 58 """ @@ -11,8 +11,10 @@ def discarded(items, log, debug_func): (print(item) for item in items) # B018: 4, "GeneratorExp" (item.close() for item in items if item) # B018: 4, "GeneratorExp" (x for x in items) # B018: 4, "GeneratorExp" + (y for x in items for y in log(x)) # B018: 4, "GeneratorExp" lambda: log("done") # B018: 4, "Lambda" lambda item, /, *rest, key=None, **kwargs: item # B018: 4, "Lambda" + lambda item=-1, *, key=len: item # B018: 4, "Lambda" if not debug_func: lambda x: True # B018: 8, "Lambda" return debug_func @@ -27,12 +29,30 @@ def used(items, log): return consume, total, callback +def evaluated_right_away(record, refresh): + # the first iterable of a generator expression and the defaults of a lambda + # are evaluated right away, so a call in them still runs + (x for x in refresh()) + lambda arg=record(): None + lambda *, arg=record(): None + + +async def awaited_right_away(refresh, pending): + (x for x in await refresh()) + lambda arg=await pending: arg + + def expected_to_raise(raises): - # directly in a `try` or `with` block, a generator expression may be built - # only to see `iter()` raise on its first iterable + # directly in a `try` or `with` block, a generator expression or a lambda + # with defaults may be built only to see its first iterable or a default raise with raises(TypeError): (x for x in 1) try: (x for x in None) except TypeError: pass + with raises(ZeroDivisionError): + lambda arg=1 / 0: arg + # a lambda without defaults evaluates nothing, so it can't raise + with raises(ZeroDivisionError): + lambda: 1 / 0 # B018: 8, "Lambda"