FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

chore: enforce the declaration ordering convention in ci (#1887) · python-zeroconf/python-zeroconf@f0a20a0 · GitHub

Repository navigation

Commit f0a20a0

Browse files
authored
chore: enforce the declaration ordering convention in ci (#1887)
1 parent d44c3f8 commit f0a20a0

19 files changed

Lines changed: 794 additions & 689 deletions

‎.pre-commit-config.yaml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,10 @@ repos:
5858
hooks:
5959
- id: cython-lint
6060
- id: double-quote-cython-strings
61+
- repo: local
62+
hooks:
63+
- id: check-declaration-order
64+
name: check declaration order
65+
entry: python3 scripts/check_declaration_order.py
66+
language: system
67+
files: ^(src/zeroconf/.*\.py|scripts/check_declaration_order\.py)$

‎CLAUDE.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,15 @@ path. The authoritative list of cythonized modules lives in
6363
references** to old codepaths or issue numbers unless there's
6464
a clear reason a future reader needs that link.
6565

66-
- **Method order**: public API at the top, private helpers
67-
(`_underscore_prefixed`) at the bottom. Modules whose names
66+
- **Method order**: enforced by `scripts/check_declaration_order.py`
67+
(a pre-commit hook): dunder methods first with `__init__` leading,
68+
then public methods, then private helpers (`_underscore_prefixed`),
69+
each group alphabetical. Sibling classes that share a locally defined
70+
base are declared alphabetically after that base. The order is
71+
rule-generated on purpose — the arrangement of declarations is
72+
derived from this stated convention rather than from any earlier
73+
codebase — so don't hand-tune it; if a grouping seems more readable,
74+
the convention still wins. Modules whose names
6875
start with `_` (`_cache`, `_dns`, `_handlers/`, etc.) are
6976
internal; the supported surface is what `zeroconf/__init__.py`
7077
and `zeroconf/asyncio.py` re-export.

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ select = [
183183
"examples/**/*" = [
184184
"T201", # intended
185185
]
186+
"scripts/**/*" = [
187+
"T201", # intended
188+
]
186189
"setup.py" = ["D100"]
187190
"conftest.py" = ["D100"]
188191
"docs/conf.py" = ["D100"]

‎scripts/check_declaration_order.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Enforce the repository's declaration ordering convention.
2+
3+
Methods within a class: dunders with ``__init__`` first, then public,
4+
then private, each group alphabetical. Sibling classes at module level:
5+
locally defined bases before their subclasses, alphabetical among
6+
subclasses sharing the same locally defined bases. See CLAUDE.md for the rationale.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import ast
12+
import sys
13+
from pathlib import Path
14+
15+
ROOT = Path(__file__).resolve().parent.parent
16+
SRC = ROOT / "src" / "zeroconf"
17+
18+
19+
def method_sort_key(name: str) -> tuple[int, bool, str]:
20+
is_dunder = name.startswith("__") and name.endswith("__")
21+
is_private = not is_dunder and name.startswith("_")
22+
group = 0 if is_dunder else (2 if is_private else 1)
23+
return (group, name != "__init__", name)
24+
25+
26+
def check_methods(rel: Path, cls: ast.ClassDef) -> list[str]:
27+
names = [node.name for node in cls.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
28+
# property setters share the getter's name; the sort is stable so
29+
# equal-name adjacency is always satisfiable
30+
expected = sorted(names, key=method_sort_key)
31+
if names != expected:
32+
return [
33+
f"{rel}:{cls.lineno}: class {cls.name}: method order breaks the "
34+
f"convention (dunders with __init__ first, then public, then "
35+
f"private, each alphabetical); expected: {', '.join(expected)}"
36+
]
37+
return []
38+
39+
40+
def check_class_order(rel: Path, tree: ast.Module) -> list[str]:
41+
classes = [node for node in tree.body if isinstance(node, ast.ClassDef)]
42+
local = {cls.name for cls in classes}
43+
position = {cls.name: i for i, cls in enumerate(classes)}
44+
problems = []
45+
for i, cls in enumerate(classes):
46+
bases = sorted(b.id for b in cls.bases if isinstance(b, ast.Name) and b.id in local)
47+
for base in bases:
48+
if position[base] > i:
49+
problems.append(f"{rel}:{cls.lineno}: class {cls.name} is defined before its base {base}")
50+
if i:
51+
prev = classes[i - 1]
52+
prev_bases = sorted(b.id for b in prev.bases if isinstance(b, ast.Name) and b.id in local)
53+
if bases and prev_bases == bases and prev.name.lower() > cls.name.lower():
54+
problems.append(
55+
f"{rel}:{cls.lineno}: class {cls.name} should come before its "
56+
f"sibling {prev.name} (siblings with the same local bases are "
57+
f"alphabetical)"
58+
)
59+
return problems
60+
61+
62+
def check_file(path: Path) -> list[str]:
63+
rel = path.resolve().relative_to(ROOT)
64+
tree = ast.parse(path.read_text())
65+
problems = check_class_order(rel, tree)
66+
stack: list[ast.AST] = [tree]
67+
while stack:
68+
node = stack.pop()
69+
for child in ast.iter_child_nodes(node):
70+
if isinstance(child, ast.ClassDef):
71+
problems.extend(check_methods(rel, child))
72+
if isinstance(child, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef, ast.If)):
73+
stack.append(child)
74+
return problems
75+
76+
77+
def main() -> int:
78+
paths = [Path(arg) for arg in sys.argv[1:]] or sorted(SRC.rglob("*.py"))
79+
problems: list[str] = []
80+
for path in paths:
81+
problems.extend(check_file(path))
82+
for problem in problems:
83+
print(problem)
84+
return 1 if problems else 0
85+
86+
87+
if __name__ == "__main__":
88+
sys.exit(main())

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL