From 2acd115c5b71dd43ab508bb43d66b054f3f77f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 27 Aug 2026 08:17:59 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20expand=20argparse=20forma?= =?UTF-8?q?t=20specifiers=20in=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help strings such as "count (default: %(default)s)" and descriptions with "%(prog)s" rendered with the literal specifier, followed by the extension's own (default: "3") suffix. A help string starting with "Default: 3" got the same duplicate because the existing-default check was case-sensitive and demanded a space after the word. Reimplement argparse's _expand_help on the public Action attributes, apply the %(prog)s substitution from _format_text to descriptions and epilogs of the root, groups and sub-commands, and detect an existing default mention with a case-insensitive word match. --- CHANGELOG.md | 2 + roots/test-help-format-specifiers/conf.py | 8 ++++ roots/test-help-format-specifiers/index.rst | 3 ++ roots/test-help-format-specifiers/parser.py | 23 +++++++++ src/sphinx_argparse_cli/_logic.py | 27 +++++++++-- tests/test_logic.py | 53 +++++++++++++++++++++ 6 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 roots/test-help-format-specifiers/conf.py create mode 100644 roots/test-help-format-specifiers/index.rst create mode 100644 roots/test-help-format-specifiers/parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a93c783..908423b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ All notable changes to this project will be documented in this file. render sub-command epilogs. - Render the argument spec after an option with argparse's formatter, so `nargs`, `choices` and tuple metavars show as in the usage line; user-supplied metavars keep their case instead of being upper-cased. +- Expand argparse format specifiers such as `%(prog)s`, `%(default)s` and `%(choices)s` in help, descriptions and + epilogs, and skip the generated `(default: ...)` when the help already mentions a default in any case. ## 1.13.1 diff --git a/roots/test-help-format-specifiers/conf.py b/roots/test-help-format-specifiers/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-help-format-specifiers/conf.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +extensions = ["sphinx_argparse_cli"] +nitpicky = True diff --git a/roots/test-help-format-specifiers/index.rst b/roots/test-help-format-specifiers/index.rst new file mode 100644 index 0000000..708ad9c --- /dev/null +++ b/roots/test-help-format-specifiers/index.rst @@ -0,0 +1,3 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make diff --git a/roots/test-help-format-specifiers/parser.py b/roots/test-help-format-specifiers/parser.py new file mode 100644 index 0000000..576679d --- /dev/null +++ b/roots/test-help-format-specifiers/parser.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser + + +def make() -> ArgumentParser: + parser = ArgumentParser( + prog="tool", + formatter_class=ArgumentDefaultsHelpFormatter, + description="%(prog)s does things", + epilog="run %(prog)s --help", + add_help=False, + ) + parser.add_argument("--n", type=int, default=3, help="count (default: %(default)s)") + parser.add_argument("--mode", choices=["a", "b"], default="a", help="pick one of %(choices)s") + parser.add_argument("--pct", default=5, help="100%% of %(prog)s") + parser.add_argument("--capital", default=3, help="Default: 3") + parser.add_argument("--kind", type=float, help="parsed with %(type)s") + group = parser.add_argument_group("tuning", description="tune %(prog)s") + group.add_argument("--level", default=1, help="level") + run = parser.add_subparsers().add_parser("run", description="%(prog)s runs", add_help=False) + run.add_argument("--target", help="target for %(prog)s") + return parser diff --git a/src/sphinx_argparse_cli/_logic.py b/src/sphinx_argparse_cli/_logic.py index a1cd70c..6d8148f 100644 --- a/src/sphinx_argparse_cli/_logic.py +++ b/src/sphinx_argparse_cli/_logic.py @@ -193,6 +193,7 @@ def run(self) -> list[Node]: def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph | literal_block | None: if block is None or not block.strip(): return None + block = _expand_prog(block, parser.prog) formatter = parser.formatter_class if "\n" in block and isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter): lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"]) @@ -248,9 +249,9 @@ def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) - ) extra: Sequence[Node] = () - if action.help: + if help_text := _expand_help(action, parser.prog): temp = paragraph() - self.state.nested_parse(StringList(load_help_text(action.help).split("\n")), 0, temp) + self.state.nested_parse(StringList(load_help_text(help_text).split("\n")), 0, temp) # only a leading paragraph can share the option's line; anything else becomes a block under it if temp.children and isinstance(temp.children[0], paragraph): line += Text(" - ") @@ -262,7 +263,7 @@ def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) - "no_default_values" not in self.options and action.default is not None and action.default != SUPPRESS - and not re.match(r".*[ (]default[s]? .*", (action.help or "")) + and not _DEFAULT_IN_HELP.search(help_text) and not isinstance(action, _StoreTrueAction | _StoreFalseAction) ): line += Text(" (default: ") @@ -428,6 +429,26 @@ def _visible_actions(group: _ArgumentGroup) -> list[Action]: ] +def _expand_prog(text: str, prog: str) -> str: + # what argparse.HelpFormatter._format_text does for descriptions and epilogs + return text % {"prog": prog} if "%(prog)" in text else text + + +def _expand_help(action: Action, prog: str) -> str: + # mirrors argparse.HelpFormatter._expand_help so the help reads as it does under --help + help_text = action.help or "" + if "%" not in help_text: + return help_text + params = { + key: getattr(value, "__name__", value) for key, value in vars(action).items() if value is not SUPPRESS + } | {"prog": prog} + if action.choices is not None: + params["choices"] = ", ".join(map(str, action.choices)) + return help_text % params + + +_DEFAULT_IN_HELP: Final[re.Pattern[str]] = re.compile(r"\bdefaults?\b", re.IGNORECASE) + _HELP_SUBSTITUTIONS: Final[list[tuple[re.Pattern[str], str]]] = [ # a quote glued to a word character is an apostrophe (don't, it's), not the edge of a quoted span (re.compile(r"(? None: assert not warning.getvalue() +@pytest.mark.sphinx(buildername="text", testroot="help-format-specifiers") +def test_help_format_specifiers(build_outcome: str) -> None: + assert ( + build_outcome + == """tool - CLI interface +******************** + +tool does things + + tool [--n N] [--mode {a,b}] [--pct PCT] [--capital CAPITAL] [--kind KIND] [--level LEVEL] + {run} ... + + +tool options +============ + +* **"--n"** "N" - count (default: 3) + +* **"--mode"** "{a,b}" - pick one of a, b (default: "a") + +* **"--pct"** "PCT" - 100% of tool (default: "5") + +* **"--capital"** "CAPITAL" - Default: 3 + +* **"--kind"** "KIND" - parsed with float + + +tool tuning +=========== + +tune tool + +* **"--level"** "LEVEL" - level (default: "1") + + +tool run +======== + +tool run runs + + tool run [--target TARGET] + + +tool run options +---------------- + +* **"--target"** "TARGET" - target for tool run + +run tool --help +""" + ) + + @pytest.mark.sphinx(buildername="text", testroot="default-handling") def test_with_default(build_outcome: str) -> None: assert (