diff --git a/.flake8 b/.flake8 index 591be5b..553239f 100644 --- a/.flake8 +++ b/.flake8 @@ -6,7 +6,7 @@ [flake8] extend-ignore = E203 count = true -max-complexity = 10 +max-complexity = 11 # black's max-line-length is 89, but it doesn't touch long string literals. max-line-length = 100 statistics = true diff --git a/changelogs/fragments/72-extend-ansible-markup.yml b/changelogs/fragments/72-extend-ansible-markup.yml new file mode 100644 index 0000000..7dd3c63 --- /dev/null +++ b/changelogs/fragments/72-extend-ansible-markup.yml @@ -0,0 +1,3 @@ +major_changes: + - "Extend plugin reference ``P(...)`` to allow referencing a role's entrypoint (https://github.com/ansible-community/antsibull-docs-parser/pull/72)." + - "Extend environment variable ``E(...)`` to allow specifying a value (https://github.com/ansible-community/antsibull-docs-parser/pull/72)." diff --git a/src/antsibull_docs_parser/dom.py b/src/antsibull_docs_parser/dom.py index 139d7c7..84a7d28 100644 --- a/src/antsibull_docs_parser/dom.py +++ b/src/antsibull_docs_parser/dom.py @@ -66,6 +66,9 @@ class ModulePart(NamedTuple): class PluginPart(NamedTuple): plugin: PluginIdentifier + entrypoint: str | None = ( + None # can be present if plugin.type == "role"; default value for backwards compatibility + ) source: str | None = None type: t.Literal[PartType.PLUGIN] = PartType.PLUGIN @@ -114,6 +117,7 @@ class OptionValuePart(NamedTuple): class EnvVariablePart(NamedTuple): name: str + value: str | None = None # default value for backwards compatibility source: str | None = None type: t.Literal[PartType.ENV_VARIABLE] = PartType.ENV_VARIABLE diff --git a/src/antsibull_docs_parser/parser.py b/src/antsibull_docs_parser/parser.py index 65149a4..667533a 100644 --- a/src/antsibull_docs_parser/parser.py +++ b/src/antsibull_docs_parser/parser.py @@ -21,6 +21,7 @@ _ARRAY_STUB_RE = re.compile(r"\[([^\]]*)\]") _FQCN_TYPE_PREFIX_RE = re.compile(r"^([^.]+\.[^.]+\.[^#]+)#([^:]+):(.*)$") _FQCN = re.compile(r"^[A-Za-z0-9_]+\.[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$") +_ENTRYPOINT = re.compile(r"^[A-Za-z0-9_]+$") _PLUGIN_TYPE = re.compile(r"^[a-z_]+$") _WHITESPACE = re.compile(r"([\s]+)") _DANGEROUS_WS = re.compile(r"[\t\n\r]") @@ -33,6 +34,10 @@ def _is_fqcn(text: str) -> bool: return _FQCN.match(text) is not None +def _is_entrypoint(text: str) -> bool: + return _ENTRYPOINT.match(text) is not None + + def _is_plugin_type(text: str) -> bool: # We do not want to hard-code a list of valid plugin types that might be # inaccurate, so we simply check whether this is a valid kind of Python @@ -376,6 +381,8 @@ def _parse_option_like( if sep: entrypoint = part1 text = part2 + if not _is_entrypoint(entrypoint): + raise ValueError(f"Entrypoint {_repr(entrypoint)} is not valid") if entrypoint is None: raise ValueError("Role reference is missing entrypoint") if ":" in text or "#" in text: @@ -406,12 +413,23 @@ def parse( if "#" not in name: raise ValueError(f"Parameter {_repr(name)} is not of the form FQCN#type") fqcn, ptype = name.split("#", 1) + entrypoint = None + part1, sep, part2 = ptype.partition(":") + if sep: + ptype = part1 + entrypoint = part2 + if not _is_entrypoint(entrypoint): + raise ValueError(f"Entrypoint {_repr(entrypoint)} is not valid") if not _is_fqcn(fqcn): raise ValueError(f"Plugin name {_repr(fqcn)} is not a FQCN") if not _is_plugin_type(ptype): raise ValueError(f"Plugin type {_repr(ptype)} is not valid") + if entrypoint is not None and ptype != "role": + raise ValueError("Only role references can have entrypoints") return dom.PluginPart( - plugin=dom.PluginIdentifier(fqcn=fqcn, type=ptype), source=source + plugin=dom.PluginIdentifier(fqcn=fqcn, type=ptype), + entrypoint=entrypoint, + source=source, ) @@ -426,13 +444,17 @@ def parse( source: str | None, whitespace: Whitespace, ) -> dom.AnyPart: + name = _process_whitespace( + parameters[0], + whitespace=whitespace, + code_environment=True, + no_newlines=True, + ) + name, sep, value_ = name.partition("=") + value = value_ if sep else None return dom.EnvVariablePart( - name=_process_whitespace( - parameters[0], - whitespace=whitespace, - code_environment=True, - no_newlines=True, - ), + name=name, + value=value, source=source, ) diff --git a/tests/unit/test_dom.py b/tests/unit/test_dom.py index 51d964a..ac1f9ba 100644 --- a/tests/unit/test_dom.py +++ b/tests/unit/test_dom.py @@ -101,9 +101,11 @@ def process_return_value(self, part: dom.ReturnValuePart) -> None: ], [ dom.TextPart(text="foo "), - dom.EnvVariablePart(name="a),b"), + dom.EnvVariablePart(name="a),b", value=None), dom.TextPart(text=" "), - dom.PluginPart(plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="bam")), + dom.PluginPart( + plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="bam"), entrypoint=None + ), dom.TextPart(text=" baz "), dom.OptionValuePart(value=" b,na)\\m, "), dom.TextPart(text=" "), diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 35ce154..ef72093 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -192,16 +192,19 @@ def test__process_whitespace( ), # semantic markup: ( - "foo E(a\\),b) P(foo.bar.baz#bam) baz V( b\\,\\na\\)\\\\m\\, ) O(foo) ", + "foo E(a\\),b) E(foo=bar=baz) P(foo.bar.baz#bam) baz V( b\\,\\na\\)\\\\m\\, ) O(foo) ", Context(), {}, [ [ dom.TextPart(text="foo "), - dom.EnvVariablePart(name="a),b"), + dom.EnvVariablePart(name="a),b", value=None), + dom.TextPart(text=" "), + dom.EnvVariablePart(name="foo", value="bar=baz"), dom.TextPart(text=" "), dom.PluginPart( - plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="bam") + plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="bam"), + entrypoint=None, ), dom.TextPart(text=" baz "), dom.OptionValuePart(value=" b,na)\\m, "), @@ -214,16 +217,21 @@ def test__process_whitespace( ], ), ( - "foo E(a\\),b) P(foo.bar.baz#bam) baz V( b\\,\\na\\)\\\\m\\, ) O(foo) ", + "foo E(a\\),b) E(foo=bar=baz) P(foo.bar.baz#bam) baz V( b\\,\\na\\)\\\\m\\, ) O(foo) ", Context(), dict(add_source=True), [ [ dom.TextPart(text="foo ", source="foo "), - dom.EnvVariablePart(name="a),b", source="E(a\\),b)"), + dom.EnvVariablePart(name="a),b", value=None, source="E(a\\),b)"), + dom.TextPart(text=" ", source=" "), + dom.EnvVariablePart( + name="foo", value="bar=baz", source="E(foo=bar=baz)" + ), dom.TextPart(text=" ", source=" "), dom.PluginPart( plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="bam"), + entrypoint=None, source="P(foo.bar.baz#bam)", ), dom.TextPart(text=" baz ", source=" baz "), @@ -243,6 +251,26 @@ def test__process_whitespace( ], ], ), + ( + "P(foo.bar.baz#role) P(foo.bar.baz#role:entrypoint)", + Context(), + dict(add_source=True), + [ + [ + dom.PluginPart( + plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="role"), + entrypoint=None, + source="P(foo.bar.baz#role)", + ), + dom.TextPart(text=" ", source=" "), + dom.PluginPart( + plugin=dom.PluginIdentifier(fqcn="foo.bar.baz", type="role"), + entrypoint="entrypoint", + source="P(foo.bar.baz#role:entrypoint)", + ), + ], + ], + ), # semantic markup option name: ( "O(foo)", @@ -843,6 +871,18 @@ def test_parse( dict(errors="exception", helpful_errors=False), 'While parsing P() at index 1: Plugin type "b m" is not valid', ), + ( + "P(foo.bar.baz#module:e p)", + Context(), + dict(errors="exception", helpful_errors=False), + 'While parsing P() at index 1: Entrypoint "e p" is not valid', + ), + ( + "P(foo.bar.baz#module:entrypoint)", + Context(), + dict(errors="exception", helpful_errors=False), + "While parsing P() at index 1: Only role references can have entrypoints", + ), # bad option name/return value (throw error): ( "O(f o.b r.b z#bam:foobar)", @@ -868,6 +908,12 @@ def test_parse( dict(errors="exception", helpful_errors=False), "While parsing O() at index 1: Role reference is missing entrypoint", ), + ( + "O(foo.bar.baz#role:e p:bam)", + Context(), + dict(errors="exception", helpful_errors=False), + 'While parsing O() at index 1: Entrypoint "e p" is not valid', + ), ]