Skip to content

Commit 6d008eb

Browse files
committed
🐛 fix: format option metavars with argparse's formatter
The option line assembled its own metavar text from dest and metavar: a user metavar was upper-cased, nargs and choices were ignored, and a positional with a tuple metavar showed only the first element. Usage on the same page showed the correct spec, so the two disagreed. Ask argparse's HelpFormatter._format_args for the text instead; it is the same call that builds the usage line. Positionals join a tuple metavar with spaces, as the name also serves as the reference anchor.
1 parent 786fd11 commit 6d008eb

8 files changed

Lines changed: 89 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file.
1313
literals.
1414
- Make `:hook:` intercept `parse_intermixed_args()` as well as `parse_args()`.
1515
- Keep ANSI color codes out of usage blocks when `PYTHON_COLORS=1` is set on Python 3.14 or newer.
16+
- Render the argument spec after an option with argparse's formatter, so `nargs`, `choices` and tuple metavars show as
17+
in the usage line; user-supplied metavars keep their case instead of being upper-cased.
1618

1719
## 1.13.1
1820

roots/test-nargs-metavar/conf.py

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

roots/test-nargs-metavar/index.rst

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

roots/test-nargs-metavar/parser.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
from argparse import REMAINDER, ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(prog="tool", add_help=False)
8+
parser.add_argument("--opt", nargs="?", help="optional value")
9+
parser.add_argument("--many", nargs="*", help="zero or more")
10+
parser.add_argument("--two", nargs=2, help="exactly two")
11+
parser.add_argument("--rest", nargs=REMAINDER, help="the rest")
12+
parser.add_argument("--out", metavar="<file>", help="output")
13+
parser.add_argument("--dir", metavar="path/to/dir", help="dir")
14+
parser.add_argument("--format", choices=["json", "xml"], help="output format")
15+
parser.add_argument("pair", nargs=2, metavar=("SRC", "DST"), help="copy pair")
16+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def run(self) -> list[Node]:
182182
if not group._group_actions or group is self.parser._subparsers: # noqa: SLF001
183183
continue
184184
home_section += self._mk_option_group(
185-
group, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1]
185+
self.parser, group, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1]
186186
)
187187
for aliases, help_msg, parser in self._iter_sub_commands():
188188
home_section += self._mk_sub_command(aliases, help_msg, parser)
@@ -206,7 +206,7 @@ def _pre_format(self, block: str | None) -> paragraph | literal_block | None:
206206
_protect_option_dashes(para)
207207
return para
208208

209-
def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> section:
209+
def _mk_option_group(self, parser: ArgumentParser, group: _ArgumentGroup, prefix: str, prog: str) -> section:
210210
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
211211
title_prefix = self.options.get("group_title_prefix")
212212
title_text = self._build_opt_grp_title(group, prefix, prog, sub_title_prefix, title_prefix)
@@ -222,7 +222,7 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> sec
222222
for action in group._group_actions: # noqa: SLF001
223223
if action.help == SUPPRESS:
224224
continue
225-
point = self._mk_option_line(action, prefix)
225+
point = self._mk_option_line(parser, action, prefix)
226226
opt_group += point
227227
group_section += opt_group
228228
return group_section
@@ -235,26 +235,22 @@ def _build_opt_grp_title(
235235
title_text += group.title or ""
236236
return title_text
237237

238-
def _mk_option_line(self, action: Action, prefix: str) -> list_item:
238+
def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) -> list_item:
239239
line = paragraph()
240-
as_key = action.dest
241-
if action.metavar:
242-
as_key = action.metavar if isinstance(action.metavar, str) else action.metavar[0]
243240
if action.option_strings:
241+
args_text = _format_args(parser, action) if action.nargs != 0 else None
244242
for at, opt in enumerate(action.option_strings):
245243
if at:
246244
line += Text(", ")
247245
self._mk_option_name(line, prefix, opt)
248-
if action.nargs != 0:
246+
if args_text is not None:
249247
line += Text(" ")
250-
metavar_text = (
251-
" ".join(meta.upper() for meta in action.metavar)
252-
if isinstance(action.metavar, tuple)
253-
else as_key.upper()
254-
)
255-
line += literal(text=metavar_text)
248+
line += literal(text=args_text)
256249
else:
257-
self._mk_option_name(line, prefix, as_key)
250+
metavar = action.metavar
251+
self._mk_option_name(
252+
line, prefix, " ".join(metavar) if isinstance(metavar, tuple) else metavar or action.dest
253+
)
258254

259255
if action.help:
260256
help_text = load_help_text(action.help)
@@ -347,7 +343,9 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
347343
continue
348344
if isinstance(group._group_actions[0], _SubParsersAction): # noqa: SLF001
349345
continue
350-
group_section += self._mk_option_group(group, prefix=parser.prog, prog=self.parser.prog.split("/")[-1])
346+
group_section += self._mk_option_group(
347+
parser, group, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]
348+
)
351349
return group_section
352350

353351
def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str:
@@ -404,6 +402,12 @@ def _no_color(self) -> Iterator[None]:
404402
yield
405403

406404

405+
def _format_args(parser: ArgumentParser, action: Action) -> str:
406+
# argparse's formatter keeps the text in step with the usage line: nargs, choices and user metavars included
407+
formatter = parser._get_formatter() # noqa: SLF001
408+
return formatter._format_args(action, formatter._get_default_metavar_for_optional(action)) # noqa: SLF001
409+
410+
407411
def make_id_lower(key: str) -> str:
408412
return re.sub("[A-Z]", lambda m: f"_{m.group(0).lower()}", make_id(key))
409413

tests/complex.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ complex options
1616

1717
* **"--no-help"**
1818

19-
* **"--outdir"** "OUT_DIR", **"-o"** "OUT_DIR" - output directory
19+
* **"--outdir"** "out_dir", **"-o"** "out_dir" - output directory
2020

2121
* **"--in-dir"** "IN_DIR", **"-i"** "IN_DIR" - input directory
2222

tests/complex_pre_310.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ complex optional arguments
1616

1717
* **"--no-help"**
1818

19-
* **"--outdir"** "OUT_DIR", **"-o"** "OUT_DIR" - output directory
19+
* **"--outdir"** "out_dir", **"-o"** "out_dir" - output directory
2020

2121
* **"--in-dir"** "IN_DIR", **"-i"** "IN_DIR" - input directory
2222

tests/test_logic.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,44 @@ def test_nargs(build_outcome: str) -> None:
486486
assert 'default: "None"' not in build_outcome
487487

488488

489+
@pytest.mark.sphinx(buildername="text", testroot="nargs-metavar")
490+
def test_nargs_metavar(build_outcome: str) -> None:
491+
assert (
492+
build_outcome
493+
== """tool - CLI interface
494+
********************
495+
496+
tool [--opt [OPT]] [--many [MANY ...]] [--two TWO TWO] [--rest ...] [--out <file>]
497+
[--dir path/to/dir] [--format {json,xml}]
498+
SRC DST
499+
500+
501+
tool positional arguments
502+
=========================
503+
504+
* **"SRC DST"** - copy pair
505+
506+
507+
tool options
508+
============
509+
510+
* **"--opt"** "[OPT]" - optional value
511+
512+
* **"--many"** "[MANY ...]" - zero or more
513+
514+
* **"--two"** "TWO TWO" - exactly two
515+
516+
* **"--rest"** "..." - the rest
517+
518+
* **"--out"** "<file>" - output
519+
520+
* **"--dir"** "path/to/dir" - dir
521+
522+
* **"--format"** "{json,xml}" - output format
523+
"""
524+
)
525+
526+
489527
@pytest.mark.sphinx(buildername="text", testroot="choices")
490528
def test_choices(build_outcome: str) -> None:
491529
assert "output format" in build_outcome

0 commit comments

Comments
 (0)