Skip to content

Commit fc42259

Browse files
authored
🐛 fix: render option metavars, nargs and choices like usage (#353)
The option line built its own argument spec from `dest` and `metavar`, and that spec disagreed with the usage line argparse prints two lines above it. 🐛 A user metavar such as `<file>` or `path/to/dir` came out upper-cased. `nargs=2` showed one `TWO` where usage shows `TWO TWO`; `nargs="?"`, `"*"` and `REMAINDER` collapsed to a bare name; `choices` were missing; a positional with `metavar=("SRC", "DST")` showed only `SRC`. Options now hand the action to argparse's own `HelpFormatter._format_args`, the same call that produces the usage line. The literal after the option name therefore reads `[OPT]`, `[MANY ...]`, `TWO TWO`, `...`, `{json,xml}` or the metavar as the author typed it. Positionals join a tuple metavar with spaces for both the displayed name and the anchor; otherwise they keep the metavar or `dest`, since that name doubles as the reference target. Rendered output changes for parsers that pass a lower-case metavar. `--outdir out_dir` used to render as `OUT_DIR` and now renders as `out_dir`, matching usage. Option anchors stay the same; a positional with a tuple metavar moves from `#tool-SRC` to `#tool-SRC-DST`.
1 parent b69c81f commit fc42259

8 files changed

Lines changed: 84 additions & 15 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+
- Render the argument spec after an option with argparse's formatter, so `nargs`, `choices` and tuple metavars show as
26+
in the usage line; user-supplied metavars keep their case instead of being upper-cased.
2527

2628
## 1.13.1
2729

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: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def _mk_option_group(
220220
self._register_ref(ref_id, title_text, group_section)
221221
opt_group = bullet_list()
222222
for action in actions:
223-
opt_group += self._mk_option_line(action, prefix)
223+
opt_group += self._mk_option_line(parser, action, prefix)
224224
group_section += opt_group
225225
return group_section
226226

@@ -230,26 +230,22 @@ def _build_opt_grp_title(
230230
sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None
231231
return self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title
232232

233-
def _mk_option_line(self, action: Action, prefix: str) -> list_item:
233+
def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) -> list_item:
234234
line = paragraph()
235-
as_key = action.dest
236-
if action.metavar:
237-
as_key = action.metavar if isinstance(action.metavar, str) else action.metavar[0]
238235
if action.option_strings:
236+
args_text = _format_args(parser, action) if action.nargs != 0 else None
239237
for at, opt in enumerate(action.option_strings):
240238
if at:
241239
line += Text(", ")
242240
self._mk_option_name(line, prefix, opt)
243-
if action.nargs != 0:
241+
if args_text is not None:
244242
line += Text(" ")
245-
metavar_text = (
246-
" ".join(meta.upper() for meta in action.metavar)
247-
if isinstance(action.metavar, tuple)
248-
else as_key.upper()
249-
)
250-
line += literal(text=metavar_text)
243+
line += literal(text=args_text)
251244
else:
252-
self._mk_option_name(line, prefix, as_key)
245+
metavar = action.metavar
246+
self._mk_option_name(
247+
line, prefix, " ".join(metavar) if isinstance(metavar, tuple) else metavar or action.dest
248+
)
253249

254250
extra: Sequence[Node] = ()
255251
if action.help:
@@ -402,6 +398,12 @@ def _no_color(self) -> Iterator[None]:
402398
yield
403399

404400

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

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
@@ -650,6 +650,44 @@ def test_nargs(build_outcome: str) -> None:
650650
assert 'default: "None"' not in build_outcome
651651

652652

653+
@pytest.mark.sphinx(buildername="text", testroot="nargs-metavar")
654+
def test_nargs_metavar(build_outcome: str) -> None:
655+
assert (
656+
build_outcome
657+
== """tool - CLI interface
658+
********************
659+
660+
tool [--opt [OPT]] [--many [MANY ...]] [--two TWO TWO] [--rest ...] [--out <file>]
661+
[--dir path/to/dir] [--format {json,xml}]
662+
SRC DST
663+
664+
665+
tool positional arguments
666+
=========================
667+
668+
* **"SRC DST"** - copy pair
669+
670+
671+
tool options
672+
============
673+
674+
* **"--opt"** "[OPT]" - optional value
675+
676+
* **"--many"** "[MANY ...]" - zero or more
677+
678+
* **"--two"** "TWO TWO" - exactly two
679+
680+
* **"--rest"** "..." - the rest
681+
682+
* **"--out"** "<file>" - output
683+
684+
* **"--dir"** "path/to/dir" - dir
685+
686+
* **"--format"** "{json,xml}" - output format
687+
"""
688+
)
689+
690+
653691
@pytest.mark.sphinx(buildername="text", testroot="choices")
654692
def test_choices(build_outcome: str) -> None:
655693
assert "output format" in build_outcome

0 commit comments

Comments
 (0)