Skip to content

Commit 75e26c5

Browse files
committed
Add force_refs_lower flag
Sphinx chooses to lower() all :ref: values, which means that we can not link to a program that has an upper-case value in it's program name, nor differentiate between upper and lower-case single character flags in references (e.g. -A/-a). While we could simply lower() the program name, it's not quite so simple for mixed-case arguments because they can then make duplicate refs. A alternative simple approach that fits nicely into the existing code is to simply prefix capitals in references with "_" and lower them. Unfortunately since this changes the refs, this would be a backwards incompatible change if made unconditionally. You may also decide that mixed case references are more clear in your output HTML URL's if you are not worried about ever making internal references in your documentation. Thus this adds a :force_refs_lower: flag to optionally enable the prefix/lower behaviour. A test-case and documentation is added as well.
1 parent 27990d1 commit 75e26c5

File tree

8 files changed

+80
-5
lines changed

8 files changed

+80
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
*~
12
.idea
23
*.egg-info/
34
.tox/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
66

77
- Allow to add content to directive.
88
- Fix Sphinx warnings about parallel reads.
9+
- Add `force_args_lower` to enable `:ref:` links with mixed-case program names and arguments.
910

1011
## 1.13.1
1112

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Within the reStructuredText files use the `sphinx_argparse_cli` directive that t
4343
| group_title_prefix | (optional) groups subsections title prefixes, accepts the string `{prog}` as a replacement for the program name - defaults to `{prog}` |
4444
| 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}` |
4545
| no_default_values | (optional) suppresses generation of `default` entries |
46-
46+
| force_refs_lower | (optional) Sphinx `:ref:` only supports lower-case references. With this, any capital letter in generated reference anchors are lowered and given an `_` prefix (i.e. `A` becomes `_a`) |
4747
For example:
4848

4949
```rst
@@ -84,3 +84,17 @@ being `cli`:
8484
- to refer to the optional arguments group use ``:ref:`cli:tox-optional-arguments` ``,
8585
- to refer to the run subcommand use ``:ref:`cli:tox-run` ``,
8686
- to refer to flag `--magic` of the `run` sub-command use ``:ref:`cli:tox-run---magic` ``.
87+
88+
Due to Sphinx's `:ref:` only supporting lower-case values, if you need
89+
to distinguish mixed case program names or arguments, set the
90+
`:force_refs_lower:` argument. With this flag, captial-letters in
91+
references will be converted to their lower-case counterpart and
92+
prefixed with an `_`. For example:
93+
94+
- A `prog` name `SampleProgram` will be referenced as ``:ref:`_sample_program...` ``.
95+
- To distinguish between mixed case flags `-a` and `-A` use ``:ref:`_sample_program--a` `` and ``:ref:`_sample_program--_a` `` respectively
96+
97+
Note that if you are _not_ concernced about using internal Sphinx
98+
`:ref:` cross-references, you may choose to leave this off to maintain
99+
mixed-case anchors in your output HTML; but be aware that later
100+
enabling it will change your anchors in the output HTML.
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
4+
:force_refs_lower:
5+
6+
Reference test
7+
--------------
8+
Flag :ref:`_prog--_b` and :ref:`_prog--b` and positional :ref:`_prog-root`.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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("--build", "-B", action="store_true", help="build flag")
10+
parser.add_argument("--binary", "-b", action="store_true", help="binary flag")
11+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ def make_id(key: str) -> str:
5252
return "-".join(key.split()).rstrip("-")
5353

5454

55+
def make_id_lower(key: str) -> str:
56+
# replace all capital letters "X" with "_lower(X)"
57+
return re.sub("[A-Z]", lambda m: "_" + m.group(0).lower(), make_id(key))
58+
59+
5560
logger = getLogger(__name__)
5661

5762

@@ -71,6 +76,10 @@ class SphinxArgparseCli(SphinxDirective):
7176
"group_title_prefix": unchanged,
7277
"group_sub_title_prefix": unchanged,
7378
"no_default_values": unchanged,
79+
# :ref: only supports lower-case. If this is set, any
80+
# would-be-upper-case chars will be prefixed with _. Since
81+
# this is backwards incompatible for URL's, this is opt-in.
82+
"force_refs_lower": flag,
7483
}
7584

7685
def __init__( # noqa: PLR0913
@@ -91,6 +100,7 @@ def __init__( # noqa: PLR0913
91100
self._parser: ArgumentParser | None = None
92101
self._std_domain: StandardDomain = cast(StandardDomain, self.env.get_domain("std"))
93102
self._raw_format: bool = False
103+
self.make_id = make_id_lower if "force_refs_lower" in self.options else make_id
94104

95105
@property
96106
def parser(self) -> ArgumentParser:
@@ -150,7 +160,7 @@ def run(self) -> list[Node]:
150160
if not title_text.strip():
151161
home_section: Element = paragraph()
152162
else:
153-
home_section = section("", title("", Text(title_text)), ids=[make_id(title_text)], names=[title_text])
163+
home_section = section("", title("", Text(title_text)), ids=[self.make_id(title_text)], names=[title_text])
154164

155165
if "usage_first" in self.options:
156166
home_section += self._mk_usage(self.parser)
@@ -193,7 +203,7 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str) -> section:
193203

194204
title_text = self._build_opt_grp_title(group, prefix, sub_title_prefix, title_prefix)
195205
title_ref: str = f"{prefix}{' ' if prefix else ''}{group.title}"
196-
ref_id = make_id(title_ref)
206+
ref_id = self.make_id(title_ref)
197207
# the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
198208
header = title("", Text(title_text))
199209
group_section = section("", header, ids=[ref_id], names=[ref_id])
@@ -271,7 +281,7 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item:
271281
return point
272282

273283
def _mk_option_name(self, line: paragraph, prefix: str, opt: str) -> None:
274-
ref_id = make_id(f"{prefix}-{opt}")
284+
ref_id = self.make_id(f"{prefix}-{opt}")
275285
ref_title = f"{prefix} {opt}"
276286
ref = reference("", refid=ref_id, reftitle=ref_title)
277287
line.attributes["ids"].append(ref_id)
@@ -317,7 +327,7 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
317327
title_text += aliases_text
318328
title_ref += aliases_text
319329
title_text = title_text.strip()
320-
ref_id = make_id(title_ref)
330+
ref_id = self.make_id(title_ref)
321331
group_section = section("", title("", Text(title_text)), ids=[ref_id], names=[title_ref])
322332
self._register_ref(ref_id, title_ref, group_section)
323333

tests/test_logic.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import pytest
99

10+
from sphinx_argparse_cli._logic import make_id, make_id_lower
11+
1012
if TYPE_CHECKING:
1113
from io import StringIO
1214

@@ -281,6 +283,26 @@ def test_lower_upper_refs(build_outcome: str, warning: StringIO) -> None:
281283
assert not warning.getvalue()
282284

283285

286+
@pytest.mark.parametrize(
287+
("key", "mixed", "lower"),
288+
[
289+
("ProgramName", "ProgramName", "_program_name"),
290+
("ProgramName -A", "ProgramName--A", "_program_name--_a"),
291+
("ProgramName -a", "ProgramName--a", "_program_name--a"),
292+
],
293+
)
294+
def test_make_id(key: str, mixed: str, lower: str) -> None:
295+
assert make_id(key) == mixed
296+
assert make_id_lower(key) == lower
297+
298+
299+
@pytest.mark.sphinx(buildername="html", testroot="force-refs-lower")
300+
def test_ref_cases(build_outcome: str, warning: StringIO) -> None:
301+
assert '<a class="reference internal" href="#_prog--_b" title="Prog -B">' in build_outcome
302+
assert '<a class="reference internal" href="#_prog--b" title="Prog -b">' in build_outcome
303+
assert not warning.getvalue()
304+
305+
284306
@pytest.mark.sphinx(buildername="text", testroot="default-handling")
285307
def test_with_default(build_outcome: str) -> None:
286308
assert (

0 commit comments

Comments
 (0)