sectionise 0.2.0__tar.gz → 0.3.0__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.
@@ -34,4 +34,8 @@ repos:
34
34
  entry: sectionise
35
35
  language: system
36
36
  types: [text]
37
+ # Markdown docs carry illustrative banners inside code fences that
38
+ # must not be reformatted; the tool cannot tell a fence from a real
39
+ # comment.
40
+ exclude: \.md$
37
41
  stages: [pre-commit]
@@ -4,6 +4,27 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0] - 2026-07-23
8
+
9
+ ### Added
10
+
11
+ - `fill_mode` (`width` or `fixed`) and `fill_count`, so single-line banners can
12
+ carry a set number of fill characters per run (`# ----- Title -----`) instead
13
+ of always padding to the target width. `align` still chooses the sides, and
14
+ box style and dividers are unaffected.
15
+ - `bookend`, closing a single-line banner with a mirror of the comment opener
16
+ (`# --- Title --- #`, `// --- Title --- //`). A trailing opener is recognised
17
+ on input whether or not `bookend` is set.
18
+ - Multi-character `fill` (for example `-=`), tiled and truncated to the target
19
+ width. Each of its characters is added to `detect_chars` so the output is
20
+ recognised on the next run.
21
+
22
+ ### Fixed
23
+
24
+ - Fill characters joined to the title text (`_function_name`, `#Nice`, `name_`)
25
+ are no longer stripped. Only stand-alone, whitespace-separated fill runs are
26
+ treated as decoration.
27
+
7
28
  ## [0.2.0] - 2026-07-23
8
29
 
9
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sectionise
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Standardise section-header comment banners
5
5
  Project-URL: Homepage, https://github.com/MitchellNeedham/sectionise
6
6
  Project-URL: Repository, https://github.com/MitchellNeedham/sectionise
@@ -135,7 +135,7 @@ pip install sectionise
135
135
 
136
136
  ```yaml
137
137
  - repo: https://github.com/MitchellNeedham/sectionise
138
- rev: 0.2.0
138
+ rev: 0.3.0
139
139
  hooks:
140
140
  - id: sectionise
141
141
  ```
@@ -174,6 +174,9 @@ overrides are honoured.
174
174
  style = "single" # or "box"
175
175
  fill = "-"
176
176
  align = "centre" # or "left"
177
+ fill_mode = "width" # or "fixed"
178
+ fill_count = 3 # fill characters per run when fill_mode = "fixed"
179
+ bookend = false # close a single-line banner with a mirror of the opener
177
180
  detect_chars = "-=*_~#—–─═"
178
181
  min_run = 3
179
182
  require_both_sides = false
@@ -189,8 +192,11 @@ encoding = "utf-8"
189
192
  | --- | --- | --- | --- |
190
193
  | `width` | `--width` | per language | Target total line length. |
191
194
  | `style` | `--style` | `single` | Output form: `single` line or 3-line `box`. |
192
- | `fill` | `--fill` | `-` | Output fill character. |
195
+ | `fill` | `--fill` | `-` | Output fill. One or more characters; a multi-character fill is tiled to the width. |
193
196
  | `align` | `--align` | `centre` | Single-line title placement: `centre` or `left`. |
197
+ | `fill_mode` | `--fill-mode` | `width` | Single-line fill: `width` (pad to `width`) or `fixed` (a set count per run). |
198
+ | `fill_count` | `--fill-count` | `3` | Fill characters per run when `fill_mode` is `fixed`. Must be at least `min_run`. |
199
+ | `bookend` | `--bookend` | `false` | Close a single-line banner with a mirror of the comment opener. Line comments only. |
194
200
  | `detect_chars` | `--detect-chars` | `-=*_~#—–─═` | Characters recognised as fill in input. |
195
201
  | `min_run` | `--min-run` | `3` | Minimum fill-run length to count as a banner side. |
196
202
  | `require_both_sides` | `--require-both-sides` | `false` | Only treat both-sided comments as banners. |
@@ -230,7 +236,63 @@ align = "left"
230
236
  # Loading models -----------------------------------------------------------------------
231
237
  ```
232
238
 
233
- Any single non-space character is a valid fill, including an emoji:
239
+ A fixed number of fill characters per run, so the line length follows the title
240
+ instead of the target width:
241
+
242
+ ```toml
243
+ [tool.sectionise]
244
+ fill_mode = "fixed"
245
+ fill_count = 5
246
+ ```
247
+
248
+ ```
249
+ # ----- Loading models -----
250
+ # ----- Setup -----
251
+ ```
252
+
253
+ `align` still decides the sides: `centre` (the default) puts a matching run on
254
+ both sides as above, while `left` keeps the trailing run only (`# Loading models
255
+ -----`). Box style and stand-alone dividers ignore `fill_mode` and always span
256
+ `width`.
257
+
258
+ Book-ended headers close with a mirror of the comment opener, so the line ends
259
+ the way it starts:
260
+
261
+ ```toml
262
+ [tool.sectionise]
263
+ fill_mode = "fixed"
264
+ fill_count = 4
265
+ bookend = true
266
+ ```
267
+
268
+ ```
269
+ # ---- Loading models ---- #
270
+ ```
271
+
272
+ The closing marker is always the opener, so it matches by construction: a `//`
273
+ comment closes with `//`, a `#` comment with `#`. Block comments already close
274
+ with their own token, so `bookend` leaves them alone. A trailing opener is
275
+ recognised on input whether or not `bookend` is set, so existing
276
+ `// --- x --- //` banners are picked up either way.
277
+
278
+ The fill can be more than one character; a multi-character fill is tiled and
279
+ truncated to reach the width:
280
+
281
+ ```toml
282
+ [tool.sectionise]
283
+ fill = "=-"
284
+ fill_mode = "fixed"
285
+ fill_count = 8
286
+ ```
287
+
288
+ ```
289
+ # =-=-=-=- Loading models =-=-=-=-
290
+ ```
291
+
292
+ A fill longer than the run it fills is truncated to its leading characters, so
293
+ in `fixed` mode a fill longer than `fill_count` is cut mid-motif.
294
+
295
+ Any non-space fill is valid, including an emoji:
234
296
 
235
297
  ```toml
236
298
  [tool.sectionise]
@@ -243,7 +305,7 @@ width = 40
243
305
  ```
244
306
 
245
307
  `width` counts characters, not display columns, so a double-width emoji fill
246
- lands on target by count but overshoots visually. Fun, not recommended.
308
+ lands on target by count but is wider on screen.
247
309
 
248
310
  ### Per-language overrides
249
311
 
@@ -118,7 +118,7 @@ pip install sectionise
118
118
 
119
119
  ```yaml
120
120
  - repo: https://github.com/MitchellNeedham/sectionise
121
- rev: 0.2.0
121
+ rev: 0.3.0
122
122
  hooks:
123
123
  - id: sectionise
124
124
  ```
@@ -157,6 +157,9 @@ overrides are honoured.
157
157
  style = "single" # or "box"
158
158
  fill = "-"
159
159
  align = "centre" # or "left"
160
+ fill_mode = "width" # or "fixed"
161
+ fill_count = 3 # fill characters per run when fill_mode = "fixed"
162
+ bookend = false # close a single-line banner with a mirror of the opener
160
163
  detect_chars = "-=*_~#—–─═"
161
164
  min_run = 3
162
165
  require_both_sides = false
@@ -172,8 +175,11 @@ encoding = "utf-8"
172
175
  | --- | --- | --- | --- |
173
176
  | `width` | `--width` | per language | Target total line length. |
174
177
  | `style` | `--style` | `single` | Output form: `single` line or 3-line `box`. |
175
- | `fill` | `--fill` | `-` | Output fill character. |
178
+ | `fill` | `--fill` | `-` | Output fill. One or more characters; a multi-character fill is tiled to the width. |
176
179
  | `align` | `--align` | `centre` | Single-line title placement: `centre` or `left`. |
180
+ | `fill_mode` | `--fill-mode` | `width` | Single-line fill: `width` (pad to `width`) or `fixed` (a set count per run). |
181
+ | `fill_count` | `--fill-count` | `3` | Fill characters per run when `fill_mode` is `fixed`. Must be at least `min_run`. |
182
+ | `bookend` | `--bookend` | `false` | Close a single-line banner with a mirror of the comment opener. Line comments only. |
177
183
  | `detect_chars` | `--detect-chars` | `-=*_~#—–─═` | Characters recognised as fill in input. |
178
184
  | `min_run` | `--min-run` | `3` | Minimum fill-run length to count as a banner side. |
179
185
  | `require_both_sides` | `--require-both-sides` | `false` | Only treat both-sided comments as banners. |
@@ -213,7 +219,63 @@ align = "left"
213
219
  # Loading models -----------------------------------------------------------------------
214
220
  ```
215
221
 
216
- Any single non-space character is a valid fill, including an emoji:
222
+ A fixed number of fill characters per run, so the line length follows the title
223
+ instead of the target width:
224
+
225
+ ```toml
226
+ [tool.sectionise]
227
+ fill_mode = "fixed"
228
+ fill_count = 5
229
+ ```
230
+
231
+ ```
232
+ # ----- Loading models -----
233
+ # ----- Setup -----
234
+ ```
235
+
236
+ `align` still decides the sides: `centre` (the default) puts a matching run on
237
+ both sides as above, while `left` keeps the trailing run only (`# Loading models
238
+ -----`). Box style and stand-alone dividers ignore `fill_mode` and always span
239
+ `width`.
240
+
241
+ Book-ended headers close with a mirror of the comment opener, so the line ends
242
+ the way it starts:
243
+
244
+ ```toml
245
+ [tool.sectionise]
246
+ fill_mode = "fixed"
247
+ fill_count = 4
248
+ bookend = true
249
+ ```
250
+
251
+ ```
252
+ # ---- Loading models ---- #
253
+ ```
254
+
255
+ The closing marker is always the opener, so it matches by construction: a `//`
256
+ comment closes with `//`, a `#` comment with `#`. Block comments already close
257
+ with their own token, so `bookend` leaves them alone. A trailing opener is
258
+ recognised on input whether or not `bookend` is set, so existing
259
+ `// --- x --- //` banners are picked up either way.
260
+
261
+ The fill can be more than one character; a multi-character fill is tiled and
262
+ truncated to reach the width:
263
+
264
+ ```toml
265
+ [tool.sectionise]
266
+ fill = "=-"
267
+ fill_mode = "fixed"
268
+ fill_count = 8
269
+ ```
270
+
271
+ ```
272
+ # =-=-=-=- Loading models =-=-=-=-
273
+ ```
274
+
275
+ A fill longer than the run it fills is truncated to its leading characters, so
276
+ in `fixed` mode a fill longer than `fill_count` is cut mid-motif.
277
+
278
+ Any non-space fill is valid, including an emoji:
217
279
 
218
280
  ```toml
219
281
  [tool.sectionise]
@@ -226,7 +288,7 @@ width = 40
226
288
  ```
227
289
 
228
290
  `width` counts characters, not display columns, so a double-width emoji fill
229
- lands on target by count but overshoots visually. Fun, not recommended.
291
+ lands on target by count but is wider on screen.
230
292
 
231
293
  ### Per-language overrides
232
294
 
@@ -141,7 +141,9 @@ def _build_parser() -> argparse.ArgumentParser:
141
141
  help="Name used to pick the comment syntax when reading stdin (default a .py name).",
142
142
  )
143
143
  parser.add_argument("--width", type=int, default=None, help="Target line length.")
144
- parser.add_argument("--fill", default=None, help="Output fill character.")
144
+ parser.add_argument(
145
+ "--fill", default=None, help="Output fill, one or more characters."
146
+ )
145
147
  parser.add_argument(
146
148
  "--detect-chars", default=None, help="Characters recognised as banner fill."
147
149
  )
@@ -163,6 +165,24 @@ def _build_parser() -> argparse.ArgumentParser:
163
165
  default=None,
164
166
  help="Single-line title placement (default centre).",
165
167
  )
168
+ parser.add_argument(
169
+ "--fill-mode",
170
+ choices=("width", "fixed"),
171
+ default=None,
172
+ help="Single-line fill: pad to width, or a fixed count per run.",
173
+ )
174
+ parser.add_argument(
175
+ "--fill-count",
176
+ type=int,
177
+ default=None,
178
+ help="Fill characters per run when fill-mode is fixed (default 3).",
179
+ )
180
+ parser.add_argument(
181
+ "--bookend",
182
+ action=argparse.BooleanOptionalAction,
183
+ default=None,
184
+ help="Close a single-line banner with a mirror of the comment opener.",
185
+ )
166
186
  parser.add_argument(
167
187
  "--max-title", type=int, default=None, help="Hard cap on title length."
168
188
  )
@@ -231,6 +251,9 @@ def _resolve_style(
231
251
  boxes=pick(args.boxes, "boxes", True),
232
252
  style=pick(args.style, "style", core.DEFAULT_STYLE),
233
253
  align=pick(args.align, "align", core.DEFAULT_ALIGN),
254
+ fill_mode=pick(args.fill_mode, "fill_mode", core.DEFAULT_FILL_MODE),
255
+ fill_count=pick(args.fill_count, "fill_count", core.DEFAULT_FILL_COUNT),
256
+ bookend=pick(args.bookend, "bookend", False),
234
257
  tab_width=pick(args.tab_width, "tab_width", core.DEFAULT_TAB_WIDTH),
235
258
  max_title=pick(args.max_title, "max_title", None),
236
259
  )
@@ -31,6 +31,8 @@ DEFAULT_DETECT_CHARS = "-=*_~#—–─═"
31
31
  DEFAULT_MIN_RUN = 3
32
32
  DEFAULT_STYLE = "single"
33
33
  DEFAULT_ALIGN = "centre"
34
+ DEFAULT_FILL_MODE = "width"
35
+ DEFAULT_FILL_COUNT = 3
34
36
  DEFAULT_TAB_WIDTH = 8
35
37
 
36
38
  # Comment syntax as (opener, closer). The closer is empty for line comments and
@@ -129,7 +131,9 @@ class Style:
129
131
 
130
132
  Attributes:
131
133
  width: Target total line length for a rewritten banner or rule.
132
- fill: The single fill character used in output.
134
+ fill: The fill used in output. Usually one character; a multi-character
135
+ fill is tiled and truncated to the exact width (`-=` gives
136
+ `-=-=-=-`), and each of its characters is added to `detect_chars`.
133
137
  detect_chars: Characters recognised as banner fill in input.
134
138
  min_run: Minimum identical-fill run length to count as a banner side.
135
139
  require_both_sides: Only treat a comment as a banner when it has a fill
@@ -141,6 +145,19 @@ class Style:
141
145
  align: Single-line title placement, `centre` (fill both sides) or `left`
142
146
  (title first, fill trailing). The American spelling `center` is
143
147
  accepted and normalised to `centre`.
148
+ fill_mode: How much fill a single-line banner carries, `width` (pad to
149
+ the target `width`) or `fixed` (`fill_count` characters per run, so
150
+ the line length grows with the title). `align` still decides the
151
+ sides: `centre` fills both, `left` fills only after the title. Box
152
+ style and stand-alone dividers are unaffected and always span
153
+ `width`.
154
+ fill_count: Fill characters per run when `fill_mode` is `fixed`. Must be
155
+ at least `min_run`, so a rendered banner is still recognised as one.
156
+ bookend: Close a single-line banner with a mirror of the comment opener,
157
+ so the line ends the way it starts (`# --- Title --- #`,
158
+ `// --- Title --- //`). Line comments only; block comments already
159
+ close with their own token, and box style is unaffected. A trailing
160
+ opener is always recognised on input regardless of this setting.
144
161
  tab_width: Columns a leading tab occupies, so tab-indented banners reach
145
162
  the target width.
146
163
  max_title: Optional hard cap on title length, on top of the width fit.
@@ -155,6 +172,9 @@ class Style:
155
172
  boxes: bool = True
156
173
  style: str = DEFAULT_STYLE
157
174
  align: str = DEFAULT_ALIGN
175
+ fill_mode: str = DEFAULT_FILL_MODE
176
+ fill_count: int = DEFAULT_FILL_COUNT
177
+ bookend: bool = False
158
178
  tab_width: int = DEFAULT_TAB_WIDTH
159
179
  max_title: int | None = None
160
180
 
@@ -180,9 +200,13 @@ class Style:
180
200
  raise ValueError(
181
201
  f"min_run must be a positive integer, got {self.min_run!r}"
182
202
  )
183
- if not isinstance(self.fill, str) or len(self.fill) != 1 or self.fill.isspace():
203
+ if (
204
+ not isinstance(self.fill, str)
205
+ or not self.fill
206
+ or any(char.isspace() for char in self.fill)
207
+ ):
184
208
  raise ValueError(
185
- f"fill must be a single non-space character, got {self.fill!r}"
209
+ f"fill must be a non-empty string without whitespace, got {self.fill!r}"
186
210
  )
187
211
  if not isinstance(self.detect_chars, str) or not self.detect_chars:
188
212
  raise ValueError(
@@ -202,6 +226,25 @@ class Style:
202
226
  object.__setattr__(self, "align", "centre")
203
227
  if self.align not in ("centre", "left"):
204
228
  raise ValueError(f"align must be 'centre' or 'left', got {self.align!r}")
229
+ if self.fill_mode not in ("width", "fixed"):
230
+ raise ValueError(
231
+ f"fill_mode must be 'width' or 'fixed', got {self.fill_mode!r}"
232
+ )
233
+ if (
234
+ not isinstance(self.fill_count, int)
235
+ or isinstance(self.fill_count, bool)
236
+ or self.fill_count < 1
237
+ ):
238
+ raise ValueError(
239
+ f"fill_count must be a positive integer, got {self.fill_count!r}"
240
+ )
241
+ # A fixed run shorter than min_run would not be re-detected as a banner,
242
+ # so a freshly written header could not be re-fitted.
243
+ if self.fill_mode == "fixed" and self.fill_count < self.min_run:
244
+ raise ValueError(
245
+ f"fill_count must be at least min_run ({self.min_run}) in fixed "
246
+ f"mode, got {self.fill_count}"
247
+ )
205
248
  if self.max_title is not None and (
206
249
  not isinstance(self.max_title, int)
207
250
  or isinstance(self.max_title, bool)
@@ -210,10 +253,14 @@ class Style:
210
253
  raise ValueError(
211
254
  f"max_title must be a positive integer or unset, got {self.max_title!r}"
212
255
  )
213
- # The output fill must be recognised on the next run, else a freshly
214
- # written banner would not be seen as one and could not be re-fitted.
215
- if self.fill not in self.detect_chars:
216
- object.__setattr__(self, "detect_chars", self.detect_chars + self.fill)
256
+ # Every output fill character must be recognised on the next run, else a
257
+ # freshly written banner would not be seen as one and could not be
258
+ # re-fitted. A multi-character fill seeds each of its characters.
259
+ missing = "".join(
260
+ dict.fromkeys(c for c in self.fill if c not in self.detect_chars)
261
+ )
262
+ if missing:
263
+ object.__setattr__(self, "detect_chars", self.detect_chars + missing)
217
264
 
218
265
 
219
266
  Syntax = tuple[str, str]
@@ -450,9 +497,41 @@ def _extract(content: str, syntax: tuple[str, str]) -> tuple[str, str] | None:
450
497
  if not inner.rstrip().endswith(closer):
451
498
  return None
452
499
  inner = inner.rstrip()[: -len(closer)]
500
+ else:
501
+ # A line-comment banner may be book-ended with a mirror of the opener
502
+ # (`# --- x --- #`, `// --- x --- //`). Drop a stand-alone trailing
503
+ # opener so the title is recovered, whatever the `bookend` setting is,
504
+ # since the opener may not be a detectable fill character.
505
+ trimmed = inner.rstrip()
506
+ if trimmed.endswith(opener) and (
507
+ len(trimmed) == len(opener) or trimmed[-len(opener) - 1].isspace()
508
+ ):
509
+ inner = trimmed[: -len(opener)]
453
510
  return indent, inner
454
511
 
455
512
 
513
+ def _strip_edge_fill(text: str, detect_chars: str) -> str:
514
+ """Drop whitespace-separated fill runs from each edge of `text`.
515
+
516
+ A run of a fill character that stands alone, bounded by whitespace or the
517
+ string edge, is decoration and is removed, so `title --- #` reduces to
518
+ `title`. A fill character joined to the title text with no space, as in
519
+ `_function_name` or `#Nice`, is part of the title and kept.
520
+ """
521
+ s = text.strip()
522
+ while s and s[0] in detect_chars:
523
+ run = len(s) - len(s.lstrip(s[0]))
524
+ if run < len(s) and not s[run].isspace():
525
+ break # attached to the title text, so keep it
526
+ s = s[run:].lstrip()
527
+ while s and s[-1] in detect_chars:
528
+ run = len(s) - len(s.rstrip(s[-1]))
529
+ if run < len(s) and not s[-1 - run].isspace():
530
+ break # attached to the title text, so keep it
531
+ s = s[: len(s) - run].rstrip()
532
+ return s
533
+
534
+
456
535
  def _side_runs(inner: str, style: Style) -> tuple[int, int, str]:
457
536
  """Measure the leading and trailing fill runs and the title between them.
458
537
 
@@ -466,20 +545,16 @@ def _side_runs(inner: str, style: Style) -> tuple[int, int, str]:
466
545
  stripped = inner.strip()
467
546
  if not stripped:
468
547
  return 0, 0, ""
548
+ # A fill run is any maximal run of detect characters, not just one repeated
549
+ # character, so a multi-character fill motif (`-=-=-=`) reads as a single
550
+ # run and the tool's own output round-trips.
469
551
  lead = 0
470
- if stripped[0] in style.detect_chars:
471
- char = stripped[0]
472
- while lead < len(stripped) and stripped[lead] == char:
473
- lead += 1
552
+ while lead < len(stripped) and stripped[lead] in style.detect_chars:
553
+ lead += 1
474
554
  trail = 0
475
- if stripped[-1] in style.detect_chars:
476
- char = stripped[-1]
477
- while trail < len(stripped) and stripped[-1 - trail] == char:
478
- trail += 1
479
- # Strip any residual fill characters left at the title edges, so decoration
480
- # like a trailing lone `#` (`# --- title --- #`) cannot drag the real fill
481
- # run into the title.
482
- title = stripped[lead : len(stripped) - trail].strip(style.detect_chars + " \t")
555
+ while trail < len(stripped) and stripped[-1 - trail] in style.detect_chars:
556
+ trail += 1
557
+ title = _strip_edge_fill(stripped[lead : len(stripped) - trail], style.detect_chars)
483
558
  return lead, trail, title
484
559
 
485
560
 
@@ -574,23 +649,53 @@ def _match_box(
574
649
  return None
575
650
 
576
651
 
652
+ def _close_part(syntax: tuple[str, str], style: Style) -> str:
653
+ """Return the trailing part of a single-line banner (space plus closer).
654
+
655
+ A block comment uses its own closer. A line comment has none, but closes
656
+ with a mirror of the opener when `bookend` is on, and is otherwise bare.
657
+ """
658
+ opener, closer = syntax
659
+ if closer:
660
+ return f" {closer}"
661
+ return f" {opener}" if style.bookend else ""
662
+
663
+
664
+ def _tile(fill: str, count: int) -> str:
665
+ """Return exactly `count` characters of the fill, tiling and truncating.
666
+
667
+ For a single-character fill this is just repetition; a multi-character fill
668
+ is repeated and cut to length, so `-=` at width 7 gives `-=-=-=-`.
669
+ """
670
+ if count <= 0:
671
+ return ""
672
+ return (fill * (count // len(fill) + 1))[:count]
673
+
674
+
577
675
  def _format_banner(
578
676
  indent: str, syntax: tuple[str, str], title: str, style: Style
579
677
  ) -> str:
580
678
  """Render a canonical single-line banner, centred or left-aligned."""
581
- opener, closer = syntax
582
- open_part = f"{opener} "
583
- close_part = f" {closer}" if closer else ""
679
+ open_part = f"{syntax[0]} "
680
+ close_part = _close_part(syntax, style)
681
+ if style.fill_mode == "fixed":
682
+ run = _tile(style.fill, style.fill_count)
683
+ if style.align == "left":
684
+ return f"{indent}{open_part}{title} {run}{close_part}"
685
+ return f"{indent}{open_part}{run} {title} {run}{close_part}"
584
686
  indent_cols = _indent_width(indent, style.tab_width)
585
687
  if style.align == "left":
586
688
  used = indent_cols + len(open_part) + len(title) + 1 + len(close_part)
587
689
  count = max(style.width - used, style.min_run)
588
- return f"{indent}{open_part}{title} {style.fill * count}{close_part}"
690
+ return f"{indent}{open_part}{title} {_tile(style.fill, count)}{close_part}"
589
691
  fixed = indent_cols + len(open_part) + 1 + len(title) + 1 + len(close_part)
590
692
  total = max(style.width - fixed, 2 * style.min_run)
591
693
  left = total // 2
592
694
  right = total - left
593
- return f"{indent}{open_part}{style.fill * left} {title} {style.fill * right}{close_part}"
695
+ return (
696
+ f"{indent}{open_part}{_tile(style.fill, left)} {title} "
697
+ f"{_tile(style.fill, right)}{close_part}"
698
+ )
594
699
 
595
700
 
596
701
  def _format_rule(indent: str, syntax: tuple[str, str], style: Style) -> str:
@@ -602,7 +707,7 @@ def _format_rule(indent: str, syntax: tuple[str, str], style: Style) -> str:
602
707
  count = max(
603
708
  style.width - indent_cols - len(open_part) - len(close_part), style.min_run
604
709
  )
605
- return f"{indent}{open_part}{style.fill * count}{close_part}"
710
+ return f"{indent}{open_part}{_tile(style.fill, count)}{close_part}"
606
711
 
607
712
 
608
713
  def _format_title_line(indent: str, syntax: tuple[str, str], title: str) -> str:
@@ -622,21 +727,30 @@ def _render(
622
727
  return [_format_banner(indent, syntax, title, style)]
623
728
 
624
729
 
625
- def _title_limit(indent: str, syntax: tuple[str, str], style: Style) -> int:
626
- """Return the maximum title length that fits the chosen style and cap."""
730
+ def _title_limit(indent: str, syntax: tuple[str, str], style: Style) -> int | None:
731
+ """Return the maximum title length for the style and cap, or `None`.
732
+
733
+ `None` means the title has no length limit, which is the case for a
734
+ fixed-fill single-line banner: its fill runs are a constant length, so the
735
+ line grows with the title and `width` imposes no bound. Only an explicit
736
+ `max_title` caps it.
737
+ """
627
738
  opener, closer = syntax
628
739
  open_part = f"{opener} "
629
- close_part = f" {closer}" if closer else ""
740
+ if style.style != "box" and style.fill_mode == "fixed":
741
+ return style.max_title
630
742
  indent_cols = _indent_width(indent, style.tab_width)
631
743
  if style.style == "box":
632
- fit = style.width - indent_cols - len(open_part) - len(close_part)
744
+ # Box is unaffected by bookend, so only its own block closer counts.
745
+ box_close = f" {closer}" if closer else ""
746
+ fit = style.width - indent_cols - len(open_part) - len(box_close)
633
747
  elif style.align == "left":
634
748
  fit = (
635
749
  style.width
636
750
  - indent_cols
637
751
  - len(open_part)
638
752
  - 1 # the space between the title and its trailing fill
639
- - len(close_part)
753
+ - len(_close_part(syntax, style))
640
754
  - style.min_run
641
755
  )
642
756
  else:
@@ -645,7 +759,7 @@ def _title_limit(indent: str, syntax: tuple[str, str], style: Style) -> int:
645
759
  - indent_cols
646
760
  - len(open_part)
647
761
  - 2 # the two spaces framing the title
648
- - len(close_part)
762
+ - len(_close_part(syntax, style))
649
763
  - 2 * style.min_run
650
764
  )
651
765
  fit = max(fit, 1)
@@ -695,7 +809,7 @@ def _emit_unit(
695
809
  """
696
810
  original = "".join(lines[start : end + 1])
697
811
  limit = _title_limit(indent, syntax, style)
698
- if len(title) > limit:
812
+ if limit is not None and len(title) > limit:
699
813
  return original, False, _too_long_error(path, start + 1, title, limit, style)
700
814
 
701
815
  rendered = _render(indent, syntax, title, style)
@@ -17,7 +17,7 @@ def write(path, text, encoding="utf-8"):
17
17
  return path
18
18
 
19
19
 
20
- # ---------------------------- Per-language defaults --------------------------
20
+ # ------------------------------- Per-language defaults --------------------------------
21
21
  @pytest.mark.parametrize(
22
22
  "suffix, comment, expected_width",
23
23
  [
@@ -33,7 +33,7 @@ def test_builtin_default_width_per_language(tmp_path, suffix, comment, expected_
33
33
  assert len(f.read_text(encoding="utf-8").splitlines()[0]) == expected_width
34
34
 
35
35
 
36
- # ------------------------------ Config precedence ----------------------------
36
+ # --------------------------------- Config precedence ----------------------------------
37
37
  def test_global_and_per_language_override(tmp_path):
38
38
  write(
39
39
  tmp_path / "pyproject.toml",
@@ -55,7 +55,30 @@ def test_flag_overrides_everything(tmp_path):
55
55
  assert len(f.read_text().splitlines()[0]) == 50
56
56
 
57
57
 
58
- # --------------------------------- Exit codes --------------------------------
58
+ # ------------------------------------- Fill mode --------------------------------------
59
+ def test_fixed_fill_mode_via_flags(tmp_path):
60
+ f = write(tmp_path / "a.py", "# ------- Setup -------\n")
61
+ assert main(["--fill-mode", "fixed", "--fill-count", "5", str(f)]) == 1
62
+ assert f.read_text(encoding="utf-8") == "# ----- Setup -----\n"
63
+
64
+
65
+ def test_fixed_fill_mode_from_config(tmp_path):
66
+ write(
67
+ tmp_path / "pyproject.toml",
68
+ '[tool.sectionise]\nfill_mode = "fixed"\nfill_count = 4\n',
69
+ )
70
+ f = write(tmp_path / "a.py", "# --- Setup ---\n")
71
+ main([str(tmp_path)])
72
+ assert f.read_text(encoding="utf-8") == "# ---- Setup ----\n"
73
+
74
+
75
+ def test_bookend_mirrors_opener_via_flag(tmp_path):
76
+ f = write(tmp_path / "a.py", "# --- Setup ---\n")
77
+ assert main(["--bookend", "--fill-mode", "fixed", "--fill-count", "4", str(f)]) == 1
78
+ assert f.read_text(encoding="utf-8") == "# ---- Setup ---- #\n"
79
+
80
+
81
+ # ------------------------------------- Exit codes -------------------------------------
59
82
  def test_exit_zero_when_clean(tmp_path, capsys):
60
83
  f = write(tmp_path / "a.py", "# --- x ---\ncode = 1\n")
61
84
  main([str(f)]) # normalise first
@@ -74,7 +97,7 @@ def test_exit_two_on_over_long_title(tmp_path, capsys):
74
97
  assert "section title" in err # errors go to stderr
75
98
 
76
99
 
77
- # ------------------------------- Check and diff ------------------------------
100
+ # ----------------------------------- Check and diff -----------------------------------
78
101
  def test_check_does_not_write(tmp_path):
79
102
  f = write(tmp_path / "a.py", "# --- x ---\n")
80
103
  main(["--check", str(f)])
@@ -90,7 +113,7 @@ def test_diff_prints_and_does_not_write(tmp_path, capsys):
90
113
  assert f.read_text() == "# --- x ---\n" # unchanged on disk
91
114
 
92
115
 
93
- # ---------------------------------- Stdin ------------------------------------
116
+ # --------------------------------------- Stdin ----------------------------------------
94
117
  def test_stdin_writes_to_stdout(monkeypatch, capsys):
95
118
  monkeypatch.setattr("sys.stdin", io.StringIO("# --- hi ---\n"))
96
119
  rc = main(["--width", "40", "-"])
@@ -105,7 +128,7 @@ def test_stdin_filename_picks_syntax(monkeypatch, capsys):
105
128
  assert capsys.readouterr().out.startswith("// ")
106
129
 
107
130
 
108
- # ------------------------------ Directory walk -------------------------------
131
+ # ----------------------------------- Directory walk -----------------------------------
109
132
  def test_directory_walk_skips_vendored_dirs(tmp_path):
110
133
  write(tmp_path / "pkg" / "y.py", "# --- y ---\n")
111
134
  write(tmp_path / ".venv" / "x.py", "# --- x ---\n")
@@ -125,7 +148,7 @@ def test_unsupported_explicit_file_warns(tmp_path, capsys):
125
148
  assert "unsupported" in capsys.readouterr().err
126
149
 
127
150
 
128
- # ---------------------------- Custom and shebang -----------------------------
151
+ # --------------------------------- Custom and shebang ---------------------------------
129
152
  def test_custom_language_from_config(tmp_path):
130
153
  write(
131
154
  tmp_path / "pyproject.toml",
@@ -152,7 +175,7 @@ def test_shebang_extensionless_script(tmp_path):
152
175
  assert len(f.read_text().splitlines()[1]) == 88 # python default width
153
176
 
154
177
 
155
- # --------------------------- Encoding and endings ----------------------------
178
+ # -------------------------------- Encoding and endings --------------------------------
156
179
  def test_lf_stays_lf(tmp_path):
157
180
  f = tmp_path / "a.py"
158
181
  f.write_bytes(b"# --- x ---\ncode = 1\n")
@@ -20,7 +20,7 @@ C = core.syntax_for(".c") # both // and /* */
20
20
 
21
21
  Case = namedtuple("Case", "name style syntax src want")
22
22
 
23
- # ------------------------------ Transform table ------------------------------
23
+ # ---------------------------------- Transform table -----------------------------------
24
24
  CASES = [
25
25
  Case(
26
26
  "centre from both sides",
@@ -64,6 +64,90 @@ CASES = [
64
64
  "# --- Setup ---\n",
65
65
  "# Setup --------------------------------\n",
66
66
  ),
67
+ Case(
68
+ "fixed fill, centre, matching run both sides",
69
+ dict(fill_mode="fixed"),
70
+ HASH,
71
+ "# ------- Setup -------\n",
72
+ "# --- Setup ---\n",
73
+ ),
74
+ Case(
75
+ "fixed fill honours fill_count",
76
+ dict(fill_mode="fixed", fill_count=5),
77
+ HASH,
78
+ "# --- HEADER ---\n",
79
+ "# ----- HEADER -----\n",
80
+ ),
81
+ Case(
82
+ "fixed fill, left align, trailing run only",
83
+ dict(fill_mode="fixed", align="left"),
84
+ HASH,
85
+ "# ------- Setup -------\n",
86
+ "# Setup ---\n",
87
+ ),
88
+ Case(
89
+ "fixed fill has no width bound so a long title fits",
90
+ dict(fill_mode="fixed", width=20),
91
+ HASH,
92
+ "# ===== a very long section title indeed =====\n",
93
+ "# --- a very long section title indeed ---\n",
94
+ ),
95
+ Case(
96
+ "box ignores fill_mode and still spans the width",
97
+ dict(fill_mode="fixed", style="box", width=40),
98
+ HASH,
99
+ "# --- Setup ---\n",
100
+ "# --------------------------------------\n# Setup\n# --------------------------------------\n",
101
+ ),
102
+ Case(
103
+ "bookend closes with a mirror of the opener",
104
+ dict(width=40, bookend=True),
105
+ HASH,
106
+ "# ------- Setup -------\n",
107
+ "# -------------- Setup --------------- #\n",
108
+ ),
109
+ Case(
110
+ "bookend, fixed fill",
111
+ dict(fill_mode="fixed", fill_count=4, bookend=True),
112
+ HASH,
113
+ "# --- NICE ---\n",
114
+ "# ---- NICE ---- #\n",
115
+ ),
116
+ Case(
117
+ "bookend mirrors a slash opener with hash fill",
118
+ dict(fill_mode="fixed", fill_count=5, fill="#", bookend=True),
119
+ SLASH,
120
+ "// --- HEADER ---\n",
121
+ "// ##### HEADER ##### //\n",
122
+ ),
123
+ Case(
124
+ "bookend, left align",
125
+ dict(width=40, align="left", bookend=True),
126
+ HASH,
127
+ "# ------- Setup -------\n",
128
+ "# Setup ------------------------------ #\n",
129
+ ),
130
+ Case(
131
+ "trailing opener marker recognised even with bookend off",
132
+ dict(width=40),
133
+ SLASH,
134
+ "// ##### HEADER ##### //\n",
135
+ "// -------------- HEADER ---------------\n",
136
+ ),
137
+ Case(
138
+ "multi-character fill is tiled to the width",
139
+ dict(width=40, fill="=-"),
140
+ HASH,
141
+ "# ------- Setup -------\n",
142
+ "# =-=-=-=-=-=-=-= Setup =-=-=-=-=-=-=-=-\n",
143
+ ),
144
+ Case(
145
+ "multi-character fill, fixed count",
146
+ dict(fill_mode="fixed", fill_count=6, fill="=-"),
147
+ HASH,
148
+ "# --- Setup ---\n",
149
+ "# =-=-=- Setup =-=-=-\n",
150
+ ),
67
151
  Case(
68
152
  "box collapses to single",
69
153
  dict(width=40, style="single"),
@@ -106,6 +190,27 @@ CASES = [
106
190
  "# ---------------- model config ---------------- #\n",
107
191
  "# ---------------------- model config ----------------------\n",
108
192
  ),
193
+ Case(
194
+ "leading fill char attached to title is kept",
195
+ dict(width=40),
196
+ HASH,
197
+ "# ------- _function_name -------\n",
198
+ "# ----------- _function_name -----------\n",
199
+ ),
200
+ Case(
201
+ "hash prefix in title is kept",
202
+ dict(width=40),
203
+ HASH,
204
+ "# --- #Nice ---\n",
205
+ "# --------------- #Nice ----------------\n",
206
+ ),
207
+ Case(
208
+ "trailing fill char attached to title is kept",
209
+ dict(width=40),
210
+ HASH,
211
+ "# --- name_ ---\n",
212
+ "# --------------- name_ ----------------\n",
213
+ ),
109
214
  Case(
110
215
  "title-less divider left alone by default",
111
216
  dict(width=40),
@@ -228,7 +333,7 @@ def test_changed_flag_matches_whether_output_differs(case):
228
333
  assert bool(changed) == (case.want != case.src)
229
334
 
230
335
 
231
- # ------------------------------ Ignored inputs -------------------------------
336
+ # ----------------------------------- Ignored inputs -----------------------------------
232
337
  @pytest.mark.parametrize(
233
338
  "src",
234
339
  [
@@ -245,7 +350,7 @@ def test_left_alone(src):
245
350
  assert errors == []
246
351
 
247
352
 
248
- # ---------------------------- String protection -----------------------------
353
+ # --------------------------------- String protection ----------------------------------
249
354
  def test_banner_inside_python_string_is_left_untouched():
250
355
  text = 'x = """\n# ==== not a comment ====\nhello\n"""\n'
251
356
  protected = core.protected_lines(text, ".py")
@@ -282,7 +387,7 @@ def test_backtick_template_literal_protects_banner():
282
387
  assert changed == 0
283
388
 
284
389
 
285
- # ------------------------------- Box handling --------------------------------
390
+ # ------------------------------------ Box handling ------------------------------------
286
391
  def test_boxes_disabled_leaves_box_untouched():
287
392
  text = "# ======\n# Setup\n# ======\n"
288
393
  out, changed, _ = core.process_text(text, HASH, Style(boxes=False, width=40))
@@ -320,7 +425,7 @@ def test_box_rule_never_shorter_than_min_run(min_run):
320
425
  assert out.splitlines()[0] == "# " + "-" * min_run
321
426
 
322
427
 
323
- # ---------------------------- Over-long titles -------------------------------
428
+ # ---------------------------------- Over-long titles ----------------------------------
324
429
  def test_too_long_single_title_errors_and_leaves_unchanged():
325
430
  text = "# --- a very long section title indeed ---\n"
326
431
  out, changed, errors = core.process_text(text, HASH, Style(width=20))
@@ -335,7 +440,7 @@ def test_box_style_accommodates_longer_titles():
335
440
  assert errors == []
336
441
 
337
442
 
338
- # ------------------------------- Alignment -----------------------------------
443
+ # ------------------------------------- Alignment --------------------------------------
339
444
  def test_left_align_is_idempotent():
340
445
  style = Style(width=40, align="left")
341
446
  once = core._format_banner("", HASH, "Section name", style)
@@ -356,18 +461,22 @@ def test_tab_indent_counts_as_configured_columns():
356
461
  assert len(line.expandtabs(8)) == 40
357
462
 
358
463
 
359
- # ------------------------------- Validation ----------------------------------
464
+ # ------------------------------------- Validation -------------------------------------
360
465
  @pytest.mark.parametrize(
361
466
  "kwargs, message",
362
467
  [
363
468
  (dict(width=0), "width"),
364
469
  (dict(width=-5), "width"),
365
470
  (dict(min_run=0), "min_run"),
366
- (dict(fill="--"), "fill"),
471
+ (dict(fill=""), "fill"),
367
472
  (dict(fill=" "), "fill"),
473
+ (dict(fill="- "), "fill"),
368
474
  (dict(detect_chars=""), "detect_chars"),
369
475
  (dict(style="banner"), "style"),
370
476
  (dict(align="middle"), "align"),
477
+ (dict(fill_mode="stretch"), "fill_mode"),
478
+ (dict(fill_count=0), "fill_count"),
479
+ (dict(fill_mode="fixed", fill_count=2, min_run=3), "min_run"),
371
480
  (dict(tab_width=0), "tab_width"),
372
481
  (dict(max_title=0), "max_title"),
373
482
  ],
@@ -385,7 +494,15 @@ def test_fill_added_to_detect_chars_for_idempotency():
385
494
  assert changed == 0
386
495
 
387
496
 
388
- # ---------------------------- Language registry ------------------------------
497
+ def test_multi_character_fill_seeds_each_char_and_round_trips():
498
+ style = Style(fill="<>", detect_chars="-=")
499
+ assert "<" in style.detect_chars and ">" in style.detect_chars
500
+ once = core._format_banner("", HASH, "Title", style)
501
+ _, changed, _ = core.process_text(once + "\n", HASH, style)
502
+ assert changed == 0
503
+
504
+
505
+ # --------------------------------- Language registry ----------------------------------
389
506
  @pytest.mark.parametrize(
390
507
  "suffix, language, width",
391
508
  [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes