Skip to content

Commit 727964b

Browse files
Add option to hook argparse (#46)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent db17170 commit 727964b

File tree

9 files changed

+101
-11
lines changed

9 files changed

+101
-11
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Within the reStructuredText files use the `sphinx_argparse_cli` directive that t
3434
| module | the module path to where the parser is defined |
3535
| func | the name of the function that once called with no arguments constructs the parser |
3636
| prog | (optional) the module path to where the parser is defined |
37+
| hook | (optional) hook `argparse` to retrieve the parser if `func` uses a parser instead of returning it. |
3738
| title | (optional) when provided, overwrites the `<prog> - CLI interface` title added by default and when empty, will not be included |
3839
| usage_width | (optional) how large should usage examples be - defaults to 100 character |
3940
| group_title_prefix | (optional) groups subsections title prefixes, accepts the string `{prog}` as a replacement for the program name - defaults to `{prog}` |
@@ -48,6 +49,16 @@ For example:
4849
:prog: my-cli-program
4950
```
5051

52+
If you have code that creates and uses a parser but does not return it, you can specify the `:hook:` flag:
53+
54+
```rst
55+
.. sphinx_argparse_cli::
56+
:module: a_project.cli
57+
:func: main
58+
:hook:
59+
:prog: my-cli-program
60+
```
61+
5162
### Refer to generated content
5263

5364
The tool will register reference links to all anchors. This means that you can use the sphinx `ref` role to refer to

roots/test-hook-fail/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-hook-fail/index.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: main
4+
:hook:

roots/test-hook-fail/parser.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+
from argparse import ArgumentParser
4+
5+
6+
def main() -> None:
7+
parser = ArgumentParser(prog="foo", add_help=False)
8+
print(parser)

roots/test-hook/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-hook/index.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: main
4+
:hook:

roots/test-hook/parser.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def main() -> None:
7+
parser = ArgumentParser(prog="foo", add_help=False)
8+
args = parser.parse_args()
9+
print(args)

src/sphinx_argparse_cli/_logic.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
_SubParsersAction,
1515
)
1616
from collections import defaultdict, namedtuple
17-
from typing import Iterator, cast
17+
from typing import Any, Iterator, cast
1818

1919
from docutils.nodes import (
2020
Element,
@@ -32,7 +32,12 @@
3232
title,
3333
whitespace_normalize_name,
3434
)
35-
from docutils.parsers.rst.directives import positive_int, unchanged, unchanged_required
35+
from docutils.parsers.rst.directives import (
36+
flag,
37+
positive_int,
38+
unchanged,
39+
unchanged_required,
40+
)
3641
from docutils.parsers.rst.states import RSTState, RSTStateMachine
3742
from docutils.statemachine import StringList
3843
from sphinx.domains.std import StandardDomain
@@ -56,6 +61,7 @@ class SphinxArgparseCli(SphinxDirective):
5661
option_spec = {
5762
"module": unchanged_required,
5863
"func": unchanged_required,
64+
"hook": flag,
5965
"prog": unchanged,
6066
"title": unchanged,
6167
"usage_width": positive_int,
@@ -86,10 +92,24 @@ def parser(self) -> ArgumentParser:
8692
if self._parser is None:
8793
module_name, attr_name = self.options["module"], self.options["func"]
8894
parser_creator = getattr(__import__(module_name, fromlist=[attr_name]), attr_name)
89-
self._parser = parser_creator()
95+
if "hook" in self.options:
96+
original_parse_known_args = ArgumentParser.parse_known_args
97+
ArgumentParser.parse_known_args = _argparse_parse_known_args_hook # type: ignore
98+
try:
99+
parser_creator()
100+
except HookError as hooked:
101+
self._parser = hooked.parser
102+
finally:
103+
ArgumentParser.parse_known_args = original_parse_known_args # type: ignore
104+
else:
105+
self._parser = parser_creator()
106+
107+
del sys.modules[module_name] # no longer needed cleanup
108+
if self._parser is None:
109+
raise self.error("Failed to hook argparse to get ArgumentParser")
110+
90111
if "prog" in self.options:
91112
self._parser.prog = self.options["prog"]
92-
del sys.modules[module_name] # no longer needed cleanup
93113
return self._parser
94114

95115
def load_sub_parsers(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
@@ -319,4 +339,13 @@ def load_help_text(help_text: str) -> str:
319339
return literal_curly_braces
320340

321341

342+
class HookError(Exception):
343+
def __init__(self, parser: ArgumentParser):
344+
self.parser = parser
345+
346+
347+
def _argparse_parse_known_args_hook(self: ArgumentParser, *args: Any, **kwargs: Any) -> None: # noqa: U100
348+
raise HookError(self)
349+
350+
322351
__all__ = ("SphinxArgparseCli",)

tests/test_logic.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ def test_complex_as_html(build_outcome: str) -> None:
5858
assert build_outcome
5959

6060

61+
@pytest.mark.sphinx(buildername="html", testroot="hook")
62+
def test_hook(build_outcome: str) -> None:
63+
assert build_outcome
64+
65+
66+
@pytest.mark.sphinx(buildername="text", testroot="hook-fail")
67+
def test_hook_fail(app: SphinxTestApp, warning: StringIO) -> None:
68+
app.build()
69+
text = (Path(app.outdir) / "index.txt").read_text()
70+
assert "Failed to hook argparse to get ArgumentParser" in warning.getvalue()
71+
assert text == ""
72+
73+
6174
@pytest.mark.sphinx(buildername="text", testroot="prog")
6275
def test_prog_as_text(build_outcome: str) -> None:
6376
assert build_outcome == "magic - CLI interface\n*********************\n\n magic\n"
@@ -124,14 +137,10 @@ def test_ref_prefix_doc(build_outcome: str) -> None:
124137
assert ref in build_outcome
125138

126139

127-
_REF_WARNING_STRING_IO = StringIO() # could not find any better way to get the warning
128-
129-
130-
@pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label", warning=_REF_WARNING_STRING_IO)
131-
def test_ref_duplicate_label(build_outcome: tuple[str, str]) -> None:
140+
@pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label")
141+
def test_ref_duplicate_label(build_outcome: tuple[str, str], warning: StringIO) -> None:
132142
assert build_outcome
133-
warnings = _REF_WARNING_STRING_IO.getvalue()
134-
assert "duplicate label prog---help" in warnings
143+
assert "duplicate label prog---help" in warning.getvalue()
135144

136145

137146
@pytest.mark.sphinx(buildername="html", testroot="group-title-prefix-default")

0 commit comments

Comments
 (0)