Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
68 changes: 57 additions & 11 deletions sphinxarg/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import importlib
import operator
import os
import re
import sys
from argparse import ArgumentParser
from typing import TYPE_CHECKING, cast
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -87,6 +88,47 @@ def map_nested_definitions(nested_content):
return definitions


_OPTION_TOKEN = re.compile(r'((?<!\w)--[a-zA-Z0-9][\w-]*)')


def _protect_option_dashes(children: list[Node]) -> 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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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(),
)
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions test/roots/test-default-html/smartquotes.rst
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions test/sample-directive-smartquotes.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions test/test_default_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading