Skip to content

Commit f9e0f77

Browse files
authored
B006 and B008: Cover additional test cases (#239)
* B006 and B008: Cover additional test cases * Add change log entry * Account for inconsistent ast between python versions * Use ast.literal_eval to simplify infinity float detection
1 parent ea0bd48 commit f9e0f77

File tree

5 files changed

+193
-100
lines changed

5 files changed

+193
-100
lines changed

Diff for: .pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ repos:
88
- id: isort
99

1010
- repo: https://github.com/psf/black
11-
rev: 21.10b0
11+
rev: 22.1.0
1212
hooks:
1313
- id: black
1414
args:

Diff for: README.rst

+5
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ MIT
279279
Change Log
280280
----------
281281

282+
<release-tbd>
283+
~~~~~~~~~~
284+
285+
* B006 and B008: Detect function calls at any level of the default expression.
286+
282287
22.3.20
283288
~~~~~~~~~~
284289

Diff for: bugbear.py

+84-59
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
import builtins
33
import itertools
44
import logging
5+
import math
56
import re
6-
import sys
77
from collections import namedtuple
88
from contextlib import suppress
99
from functools import lru_cache, partial
@@ -354,13 +354,13 @@ def visit_Assert(self, node):
354354

355355
def visit_AsyncFunctionDef(self, node):
356356
self.check_for_b902(node)
357-
self.check_for_b006(node)
357+
self.check_for_b006_and_b008(node)
358358
self.generic_visit(node)
359359

360360
def visit_FunctionDef(self, node):
361361
self.check_for_b901(node)
362362
self.check_for_b902(node)
363-
self.check_for_b006(node)
363+
self.check_for_b006_and_b008(node)
364364
self.check_for_b018(node)
365365
self.check_for_b019(node)
366366
self.check_for_b021(node)
@@ -390,23 +390,14 @@ def visit_With(self, node):
390390
self.check_for_b022(node)
391391
self.generic_visit(node)
392392

393-
def compose_call_path(self, node):
394-
if isinstance(node, ast.Attribute):
395-
yield from self.compose_call_path(node.value)
396-
yield node.attr
397-
elif isinstance(node, ast.Call):
398-
yield from self.compose_call_path(node.func)
399-
elif isinstance(node, ast.Name):
400-
yield node.id
401-
402393
def check_for_b005(self, node):
403394
if node.func.attr not in B005.methods:
404395
return # method name doesn't match
405396

406397
if len(node.args) != 1 or not isinstance(node.args[0], ast.Str):
407398
return # used arguments don't match the builtin strip
408399

409-
call_path = ".".join(self.compose_call_path(node.func.value))
400+
call_path = ".".join(compose_call_path(node.func.value))
410401
if call_path in B005.valid_paths:
411402
return # path is exempt
412403

@@ -419,48 +410,10 @@ def check_for_b005(self, node):
419410

420411
self.errors.append(B005(node.lineno, node.col_offset))
421412

422-
def check_for_b006(self, node):
423-
for default in node.args.defaults + node.args.kw_defaults:
424-
if isinstance(
425-
default, (*B006.mutable_literals, *B006.mutable_comprehensions)
426-
):
427-
self.errors.append(B006(default.lineno, default.col_offset))
428-
elif isinstance(default, ast.Call):
429-
call_path = ".".join(self.compose_call_path(default.func))
430-
if call_path in B006.mutable_calls:
431-
self.errors.append(B006(default.lineno, default.col_offset))
432-
elif (
433-
call_path
434-
not in B008.immutable_calls | self.b008_extend_immutable_calls
435-
):
436-
# Check if function call is actually a float infinity/NaN literal
437-
if call_path == "float" and len(default.args) == 1:
438-
float_arg = default.args[0]
439-
if sys.version_info < (3, 8, 0):
440-
# NOTE: pre-3.8, string literals are represented with ast.Str
441-
if isinstance(float_arg, ast.Str):
442-
str_val = float_arg.s
443-
else:
444-
str_val = ""
445-
else:
446-
# NOTE: post-3.8, string literals are represented with ast.Constant
447-
if isinstance(float_arg, ast.Constant):
448-
str_val = float_arg.value
449-
if not isinstance(str_val, str):
450-
str_val = ""
451-
else:
452-
str_val = ""
453-
454-
# NOTE: regex derived from documentation at:
455-
# https://docs.python.org/3/library/functions.html#float
456-
inf_nan_regex = r"^[+-]?(inf|infinity|nan)$"
457-
re_result = re.search(inf_nan_regex, str_val.lower())
458-
is_float_literal = re_result is not None
459-
else:
460-
is_float_literal = False
461-
462-
if not is_float_literal:
463-
self.errors.append(B008(default.lineno, default.col_offset))
413+
def check_for_b006_and_b008(self, node):
414+
visitor = FuntionDefDefaultsVisitor(self.b008_extend_immutable_calls)
415+
visitor.visit(node.args.defaults + node.args.kw_defaults)
416+
self.errors.extend(visitor.errors)
464417

465418
def check_for_b007(self, node):
466419
targets = NameFinder()
@@ -536,8 +489,7 @@ def check_for_b019(self, node):
536489
# Preserve decorator order so we can get the lineno from the decorator node
537490
# rather than the function node (this location definition changes in Python 3.8)
538491
resolved_decorators = (
539-
".".join(self.compose_call_path(decorator))
540-
for decorator in node.decorator_list
492+
".".join(compose_call_path(decorator)) for decorator in node.decorator_list
541493
)
542494
for idx, decorator in enumerate(resolved_decorators):
543495
if decorator in {"classmethod", "staticmethod"}:
@@ -755,6 +707,16 @@ def check_for_b022(self, node):
755707
self.errors.append(B022(node.lineno, node.col_offset))
756708

757709

710+
def compose_call_path(node):
711+
if isinstance(node, ast.Attribute):
712+
yield from compose_call_path(node.value)
713+
yield node.attr
714+
elif isinstance(node, ast.Call):
715+
yield from compose_call_path(node.func)
716+
elif isinstance(node, ast.Name):
717+
yield node.id
718+
719+
758720
@attr.s
759721
class NameFinder(ast.NodeVisitor):
760722
"""Finds a name within a tree of nodes.
@@ -778,6 +740,69 @@ def visit(self, node):
778740
return node
779741

780742

743+
class FuntionDefDefaultsVisitor(ast.NodeVisitor):
744+
def __init__(self, b008_extend_immutable_calls=None):
745+
self.b008_extend_immutable_calls = b008_extend_immutable_calls or set()
746+
for node in B006.mutable_literals + B006.mutable_comprehensions:
747+
setattr(self, f"visit_{node}", self.visit_mutable_literal_or_comprehension)
748+
self.errors = []
749+
self.arg_depth = 0
750+
super().__init__()
751+
752+
def visit_mutable_literal_or_comprehension(self, node):
753+
# Flag B006 iff mutable literal/comprehension is not nested.
754+
# We only flag these at the top level of the expression as we
755+
# cannot easily guarantee that nested mutable structures are not
756+
# made immutable by outer operations, so we prefer no false positives.
757+
# e.g.
758+
# >>> def this_is_fine(a=frozenset({"a", "b", "c"})): ...
759+
#
760+
# >>> def this_is_not_fine_but_hard_to_detect(a=(lambda x: x)([1, 2, 3]))
761+
#
762+
# We do still search for cases of B008 within mutable structures though.
763+
if self.arg_depth == 1:
764+
self.errors.append(B006(node.lineno, node.col_offset))
765+
# Check for nested functions.
766+
self.generic_visit(node)
767+
768+
def visit_Call(self, node):
769+
call_path = ".".join(compose_call_path(node.func))
770+
if call_path in B006.mutable_calls:
771+
self.errors.append(B006(node.lineno, node.col_offset))
772+
self.generic_visit(node)
773+
return
774+
775+
if call_path in B008.immutable_calls | self.b008_extend_immutable_calls:
776+
self.generic_visit(node)
777+
return
778+
779+
# Check if function call is actually a float infinity/NaN literal
780+
if call_path == "float" and len(node.args) == 1:
781+
try:
782+
value = float(ast.literal_eval(node.args[0]))
783+
except Exception:
784+
pass
785+
else:
786+
if math.isfinite(value):
787+
self.errors.append(B008(node.lineno, node.col_offset))
788+
else:
789+
self.errors.append(B008(node.lineno, node.col_offset))
790+
791+
# Check for nested functions.
792+
self.generic_visit(node)
793+
794+
def visit(self, node):
795+
"""Like super-visit but supports iteration over lists."""
796+
self.arg_depth += 1
797+
if isinstance(node, list):
798+
for elem in node:
799+
if elem is not None:
800+
super().visit(elem)
801+
else:
802+
super().visit(node)
803+
self.arg_depth -= 1
804+
805+
781806
class B020NameFinder(NameFinder):
782807
"""Ignore names defined within the local scope of a comprehension."""
783808

@@ -851,8 +876,8 @@ def visit_comprehension(self, node):
851876
"between them."
852877
)
853878
)
854-
B006.mutable_literals = (ast.Dict, ast.List, ast.Set)
855-
B006.mutable_comprehensions = (ast.ListComp, ast.DictComp, ast.SetComp)
879+
B006.mutable_literals = ("Dict", "List", "Set")
880+
B006.mutable_comprehensions = ("ListComp", "DictComp", "SetComp")
856881
B006.mutable_calls = {
857882
"Counter",
858883
"OrderedDict",

0 commit comments

Comments
 (0)