Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ Semantic versioning in our case means:
change the client facing API, change code conventions significantly, etc.


## WIP

### Features
Comment thread
vyhuholl marked this conversation as resolved.

- Adds `WPS484`: forbid strings that look like docstrings
but document nothing, #3808


## 1.8.1

### Bugfixes
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/noqa/noqa.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,3 +789,6 @@ async def test_await_in_loop():

if not user in users: # noqa: WPS364
my_print('legacy not-in style')

some_sequence.first = 1
'Documents nothing.' # noqa: WPS484
1 change: 1 addition & 0 deletions tests/test_checker/test_noqa.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@
'WPS481': 10,
'WPS482': 0, # enabled only in python 3.15+
'WPS483': 0, # enabled only in python 3.15+
'WPS484': 1,
'WPS500': 1,
'WPS501': 1,
'WPS502': 0, # disabled since 1.0.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@ def fourth():
{0}
"""

# Only a constructor defines instance attributes, other methods just
# assign to an object that is already made, there's nothing to document.
not_a_constructor_docstring = """
class Some:
def first(self):
self.field = 1
{0}

def second(self):
self.field = 2
{0}

def third(self):
self.field = 3
{0}

def fourth(self):
self.field = 4
{0}
"""

# `x.some = 1` defines an attribute of `x`, not of the module, the class,
# or the instance we are in. So, there's nothing here to document.
foreign_attribute_docstrings = """
Expand Down Expand Up @@ -502,6 +523,7 @@ def test_docstrings_not_counted(
[
not_a_docstring,
not_an_attribute_docstring,
not_a_constructor_docstring,
foreign_attribute_docstrings,
],
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import pytest

from wemake_python_styleguide.compat.constants import PY312
from wemake_python_styleguide.violations.best_practices import (
WrongDocStringPlacementViolation,
)
from wemake_python_styleguide.visitors.ast.statements import (
DocStringPlacementVisitor,
)

module_docstring = '{0}'

function_docstring = """
def some():
{0}
"""

class_docstring = """
class Some:
{0}
"""

module_attribute = """
first = 1
{0}
"""

annotated_module_attribute = """
first: int = 1
{0}
"""

class_attribute = """
class Some:
'Class docs.'

first = 1
{0}
"""

dataclass_attribute = """
@dataclass
class Some:
'Class docs.'

first: int
{0}
"""

dataclass_attribute_with_default = """
@dataclass
class Some:
'Class docs.'

first: int = 0
{0}
"""

instance_attribute = """
class Some:
def __init__(self):
'Method docs.'
self.first = 1
{0}
"""

instance_attribute_in_new = """
class Some:
def __new__(cls):
'Method docs.'
cls.first = 1
{0}
"""

type_alias = pytest.param(
"""
type Some = int
{0}
""",
marks=pytest.mark.skipif(
not PY312,
reason='`type` aliases are only in Python 3.12+',
),
)

foreign_attribute = """
some.first = 1
{0}
"""

# A docstring goes after the attribute it documents, never before it.
before_module_attribute = """
'Module docs.'

{0}
first = 1
"""

before_class_attribute = """
class Some:
'Class docs.'

{0}
first: int
"""

between_attributes = """
class Some:
'Class docs.'

first = 1
'Documents first.'

{0}
second = 2
"""

instance_attribute_outside_constructor = """
class Some:
def method(self):
'Method docs.'
self.first = 1
{0}
"""

local_variable = """
def some():
'Function docs.'
first = 1
{0}
"""

multiple_targets = """
first = second = 1
{0}
"""

inside_condition = """
def some(arg):
'Function docs.'
if arg:
{0}
return 1
return 0
"""

after_call = """
def some():
'Function docs.'
print(1)
{0}
"""

after_doc_string = """
def some():
'Function docs.'
{0}
"""


@pytest.mark.parametrize(
'code',
[
module_docstring,
function_docstring,
class_docstring,
module_attribute,
annotated_module_attribute,
class_attribute,
dataclass_attribute,
dataclass_attribute_with_default,
instance_attribute,
instance_attribute_in_new,
type_alias,
],
)
@pytest.mark.parametrize(
'string_value',
[
'"Doc"',
"'Doc'",
'"""Doc"""',
"'''Doc'''",
],
)
def test_documenting_string(
assert_errors,
parse_ast_tree,
default_options,
code,
string_value,
):
"""Testing that strings documenting something are allowed."""
tree = parse_ast_tree(code.format(string_value))

visitor = DocStringPlacementVisitor(default_options, tree=tree)
visitor.run()

assert_errors(visitor, [])


@pytest.mark.parametrize(
'code',
[
foreign_attribute,
before_module_attribute,
before_class_attribute,
between_attributes,
instance_attribute_outside_constructor,
local_variable,
multiple_targets,
inside_condition,
after_call,
after_doc_string,
],
)
@pytest.mark.parametrize(
'string_value',
[
'"Doc"',
"'Doc'",
'"""Doc"""',
"'''Doc'''",
],
)
def test_string_documenting_nothing(
assert_errors,
parse_ast_tree,
default_options,
code,
string_value,
):
"""Testing that strings documenting nothing are forbidden."""
tree = parse_ast_tree(code.format(string_value))

visitor = DocStringPlacementVisitor(default_options, tree=tree)
visitor.run()

assert_errors(visitor, [WrongDocStringPlacementViolation])
14 changes: 11 additions & 3 deletions wemake_python_styleguide/logic/tree/strings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ast
import itertools
from typing import Final

from wemake_python_styleguide.compat import nodes
from wemake_python_styleguide.compat.aliases import AssignNodes, FunctionNodes
Expand All @@ -9,6 +10,9 @@
from wemake_python_styleguide.logic.walk import get_closest_parent
from wemake_python_styleguide.types import ContextNodes

#: Methods that define the instance attributes a docstring can document.
_ConstructorMethods: Final = frozenset(('__init__', '__new__'))


def is_doc_string(node: ast.AST) -> bool:
"""
Expand Down Expand Up @@ -66,9 +70,13 @@ def _is_documented(statement: ast.stmt, context: ContextNodes) -> bool:
return False
target = targets[0]
if isinstance(context, FunctionNodes):
# Locals are not attributes. Only `self.some = 1` defines one,
# while `x.some = 1` documents an attribute of some other object.
return isinstance(target, ast.Attribute) and is_special_attr(target)
# Only a constructor defines the instance attributes. Locals are
# not attributes, and `x.some = 1` belongs to some other object.
return (
context.name in _ConstructorMethods
and isinstance(target, ast.Attribute)
and is_special_attr(target)
)
# Modules and classes define their attributes by plain names,
# `x.some = 1` here belongs to `x`, not to this module or class.
return isinstance(target, ast.Name)
Expand Down
1 change: 1 addition & 0 deletions wemake_python_styleguide/presets/types/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
statements.WrongNamedKeywordVisitor,
statements.AssignmentPatternsVisitor,
statements.WrongMethodArgumentsVisitor,
statements.DocStringPlacementVisitor,
keywords.WrongRaiseVisitor,
keywords.WrongKeywordVisitor,
keywords.WrongContextManagerVisitor,
Expand Down
48 changes: 48 additions & 0 deletions wemake_python_styleguide/violations/best_practices.py
Original file line number Diff line number Diff line change
Expand Up @@ -3117,3 +3117,51 @@ class ForbidMappingProxyTypeViolation(ASTViolation):
'Found a `types.MappingProxyType` usage, prefer `frozendict` on 3.15+'
)
code = 483


@final
class WrongDocStringPlacementViolation(ASTViolation):
"""
Comment thread
vyhuholl marked this conversation as resolved.
Forbid strings that look like docstrings, but document nothing.

A string statement documents something only when it is placed:

1. as the first statement of a module, class, or function body
2. after an assignment to a plain name in a module or a class
3. after an assignment to ``self`` inside a constructor
4. after a ``type`` alias on ``python3.12+``

Anywhere else it does nothing at runtime,
while still reading like documentation.

Reasoning:
Such strings are dead code that looks alive.
A reader takes them for documentation of the line above,
while no tool will ever render them,
and the code they seem to describe can change without notice.

Solution:
Move the string to a place where it documents something,
or turn it into a regular ``#`` comment.
A docstring goes after the attribute it documents, never before it.

Example::

# Correct:
first = 1
'''Documents ``first``.'''

# Wrong:
'''Documents nothing, a docstring goes after the attribute.'''
first = 1

# Wrong:
some.first = 1
'''Documents nothing, ``first`` belongs to ``some``.'''

.. versionadded:: 1.9.0

"""

error_template = 'Found a string that documents nothing'
code = 484
Loading