Skip to content

Commit 5b56c48

Browse files
authored
🐛 fix(smartquotes): prevent --option names from being rewritten to en dashes (#322)
Option names mentioned in parser-supplied text - descriptions, epilogs, and help strings - had their leading `--` rewritten to an en dash in the rendered HTML, so a flag documented as `--text` displayed as `–text`. Readers copying the option out of the docs got something the command line no longer accepts. Each `--option` token in parser-supplied text is now wrapped in an inline node that opts out via support_smartquotes, so smart quotes skips it while the surrounding prose keeps normal typography. Fixes #321
1 parent dc34c4d commit 5b56c48

6 files changed

Lines changed: 83 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file.
77
- Allow to add content to directive.
88
- Fix Sphinx warnings about parallel reads.
99
- Add `force_args_lower` to enable `:ref:` links with mixed-case program names and arguments.
10+
- `--option` names mentioned in descriptions, epilogs, and help text keep their double hyphen instead of being rewritten
11+
to an en dash by Sphinx's smart quotes transform; the surrounding text keeps normal typography.
1012

1113
## 1.13.1
1214

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

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

roots/test-smartquotes/parser.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(
8+
prog="smartquotes",
9+
description="Pass input via --text or stdin.",
10+
epilog="Read the --text docs; see also --2fa and --dry_run.",
11+
)
12+
parser.add_argument("--text", help="text to encode; combine with --output for files")
13+
parser.add_argument(
14+
"--typography", help="typography still applies to non-options like these north--south and 10--20"
15+
)
16+
sub = parser.add_subparsers()
17+
sub.add_parser("build", help="build things with --flair")
18+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222

2323
from docutils.nodes import (
2424
Element,
25+
FixedTextElement,
2526
Node,
2627
Text,
2728
bullet_list,
2829
container,
2930
fully_normalize_name,
31+
inline,
3032
list_item,
3133
literal,
3234
literal_block,
@@ -228,7 +230,9 @@ def _pre_format(self, block: None | str) -> None | paragraph | literal_block:
228230
lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"])
229231
lit["language"] = "none"
230232
return lit
231-
return paragraph("", Text(block))
233+
para = paragraph("", Text(block))
234+
_protect_option_dashes(para)
235+
return para
232236

233237
def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> section:
234238
sub_title_prefix: str = self.options["group_sub_title_prefix"]
@@ -302,6 +306,7 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item:
302306
line += Text(" (default: ")
303307
line += literal(text=str(action.default).replace(str(Path.cwd()), "{cwd}"))
304308
line += Text(")")
309+
_protect_option_dashes(line)
305310
return point
306311

307312
def _mk_option_name(self, line: paragraph, prefix: str, opt: str) -> None:
@@ -365,6 +370,7 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
365370
command_desc = (parser.description or help_msg or "").strip()
366371
if command_desc:
367372
desc_paragraph = paragraph("", Text(command_desc))
373+
_protect_option_dashes(desc_paragraph)
368374
group_section += desc_paragraph
369375

370376
if "usage_first" not in self.options:
@@ -444,6 +450,38 @@ def load_help_text(help_text: str) -> str:
444450
return CURLY_BRACES.sub("``{\\1}``", double_quote)
445451

446452

453+
_OPTION_TOKEN = re.compile(r"((?<!\w)--[a-zA-Z0-9][\w-]*)")
454+
455+
456+
def _protect_option_dashes(node: Element) -> None:
457+
"""
458+
Wrap ``--option`` tokens so smart quotes can't rewrite their ``--`` to an en dash.
459+
460+
Each token becomes an inline exempted via ``support_smartquotes``; surrounding text is
461+
left untouched. The node is modified in place.
462+
"""
463+
for text in list(node.findall(Text)):
464+
parent = text.parent
465+
if isinstance(parent, (literal, FixedTextElement)):
466+
continue
467+
# Capturing group => split() yields the option tokens at odd indices,
468+
# interleaved with the surrounding text.
469+
parts = _OPTION_TOKEN.split(text)
470+
if len(parts) == 1:
471+
continue
472+
replacement: list[Node] = []
473+
for i, part in enumerate(parts):
474+
if not part:
475+
continue
476+
if i % 2:
477+
inline_node = inline("", part)
478+
inline_node["support_smartquotes"] = False
479+
replacement.append(inline_node)
480+
else:
481+
replacement.append(Text(part))
482+
parent.replace(text, replacement)
483+
484+
447485
class HookError(Exception):
448486
def __init__(self, parser: ArgumentParser) -> None:
449487
self.parser = parser

tests/test_logic.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ def test_multiline_epilog_subclass_formatter_as_html(build_outcome: str) -> None
142142
assert ref in build_outcome
143143

144144

145+
@pytest.mark.sphinx(buildername="html", testroot="smartquotes")
146+
def test_option_dashes_survive_smartquotes(build_outcome: str) -> None:
147+
# option names mentioned in parser-supplied text keep their double hyphen
148+
assert "Pass input via <span>--text</span> or stdin." in build_outcome
149+
assert "see also <span>--2fa</span> and <span>--dry_run</span>." in build_outcome
150+
assert "combine with <span>--output</span> for files" in build_outcome
151+
assert "build things with <span>--flair</span>" in build_outcome
152+
for mangled in ("\u2013text", "\u2013output", "\u20132fa", "\u2013dry_run", "\u2013flair"):
153+
assert mangled not in build_outcome
154+
# the surrounding text keeps normal typography
155+
assert "north\u2013south and 10\u201320" in build_outcome
156+
157+
145158
@pytest.mark.sphinx(buildername="text", testroot="complex")
146159
@pytest.mark.prepare(directive_args=[":usage_width: 100"])
147160
def test_usage_width_default(build_outcome: str) -> None:

0 commit comments

Comments
 (0)