Repository: PyCQA/flake8-bugbear

Issue:
Require warnings.warn stacklevel argument as 2+
Calling [`warnings.warn()`](https://docs.python.org/3/library/warnings.html#warnings.warn) without a `stacklevel`, or a `stacklevel` value of <2, is nearly always a mistake. Doing so flags the warning inside the called function, rather than the caller, making it hard to fix the warning.

flake8-bugbear could detect such invocations of `warnings.warn()`.

This issue inspired by @asottile's video of the day: https://www.youtube.com/watch?v=CtFdXBEwYfk . This is also something I've seen recur as a review point in Django PR's.

Comments:

Patch:
diff --git a/README.rst b/README.rst
index fe72e3c..832cbd9 100644
--- a/README.rst
+++ b/README.rst
@@ -173,6 +173,10 @@ limitations make it difficult.
 
 **B027**: Empty method in abstract base class, but has no abstract decorator. Consider adding @abstractmethod.
 
+**BO28**: No explicit stacklevel keyword argument found. The warn method from the warnings module uses a
+stacklevel of 1 by default. This will only show a stack trace for the line on which the warn method is called.
+It is therefore recommended to use a stacklevel of 2 or greater to provide more information to the user.
+
 Opinionated warnings
 ~~~~~~~~~~~~~~~~~~~~
 
@@ -320,6 +324,7 @@ Future
   ``ast.Str`` nodes are all deprecated, but may still be used by some codebases in
   order to maintain backwards compatibility with Python 3.7.
 * B016: Warn when raising f-strings.
+* Add B028: Check for an explicit stacklevel keyword argument on the warn method from the warnings module.
 
 23.1.20
 ~~~~~~~~~
diff --git a/bugbear.py b/bugbear.py
index a6b883b..edd4faf 100644
--- a/bugbear.py
+++ b/bugbear.py
@@ -360,6 +360,7 @@ class BugBearVisitor(ast.NodeVisitor):
             self.check_for_b026(node)
 
         self.check_for_b905(node)
+        self.check_for_b028(node)
         self.generic_visit(node)
 
     def visit_Module(self, node):
@@ -1146,6 +1147,16 @@ class BugBearVisitor(ast.NodeVisitor):
             # if no pre-mark or variable detected, reset state
             current_mark = variable = None
 
+    def check_for_b028(self, node):
+        if (
+            isinstance(node.func, ast.Attribute)
+            and node.func.attr == "warn"
+            and isinstance(node.func.value, ast.Name)
+            and node.func.value.id == "warnings"
+            and not any(kw.arg == "stacklevel" for kw in node.keywords)
+        ):
+            self.errors.append(B028(node.lineno, node.col_offset))
+
 
 def compose_call_path(node):
     if isinstance(node, ast.Attribute):
@@ -1510,6 +1521,15 @@ B027 = Error(
         " decorator. Consider adding @abstractmethod."
     )
 )
+B028 = Error(
+    message=(
+        "B028 No explicit stacklevel keyword argument found. The warn method from the"
+        " warnings module uses a stacklevel of 1 by default. This will only show a"
+        " stack trace for the line on which the warn method is called."
+        " It is therefore recommended to use a stacklevel of 2 or"
+        " greater to provide more information to the user."
+    )
+)
 
 # Warnings disabled by default.
 B901 = Error(


Test patch:
diff --git a/tests/b028.py b/tests/b028.py
new file mode 100644
index 0000000..a2915f2
--- /dev/null
+++ b/tests/b028.py
@@ -0,0 +1,11 @@
+import warnings
+
+"""
+Should emit:
+B028 - on lines 8 and 9
+"""
+
+warnings.warn(DeprecationWarning("test"))
+warnings.warn(DeprecationWarning("test"), source=None)
+warnings.warn(DeprecationWarning("test"), source=None, stacklevel=2)
+warnings.warn(DeprecationWarning("test"), stacklevel=1)
diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py
index 0b91a06..0f5f283 100644
--- a/tests/test_bugbear.py
+++ b/tests/test_bugbear.py
@@ -39,6 +39,7 @@ from bugbear import (
     B025,
     B026,
     B027,
+    B028,
     B901,
     B902,
     B903,
@@ -430,6 +431,13 @@ class BugbearTestCase(unittest.TestCase):
         )
         self.assertEqual(errors, expected)
 
+    def test_b028(self):
+        filename = Path(__file__).absolute().parent / "b028.py"
+        bbc = BugBearChecker(filename=str(filename))
+        errors = list(bbc.run())
+        expected = self.errors(B028(8, 0), B028(9, 0))
+        self.assertEqual(errors, expected)
+
     @unittest.skipIf(sys.version_info < (3, 8), "not implemented for <3.8")
     def test_b907(self):
         filename = Path(__file__).absolute().parent / "b907.py"