Coverage for bzfs_main/util/markdown_from_argparse.py: 100%

269 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-21 12:39 +0000

1# Copyright 2024 Wolfgang Hoschek AT mac DOT com 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# 

15""" 

16Automatically generate or regenerate a markdown README.md manpage from argparse parser definitions. 

17 

18This avoids manually editing the same doc in two places, namely in the argparse.ArgumentParser help configuration 

19(help=, description=, etc.), and also in a manually edited manpage within README.md. 

20 

21Has zero dependencies beyond the Python standard library. 

22""" 

23 

24from __future__ import ( 

25 annotations, 

26) 

27import argparse 

28import html 

29import importlib 

30import os 

31import re 

32import sys 

33import textwrap 

34import urllib.parse 

35from argparse import ( 

36 ArgumentParser, 

37) 

38from pathlib import ( 

39 Path, 

40) 

41from typing import ( 

42 Final, 

43 NoReturn, 

44 final, 

45) 

46 

47# constants: 

48_WRAP_TEXT_WIDTH: Final[int] = 125 

49TRIPLE_BACKTICK: Final[str] = "```" 

50 

51 

52def _markdown_file_template(tool_name: str) -> str: 

53 return f"""# {tool_name} 

54 

55<!-- BEGIN-MANPAGE-USAGE --> 

56<!-- END-MANPAGE-USAGE --> 

57 

58<!-- BEGIN-MANPAGE-DESCRIPTION --> 

59<!-- END-MANPAGE-DESCRIPTION --> 

60 

61# Options 

62 

63<!-- BEGIN-MANPAGE-DETAILS --> 

64<!-- END-MANPAGE-DETAILS --> 

65""" 

66 

67 

68############################################################################# 

69def _argument_parser() -> ArgumentParser: 

70 cli = ArgumentParser( 

71 allow_abbrev=False, 

72 formatter_class=argparse.RawTextHelpFormatter, 

73 description=f""" 

74Automatically generate or regenerate a markdown README.md manpage from argparse parser definitions. This avoids manually 

75editing the same doc in two places, namely in the argparse.ArgumentParser help configuration (help=, description=, etc), 

76and also in a manually edited manpage within README.md. 

77 

78Has zero dependencies beyond the Python standard library. 

79 

80Example README.md file: 

81 

82```markdown 

83{_markdown_file_template('Example CLI Tool').replace("-MANPAGE-", ".MANPAGE.").rstrip()} 

84``` 

85 

86Manually replace all occurrences of '.MANPAGE.' with '-MANPAGE-' in the example file above. 

87Then run this to generate the manpage blurbs and replace the sections between the BEGIN-MANPAGE-* and END-MANPAGE-* 

88marker pairs within the given markdown file with those blurbs: 

89 

90``` 

91python3 -m bzfs_main.util.markdown_from_argparse \\ 

92 --readme=README_markdown_from_argparse.md \\ 

93 --module=bzfs_main.util.markdown_from_argparse \\ 

94 --function=_argument_parser 

95``` 

96 

97Existing file content outside of the marker pair sections is retained as-is. This enables reordering of sections, adding 

98custom document headers/footers/notes, and other forms of customization. 

99 

100The [BEGIN|END]-MANPAGE-DESCRIPTION marker pair is optional. 

101 

102Subparser sections are rendered recursively. Subparsers can be nested arbitrarily. 

103 

104Generated CLI option entries include explicit HTML `id` anchors and inline permalinks so users can refer to them via copy 

105and paste. Subparser headings also include explicit HTML `id` anchors. 

106 

107The renderer expects argparse parser `description`, `epilog`, and `help=` text in the form of blank-line-separated blocks, 

108where the first block of each `help=` text is prose. 

109 

110Supported Markdown input contract: 

111 

112- Blank lines separate blocks. In action help, the first block becomes the generated option bullet text; later blocks are 

113 indented under it. 

114- Ordinary prose blocks are rewrapped. 

115- Single-line Markdown headings (`#` through `######`) are preserved. 

116- Markdown pipe tables are preserved when the first row contains `|` and the second row is a separator row. 

117- Homogeneous Markdown list blocks are preserved when the first line starts an ordered or unordered list and following 

118 nonblank lines are list items or indented continuations. 

119- Homogeneous multiline blockquote blocks are preserved when every nonblank line starts with `>`. 

120- Triple-backtick fenced code blocks are preserved, including blank lines inside the fence. Opening and closing fences 

121 must be balanced. 

122- Blocks indented with four spaces or one tab are fenced as code. 

123- Blocks with repeated aligned columns are fenced as code. 

124 

125Ambiguous layouts are treated as prose and may be rewrapped. Use explicit triple-backtick fences for shell sessions, 

126configuration, YAML, JSON, mixed prose and examples, or any layout that must survive unchanged. 

127""", 

128 epilog=""" 

129# Examples 

130 

131```shell 

132python3 -m bzfs_main.util.markdown_from_argparse --readme=README.md --module=bzfs_main.bzfs 

133``` 

134""", 

135 ) 

136 

137 cli.add_argument( 

138 "--readme", 

139 required=True, 

140 type=Path, 

141 metavar="PATH", 

142 help="Path of README markdown file to update. If the file does not exist a template will be generated. " 

143 "Example: path/to/README.md", 

144 ) 

145 cli.add_argument( 

146 "--module", 

147 required=True, 

148 metavar="STRING", 

149 help="Python module containing the parser factory. Example: 'bzfs_main.bzfs'", 

150 ) 

151 cli.add_argument( 

152 "--function", 

153 default="argument_parser", 

154 metavar="STRING", 

155 help="Name of the no-argument parser factory function within the Python module. The function must return an " 

156 "instance of argparse.ArgumentParser. Default is '%(default)s'.", 

157 ) 

158 cli.add_argument( 

159 "--heading-level", 

160 default=1, 

161 type=int, 

162 metavar="INT", 

163 help="Markdown heading level for generated group/subparser sections. Must be >= 1. Default is '%(default)s'.", 

164 ) 

165 return cli 

166 

167 

168def _markdown_template_name(readme_path: Path) -> str: 

169 s: str = readme_path.stem # basename without file extension 

170 s = re.sub(r"^README[ _.-]?", "", s, flags=re.IGNORECASE) # remove prefix if present 

171 s = re.sub(r"[ _.-]man([ _.-]?page)?$", "", s, flags=re.IGNORECASE) # remove suffix if present 

172 s = s + " man page" 

173 return s.lstrip() 

174 

175 

176def main() -> None: 

177 """API for command line clients.""" 

178 args: argparse.Namespace = _argument_parser().parse_args() 

179 module = importlib.import_module(args.module) 

180 parser = getattr(module, args.function)() 

181 assert isinstance(parser, ArgumentParser) 

182 readme_path: Path = args.readme 

183 if readme_path.exists(): 

184 readme: str = readme_path.read_text(encoding="utf-8") 

185 msg = "updated" 

186 else: 

187 readme = _markdown_file_template(_markdown_template_name(readme_path)) 

188 msg = "created" 

189 renderer: MarkdownFromArgparse = MarkdownFromArgparse(parser=parser, readme=readme, args=args) 

190 new_readme: str = renderer.render_readme() 

191 if new_readme != readme or not readme_path.exists(): 

192 readme_path.write_text(new_readme, encoding="utf-8") 

193 else: 

194 msg = "left unchanged" 

195 print(f"Successfully {msg} {readme_path}", file=sys.stderr) 

196 

197 

198############################################################################# 

199@final 

200class MarkdownFromArgparse: 

201 """API for Python clients.""" 

202 

203 def __init__(self, parser: ArgumentParser, readme: str, args: argparse.Namespace) -> None: 

204 self.parser: Final[ArgumentParser] = parser 

205 self.readme: Final[str] = readme 

206 self.args: Final[argparse.Namespace] = args 

207 self.heading_level: Final[int] = args.heading_level 

208 assert self.heading_level >= 1, self.heading_level 

209 

210 def render_readme(self) -> str: 

211 """Returns README text with generated sections replaced from argparse.""" 

212 usage: list[str] = ( 

213 [TRIPLE_BACKTICK + "\n"] + self._format_usage(self.parser).splitlines(keepends=True) + [TRIPLE_BACKTICK + "\n"] 

214 ) 

215 details: str = self._render_help_details() 

216 lines: list[str] = self.readme.splitlines(keepends=True) 

217 if "<!-- BEGIN-MANPAGE-DESCRIPTION -->" in self.readme: 

218 descr: str = "\n".join(self._render_blocks(self.parser.description or "")).strip() + "\n" 

219 lines = self._replace(lines, "<!-- BEGIN-MANPAGE-DESCRIPTION -->", [descr], "<!-- END-MANPAGE-DESCRIPTION -->") 

220 lines = self._replace(lines, "<!-- BEGIN-MANPAGE-USAGE -->", usage, end_tag="<!-- END-MANPAGE-USAGE -->") 

221 lines = self._replace(lines, "<!-- BEGIN-MANPAGE-DETAILS -->", [details], end_tag="<!-- END-MANPAGE-DETAILS -->") 

222 return "".join(lines) 

223 

224 def _replace(self, lines: list[str], begin_tag: str, replacement: list[str], end_tag: str) -> list[str]: 

225 """Replaces the lines between begin_tag and end_tag with the given replacement.""" 

226 begin: int | None = next((i for i, line in enumerate(lines) if begin_tag in line), None) 

227 if begin is None: 

228 self._die(f"Marker not found: {begin_tag!r}") 

229 end: int | None = next((i for i, line in enumerate(lines[begin + 1 :], start=begin + 1) if end_tag in line), None) 

230 if end is None: 

231 self._die(f"Marker not found: {end_tag!r}") 

232 return lines[: begin + 1] + replacement + lines[end:] 

233 

234 def _render_blocks(self, text: str, *, is_list: bool = False) -> list[str]: 

235 """Renders argparse help into the README's supported Markdown subset. 

236 

237 In is_list mode, argparse action help is expected to start with prose as the bullet body, separated from later blocks 

238 by a blank line, with those later blocks indented beneath it. This is a small block renderer, not a full Markdown 

239 parser; mixed prose plus verbatim text in one physical block is ambiguous, and structured first-block action help is 

240 outside the intended contract. 

241 """ 

242 first_indent: str = "- " if is_list else "" 

243 later_indent: str = " " if is_list else "" 

244 if text.count(TRIPLE_BACKTICK) % 2 != 0: 

245 self._die( 

246 f"Malformed argparse help text: Opening {TRIPLE_BACKTICK} fence without a matching closing fence; " 

247 f"input text: {text!r}" 

248 ) 

249 

250 # Split prose around fenced code so blank lines inside examples survive. 

251 blocks: list[str] = [] 

252 for i, part in enumerate(text.strip("\n").split(TRIPLE_BACKTICK)): 

253 if i % 2 != 0: 

254 blocks.append(TRIPLE_BACKTICK + part + TRIPLE_BACKTICK) 

255 else: # blocks are separated by a blank line 

256 blocks += [block for block in re.split(r"\n\s*\n", part) if block.strip()] 

257 

258 results: list[str] = [] 

259 for i, block in enumerate(blocks): 

260 if i > 0: 

261 results.append("") 

262 indent: str = first_indent if i == 0 else later_indent 

263 if block.startswith(TRIPLE_BACKTICK) and block.endswith(TRIPLE_BACKTICK): 

264 results += [""] + [indent + line for line in block.splitlines()] + [""] 

265 elif self._is_markdown_block(block): 

266 results += [indent + line for line in block.splitlines()] 

267 elif self._is_verbatim_block(block): 

268 results += ( 

269 [""] + [indent + line for line in [TRIPLE_BACKTICK] + block.splitlines() + [TRIPLE_BACKTICK]] + [""] 

270 ) 

271 else: 

272 results += self._wrap_text(" ".join(block.split()), first_indent=indent, later_indent=later_indent) 

273 return results 

274 

275 def _is_markdown_block(self, block: str) -> bool: 

276 """Returns True when wrapping would break explicit Markdown structure. 

277 

278 This is a small block-level recognizer. Its job is not to understand all Markdown; it only answers: "Would normal 

279 prose wrapping destroy an explicit Markdown structure here?" 

280 

281 The deeper principle is conservative explicitness: preserve syntax that is obviously Markdown, but do not infer 

282 ambiguous things like shell commands, config formats, YAML, HTTP examples, or project-specific conventions. For those 

283 the author should specify explicit fenced code blocks. This keeps the renderer simple and avoids content-specific 

284 workarounds. 

285 """ 

286 

287 # First, split the block into nonblank physical lines. This removes blank lines and trailing whitespace, but 

288 # preserves leading indentation. Preserving leading indentation matters for nested lists. 

289 lines: list[str] = [line.rstrip() for line in block.splitlines() if line.strip()] 

290 

291 # 0. Single-line markdown headings are explicit Markdown syntax and should not be wrapped, for example: 

292 # # Heading 

293 # ### Heading 

294 markdown_heading = r"#{1,6} .+" 

295 if len(lines) == 1 and re.fullmatch(markdown_heading, lines[0]): 

296 return True 

297 

298 # 1. Empty and one-line non-heading blocks are not Markdown structures under these rules. They are ordinary prose for 

299 # the caller to wrap. 

300 if len(lines) < 2: 

301 return False 

302 

303 # 2. Markdown pipe tables. This recognizes the canonical Markdown table shape: 

304 # 

305 # | Name | Meaning | 

306 # | --- | --- | 

307 # | A | B | 

308 # 

309 # The first line must look like a table header by containing "|". The second line must be a separator row with at 

310 # least two columns, each made from at least three dashes, with optional alignment colons. 

311 # 

312 # Principle: once a block starts as a Markdown table, trust the author and preserve the whole block line-for-line. 

313 # Wrapping table rows would destroy the table. 

314 table_separator = r"\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*" 

315 if "|" in lines[0] and re.fullmatch(table_separator, lines[1]): 

316 return True 

317 

318 # 3. List blocks. The first line must declare the list; later indented lines may continue the current item. 

319 # 

320 # - unordered item 

321 # * unordered item 

322 # + unordered item 

323 # 1. ordered item 

324 # 1) ordered item 

325 # 

326 # Leading indentation is allowed, so nested lists work: 

327 # 

328 # - Parent 

329 # - Child 

330 list_line = r"\s*([-*+]|[0-9]{1,9}[.)])\s+\S.*" 

331 continuation_line = r"\s+\S.*" 

332 if re.fullmatch(list_line, lines[0]): 

333 return all(re.fullmatch(list_line, line) or re.fullmatch(continuation_line, line) for line in lines[1:]) 

334 

335 # 4. Homogeneous blockquote blocks. If prose and blockquote syntax are mixed in the same block, do not guess; 

336 # the author should separate them with a blank line or use explicit fences. 

337 # 

338 # > blockquote 

339 blockquote_line = r"\s*>\s?.*" 

340 return all(re.fullmatch(blockquote_line, line) for line in lines) 

341 

342 def _is_verbatim_block(self, block: str) -> bool: 

343 """Returns True when physical line layout should be fenced instead of wrapped. 

344 

345 The recognizer stays layout-based: specifically indented lines are manual code blocks, and repeated wide internal 

346 whitespace suggests aligned text columns. 

347 """ 

348 # Split the block into nonblank physical lines. 

349 lines = [line.rstrip() for line in block.splitlines() if line.strip()] 

350 

351 # Manual code block: line indented by at least 4 spaces (or 1 tab) 

352 if any(line.startswith((" ", "\t")) for line in lines): 

353 return True 

354 

355 # Manual text table aligned with whitespace: checks whether at least two lines contain a non-space character that 

356 # is not common sentence punctuation, followed by two or more whitespace characters, then another non-space char. 

357 has_aligned_columns: bool = sum(bool(re.search(r"[^\s.!?:;]\s{2,}\S", line)) for line in lines) >= 2 

358 return has_aligned_columns 

359 

360 def _render_help_details(self) -> str: 

361 """Renders all argparse actions including their help text as anchored Markdown reference entries.""" 

362 return "\n".join(self._render_help_details_recursive(self.parser, self.heading_level, anchor_prefix="")) 

363 

364 def _render_help_details_recursive(self, parser: ArgumentParser, heading_level: int, *, anchor_prefix: str) -> list[str]: 

365 """Includes recursive descent into nested subparsers.""" 

366 list_separator: list[str] = ["<!-- -->", ""] # prevent adjacent Markdown lists from merging 

367 all_results: list[str] = [] 

368 sub_results: list[str] = [] # subparser command details 

369 formatter: argparse.HelpFormatter = parser._get_formatter() # noqa: SLF001 # pylint: disable=protected-access 

370 mutually_exclusive_notes: dict[int, str] = self._mutually_exclusive_group_notes(parser, formatter) 

371 for group in parser._action_groups: # noqa: SLF001 # pylint: disable=protected-access # no public iterator 

372 group_results: list[str] = [] 

373 for action in self._visible_group_actions(group): 

374 gists: list[str] = [] # subparser command overview list 

375 if note := mutually_exclusive_notes.get(id(action)): 

376 group_results += self._render_blocks(note) + [""] 

377 if not isinstance(action, argparse._SubParsersAction): # noqa: SLF001 # pylint: disable=protected-access 

378 group_results += [self._render_option_title(parser, action, anchor_prefix=anchor_prefix), ""] 

379 is_list = True 

380 else: 

381 is_list = False 

382 visible_subparser_actions: list[tuple] = self._visible_subparser_actions(action) 

383 if len(visible_subparser_actions) == 0: 

384 continue 

385 for name, title, subparser, subaction in visible_subparser_actions: # generate cmd overview + details 

386 sub_anchor_prefix: str = f"{anchor_prefix}{name}~" 

387 link: str = _link(_bold(_escape_md(title)), sub_anchor_prefix) 

388 if subaction is not None and subaction.help: 

389 gists += self._render_blocks(f"{link}: {self._expand_help(subaction, formatter)}", is_list=True) 

390 else: 

391 gists += [f"- {link}"] 

392 sub_details: list[str] = [_heading(heading_level, _escape_md(title), anchor=sub_anchor_prefix), ""] 

393 if subparser.description and subparser.description != argparse.SUPPRESS: 

394 sub_details += self._render_blocks(subparser.description) + [""] 

395 elif subaction is not None and subaction.help: 

396 sub_details += self._render_blocks(self._expand_help(subaction, formatter)) + [""] 

397 sub_details += [TRIPLE_BACKTICK] + self._format_usage(subparser).splitlines() + [TRIPLE_BACKTICK, ""] 

398 sub_results += sub_details + self._render_help_details_recursive( # recurse into nested subparser 

399 subparser, heading_level + 1, anchor_prefix=sub_anchor_prefix 

400 ) 

401 gists += [""] 

402 

403 if action.help: 

404 group_results += self._render_blocks(self._expand_help(action, formatter), is_list=is_list) + [""] 

405 group_results += list_separator if len(gists) > 0 else [] 

406 group_results += gists + list_separator 

407 

408 skip_builtin_group_titles: set[str] = {"positional arguments", "optional arguments", "options"} 

409 if len(group_results) > 0 and group.title and group.title not in skip_builtin_group_titles: 

410 all_results += [_heading(heading_level, _escape_md(group.title), css_class="man-group-heading"), ""] 

411 if group.description and group.description != argparse.SUPPRESS: 

412 all_results += self._render_blocks(group.description) + [""] + list_separator 

413 all_results += group_results 

414 if parser.epilog and parser.epilog != argparse.SUPPRESS: 

415 all_results += self._render_blocks(parser.epilog) + [""] 

416 all_results += sub_results 

417 return all_results 

418 

419 def _mutually_exclusive_group_notes(self, parser: ArgumentParser, fmt: argparse.HelpFormatter) -> dict[int, str]: 

420 """Returns notes keyed by the first visible action in each mutually exclusive group.""" 

421 notes: dict[int, str] = {} 

422 for group in parser._mutually_exclusive_groups: # noqa: SLF001 # pylint: disable=protected-access 

423 actions: list[argparse.Action] = self._visible_group_actions(group) 

424 if len(actions) >= 2 or (group.required and len(actions) == 1): 

425 choice_labels: list[str] = [] 

426 for action in actions: 

427 if action.option_strings: 

428 choice_labels.append(" / ".join(_bold(_escape_md(option)) for option in action.option_strings)) 

429 else: 

430 # pylint: disable-next=protected-access 

431 met: str = fmt._get_default_metavar_for_positional(action) # noqa: SLF001 

432 metavars = fmt._metavar_formatter(action, met)(1) # noqa: SLF001 # pylint: disable=protected-access 

433 label = " ".join(metavars) 

434 choice_labels.append(_bold(_escape_md(label))) 

435 if len(actions) == 1: 

436 notes[id(actions[0])] = f"Required mutually exclusive group member: {choice_labels[0]}." 

437 else: 

438 quantifier: str = "exactly one" if group.required else "at most one" 

439 notes[id(actions[0])] = f"Mutually exclusive group: choose {quantifier} of {', '.join(choice_labels)}." 

440 return notes 

441 

442 def _visible_group_actions( 

443 self, group: argparse._ArgumentGroup # pylint: disable=protected-access 

444 ) -> list[argparse.Action]: 

445 """Returns documented actions from one argparse action group.""" 

446 group_actions: list[argparse.Action] = group._group_actions # noqa: SLF001 # pylint: disable=protected-access 

447 return [action for action in group_actions if action.help != argparse.SUPPRESS] 

448 

449 def _visible_subparser_actions( 

450 self, action: argparse._SubParsersAction # pylint: disable=protected-access 

451 ) -> list[tuple[str, str, ArgumentParser, argparse.Action | None]]: 

452 subactions: dict[str, argparse.Action] = { 

453 subact.dest: subact for subact in action._get_subactions() # noqa: SLF001 # pylint: disable=protected-access 

454 } 

455 results: list[tuple[str, str, ArgumentParser, argparse.Action | None]] = [] 

456 seen: set[int] = set() 

457 for name, subparser in action.choices.items(): 

458 if id(subparser) not in seen: 

459 seen.add(id(subparser)) 

460 subaction = subactions.get(name) 

461 if subaction is None or subaction.help != argparse.SUPPRESS: 

462 if subaction is not None and subaction.metavar: 

463 title = str(subaction.metavar) 

464 else: 

465 aliases: list[str] = [ 

466 choice_name 

467 for choice_name, choice_parser in action.choices.items() 

468 if choice_parser is subparser and choice_name != name 

469 ] 

470 title = name if len(aliases) == 0 else f"{name} ({', '.join(aliases)})" 

471 results.append((name, title, subparser, subaction)) 

472 return results 

473 

474 def _render_option_title(self, parser: ArgumentParser, action: argparse.Action, *, anchor_prefix: str = "") -> str: 

475 """Returns the rendered anchor and title line for one argparse action.""" 

476 

477 def _render_anchor_and_title(anchor: str, title: str) -> str: 

478 anchor = anchor_prefix + anchor.replace(" ", "_") 

479 result: str = f"{_html_option_title(anchor, title)} {_html_permalink(anchor)}" 

480 return result 

481 

482 formatter: argparse.HelpFormatter = parser._get_formatter() # noqa: SLF001 # pylint: disable=protected-access 

483 if not action.option_strings: 

484 dflt = formatter._get_default_metavar_for_positional(action) # noqa: SLF001 # pylint: disable=protected-access 

485 positional_args: str = formatter._format_args(action, dflt) # noqa: SLF001 # pylint: disable=protected-access 

486 if action.nargs == argparse.REMAINDER and positional_args == "...": 

487 positional_args = str(action.metavar or dflt) 

488 return _render_anchor_and_title(action.dest, _bold(_escape_md(positional_args))) 

489 elif action.nargs == 0: 

490 title_line: str = ", ".join(_bold(_escape_md(opt)) for opt in action.option_strings) 

491 else: 

492 dflt = formatter._get_default_metavar_for_optional(action) # noqa: SLF001 # pylint: disable=protected-access 

493 option_args: str = formatter._format_args(action, dflt) # noqa: SLF001 # pylint: disable=protected-access 

494 title_line = ", ".join(f"{_bold(_escape_md(opt))} *{_escape_md(option_args)}*" for opt in action.option_strings) 

495 if action.required: 

496 title_line += " _(required)_" 

497 return _render_anchor_and_title(action.option_strings[0], title_line) 

498 

499 def _wrap_text(self, text: str, *, first_indent: str = "", later_indent: str = "") -> list[str]: 

500 """Wraps prose without splitting words, option names, or paths.""" 

501 lines: list[str] = textwrap.wrap( 

502 text, 

503 width=_WRAP_TEXT_WIDTH, 

504 initial_indent=first_indent, 

505 subsequent_indent=later_indent, 

506 break_long_words=False, 

507 break_on_hyphens=False, 

508 ) 

509 for i in range(1, len(lines)): 

510 # Escape list markers that wrapping moved to column zero, where Markdown would otherwise start a nested list. 

511 continuation: str = lines[i][len(later_indent) :] 

512 continuation = re.sub(r"^([-*+]\s)", r"\\\1", continuation) # "- item" -> "\\- item" 

513 continuation = re.sub(r"^(\d+)([.)])(\s)", r"\1\\\2\3", continuation) # "1. x" -> "1\\. x", "1) x" -> "1\\) x" 

514 lines[i] = later_indent + continuation 

515 return lines 

516 

517 def _expand_help(self, action: argparse.Action, formatter: argparse.HelpFormatter) -> str: 

518 help_text: str = formatter._expand_help(action) # noqa: SLF001 # pylint: disable=protected-access 

519 return help_text 

520 

521 def _format_usage(self, parser: ArgumentParser) -> str: 

522 def _restore(key: str, previous_value: str | None) -> None: 

523 if previous_value is None: 

524 os.environ.pop(key, None) 

525 else: 

526 os.environ[key] = previous_value 

527 

528 previous_columns: str | None = os.environ.get("COLUMNS") 

529 previous_colors: str | None = os.environ.get("PYTHON_COLORS") 

530 try: 

531 os.environ["COLUMNS"] = "18" # force each option onto a separate line 

532 os.environ["PYTHON_COLORS"] = "0" # don't add color codes to generated man pages 

533 usage: str = parser.format_usage() 

534 return usage 

535 finally: 

536 _restore("COLUMNS", previous_columns) 

537 _restore("PYTHON_COLORS", previous_colors) 

538 

539 def _die(self, msg: str) -> NoReturn: 

540 raise SystemExit(f"ERROR: {msg}") 

541 

542 

543def _bold(text: str) -> str: 

544 """Returns strongly emphasized Markdown.""" 

545 return f"**{text}**" 

546 

547 

548def _heading(heading_level: int, text: str, *, anchor: str = "", css_class: str = "man-subparser-heading") -> str: 

549 """Returns a Markdown heading whose title text can be styled by CSS.""" 

550 id_attr = f' id="{html.escape(anchor, quote=True)}"' if anchor else "" 

551 return f'{"#" * heading_level} <span{id_attr} class="{css_class}">{text}</span>' 

552 

553 

554def _link(text: str, fragment: str) -> str: 

555 """Returns an inline Markdown link that points to a URL-encoded fragment.""" 

556 text = text.replace("[", "\\[").replace("]", "\\]") # escape Markdown link-label delimiters 

557 fragment = urllib.parse.quote(fragment, safe="-._~") # quote chars that are unsafe in a URL fragment 

558 return f"[{text}](#{fragment})" 

559 

560 

561def _html_option_title(anchor: str, title_line: str) -> str: 

562 """Returns an inline wrapper for a rendered argparse action title; can be styled by CSS.""" 

563 anchor = html.escape(anchor, quote=True) 

564 return f'<span id="{anchor}" class="man-option-title">{title_line}</span>' 

565 

566 

567def _html_permalink(anchor: str) -> str: 

568 """Returns an inline self-link that the user can copy and paste to refer to the section identified by the anchor.""" 

569 label = html.escape(f"Permalink to {anchor}", quote=True) 

570 fragment = urllib.parse.quote(anchor, safe="-._~") 

571 return f'<a href="#{fragment}" title="{label}" aria-label="{label}" class="man-option-permalink">&#x1F517;</a>' 

572 

573 

574def _escape_md(text: str) -> str: 

575 """Escapes generated parser metadata for Markdown inline contexts.""" 

576 return re.sub(r"([\\`*])", r"\\\1", html.escape(text, quote=False)) 

577 

578 

579############################################################################# 

580if __name__ == "__main__": 

581 main()