Skip to content

Commit 24e272c

Browse files
committed
Add semantic markup support.
1 parent 64a17fd commit 24e272c

File tree

11 files changed

+453
-6
lines changed

11 files changed

+453
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
major_changes:
2+
- Support new semantic markup in documentation (https://github.com/ansible-community/antsibull-docs/pull/4).

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ ansible-pygments = "*"
3434
antsibull-core = ">= 1.0.0, < 2.0.0"
3535
asyncio-pool = "*"
3636
docutils = "*"
37-
jinja2 = "*"
37+
jinja2 = ">= 3.0"
3838
rstcheck = ">= 3.0.0, < 7.0.0"
3939
sphinx = "*"
4040

src/antsibull_docs/jinja2/filters.py

+169-3
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
55
"""
66

77
import re
8+
from functools import partial
89
from html import escape as html_escape
910
from urllib.parse import quote
1011

1112
import typing as t
1213

13-
from jinja2.runtime import Undefined
14+
from jinja2.runtime import Context, Undefined
15+
from jinja2.utils import pass_context
1416

1517
from antsibull_core.logging import log
1618

19+
from ..semantic_helper import parse_option, parse_return_value, augment_plugin_name_type
20+
1721

1822
mlog = log.fields(mod=__name__)
1923

@@ -22,32 +26,152 @@
2226
_ITALIC = re.compile(r"\bI\(([^)]+)\)")
2327
_BOLD = re.compile(r"\bB\(([^)]+)\)")
2428
_MODULE = re.compile(r"\bM\(([^).]+)\.([^).]+)\.([^)]+)\)")
29+
_PLUGIN = re.compile(r"\bP\(([^).]+)\.([^).]+)\.([^)]+)#([a-z]+)\)")
2530
_URL = re.compile(r"\bU\(([^)]+)\)")
2631
_LINK = re.compile(r"\bL\(([^)]+), *([^)]+)\)")
2732
_REF = re.compile(r"\bR\(([^)]+), *([^)]+)\)")
2833
_CONST = re.compile(r"\bC\(([^)]+)\)")
34+
_SEM_PARAMETER_STRING = r"\(((?:[^\\)]+|\\.)+)\)"
35+
_SEM_OPTION_NAME = re.compile(r"\bO" + _SEM_PARAMETER_STRING)
36+
_SEM_OPTION_VALUE = re.compile(r"\bV" + _SEM_PARAMETER_STRING)
37+
_SEM_ENV_VARIABLE = re.compile(r"\bE" + _SEM_PARAMETER_STRING)
38+
_SEM_RET_VALUE = re.compile(r"\bRV" + _SEM_PARAMETER_STRING)
2939
_RULER = re.compile(r"\bHORIZONTALLINE\b")
40+
_UNESCAPE = re.compile(r"\\(.)")
3041

3142
_EMAIL_ADDRESS = re.compile(r"(?:<{mail}>|\({mail}\)|{mail})".format(mail=r"[\w.+-]+@[\w.-]+\.\w+"))
3243

3344

34-
def html_ify(text):
45+
def extract_plugin_data(context: Context) -> t.Tuple[t.Optional[str], t.Optional[str]]:
46+
plugin_fqcn = context.get('plugin_name')
47+
plugin_type = context.get('plugin_type')
48+
if plugin_fqcn is None or plugin_type is None:
49+
return None, None
50+
# if plugin_type == 'role':
51+
# entry_point = context.get('entry_point', 'main')
52+
# # FIXME: use entry_point
53+
return plugin_fqcn, plugin_type
54+
55+
56+
def _unescape_sem_value(text: str) -> str:
57+
return _UNESCAPE.sub(r'\1', text)
58+
59+
60+
def _check_plugin(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
61+
matcher: 're.Match') -> None:
62+
if plugin_fqcn is None or plugin_type is None:
63+
raise Exception(f'The markup {matcher.group(0)} cannot be used outside a plugin or role')
64+
65+
66+
def _create_error(text: str, error: str) -> str: # pylint:disable=unused-argument
67+
return '...' # FIXME
68+
69+
70+
def _option_name_html(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
71+
matcher: 're.Match') -> str:
72+
_check_plugin(plugin_fqcn, plugin_type, matcher)
73+
text = _unescape_sem_value(matcher.group(1))
74+
try:
75+
plugin_fqcn, plugin_type, option_link, option, value = parse_option(
76+
text, plugin_fqcn, plugin_type, require_plugin=False)
77+
except ValueError as exc:
78+
return _create_error(text, str(exc))
79+
if value is None:
80+
cls = 'ansible-option'
81+
text = f'{option}'
82+
strong_start = '<strong>'
83+
strong_end = '</strong>'
84+
else:
85+
cls = 'ansible-option-value'
86+
text = f'{option}={value}'
87+
strong_start = ''
88+
strong_end = ''
89+
if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2:
90+
# TODO: handle role arguments (entrypoint!)
91+
namespace, name, plugin = plugin_fqcn.split('.', 2)
92+
url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html'
93+
fragment = f'parameter-{quote(option_link.replace(".", "/"))}'
94+
link_start = (
95+
f'<a class="reference internal" href="{url}#{fragment}">'
96+
'<span class="std std-ref"><span class="pre">'
97+
)
98+
link_end = '</span></span></a>'
99+
else:
100+
link_start = ''
101+
link_end = ''
102+
return (
103+
f'<code class="{cls} literal notranslate">'
104+
f'{strong_start}{link_start}{text}{link_end}{strong_end}</code>'
105+
)
106+
107+
108+
def _return_value_html(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
109+
matcher: 're.Match') -> str:
110+
_check_plugin(plugin_fqcn, plugin_type, matcher)
111+
text = _unescape_sem_value(matcher.group(1))
112+
try:
113+
plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value(
114+
text, plugin_fqcn, plugin_type, require_plugin=False)
115+
except ValueError as exc:
116+
return _create_error(text, str(exc))
117+
cls = 'ansible-return-value'
118+
if value is None:
119+
text = f'{rv}'
120+
else:
121+
text = f'{rv}={value}'
122+
if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2:
123+
namespace, name, plugin = plugin_fqcn.split('.', 2)
124+
url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html'
125+
fragment = f'return-{quote(rv_link.replace(".", "/"))}'
126+
link_start = (
127+
f'<a class="reference internal" href="{url}#{fragment}">'
128+
'<span class="std std-ref"><span class="pre">'
129+
)
130+
link_end = '</span></span></a>'
131+
else:
132+
link_start = ''
133+
link_end = ''
134+
return f'<code class="{cls} literal notranslate">{link_start}{text}{link_end}</code>'
135+
136+
137+
def _value_html(matcher: 're.Match') -> str:
138+
text = _unescape_sem_value(matcher.group(1))
139+
return f'<code class="ansible-value literal notranslate">{text}</code>'
140+
141+
142+
def _env_var_html(matcher: 're.Match') -> str:
143+
text = _unescape_sem_value(matcher.group(1))
144+
return f'<code class="xref std std-envvar literal notranslate">{text}</code>'
145+
146+
147+
@pass_context
148+
def html_ify(context: Context, text: str) -> str:
35149
''' convert symbols like I(this is in italics) to valid HTML '''
36150

37151
flog = mlog.fields(func='html_ify')
38152
flog.fields(text=text).debug('Enter')
39153
_counts = {}
40154

155+
plugin_fqcn, plugin_type = extract_plugin_data(context)
156+
41157
text = html_escape(text)
42158
text, _counts['italic'] = _ITALIC.subn(r"<em>\1</em>", text)
43159
text, _counts['bold'] = _BOLD.subn(r"<b>\1</b>", text)
44160
text, _counts['module'] = _MODULE.subn(
45161
r"<a href='../../\1/\2/\3_module.html' class='module'>\1.\2.\3</a>", text)
162+
text, _counts['plugin'] = _PLUGIN.subn(
163+
r"<a href='../../\1/\2/\3_\4.html' class='module plugin-\4'>\1.\2.\3</span>", text)
46164
text, _counts['url'] = _URL.subn(r"<a href='\1'>\1</a>", text)
47165
text, _counts['ref'] = _REF.subn(r"<span class='module'>\1</span>", text)
48166
text, _counts['link'] = _LINK.subn(r"<a href='\2'>\1</a>", text)
49167
text, _counts['const'] = _CONST.subn(
50168
r"<code class='docutils literal notranslate'>\1</code>", text)
169+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
170+
partial(_option_name_html, plugin_fqcn, plugin_type), text)
171+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_value_html, text)
172+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_env_var_html, text)
173+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
174+
partial(_return_value_html, plugin_fqcn, plugin_type), text)
51175
text, _counts['ruler'] = _RULER.subn(r"<hr/>", text)
52176

53177
text = text.strip()
@@ -95,6 +219,12 @@ def _rst_ify_module(m: 're.Match') -> str:
95219
return f"\\ :ref:`{rst_escape(fqcn)} <ansible_collections.{fqcn}_module>`\\ "
96220

97221

222+
def _rst_ify_plugin(m: 're.Match') -> str:
223+
fqcn = f'{m.group(1)}.{m.group(2)}.{m.group(3)}'
224+
plugin_type = m.group(4)
225+
return f"\\ :ref:`{rst_escape(fqcn)} <ansible_collections.{fqcn}_{plugin_type}>`\\ "
226+
227+
98228
def _escape_url(url: str) -> str:
99229
# We include '<>[]{}' in safe to allow urls such as 'https://<HOST>:[PORT]/v{version}/' to
100230
# remain unmangled by percent encoding
@@ -118,20 +248,56 @@ def _rst_ify_const(m: 're.Match') -> str:
118248
return f"\\ :literal:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ "
119249

120250

121-
def rst_ify(text):
251+
def _rst_ify_option_name(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
252+
m: 're.Match') -> str:
253+
_check_plugin(plugin_fqcn, plugin_type, m)
254+
text = _unescape_sem_value(m.group(1))
255+
text = augment_plugin_name_type(text, plugin_fqcn, plugin_type)
256+
return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
257+
258+
259+
def _rst_ify_value(m: 're.Match') -> str:
260+
text = _unescape_sem_value(m.group(1))
261+
return f"\\ :ansval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
262+
263+
264+
def _rst_ify_return_value(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
265+
m: 're.Match') -> str:
266+
_check_plugin(plugin_fqcn, plugin_type, m)
267+
text = _unescape_sem_value(m.group(1))
268+
text = augment_plugin_name_type(text, plugin_fqcn, plugin_type)
269+
return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
270+
271+
272+
def _rst_ify_envvar(m: 're.Match') -> str:
273+
text = _unescape_sem_value(m.group(1))
274+
return f"\\ :envvar:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
275+
276+
277+
@pass_context
278+
def rst_ify(context: Context, text: str) -> str:
122279
''' convert symbols like I(this is in italics) to valid restructured text '''
123280

124281
flog = mlog.fields(func='rst_ify')
125282
flog.fields(text=text).debug('Enter')
126283
_counts = {}
127284

285+
plugin_fqcn, plugin_type = extract_plugin_data(context)
286+
128287
text, _counts['italic'] = _ITALIC.subn(_rst_ify_italic, text)
129288
text, _counts['bold'] = _BOLD.subn(_rst_ify_bold, text)
130289
text, _counts['module'] = _MODULE.subn(_rst_ify_module, text)
290+
text, _counts['plugin'] = _PLUGIN.subn(_rst_ify_plugin, text)
131291
text, _counts['link'] = _LINK.subn(_rst_ify_link, text)
132292
text, _counts['url'] = _URL.subn(_rst_ify_url, text)
133293
text, _counts['ref'] = _REF.subn(_rst_ify_ref, text)
134294
text, _counts['const'] = _CONST.subn(_rst_ify_const, text)
295+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
296+
partial(_rst_ify_option_name, plugin_fqcn, plugin_type), text)
297+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_rst_ify_value, text)
298+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_rst_ify_envvar, text)
299+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
300+
partial(_rst_ify_return_value, plugin_fqcn, plugin_type), text)
135301
text, _counts['ruler'] = _RULER.subn('\n\n.. raw:: html\n\n <hr>\n\n', text)
136302

137303
flog.fields(counts=_counts).info('Number of macros converted to rst equivalents')

src/antsibull_docs/lint_extra_docs.py

+11
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import re
1010
import typing as t
1111

12+
from docutils.parsers.rst import roles as docutils_roles
13+
14+
from sphinx_antsibull_ext import roles as antsibull_roles
15+
1216
from .extra_docs import (
1317
find_extra_docs,
1418
lint_required_conditions,
@@ -35,6 +39,12 @@ def lint_optional_conditions(content: str, path: str, collection_name: str
3539
return check_rst_content(content, filename=path)
3640

3741

42+
def _setup_antsibull_roles():
43+
'''Make sure that rstcheck knows about our roles.'''
44+
for name, role in antsibull_roles.ROLES.items():
45+
docutils_roles.register_local_role(name, role)
46+
47+
3848
def lint_collection_extra_docs_files(path_to_collection: str
3949
) -> t.List[t.Tuple[str, int, int, str]]:
4050
try:
@@ -46,6 +56,7 @@ def lint_collection_extra_docs_files(path_to_collection: str
4656
result = []
4757
all_labels = set()
4858
docs = find_extra_docs(path_to_collection)
59+
_setup_antsibull_roles()
4960
for doc in docs:
5061
try:
5162
# Load content

src/antsibull_docs/semantic_helper.py

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright: (c) 2021, Ansible Project
2+
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
3+
"""
4+
Helpers for parsing semantic markup.
5+
"""
6+
7+
import re
8+
9+
import typing as t
10+
11+
12+
_ARRAY_STUB_RE = re.compile(r'\[([^\]]*)\]')
13+
_FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([a-z]+):(.*)$')
14+
_IGNORE_MARKER = 'ignore:'
15+
16+
17+
def _remove_array_stubs(text: str) -> str:
18+
return _ARRAY_STUB_RE.sub('', text)
19+
20+
21+
def parse_option(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
22+
require_plugin=False) -> t.Tuple[str, str, str, str, t.Optional[str]]:
23+
"""
24+
Given the contents of O(...) / :ansopt:`...` with potential escaping removed,
25+
split it into plugin FQCN, plugin type, option link name, option name, and option value.
26+
"""
27+
value = None
28+
if '=' in text:
29+
text, value = text.split('=', 1)
30+
m = _FQCN_TYPE_PREFIX_RE.match(text)
31+
if m:
32+
plugin_fqcn = m.group(1)
33+
plugin_type = m.group(2)
34+
text = m.group(3)
35+
elif require_plugin:
36+
raise ValueError('Cannot extract plugin name and type')
37+
elif text.startswith(_IGNORE_MARKER):
38+
plugin_fqcn = ''
39+
plugin_type = ''
40+
text = text[len(_IGNORE_MARKER):]
41+
if ':' in text or '#' in text:
42+
raise ValueError(f'Invalid option name "{text}"')
43+
return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value
44+
45+
46+
def parse_return_value(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
47+
require_plugin=False) -> t.Tuple[str, str, str, str, t.Optional[str]]:
48+
"""
49+
Given the contents of RV(...) / :ansretval:`...` with potential escaping removed,
50+
split it into plugin FQCN, plugin type, option link name, option name, and option value.
51+
"""
52+
value = None
53+
if '=' in text:
54+
text, value = text.split('=', 1)
55+
m = _FQCN_TYPE_PREFIX_RE.match(text)
56+
if m:
57+
plugin_fqcn = m.group(1)
58+
plugin_type = m.group(2)
59+
text = m.group(3)
60+
elif require_plugin:
61+
raise ValueError('Cannot extract plugin name and type')
62+
elif text.startswith(_IGNORE_MARKER):
63+
plugin_fqcn = ''
64+
plugin_type = ''
65+
text = text[len(_IGNORE_MARKER):]
66+
if ':' in text or '#' in text:
67+
raise ValueError(f'Invalid return value name "{text}"')
68+
return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value
69+
70+
71+
def augment_plugin_name_type(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str]
72+
) -> str:
73+
"""
74+
Given the text contents of O(...) or RV(...) and a plugin's FQCN and type, insert
75+
the FQCN and type if they are not already present.
76+
"""
77+
value = None
78+
if '=' in text:
79+
text, value = text.split('=', 1)
80+
if ':' not in text and plugin_fqcn and plugin_type:
81+
text = f'{plugin_fqcn}#{plugin_type}:{text}'
82+
return text if value is None else f'{text}={value}'

src/sphinx_antsibull_ext/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414

1515
from .assets import setup_assets
16+
from .roles import setup_roles
1617

1718

1819
def setup(app):
@@ -24,6 +25,9 @@ def setup(app):
2425
# Add assets
2526
setup_assets(app)
2627

28+
# Add roles
29+
setup_roles(app)
30+
2731
return dict(
2832
parallel_read_safe=True,
2933
parallel_write_safe=True,

0 commit comments

Comments
 (0)