From 5b6de3f41a50a7e4bd8e57ad96cb6debe5f86a91 Mon Sep 17 00:00:00 2001 From: Michael Mulqueen Date: Mon, 6 Jul 2026 13:23:37 +0100 Subject: [PATCH] Prevent smartquotes from rewriting option dashes. --- .ruff.toml | 10 +-- sphinxarg/ext.py | 68 ++++++++++++++++---- test/roots/test-default-html/smartquotes.rst | 9 +++ test/sample-directive-smartquotes.py | 19 ++++++ test/test_default_html.py | 22 +++++++ 5 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 test/roots/test-default-html/smartquotes.rst create mode 100644 test/sample-directive-smartquotes.py diff --git a/.ruff.toml b/.ruff.toml index cdbd5d6..d540b99 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -30,14 +30,8 @@ ignore = [ ] [lint.per-file-ignores] -"test/sample-default-supressed.py" = [ - "N999", # invalid module name -] -"test/sample-directive-opts.py" = [ - "N999", # invalid module name -] -"test/sample-directive-special.py" = [ - "N999", # invalid module name +"test/sample-*.py" = [ + "N999", # invalid module name (dashes in filename) ] [format] diff --git a/sphinxarg/ext.py b/sphinxarg/ext.py index f796b17..85f286d 100644 --- a/sphinxarg/ext.py +++ b/sphinxarg/ext.py @@ -3,6 +3,7 @@ import importlib import operator import os +import re import sys from argparse import ArgumentParser from typing import TYPE_CHECKING, cast @@ -33,7 +34,7 @@ from collections.abc import Iterable, Sequence from pathlib import Path - from docutils.nodes import Element + from docutils.nodes import Element, Node from sphinx.addnodes import pending_xref from sphinx.application import Sphinx from sphinx.builders import Builder @@ -87,6 +88,47 @@ def map_nested_definitions(nested_content): return definitions +_OPTION_TOKEN = re.compile(r'((? None: + """Shield ``--option`` tokens from smart quotes rewriting ``--`` as an en dash. + + Mutates the tree under ``children`` in place. Each token is wrapped in an + unstyled inline exempted via ``support_smartquotes``. + """ + for child in children: + if not isinstance(child, nodes.Element): + continue + for text in list(child.findall(nodes.Text)): + parent = text.parent + if isinstance(parent, nodes.literal | nodes.FixedTextElement): + continue + # Capturing group => split() yields the option tokens at odd + # indices, interleaved with the surrounding text. + parts = _OPTION_TOKEN.split(text) + if len(parts) == 1: + continue + replacement: list[Node] = [] + for i, part in enumerate(parts): + if not part: + continue + if i % 2: + inline = nodes.inline('', part) + inline['support_smartquotes'] = False + replacement.append(inline) + else: + replacement.append(nodes.Text(part)) + parent.replace(text, replacement) + + +def _option_safe_paragraph(text: str) -> nodes.paragraph: + """Return a paragraph of parser-supplied text with option dashes protected.""" + paragraph = nodes.paragraph(text=text) + _protect_option_dashes([paragraph]) + return paragraph + + def render_list(l, markdown_help, settings=None): """ Given a list of reStructuredText or MarkDown sections, return a docutils node list @@ -96,7 +138,9 @@ def render_list(l, markdown_help, settings=None): if markdown_help: from sphinxarg.markdown import parse_markdown_block - return parse_markdown_block('\n\n'.join(l) + '\n') + blocks = parse_markdown_block('\n\n'.join(l) + '\n') + _protect_option_dashes(blocks) + return blocks else: if settings is None: settings = get_default_settings(Parser) @@ -105,6 +149,7 @@ def render_list(l, markdown_help, settings=None): if isinstance(element, str): document = new_document('', settings) Parser().parse(element + '\n', document) + _protect_option_dashes(document.children) all_children += document.children elif isinstance(element, nodes.definition): all_children += element @@ -355,8 +400,8 @@ def _construct_manpage_specific_structure(self, parser_info): description_section = nodes.section( '', nodes.title(text='Description'), - nodes.paragraph( - text=parser_info.get( + _option_safe_paragraph( + parser_info.get( 'description', parser_info.get('help', 'undocumented').capitalize(), ) @@ -371,9 +416,9 @@ def _construct_manpage_specific_structure(self, parser_info): # parse method invoked above seem to be able to do this but # I haven't found a way to do it for arbitrary text if description_section: - description_section += nodes.paragraph(text=parser_info['epilog']) + description_section += _option_safe_paragraph(parser_info['epilog']) else: - description_section = nodes.paragraph(text=parser_info['epilog']) + description_section = _option_safe_paragraph(parser_info['epilog']) items.append(description_section) # OPTIONS section options_section = nodes.section( @@ -425,12 +470,12 @@ def _format_positional_arguments(self, parser_info): for arg in parser_info['args']: arg_items = [] if arg['help']: - arg_items.append(nodes.paragraph(text=arg['help'])) + arg_items.append(_option_safe_paragraph(arg['help'])) elif 'choices' not in arg: arg_items.append(nodes.paragraph(text='Undocumented')) if 'choices' in arg: arg_items.append( - nodes.paragraph(text='Possible choices: ' + ', '.join(arg['choices'])) + _option_safe_paragraph('Possible choices: ' + ', '.join(arg['choices'])) ) items.append( nodes.option_list_item( @@ -457,12 +502,12 @@ def _format_optional_arguments(self, parser_info): ) names.append(nodes.option('', *option_declaration)) if opt['help']: - opt_items.append(nodes.paragraph(text=opt['help'])) + opt_items.append(_option_safe_paragraph(opt['help'])) elif 'choices' not in opt: opt_items.append(nodes.paragraph(text='Undocumented')) if 'choices' in opt: opt_items.append( - nodes.paragraph(text='Possible choices: ' + ', '.join(opt['choices'])) + _option_safe_paragraph('Possible choices: ' + ', '.join(opt['choices'])) ) items.append( nodes.option_list_item( @@ -479,7 +524,7 @@ def _format_subcommands(self, parser_info): for subcmd in parser_info['children']: subcmd_items = [] if subcmd['help']: - subcmd_items.append(nodes.paragraph(text=subcmd['help'])) + subcmd_items.append(_option_safe_paragraph(subcmd['help'])) else: subcmd_items.append(nodes.paragraph(text='Undocumented')) items.append( @@ -494,6 +539,7 @@ def _format_subcommands(self, parser_info): def _nested_parse_paragraph(self, text): content = nodes.paragraph() self.state.nested_parse(StringList(text.split('\n')), 0, content) + _protect_option_dashes([content]) return content @property diff --git a/test/roots/test-default-html/smartquotes.rst b/test/roots/test-default-html/smartquotes.rst new file mode 100644 index 0000000..db5d4e9 --- /dev/null +++ b/test/roots/test-default-html/smartquotes.rst @@ -0,0 +1,9 @@ +Smart quotes +============ + +.. argparse:: + :filename: sample-directive-smartquotes.py + :prog: sample-directive-smartquotes + :func: get_parser + + Directive body prose keeps typography -- an en dash. diff --git a/test/sample-directive-smartquotes.py b/test/sample-directive-smartquotes.py new file mode 100644 index 0000000..0a3aca0 --- /dev/null +++ b/test/sample-directive-smartquotes.py @@ -0,0 +1,19 @@ +import argparse + + +def get_parser(): + parser = argparse.ArgumentParser( + prog='sample-directive-smartquotes', + description='Pass input via --text or stdin.', + epilog='Read the --text docs; see also --2fa and --dry_run.', + ) + parser.add_argument( + '--text', + help='text to encode; combine with --output for files', + ) + parser.add_argument( + '--typography', + help='typography still applies to \'single\' and "double" quotes, ' + 'the east--west mid-word dash, and ranges like 10--20', + ) + return parser diff --git a/test/test_default_html.py b/test/test_default_html.py index 27dc351..2913891 100644 --- a/test/test_default_html.py +++ b/test/test_default_html.py @@ -71,6 +71,28 @@ ('.//section/dl/dd/p', 'Default', False), ], ), + ( + 'smartquotes.html', + [ + ('.//h1', 'Smart quotes'), + # option names in parser-supplied text keep their double + # hyphen: smart quotes must not turn them into an en dash + ('.//p/span', '--text'), + ('.//p/span', '--2fa'), + ('.//p/span', '--dry_run'), + ('.//section/dl/dd/p/span', '--output'), + ('.//p', '–text', False), + ('.//p', '–2fa', False), + ('.//p', '–dry_run', False), + ('.//section/dl/dd/p', '–output', False), + # the surrounding text keeps normal typography + ('.//section/dl/dd/p', 'to ‘single’ and “double” quotes'), + ('.//section/dl/dd/p', 'the east–west mid-word dash'), + ('.//section/dl/dd/p', 'ranges like 10–20'), + # as does prose from the directive body + ('.//p', 'keeps typography – an en dash'), + ], + ), ], ) @pytest.mark.sphinx('html', testroot='default-html')