Skip to content

Commit 50a092f

Browse files
committed
Implement ability to use ref to refer to content generated
Signed-off-by: Bernát Gábor <[email protected]>
1 parent 17c6a96 commit 50a092f

File tree

18 files changed

+201
-24
lines changed

18 files changed

+201
-24
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## 1.6.0 (2021-04-15)
6+
7+
- Support for using the `ref` sphinx role to refer to all anchor-able objects generated by the tool
8+
- Flags now have their reference title set (for the HTML builder this is shown when hover over the reference)
9+
- Anchors generated no longer collapse multiple subsequent `-` characters (to avoid clash when there's a flag and a
10+
positional argument with the same name)
11+
- Added a sphinx flag `sphinx_argparse_cli_prefix_document` (by default `False`) to avoid reference clashes when
12+
multiple documents generate the same reference labels
13+
- The root `prog` name now is prefixed for the root level optional/positional arguments' header (to avoid multiple
14+
anchors with the same id when multiple commands are documented in the same document)
15+
516
## 1.5.1 (2021-04-15)
617

718
- For sub-commands use the parser description first as description and only then fallback to the help message

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,25 @@ For example:
4747
:func: build_parser
4848
:prog: my-cli-program
4949
```
50+
51+
### Refer to generated content
52+
53+
The tool will register reference links to all anchors. This means that you can use the sphinx `ref` role to refer to
54+
both the (sub)command title/groups and every flag/argument. The tool offers a configuration flag
55+
`sphinx_argparse_cli_prefix_document` (change by setting this variable in `conf.py` - by default `False`). This option
56+
influences the reference ids generated. If it's false the reference will be the anchor id (the text appearing after the
57+
`'#` in the URI once you click on it). If it's true the anchor id will be prefixed by the document name (this is useful
58+
to avoid reference label clash when the same anchors are generated in multiple documents).
59+
60+
For example in case of a `tox` command, and `sphinx_argparse_cli_prefix_document=False` (default):
61+
62+
- to refer to the optional arguments group use `` :ref:`tox-optional-arguments` ``,
63+
- to refer to the run subcommand use `` :ref:`tox-run` ``,
64+
- to refer to flag `--magic` of the `run` sub-command use `` :ref:`tox-run---magic` ``.
65+
66+
For example in case of a `tox` command, and `sphinx_argparse_cli_prefix_document=True`, and the current document name
67+
being `cli`:
68+
69+
- to refer to the optional arguments group use `` :ref:`cli:tox-optional-arguments` ``,
70+
- to refer to the run subcommand use `` :ref:`cli:tox-run` ``,
71+
- to refer to flag `--magic` of the `run` sub-command use `` :ref:`cli:tox-run---magic` ``.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
4+
5+
.. sphinx_argparse_cli::
6+
:module: parser
7+
:func: make
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
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
4+
5+
.. sphinx_argparse_cli::
6+
:module: parser
7+
:func: make
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(description="argparse tester", prog="prog")
8+
parser.add_argument("root")
9+
parser.add_argument("--root", action="store_true", help="root flag")
10+
return parser

roots/test-ref-prefix-doc/conf.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+
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
9+
sphinx_argparse_cli_prefix_document = True

roots/test-ref-prefix-doc/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
4+
5+
Reference test
6+
--------------
7+
Flag :ref:`index:prog---root` and positional :ref:`index:prog-root`.

roots/test-ref-prefix-doc/parser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(description="argparse tester", prog="prog")
8+
parser.add_argument("root")
9+
parser.add_argument("--root", action="store_true", help="root flag")
10+
return parser

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
4+
5+
Reference test
6+
--------------
7+
Flag :ref:`prog---root` and positional :ref:`prog-root`.

roots/test-ref/parser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(description="argparse tester", prog="prog")
8+
parser.add_argument("root")
9+
parser.add_argument("--root", action="store_true", help="root flag")
10+
return parser

src/sphinx_argparse_cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ def setup(app: Sphinx) -> None:
1111
from ._logic import SphinxArgparseCli
1212

1313
app.add_directive(SphinxArgparseCli.name, SphinxArgparseCli)
14+
app.add_config_value("sphinx_argparse_cli_prefix_document", False, "env")
1415

1516

1617
__all__ = ("__version__",)

src/sphinx_argparse_cli/_logic.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,9 @@
33
import os
44
import re
55
import sys
6-
from argparse import (
7-
SUPPRESS,
8-
Action,
9-
ArgumentParser,
10-
HelpFormatter,
11-
_ArgumentGroup,
12-
_SubParsersAction,
13-
)
6+
from argparse import _ArgumentGroup # noqa
7+
from argparse import _SubParsersAction # noqa
8+
from argparse import SUPPRESS, Action, ArgumentParser, HelpFormatter
149
from collections import defaultdict, namedtuple
1510
from typing import Iterator, cast
1611

@@ -19,6 +14,7 @@
1914
Node,
2015
Text,
2116
bullet_list,
17+
fully_normalize_name,
2218
list_item,
2319
literal,
2420
literal_block,
@@ -31,13 +27,19 @@
3127
from docutils.parsers.rst.directives import positive_int, unchanged, unchanged_required
3228
from docutils.parsers.rst.states import RSTState, RSTStateMachine
3329
from docutils.statemachine import StringList
30+
from sphinx.domains.std import StandardDomain
31+
from sphinx.locale import __
3432
from sphinx.util.docutils import SphinxDirective
33+
from sphinx.util.logging import getLogger
3534

3635
TextAsDefault = namedtuple("TextAsDefault", ["text"])
3736

3837

3938
def make_id(key: str) -> str:
40-
return re.sub(r"-{2,}", "-", re.sub(r"\W", "-", key)).rstrip("-").lower()
39+
return "-".join(key.split()).rstrip("-").lower()
40+
41+
42+
logger = getLogger(__name__)
4143

4244

4345
class SphinxArgparseCli(SphinxDirective):
@@ -65,6 +67,7 @@ def __init__(
6567
):
6668
super().__init__(name, arguments, options, content, lineno, content_offset, block_text, state, state_machine)
6769
self._parser: ArgumentParser | None = None
70+
self._std_domain: StandardDomain = cast(StandardDomain, self.env.get_domain("std"))
6871

6972
@property
7073
def parser(self) -> ArgumentParser:
@@ -116,7 +119,7 @@ def run(self) -> list[Node]:
116119
for group in self.parser._action_groups: # noqa
117120
if not group._group_actions or group is self.parser._subparsers: # noqa
118121
continue
119-
home_section += self._mk_option_group(group, prefix="")
122+
home_section += self._mk_option_group(group, prefix=self.parser.prog.split("/")[-1])
120123
# construct sub-parser
121124
for aliases, help_msg, parser in self.load_sub_parsers():
122125
home_section += self._mk_sub_command(aliases, help_msg, parser)
@@ -130,6 +133,7 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str) -> section:
130133
group_section = section("", header, ids=[ref_id], names=[ref_id])
131134
if group.description:
132135
group_section += paragraph("", Text(group.description))
136+
self._register_ref(ref_id, title_text, group_section)
133137
opt_group = bullet_list()
134138
for action in group._group_actions: # noqa
135139
point = self._mk_option_line(action, prefix)
@@ -171,21 +175,42 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item: # noqa
171175
line += Text(")")
172176
return point
173177

174-
@staticmethod
175-
def _mk_option_name(line: paragraph, prefix: str, opt: str) -> None:
178+
def _mk_option_name(self, line: paragraph, prefix: str, opt: str) -> None:
176179
ref_id = make_id(f"{prefix}-{opt}")
177-
ref = reference("", refid=ref_id)
180+
ref_title = f"{prefix} {opt}"
181+
ref = reference("", refid=ref_id, reftitle=ref_title)
178182
line.attributes["ids"].append(ref_id)
179183
st = strong()
180184
st += literal(text=opt)
181185
ref += st
186+
self._register_ref(ref_id, ref_title, ref)
182187
line += ref
183188

189+
def _register_ref(self, ref_name: str, ref_title: str, node: Element) -> None:
190+
doc_name = self.env.docname
191+
if self.env.config.sphinx_argparse_cli_prefix_document:
192+
name = fully_normalize_name(f"{doc_name}:{ref_name}")
193+
else:
194+
name = fully_normalize_name(ref_name)
195+
if name in self._std_domain.labels:
196+
logger.warning(
197+
__("duplicate label %s, other instance in %s"),
198+
name,
199+
self.env.doc2path(self._std_domain.labels[name][0]),
200+
location=node,
201+
type="sphinx-argparse-cli",
202+
subtype=self.env.docname,
203+
)
204+
self._std_domain.anonlabels[name] = doc_name, ref_name
205+
self._std_domain.labels[name] = doc_name, ref_name, ref_title
206+
184207
def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser) -> section:
185208
title_text = f"{parser.prog}"
186209
if aliases:
187210
title_text += f" ({', '.join(aliases)})"
188-
group_section = section("", title("", Text(title_text)), ids=[make_id(title_text)], names=[title_text])
211+
ref_id = make_id(title_text)
212+
group_section = section("", title("", Text(title_text)), ids=[ref_id], names=[title_text])
213+
self._register_ref(ref_id, title_text, group_section)
189214

190215
command_desc = (parser.description or help_msg or "").strip()
191216
if command_desc:

tests/complex.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ argparse tester
77
{first,f,second,third} ...
88

99

10-
optional arguments
11-
==================
10+
complex optional arguments
11+
==========================
1212

1313
* **"-h"**, **"--help"** - show this help message and exit
1414

@@ -23,8 +23,8 @@ optional arguments
2323
(default: "None")
2424

2525

26-
Exclusive
27-
=========
26+
complex Exclusive
27+
=================
2828

2929
this is an exclusive group
3030

tests/test_logic.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
from io import StringIO
45
from pathlib import Path
56
from typing import cast
67

@@ -29,7 +30,8 @@ def build_outcome(app: SphinxTestApp, request: SubRequest) -> str:
2930
ext = ext_mapping[sphinx_marker.kwargs.get("buildername")]
3031

3132
app.build()
32-
return (Path(app.outdir) / f"index.{ext}").read_text()
33+
text = (Path(app.outdir) / f"index.{ext}").read_text()
34+
return text
3335

3436

3537
@pytest.mark.sphinx(buildername="html", testroot="basic")
@@ -92,3 +94,33 @@ def test_help_loader(example: str, output: str) -> None:
9294

9395
result = load_help_text(example)
9496
assert result == output
97+
98+
99+
@pytest.mark.sphinx(buildername="html", testroot="ref")
100+
def test_ref_as_html(build_outcome: str) -> None:
101+
ref = (
102+
'<p>Flag <a class="reference internal" href="#prog---root"><span class="std std-ref">prog --root</span></a> and'
103+
' positional <a class="reference internal" href="#prog-root"><span class="std std-ref">prog root</span></a>.'
104+
"</p>"
105+
)
106+
assert ref in build_outcome
107+
108+
109+
@pytest.mark.sphinx(buildername="html", testroot="ref-prefix-doc")
110+
def test_ref_prefix_doc(build_outcome: str) -> None:
111+
ref = (
112+
'<p>Flag <a class="reference internal" href="#prog---root"><span class="std std-ref">prog --root</span></a> and'
113+
' positional <a class="reference internal" href="#prog-root"><span class="std std-ref">prog root</span></a>.'
114+
"</p>"
115+
)
116+
assert ref in build_outcome
117+
118+
119+
_REF_WARNING_STRING_IO = StringIO() # could not find any better way to get the warning
120+
121+
122+
@pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label", warning=_REF_WARNING_STRING_IO)
123+
def test_ref_duplicate_label(build_outcome: tuple[str, str]) -> None:
124+
assert build_outcome
125+
warnings = _REF_WARNING_STRING_IO.getvalue()
126+
assert "duplicate label prog---help" in warnings

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ description = run type check on code base
4646
setenv =
4747
{tty:MYPY_FORCE_COLOR = 1}
4848
deps =
49-
mypy==0.800
49+
mypy==0.812
5050
commands =
5151
mypy --strict --python-version 3.9 src
5252
mypy --strict --python-version 3.9 tests

whitelist.txt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
Formattter
1+
addinivalue
2+
anonlabels
23
autosectionlabel
3-
autouse
44
buildername
55
confdir
66
desc
77
dest
8+
doc2path
9+
docname
810
docutils
911
formatter
12+
Formatter
1013
fromlist
11-
func
1214
lineno
1315
linesep
1416
metavar
@@ -18,9 +20,10 @@ parsers
1820
pathlib
1921
prog
2022
refid
23+
reftitle
2124
rst
2225
statemachine
2326
subparsers
27+
subtype
2428
testroot
2529
util
26-
addinivalue

0 commit comments

Comments
 (0)