From a31f3b58fbbcaba73f9e3a4fb1e2b17c2e788b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 27 Aug 2026 08:17:33 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20format=20option=20metavar?= =?UTF-8?q?s=20with=20argparse's=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 ++ roots/test-nargs-metavar/conf.py | 8 +++++++ roots/test-nargs-metavar/index.rst | 3 +++ roots/test-nargs-metavar/parser.py | 16 +++++++++++++ src/sphinx_argparse_cli/_logic.py | 28 ++++++++++++---------- tests/complex.txt | 2 +- tests/complex_pre_310.txt | 2 +- tests/test_logic.py | 38 ++++++++++++++++++++++++++++++ 8 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 roots/test-nargs-metavar/conf.py create mode 100644 roots/test-nargs-metavar/index.rst create mode 100644 roots/test-nargs-metavar/parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e6af5..a93c783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ All notable changes to this project will be documented in this file. whose arguments are all suppressed. - Keep `RawDescriptionHelpFormatter` line breaks in epilogs and in descriptions rendered after the usage block, and 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. ## 1.13.1 diff --git a/roots/test-nargs-metavar/conf.py b/roots/test-nargs-metavar/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-nargs-metavar/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-nargs-metavar/index.rst b/roots/test-nargs-metavar/index.rst new file mode 100644 index 0000000..708ad9c --- /dev/null +++ b/roots/test-nargs-metavar/index.rst @@ -0,0 +1,3 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make diff --git a/roots/test-nargs-metavar/parser.py b/roots/test-nargs-metavar/parser.py new file mode 100644 index 0000000..77c4400 --- /dev/null +++ b/roots/test-nargs-metavar/parser.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from argparse import REMAINDER, ArgumentParser + + +def make() -> ArgumentParser: + parser = ArgumentParser(prog="tool", add_help=False) + parser.add_argument("--opt", nargs="?", help="optional value") + parser.add_argument("--many", nargs="*", help="zero or more") + parser.add_argument("--two", nargs=2, help="exactly two") + parser.add_argument("--rest", nargs=REMAINDER, help="the rest") + parser.add_argument("--out", metavar="", help="output") + parser.add_argument("--dir", metavar="path/to/dir", help="dir") + parser.add_argument("--format", choices=["json", "xml"], help="output format") + parser.add_argument("pair", nargs=2, metavar=("SRC", "DST"), help="copy pair") + return parser diff --git a/src/sphinx_argparse_cli/_logic.py b/src/sphinx_argparse_cli/_logic.py index 8b9643e..a1cd70c 100644 --- a/src/sphinx_argparse_cli/_logic.py +++ b/src/sphinx_argparse_cli/_logic.py @@ -220,7 +220,7 @@ def _mk_option_group( self._register_ref(ref_id, title_text, group_section) opt_group = bullet_list() for action in actions: - opt_group += self._mk_option_line(action, prefix) + opt_group += self._mk_option_line(parser, action, prefix) group_section += opt_group return group_section @@ -230,26 +230,22 @@ def _build_opt_grp_title( sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None return self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title - def _mk_option_line(self, action: Action, prefix: str) -> list_item: + def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) -> list_item: line = paragraph() - as_key = action.dest - if action.metavar: - as_key = action.metavar if isinstance(action.metavar, str) else action.metavar[0] if action.option_strings: + args_text = _format_args(parser, action) if action.nargs != 0 else None for at, opt in enumerate(action.option_strings): if at: line += Text(", ") self._mk_option_name(line, prefix, opt) - if action.nargs != 0: + if args_text is not None: line += Text(" ") - metavar_text = ( - " ".join(meta.upper() for meta in action.metavar) - if isinstance(action.metavar, tuple) - else as_key.upper() - ) - line += literal(text=metavar_text) + line += literal(text=args_text) else: - self._mk_option_name(line, prefix, as_key) + metavar = action.metavar + self._mk_option_name( + line, prefix, " ".join(metavar) if isinstance(metavar, tuple) else metavar or action.dest + ) extra: Sequence[Node] = () if action.help: @@ -402,6 +398,12 @@ def _no_color(self) -> Iterator[None]: yield +def _format_args(parser: ArgumentParser, action: Action) -> str: + # argparse's formatter keeps the text in step with the usage line: nargs, choices and user metavars included + formatter = parser._get_formatter() # noqa: SLF001 + return formatter._format_args(action, formatter._get_default_metavar_for_optional(action)) # noqa: SLF001 + + def make_id_lower(key: str) -> str: return re.sub("[A-Z]", lambda m: f"_{m.group(0).lower()}", make_id(key)) diff --git a/tests/complex.txt b/tests/complex.txt index 8ac0892..19cfc5d 100644 --- a/tests/complex.txt +++ b/tests/complex.txt @@ -16,7 +16,7 @@ complex options * **"--no-help"** -* **"--outdir"** "OUT_DIR", **"-o"** "OUT_DIR" - output directory +* **"--outdir"** "out_dir", **"-o"** "out_dir" - output directory * **"--in-dir"** "IN_DIR", **"-i"** "IN_DIR" - input directory diff --git a/tests/complex_pre_310.txt b/tests/complex_pre_310.txt index ca1ce68..6c76cbc 100644 --- a/tests/complex_pre_310.txt +++ b/tests/complex_pre_310.txt @@ -16,7 +16,7 @@ complex optional arguments * **"--no-help"** -* **"--outdir"** "OUT_DIR", **"-o"** "OUT_DIR" - output directory +* **"--outdir"** "out_dir", **"-o"** "out_dir" - output directory * **"--in-dir"** "IN_DIR", **"-i"** "IN_DIR" - input directory diff --git a/tests/test_logic.py b/tests/test_logic.py index 264846c..ce61655 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -650,6 +650,44 @@ def test_nargs(build_outcome: str) -> None: assert 'default: "None"' not in build_outcome +@pytest.mark.sphinx(buildername="text", testroot="nargs-metavar") +def test_nargs_metavar(build_outcome: str) -> None: + assert ( + build_outcome + == """tool - CLI interface +******************** + + tool [--opt [OPT]] [--many [MANY ...]] [--two TWO TWO] [--rest ...] [--out ] + [--dir path/to/dir] [--format {json,xml}] + SRC DST + + +tool positional arguments +========================= + +* **"SRC DST"** - copy pair + + +tool options +============ + +* **"--opt"** "[OPT]" - optional value + +* **"--many"** "[MANY ...]" - zero or more + +* **"--two"** "TWO TWO" - exactly two + +* **"--rest"** "..." - the rest + +* **"--out"** "" - output + +* **"--dir"** "path/to/dir" - dir + +* **"--format"** "{json,xml}" - output format +""" + ) + + @pytest.mark.sphinx(buildername="text", testroot="choices") def test_choices(build_outcome: str) -> None: assert "output format" in build_outcome