Skip to content

Commit 188eab8

Browse files
authored
Merge pull request #476 from jakkdl/contextvar_mutable_or_call_default
Contextvar mutable or call default
2 parents 18abb9f + 6fee565 commit 188eab8

File tree

6 files changed

+107
-16
lines changed

6 files changed

+107
-16
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
runs-on: ${{ matrix.os }}
1010
strategy:
1111
matrix:
12-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
12+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev"]
1313

1414
os: [ubuntu-latest]
1515

README.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ second usage. Save the result to a list if the result is needed multiple times.
201201

202202
**B038**: **Moved to B909** - Found a mutation of a mutable loop iterable inside the loop body. Changes to the iterable of a loop such as calls to `list.remove()` or via `del` can cause unintended bugs.
203203

204+
**B039**: ``ContextVar`` with mutable literal or function call as default. This is only evaluated once, and all subsequent calls to `.get()` would return the same instance of the default. This uses the same logic as B006 and B008, including ignoring values in ``extend-immutable-calls``.
205+
204206
Opinionated warnings
205207
~~~~~~~~~~~~~~~~~~~~
206208

@@ -315,7 +317,7 @@ The plugin currently has the following settings:
315317
``extend-immutable-calls``: Specify a list of additional immutable calls.
316318
This could be useful, when using other libraries that provide more immutable calls,
317319
beside those already handled by ``flake8-bugbear``. Calls to these method will no longer
318-
raise a ``B008`` warning.
320+
raise a ``B008`` or ``B039`` warning.
319321

320322
``classmethod-decorators``: Specify a list of decorators to additionally mark a method as a ``classmethod`` as used by B902. The default only checks for ``classmethod``. When an ``@obj.name`` decorator is specified it will match against either ``name`` or ``obj.name``.
321323
This functions similarly to how `pep8-naming <https://github.com/PyCQA/pep8-naming>` handles it, but with different defaults, and they don't support specifying attributes such that a decorator will never match against a specified value ``obj.name`` even if decorated with ``@obj.name``.
@@ -351,6 +353,11 @@ MIT
351353
Change Log
352354
----------
353355

356+
FUTURE
357+
~~~~~~
358+
359+
* Add B039, ``ContextVar`` with mutable literal or function call as default.
360+
354361
24.4.26
355362
~~~~~~~
356363

bugbear.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ def run(self):
6363
self.load_file()
6464

6565
if self.options and hasattr(self.options, "extend_immutable_calls"):
66-
b008_extend_immutable_calls = set(self.options.extend_immutable_calls)
66+
b008_b039_extend_immutable_calls = set(self.options.extend_immutable_calls)
6767
else:
68-
b008_extend_immutable_calls = set()
68+
b008_b039_extend_immutable_calls = set()
6969

7070
b902_classmethod_decorators: set[str] = B902_default_decorators
7171
if self.options and hasattr(self.options, "classmethod_decorators"):
@@ -74,7 +74,7 @@ def run(self):
7474
visitor = self.visitor(
7575
filename=self.filename,
7676
lines=self.lines,
77-
b008_extend_immutable_calls=b008_extend_immutable_calls,
77+
b008_b039_extend_immutable_calls=b008_b039_extend_immutable_calls,
7878
b902_classmethod_decorators=b902_classmethod_decorators,
7979
)
8080
visitor.visit(self.tree)
@@ -360,7 +360,7 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler):
360360
class BugBearVisitor(ast.NodeVisitor):
361361
filename = attr.ib()
362362
lines = attr.ib()
363-
b008_extend_immutable_calls = attr.ib(default=attr.Factory(set))
363+
b008_b039_extend_immutable_calls = attr.ib(default=attr.Factory(set))
364364
b902_classmethod_decorators = attr.ib(default=attr.Factory(set))
365365
node_window = attr.ib(default=attr.Factory(list))
366366
errors = attr.ib(default=attr.Factory(list))
@@ -507,6 +507,7 @@ def visit_Call(self, node):
507507
self.check_for_b026(node)
508508
self.check_for_b028(node)
509509
self.check_for_b034(node)
510+
self.check_for_b039(node)
510511
self.check_for_b905(node)
511512
self.generic_visit(node)
512513

@@ -655,10 +656,36 @@ def check_for_b005(self, node):
655656
self.errors.append(B005(node.lineno, node.col_offset))
656657

657658
def check_for_b006_and_b008(self, node):
658-
visitor = FuntionDefDefaultsVisitor(self.b008_extend_immutable_calls)
659+
visitor = FunctionDefDefaultsVisitor(
660+
B006, B008, self.b008_b039_extend_immutable_calls
661+
)
659662
visitor.visit(node.args.defaults + node.args.kw_defaults)
660663
self.errors.extend(visitor.errors)
661664

665+
def check_for_b039(self, node: ast.Call):
666+
if not (
667+
(isinstance(node.func, ast.Name) and node.func.id == "ContextVar")
668+
or (
669+
isinstance(node.func, ast.Attribute)
670+
and node.func.attr == "ContextVar"
671+
and isinstance(node.func.value, ast.Name)
672+
and node.func.value.id == "contextvars"
673+
)
674+
):
675+
return
676+
# ContextVar only takes one kw currently, but better safe than sorry
677+
for kw in node.keywords:
678+
if kw.arg == "default":
679+
break
680+
else:
681+
return
682+
683+
visitor = FunctionDefDefaultsVisitor(
684+
B039, B039, self.b008_b039_extend_immutable_calls
685+
)
686+
visitor.visit(kw.value)
687+
self.errors.extend(visitor.errors)
688+
662689
def check_for_b007(self, node):
663690
targets = NameFinder()
664691
targets.visit(node.target)
@@ -1780,9 +1807,20 @@ def visit(self, node):
17801807
return node
17811808

17821809

1783-
class FuntionDefDefaultsVisitor(ast.NodeVisitor):
1784-
def __init__(self, b008_extend_immutable_calls=None):
1785-
self.b008_extend_immutable_calls = b008_extend_immutable_calls or set()
1810+
class FunctionDefDefaultsVisitor(ast.NodeVisitor):
1811+
"""Used by B006, B008, and B039. B039 is essentially B006+B008 but for ContextVar."""
1812+
1813+
def __init__(
1814+
self,
1815+
error_code_calls, # B006 or B039
1816+
error_code_literals, # B008 or B039
1817+
b008_b039_extend_immutable_calls=None,
1818+
):
1819+
self.b008_b039_extend_immutable_calls = (
1820+
b008_b039_extend_immutable_calls or set()
1821+
)
1822+
self.error_code_calls = error_code_calls
1823+
self.error_code_literals = error_code_literals
17861824
for node in B006.mutable_literals + B006.mutable_comprehensions:
17871825
setattr(self, f"visit_{node}", self.visit_mutable_literal_or_comprehension)
17881826
self.errors = []
@@ -1801,18 +1839,18 @@ def visit_mutable_literal_or_comprehension(self, node):
18011839
#
18021840
# We do still search for cases of B008 within mutable structures though.
18031841
if self.arg_depth == 1:
1804-
self.errors.append(B006(node.lineno, node.col_offset))
1842+
self.errors.append(self.error_code_calls(node.lineno, node.col_offset))
18051843
# Check for nested functions.
18061844
self.generic_visit(node)
18071845

18081846
def visit_Call(self, node):
18091847
call_path = ".".join(compose_call_path(node.func))
18101848
if call_path in B006.mutable_calls:
1811-
self.errors.append(B006(node.lineno, node.col_offset))
1849+
self.errors.append(self.error_code_calls(node.lineno, node.col_offset))
18121850
self.generic_visit(node)
18131851
return
18141852

1815-
if call_path in B008.immutable_calls | self.b008_extend_immutable_calls:
1853+
if call_path in B008.immutable_calls | self.b008_b039_extend_immutable_calls:
18161854
self.generic_visit(node)
18171855
return
18181856

@@ -1824,9 +1862,11 @@ def visit_Call(self, node):
18241862
pass
18251863
else:
18261864
if math.isfinite(value):
1827-
self.errors.append(B008(node.lineno, node.col_offset))
1865+
self.errors.append(
1866+
self.error_code_literals(node.lineno, node.col_offset)
1867+
)
18281868
else:
1829-
self.errors.append(B008(node.lineno, node.col_offset))
1869+
self.errors.append(self.error_code_literals(node.lineno, node.col_offset))
18301870

18311871
# Check for nested functions.
18321872
self.generic_visit(node)
@@ -1926,6 +1966,8 @@ def visit_Lambda(self, node):
19261966
"between them."
19271967
)
19281968
)
1969+
1970+
# Note: these are also used by B039
19291971
B006.mutable_literals = ("Dict", "List", "Set")
19301972
B006.mutable_comprehensions = ("ListComp", "DictComp", "SetComp")
19311973
B006.mutable_calls = {
@@ -1956,6 +1998,8 @@ def visit_Lambda(self, node):
19561998
"use that variable as a default value."
19571999
)
19582000
)
2001+
2002+
# Note: these are also used by B039
19592003
B008.immutable_calls = {
19602004
"tuple",
19612005
"frozenset",
@@ -2166,6 +2210,14 @@ def visit_Lambda(self, node):
21662210
message="B037 Class `__init__` methods must not return or yield and any values."
21672211
)
21682212

2213+
B039 = Error(
2214+
message=(
2215+
"B039 ContextVar with mutable literal or function call as default. "
2216+
"This is only evaluated once, and all subsequent calls to `.get()` "
2217+
"will return the same instance of the default."
2218+
)
2219+
)
2220+
21692221
# Warnings disabled by default.
21702222
B901 = Error(
21712223
message=(

tests/b039.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import contextvars
2+
import time
3+
from contextvars import ContextVar
4+
5+
ContextVar("cv", default=[]) # bad
6+
ContextVar("cv", default=list()) # bad
7+
ContextVar("cv", default=set()) # bad
8+
ContextVar("cv", default=time.time()) # bad (B008-like)
9+
contextvars.ContextVar("cv", default=[]) # bad
10+
11+
12+
# good
13+
ContextVar("cv", default=())
14+
contextvars.ContextVar("cv", default=())
15+
ContextVar("cv", default=tuple())
16+
17+
# see tests/b006_b008.py for more comprehensive tests

tests/test_bugbear.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
B035,
4747
B036,
4848
B037,
49+
B039,
4950
B901,
5051
B902,
5152
B903,
@@ -636,6 +637,19 @@ def test_b037(self) -> None:
636637
)
637638
self.assertEqual(errors, expected)
638639

640+
def test_b039(self) -> None:
641+
filename = Path(__file__).absolute().parent / "b039.py"
642+
bbc = BugBearChecker(filename=str(filename))
643+
errors = list(bbc.run())
644+
expected = self.errors(
645+
B039(5, 25),
646+
B039(6, 25),
647+
B039(7, 25),
648+
B039(8, 25),
649+
B039(9, 37),
650+
)
651+
self.assertEqual(errors, expected)
652+
639653
def test_b908(self):
640654
filename = Path(__file__).absolute().parent / "b908.py"
641655
bbc = BugBearChecker(filename=str(filename))

tox.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# The test environment and commands
22
[tox]
33
# default environments to run without `-e`
4-
envlist = py38, py39, py310, py311, py312, pep8_naming
4+
envlist = py38, py39, py310, py311, py312, py313, pep8_naming
55

66
[gh-actions]
77
python =
@@ -10,6 +10,7 @@ python =
1010
3.10: py310
1111
3.11: py311,pep8_naming
1212
3.12: py312
13+
3.13-dev: py313
1314

1415
[testenv]
1516
description = Run coverage

0 commit comments

Comments
 (0)