Skip to content

Commit 75b403c

Browse files
committed
🐛 fix: expand argparse format specifiers in help
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.
1 parent b69c81f commit 75b403c

6 files changed

Lines changed: 115 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ All notable changes to this project will be documented in this file.
2222
whose arguments are all suppressed.
2323
- Keep `RawDescriptionHelpFormatter` line breaks in epilogs and in descriptions rendered after the usage block, and
2424
render sub-command epilogs.
25+
- Expand argparse format specifiers such as `%(prog)s`, `%(default)s` and `%(choices)s` in help, descriptions and
26+
epilogs, and skip the generated `(default: ...)` when the help already mentions a default in any case.
2527

2628
## 1.13.1
2729

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
6+
sys.path.insert(0, str(Path(__file__).parent))
7+
extensions = ["sphinx_argparse_cli"]
8+
nitpicky = True
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(
8+
prog="tool",
9+
formatter_class=ArgumentDefaultsHelpFormatter,
10+
description="%(prog)s does things",
11+
epilog="run %(prog)s --help",
12+
add_help=False,
13+
)
14+
parser.add_argument("--n", type=int, default=3, help="count (default: %(default)s)")
15+
parser.add_argument("--mode", choices=["a", "b"], default="a", help="pick one of %(choices)s")
16+
parser.add_argument("--pct", default=5, help="100%% of %(prog)s")
17+
parser.add_argument("--capital", default=3, help="Default: 3")
18+
parser.add_argument("--kind", type=float, help="parsed with %(type)s")
19+
group = parser.add_argument_group("tuning", description="tune %(prog)s")
20+
group.add_argument("--level", default=1, help="level")
21+
run = parser.add_subparsers().add_parser("run", description="%(prog)s runs", add_help=False)
22+
run.add_argument("--target", help="target for %(prog)s")
23+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ def run(self) -> list[Node]:
193193
def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph | literal_block | None:
194194
if block is None or not block.strip():
195195
return None
196+
block = _expand_prog(block, parser.prog)
196197
formatter = parser.formatter_class
197198
if "\n" in block and isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter):
198199
lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"])
@@ -220,7 +221,7 @@ def _mk_option_group(
220221
self._register_ref(ref_id, title_text, group_section)
221222
opt_group = bullet_list()
222223
for action in actions:
223-
opt_group += self._mk_option_line(action, prefix)
224+
opt_group += self._mk_option_line(parser, action, prefix)
224225
group_section += opt_group
225226
return group_section
226227

@@ -230,7 +231,7 @@ def _build_opt_grp_title(
230231
sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None
231232
return self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title
232233

233-
def _mk_option_line(self, action: Action, prefix: str) -> list_item:
234+
def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) -> list_item:
234235
line = paragraph()
235236
as_key = action.dest
236237
if action.metavar:
@@ -252,9 +253,9 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item:
252253
self._mk_option_name(line, prefix, as_key)
253254

254255
extra: Sequence[Node] = ()
255-
if action.help:
256+
if help_text := _expand_help(action, parser.prog):
256257
temp = paragraph()
257-
self.state.nested_parse(StringList(load_help_text(action.help).split("\n")), 0, temp)
258+
self.state.nested_parse(StringList(load_help_text(help_text).split("\n")), 0, temp)
258259
# only a leading paragraph can share the option's line; anything else becomes a block under it
259260
if temp.children and isinstance(temp.children[0], paragraph):
260261
line += Text(" - ")
@@ -266,7 +267,7 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item:
266267
"no_default_values" not in self.options
267268
and action.default is not None
268269
and action.default != SUPPRESS
269-
and not re.match(r".*[ (]default[s]? .*", (action.help or ""))
270+
and not _DEFAULT_IN_HELP.search(help_text)
270271
and not isinstance(action, _StoreTrueAction | _StoreFalseAction)
271272
):
272273
line += Text(" (default: ")
@@ -426,6 +427,26 @@ def _visible_actions(group: _ArgumentGroup) -> list[Action]:
426427
]
427428

428429

430+
def _expand_prog(text: str, prog: str) -> str:
431+
# what argparse.HelpFormatter._format_text does for descriptions and epilogs
432+
return text % {"prog": prog} if "%(prog)" in text else text
433+
434+
435+
def _expand_help(action: Action, prog: str) -> str:
436+
# mirrors argparse.HelpFormatter._expand_help so the help reads as it does under --help
437+
help_text = action.help or ""
438+
if "%" not in help_text:
439+
return help_text
440+
params = {
441+
key: getattr(value, "__name__", value) for key, value in vars(action).items() if value is not SUPPRESS
442+
} | {"prog": prog}
443+
if action.choices is not None:
444+
params["choices"] = ", ".join(map(str, action.choices))
445+
return help_text % params
446+
447+
448+
_DEFAULT_IN_HELP: Final[re.Pattern[str]] = re.compile(r"\bdefaults?\b", re.IGNORECASE)
449+
429450
_HELP_SUBSTITUTIONS: Final[list[tuple[re.Pattern[str], str]]] = [
430451
# a quote glued to a word character is an apostrophe (don't, it's), not the edge of a quoted span
431452
(re.compile(r"(?<!\w)'([^']+?)'(?!\w)"), "``'\\1'``"),

tests/test_logic.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,59 @@ def test_ref_cases(build_outcome: str, warning: StringIO) -> None:
575575
assert not warning.getvalue()
576576

577577

578+
@pytest.mark.sphinx(buildername="text", testroot="help-format-specifiers")
579+
def test_help_format_specifiers(build_outcome: str) -> None:
580+
assert (
581+
build_outcome
582+
== """tool - CLI interface
583+
********************
584+
585+
tool does things
586+
587+
tool [--n N] [--mode {a,b}] [--pct PCT] [--capital CAPITAL] [--kind KIND] [--level LEVEL]
588+
{run} ...
589+
590+
591+
tool options
592+
============
593+
594+
* **"--n"** "N" - count (default: 3)
595+
596+
* **"--mode"** "MODE" - pick one of a, b (default: "a")
597+
598+
* **"--pct"** "PCT" - 100% of tool (default: "5")
599+
600+
* **"--capital"** "CAPITAL" - Default: 3
601+
602+
* **"--kind"** "KIND" - parsed with float
603+
604+
605+
tool tuning
606+
===========
607+
608+
tune tool
609+
610+
* **"--level"** "LEVEL" - level (default: "1")
611+
612+
613+
tool run
614+
========
615+
616+
tool run runs
617+
618+
tool run [--target TARGET]
619+
620+
621+
tool run options
622+
----------------
623+
624+
* **"--target"** "TARGET" - target for tool run
625+
626+
run tool --help
627+
"""
628+
)
629+
630+
578631
@pytest.mark.sphinx(buildername="text", testroot="default-handling")
579632
def test_with_default(build_outcome: str) -> None:
580633
assert (

0 commit comments

Comments
 (0)