diff --git a/changelogs/fragments/4-semantic-markup.yml b/changelogs/fragments/4-semantic-markup.yml new file mode 100644 index 00000000..dd5f1e4e --- /dev/null +++ b/changelogs/fragments/4-semantic-markup.yml @@ -0,0 +1,2 @@ +major_changes: + - Support new semantic markup in documentation (https://github.com/ansible-community/antsibull-docs/pull/4). diff --git a/pyproject.toml b/pyproject.toml index a17f7c19..22e22fa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ ansible-pygments = "*" antsibull-core = ">= 1.2.0, < 2.0.0" asyncio-pool = "*" docutils = "*" -jinja2 = "*" +jinja2 = ">= 3.0" packaging = "*" rstcheck = ">= 3.0.0, < 7.0.0" sphinx = "*" diff --git a/src/antsibull_docs/jinja2/filters.py b/src/antsibull_docs/jinja2/filters.py index dcd78c11..d20347a3 100644 --- a/src/antsibull_docs/jinja2/filters.py +++ b/src/antsibull_docs/jinja2/filters.py @@ -13,7 +13,7 @@ from collections.abc import Mapping, Sequence -from jinja2.runtime import Undefined +from jinja2.runtime import Context, Undefined from antsibull_core.logging import log @@ -23,6 +23,17 @@ _EMAIL_ADDRESS = re.compile(r"(?:<{mail}>|\({mail}\)|{mail})".format(mail=r"[\w.+-]+@[\w.-]+\.\w+")) +def extract_plugin_data(context: Context) -> t.Tuple[t.Optional[str], t.Optional[str]]: + plugin_fqcn = context.get('plugin_name') + plugin_type = context.get('plugin_type') + if plugin_fqcn is None or plugin_type is None: + return None, None + # if plugin_type == 'role': + # entry_point = context.get('entry_point', 'main') + # # FIXME: use entry_point + return plugin_fqcn, plugin_type + + def documented_type(text) -> str: ''' Convert any python type to a type for documentation ''' diff --git a/src/antsibull_docs/jinja2/htmlify.py b/src/antsibull_docs/jinja2/htmlify.py index 657c6350..de534a73 100644 --- a/src/antsibull_docs/jinja2/htmlify.py +++ b/src/antsibull_docs/jinja2/htmlify.py @@ -12,8 +12,14 @@ import typing as t +from jinja2.runtime import Context +from jinja2.utils import pass_context + from antsibull_core.logging import log +from ..semantic_helper import parse_option, parse_return_value + +from .filters import extract_plugin_data from .parser import Command, CommandSet, convert_text @@ -35,9 +41,13 @@ def _create_error(text: str, error: str) -> str: class _Context: + j2_context: Context counts: t.Dict[str, int] + plugin_fqcn: t.Optional[str] + plugin_type: t.Optional[str] - def __init__(self): + def __init__(self, j2_context: Context): + self.j2_context = j2_context self.counts = { 'italic': 0, 'bold': 0, @@ -53,6 +63,7 @@ def __init__(self): 'return-value': 0, 'ruler': 0, } + self.plugin_fqcn, self.plugin_type = extract_plugin_data(j2_context) # In the following, we make heavy use of escaped whitespace ("\ ") being removed from the output. @@ -159,6 +170,107 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str: return f"{html_escape(parameters[0])}" +class _OptionName(Command): + command = 'O' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['option-name'] += 1 + if context.plugin_fqcn is None or context.plugin_type is None: + raise Exception('The markup O(...) cannot be used outside a plugin or role') + text = parameters[0] + try: + plugin_fqcn, plugin_type, option_link, option, value = parse_option( + text, context.plugin_fqcn, context.plugin_type, require_plugin=False) + except ValueError as exc: + return _create_error(f'O({text})', str(exc)) + if value is None: + cls = 'ansible-option' + text = f'{option}' + strong_start = '' + strong_end = '' + else: + cls = 'ansible-option-value' + text = f'{option}={value}' + strong_start = '' + strong_end = '' + if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2: + # TODO: handle role arguments (entrypoint!) + namespace, name, plugin = plugin_fqcn.split('.', 2) + url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html' + fragment = f'parameter-{quote(option_link.replace(".", "/"))}' + link_start = ( + f'' + '' + ) + link_end = '' + else: + link_start = '' + link_end = '' + return ( + f'' + f'{strong_start}{link_start}{text}{link_end}{strong_end}' + ) + + +class _OptionValue(Command): + command = 'V' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['option-value'] += 1 + text = parameters[0] + return f'{html_escape(text)}' + + +class _EnvVariable(Command): + command = 'E' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['environment-var'] += 1 + text = parameters[0] + return f'{html_escape(text)}' + + +class _RetValue(Command): + command = 'RV' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['return-value'] += 1 + if context.plugin_fqcn is None or context.plugin_type is None: + raise Exception('The markup RV(...) cannot be used outside a plugin or role') + text = parameters[0] + try: + plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value( + text, context.plugin_fqcn, context.plugin_type, require_plugin=False) + except ValueError as exc: + return _create_error(f'RV({text})', str(exc)) + cls = 'ansible-return-value' + if value is None: + text = f'{rv}' + else: + text = f'{rv}={value}' + if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2: + namespace, name, plugin = plugin_fqcn.split('.', 2) + url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html' + fragment = f'return-{quote(rv_link.replace(".", "/"))}' + link_start = ( + f'' + '' + ) + link_end = '' + else: + link_start = '' + link_end = '' + return f'{link_start}{text}{link_end}' + + class _HorizontalLine(Command): command = 'HORIZONTALLINE' parameter_count = 0 @@ -178,16 +290,21 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str: _Link(), _Ref(), _Const(), + _OptionName(), + _OptionValue(), + _EnvVariable(), + _RetValue(), _HorizontalLine(), ]) -def html_ify(text: str) -> str: +@pass_context +def html_ify(context: Context, text: str) -> str: ''' convert symbols like I(this is in italics) to valid HTML ''' flog = mlog.fields(func='html_ify') flog.fields(text=text).debug('Enter') - our_context = _Context() + our_context = _Context(context) try: text = convert_text(text, _COMMAND_SET, html_escape, our_context) diff --git a/src/antsibull_docs/jinja2/rstify.py b/src/antsibull_docs/jinja2/rstify.py index 6aa72a6f..203f4f37 100644 --- a/src/antsibull_docs/jinja2/rstify.py +++ b/src/antsibull_docs/jinja2/rstify.py @@ -11,8 +11,14 @@ import typing as t +from jinja2.runtime import Context +from jinja2.utils import pass_context + from antsibull_core.logging import log +from ..semantic_helper import augment_plugin_name_type + +from .filters import extract_plugin_data from .parser import Command, CommandSet, convert_text @@ -63,9 +69,13 @@ def _create_error(text: str, error: str) -> str: class _Context: + j2_context: Context counts: t.Dict[str, int] + plugin_fqcn: t.Optional[str] + plugin_type: t.Optional[str] - def __init__(self): + def __init__(self, j2_context: Context): + self.j2_context = j2_context self.counts = { 'italic': 0, 'bold': 0, @@ -81,6 +91,7 @@ def __init__(self): 'return-value': 0, 'ruler': 0, } + self.plugin_fqcn, self.plugin_type = extract_plugin_data(j2_context) # In the following, we make heavy use of escaped whitespace ("\ ") being removed from the output. @@ -182,6 +193,52 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str: return f"\\ :literal:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " +class _OptionName(Command): + command = 'O' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['option-name'] += 1 + if context.plugin_fqcn is None or context.plugin_type is None: + raise Exception('The markup O(...) cannot be used outside a plugin or role') + text = augment_plugin_name_type(parameters[0], context.plugin_fqcn, context.plugin_type) + return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " + + +class _OptionValue(Command): + command = 'V' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['option-value'] += 1 + return f"\\ :ansval:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " + + +class _EnvVariable(Command): + command = 'E' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['environment-var'] += 1 + return f"\\ :envvar:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " + + +class _RetValue(Command): + command = 'RV' + parameter_count = 1 + escaped_content = True + + def handle(self, parameters: t.List[str], context: t.Any) -> str: + context.counts['return-value'] += 1 + if context.plugin_fqcn is None or context.plugin_type is None: + raise Exception('The markup RV(...) cannot be used outside a plugin or role') + text = augment_plugin_name_type(parameters[0], context.plugin_fqcn, context.plugin_type) + return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " + + class _HorizontalLine(Command): command = 'HORIZONTALLINE' parameter_count = 0 @@ -201,16 +258,21 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str: _Link(), _Ref(), _Const(), + _OptionName(), + _OptionValue(), + _EnvVariable(), + _RetValue(), _HorizontalLine(), ]) -def rst_ify(text: str) -> str: +@pass_context +def rst_ify(context: Context, text: str) -> str: ''' convert symbols like I(this is in italics) to valid restructured text ''' flog = mlog.fields(func='rst_ify') flog.fields(text=text).debug('Enter') - our_context = _Context() + our_context = _Context(context) try: text = convert_text(text, _COMMAND_SET, rst_escape, our_context) diff --git a/src/antsibull_docs/semantic_helper.py b/src/antsibull_docs/semantic_helper.py new file mode 100644 index 00000000..e1dbe399 --- /dev/null +++ b/src/antsibull_docs/semantic_helper.py @@ -0,0 +1,87 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2021, Ansible Project +""" +Helpers for parsing semantic markup. +""" + +import re + +import typing as t + + +_ARRAY_STUB_RE = re.compile(r'\[([^\]]*)\]') +_FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([a-z]+):(.*)$') +_IGNORE_MARKER = 'ignore:' + + +def _remove_array_stubs(text: str) -> str: + return _ARRAY_STUB_RE.sub('', text) + + +def parse_option(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + require_plugin=False + ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: + """ + Given the contents of O(...) / :ansopt:`...` with potential escaping removed, + split it into plugin FQCN, plugin type, option link name, option name, and option value. + """ + value = None + if '=' in text: + text, value = text.split('=', 1) + m = _FQCN_TYPE_PREFIX_RE.match(text) + if m: + plugin_fqcn = m.group(1) + plugin_type = m.group(2) + text = m.group(3) + elif require_plugin: + raise ValueError('Cannot extract plugin name and type') + elif text.startswith(_IGNORE_MARKER): + plugin_fqcn = '' + plugin_type = '' + text = text[len(_IGNORE_MARKER):] + if ':' in text or '#' in text: + raise ValueError(f'Invalid option name "{text}"') + return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value + + +def parse_return_value(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + require_plugin=False + ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: + """ + Given the contents of RV(...) / :ansretval:`...` with potential escaping removed, + split it into plugin FQCN, plugin type, option link name, option name, and option value. + """ + value = None + if '=' in text: + text, value = text.split('=', 1) + m = _FQCN_TYPE_PREFIX_RE.match(text) + if m: + plugin_fqcn = m.group(1) + plugin_type = m.group(2) + text = m.group(3) + elif require_plugin: + raise ValueError('Cannot extract plugin name and type') + elif text.startswith(_IGNORE_MARKER): + plugin_fqcn = '' + plugin_type = '' + text = text[len(_IGNORE_MARKER):] + if ':' in text or '#' in text: + raise ValueError(f'Invalid return value name "{text}"') + return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value + + +def augment_plugin_name_type(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str] + ) -> str: + """ + Given the text contents of O(...) or RV(...) and a plugin's FQCN and type, insert + the FQCN and type if they are not already present. + """ + value = None + if '=' in text: + text, value = text.split('=', 1) + if ':' not in text and plugin_fqcn and plugin_type: + text = f'{plugin_fqcn}#{plugin_type}:{text}' + return text if value is None else f'{text}={value}' diff --git a/src/sphinx_antsibull_ext/antsibull-minimal.css b/src/sphinx_antsibull_ext/antsibull-minimal.css index 85c1ce33..8407569c 100644 --- a/src/sphinx_antsibull_ext/antsibull-minimal.css +++ b/src/sphinx_antsibull_ext/antsibull-minimal.css @@ -1,3 +1,3 @@ @charset "UTF-8"; /* Copyright (c) Ansible and contributors */ -/* GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt) */.ansible-links{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:start;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.ansible-links>a{padding:4px 12px;margin:2px 4px;cursor:pointer;border-radius:3px;background-color:#5bbdbf;color:#fff}.ansible-links>a:active,.ansible-links>a:focus,.ansible-links>a:hover{background-color:#91d9db}.ansible-links>a:focus{outline:3px solid #204748}table.documentation-table{border-bottom:1px solid #000;border-right:1px solid #000}table.documentation-table th{background-color:#6ab0de}table.documentation-table td,table.documentation-table th{padding:4px;border-left:1px solid #000;border-top:1px solid #000}table.documentation-table td.elbow-placeholder{border-top:0;width:30px;min-width:30px}table.documentation-table td{vertical-align:top}table.documentation-table td:first-child{white-space:nowrap}table.documentation-table tr .ansibleOptionLink{display:inline-block;visibility:hidden}table.documentation-table tr .ansibleOptionLink:after{content:"🔗"}table.documentation-table tr:hover .ansibleOptionLink:after{visibility:visible}table.documentation-table tr:nth-child(odd){background-color:#fff}table.documentation-table tr:nth-child(2n){background-color:#e7f2fa}table.ansible-option-table{display:table;border-color:#000!important;height:1px}table.ansible-option-table tr{height:100%}table.ansible-option-table td,table.ansible-option-table th{border-color:#000!important;border-bottom:none!important;vertical-align:top!important}table.ansible-option-table th>p{font-size:medium!important}table.ansible-option-table thead tr{background-color:#6ab0de}table.ansible-option-table tbody .row-odd td{background-color:#fff!important}table.ansible-option-table tbody .row-even td{background-color:#e7f2fa!important}table.ansible-option-table ul>li>p{margin:0!important}table.ansible-option-table ul>li>div[class^=highlight]{margin-bottom:4px!important}table.ansible-option-table p.ansible-option-title{display:inline}table.ansible-option-table .ansible-option-type-line{font-size:small;margin-bottom:0}table.ansible-option-table .ansible-option-elements,table.ansible-option-table .ansible-option-type{color:purple}table.ansible-option-table .ansible-option-required{color:red}table.ansible-option-table .ansible-option-versionadded{font-style:italic;font-size:small;color:#006400}table.ansible-option-table .ansible-option-aliases{color:#006400;white-space:normal}table.ansible-option-table .ansible-option-line{margin-top:8px}table.ansible-option-table .ansible-option-choices{font-weight:700}table.ansible-option-table .ansible-option-choices-default-mark,table.ansible-option-table .ansible-option-default{color:#00f}table.ansible-option-table .ansible-option-default-bold{color:#00f;font-weight:700}table.ansible-option-table .ansible-option-returned-bold{font-weight:700}table.ansible-option-table .ansible-option-sample{color:#00f;word-wrap:break-word;word-break:break-all}table.ansible-option-table .ansible-option-sample-bold{color:#000;font-weight:700}table.ansible-option-table .ansible-option-configuration{font-weight:700}table.ansible-option-table .ansibleOptionLink{display:inline-block;visibility:hidden}table.ansible-option-table .ansibleOptionLink:after{content:"🔗"}table.ansible-option-table p{margin:0 0 8px}table.ansible-option-table tr:hover .ansibleOptionLink:after{visibility:visible}table.ansible-option-table td{padding:0!important;white-space:normal}table.ansible-option-table td>div.ansible-option-cell{padding:8px 16px;border-top:1px solid #000}table.ansible-option-table td:first-child{height:inherit;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:first-child>div.ansible-option-cell{height:inherit;-webkit-box-flex:1;-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;white-space:nowrap;max-width:100%}table.ansible-option-table .ansible-option-indent{margin-left:2em;border-right:1px solid #000}table.ansible-option-table .ansible-attribute-support-label{display:none}table.ansible-option-table .ansible-attribute-support-label,table.ansible-option-table .ansible-attribute-support-property{font-weight:700}table.ansible-option-table .ansible-attribute-support-none{font-weight:700;color:red}table.ansible-option-table .ansible-attribute-support-partial{font-weight:700;color:#a5a500}table.ansible-option-table .ansible-attribute-support-full{font-weight:700;color:green}table.ansible-option-table .ansible-attribute-details{font-style:italic}@media (max-width:1200px){table.ansible-option-table{display:block;height:unset;border:none!important}table.ansible-option-table thead{display:none}table.ansible-option-table tbody,table.ansible-option-table td,table.ansible-option-table tr{display:block;border:none!important}table.ansible-option-table tbody .row-even td,table.ansible-option-table tbody .row-odd td{background-color:unset!important}table.ansible-option-table td>div.ansible-option-cell{border-top:none}table.ansible-option-table td:first-child>div.ansible-option-cell{background-color:#e7f2fa!important}table.ansible-option-table td:not(:first-child){display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:not(:first-child)>div.ansible-option-cell{margin-left:1em}table.ansible-option-table .ansible-option-indent,table.ansible-option-table .ansible-option-indent-desc{margin-left:1em;border:none;border-right:3px solid #e7f2fa}table.ansible-option-table .ansible-attribute-support-label{display:unset}}.ansible-version-added{font-style:italic} \ No newline at end of file +/* GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt) */.ansible-links{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:start;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.ansible-links>a{padding:4px 12px;margin:2px 4px;cursor:pointer;border-radius:3px;background-color:#5bbdbf;color:#fff}.ansible-links>a:active,.ansible-links>a:focus,.ansible-links>a:hover{background-color:#91d9db}.ansible-links>a:focus{outline:3px solid #204748}table.documentation-table{border-bottom:1px solid #000;border-right:1px solid #000}table.documentation-table th{background-color:#6ab0de}table.documentation-table td,table.documentation-table th{padding:4px;border-left:1px solid #000;border-top:1px solid #000}table.documentation-table td.elbow-placeholder{border-top:0;width:30px;min-width:30px}table.documentation-table td{vertical-align:top}table.documentation-table td:first-child{white-space:nowrap}table.documentation-table tr .ansibleOptionLink{display:inline-block;visibility:hidden}table.documentation-table tr .ansibleOptionLink:after{content:"🔗"}table.documentation-table tr:hover .ansibleOptionLink:after{visibility:visible}table.documentation-table tr:nth-child(odd){background-color:#fff}table.documentation-table tr:nth-child(2n){background-color:#e7f2fa}table.ansible-option-table{display:table;border-color:#000!important;height:1px}table.ansible-option-table tr{height:100%}table.ansible-option-table td,table.ansible-option-table th{border-color:#000!important;border-bottom:none!important;vertical-align:top!important}table.ansible-option-table th>p{font-size:medium!important}table.ansible-option-table thead tr{background-color:#6ab0de}table.ansible-option-table tbody .row-odd td{background-color:#fff!important}table.ansible-option-table tbody .row-even td{background-color:#e7f2fa!important}table.ansible-option-table ul>li>p{margin:0!important}table.ansible-option-table ul>li>div[class^=highlight]{margin-bottom:4px!important}table.ansible-option-table p.ansible-option-title{display:inline}table.ansible-option-table .ansible-option-type-line{font-size:small;margin-bottom:0}table.ansible-option-table .ansible-option-elements,table.ansible-option-table .ansible-option-type{color:purple}table.ansible-option-table .ansible-option-required{color:red}table.ansible-option-table .ansible-option-versionadded{font-style:italic;font-size:small;color:#006400}table.ansible-option-table .ansible-option-aliases{color:#006400;white-space:normal}table.ansible-option-table .ansible-option-line{margin-top:8px}table.ansible-option-table .ansible-option-choices{font-weight:700}table.ansible-option-table .ansible-option-choices-default-mark,table.ansible-option-table .ansible-option-default{color:#00f}table.ansible-option-table .ansible-option-default-bold{color:#00f;font-weight:700}table.ansible-option-table .ansible-option-returned-bold{font-weight:700}table.ansible-option-table .ansible-option-sample{color:#00f;word-wrap:break-word;word-break:break-all}table.ansible-option-table .ansible-option-sample-bold{color:#000;font-weight:700}table.ansible-option-table .ansible-option-configuration{font-weight:700}table.ansible-option-table .ansibleOptionLink{display:inline-block;visibility:hidden}table.ansible-option-table .ansibleOptionLink:after{content:"🔗"}table.ansible-option-table p{margin:0 0 8px}table.ansible-option-table tr:hover .ansibleOptionLink:after{visibility:visible}table.ansible-option-table td{padding:0!important;white-space:normal}table.ansible-option-table td>div.ansible-option-cell{padding:8px 16px;border-top:1px solid #000}table.ansible-option-table td:first-child{height:inherit;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:first-child>div.ansible-option-cell{height:inherit;-webkit-box-flex:1;-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;white-space:nowrap;max-width:100%}table.ansible-option-table .ansible-option-indent{margin-left:2em;border-right:1px solid #000}table.ansible-option-table .ansible-attribute-support-label{display:none}table.ansible-option-table .ansible-attribute-support-label,table.ansible-option-table .ansible-attribute-support-property{font-weight:700}table.ansible-option-table .ansible-attribute-support-none{font-weight:700;color:red}table.ansible-option-table .ansible-attribute-support-partial{font-weight:700;color:#a5a500}table.ansible-option-table .ansible-attribute-support-full{font-weight:700;color:green}table.ansible-option-table .ansible-attribute-details{font-style:italic}@media (max-width:1200px){table.ansible-option-table{display:block;height:unset;border:none!important}table.ansible-option-table thead{display:none}table.ansible-option-table tbody,table.ansible-option-table td,table.ansible-option-table tr{display:block;border:none!important}table.ansible-option-table tbody .row-even td,table.ansible-option-table tbody .row-odd td{background-color:unset!important}table.ansible-option-table td>div.ansible-option-cell{border-top:none}table.ansible-option-table td:first-child>div.ansible-option-cell{background-color:#e7f2fa!important}table.ansible-option-table td:not(:first-child){display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:not(:first-child)>div.ansible-option-cell{margin-left:1em}table.ansible-option-table .ansible-option-indent,table.ansible-option-table .ansible-option-indent-desc{margin-left:1em;border:none;border-right:3px solid #e7f2fa}table.ansible-option-table .ansible-attribute-support-label{display:unset}}.ansible-version-added{font-style:italic}.ansible-option-value a.reference.internal,.ansible-option-value a.reference.internal:hover,.ansible-option a.reference.internal,.ansible-option a.reference.internal:hover,.ansible-return-value a.reference.internal,.ansible-return-value a.reference.internal:hover{color:unset} \ No newline at end of file diff --git a/src/sphinx_antsibull_ext/css/antsibull-minimal.scss b/src/sphinx_antsibull_ext/css/antsibull-minimal.scss index 59895c67..528b43d5 100644 --- a/src/sphinx_antsibull_ext/css/antsibull-minimal.scss +++ b/src/sphinx_antsibull_ext/css/antsibull-minimal.scss @@ -339,3 +339,13 @@ table.ansible-option-table { .ansible-version-added { font-style: italic; } + +.ansible-option, .ansible-option-value, .ansible-return-value { + a.reference.internal { + color: unset; + + &:hover { + color: unset; + } + } +} diff --git a/src/sphinx_antsibull_ext/roles.py b/src/sphinx_antsibull_ext/roles.py index c438c1e4..49c5b53b 100644 --- a/src/sphinx_antsibull_ext/roles.py +++ b/src/sphinx_antsibull_ext/roles.py @@ -2,12 +2,17 @@ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or # https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2022, Ansible Project +# SPDX-FileCopyrightText: 2021, Ansible Project ''' Add roles for semantic markup. ''' +import typing as t + from docutils import nodes +from sphinx import addnodes + +from antsibull_docs.semantic_helper import parse_option, parse_return_value # pylint:disable-next=unused-argument,dangerous-default-value @@ -86,11 +91,149 @@ def return_value_sample(name, rawtext, text, lineno, inliner, options={}, conten return [nodes.literal(rawtext, text, classes=['ansible-option-sample'])], [] +def _create_option_reference(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + option: str) -> t.Optional[str]: + if not plugin_fqcn or not plugin_type: + return None + # TODO: handle role arguments (entrypoint!) + ref = option.replace(".", "/") + return f'ansible_collections.{plugin_fqcn}_{plugin_type}__parameter-{ref}' + + +def _create_return_value_reference(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + return_value: str) -> t.Optional[str]: + if not plugin_fqcn or not plugin_type: + return None + ref = return_value.replace(".", "/") + return f'ansible_collections.{plugin_fqcn}_{plugin_type}__return-{ref}' + + +def _create_ref_or_not(create_ref: t.Callable[[t.Optional[str], t.Optional[str], str], + t.Optional[str]], + plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + ref_parameter: str, text: str + ) -> t.Tuple[str, t.List[t.Any]]: + ref = create_ref(plugin_fqcn, plugin_type, ref_parameter) + if ref is None: + return text, [] + + # The content node will be replaced by Sphinx anyway, so it doesn't matter what kind + # of node we are using... + content = nodes.literal(text, text) + + options = { + 'reftype': 'ref', + 'refdomain': 'std', + 'refexplicit': True, + 'refwarn': True, + } + refnode = addnodes.pending_xref(text, content, **options) + refnode['reftarget'] = ref + return '', [refnode] + + +# pylint:disable-next=unused-argument +def _create_error(rawtext: str, text: str, error: str) -> t.Tuple[t.List[t.Any], t.List[str]]: + node = ... # FIXME + return [node], [] + + +# pylint:disable-next=unused-argument,dangerous-default-value +def option_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Format Ansible option key, or option key-value. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + classes = [] + try: + plugin_fqcn, plugin_type, option_link, option, value = parse_option( + text.replace('\x00', ''), '', '', require_plugin=False) + except ValueError as exc: + return _create_error(rawtext, text, str(exc)) + if value is None: + text = f'{option}' + classes.append('ansible-option') + else: + text = f'{option}={value}' + classes.append('ansible-option-value') + text, subnodes = _create_ref_or_not( + _create_option_reference, plugin_fqcn, plugin_type, option_link, text) + if value is None: + content = nodes.strong(rawtext, text, *subnodes) + content = nodes.literal(rawtext, '', content, classes=classes) + else: + content = nodes.literal(rawtext, text, *subnodes, classes=classes) + return [content], [] + + +# pylint:disable-next=unused-argument,dangerous-default-value +def value_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Format Ansible option value. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + return [nodes.literal(rawtext, text, classes=['ansible-value'])], [] + + +# pylint:disable-next=unused-argument,dangerous-default-value +def return_value_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Format Ansible option value. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + classes = ['ansible-return-value'] + try: + plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value( + text.replace('\x00', ''), '', '', require_plugin=False) + except ValueError as exc: + return _create_error(rawtext, text, str(exc)) + if value is None: + text = f'{rv}' + else: + text = f'{rv}={value}' + text, subnodes = _create_ref_or_not( + _create_return_value_reference, plugin_fqcn, plugin_type, rv_link, text) + return [nodes.literal(rawtext, text, *subnodes, classes=classes)], [] + + ROLES = { 'ansible-option-choices-entry': option_choice, 'ansible-option-choices-entry-default': option_choice_default, 'ansible-option-default': option_default, 'ansible-rv-sample-value': return_value_sample, + 'ansopt': option_role, + 'ansval': value_role, + 'ansretval': return_value_role, } diff --git a/stubs/jinja2/utils.pyi b/stubs/jinja2/utils.pyi new file mode 100644 index 00000000..bd4c6700 --- /dev/null +++ b/stubs/jinja2/utils.pyi @@ -0,0 +1,11 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +import typing as t + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +def pass_context(f: F) -> F: ... +def pass_eval_context(f: F) -> F: ... +def pass_environment(f: F) -> F: ... diff --git a/tests/functional/baseline-default/collections/ns2/col/foo_become.rst b/tests/functional/baseline-default/collections/ns2/col/foo_become.rst index a3a6f56e..ebdf31f6 100644 --- a/tests/functional/baseline-default/collections/ns2/col/foo_become.rst +++ b/tests/functional/baseline-default/collections/ns2/col/foo_become.rst @@ -136,9 +136,9 @@ Parameters Bar. \ :strong:`BAR!`\ - Totally unrelated to \ :emphasis:`become\_user`\ . Even with \ :emphasis:`become\_user=foo`\ . + Totally unrelated to \ :ansopt:`ns2.col.foo#become:become\_user`\ . Even with \ :ansopt:`ns2.col.foo#become:become\_user=foo`\ . - Might not be compatible when \ :emphasis:`become\_user`\ is \ :literal:`bar`\ , though. + Might not be compatible when \ :ansopt:`ns2.col.foo#become:become\_user`\ is \ :ansval:`bar`\ , though. .. raw:: html diff --git a/tests/functional/baseline-default/collections/ns2/col/foo_module.rst b/tests/functional/baseline-default/collections/ns2/col/foo_module.rst index 93b5563b..92bc8c40 100644 --- a/tests/functional/baseline-default/collections/ns2/col/foo_module.rst +++ b/tests/functional/baseline-default/collections/ns2/col/foo_module.rst @@ -142,7 +142,9 @@ Parameters A bar. - Independent from \ :emphasis:`foo`\ . + Independent from \ :ansopt:`ns2.col.foo#module:foo`\ . + + Do not confuse with \ :ansretval:`ns2.col.foo#module:bar`\ . .. raw:: html @@ -251,7 +253,7 @@ Parameters Whatever. - Also required when \ :emphasis:`subfoo`\ is specified when \ :emphasis:`foo=bar`\ or \ :literal:`baz`\ . + Also required when \ :ansopt:`ns2.col.foo#module:subfoo`\ is specified when \ :ansopt:`ns2.col.foo#module:foo=bar`\ or \ :ansval:`baz`\ . .. raw:: html @@ -521,6 +523,10 @@ Common return values are documented :ref:`here `, the foll Some bar. + Referencing myself as \ :ansretval:`ns2.col.foo#module:bar`\ . + + Do not confuse with \ :ansopt:`ns2.col.foo#module:bar`\ . + .. rst-class:: ansible-option-line diff --git a/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_become.rst b/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_become.rst index a3a6f56e..ebdf31f6 100644 --- a/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_become.rst +++ b/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_become.rst @@ -136,9 +136,9 @@ Parameters Bar. \ :strong:`BAR!`\ - Totally unrelated to \ :emphasis:`become\_user`\ . Even with \ :emphasis:`become\_user=foo`\ . + Totally unrelated to \ :ansopt:`ns2.col.foo#become:become\_user`\ . Even with \ :ansopt:`ns2.col.foo#become:become\_user=foo`\ . - Might not be compatible when \ :emphasis:`become\_user`\ is \ :literal:`bar`\ , though. + Might not be compatible when \ :ansopt:`ns2.col.foo#become:become\_user`\ is \ :ansval:`bar`\ , though. .. raw:: html diff --git a/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_module.rst b/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_module.rst index 93b5563b..92bc8c40 100644 --- a/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_module.rst +++ b/tests/functional/baseline-no-breadcrumbs/collections/ns2/col/foo_module.rst @@ -142,7 +142,9 @@ Parameters A bar. - Independent from \ :emphasis:`foo`\ . + Independent from \ :ansopt:`ns2.col.foo#module:foo`\ . + + Do not confuse with \ :ansretval:`ns2.col.foo#module:bar`\ . .. raw:: html @@ -251,7 +253,7 @@ Parameters Whatever. - Also required when \ :emphasis:`subfoo`\ is specified when \ :emphasis:`foo=bar`\ or \ :literal:`baz`\ . + Also required when \ :ansopt:`ns2.col.foo#module:subfoo`\ is specified when \ :ansopt:`ns2.col.foo#module:foo=bar`\ or \ :ansval:`baz`\ . .. raw:: html @@ -521,6 +523,10 @@ Common return values are documented :ref:`here `, the foll Some bar. + Referencing myself as \ :ansretval:`ns2.col.foo#module:bar`\ . + + Do not confuse with \ :ansopt:`ns2.col.foo#module:bar`\ . + .. rst-class:: ansible-option-line diff --git a/tests/functional/baseline-no-indexes/collections/ns2/col/foo_become.rst b/tests/functional/baseline-no-indexes/collections/ns2/col/foo_become.rst index a3a6f56e..ebdf31f6 100644 --- a/tests/functional/baseline-no-indexes/collections/ns2/col/foo_become.rst +++ b/tests/functional/baseline-no-indexes/collections/ns2/col/foo_become.rst @@ -136,9 +136,9 @@ Parameters Bar. \ :strong:`BAR!`\ - Totally unrelated to \ :emphasis:`become\_user`\ . Even with \ :emphasis:`become\_user=foo`\ . + Totally unrelated to \ :ansopt:`ns2.col.foo#become:become\_user`\ . Even with \ :ansopt:`ns2.col.foo#become:become\_user=foo`\ . - Might not be compatible when \ :emphasis:`become\_user`\ is \ :literal:`bar`\ , though. + Might not be compatible when \ :ansopt:`ns2.col.foo#become:become\_user`\ is \ :ansval:`bar`\ , though. .. raw:: html diff --git a/tests/functional/baseline-no-indexes/collections/ns2/col/foo_module.rst b/tests/functional/baseline-no-indexes/collections/ns2/col/foo_module.rst index 93b5563b..92bc8c40 100644 --- a/tests/functional/baseline-no-indexes/collections/ns2/col/foo_module.rst +++ b/tests/functional/baseline-no-indexes/collections/ns2/col/foo_module.rst @@ -142,7 +142,9 @@ Parameters A bar. - Independent from \ :emphasis:`foo`\ . + Independent from \ :ansopt:`ns2.col.foo#module:foo`\ . + + Do not confuse with \ :ansretval:`ns2.col.foo#module:bar`\ . .. raw:: html @@ -251,7 +253,7 @@ Parameters Whatever. - Also required when \ :emphasis:`subfoo`\ is specified when \ :emphasis:`foo=bar`\ or \ :literal:`baz`\ . + Also required when \ :ansopt:`ns2.col.foo#module:subfoo`\ is specified when \ :ansopt:`ns2.col.foo#module:foo=bar`\ or \ :ansval:`baz`\ . .. raw:: html @@ -521,6 +523,10 @@ Common return values are documented :ref:`here `, the foll Some bar. + Referencing myself as \ :ansretval:`ns2.col.foo#module:bar`\ . + + Do not confuse with \ :ansopt:`ns2.col.foo#module:bar`\ . + .. rst-class:: ansible-option-line diff --git a/tests/functional/baseline-squash-hierarchy/foo_become.rst b/tests/functional/baseline-squash-hierarchy/foo_become.rst index a3a6f56e..ebdf31f6 100644 --- a/tests/functional/baseline-squash-hierarchy/foo_become.rst +++ b/tests/functional/baseline-squash-hierarchy/foo_become.rst @@ -136,9 +136,9 @@ Parameters Bar. \ :strong:`BAR!`\ - Totally unrelated to \ :emphasis:`become\_user`\ . Even with \ :emphasis:`become\_user=foo`\ . + Totally unrelated to \ :ansopt:`ns2.col.foo#become:become\_user`\ . Even with \ :ansopt:`ns2.col.foo#become:become\_user=foo`\ . - Might not be compatible when \ :emphasis:`become\_user`\ is \ :literal:`bar`\ , though. + Might not be compatible when \ :ansopt:`ns2.col.foo#become:become\_user`\ is \ :ansval:`bar`\ , though. .. raw:: html diff --git a/tests/functional/baseline-squash-hierarchy/foo_module.rst b/tests/functional/baseline-squash-hierarchy/foo_module.rst index 93b5563b..92bc8c40 100644 --- a/tests/functional/baseline-squash-hierarchy/foo_module.rst +++ b/tests/functional/baseline-squash-hierarchy/foo_module.rst @@ -142,7 +142,9 @@ Parameters A bar. - Independent from \ :emphasis:`foo`\ . + Independent from \ :ansopt:`ns2.col.foo#module:foo`\ . + + Do not confuse with \ :ansretval:`ns2.col.foo#module:bar`\ . .. raw:: html @@ -251,7 +253,7 @@ Parameters Whatever. - Also required when \ :emphasis:`subfoo`\ is specified when \ :emphasis:`foo=bar`\ or \ :literal:`baz`\ . + Also required when \ :ansopt:`ns2.col.foo#module:subfoo`\ is specified when \ :ansopt:`ns2.col.foo#module:foo=bar`\ or \ :ansval:`baz`\ . .. raw:: html @@ -521,6 +523,10 @@ Common return values are documented :ref:`here `, the foll Some bar. + Referencing myself as \ :ansretval:`ns2.col.foo#module:bar`\ . + + Do not confuse with \ :ansopt:`ns2.col.foo#module:bar`\ . + .. rst-class:: ansible-option-line diff --git a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst index 9c9150e7..9661af9f 100644 --- a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst +++ b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst @@ -110,8 +110,8 @@ Parameters

Bar. BAR!

-

Totally unrelated to become_user. Even with become_user=foo.

-

Might not be compatible when become_user is bar, though.

+

Totally unrelated to become_user. Even with become_user=foo.

+

Might not be compatible when become_user is bar, though.

diff --git a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst index 469c9e8c..447cf79d 100644 --- a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst +++ b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst @@ -121,7 +121,8 @@ Parameters

A bar.

-

Independent from foo.

+

Independent from foo.

+

Do not confuse with bar.

@@ -165,7 +166,7 @@ Parameters

A sub foo.

Whatever.

-

Also required when subfoo is specified when foo=bar or baz.

+

Also required when subfoo is specified when foo=bar or baz.

@@ -417,6 +418,8 @@ Common return values are documented :ref:`here `, the foll

Some bar.

+

Referencing myself as bar.

+

Do not confuse with bar.

Returned: success

Sample: "baz"

diff --git a/tests/functional/collections/ansible_collections/ns2/col/plugins/become/foo.py b/tests/functional/collections/ansible_collections/ns2/col/plugins/become/foo.py index b281a0fc..b233c2bc 100644 --- a/tests/functional/collections/ansible_collections/ns2/col/plugins/become/foo.py +++ b/tests/functional/collections/ansible_collections/ns2/col/plugins/become/foo.py @@ -66,8 +66,8 @@ bar: description: - Bar. B(BAR!) - - Totally unrelated to I(become_user). Even with I(become_user=foo). - - Might not be compatible when I(become_user) is C(bar), though. + - Totally unrelated to O(become_user). Even with O(become_user=foo). + - Might not be compatible when O(become_user) is V(bar), though. type: str version_added: 1.2.0 deprecated: diff --git a/tests/functional/collections/ansible_collections/ns2/col/plugins/modules/foo.py b/tests/functional/collections/ansible_collections/ns2/col/plugins/modules/foo.py index 45cc6c0a..3fce47f7 100644 --- a/tests/functional/collections/ansible_collections/ns2/col/plugins/modules/foo.py +++ b/tests/functional/collections/ansible_collections/ns2/col/plugins/modules/foo.py @@ -26,7 +26,8 @@ bar: description: - A bar. - - Independent from I(foo). + - Independent from O(foo). + - Do not confuse with RV(bar). type: list elements: int aliases: @@ -40,7 +41,7 @@ description: - A sub foo. - Whatever. - - Also required when I(subfoo) is specified when I(foo=bar) or C(baz). + - Also required when O(subfoo) is specified when O(foo=bar) or V(baz). type: str required: true @@ -79,7 +80,10 @@ RETURN = ''' bar: - description: Some bar. + description: + - Some bar. + - Referencing myself as RV(bar). + - Do not confuse with O(bar). returned: success type: str sample: baz diff --git a/tests/units/test_jinja2.py b/tests/units/test_jinja2.py index f50a9a4f..810a413e 100644 --- a/tests/units/test_jinja2.py +++ b/tests/units/test_jinja2.py @@ -35,7 +35,11 @@ @pytest.mark.parametrize('text, expected', RST_IFY_DATA.items()) def test_rst_ify(text, expected): - assert rst_ify(text) == expected + context = { + 'plugin_name': 'foo.bar.baz', + 'plugin_type': 'module', + } + assert rst_ify(context, text) == expected RST_ESCAPE_DATA = {