Repository: PyCQA/flake8-bugbear

Issue:
B018 complains about f-strings as docstrings... and it's right!  (but a more specific message would be helpful)
```python
CONSTANT = "magic value"

def f():
    f"""Return {CONSTANT!r}."""  # warning B018
    return CONSTANT

assert f.__doc__ is not None  # fails!
```

I was originally reporting this as a bug, but it turns out that f-strings don't work as docstrings!  IMO this deserves to be split out into a new B019 check with a more specific message.

Comments:

Patch:
diff --git a/README.rst b/README.rst
index 09e87df..7994a6c 100644
--- a/README.rst
+++ b/README.rst
@@ -134,6 +134,9 @@ data available in ``ex``.
 
 **B020**: Loop control variable overrides iterable it iterates
 
+**B021**: f-string used as docstring. This will be interpreted by python
+as a joined string rather than a docstring.
+
 
 Opinionated warnings
 ~~~~~~~~~~~~~~~~~~~~
diff --git a/bugbear.py b/bugbear.py
index 5e7ea93..d71a44b 100644
--- a/bugbear.py
+++ b/bugbear.py
@@ -350,11 +350,13 @@ class BugBearVisitor(ast.NodeVisitor):
         self.check_for_b902(node)
         self.check_for_b006(node)
         self.check_for_b018(node)
+        self.check_for_b021(node)
         self.generic_visit(node)
 
     def visit_ClassDef(self, node):
         self.check_for_b903(node)
         self.check_for_b018(node)
+        self.check_for_b021(node)
         self.generic_visit(node)
 
     def visit_Try(self, node):
@@ -685,6 +687,16 @@ class BugBearVisitor(ast.NodeVisitor):
             ):
                 self.errors.append(B018(subnode.lineno, subnode.col_offset))
 
+    def check_for_b021(self, node):
+        if (
+            node.body
+            and isinstance(node.body[0], ast.Expr)
+            and isinstance(node.body[0].value, ast.JoinedStr)
+        ):
+            self.errors.append(
+                B021(node.body[0].value.lineno, node.body[0].value.col_offset)
+            )
+
 
 @attr.s
 class NameFinder(ast.NodeVisitor):
@@ -892,6 +904,12 @@ B020 = Error(
         + "with each iterable value."
     )
 )
+B021 = Error(
+    message=(
+        "B021 f-string used as docstring."
+        "This will be interpreted by python as a joined string rather than a docstring."
+    )
+)
 
 # Warnings disabled by default.
 B901 = Error(


Test patch:
diff --git a/tests/b021.py b/tests/b021.py
new file mode 100644
index 0000000..dd0bb63
--- /dev/null
+++ b/tests/b021.py
@@ -0,0 +1,76 @@
+"""
+Should emit:
+B021 - on lines 14, 22, 30, 38, 46, 54, 62, 70, 73
+"""
+
+VARIABLE = "world"
+
+
+def foo1():
+    """hello world!"""
+
+
+def foo2():
+    f"""hello {VARIABLE}!"""
+
+
+class bar1:
+    """hello world!"""
+
+
+class bar2:
+    f"""hello {VARIABLE}!"""
+
+
+def foo1():
+    """hello world!"""
+
+
+def foo2():
+    f"""hello {VARIABLE}!"""
+
+
+class bar1:
+    """hello world!"""
+
+
+class bar2:
+    f"""hello {VARIABLE}!"""
+
+
+def foo1():
+    "hello world!"
+
+
+def foo2():
+    f"hello {VARIABLE}!"
+
+
+class bar1:
+    "hello world!"
+
+
+class bar2:
+    f"hello {VARIABLE}!"
+
+
+def foo1():
+    "hello world!"
+
+
+def foo2():
+    f"hello {VARIABLE}!"
+
+
+class bar1:
+    "hello world!"
+
+
+class bar2:
+    f"hello {VARIABLE}!"
+
+
+def baz():
+    f"""I'm probably a docstring: {VARIABLE}!"""
+    print(f"""I'm a normal string""")
+    f"""Don't detect me!"""
diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py
index 7e25386..05f9cbf 100644
--- a/tests/test_bugbear.py
+++ b/tests/test_bugbear.py
@@ -30,6 +30,7 @@ from bugbear import (
     B017,
     B018,
     B020,
+    B021,
     B901,
     B902,
     B903,
@@ -262,6 +263,23 @@ class BugbearTestCase(unittest.TestCase):
             ),
         )
 
+    def test_b021_classes(self):
+        filename = Path(__file__).absolute().parent / "b021.py"
+        bbc = BugBearChecker(filename=str(filename))
+        errors = list(bbc.run())
+        expected = self.errors(
+            B021(14, 4),
+            B021(22, 4),
+            B021(30, 4),
+            B021(38, 4),
+            B021(46, 4),
+            B021(54, 4),
+            B021(62, 4),
+            B021(70, 4),
+            B021(74, 4),
+        )
+        self.assertEqual(errors, expected)
+
     def test_b901(self):
         filename = Path(__file__).absolute().parent / "b901.py"
         bbc = BugBearChecker(filename=str(filename))