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 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
+ [![PyPI](https://img.shields.io/pypi/v/sectionise.svg)](https://pypi.org/project/sectionise/)
21
+ [![Python](https://img.shields.io/pypi/pyversions/sectionise.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sectionise = sectionise.cli:main
@@ -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.