sectionise 0.1.0__py3-none-any.whl
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.
- sectionise/__init__.py +0 -0
- sectionise/cli.py +150 -0
- sectionise/core.py +383 -0
- sectionise-0.1.0.dist-info/METADATA +146 -0
- sectionise-0.1.0.dist-info/RECORD +8 -0
- sectionise-0.1.0.dist-info/WHEEL +4 -0
- sectionise-0.1.0.dist-info/entry_points.txt +2 -0
- sectionise-0.1.0.dist-info/licenses/LICENSE +21 -0
sectionise/__init__.py
ADDED
|
File without changes
|
sectionise/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Command-line entry point for sectionise.
|
|
2
|
+
|
|
3
|
+
Resolves settings with the precedence flags > `[tool.sectionise]` in the nearest
|
|
4
|
+
`pyproject.toml` > built-in defaults, then lints or autofixes the given files.
|
|
5
|
+
Exits non-zero when it changed anything or hit an over-long title, matching the
|
|
6
|
+
pre-commit formatter convention.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import core
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_pyproject(start: Path) -> Path | None:
|
|
17
|
+
"""Return the nearest `pyproject.toml` at or above `start`, else `None`."""
|
|
18
|
+
start = start.resolve()
|
|
19
|
+
for parent in (start, *start.parents):
|
|
20
|
+
candidate = parent / "pyproject.toml"
|
|
21
|
+
if candidate.is_file():
|
|
22
|
+
return candidate
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_config(path: Path | None) -> dict:
|
|
27
|
+
"""Read the `[tool.sectionise]` table from a `pyproject.toml`.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
path: The file to read, or `None`.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The table as a dict, or an empty dict when absent or unreadable.
|
|
34
|
+
"""
|
|
35
|
+
if path is None or not path.is_file():
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
with open(path, "rb") as handle:
|
|
39
|
+
data = tomllib.load(handle)
|
|
40
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
41
|
+
return {}
|
|
42
|
+
tool = data.get("tool", {})
|
|
43
|
+
section = tool.get("sectionise", {})
|
|
44
|
+
return section if isinstance(section, dict) else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
"""Build the argument parser.
|
|
49
|
+
|
|
50
|
+
Overridable options default to `None` so an unset flag falls through to
|
|
51
|
+
`pyproject.toml` and then the built-in default.
|
|
52
|
+
"""
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog="sectionise",
|
|
55
|
+
description="Standardise section-header comment banners.",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument("filenames", nargs="*", help="Files to process.")
|
|
58
|
+
parser.add_argument("--config", type=Path, default=None, help="pyproject.toml to read.")
|
|
59
|
+
parser.add_argument("--width", type=int, default=None, help="Target line length.")
|
|
60
|
+
parser.add_argument("--fill", default=None, help="Output fill character.")
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--detect-chars", default=None, help="Characters recognised as banner fill."
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--min-run", type=int, default=None, help="Minimum fill-run length.")
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--style", choices=("single", "box"), default=None, help="Output form."
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--max-title", type=int, default=None, help="Hard cap on title length."
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--require-both-sides",
|
|
73
|
+
action=argparse.BooleanOptionalAction,
|
|
74
|
+
default=None,
|
|
75
|
+
help="Only treat comments with fill on both sides as banners.",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--dividers",
|
|
79
|
+
action=argparse.BooleanOptionalAction,
|
|
80
|
+
default=None,
|
|
81
|
+
help="Also standardise stand-alone title-less rules.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--check", action="store_true", help="Report only; do not rewrite files."
|
|
85
|
+
)
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_style(args: argparse.Namespace, config: dict) -> core.Style:
|
|
90
|
+
"""Merge flags over `pyproject.toml` over defaults into a `Style`."""
|
|
91
|
+
|
|
92
|
+
def pick(flag_value, key, default):
|
|
93
|
+
if flag_value is not None:
|
|
94
|
+
return flag_value
|
|
95
|
+
return config.get(key, default)
|
|
96
|
+
|
|
97
|
+
return core.Style(
|
|
98
|
+
width=pick(args.width, "width", core.DEFAULT_WIDTH),
|
|
99
|
+
fill=pick(args.fill, "fill", core.DEFAULT_FILL),
|
|
100
|
+
detect_chars=pick(args.detect_chars, "detect_chars", core.DEFAULT_DETECT_CHARS),
|
|
101
|
+
min_run=pick(args.min_run, "min_run", core.DEFAULT_MIN_RUN),
|
|
102
|
+
require_both_sides=pick(args.require_both_sides, "require_both_sides", False),
|
|
103
|
+
dividers=pick(args.dividers, "dividers", False),
|
|
104
|
+
style=pick(args.style, "style", core.DEFAULT_STYLE),
|
|
105
|
+
max_title=pick(args.max_title, "max_title", None),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: list[str] | None = None) -> int:
|
|
110
|
+
"""Lint or autofix section-header banners in the given files.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
argv: Argument list; defaults to `sys.argv[1:]`.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
`1` if any file was (or would be) changed or a title was too long, else
|
|
117
|
+
`0`.
|
|
118
|
+
"""
|
|
119
|
+
args = _build_parser().parse_args(argv)
|
|
120
|
+
config = _load_config(args.config or _find_pyproject(Path.cwd()))
|
|
121
|
+
style = _resolve_style(args, config)
|
|
122
|
+
|
|
123
|
+
changed_files: list[str] = []
|
|
124
|
+
all_errors: list[str] = []
|
|
125
|
+
for name in args.filenames:
|
|
126
|
+
path = Path(name)
|
|
127
|
+
syntax = core.syntax_for(path.suffix)
|
|
128
|
+
if syntax is None:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
text = path.read_text(encoding="utf-8")
|
|
132
|
+
except (OSError, UnicodeDecodeError):
|
|
133
|
+
continue
|
|
134
|
+
new_text, changed, errors = core.process_text(text, syntax, style, name)
|
|
135
|
+
all_errors.extend(errors)
|
|
136
|
+
if changed:
|
|
137
|
+
changed_files.append(name)
|
|
138
|
+
if not args.check:
|
|
139
|
+
path.write_text(new_text, encoding="utf-8")
|
|
140
|
+
|
|
141
|
+
verb = "would reformat" if args.check else "reformatted"
|
|
142
|
+
for name in changed_files:
|
|
143
|
+
print(f"{verb} section headers in {name}")
|
|
144
|
+
for error in all_errors:
|
|
145
|
+
print(error)
|
|
146
|
+
return 1 if changed_files or all_errors else 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
raise SystemExit(main())
|
sectionise/core.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Detect and standardise section-header comment banners.
|
|
2
|
+
|
|
3
|
+
A section header is a comment styled as a banner: a title framed by a run of
|
|
4
|
+
fill characters. Real code carries many variants, all of which this module
|
|
5
|
+
detects and rewrites to one canonical style:
|
|
6
|
+
|
|
7
|
+
* single-line, framed both sides: `# ------- Loading models -------`
|
|
8
|
+
* single-line, filled one side: `# Ancillary functions ----------`
|
|
9
|
+
* Unicode fill (em/en dash, rules): `# --- Loading models ---`
|
|
10
|
+
* three-line box: a rule, a title comment, a rule
|
|
11
|
+
|
|
12
|
+
Detection is deliberately conservative: only full-line comments whose content
|
|
13
|
+
is framed by a fill run of at least `min_run` characters are touched, so
|
|
14
|
+
ordinary comments, trailing comments, and commented-out code (for example
|
|
15
|
+
`# print("=== x ===")`) are left alone. Title-less rules are ignored unless
|
|
16
|
+
`dividers` is enabled.
|
|
17
|
+
|
|
18
|
+
The pure functions here have no I/O; `cli` wires configuration and files around
|
|
19
|
+
them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
DEFAULT_WIDTH = 88
|
|
25
|
+
DEFAULT_FILL = "-"
|
|
26
|
+
# ASCII banner fills plus common Unicode ones (em dash, en dash, box rules).
|
|
27
|
+
DEFAULT_DETECT_CHARS = "-=*_~#—–─═"
|
|
28
|
+
DEFAULT_MIN_RUN = 3
|
|
29
|
+
DEFAULT_STYLE = "single"
|
|
30
|
+
|
|
31
|
+
# Comment syntax per file extension as (opener, closer). The closer is empty for
|
|
32
|
+
# line comments and non-empty for block comments (HTML/XML/Markdown).
|
|
33
|
+
_LINE_HASH = ("#", "")
|
|
34
|
+
_LINE_SLASH = ("//", "")
|
|
35
|
+
_BLOCK_HTML = ("<!--", "-->")
|
|
36
|
+
|
|
37
|
+
_SYNTAX_BY_SUFFIX = {
|
|
38
|
+
".py": _LINE_HASH,
|
|
39
|
+
".pyi": _LINE_HASH,
|
|
40
|
+
".sh": _LINE_HASH,
|
|
41
|
+
".bash": _LINE_HASH,
|
|
42
|
+
".toml": _LINE_HASH,
|
|
43
|
+
".yaml": _LINE_HASH,
|
|
44
|
+
".yml": _LINE_HASH,
|
|
45
|
+
".cfg": _LINE_HASH,
|
|
46
|
+
".ini": _LINE_HASH,
|
|
47
|
+
".js": _LINE_SLASH,
|
|
48
|
+
".jsx": _LINE_SLASH,
|
|
49
|
+
".ts": _LINE_SLASH,
|
|
50
|
+
".tsx": _LINE_SLASH,
|
|
51
|
+
".c": _LINE_SLASH,
|
|
52
|
+
".h": _LINE_SLASH,
|
|
53
|
+
".cpp": _LINE_SLASH,
|
|
54
|
+
".cc": _LINE_SLASH,
|
|
55
|
+
".java": _LINE_SLASH,
|
|
56
|
+
".css": _LINE_SLASH,
|
|
57
|
+
".go": _LINE_SLASH,
|
|
58
|
+
".rs": _LINE_SLASH,
|
|
59
|
+
".html": _BLOCK_HTML,
|
|
60
|
+
".htm": _BLOCK_HTML,
|
|
61
|
+
".xml": _BLOCK_HTML,
|
|
62
|
+
".md": _BLOCK_HTML,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Style:
|
|
68
|
+
"""Banner detection and output settings.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
width: Target total line length for a rewritten banner or rule.
|
|
72
|
+
fill: The single fill character used in output.
|
|
73
|
+
detect_chars: Characters recognised as banner fill in input.
|
|
74
|
+
min_run: Minimum identical-fill run length to count as a banner side.
|
|
75
|
+
require_both_sides: Only treat a comment as a banner when it has a fill
|
|
76
|
+
run on both sides of the title.
|
|
77
|
+
dividers: Also standardise stand-alone title-less rules.
|
|
78
|
+
style: Output form, `single` (one line) or `box` (three lines).
|
|
79
|
+
max_title: Optional hard cap on title length, on top of the width fit.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
width: int = DEFAULT_WIDTH
|
|
83
|
+
fill: str = DEFAULT_FILL
|
|
84
|
+
detect_chars: str = DEFAULT_DETECT_CHARS
|
|
85
|
+
min_run: int = DEFAULT_MIN_RUN
|
|
86
|
+
require_both_sides: bool = False
|
|
87
|
+
dividers: bool = False
|
|
88
|
+
style: str = DEFAULT_STYLE
|
|
89
|
+
max_title: int | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def syntax_for(suffix: str) -> tuple[str, str] | None:
|
|
93
|
+
"""Return the comment syntax for a file suffix, or `None` if unsupported.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
suffix: A file extension including the dot (for example `.py`).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The `(opener, closer)` comment tokens, or `None`.
|
|
100
|
+
"""
|
|
101
|
+
return _SYNTAX_BY_SUFFIX.get(suffix.lower())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _content(line: str) -> str:
|
|
105
|
+
"""Return `line` without its trailing newline."""
|
|
106
|
+
return line.rstrip("\r\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _eol(line: str) -> str:
|
|
110
|
+
"""Return the trailing newline of a keepends line, or empty at end of file."""
|
|
111
|
+
return line[len(_content(line)) :]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _extract(content: str, syntax: tuple[str, str]) -> tuple[str, str] | None:
|
|
115
|
+
"""Split a full-line comment into its indent and inner text.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
content: One line with the newline already removed.
|
|
119
|
+
syntax: The `(opener, closer)` comment tokens.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
`(indent, inner)` when the whole line is a comment in this syntax, else
|
|
123
|
+
`None` (a code line, or a trailing comment).
|
|
124
|
+
"""
|
|
125
|
+
opener, closer = syntax
|
|
126
|
+
stripped = content.lstrip()
|
|
127
|
+
indent = content[: len(content) - len(stripped)]
|
|
128
|
+
if not stripped.startswith(opener):
|
|
129
|
+
return None
|
|
130
|
+
inner = stripped[len(opener) :]
|
|
131
|
+
if closer:
|
|
132
|
+
if not inner.rstrip().endswith(closer):
|
|
133
|
+
return None
|
|
134
|
+
inner = inner.rstrip()[: -len(closer)]
|
|
135
|
+
return indent, inner
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _side_runs(inner: str, style: Style) -> tuple[int, int, str]:
|
|
139
|
+
"""Measure the leading and trailing fill runs and the title between them.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
inner: The comment's inner text (between the comment tokens).
|
|
143
|
+
style: The active settings.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
`(leading_run, trailing_run, title)` for the stripped inner text.
|
|
147
|
+
"""
|
|
148
|
+
stripped = inner.strip()
|
|
149
|
+
if not stripped:
|
|
150
|
+
return 0, 0, ""
|
|
151
|
+
lead = 0
|
|
152
|
+
if stripped[0] in style.detect_chars:
|
|
153
|
+
char = stripped[0]
|
|
154
|
+
while lead < len(stripped) and stripped[lead] == char:
|
|
155
|
+
lead += 1
|
|
156
|
+
trail = 0
|
|
157
|
+
if stripped[-1] in style.detect_chars:
|
|
158
|
+
char = stripped[-1]
|
|
159
|
+
while trail < len(stripped) and stripped[-1 - trail] == char:
|
|
160
|
+
trail += 1
|
|
161
|
+
title = stripped[lead : len(stripped) - trail].strip()
|
|
162
|
+
return lead, trail, title
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_rule(content: str, syntax: tuple[str, str], style: Style) -> bool:
|
|
166
|
+
"""Return whether a line is a title-less fill rule."""
|
|
167
|
+
extracted = _extract(content, syntax)
|
|
168
|
+
if extracted is None:
|
|
169
|
+
return False
|
|
170
|
+
lead, trail, title = _side_runs(extracted[1], style)
|
|
171
|
+
return not title and (lead >= style.min_run or trail >= style.min_run)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _banner_title(content: str, syntax: tuple[str, str], style: Style) -> str | None:
|
|
175
|
+
"""Return the title if a line is a single-line banner, else `None`."""
|
|
176
|
+
extracted = _extract(content, syntax)
|
|
177
|
+
if extracted is None:
|
|
178
|
+
return None
|
|
179
|
+
lead, trail, title = _side_runs(extracted[1], style)
|
|
180
|
+
if not title:
|
|
181
|
+
return None
|
|
182
|
+
has_lead = lead >= style.min_run
|
|
183
|
+
has_trail = trail >= style.min_run
|
|
184
|
+
ok = (has_lead and has_trail) if style.require_both_sides else (has_lead or has_trail)
|
|
185
|
+
return title if ok else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _box_title(content: str, syntax: tuple[str, str], style: Style) -> str | None:
|
|
189
|
+
"""Return the title of a box's middle comment line, else `None`."""
|
|
190
|
+
extracted = _extract(content, syntax)
|
|
191
|
+
if extracted is None:
|
|
192
|
+
return None
|
|
193
|
+
title = _side_runs(extracted[1], style)[2]
|
|
194
|
+
return title or None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _format_banner(indent: str, syntax: tuple[str, str], title: str, style: Style) -> str:
|
|
198
|
+
"""Render a canonical single-line banner: fill padded around a centred title."""
|
|
199
|
+
opener, closer = syntax
|
|
200
|
+
open_part = f"{opener} "
|
|
201
|
+
close_part = f" {closer}" if closer else ""
|
|
202
|
+
fixed = len(indent) + len(open_part) + 1 + len(title) + 1 + len(close_part)
|
|
203
|
+
total = max(style.width - fixed, 2 * style.min_run)
|
|
204
|
+
left = total // 2
|
|
205
|
+
right = total - left
|
|
206
|
+
return f"{indent}{open_part}{style.fill * left} {title} {style.fill * right}{close_part}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _format_rule(indent: str, syntax: tuple[str, str], style: Style) -> str:
|
|
210
|
+
"""Render a canonical title-less fill rule padded to the target width."""
|
|
211
|
+
opener, closer = syntax
|
|
212
|
+
open_part = f"{opener} "
|
|
213
|
+
close_part = f" {closer}" if closer else ""
|
|
214
|
+
count = max(style.width - len(indent) - len(open_part) - len(close_part), style.min_run)
|
|
215
|
+
return f"{indent}{open_part}{style.fill * count}{close_part}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _format_title_line(indent: str, syntax: tuple[str, str], title: str) -> str:
|
|
219
|
+
"""Render the middle title line of a box header."""
|
|
220
|
+
opener, closer = syntax
|
|
221
|
+
close_part = f" {closer}" if closer else ""
|
|
222
|
+
return f"{indent}{opener} {title}{close_part}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render(indent: str, syntax: tuple[str, str], title: str, style: Style) -> list[str]:
|
|
226
|
+
"""Return the canonical output lines for a banner in the chosen style."""
|
|
227
|
+
if style.style == "box":
|
|
228
|
+
rule = _format_rule(indent, syntax, style)
|
|
229
|
+
return [rule, _format_title_line(indent, syntax, title), rule]
|
|
230
|
+
return [_format_banner(indent, syntax, title, style)]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _title_limit(indent: str, syntax: tuple[str, str], style: Style) -> int:
|
|
234
|
+
"""Return the maximum title length that fits the chosen style and cap."""
|
|
235
|
+
opener, closer = syntax
|
|
236
|
+
open_part = f"{opener} "
|
|
237
|
+
close_part = f" {closer}" if closer else ""
|
|
238
|
+
if style.style == "box":
|
|
239
|
+
fit = style.width - len(indent) - len(open_part) - len(close_part)
|
|
240
|
+
else:
|
|
241
|
+
fit = (
|
|
242
|
+
style.width
|
|
243
|
+
- len(indent)
|
|
244
|
+
- len(open_part)
|
|
245
|
+
- 2 # the two spaces framing the title
|
|
246
|
+
- len(close_part)
|
|
247
|
+
- 2 * style.min_run
|
|
248
|
+
)
|
|
249
|
+
fit = max(fit, 1)
|
|
250
|
+
return min(fit, style.max_title) if style.max_title is not None else fit
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _too_long_error(path: str, lineno: int, title: str, limit: int, style: Style) -> str:
|
|
254
|
+
"""Build the error message for an over-long title."""
|
|
255
|
+
base = (
|
|
256
|
+
f"{path}:{lineno}: section title is {len(title)} chars but the limit is "
|
|
257
|
+
f"{limit} ({style.style} style, width {style.width})."
|
|
258
|
+
)
|
|
259
|
+
if style.style == "single":
|
|
260
|
+
return base + " Shorten it, or use box style for a multi-line header."
|
|
261
|
+
return base + " Shorten it."
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _emit_unit(
|
|
265
|
+
lines: list[str],
|
|
266
|
+
start: int,
|
|
267
|
+
end: int,
|
|
268
|
+
indent: str,
|
|
269
|
+
title: str,
|
|
270
|
+
syntax: tuple[str, str],
|
|
271
|
+
style: Style,
|
|
272
|
+
path: str,
|
|
273
|
+
file_eol: str,
|
|
274
|
+
) -> tuple[str, bool, str | None]:
|
|
275
|
+
"""Render one banner unit, or pass it through on an over-long title.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
lines: The file split with line endings kept.
|
|
279
|
+
start: Index of the unit's first source line.
|
|
280
|
+
end: Index of the unit's last source line.
|
|
281
|
+
indent: The unit's leading whitespace.
|
|
282
|
+
title: The extracted title.
|
|
283
|
+
syntax: The `(opener, closer)` comment tokens.
|
|
284
|
+
style: The active settings.
|
|
285
|
+
path: Display path for error messages.
|
|
286
|
+
file_eol: The file's dominant newline, used when a source line has none.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
`(chunk, changed, error)`. On error, `chunk` is the original text so the
|
|
290
|
+
source is left untouched.
|
|
291
|
+
"""
|
|
292
|
+
original = "".join(lines[start : end + 1])
|
|
293
|
+
limit = _title_limit(indent, syntax, style)
|
|
294
|
+
if len(title) > limit:
|
|
295
|
+
return original, False, _too_long_error(path, start + 1, title, limit, style)
|
|
296
|
+
|
|
297
|
+
rendered = _render(indent, syntax, title, style)
|
|
298
|
+
eol = _eol(lines[start]) or file_eol
|
|
299
|
+
had_final_newline = bool(_eol(lines[end]))
|
|
300
|
+
chunk = ""
|
|
301
|
+
for idx, out_line in enumerate(rendered):
|
|
302
|
+
chunk += out_line
|
|
303
|
+
if idx < len(rendered) - 1 or had_final_newline:
|
|
304
|
+
chunk += eol
|
|
305
|
+
return chunk, chunk != original, None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def process_text(
|
|
309
|
+
text: str, syntax: tuple[str, str], style: Style, path: str = "<text>"
|
|
310
|
+
) -> tuple[str, int, list[str]]:
|
|
311
|
+
"""Reformat every section-header banner in `text`.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
text: The full file contents.
|
|
315
|
+
syntax: The `(opener, closer)` comment tokens for the file.
|
|
316
|
+
style: The active settings.
|
|
317
|
+
path: Display path used in error messages.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
`(new_text, changed_count, errors)`. Passthrough lines keep their exact
|
|
321
|
+
original newline; rewritten units use their first source line's newline.
|
|
322
|
+
"""
|
|
323
|
+
lines = text.splitlines(keepends=True)
|
|
324
|
+
n = len(lines)
|
|
325
|
+
file_eol = "\r\n" if "\r\n" in text else "\n"
|
|
326
|
+
out: list[str] = []
|
|
327
|
+
changed = 0
|
|
328
|
+
errors: list[str] = []
|
|
329
|
+
|
|
330
|
+
i = 0
|
|
331
|
+
while i < n:
|
|
332
|
+
c0 = _content(lines[i])
|
|
333
|
+
|
|
334
|
+
# Three-line box: rule / title comment / rule, all the same indent.
|
|
335
|
+
if i + 2 < n:
|
|
336
|
+
c1, c2 = _content(lines[i + 1]), _content(lines[i + 2])
|
|
337
|
+
if (
|
|
338
|
+
_is_rule(c0, syntax, style)
|
|
339
|
+
and not _is_rule(c1, syntax, style)
|
|
340
|
+
and _is_rule(c2, syntax, style)
|
|
341
|
+
):
|
|
342
|
+
title = _box_title(c1, syntax, style)
|
|
343
|
+
indents = [_extract(c, syntax) for c in (c0, c1, c2)]
|
|
344
|
+
if title and all(indents) and len({e[0] for e in indents}) == 1:
|
|
345
|
+
chunk, did, error = _emit_unit(
|
|
346
|
+
lines, i, i + 2, indents[0][0], title, syntax, style, path, file_eol
|
|
347
|
+
)
|
|
348
|
+
out.append(chunk)
|
|
349
|
+
changed += did
|
|
350
|
+
if error:
|
|
351
|
+
errors.append(error)
|
|
352
|
+
i += 3
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
# Stand-alone title-less rule.
|
|
356
|
+
if _is_rule(c0, syntax, style):
|
|
357
|
+
if style.dividers:
|
|
358
|
+
rule = _format_rule(_extract(c0, syntax)[0], syntax, style)
|
|
359
|
+
new_line = rule + (_eol(lines[i]) or file_eol) if _eol(lines[i]) else rule
|
|
360
|
+
out.append(new_line)
|
|
361
|
+
changed += new_line != lines[i]
|
|
362
|
+
else:
|
|
363
|
+
out.append(lines[i])
|
|
364
|
+
i += 1
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# Single-line banner.
|
|
368
|
+
title = _banner_title(c0, syntax, style)
|
|
369
|
+
if title:
|
|
370
|
+
chunk, did, error = _emit_unit(
|
|
371
|
+
lines, i, i, _extract(c0, syntax)[0], title, syntax, style, path, file_eol
|
|
372
|
+
)
|
|
373
|
+
out.append(chunk)
|
|
374
|
+
changed += did
|
|
375
|
+
if error:
|
|
376
|
+
errors.append(error)
|
|
377
|
+
i += 1
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
out.append(lines[i])
|
|
381
|
+
i += 1
|
|
382
|
+
|
|
383
|
+
return "".join(out), changed, errors
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sectionise
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standardise section-header comment banners
|
|
5
|
+
Project-URL: Homepage, https://github.com/MitchellNeedham/sectionise
|
|
6
|
+
Project-URL: Repository, https://github.com/MitchellNeedham/sectionise
|
|
7
|
+
Author-email: Mitchell Needham <contact@mitchellneedham.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: comments,formatter,linter,pre-commit,section-headers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# sectionise
|
|
19
|
+
|
|
20
|
+
[](https://pypi.org/project/sectionise/)
|
|
21
|
+
[](https://pypi.org/project/sectionise/)
|
|
22
|
+
|
|
23
|
+
Standardise section-header comment banners across a codebase to one canonical
|
|
24
|
+
style. Runs as a pre-commit hook or a CLI, and is configured from
|
|
25
|
+
`pyproject.toml` (with flag overrides).
|
|
26
|
+
|
|
27
|
+
A section header is a comment styled as a banner: a title framed by a run of
|
|
28
|
+
fill characters. Real code accumulates many variants; `sectionise` detects them
|
|
29
|
+
and rewrites each to the same shape.
|
|
30
|
+
|
|
31
|
+
## What it standardises
|
|
32
|
+
|
|
33
|
+
| Variant | Example in |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Single-line, framed both sides | `# ------- Loading models -------` |
|
|
36
|
+
| Single-line, filled one side | `# Ancillary functions ----------` |
|
|
37
|
+
| Unicode fill (em/en dash, box rules) | `# ——— Loading models ———` |
|
|
38
|
+
| Three-line box | a rule line, a title comment, a rule line |
|
|
39
|
+
|
|
40
|
+
All of them normalise to the configured style, for example the single-line form:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
# ---------------------------- Loading models -----------------------------
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Detection is conservative: only full-line comments whose content is framed by a
|
|
47
|
+
fill run of at least `min_run` characters are touched. Ordinary comments,
|
|
48
|
+
trailing comments, and commented-out code (`# print("=== run ===")`) are left
|
|
49
|
+
alone. Title-less rules (`# --------`) are ignored unless `dividers` is enabled.
|
|
50
|
+
|
|
51
|
+
A title too long to fit the chosen style is reported as an error suggesting a
|
|
52
|
+
shorter title or the multi-line `box` style, rather than being silently
|
|
53
|
+
overflowed.
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install sectionise
|
|
59
|
+
# or: uv add sectionise (add to a project)
|
|
60
|
+
# or: uvx sectionise ... (run without installing)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Use as a pre-commit hook
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- repo: https://github.com/MitchellNeedham/sectionise
|
|
67
|
+
rev: 0.1.0
|
|
68
|
+
hooks:
|
|
69
|
+
- id: sectionise
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
It fixes in place and fails the commit if it changed anything (re-stage and
|
|
73
|
+
commit again), the same way `ruff-format` behaves.
|
|
74
|
+
|
|
75
|
+
## Use as a CLI
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
sectionise path/to/file.py # fix in place
|
|
79
|
+
sectionise --check path/to/file.py # report only, no writes
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Settings resolve with the precedence **flags > `[tool.sectionise]` > defaults**.
|
|
85
|
+
Put shared settings in each repo's `pyproject.toml`:
|
|
86
|
+
|
|
87
|
+
```toml
|
|
88
|
+
[tool.sectionise]
|
|
89
|
+
width = 88
|
|
90
|
+
style = "single" # or "box"
|
|
91
|
+
fill = "-"
|
|
92
|
+
detect_chars = "-=*_~#—–─═"
|
|
93
|
+
min_run = 3
|
|
94
|
+
require_both_sides = false
|
|
95
|
+
dividers = false
|
|
96
|
+
# max_title = 60 # optional hard cap on title length
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Setting | Flag | Default | Meaning |
|
|
100
|
+
| --- | --- | --- | --- |
|
|
101
|
+
| `width` | `--width` | `88` | Target total line length. |
|
|
102
|
+
| `style` | `--style` | `single` | Output form: `single` line or 3-line `box`. |
|
|
103
|
+
| `fill` | `--fill` | `-` | Output fill character. |
|
|
104
|
+
| `detect_chars` | `--detect-chars` | `-=*_~#—–─═` | Characters recognised as fill in input. |
|
|
105
|
+
| `min_run` | `--min-run` | `3` | Minimum fill-run length to count as a banner side. |
|
|
106
|
+
| `require_both_sides` | `--require-both-sides` | `false` | Only treat both-sided comments as banners. |
|
|
107
|
+
| `dividers` | `--dividers` | `false` | Also standardise title-less rules. |
|
|
108
|
+
| `max_title` | `--max-title` | none | Hard cap on title length, on top of the width fit. |
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv sync
|
|
114
|
+
uv run pytest
|
|
115
|
+
uv run ruff check
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Publishing
|
|
119
|
+
|
|
120
|
+
Releases go to [PyPI](https://pypi.org/project/sectionise/) on a version tag.
|
|
121
|
+
|
|
122
|
+
- **Set the token once.** In the GitHub repo, add a repository secret named
|
|
123
|
+
`PYPI_API_TOKEN` holding a PyPI API token
|
|
124
|
+
(Settings > Secrets and variables > Actions).
|
|
125
|
+
- **Release.** Tag a version and push it; the publish workflow builds and
|
|
126
|
+
uploads:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
git tag 0.1.0
|
|
130
|
+
git push origin 0.1.0
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The version comes from the tag via `hatch-vcs`, so the tag is the single
|
|
134
|
+
source of truth.
|
|
135
|
+
|
|
136
|
+
To publish from your machine instead, copy `.env.example` to `.env`, paste your
|
|
137
|
+
token, and run:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
set -a && source .env && set +a
|
|
141
|
+
uv build && uv publish dist/*
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sectionise/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sectionise/cli.py,sha256=JCcyGTAH5bNcd1N9XLloMCbcAroz0i84DCxb9RTPf1M,5214
|
|
3
|
+
sectionise/core.py,sha256=o3znwH_oJUM9ZJNmYrQ4JVmJ5wOYUmYFCDVhbMUL2K4,13458
|
|
4
|
+
sectionise-0.1.0.dist-info/METADATA,sha256=2QenkoDpEDoN7vP6o49n3swAwCZYl5xWSNTME4DfC30,4740
|
|
5
|
+
sectionise-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
sectionise-0.1.0.dist-info/entry_points.txt,sha256=nucBwiJ1zfWLzS4nO5yjPstuC67PbPjc8uN2eXVm_oQ,51
|
|
7
|
+
sectionise-0.1.0.dist-info/licenses/LICENSE,sha256=DgHBfjd2VifqW82gIJBB0calAMjf5mEtxQGyptIpEfw,1073
|
|
8
|
+
sectionise-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mitchell Needham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
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
|
|
10
|
+
furnished 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
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|