Skip to content

Commit 56d5cc1

Browse files
committed
Implement updated semantic markup draft and add option and return value linking.
1 parent 77ec886 commit 56d5cc1

File tree

8 files changed

+357
-36
lines changed

8 files changed

+357
-36
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ antsibull-changelog = ">= 0.10.0"
4040
asyncio-pool = "*"
4141
docutils = "*"
4242
importlib-metadata = {version = "*", python = "<3.8"}
43-
jinja2 = "*"
43+
jinja2 = ">= 3.0"
4444
# major/minor was introduced here
4545
packaging = ">= 20.0"
4646
perky = "*"

src/antsibull/jinja2/filters.py

Lines changed: 152 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
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 ..logging import log
18+
from ..semantic_helper import parse_option, parse_return_value, augment_plugin_name_type
1619

1720

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

46148
flog = mlog.fields(func='html_ify')
47149
flog.fields(text=text).debug('Enter')
48150
_counts = {}
49151

152+
plugin_fqcn, plugin_type = extract_plugin_data(context)
153+
50154
text = html_escape(text)
51155
text, _counts['italic'] = _ITALIC.subn(r"<em>\1</em>", text)
52156
text, _counts['bold'] = _BOLD.subn(r"<b>\1</b>", text)
@@ -59,11 +163,12 @@ def html_ify(text):
59163
text, _counts['link'] = _LINK.subn(r"<a href='\2'>\1</a>", text)
60164
text, _counts['const'] = _CONST.subn(
61165
r"<code class='docutils literal notranslate'>\1</code>", text)
62-
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(_option_name_html, text)
63-
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(
64-
r"<code class='ansible-value literal notranslate'>\1</code>", text)
65-
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(
66-
r"<code class='xref std std-envvar literal notranslate'>\1</code>", text)
166+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
167+
partial(_option_name_html, plugin_fqcn, plugin_type), text)
168+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_value_html, text)
169+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_env_var_html, text)
170+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
171+
partial(_return_value_html, plugin_fqcn, plugin_type), text)
67172
text, _counts['ruler'] = _RULER.subn(r"<hr/>", text)
68173

69174
text = text.strip()
@@ -134,35 +239,55 @@ def _rst_ify_const(m: 're.Match') -> str:
134239
return f"\\ :literal:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ "
135240

136241

137-
def _rst_ify_option_name(m):
138-
return f"\\ :ansopt:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ "
242+
def _rst_ify_option_name(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
243+
m: 're.Match') -> str:
244+
_check_plugin(plugin_fqcn, plugin_type, m)
245+
text = _unescape_sem_value(m.group(1))
246+
text = augment_plugin_name_type(text, plugin_fqcn, plugin_type)
247+
return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
248+
139249

250+
def _rst_ify_value(m: 're.Match') -> str:
251+
text = _unescape_sem_value(m.group(1))
252+
return f"\\ :ansval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
140253

141-
def _rst_ify_value(m):
142-
return f"\\ :ansval:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ "
143254

255+
def _rst_ify_return_value(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
256+
m: 're.Match') -> str:
257+
_check_plugin(plugin_fqcn, plugin_type, m)
258+
text = _unescape_sem_value(m.group(1))
259+
text = augment_plugin_name_type(text, plugin_fqcn, plugin_type)
260+
return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
144261

145-
def _rst_ify_envvar(m):
146-
return f"\\ :envvar:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ "
147262

263+
def _rst_ify_envvar(m: 're.Match') -> str:
264+
text = _unescape_sem_value(m.group(1))
265+
return f"\\ :envvar:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
148266

149-
def rst_ify(text):
267+
268+
@pass_context
269+
def rst_ify(context: Context, text: str) -> str:
150270
''' convert symbols like I(this is in italics) to valid restructured text '''
151271

152272
flog = mlog.fields(func='rst_ify')
153273
flog.fields(text=text).debug('Enter')
154274
_counts = {}
155275

276+
plugin_fqcn, plugin_type = extract_plugin_data(context)
277+
156278
text, _counts['italic'] = _ITALIC.subn(_rst_ify_italic, text)
157279
text, _counts['bold'] = _BOLD.subn(_rst_ify_bold, text)
158280
text, _counts['module'] = _MODULE.subn(_rst_ify_module, text)
159281
text, _counts['link'] = _LINK.subn(_rst_ify_link, text)
160282
text, _counts['url'] = _URL.subn(_rst_ify_url, text)
161283
text, _counts['ref'] = _REF.subn(_rst_ify_ref, text)
162284
text, _counts['const'] = _CONST.subn(_rst_ify_const, text)
163-
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(_rst_ify_option_name, text)
285+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
286+
partial(_rst_ify_option_name, plugin_fqcn, plugin_type), text)
164287
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_rst_ify_value, text)
165288
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_rst_ify_envvar, text)
289+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
290+
partial(_rst_ify_return_value, plugin_fqcn, plugin_type), text)
166291
text, _counts['ruler'] = _RULER.subn('\n\n.. raw:: html\n\n <hr>\n\n', text)
167292

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

src/antsibull/semantic_helper.py

Lines changed: 82 additions & 0 deletions
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}'

0 commit comments

Comments
 (0)