Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ All notable changes to this project will be documented in this file.
argument instead of inside its paragraph, and drop the empty paragraph an empty `:description:` produced.
- Fix a crash and render positional arguments when they are added before `add_subparsers()`; skip headings for groups
whose arguments are all suppressed.
- Keep `RawDescriptionHelpFormatter` line breaks in epilogs and in descriptions rendered after the usage block, and
render sub-command epilogs.

## 1.13.1

Expand Down
3 changes: 2 additions & 1 deletion roots/test-description-multiline/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ def make() -> ArgumentParser:
add_help=False,
)
group = parser.add_argument_group(
"group",
description="""This group description

spans multiple lines.
"""
""",
)
group.add_argument("--dummy")
return parser
8 changes: 8 additions & 0 deletions roots/test-subcommand-epilog-raw/conf.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions roots/test-subcommand-epilog-raw/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.. sphinx_argparse_cli::
:module: parser
:func: make
:usage_first:
25 changes: 25 additions & 0 deletions roots/test-subcommand-epilog-raw/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from argparse import ArgumentParser, RawDescriptionHelpFormatter


def make() -> ArgumentParser:
parser = ArgumentParser(
prog="prog",
description="root description\n kept as is",
epilog="root epilog\n kept as is",
formatter_class=RawDescriptionHelpFormatter,
add_help=False,
)
sub = parser.add_subparsers()
sub.add_parser(
"raw",
description="raw description\n kept as is",
epilog="raw epilog\n kept as is",
formatter_class=RawDescriptionHelpFormatter,
add_help=False,
).add_argument("--flag", help="raw flag")
sub.add_parser(
"plain", description="plain description\n reflowed", epilog="plain epilog\n reflowed", add_help=False
)
return parser
41 changes: 21 additions & 20 deletions src/sphinx_argparse_cli/_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,6 @@ def _std_domain(self) -> StandardDomain:
def _make_id(self) -> Callable[[str], str]:
return make_id_lower if "force_refs_lower" in self.options else make_id

@property
def _raw_format(self) -> bool:
formatter = self.parser.formatter_class
return isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter)

def _load_sub_parsers(
self, sub_parser: _SubParsersAction[ArgumentParser]
) -> Iterator[tuple[list[str], str, ArgumentParser]]:
Expand Down Expand Up @@ -169,7 +164,7 @@ def run(self) -> list[Node]:
if "usage_first" in self.options:
home_section += self._mk_usage(self.parser)

if description := self._pre_format(self.options.get("description", self.parser.description)):
if description := self._pre_format(self.options.get("description", self.parser.description), self.parser):
home_section += description

if "usage_first" not in self.options:
Expand All @@ -178,31 +173,38 @@ def run(self) -> list[Node]:
for group in self.parser._action_groups: # noqa: SLF001
if actions := _visible_actions(group):
home_section += self._mk_option_group(
group, actions, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1]
group,
actions,
self.parser,
prefix=self.parser.prog.split("/")[-1],
prog=self.parser.prog.split("/")[-1],
)
for aliases, help_msg, parser in self._iter_sub_commands():
home_section += self._mk_sub_command(aliases, help_msg, parser)

if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog)):
if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog), self.parser):
home_section += epilog

if self.content:
self.state.nested_parse(self.content, self.content_offset, home_section)

return [home_section]

def _pre_format(self, block: str | None) -> paragraph | literal_block | None:
def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph | literal_block | None:
if block is None or not block.strip():
return None
if self._raw_format and "\n" in block:
formatter = parser.formatter_class
if "\n" in block and isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter):
lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"])
lit["language"] = "none"
return lit
para = paragraph("", Text(block))
_protect_option_dashes(para)
return para

def _mk_option_group(self, group: _ArgumentGroup, actions: list[Action], prefix: str, prog: str) -> section:
def _mk_option_group(
self, group: _ArgumentGroup, actions: list[Action], parser: ArgumentParser, prefix: str, prog: str
) -> section:
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
title_prefix = self.options.get("group_title_prefix")
# an untitled group borrows its description as heading so its anchor stays unique
Expand All @@ -213,7 +215,7 @@ def _mk_option_group(self, group: _ArgumentGroup, actions: list[Action], prefix:
# the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
header = title("", Text(title_text))
group_section = section("", header, ids=[ref_id], names=[ref_id])
if group.title and (description := self._pre_format(group.description)):
if group.title and (description := self._pre_format(group.description, parser)):
group_section += description
self._register_ref(ref_id, title_text, group_section)
opt_group = bullet_list()
Expand Down Expand Up @@ -331,20 +333,19 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
if "usage_first" in self.options:
group_section += self._mk_usage(parser)

command_desc = (parser.description or help_msg or "").strip()
if command_desc:
desc_paragraph = paragraph("", Text(command_desc))
_protect_option_dashes(desc_paragraph)
group_section += desc_paragraph
if command_desc := (parser.description or help_msg).strip():
group_section += self._pre_format(command_desc, parser)

if "usage_first" not in self.options:
group_section += self._mk_usage(parser)

for group in parser._action_groups: # noqa: SLF001
if actions := _visible_actions(group):
group_section += self._mk_option_group(
group, actions, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]
group, actions, parser, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]
)
if epilog := self._pre_format(parser.epilog, parser):
group_section += epilog
return group_section

def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str:
Expand Down Expand Up @@ -388,8 +389,8 @@ def _apply_sub_title(title_text: str, sub_title_prefix: str, prog: str, sub_cmd:
return title_text

def _mk_usage(self, parser: ArgumentParser) -> literal_block:
parser.formatter_class = lambda prog: HelpFormatter(prog, width=self.options.get("usage_width", 100))
with self._no_color():
width = self.options.get("usage_width", 100)
with patch.object(parser, "formatter_class", lambda prog: HelpFormatter(prog, width=width)), self._no_color():
texts = parser.format_usage()[len("usage: ") :].splitlines()
texts = [line if at == 0 else f"{' ' * (len(parser.prog) + 1)}{line.lstrip()}" for at, line in enumerate(texts)]
return literal_block("", Text("\n".join(texts)), classes=["sphinx-argparse-cli-wrap"])
Expand Down
60 changes: 53 additions & 7 deletions tests/test_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,12 @@ def test_empty_description_as_text(build_outcome: str) -> None:
@pytest.mark.sphinx(buildername="html", testroot="description-multiline")
def test_multiline_description_as_html(build_outcome: str) -> None:
ref = (
"This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
" a separate paragraph.\n"
"<pre><span></span>This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
"Now this should be a separate paragraph.\n</pre>"
)
assert ref in build_outcome

ref = "This group description\n\nspans multiple lines.\n"
ref = "<pre><span></span>This group description\n\nspans multiple lines.\n</pre>"
assert ref in build_outcome


Expand All @@ -146,21 +146,67 @@ def test_empty_epilog_as_text(build_outcome: str) -> None:
@pytest.mark.sphinx(buildername="html", testroot="epilog-multiline")
def test_multiline_epilog_as_html(build_outcome: str) -> None:
ref = (
"This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
" a separate paragraph.\n"
"<pre><span></span>This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
"Now this should be a separate paragraph.\n</pre>"
)
assert ref in build_outcome


@pytest.mark.sphinx(buildername="html", testroot="epilog-multiline-subclass")
def test_multiline_epilog_subclass_formatter_as_html(build_outcome: str) -> None:
ref = (
"This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
" a separate paragraph.\n"
"<pre><span></span>This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
"Now this should be a separate paragraph.\n</pre>"
)
assert ref in build_outcome


@pytest.mark.sphinx(buildername="text", testroot="subcommand-epilog-raw")
def test_sub_command_description_epilog_as_text(build_outcome: str) -> None:
assert (
build_outcome
== """prog - CLI interface
********************

prog {raw,plain} ...

root description
kept as is


prog raw
========

prog raw [--flag FLAG]

raw description
kept as is


prog raw options
----------------

* **"--flag"** "FLAG" - raw flag

raw epilog
kept as is


prog plain
==========

prog plain

plain description reflowed

plain epilog reflowed

root epilog
kept as is
"""
)


@pytest.mark.sphinx(buildername="html", testroot="smartquotes")
def test_option_dashes_survive_smartquotes(build_outcome: str) -> None:
# option names mentioned in parser-supplied text keep their double hyphen
Expand Down