sphinx-argparse-cli 1.22.0__tar.gz → 1.23.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sphinx-argparse-cli
3
- Version: 1.22.0
3
+ Version: 1.23.0
4
4
  Summary: render CLI arguments (sub-commands friendly) defined by argparse module
5
5
  Project-URL: Documentation, https://github.com/tox-dev/sphinx-argparse-cli#sphinx-argparse-cli
6
6
  Project-URL: Homepage, https://github.com/tox-dev/sphinx-argparse-cli
@@ -164,6 +164,23 @@ With `sphinx_argparse_cli_prefix_document = True` (anchors prefixed by document
164
164
 
165
165
  The anchor text is visible after the `#` in the URL when you click a heading.
166
166
 
167
+ Flags and positional arguments are also registered as Sphinx program options, so the `:option:` role works with the
168
+ program (and sub-command) name followed by the argument, or scoped through a `.. program::` directive:
169
+
170
+ ```rst
171
+ :option:`tox --magic`
172
+ :option:`tox run --magic`
173
+
174
+ .. program:: tox run
175
+
176
+ :option:`--magic`
177
+ :option:`--magic=value`
178
+ ```
179
+
180
+ `:option:` targets keep their original case, so they do not need `:force_refs_lower:`. They ignore
181
+ `sphinx_argparse_cli_prefix_document`; when two documents render the same program, the role links to the first one
182
+ Sphinx reads.
183
+
167
184
  ### Handle mixed-case references
168
185
 
169
186
  Sphinx `:ref:` only supports lower-case targets. When your program name or flags contain capital letters, set
@@ -187,6 +204,14 @@ For a program named `SampleProgram`:
187
204
  If you do not need Sphinx `:ref:` cross-references you can leave this off to keep mixed-case anchors in the HTML output,
188
205
  but enabling it later will change existing anchor URLs.
189
206
 
207
+ ### Write help text as reStructuredText
208
+
209
+ The directive parses every argument's `help` string as reStructuredText, so inline markup such as a `:ref:` role or
210
+ `**bold**` works. The same applies to characters that carry meaning in reStructuredText: a lone `*` (`match *.py`), a
211
+ trailing `_` (`name_`), backticks, or a trailing `::` produce docutils warnings and fail a build run with `-W`. Escape
212
+ them with a backslash (`match \*.py`) or wrap them in inline literals. Text between matching single quotes, double
213
+ quotes, or braces becomes an inline literal for you, so `pick 'fast' or "slow"` shows `'fast'` and `"slow"` as code.
214
+
190
215
  ### Add extra content after generated docs
191
216
 
192
217
  Any content nested inside the directive is appended after the generated CLI documentation:
@@ -132,6 +132,23 @@ With `sphinx_argparse_cli_prefix_document = True` (anchors prefixed by document
132
132
 
133
133
  The anchor text is visible after the `#` in the URL when you click a heading.
134
134
 
135
+ Flags and positional arguments are also registered as Sphinx program options, so the `:option:` role works with the
136
+ program (and sub-command) name followed by the argument, or scoped through a `.. program::` directive:
137
+
138
+ ```rst
139
+ :option:`tox --magic`
140
+ :option:`tox run --magic`
141
+
142
+ .. program:: tox run
143
+
144
+ :option:`--magic`
145
+ :option:`--magic=value`
146
+ ```
147
+
148
+ `:option:` targets keep their original case, so they do not need `:force_refs_lower:`. They ignore
149
+ `sphinx_argparse_cli_prefix_document`; when two documents render the same program, the role links to the first one
150
+ Sphinx reads.
151
+
135
152
  ### Handle mixed-case references
136
153
 
137
154
  Sphinx `:ref:` only supports lower-case targets. When your program name or flags contain capital letters, set
@@ -155,6 +172,14 @@ For a program named `SampleProgram`:
155
172
  If you do not need Sphinx `:ref:` cross-references you can leave this off to keep mixed-case anchors in the HTML output,
156
173
  but enabling it later will change existing anchor URLs.
157
174
 
175
+ ### Write help text as reStructuredText
176
+
177
+ The directive parses every argument's `help` string as reStructuredText, so inline markup such as a `:ref:` role or
178
+ `**bold**` works. The same applies to characters that carry meaning in reStructuredText: a lone `*` (`match *.py`), a
179
+ trailing `_` (`name_`), backticks, or a trailing `::` produce docutils warnings and fail a build run with `-W`. Escape
180
+ them with a backslash (`match \*.py`) or wrap them in inline literals. Text between matching single quotes, double
181
+ quotes, or braces becomes an inline literal for you, so `pick 'fast' or "slow"` shows `'fast'` and `"slow"` as code.
182
+
158
183
  ### Add extra content after generated docs
159
184
 
160
185
  Any content nested inside the directive is appended after the generated CLI documentation:
@@ -16,6 +16,7 @@ from argparse import (
16
16
  )
17
17
  from collections import defaultdict
18
18
  from contextlib import contextmanager
19
+ from functools import cached_property
19
20
  from pathlib import Path
20
21
  from typing import TYPE_CHECKING, Any, ClassVar, Final, cast
21
22
  from unittest.mock import patch
@@ -42,13 +43,13 @@ from docutils.nodes import (
42
43
  from docutils.parsers.rst.directives import flag, positive_int, unchanged, unchanged_required
43
44
  from docutils.statemachine import StringList
44
45
  from sphinx.locale import __
46
+ from sphinx.util import ws_re
45
47
  from sphinx.util.docutils import SphinxDirective
46
48
  from sphinx.util.logging import getLogger
47
49
 
48
50
  if TYPE_CHECKING:
49
- from collections.abc import Iterator
51
+ from collections.abc import Callable, Iterator, Sequence
50
52
 
51
- from docutils.parsers.rst.states import RSTState, RSTStateMachine
52
53
  from sphinx.domains.std import StandardDomain
53
54
  from sphinx.util.logging import SphinxLoggerAdapter
54
55
 
@@ -77,70 +78,56 @@ class SphinxArgparseCli(SphinxDirective):
77
78
  "force_refs_lower": flag,
78
79
  }
79
80
 
80
- def __init__( # noqa: PLR0913
81
- self,
82
- name: str,
83
- arguments: list[str],
84
- options: dict[str, str | None],
85
- content: StringList,
86
- lineno: int,
87
- content_offset: int,
88
- block_text: str,
89
- state: RSTState,
90
- state_machine: RSTStateMachine,
91
- ) -> None:
92
- options.setdefault("group_title_prefix", None)
93
- options.setdefault("group_sub_title_prefix", None)
94
- super().__init__(name, arguments, options, content, lineno, content_offset, block_text, state, state_machine)
95
- self._parser: ArgumentParser | None = None
96
- self._std_domain: StandardDomain = cast("StandardDomain", self.env.get_domain("std"))
97
- self._raw_format: bool = False
98
- self._make_id = make_id_lower if "force_refs_lower" in self.options else make_id
99
-
100
- @property
81
+ @cached_property
101
82
  def parser(self) -> ArgumentParser:
102
- if self._parser is None:
103
- module_name, attr_name = self.options["module"], self.options["func"]
104
- try:
105
- module = __import__(module_name, fromlist=[attr_name])
106
- except ImportError:
107
- msg = f"Failed to import module {module_name!r}"
108
- raise self.error(msg) # noqa: B904
109
- try:
110
- parser_creator = getattr(module, attr_name)
111
- except AttributeError:
112
- del sys.modules[module_name]
113
- msg = f"Module {module_name!r} has no attribute {attr_name!r}"
114
- raise self.error(msg) # noqa: B904
115
- if "hook" in self.options:
116
- original_parse_known_args = ArgumentParser.parse_known_args
117
- ArgumentParser.parse_known_args = _parse_known_args_hook # type: ignore[method-assign,assignment]
83
+ module_name, attr_name = self.options["module"], self.options["func"]
84
+ try:
85
+ module = __import__(module_name, fromlist=[attr_name])
86
+ except ImportError:
87
+ msg = f"Failed to import module {module_name!r}"
88
+ raise self.error(msg) # noqa: B904
89
+ try:
90
+ parser_creator = getattr(module, attr_name)
91
+ except AttributeError:
92
+ del sys.modules[module_name]
93
+ msg = f"Module {module_name!r} has no attribute {attr_name!r}"
94
+ raise self.error(msg) # noqa: B904
95
+ parser: ArgumentParser | None = None
96
+ if "hook" in self.options:
97
+ # parse_intermixed_args bypasses parse_known_args since Python 3.12, so both entry points need the hook
98
+ with (
99
+ patch.object(ArgumentParser, "parse_known_args", _parse_known_args_hook),
100
+ patch.object(ArgumentParser, "parse_known_intermixed_args", _parse_known_args_hook),
101
+ ):
118
102
  try:
119
103
  parser_creator()
120
104
  except HookError as hooked:
121
- self._parser = hooked.parser
122
- finally:
123
- ArgumentParser.parse_known_args = original_parse_known_args
124
- else:
125
- self._parser = parser_creator()
105
+ parser = hooked.parser
106
+ else:
107
+ parser = parser_creator()
126
108
 
127
- del sys.modules[module_name]
128
- if self._parser is None:
129
- msg = "Failed to hook argparse to get ArgumentParser"
130
- raise self.error(msg)
109
+ del sys.modules[module_name]
110
+ if parser is None:
111
+ msg = "Failed to hook argparse to get ArgumentParser"
112
+ raise self.error(msg)
113
+
114
+ if "prog" in self.options:
115
+ old_prog, new_prog = parser.prog, self.options["prog"]
116
+ parser.prog = new_prog
117
+ _update_sub_parser_prog(parser, old_prog, new_prog)
118
+ return parser
131
119
 
132
- if "prog" in self.options:
133
- old_prog, new_prog = self._parser.prog, self.options["prog"]
134
- self._parser.prog = new_prog
135
- _update_sub_parser_prog(self._parser, old_prog, new_prog)
120
+ @cached_property
121
+ def _std_domain(self) -> StandardDomain:
122
+ return cast("StandardDomain", self.env.get_domain("std"))
136
123
 
137
- formatter = self._parser.formatter_class
138
- self._raw_format = isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter)
139
- return self._parser
124
+ @cached_property
125
+ def _make_id(self) -> Callable[[str], str]:
126
+ return make_id_lower if "force_refs_lower" in self.options else make_id
140
127
 
141
128
  def _load_sub_parsers(
142
- self, sub_parser: _SubParsersAction[ArgumentParser]
143
- ) -> Iterator[tuple[list[str], str, ArgumentParser]]:
129
+ self, sub_parser: _SubParsersAction[ArgumentParser], parent_cmd: str = ""
130
+ ) -> Iterator[tuple[list[str], str, ArgumentParser, str]]:
144
131
  parser_to_args: dict[int, list[str]] = defaultdict(list)
145
132
  for key, parser in sub_parser._name_parser_map.items(): # noqa: SLF001
146
133
  parser_to_args[id(parser)].append(key)
@@ -154,19 +141,18 @@ class SphinxArgparseCli(SphinxDirective):
154
141
  aliases = parser_to_args[id(parser)]
155
142
  aliases.remove(name)
156
143
  # help is stored in a pseudo action
157
- help_msg = next((a.help for a in sub_parser._choices_actions if a.dest == name), None) or "" # noqa: SLF001
158
- yield aliases, help_msg, parser
144
+ help_msg = next((a.help for a in sub_parser._choices_actions if a.dest == name), None) # noqa: SLF001
145
+ if help_msg == SUPPRESS:
146
+ continue
147
+ sub_cmd = f"{parent_cmd} {name}".strip()
148
+ yield aliases, help_msg or "", parser, sub_cmd
159
149
 
160
- if parser._subparsers: # noqa: SLF001
161
- sub_sub_parser: _SubParsersAction[ArgumentParser] = parser._subparsers._group_actions[0] # type: ignore[assignment] # noqa: SLF001
162
- yield from self._load_sub_parsers(sub_sub_parser)
150
+ if (sub_sub_parser := _sub_parser_action(parser)) is not None:
151
+ yield from self._load_sub_parsers(sub_sub_parser, sub_cmd)
163
152
 
164
- def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
165
- top_sub_parser = self.parser._subparsers # noqa: SLF001
166
- if not top_sub_parser:
167
- return
168
- sub_parser: _SubParsersAction[ArgumentParser] = top_sub_parser._group_actions[0] # type: ignore[assignment] # noqa: SLF001
169
- yield from self._load_sub_parsers(sub_parser)
153
+ def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser, str]]:
154
+ if (sub_parser := _sub_parser_action(self.parser)) is not None:
155
+ yield from self._load_sub_parsers(sub_parser)
170
156
 
171
157
  def run(self) -> list[Node]:
172
158
  self.env.note_reread() # this document needs to always be rebuilt
@@ -179,22 +165,24 @@ class SphinxArgparseCli(SphinxDirective):
179
165
  if "usage_first" in self.options:
180
166
  home_section += self._mk_usage(self.parser)
181
167
 
182
- if description := self._pre_format(self.options.get("description", self.parser.description)):
168
+ if description := self._pre_format(self.options.get("description", self.parser.description), self.parser):
183
169
  home_section += description
184
170
 
185
171
  if "usage_first" not in self.options:
186
172
  home_section += self._mk_usage(self.parser)
187
173
 
188
174
  for group in self.parser._action_groups: # noqa: SLF001
189
- if not group._group_actions or group is self.parser._subparsers: # noqa: SLF001
190
- continue
191
- home_section += self._mk_option_group(
192
- group, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1]
193
- )
194
- for aliases, help_msg, parser in self._iter_sub_commands():
195
- home_section += self._mk_sub_command(aliases, help_msg, parser)
196
-
197
- if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog)):
175
+ if actions := _visible_actions(group):
176
+ home_section += self._mk_option_group(
177
+ group,
178
+ actions,
179
+ self.parser,
180
+ prefix=self.parser.prog.split("/")[-1],
181
+ )
182
+ for aliases, help_msg, parser, sub_cmd in self._iter_sub_commands():
183
+ home_section += self._mk_sub_command(aliases, help_msg, parser, sub_cmd)
184
+
185
+ if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog), self.parser):
198
186
  home_section += epilog
199
187
 
200
188
  if self.content:
@@ -202,10 +190,12 @@ class SphinxArgparseCli(SphinxDirective):
202
190
 
203
191
  return [home_section]
204
192
 
205
- def _pre_format(self, block: None | str) -> None | paragraph | literal_block:
206
- if block is None:
193
+ def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph | literal_block | None:
194
+ if block is None or not block.strip():
207
195
  return None
208
- if self._raw_format and "\n" in block:
196
+ block = _expand_prog(block, parser.prog)
197
+ formatter = parser.formatter_class
198
+ if "\n" in block and isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter):
209
199
  lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"])
210
200
  lit["language"] = "none"
211
201
  return lit
@@ -213,75 +203,75 @@ class SphinxArgparseCli(SphinxDirective):
213
203
  _protect_option_dashes(para)
214
204
  return para
215
205
 
216
- def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> section:
217
- sub_title_prefix: str = self.options["group_sub_title_prefix"]
218
- title_prefix = self.options["group_title_prefix"]
219
- title_text = self._build_opt_grp_title(group, prefix, prog, sub_title_prefix, title_prefix)
220
- title_ref: str = f"{prefix}{' ' if prefix else ''}{group.title}"
206
+ def _mk_option_group(
207
+ self,
208
+ group: _ArgumentGroup,
209
+ actions: list[Action],
210
+ parser: ArgumentParser,
211
+ prefix: str,
212
+ sub_cmd: str | None = None,
213
+ ) -> section:
214
+ prog = self.parser.prog.split("/")[-1]
215
+ sub_title_prefix: str = self.options.get("group_sub_title_prefix")
216
+ title_prefix = self.options.get("group_title_prefix")
217
+ # an untitled group borrows its description as heading so its anchor stays unique
218
+ group_title = group.title or group.description or "arguments"
219
+ title_text = self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title
220
+ title_ref: str = f"{prefix}{' ' if prefix else ''}{group_title}"
221
221
  ref_id = self._make_id(title_ref)
222
222
  # the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
223
223
  header = title("", Text(title_text))
224
224
  group_section = section("", header, ids=[ref_id], names=[ref_id])
225
- if description := self._pre_format(group.description):
225
+ if group.title and (description := self._pre_format(group.description, parser)):
226
226
  group_section += description
227
227
  self._register_ref(ref_id, title_text, group_section)
228
228
  opt_group = bullet_list()
229
- for action in group._group_actions: # noqa: SLF001
230
- if action.help == SUPPRESS:
231
- continue
232
- point = self._mk_option_line(action, prefix)
233
- opt_group += point
229
+ for action in actions:
230
+ opt_group += self._mk_option_line(parser, action, prefix)
234
231
  group_section += opt_group
235
232
  return group_section
236
233
 
237
- def _build_opt_grp_title(
238
- self, group: _ArgumentGroup, prefix: str, prog: str, sub_title_prefix: str, title_prefix: str
239
- ) -> str:
240
- sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None
241
- title_text = self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix)
242
- title_text += group.title or ""
243
- return title_text
244
-
245
- 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:
246
235
  line = paragraph()
247
- as_key = action.dest
248
- if action.metavar:
249
- as_key = action.metavar if isinstance(action.metavar, str) else action.metavar[0]
250
236
  if action.option_strings:
237
+ args_text = _format_args(parser, action) if action.nargs != 0 else None
251
238
  for at, opt in enumerate(action.option_strings):
252
239
  if at:
253
240
  line += Text(", ")
254
241
  self._mk_option_name(line, prefix, opt)
255
- if action.nargs != 0:
242
+ if args_text is not None:
256
243
  line += Text(" ")
257
- metavar_text = (
258
- " ".join(meta.upper() for meta in action.metavar)
259
- if isinstance(action.metavar, tuple)
260
- else as_key.upper()
261
- )
262
- line += literal(text=metavar_text)
244
+ line += literal(text=args_text)
263
245
  else:
264
- self._mk_option_name(line, prefix, as_key)
246
+ metavar = action.metavar
247
+ self._mk_option_name(
248
+ line, prefix, " ".join(metavar) if isinstance(metavar, tuple) else metavar or action.dest
249
+ )
265
250
 
266
- if action.help:
267
- help_text = load_help_text(action.help)
251
+ extra: Sequence[Node] = ()
252
+ if help_text := _expand_help(action, parser.prog):
268
253
  temp = paragraph()
269
- self.state.nested_parse(StringList(help_text.split("\n")), 0, temp)
270
- line += Text(" - ")
271
- for content in cast("paragraph", temp.children[0]).children:
272
- line += content
254
+ self.state.nested_parse(StringList(load_help_text(help_text).split("\n")), 0, temp)
255
+ # only a leading paragraph can share the option's line; anything else becomes a block under it
256
+ if temp.children and isinstance(temp.children[0], paragraph):
257
+ line += Text(" - ")
258
+ line += temp.children[0].children
259
+ extra = temp.children[1:]
260
+ else:
261
+ extra = temp.children
273
262
  if (
274
263
  "no_default_values" not in self.options
275
264
  and action.default is not None
276
265
  and action.default != SUPPRESS
277
- and not re.match(r".*[ (]default[s]? .*", (action.help or ""))
266
+ and not _DEFAULT_IN_HELP.search(help_text)
278
267
  and not isinstance(action, _StoreTrueAction | _StoreFalseAction)
279
268
  ):
280
269
  line += Text(" (default: ")
281
270
  line += literal(text=str(action.default).replace(str(Path.cwd()), "{cwd}"))
282
271
  line += Text(")")
283
- _protect_option_dashes(line)
284
- return list_item("", line, ids=[])
272
+ item = list_item("", line, *extra, ids=[])
273
+ _protect_option_dashes(item)
274
+ return item
285
275
 
286
276
  def _mk_option_name(self, line: paragraph, prefix: str, opt: str) -> None:
287
277
  ref_id = self._make_id(f"{prefix}-{opt}")
@@ -290,6 +280,7 @@ class SphinxArgparseCli(SphinxDirective):
290
280
  line.attributes["ids"].append(ref_id)
291
281
  ref += strong("", "", literal(text=opt))
292
282
  self._register_ref(ref_id, ref_title, ref, is_cli_option=True)
283
+ self._std_domain.add_program_option(ws_re.sub("-", prefix), opt, self.env.docname, ref_id)
293
284
  line += ref
294
285
 
295
286
  def _register_ref(
@@ -317,15 +308,16 @@ class SphinxArgparseCli(SphinxDirective):
317
308
  self._std_domain.anonlabels[name] = doc_name, ref_name
318
309
  self._std_domain.labels[name] = doc_name, ref_name, ref_title
319
310
 
320
- def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser) -> section:
321
- sub_title_prefix: str = self.options["group_sub_title_prefix"]
322
- title_prefix: str = self.options["group_title_prefix"]
311
+ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser, sub_cmd: str) -> section:
312
+ sub_title_prefix: str = self.options.get("group_sub_title_prefix")
313
+ title_prefix: str = self.options.get("group_title_prefix")
323
314
 
324
315
  if sys.version_info >= (3, 14): # pragma: >=3.14 cover
325
316
  # https://github.com/python/cpython/issues/139809
326
317
  parser.prog = _strip_ansi_colors(parser.prog)
327
318
 
328
- title_text = self._build_sub_cmd_title(parser, sub_title_prefix, title_prefix)
319
+ root_prog = self.parser.prog.split("/")[-1]
320
+ title_text = self._resolve_prefix(root_prog, sub_cmd, parser.prog, title_prefix, sub_title_prefix).rstrip()
329
321
  title_ref: str = parser.prog
330
322
  if aliases:
331
323
  aliases_text: str = f" ({', '.join(aliases)})"
@@ -339,28 +331,19 @@ class SphinxArgparseCli(SphinxDirective):
339
331
  if "usage_first" in self.options:
340
332
  group_section += self._mk_usage(parser)
341
333
 
342
- command_desc = (parser.description or help_msg or "").strip()
343
- if command_desc:
344
- desc_paragraph = paragraph("", Text(command_desc))
345
- _protect_option_dashes(desc_paragraph)
346
- group_section += desc_paragraph
334
+ if command_desc := (parser.description or help_msg).strip():
335
+ group_section += self._pre_format(command_desc, parser)
347
336
 
348
337
  if "usage_first" not in self.options:
349
338
  group_section += self._mk_usage(parser)
350
339
 
351
340
  for group in parser._action_groups: # noqa: SLF001
352
- if not group._group_actions: # noqa: SLF001
353
- continue
354
- if isinstance(group._group_actions[0], _SubParsersAction): # noqa: SLF001
355
- continue
356
- group_section += self._mk_option_group(group, prefix=parser.prog, prog=self.parser.prog.split("/")[-1])
341
+ if actions := _visible_actions(group):
342
+ group_section += self._mk_option_group(group, actions, parser, prefix=parser.prog, sub_cmd=sub_cmd)
343
+ if epilog := self._pre_format(parser.epilog, parser):
344
+ group_section += epilog
357
345
  return group_section
358
346
 
359
- def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str:
360
- root_prog = self.parser.prog.split("/")[-1]
361
- sub_cmd = parser.prog[len(root_prog) :].strip().split(" ", maxsplit=1)[0]
362
- return self._resolve_prefix(root_prog, sub_cmd, parser.prog, title_prefix, sub_title_prefix).rstrip()
363
-
364
347
  def _resolve_prefix(
365
348
  self,
366
349
  prog_name: str,
@@ -397,18 +380,25 @@ class SphinxArgparseCli(SphinxDirective):
397
380
  return title_text
398
381
 
399
382
  def _mk_usage(self, parser: ArgumentParser) -> literal_block:
400
- parser.formatter_class = lambda prog: HelpFormatter(prog, width=self.options.get("usage_width", 100))
401
- with self._no_color():
383
+ width = self.options.get("usage_width", 100)
384
+ with patch.object(parser, "formatter_class", lambda prog: HelpFormatter(prog, width=width)), self._no_color():
402
385
  texts = parser.format_usage()[len("usage: ") :].splitlines()
403
386
  texts = [line if at == 0 else f"{' ' * (len(parser.prog) + 1)}{line.lstrip()}" for at, line in enumerate(texts)]
404
387
  return literal_block("", Text("\n".join(texts)), classes=["sphinx-argparse-cli-wrap"])
405
388
 
406
389
  @contextmanager
407
390
  def _no_color(self) -> Iterator[None]:
408
- with patch.dict(os.environ, {"NO_COLOR": "1"}, clear=False):
391
+ # PYTHON_COLORS=1 outranks NO_COLOR in argparse, so both must be set to get plain text
392
+ with patch.dict(os.environ, {"NO_COLOR": "1", "PYTHON_COLORS": "0"}, clear=False):
409
393
  yield
410
394
 
411
395
 
396
+ def _format_args(parser: ArgumentParser, action: Action) -> str:
397
+ # argparse's formatter keeps the text in step with the usage line: nargs, choices and user metavars included
398
+ formatter = parser._get_formatter() # noqa: SLF001
399
+ return formatter._format_args(action, formatter._get_default_metavar_for_optional(action)) # noqa: SLF001
400
+
401
+
412
402
  def make_id_lower(key: str) -> str:
413
403
  return re.sub("[A-Z]", lambda m: f"_{m.group(0).lower()}", make_id(key))
414
404
 
@@ -417,9 +407,46 @@ def make_id(key: str) -> str:
417
407
  return "-".join(key.split()).rstrip("-")
418
408
 
419
409
 
410
+ def _sub_parser_action(parser: ArgumentParser) -> _SubParsersAction[ArgumentParser] | None:
411
+ # add_subparsers reuses the positional group, so the action may sit after positional arguments;
412
+ # isinstance cannot recover the type parameter, hence the cast
413
+ found = next((action for action in parser._actions if isinstance(action, _SubParsersAction)), None) # noqa: SLF001
414
+ return cast("_SubParsersAction[ArgumentParser] | None", found)
415
+
416
+
417
+ def _visible_actions(group: _ArgumentGroup) -> list[Action]:
418
+ # sub-commands get their own sections, and a heading over an empty list helps nobody
419
+ return [
420
+ action
421
+ for action in group._group_actions # noqa: SLF001
422
+ if action.help != SUPPRESS and not isinstance(action, _SubParsersAction)
423
+ ]
424
+
425
+
426
+ def _expand_prog(text: str, prog: str) -> str:
427
+ # what argparse.HelpFormatter._format_text does for descriptions and epilogs
428
+ return text % {"prog": prog} if "%(prog)" in text else text
429
+
430
+
431
+ def _expand_help(action: Action, prog: str) -> str:
432
+ # mirrors argparse.HelpFormatter._expand_help so the help reads as it does under --help
433
+ help_text = action.help or ""
434
+ if "%" not in help_text:
435
+ return help_text
436
+ params = {
437
+ key: getattr(value, "__name__", value) for key, value in vars(action).items() if value is not SUPPRESS
438
+ } | {"prog": prog}
439
+ if action.choices is not None:
440
+ params["choices"] = ", ".join(map(str, action.choices))
441
+ return help_text % params
442
+
443
+
444
+ _DEFAULT_IN_HELP: Final[re.Pattern[str]] = re.compile(r"\bdefaults?\b", re.IGNORECASE)
445
+
420
446
  _HELP_SUBSTITUTIONS: Final[list[tuple[re.Pattern[str], str]]] = [
421
- (re.compile(r"[']+(.+?)[']+"), "``'\\1'``"),
422
- (re.compile(r'["]+(.+?)["]+'), '``"\\1"``'),
447
+ # a quote glued to a word character is an apostrophe (don't, it's), not the edge of a quoted span
448
+ (re.compile(r"(?<!\w)'([^']+?)'(?!\w)"), "``'\\1'``"),
449
+ (re.compile(r'(?<!\w)"([^"]+?)"(?!\w)'), '``"\\1"``'),
423
450
  (re.compile(r"[{](.+?)[}]"), "``{\\1}``"),
424
451
  ]
425
452
 
@@ -470,9 +497,8 @@ def _strip_ansi_colors(text: str) -> str: # pragma: >=3.14 cover
470
497
 
471
498
 
472
499
  def _update_sub_parser_prog(parser: ArgumentParser, old_prog: str, new_prog: str) -> None:
473
- if not (sub_parsers := parser._subparsers): # noqa: SLF001
500
+ if (sub_action := _sub_parser_action(parser)) is None:
474
501
  return
475
- sub_action: _SubParsersAction[ArgumentParser] = sub_parsers._group_actions[0] # type: ignore[assignment] # noqa: SLF001
476
502
  for sub_parser in sub_action.choices.values():
477
503
  sub_parser.prog = sub_parser.prog.replace(old_prog, new_prog, 1)
478
504
  _update_sub_parser_prog(sub_parser, old_prog, new_prog)
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.22.0'
22
- __version_tuple__ = version_tuple = (1, 22, 0)
21
+ __version__ = version = '1.23.0'
22
+ __version_tuple__ = version_tuple = (1, 23, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -16,7 +16,7 @@ complex options
16
16
 
17
17
  * **"--no-help"**
18
18
 
19
- * **"--outdir"** "OUT_DIR", **"-o"** "OUT_DIR" - output directory
19
+ * **"--outdir"** "out_dir", **"-o"** "out_dir" - output directory
20
20
 
21
21
  * **"--in-dir"** "IN_DIR", **"-i"** "IN_DIR" - input directory
22
22
 
@@ -16,7 +16,7 @@ complex optional arguments
16
16
 
17
17
  * **"--no-help"**
18
18
 
19
- * **"--outdir"** "OUT_DIR", **"-o"** "OUT_DIR" - output directory
19
+ * **"--outdir"** "out_dir", **"-o"** "out_dir" - output directory
20
20
 
21
21
  * **"--in-dir"** "IN_DIR", **"-i"** "IN_DIR" - input directory
22
22
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import re
4
5
  import sys
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING
@@ -69,6 +70,24 @@ def test_hook(build_outcome: str) -> None:
69
70
  assert build_outcome
70
71
 
71
72
 
73
+ @pytest.mark.sphinx(buildername="text", testroot="hook-intermixed")
74
+ def test_hook_intermixed(build_outcome: str) -> None:
75
+ assert (
76
+ build_outcome
77
+ == """foo - CLI interface
78
+ *******************
79
+
80
+ foo [--flag FLAG]
81
+
82
+
83
+ foo options
84
+ ===========
85
+
86
+ * **"--flag"** "FLAG" - a flag
87
+ """
88
+ )
89
+
90
+
72
91
  @pytest.mark.sphinx(buildername="text", testroot="hook-fail")
73
92
  def test_hook_fail(app: SphinxTestApp, warning: StringIO) -> None:
74
93
  app.build()
@@ -99,18 +118,18 @@ def test_set_description_as_text(build_outcome: str) -> None:
99
118
 
100
119
  @pytest.mark.sphinx(buildername="text", testroot="description-empty")
101
120
  def test_empty_description_as_text(build_outcome: str) -> None:
102
- assert build_outcome == "foo - CLI interface\n*******************\n\n\n foo\n"
121
+ assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n"
103
122
 
104
123
 
105
124
  @pytest.mark.sphinx(buildername="html", testroot="description-multiline")
106
125
  def test_multiline_description_as_html(build_outcome: str) -> None:
107
126
  ref = (
108
- "This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
109
- " a separate paragraph.\n"
127
+ "<pre><span></span>This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
128
+ "Now this should be a separate paragraph.\n</pre>"
110
129
  )
111
130
  assert ref in build_outcome
112
131
 
113
- ref = "This group description\n\nspans multiple lines.\n"
132
+ ref = "<pre><span></span>This group description\n\nspans multiple lines.\n</pre>"
114
133
  assert ref in build_outcome
115
134
 
116
135
 
@@ -121,14 +140,14 @@ def test_set_epilog_as_text(build_outcome: str) -> None:
121
140
 
122
141
  @pytest.mark.sphinx(buildername="text", testroot="epilog-empty")
123
142
  def test_empty_epilog_as_text(build_outcome: str) -> None:
124
- assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n\n"
143
+ assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n"
125
144
 
126
145
 
127
146
  @pytest.mark.sphinx(buildername="html", testroot="epilog-multiline")
128
147
  def test_multiline_epilog_as_html(build_outcome: str) -> None:
129
148
  ref = (
130
- "This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
131
- " a separate paragraph.\n"
149
+ "<pre><span></span>This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
150
+ "Now this should be a separate paragraph.\n</pre>"
132
151
  )
133
152
  assert ref in build_outcome
134
153
 
@@ -136,12 +155,58 @@ def test_multiline_epilog_as_html(build_outcome: str) -> None:
136
155
  @pytest.mark.sphinx(buildername="html", testroot="epilog-multiline-subclass")
137
156
  def test_multiline_epilog_subclass_formatter_as_html(build_outcome: str) -> None:
138
157
  ref = (
139
- "This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
140
- " a separate paragraph.\n"
158
+ "<pre><span></span>This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
159
+ "Now this should be a separate paragraph.\n</pre>"
141
160
  )
142
161
  assert ref in build_outcome
143
162
 
144
163
 
164
+ @pytest.mark.sphinx(buildername="text", testroot="subcommand-epilog-raw")
165
+ def test_sub_command_description_epilog_as_text(build_outcome: str) -> None:
166
+ assert (
167
+ build_outcome
168
+ == """prog - CLI interface
169
+ ********************
170
+
171
+ prog {raw,plain} ...
172
+
173
+ root description
174
+ kept as is
175
+
176
+
177
+ prog raw
178
+ ========
179
+
180
+ prog raw [--flag FLAG]
181
+
182
+ raw description
183
+ kept as is
184
+
185
+
186
+ prog raw options
187
+ ----------------
188
+
189
+ * **"--flag"** "FLAG" - raw flag
190
+
191
+ raw epilog
192
+ kept as is
193
+
194
+
195
+ prog plain
196
+ ==========
197
+
198
+ prog plain
199
+
200
+ plain description reflowed
201
+
202
+ plain epilog reflowed
203
+
204
+ root epilog
205
+ kept as is
206
+ """
207
+ )
208
+
209
+
145
210
  @pytest.mark.sphinx(buildername="html", testroot="smartquotes")
146
211
  def test_option_dashes_survive_smartquotes(build_outcome: str) -> None:
147
212
  # option names mentioned in parser-supplied text keep their double hyphen
@@ -174,21 +239,97 @@ def test_set_usage_first(build_outcome: str) -> None:
174
239
  assert "complex first [-h]" in build_outcome.split("a-first-desc", maxsplit=1)[0]
175
240
 
176
241
 
242
+ @pytest.mark.sphinx(buildername="text", testroot="help-apostrophe")
243
+ def test_help_apostrophe(build_outcome: str, warning: StringIO) -> None:
244
+ assert (
245
+ build_outcome
246
+ == """prog - CLI interface
247
+ ********************
248
+
249
+ prog [--a A] [--b B]
250
+
251
+
252
+ prog options
253
+ ============
254
+
255
+ * **"--a"** "A" - don't use it's value
256
+
257
+ * **"--b"** "B" - it's a "'thing'" to see
258
+ """
259
+ )
260
+ assert not warning.getvalue()
261
+
262
+
177
263
  @pytest.mark.sphinx(buildername="text", testroot="suppressed-action")
178
264
  def test_suppressed_action(build_outcome: str) -> None:
179
- assert "--activities-since" not in build_outcome
265
+ assert (
266
+ build_outcome
267
+ == """foo - CLI interface
268
+ *******************
269
+
270
+ desc
271
+
272
+ foo
273
+ """
274
+ )
275
+
276
+
277
+ @pytest.mark.sphinx(buildername="text", testroot="subparsers-positional")
278
+ def test_subparsers_after_positional(build_outcome: str) -> None:
279
+ assert (
280
+ build_outcome
281
+ == """prog - CLI interface
282
+ ********************
283
+
284
+ prog root {run} ...
285
+
286
+
287
+ prog positional arguments
288
+ =========================
289
+
290
+ * **"root"** - root positional
291
+
292
+
293
+ prog root run
294
+ =============
295
+
296
+ prog root run target {go} ...
297
+
298
+
299
+ prog root run positional arguments
300
+ ----------------------------------
301
+
302
+ * **"target"** - run positional
303
+
304
+
305
+ prog root run target go
306
+ =======================
307
+
308
+ prog root run target go [--flag FLAG]
309
+
310
+
311
+ prog root run target go options
312
+ -------------------------------
313
+
314
+ * **"--flag"** "FLAG" - go flag
315
+ """
316
+ )
180
317
 
181
318
 
182
319
  @pytest.mark.parametrize(
183
320
  ("example", "output"),
184
321
  [
185
- ("", ""),
186
- ("{", "{"),
187
- ('"', '"'),
188
- ("'", "'"),
189
- ("{a}", "``{a}``"),
190
- ('"a"', '``"a"``'),
191
- ("'a'", "``'a'``"),
322
+ pytest.param("", "", id="empty"),
323
+ pytest.param("{", "{", id="lone-brace"),
324
+ pytest.param('"', '"', id="lone-double-quote"),
325
+ pytest.param("'", "'", id="lone-single-quote"),
326
+ pytest.param("{a}", "``{a}``", id="braces"),
327
+ pytest.param('"a"', '``"a"``', id="double-quoted"),
328
+ pytest.param("'a'", "``'a'``", id="single-quoted"),
329
+ pytest.param("don't use it's value", "don't use it's value", id="apostrophes"),
330
+ pytest.param("it's a 'thing' to see", "it's a ``'thing'`` to see", id="apostrophe-and-quoted"),
331
+ pytest.param("'a' and 'b'", "``'a'`` and ``'b'``", id="two-quoted"),
332
+ pytest.param('say "hi" or "bye"', 'say ``"hi"`` or ``"bye"``', id="two-double-quoted"),
192
333
  ],
193
334
  )
194
335
  def test_help_loader(example: str, output: str) -> None:
@@ -215,6 +356,129 @@ def test_ref_prefix_doc(build_outcome: str) -> None:
215
356
  assert ref in build_outcome
216
357
 
217
358
 
359
+ @pytest.mark.sphinx(buildername="html", testroot="option")
360
+ def test_option_role_as_html(build_outcome: str, warning: StringIO) -> None:
361
+ hrefs = re.findall(r'<a class="reference internal" href="(#[^"]+)"><code class="xref std std-option', build_outcome)
362
+ assert hrefs == [
363
+ "#prog---root",
364
+ "#prog--r",
365
+ "#prog-run---magic",
366
+ "#prog-run-target",
367
+ "#prog-run---magic",
368
+ "#prog-run---magic",
369
+ ]
370
+ assert not warning.getvalue()
371
+
372
+
373
+ @pytest.mark.sphinx(buildername="text", testroot="subparsers-suppressed")
374
+ def test_suppressed_sub_command(build_outcome: str) -> None:
375
+ assert (
376
+ build_outcome
377
+ == """prog - CLI interface
378
+ ********************
379
+
380
+ prog {run,secret} ...
381
+
382
+
383
+ prog run
384
+ ========
385
+
386
+ run it
387
+
388
+ prog run
389
+ """
390
+ )
391
+
392
+
393
+ @pytest.mark.sphinx(buildername="text", testroot="python-colors")
394
+ def test_usage_ignores_python_colors(build_outcome: str) -> None:
395
+ assert (
396
+ build_outcome
397
+ == """prog - CLI interface
398
+ ********************
399
+
400
+ prog [--flag FLAG] {run} ...
401
+
402
+
403
+ prog options
404
+ ============
405
+
406
+ * **"--flag"** "FLAG" - a flag
407
+
408
+
409
+ prog run
410
+ ========
411
+
412
+ prog run [--magic MAGIC]
413
+
414
+
415
+ prog run options
416
+ ----------------
417
+
418
+ * **"--magic"** "MAGIC" - magic
419
+ """
420
+ )
421
+
422
+
423
+ @pytest.mark.sphinx(buildername="html", testroot="subcommand-placeholder")
424
+ def test_sub_command_placeholder_nested(build_outcome: str) -> None:
425
+ headings = re.findall(r'<h[23]>([^<]*)<a class="headerlink" href="(#[^"]*)"', build_outcome)
426
+ assert headings == [
427
+ ("prog positional arguments", "#prog-positional-arguments"),
428
+ ("prog first (f)", "#prog-root-first-(f)"),
429
+ ("prog first options", "#prog-root-first-options"),
430
+ ("prog first nested", "#prog-root-first-nested"),
431
+ ("prog first nested options", "#prog-root-first-nested-options"),
432
+ ]
433
+
434
+
435
+ @pytest.mark.sphinx(buildername="text", testroot="help-nodes")
436
+ def test_help_nodes_as_text(build_outcome: str) -> None:
437
+ assert (
438
+ build_outcome
439
+ == """prog - CLI interface
440
+ ********************
441
+
442
+ prog [--blank BLANK] [--list LIST] [--two TWO]
443
+
444
+
445
+ prog options
446
+ ============
447
+
448
+ * **"--blank"** "BLANK"
449
+
450
+ * **"--list"** "LIST"
451
+
452
+ * item one
453
+
454
+ * item two
455
+
456
+ * **"--two"** "TWO" - first paragraph
457
+
458
+ second paragraph
459
+ """
460
+ )
461
+
462
+
463
+ @pytest.mark.sphinx(buildername="html", testroot="help-nodes")
464
+ def test_help_nodes_as_html(build_outcome: str, warning: StringIO) -> None:
465
+ assert "<p></p>" not in build_outcome
466
+ assert not warning.getvalue()
467
+
468
+
469
+ @pytest.mark.sphinx(buildername="html", testroot="group-untitled")
470
+ def test_group_untitled(build_outcome: str, warning: StringIO) -> None:
471
+ headings = re.findall(r'<h2>(.*?)<a class="headerlink" href="(#[^"]+)"', build_outcome)
472
+ assert headings == [
473
+ ("tool options", "#tool-options"),
474
+ ("tool only a description", "#tool-only-a-description"),
475
+ ("tool another", "#tool-another"),
476
+ ("tool arguments", "#tool-arguments"),
477
+ ]
478
+ assert "<p>only a description</p>" not in build_outcome
479
+ assert not warning.getvalue()
480
+
481
+
218
482
  @pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label")
219
483
  def test_ref_duplicate_label(build_outcome: tuple[str, str], warning: StringIO) -> None:
220
484
  assert build_outcome
@@ -286,8 +550,8 @@ def test_group_title_prefix_sub_command_replacement(build_outcome: str, opt_grp_
286
550
  grp, anchor = opt_grp_name
287
551
  assert f'<h2>bar {grp}<a class="headerlink" href="#bar-{anchor}"' in build_outcome
288
552
  assert '<h2>bar Exclusive<a class="headerlink" href="#bar-Exclusive"' in build_outcome
289
- assert '<h2>bar baronlyroot (f)<a class="headerlink" href="#bar-root-first-(f)"' in build_outcome
290
- assert '<h3>bar baronlyroot first positional arguments<a class="headerlink"' in build_outcome
553
+ assert '<h2>bar baronlyfirst (f)<a class="headerlink" href="#bar-root-first-(f)"' in build_outcome
554
+ assert '<h3>bar baronlyfirst positional arguments<a class="headerlink"' in build_outcome
291
555
 
292
556
 
293
557
  @pytest.mark.sphinx(buildername="html", testroot="store-true-false")
@@ -323,6 +587,59 @@ def test_ref_cases(build_outcome: str, warning: StringIO) -> None:
323
587
  assert not warning.getvalue()
324
588
 
325
589
 
590
+ @pytest.mark.sphinx(buildername="text", testroot="help-format-specifiers")
591
+ def test_help_format_specifiers(build_outcome: str) -> None:
592
+ assert (
593
+ build_outcome
594
+ == """tool - CLI interface
595
+ ********************
596
+
597
+ tool does things
598
+
599
+ tool [--n N] [--mode {a,b}] [--pct PCT] [--capital CAPITAL] [--kind KIND] [--level LEVEL]
600
+ {run} ...
601
+
602
+
603
+ tool options
604
+ ============
605
+
606
+ * **"--n"** "N" - count (default: 3)
607
+
608
+ * **"--mode"** "{a,b}" - pick one of a, b (default: "a")
609
+
610
+ * **"--pct"** "PCT" - 100% of tool (default: "5")
611
+
612
+ * **"--capital"** "CAPITAL" - Default: 3
613
+
614
+ * **"--kind"** "KIND" - parsed with float
615
+
616
+
617
+ tool tuning
618
+ ===========
619
+
620
+ tune tool
621
+
622
+ * **"--level"** "LEVEL" - level (default: "1")
623
+
624
+
625
+ tool run
626
+ ========
627
+
628
+ tool run runs
629
+
630
+ tool run [--target TARGET]
631
+
632
+
633
+ tool run options
634
+ ----------------
635
+
636
+ * **"--target"** "TARGET" - target for tool run
637
+
638
+ run tool --help
639
+ """
640
+ )
641
+
642
+
326
643
  @pytest.mark.sphinx(buildername="text", testroot="default-handling")
327
644
  def test_with_default(build_outcome: str) -> None:
328
645
  assert (
@@ -369,6 +686,13 @@ def test_subparsers(build_outcome: str) -> None:
369
686
  assert '<section id="test-no_child-options">' in build_outcome
370
687
 
371
688
 
689
+ @pytest.mark.sphinx(buildername="html", testroot="subparsers-storeaction")
690
+ def test_subparsers_stoeaction(build_outcome: str) -> None:
691
+ assert '<section id="test-options">' in build_outcome
692
+ assert '<section id="test-subparser">' in build_outcome
693
+ assert '<section id="test-subparser-positional-arguments">' in build_outcome
694
+
695
+
372
696
  @pytest.mark.sphinx(buildername="text", testroot="bad-module")
373
697
  def test_bad_module(app: SphinxTestApp, warning: StringIO) -> None:
374
698
  app.build()
@@ -391,6 +715,44 @@ def test_nargs(build_outcome: str) -> None:
391
715
  assert 'default: "None"' not in build_outcome
392
716
 
393
717
 
718
+ @pytest.mark.sphinx(buildername="text", testroot="nargs-metavar")
719
+ def test_nargs_metavar(build_outcome: str) -> None:
720
+ assert (
721
+ build_outcome
722
+ == """tool - CLI interface
723
+ ********************
724
+
725
+ tool [--opt [OPT]] [--many [MANY ...]] [--two TWO TWO] [--rest ...] [--out <file>]
726
+ [--dir path/to/dir] [--format {json,xml}]
727
+ SRC DST
728
+
729
+
730
+ tool positional arguments
731
+ =========================
732
+
733
+ * **"SRC DST"** - copy pair
734
+
735
+
736
+ tool options
737
+ ============
738
+
739
+ * **"--opt"** "[OPT]" - optional value
740
+
741
+ * **"--many"** "[MANY ...]" - zero or more
742
+
743
+ * **"--two"** "TWO TWO" - exactly two
744
+
745
+ * **"--rest"** "..." - the rest
746
+
747
+ * **"--out"** "<file>" - output
748
+
749
+ * **"--dir"** "path/to/dir" - dir
750
+
751
+ * **"--format"** "{json,xml}" - output format
752
+ """
753
+ )
754
+
755
+
394
756
  @pytest.mark.sphinx(buildername="text", testroot="choices")
395
757
  def test_choices(build_outcome: str) -> None:
396
758
  assert "output format" in build_outcome
@@ -136,10 +136,10 @@ environment.python-version = "3.14"
136
136
 
137
137
  [tool.coverage]
138
138
  run.parallel = true
139
+ run.relative_files = true
139
140
  run.plugins = [
140
141
  "covdefaults",
141
142
  ]
142
- run.relative_files = true
143
143
  paths.source = [
144
144
  "src",
145
145
  "**/site-packages",