placard 2026.5.29.63299.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- placard-2026.5.29.63299.dev1/PKG-INFO +40 -0
- placard-2026.5.29.63299.dev1/README.rst +4 -0
- placard-2026.5.29.63299.dev1/git-log-head +16 -0
- placard-2026.5.29.63299.dev1/pyproject.toml +52 -0
- placard-2026.5.29.63299.dev1/setup.cfg +4 -0
- placard-2026.5.29.63299.dev1/src/placard/__init__.py +10 -0
- placard-2026.5.29.63299.dev1/src/placard/_plugin.py +49 -0
- placard-2026.5.29.63299.dev1/src/placard/_rules.py +151 -0
- placard-2026.5.29.63299.dev1/src/placard/tests/__init__.py +1 -0
- placard-2026.5.29.63299.dev1/src/placard/tests/test_init.py +14 -0
- placard-2026.5.29.63299.dev1/src/placard/tests/test_plugin.py +80 -0
- placard-2026.5.29.63299.dev1/src/placard/tests/test_rules.py +228 -0
- placard-2026.5.29.63299.dev1/src/placard.egg-info/PKG-INFO +40 -0
- placard-2026.5.29.63299.dev1/src/placard.egg-info/SOURCES.txt +16 -0
- placard-2026.5.29.63299.dev1/src/placard.egg-info/dependency_links.txt +1 -0
- placard-2026.5.29.63299.dev1/src/placard.egg-info/entry_points.txt +2 -0
- placard-2026.5.29.63299.dev1/src/placard.egg-info/requires.txt +16 -0
- placard-2026.5.29.63299.dev1/src/placard.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: placard
|
|
3
|
+
Version: 2026.5.29.63299.dev1
|
|
4
|
+
Author-email: Moshe Zadka <moshez@zadka.club>
|
|
5
|
+
License: Permission is hereby granted, free of charge, to any person obtaining a
|
|
6
|
+
copy of this software and associated documentation files (the "Software"),
|
|
7
|
+
to deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is furnished
|
|
10
|
+
to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
16
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
17
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
18
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
19
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
20
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
21
|
+
|
|
22
|
+
Project-URL: Homepage, https://github.com/moshez/placard
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
Provides-Extra: tests
|
|
25
|
+
Requires-Dist: virtue; extra == "tests"
|
|
26
|
+
Requires-Dist: pyhamcrest; extra == "tests"
|
|
27
|
+
Requires-Dist: coverage; extra == "tests"
|
|
28
|
+
Provides-Extra: mypy
|
|
29
|
+
Requires-Dist: mypy; extra == "mypy"
|
|
30
|
+
Provides-Extra: lint
|
|
31
|
+
Requires-Dist: flake8; extra == "lint"
|
|
32
|
+
Requires-Dist: black; extra == "lint"
|
|
33
|
+
Requires-Dist: stolid; extra == "lint"
|
|
34
|
+
Provides-Extra: docs
|
|
35
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
36
|
+
|
|
37
|
+
placard
|
|
38
|
+
========================
|
|
39
|
+
|
|
40
|
+
Stuff
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
commit 2919a6c42842c8a9d2fa482c59fdf6d6b0687ebf
|
|
2
|
+
Author: Claude <noreply@anthropic.com>
|
|
3
|
+
Date: 2026-05-29 17:34:59 +0000
|
|
4
|
+
|
|
5
|
+
Match stolid's lint setup; self-apply placard via flake8 plugin discovery
|
|
6
|
+
|
|
7
|
+
Drop the project-local [tool.black] line-length=79 (which conflicted
|
|
8
|
+
with the upstream black default and forced noxfile.py to wrap) in favor
|
|
9
|
+
of stolid's own pattern: black at the default 88 and stolid passed
|
|
10
|
+
--max-line-length=88 --ignore=E203,E503,W503 so the two agree.
|
|
11
|
+
|
|
12
|
+
Self-application is automatic: nox lint installs placard via
|
|
13
|
+
``pip install -e .``, and ``python -m stolid`` invokes flake8, which
|
|
14
|
+
discovers placard's flake8.extension entry point alongside stolid's.
|
|
15
|
+
``flake8 --version`` in the lint venv now reports both plugins, and
|
|
16
|
+
``flake8 --select=PLC src/`` is clean.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"setuptools",
|
|
4
|
+
"autocalver",
|
|
5
|
+
]
|
|
6
|
+
build-backend = "setuptools.build_meta"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "placard"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
description = ""
|
|
12
|
+
readme = "README.rst"
|
|
13
|
+
authors = [{name = "Moshe Zadka", email = "moshez@zadka.club"}]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
tests = ["virtue", "pyhamcrest", "coverage"]
|
|
17
|
+
mypy = ["mypy"]
|
|
18
|
+
lint = ["flake8", "black", "stolid"]
|
|
19
|
+
docs = ["sphinx"]
|
|
20
|
+
|
|
21
|
+
[project.license]
|
|
22
|
+
text = """
|
|
23
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
24
|
+
copy of this software and associated documentation files (the "Software"),
|
|
25
|
+
to deal in the Software without restriction, including without limitation the
|
|
26
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
27
|
+
copies of the Software, and to permit persons to whom the Software is furnished
|
|
28
|
+
to do so, subject to the following conditions:
|
|
29
|
+
|
|
30
|
+
The above copyright notice and this permission notice shall be included in all
|
|
31
|
+
copies or substantial portions of the Software.
|
|
32
|
+
|
|
33
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
34
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
35
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
36
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
37
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
38
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/moshez/placard"
|
|
43
|
+
|
|
44
|
+
[tool.autocalver]
|
|
45
|
+
use = true
|
|
46
|
+
log = "git-log-head"
|
|
47
|
+
log_command = "git log -n 1 --date=iso"
|
|
48
|
+
is_main_var = "GITHUB_REF"
|
|
49
|
+
is_main_match = ".*/trunk$"
|
|
50
|
+
|
|
51
|
+
[project.entry-points."flake8.extension"]
|
|
52
|
+
PLC = "placard:Plugin"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The placard flake8 plugin: Google-style docstring shape checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
|
|
7
|
+
from ._plugin import Plugin
|
|
8
|
+
|
|
9
|
+
__version__ = importlib.metadata.version(__name__)
|
|
10
|
+
__all__ = ["Plugin", "__version__"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Flake8 plugin glue: walks the AST and yields PLC violations for each
|
|
2
|
+
# node whose docstring exists.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
from ._rules import _violations
|
|
11
|
+
|
|
12
|
+
_DOCSTRING_NODES = (
|
|
13
|
+
ast.Module,
|
|
14
|
+
ast.ClassDef,
|
|
15
|
+
ast.FunctionDef,
|
|
16
|
+
ast.AsyncFunctionDef,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# SLD503 (kw_only=True) is intentionally omitted: flake8 introspects
|
|
21
|
+
# the plugin class's positional ``__init__`` parameters via
|
|
22
|
+
# ``inspect.signature`` to decide which fixtures (``tree``, ``lines``,
|
|
23
|
+
# ``filename``, ...) to inject, and keyword-only parameters fall out of
|
|
24
|
+
# that lookup.
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Plugin: # noqa: SLD503
|
|
27
|
+
"""Flake8 plugin: walks ``tree`` and yields placard's ``PLC`` codes."""
|
|
28
|
+
|
|
29
|
+
tree: ast.AST
|
|
30
|
+
|
|
31
|
+
def run( # noqa: SLD303
|
|
32
|
+
self,
|
|
33
|
+
) -> Iterator[tuple[int, int, str, type[Plugin]]]:
|
|
34
|
+
"""Yield ``(line, col, message, type)`` for each PLC violation."""
|
|
35
|
+
owner = type(self)
|
|
36
|
+
for node in ast.walk(self.tree):
|
|
37
|
+
if not isinstance(node, _DOCSTRING_NODES):
|
|
38
|
+
continue
|
|
39
|
+
text = ast.get_docstring(node)
|
|
40
|
+
if text is None:
|
|
41
|
+
continue
|
|
42
|
+
anchor = node.body[0]
|
|
43
|
+
for problem in _violations(text):
|
|
44
|
+
yield (
|
|
45
|
+
anchor.lineno,
|
|
46
|
+
anchor.col_offset,
|
|
47
|
+
f"{problem.code} {problem.message}",
|
|
48
|
+
owner,
|
|
49
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Pure docstring-shape checks. Every function takes the cleaned string
|
|
2
|
+
# returned by ``ast.get_docstring(node)`` (or a derived view of it) and
|
|
3
|
+
# yields PLC violations without consulting the AST or raw source.
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
RECOGNIZED: frozenset[str] = frozenset({"Args", "Returns", "Raises", "Yields"})
|
|
12
|
+
_RECOGNIZED_LOWER: frozenset[str] = frozenset(label.lower() for label in RECOGNIZED)
|
|
13
|
+
_CANONICAL_HEADERS: frozenset[str] = frozenset(f"{label}:" for label in RECOGNIZED)
|
|
14
|
+
_CANONICAL_ARGS = "Args:"
|
|
15
|
+
|
|
16
|
+
_SIGNATURE_LIKE = re.compile(r"^[A-Za-z_]\w*\(")
|
|
17
|
+
_PURE_WORD = re.compile(r"^[A-Za-z]+$")
|
|
18
|
+
_VALID_ARGS_ENTRY = re.compile(r"^[^():]*?[^():\s][^():]*?: \S")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class Violation:
|
|
23
|
+
"""A placard violation: ``code`` is the PLCxxx, ``message`` the prose."""
|
|
24
|
+
|
|
25
|
+
code: str
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _violations(text: str) -> Iterator[Violation]:
|
|
30
|
+
cleaned_lines = tuple(text.splitlines())
|
|
31
|
+
yield from _summary_violations(cleaned_lines)
|
|
32
|
+
yield from _header_violations(cleaned_lines)
|
|
33
|
+
for body in _args_bodies(cleaned_lines):
|
|
34
|
+
yield from _args_body_violations(body)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _summary_violations(
|
|
38
|
+
cleaned_lines: tuple[str, ...],
|
|
39
|
+
) -> Iterator[Violation]:
|
|
40
|
+
if not cleaned_lines or not cleaned_lines[0].strip():
|
|
41
|
+
yield Violation(code="PLC101", message="Summary is empty")
|
|
42
|
+
return
|
|
43
|
+
summary = cleaned_lines[0]
|
|
44
|
+
if not summary.endswith("."):
|
|
45
|
+
yield Violation(
|
|
46
|
+
code="PLC102",
|
|
47
|
+
message="Summary lacks trailing period",
|
|
48
|
+
)
|
|
49
|
+
if _SIGNATURE_LIKE.match(summary):
|
|
50
|
+
yield Violation(
|
|
51
|
+
code="PLC103",
|
|
52
|
+
message=f"Summary mirrors signature: {summary!r}",
|
|
53
|
+
)
|
|
54
|
+
if len(cleaned_lines) > 1 and cleaned_lines[1].strip():
|
|
55
|
+
yield Violation(
|
|
56
|
+
code="PLC104",
|
|
57
|
+
message="Missing blank line after summary",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _header_violations(
|
|
62
|
+
cleaned_lines: tuple[str, ...],
|
|
63
|
+
) -> Iterator[Violation]:
|
|
64
|
+
for entry in cleaned_lines[1:]:
|
|
65
|
+
if entry != entry.lstrip():
|
|
66
|
+
continue
|
|
67
|
+
defect = _header_defect(entry.rstrip())
|
|
68
|
+
if defect is not None:
|
|
69
|
+
yield defect
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _header_defect(stripped: str) -> Violation | None:
|
|
73
|
+
if not stripped or stripped in _CANONICAL_HEADERS:
|
|
74
|
+
return None
|
|
75
|
+
head, sep, tail = stripped.partition(":")
|
|
76
|
+
label = head.strip()
|
|
77
|
+
if not _PURE_WORD.match(label):
|
|
78
|
+
return None
|
|
79
|
+
if sep == "":
|
|
80
|
+
return _bare_word_defect(label, stripped)
|
|
81
|
+
return _colon_form_defect(label, tail, stripped)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _bare_word_defect(label: str, stripped: str) -> Violation | None:
|
|
85
|
+
if label.lower() not in _RECOGNIZED_LOWER:
|
|
86
|
+
return None
|
|
87
|
+
return Violation(
|
|
88
|
+
code="PLC203",
|
|
89
|
+
message=f"Section header missing colon: {stripped!r}",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _colon_form_defect(label: str, tail: str, stripped: str) -> Violation | None:
|
|
94
|
+
if label in RECOGNIZED:
|
|
95
|
+
if tail.strip():
|
|
96
|
+
return Violation(
|
|
97
|
+
code="PLC204",
|
|
98
|
+
message=f"Section header trailing content: {stripped!r}",
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
if label.lower() in _RECOGNIZED_LOWER:
|
|
102
|
+
return Violation(
|
|
103
|
+
code="PLC202",
|
|
104
|
+
message=f"Section header wrong case: {stripped!r}",
|
|
105
|
+
)
|
|
106
|
+
return Violation(
|
|
107
|
+
code="PLC201",
|
|
108
|
+
message=f"Unrecognized section header: {stripped!r}",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _args_bodies(
|
|
113
|
+
cleaned_lines: tuple[str, ...],
|
|
114
|
+
) -> Iterator[tuple[str, ...]]:
|
|
115
|
+
cursor = 1
|
|
116
|
+
while cursor < len(cleaned_lines):
|
|
117
|
+
entry = cleaned_lines[cursor]
|
|
118
|
+
cursor += 1
|
|
119
|
+
if entry != entry.lstrip() or entry.rstrip() != _CANONICAL_ARGS:
|
|
120
|
+
continue
|
|
121
|
+
body_start = cursor
|
|
122
|
+
while cursor < len(cleaned_lines) and _is_body_member(cleaned_lines[cursor]):
|
|
123
|
+
cursor += 1
|
|
124
|
+
yield cleaned_lines[body_start:cursor]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_body_member(entry: str) -> bool:
|
|
128
|
+
if not entry.strip():
|
|
129
|
+
return True
|
|
130
|
+
return entry != entry.lstrip()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _args_body_violations(body: tuple[str, ...]) -> Iterator[Violation]:
|
|
134
|
+
nonblank = tuple(entry for entry in body if entry.strip())
|
|
135
|
+
if not nonblank:
|
|
136
|
+
yield Violation(
|
|
137
|
+
code="PLC302",
|
|
138
|
+
message="Args section is empty",
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
base_indent = min(len(entry) - len(entry.lstrip()) for entry in nonblank)
|
|
142
|
+
for entry in nonblank:
|
|
143
|
+
current_indent = len(entry) - len(entry.lstrip())
|
|
144
|
+
if current_indent != base_indent:
|
|
145
|
+
continue
|
|
146
|
+
content = entry.lstrip()
|
|
147
|
+
if _VALID_ARGS_ENTRY.match(content) is None:
|
|
148
|
+
yield Violation(
|
|
149
|
+
code="PLC301",
|
|
150
|
+
message=f"Args entry malformed: {content!r}",
|
|
151
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for placard."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Smoke tests for the placard package."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from hamcrest import assert_that, contains_string
|
|
5
|
+
|
|
6
|
+
from .. import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestInit(unittest.TestCase):
|
|
10
|
+
"""Check that package-level attributes are wired up."""
|
|
11
|
+
|
|
12
|
+
def test_version(self) -> None:
|
|
13
|
+
"""The package exposes a dotted version string."""
|
|
14
|
+
assert_that(__version__, contains_string("."))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Cover the flake8 plugin entry point that drives placard's rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from hamcrest import assert_that, contains_string, equal_to, has_item
|
|
9
|
+
|
|
10
|
+
from .._plugin import Plugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _runs(
|
|
14
|
+
tree: ast.AST,
|
|
15
|
+
) -> list[tuple[int, int, str, type[Plugin]]]:
|
|
16
|
+
return list(Plugin(tree=tree).run())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _codes_in(source: str) -> list[str]:
|
|
20
|
+
return [run[2].split()[0] for run in _runs(ast.parse(source))]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _first_run(source: str) -> tuple[int, int, str, type[Plugin]]:
|
|
24
|
+
return _runs(ast.parse(source))[0]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestPluginDispatch(unittest.TestCase):
|
|
28
|
+
"""Plugin.run walks the AST and emits PLC violations for docstrings."""
|
|
29
|
+
|
|
30
|
+
def test_module_docstring_line_and_code(self) -> None:
|
|
31
|
+
"""A module docstring violation reports at line 1 with its code."""
|
|
32
|
+
first = _first_run('"""bad summary"""\n')
|
|
33
|
+
assert_that(first[0], equal_to(1))
|
|
34
|
+
assert_that(first[2], contains_string("PLC102"))
|
|
35
|
+
|
|
36
|
+
def test_function_docstring_violations_emitted(self) -> None:
|
|
37
|
+
"""A function docstring is checked just like a module docstring."""
|
|
38
|
+
source = 'def f():\n """bad summary"""\n return 1\n'
|
|
39
|
+
assert_that(_codes_in(source), has_item("PLC102"))
|
|
40
|
+
|
|
41
|
+
def test_class_docstring_violations_emitted(self) -> None:
|
|
42
|
+
"""A class docstring is checked."""
|
|
43
|
+
source = 'class C:\n """bad summary"""\n pass\n'
|
|
44
|
+
assert_that(_codes_in(source), has_item("PLC102"))
|
|
45
|
+
|
|
46
|
+
def test_async_function_docstring_violations_emitted(self) -> None:
|
|
47
|
+
"""An async function docstring is checked."""
|
|
48
|
+
source = 'async def f():\n """bad summary"""\n return 1\n'
|
|
49
|
+
assert_that(_codes_in(source), has_item("PLC102"))
|
|
50
|
+
|
|
51
|
+
def test_missing_docstring_is_skipped(self) -> None:
|
|
52
|
+
"""A function without a docstring produces no output."""
|
|
53
|
+
source = "def f():\n return 1\n"
|
|
54
|
+
assert_that(_codes_in(source), equal_to([]))
|
|
55
|
+
|
|
56
|
+
def test_good_docstring_produces_no_violations(self) -> None:
|
|
57
|
+
"""A well-formed docstring produces no violations."""
|
|
58
|
+
source = 'def f():\n """Do the thing."""\n return 1\n'
|
|
59
|
+
assert_that(_codes_in(source), equal_to([]))
|
|
60
|
+
|
|
61
|
+
def test_yielded_class_is_plugin(self) -> None:
|
|
62
|
+
"""The fourth tuple element is the Plugin class itself."""
|
|
63
|
+
first = _first_run('"""bad"""\n')
|
|
64
|
+
assert_that(first[3], equal_to(Plugin))
|
|
65
|
+
|
|
66
|
+
def test_nested_function_docstring_is_checked(self) -> None:
|
|
67
|
+
"""Docstring rules apply to functions nested inside other nodes."""
|
|
68
|
+
source = (
|
|
69
|
+
"def outer():\n"
|
|
70
|
+
" def inner():\n"
|
|
71
|
+
' """bad summary"""\n'
|
|
72
|
+
" return 1\n"
|
|
73
|
+
" return inner\n"
|
|
74
|
+
)
|
|
75
|
+
assert_that(_codes_in(source), has_item("PLC102"))
|
|
76
|
+
|
|
77
|
+
def test_column_matches_docstring_col_offset(self) -> None:
|
|
78
|
+
"""The reported column equals the docstring node's col_offset."""
|
|
79
|
+
first = _first_run('def f():\n """bad"""\n return 1\n')
|
|
80
|
+
assert_that(first[1], equal_to(4))
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Cover every PLC rule and every branch of placard's docstring checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from hamcrest import assert_that, contains_string, equal_to, has_item
|
|
8
|
+
|
|
9
|
+
from .._rules import Violation, _violations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _codes(text: str) -> list[str]:
|
|
13
|
+
return [problem.code for problem in _violations(text)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _messages(text: str) -> list[str]:
|
|
17
|
+
return [problem.message for problem in _violations(text)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestSummaryRules(unittest.TestCase):
|
|
21
|
+
"""Cover PLC101-PLC104 against the docstring summary line."""
|
|
22
|
+
|
|
23
|
+
def test_empty_string_raises_plc101(self) -> None:
|
|
24
|
+
"""An entirely empty docstring fires PLC101."""
|
|
25
|
+
assert_that(_codes(""), equal_to(["PLC101"]))
|
|
26
|
+
|
|
27
|
+
def test_whitespace_only_summary_raises_plc101(self) -> None:
|
|
28
|
+
"""A summary containing only whitespace fires PLC101."""
|
|
29
|
+
assert_that(_codes(" "), equal_to(["PLC101"]))
|
|
30
|
+
|
|
31
|
+
def test_summary_missing_period_raises_plc102(self) -> None:
|
|
32
|
+
"""A summary that doesn't end with a period fires PLC102."""
|
|
33
|
+
assert_that(_codes("Summarize the thing"), equal_to(["PLC102"]))
|
|
34
|
+
|
|
35
|
+
def test_signature_shape_raises_plc103(self) -> None:
|
|
36
|
+
"""A summary shaped like a call signature fires PLC103."""
|
|
37
|
+
assert_that(_codes("process(data, config)."), equal_to(["PLC103"]))
|
|
38
|
+
|
|
39
|
+
def test_prose_summary_does_not_fire_plc103(self) -> None:
|
|
40
|
+
"""A normal prose summary does not fire PLC103."""
|
|
41
|
+
assert_that(_codes("Process the input."), equal_to([]))
|
|
42
|
+
|
|
43
|
+
def test_no_blank_line_after_summary_raises_plc104(self) -> None:
|
|
44
|
+
"""A multi-line docstring without a blank line fires PLC104."""
|
|
45
|
+
text = "Summarize.\nMore detail follows."
|
|
46
|
+
assert_that(_codes(text), equal_to(["PLC104"]))
|
|
47
|
+
|
|
48
|
+
def test_blank_line_after_summary_is_clean(self) -> None:
|
|
49
|
+
"""A blank line after the summary satisfies PLC104."""
|
|
50
|
+
text = "Summarize.\n\nMore detail follows."
|
|
51
|
+
assert_that(_codes(text), equal_to([]))
|
|
52
|
+
|
|
53
|
+
def test_single_line_summary_no_plc104(self) -> None:
|
|
54
|
+
"""A one-line docstring never triggers PLC104."""
|
|
55
|
+
assert_that(_codes("Just one line."), equal_to([]))
|
|
56
|
+
|
|
57
|
+
def test_plc103_message_includes_summary(self) -> None:
|
|
58
|
+
"""The PLC103 message includes the offending summary verbatim."""
|
|
59
|
+
assert_that(
|
|
60
|
+
_messages("connect(host, port).")[0],
|
|
61
|
+
contains_string("connect(host, port)."),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestHeaderRules(unittest.TestCase):
|
|
66
|
+
"""Cover PLC201-PLC204 against section header lines."""
|
|
67
|
+
|
|
68
|
+
def test_canonical_args_header_is_clean(self) -> None:
|
|
69
|
+
"""An exact ``Args:`` header is accepted with a valid entry."""
|
|
70
|
+
text = "Summary.\n\nArgs:\n x: the input.\n"
|
|
71
|
+
assert_that(_codes(text), equal_to([]))
|
|
72
|
+
|
|
73
|
+
def test_unrecognized_header_raises_plc201(self) -> None:
|
|
74
|
+
"""A non-recognized ``Word:`` header fires PLC201."""
|
|
75
|
+
text = "Summary.\n\nArguments:\n x: the input.\n"
|
|
76
|
+
codes = _codes(text)
|
|
77
|
+
assert_that(codes, has_item("PLC201"))
|
|
78
|
+
|
|
79
|
+
def test_lowercased_header_raises_plc202(self) -> None:
|
|
80
|
+
"""A wrong-case header (``args:``) fires PLC202."""
|
|
81
|
+
text = "Summary.\n\nargs:\n x: the input.\n"
|
|
82
|
+
codes = _codes(text)
|
|
83
|
+
assert_that(codes, has_item("PLC202"))
|
|
84
|
+
|
|
85
|
+
def test_missing_colon_raises_plc203(self) -> None:
|
|
86
|
+
"""A bare recognized name (``Args``) fires PLC203."""
|
|
87
|
+
text = "Summary.\n\nArgs\n x: the input.\n"
|
|
88
|
+
codes = _codes(text)
|
|
89
|
+
assert_that(codes, has_item("PLC203"))
|
|
90
|
+
|
|
91
|
+
def test_trailing_content_raises_plc204(self) -> None:
|
|
92
|
+
"""A canonical header with trailing content fires PLC204."""
|
|
93
|
+
text = "Summary.\n\nArgs: some.\n x: the input.\n"
|
|
94
|
+
codes = _codes(text)
|
|
95
|
+
assert_that(codes, has_item("PLC204"))
|
|
96
|
+
|
|
97
|
+
def test_indented_word_is_not_header(self) -> None:
|
|
98
|
+
"""An indented ``Args:`` line is body content, not a header."""
|
|
99
|
+
text = "Summary.\n\n Args:\n"
|
|
100
|
+
assert_that(_codes(text), equal_to([]))
|
|
101
|
+
|
|
102
|
+
def test_blank_lines_after_summary_are_ignored(self) -> None:
|
|
103
|
+
"""Blank body lines are not classified as headers."""
|
|
104
|
+
text = "Summary.\n\n\n"
|
|
105
|
+
assert_that(_codes(text), equal_to([]))
|
|
106
|
+
|
|
107
|
+
def test_multi_word_prose_is_not_a_header(self) -> None:
|
|
108
|
+
"""Prose containing a colon is not flagged as a header."""
|
|
109
|
+
text = "Summary.\n\nNotes about: the implementation.\n"
|
|
110
|
+
assert_that(_codes(text), equal_to([]))
|
|
111
|
+
|
|
112
|
+
def test_bare_unrecognized_word_is_not_a_header(self) -> None:
|
|
113
|
+
"""A bare unrecognized word at col 0 is not flagged."""
|
|
114
|
+
text = "Summary.\n\nFoo\n"
|
|
115
|
+
assert_that(_codes(text), equal_to([]))
|
|
116
|
+
|
|
117
|
+
def test_lowercased_bare_name_raises_plc203(self) -> None:
|
|
118
|
+
"""A bare lowercase recognized name still fires PLC203."""
|
|
119
|
+
text = "Summary.\n\nargs\n"
|
|
120
|
+
assert_that(_codes(text), has_item("PLC203"))
|
|
121
|
+
|
|
122
|
+
def test_canonical_returns_header_is_clean(self) -> None:
|
|
123
|
+
"""The other canonical headers are also accepted."""
|
|
124
|
+
text = "Summary.\n\nReturns:\n the value.\n"
|
|
125
|
+
assert_that(_codes(text), equal_to([]))
|
|
126
|
+
|
|
127
|
+
def test_recognized_name_with_space_before_colon_passes(self) -> None:
|
|
128
|
+
"""``Args :`` (space before colon, no trailing content) is not flagged.
|
|
129
|
+
|
|
130
|
+
Placard reports PLC204 only when there is real content after the
|
|
131
|
+
colon; whitespace-only oddities silently pass.
|
|
132
|
+
"""
|
|
133
|
+
text = "Summary.\n\nArgs :\n"
|
|
134
|
+
assert_that(_codes(text), equal_to([]))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestArgsBodyRules(unittest.TestCase):
|
|
138
|
+
"""Cover PLC301 and PLC302 inside ``Args:`` bodies."""
|
|
139
|
+
|
|
140
|
+
def test_valid_entry_is_clean(self) -> None:
|
|
141
|
+
"""A ``name: description`` entry passes."""
|
|
142
|
+
text = "Summary.\n\nArgs:\n x: the input value.\n"
|
|
143
|
+
assert_that(_codes(text), equal_to([]))
|
|
144
|
+
|
|
145
|
+
def test_empty_args_body_raises_plc302(self) -> None:
|
|
146
|
+
"""An ``Args:`` header with no entries fires PLC302."""
|
|
147
|
+
text = "Summary.\n\nArgs:\n"
|
|
148
|
+
assert_that(_codes(text), equal_to(["PLC302"]))
|
|
149
|
+
|
|
150
|
+
def test_parenthesized_type_raises_plc301(self) -> None:
|
|
151
|
+
"""An entry with a parenthesized type fires PLC301."""
|
|
152
|
+
text = "Summary.\n\nArgs:\n x (int): the value.\n"
|
|
153
|
+
assert_that(_codes(text), equal_to(["PLC301"]))
|
|
154
|
+
|
|
155
|
+
def test_no_colon_in_entry_raises_plc301(self) -> None:
|
|
156
|
+
"""An entry without a colon fires PLC301."""
|
|
157
|
+
text = "Summary.\n\nArgs:\n just a word\n"
|
|
158
|
+
assert_that(_codes(text), equal_to(["PLC301"]))
|
|
159
|
+
|
|
160
|
+
def test_no_description_after_colon_raises_plc301(self) -> None:
|
|
161
|
+
"""An entry with a colon but no description fires PLC301."""
|
|
162
|
+
text = "Summary.\n\nArgs:\n x:\n"
|
|
163
|
+
assert_that(_codes(text), equal_to(["PLC301"]))
|
|
164
|
+
|
|
165
|
+
def test_no_space_after_colon_raises_plc301(self) -> None:
|
|
166
|
+
"""An entry must have a space between the colon and description."""
|
|
167
|
+
text = "Summary.\n\nArgs:\n x:description\n"
|
|
168
|
+
assert_that(_codes(text), equal_to(["PLC301"]))
|
|
169
|
+
|
|
170
|
+
def test_continuation_lines_do_not_double_report(self) -> None:
|
|
171
|
+
"""Lines deeper than the entry indent are continuations."""
|
|
172
|
+
text = (
|
|
173
|
+
"Summary.\n"
|
|
174
|
+
"\n"
|
|
175
|
+
"Args:\n"
|
|
176
|
+
" x: the value.\n"
|
|
177
|
+
" wraps across lines.\n"
|
|
178
|
+
" y: the other value.\n"
|
|
179
|
+
)
|
|
180
|
+
assert_that(_codes(text), equal_to([]))
|
|
181
|
+
|
|
182
|
+
def test_blank_line_inside_body_stays_in_section(self) -> None:
|
|
183
|
+
"""Blank lines inside the ``Args`` body do not end the section."""
|
|
184
|
+
text = (
|
|
185
|
+
"Summary.\n"
|
|
186
|
+
"\n"
|
|
187
|
+
"Args:\n"
|
|
188
|
+
" x: the first.\n"
|
|
189
|
+
"\n"
|
|
190
|
+
" y: the second.\n"
|
|
191
|
+
)
|
|
192
|
+
assert_that(_codes(text), equal_to([]))
|
|
193
|
+
|
|
194
|
+
def test_body_ends_at_next_header(self) -> None:
|
|
195
|
+
"""A non-indented line after the body ends the Args section."""
|
|
196
|
+
text = (
|
|
197
|
+
"Summary.\n"
|
|
198
|
+
"\n"
|
|
199
|
+
"Args:\n"
|
|
200
|
+
" x: the input.\n"
|
|
201
|
+
"Returns:\n"
|
|
202
|
+
" the output.\n"
|
|
203
|
+
)
|
|
204
|
+
assert_that(_codes(text), equal_to([]))
|
|
205
|
+
|
|
206
|
+
def test_plc301_message_includes_offending_entry(self) -> None:
|
|
207
|
+
"""The PLC301 message mentions the offending entry text."""
|
|
208
|
+
text = "Summary.\n\nArgs:\n x (int): the value.\n"
|
|
209
|
+
assert_that(
|
|
210
|
+
_messages(text)[0],
|
|
211
|
+
contains_string("x (int): the value."),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestViolationDataclass(unittest.TestCase):
|
|
216
|
+
"""Cover the Violation dataclass contract."""
|
|
217
|
+
|
|
218
|
+
def test_violation_is_frozen(self) -> None:
|
|
219
|
+
"""``Violation`` instances are immutable."""
|
|
220
|
+
problem = Violation(code="PLC101", message="x")
|
|
221
|
+
with self.assertRaises(Exception):
|
|
222
|
+
problem.code = "PLC102" # type: ignore[misc]
|
|
223
|
+
|
|
224
|
+
def test_violation_carries_code_and_message(self) -> None:
|
|
225
|
+
"""``Violation`` exposes ``code`` and ``message`` attributes."""
|
|
226
|
+
problem = Violation(code="PLC101", message="x")
|
|
227
|
+
assert_that(problem.code, equal_to("PLC101"))
|
|
228
|
+
assert_that(problem.message, equal_to("x"))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: placard
|
|
3
|
+
Version: 2026.5.29.63299.dev1
|
|
4
|
+
Author-email: Moshe Zadka <moshez@zadka.club>
|
|
5
|
+
License: Permission is hereby granted, free of charge, to any person obtaining a
|
|
6
|
+
copy of this software and associated documentation files (the "Software"),
|
|
7
|
+
to deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is furnished
|
|
10
|
+
to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
16
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
17
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
18
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
19
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
20
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
21
|
+
|
|
22
|
+
Project-URL: Homepage, https://github.com/moshez/placard
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
Provides-Extra: tests
|
|
25
|
+
Requires-Dist: virtue; extra == "tests"
|
|
26
|
+
Requires-Dist: pyhamcrest; extra == "tests"
|
|
27
|
+
Requires-Dist: coverage; extra == "tests"
|
|
28
|
+
Provides-Extra: mypy
|
|
29
|
+
Requires-Dist: mypy; extra == "mypy"
|
|
30
|
+
Provides-Extra: lint
|
|
31
|
+
Requires-Dist: flake8; extra == "lint"
|
|
32
|
+
Requires-Dist: black; extra == "lint"
|
|
33
|
+
Requires-Dist: stolid; extra == "lint"
|
|
34
|
+
Provides-Extra: docs
|
|
35
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
36
|
+
|
|
37
|
+
placard
|
|
38
|
+
========================
|
|
39
|
+
|
|
40
|
+
Stuff
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.rst
|
|
2
|
+
git-log-head
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/placard/__init__.py
|
|
5
|
+
src/placard/_plugin.py
|
|
6
|
+
src/placard/_rules.py
|
|
7
|
+
src/placard.egg-info/PKG-INFO
|
|
8
|
+
src/placard.egg-info/SOURCES.txt
|
|
9
|
+
src/placard.egg-info/dependency_links.txt
|
|
10
|
+
src/placard.egg-info/entry_points.txt
|
|
11
|
+
src/placard.egg-info/requires.txt
|
|
12
|
+
src/placard.egg-info/top_level.txt
|
|
13
|
+
src/placard/tests/__init__.py
|
|
14
|
+
src/placard/tests/test_init.py
|
|
15
|
+
src/placard/tests/test_plugin.py
|
|
16
|
+
src/placard/tests/test_rules.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
placard
|