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
« 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.
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.
21Has zero dependencies beyond the Python standard library.
22"""
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)
47# constants:
48_WRAP_TEXT_WIDTH: Final[int] = 125
49TRIPLE_BACKTICK: Final[str] = "```"
52def _markdown_file_template(tool_name: str) -> str:
53 return f"""# {tool_name}
55<!-- BEGIN-MANPAGE-USAGE -->
56<!-- END-MANPAGE-USAGE -->
58<!-- BEGIN-MANPAGE-DESCRIPTION -->
59<!-- END-MANPAGE-DESCRIPTION -->
61# Options
63<!-- BEGIN-MANPAGE-DETAILS -->
64<!-- END-MANPAGE-DETAILS -->
65"""
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.
78Has zero dependencies beyond the Python standard library.
80Example README.md file:
82```markdown
83{_markdown_file_template('Example CLI Tool').replace("-MANPAGE-", ".MANPAGE.").rstrip()}
84```
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:
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```
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.
100The [BEGIN|END]-MANPAGE-DESCRIPTION marker pair is optional.
102Subparser sections are rendered recursively. Subparsers can be nested arbitrarily.
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.
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.
110Supported Markdown input contract:
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.
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
131```shell
132python3 -m bzfs_main.util.markdown_from_argparse --readme=README.md --module=bzfs_main.bzfs
133```
134""",
135 )
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
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()
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)
198#############################################################################
199@final
200class MarkdownFromArgparse:
201 """API for Python clients."""
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
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)
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:]
234 def _render_blocks(self, text: str, *, is_list: bool = False) -> list[str]:
235 """Renders argparse help into the README's supported Markdown subset.
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 )
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()]
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
275 def _is_markdown_block(self, block: str) -> bool:
276 """Returns True when wrapping would break explicit Markdown structure.
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?"
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 """
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()]
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
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
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
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:])
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)
342 def _is_verbatim_block(self, block: str) -> bool:
343 """Returns True when physical line layout should be fenced instead of wrapped.
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()]
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
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
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=""))
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 += [""]
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
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
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
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]
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
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."""
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
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)
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
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
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
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)
539 def _die(self, msg: str) -> NoReturn:
540 raise SystemExit(f"ERROR: {msg}")
543def _bold(text: str) -> str:
544 """Returns strongly emphasized Markdown."""
545 return f"**{text}**"
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>'
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})"
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>'
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">🔗</a>'
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))
579#############################################################################
580if __name__ == "__main__":
581 main()