diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 665d24c..5e7d86f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,7 +2,7 @@ name: check on: workflow_dispatch: push: - branches: "main" + branches: ["main"] tags-ignore: ["**"] pull_request: schedule: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b94e7a..c07ff5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,10 +4,16 @@ repos: hooks: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/psf/black - rev: 24.1.1 + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.0 hooks: - - id: black + - id: check-github-workflows + args: [ "--verbose" ] + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + args: ["--write-changes"] - repo: https://github.com/tox-dev/tox-ini-fmt rev: "1.3.1" hooks: @@ -17,12 +23,13 @@ repos: rev: "1.7.0" hooks: - id: pyproject-fmt - additional_dependencies: ["tox>=4.11.4"] + additional_dependencies: ["tox>=4.12.1"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.2.0" + rev: "v0.2.1" hooks: + - id: ruff-format - id: ruff - args: [--fix, --exit-non-zero-on-fix] + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] - repo: meta hooks: - id: check-hooks-apply diff --git a/README.md b/README.md index c3f9c4e..4ce961b 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,12 @@ Within the reStructuredText files use the `sphinx_argparse_cli` directive that t | hook | (optional) hook `argparse` to retrieve the parser if `func` uses a parser instead of returning it. | | title | (optional) when provided, overwrites the ` - CLI interface` title added by default and when empty, will not be included | | description | (optional) when provided, overwrites the description and when empty, will not be included | +| epilog | (optional) when provided, overwrites the epilog and when empty, will not be included | | usage_width | (optional) how large should usage examples be - defaults to 100 character | +| usage_first | (optional) show usage before description | | group_title_prefix | (optional) groups subsections title prefixes, accepts the string `{prog}` as a replacement for the program name - defaults to `{prog}` | | group_sub_title_prefix | (optional) subcommands groups subsections title prefixes, accepts replacement of `{prog}` and `{subcommand}` for program and subcommand name - defaults to `{prog} {subcommand}` | -| no_default_values | (optional) supresses generation of `default` entries | +| no_default_values | (optional) suppresses generation of `default` entries | For example: diff --git a/pyproject.toml b/pyproject.toml index 1fdfc9d..c5ac9f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,18 +59,21 @@ version.source = "vcs" line-length = 120 [tool.ruff] -select = ["ALL"] line-length = 120 target-version = "py38" -isort = {known-first-party = ["sphinx_argparse_cli"], required-imports = ["from __future__ import annotations"]} -ignore = [ - "ANN101", # no typoe annotation for self +lint.select = ["ALL"] +lint.isort = {known-first-party = ["sphinx_argparse_cli"], required-imports = ["from __future__ import annotations"]} +lint.ignore = [ + "ANN101", # no type annotation for self "ANN401", # allow Any as type annotation "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible "S104", # Possible binding to all interface + "COM812", # Conflict with formatter + "ISC001", # Conflict with formatter + "CPY", # No copyright statements ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ "S101", # asserts allowed in tests... "FBT", # don"t care about booleans as positional arguments in tests @@ -78,12 +81,20 @@ ignore = [ "D", # don"t care about documentation in tests "S603", # `subprocess` call: check for execution of untrusted input "PLR2004", # Magic value used in comparison, consider replacing with a constant variable + "PLR2004", # Magic value used in comparison, consider replacing with a constant variable + "PLR0913", # any number of arguments in tests + "PLR0917", # any number of arguments in tests + "PLC2701", # private import ] "roots/**/*.py" = [ "INP001", # no namespace "D", # no docs ] +[tool.codespell] +builtin = "clear,usage,en-GB_to_en-US" +count = true + [tool.coverage] html.show_contexts = true html.skip_covered = false diff --git a/roots/test-complex/parser.py b/roots/test-complex/parser.py index 02c1047..35c55fc 100644 --- a/roots/test-complex/parser.py +++ b/roots/test-complex/parser.py @@ -4,7 +4,7 @@ def make() -> ArgumentParser: - parser = ArgumentParser(description="argparse tester", prog="complex") + parser = ArgumentParser(description="argparse tester", prog="complex", epilog="test epilog") parser.add_argument("--root", action="store_true", help="root flag") parser.add_argument("--no-help", action="store_true") parser.add_argument("--outdir", "-o", type=str, help="output directory", metavar="out_dir") diff --git a/roots/test-epilog-empty/conf.py b/roots/test-epilog-empty/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-epilog-empty/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-epilog-empty/index.rst b/roots/test-epilog-empty/index.rst new file mode 100644 index 0000000..a63e307 --- /dev/null +++ b/roots/test-epilog-empty/index.rst @@ -0,0 +1,4 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make + :epilog: diff --git a/roots/test-epilog-empty/parser.py b/roots/test-epilog-empty/parser.py new file mode 100644 index 0000000..c204a1e --- /dev/null +++ b/roots/test-epilog-empty/parser.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from argparse import ArgumentParser + + +def make() -> ArgumentParser: + return ArgumentParser(prog="foo", epilog="epi", add_help=False) diff --git a/roots/test-epilog-multiline/conf.py b/roots/test-epilog-multiline/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-epilog-multiline/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-epilog-multiline/index.rst b/roots/test-epilog-multiline/index.rst new file mode 100644 index 0000000..708ad9c --- /dev/null +++ b/roots/test-epilog-multiline/index.rst @@ -0,0 +1,3 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make diff --git a/roots/test-epilog-multiline/parser.py b/roots/test-epilog-multiline/parser.py new file mode 100644 index 0000000..5b875df --- /dev/null +++ b/roots/test-epilog-multiline/parser.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from argparse import ArgumentParser, RawDescriptionHelpFormatter + + +def make() -> ArgumentParser: + return ArgumentParser( + prog="foo", + epilog="""This epilog +spans multiple lines. + + this line is indented. + and also this. + +Now this should be a separate paragraph. +""", + formatter_class=RawDescriptionHelpFormatter, + add_help=False, + ) diff --git a/roots/test-epilog-set/conf.py b/roots/test-epilog-set/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-epilog-set/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-epilog-set/index.rst b/roots/test-epilog-set/index.rst new file mode 100644 index 0000000..629a901 --- /dev/null +++ b/roots/test-epilog-set/index.rst @@ -0,0 +1,4 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make + :epilog: My own epilog diff --git a/roots/test-epilog-set/parser.py b/roots/test-epilog-set/parser.py new file mode 100644 index 0000000..c204a1e --- /dev/null +++ b/roots/test-epilog-set/parser.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from argparse import ArgumentParser + + +def make() -> ArgumentParser: + return ArgumentParser(prog="foo", epilog="epi", add_help=False) diff --git a/roots/test-suppressed-action/conf.py b/roots/test-suppressed-action/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-suppressed-action/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-suppressed-action/index.rst b/roots/test-suppressed-action/index.rst new file mode 100644 index 0000000..708ad9c --- /dev/null +++ b/roots/test-suppressed-action/index.rst @@ -0,0 +1,3 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make diff --git a/roots/test-suppressed-action/parser.py b/roots/test-suppressed-action/parser.py new file mode 100644 index 0000000..fa71081 --- /dev/null +++ b/roots/test-suppressed-action/parser.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from argparse import SUPPRESS, ArgumentParser + + +def make() -> ArgumentParser: + parser = ArgumentParser(prog="foo", description="desc", add_help=False) + parser.add_argument( + "--activities-since", + metavar="TIMESTAMP", + help=SUPPRESS, + ) + return parser diff --git a/src/sphinx_argparse_cli/_logic.py b/src/sphinx_argparse_cli/_logic.py index 6cfab94..92fe33b 100644 --- a/src/sphinx_argparse_cli/_logic.py +++ b/src/sphinx_argparse_cli/_logic.py @@ -65,7 +65,9 @@ class SphinxArgparseCli(SphinxDirective): "prog": unchanged, "title": unchanged, "description": unchanged, + "epilog": unchanged, "usage_width": positive_int, + "usage_first": flag, "group_title_prefix": unchanged, "group_sub_title_prefix": unchanged, "no_default_values": unchanged, @@ -88,6 +90,7 @@ def __init__( # noqa: PLR0913 super().__init__(name, arguments, options, content, lineno, content_offset, block_text, state, state_machine) self._parser: ArgumentParser | None = None self._std_domain: StandardDomain = cast(StandardDomain, self.env.get_domain("std")) + self._raw_format: bool = False @property def parser(self) -> ArgumentParser: @@ -113,6 +116,8 @@ def parser(self) -> ArgumentParser: if "prog" in self.options: self._parser.prog = self.options["prog"] + + self._raw_format = self._parser.formatter_class == RawDescriptionHelpFormatter return self._parser def load_sub_parsers(self) -> Iterator[tuple[list[str], str, ArgumentParser]]: @@ -147,16 +152,16 @@ def run(self) -> list[Node]: else: home_section = section("", title("", Text(title_text)), ids=[make_id(title_text)], names=[title_text]) - description = self.options.get("description", self.parser.description) - if description: - if isinstance(self.parser.formatter_class(""), RawDescriptionHelpFormatter) and "\n" in description: - lit = literal_block("", Text(description)) - lit["language"] = "none" - home_section += lit - else: - home_section += paragraph("", Text(description)) + 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)): + home_section += description + + if "usage_first" not in self.options: + home_section += self._mk_usage(self.parser) + # construct groups excluding sub-parsers - home_section += self._mk_usage(self.parser) for group in self.parser._action_groups: # noqa: SLF001 if not group._group_actions or group is self.parser._subparsers: # noqa: SLF001 continue @@ -164,8 +169,21 @@ def run(self) -> list[Node]: # construct sub-parser for aliases, help_msg, parser in self.load_sub_parsers(): home_section += self._mk_sub_command(aliases, help_msg, parser) + + if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog)): + home_section += epilog + return [home_section] + def _pre_format(self, block: None | str) -> None | paragraph | literal_block: + if block is None: + return None + if self._raw_format and "\n" in block: + lit = literal_block("", Text(block)) + lit["language"] = "none" + return lit + return paragraph("", Text(block)) + def _mk_option_group(self, group: _ArgumentGroup, prefix: str) -> section: sub_title_prefix: str = self.options["group_sub_title_prefix"] title_prefix = self.options["group_title_prefix"] @@ -181,6 +199,8 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str) -> section: self._register_ref(ref_id, title_text, group_section) opt_group = bullet_list() for action in group._group_actions: # noqa: SLF001 + if action.help == SUPPRESS: + continue point = self._mk_option_line(action, prefix) opt_group += point group_section += opt_group @@ -298,11 +318,17 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar group_section = section("", title("", Text(title_text)), ids=[ref_id], names=[title_ref]) self._register_ref(ref_id, title_ref, group_section) + 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)) group_section += desc_paragraph - group_section += self._mk_usage(parser) + + if "usage_first" not in self.options: + group_section += self._mk_usage(parser) + for group in parser._action_groups: # noqa: SLF001 if not group._group_actions: # do not show empty groups # noqa: SLF001 continue diff --git a/tests/complex.txt b/tests/complex.txt index 04a8d23..10b5772 100644 --- a/tests/complex.txt +++ b/tests/complex.txt @@ -93,3 +93,5 @@ complex third options --------------------- * **"-h"**, **"--help"** - show this help message and exit + +test epilog diff --git a/tests/complex_pre_310.txt b/tests/complex_pre_310.txt index 41e91d4..23a73cc 100644 --- a/tests/complex_pre_310.txt +++ b/tests/complex_pre_310.txt @@ -93,3 +93,5 @@ complex third optional arguments -------------------------------- * **"-h"**, **"--help"** - show this help message and exit + +test epilog diff --git a/tests/test_logic.py b/tests/test_logic.py index da81f36..4e0ea35 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -108,6 +108,25 @@ def test_multiline_description_as_html(build_outcome: str) -> None: assert ref in build_outcome +@pytest.mark.sphinx(buildername="text", testroot="epilog-set") +def test_set_epilog_as_text(build_outcome: str) -> None: + assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n\nMy own epilog\n" + + +@pytest.mark.sphinx(buildername="text", testroot="epilog-empty") +def test_empty_epilog_as_text(build_outcome: str) -> None: + assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n" + + +@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" + ) + assert ref in build_outcome + + @pytest.mark.sphinx(buildername="text", testroot="complex") @pytest.mark.prepare(directive_args=[":usage_width: 100"]) def test_usage_width_default(build_outcome: str) -> None: @@ -120,6 +139,18 @@ def test_usage_width_custom(build_outcome: str) -> None: assert "complex second [-h] [--flag] [--root]\n" in build_outcome +@pytest.mark.sphinx(buildername="text", testroot="complex") +@pytest.mark.prepare(directive_args=[":usage_first:"]) +def test_set_usage_first(build_outcome: str) -> None: + assert "complex [-h]" in build_outcome.split("argparse tester")[0] + assert "complex first [-h]" in build_outcome.split("a-first-desc")[0] + + +@pytest.mark.sphinx(buildername="text", testroot="suppressed-action") +def test_suppressed_action(build_outcome: str) -> None: + assert "--activities-since" not in build_outcome + + @pytest.mark.parametrize( ("example", "output"), [