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.
@@ -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,4 @@
1
+ placard
2
+ ========================
3
+
4
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [flake8.extension]
2
+ PLC = placard:Plugin
@@ -0,0 +1,16 @@
1
+
2
+ [docs]
3
+ sphinx
4
+
5
+ [lint]
6
+ flake8
7
+ black
8
+ stolid
9
+
10
+ [mypy]
11
+ mypy
12
+
13
+ [tests]
14
+ virtue
15
+ pyhamcrest
16
+ coverage