diff --git a/changelogs/fragments/219-collection-wildcards.yml b/changelogs/fragments/219-collection-wildcards.yml new file mode 100644 index 00000000..f8b3428e --- /dev/null +++ b/changelogs/fragments/219-collection-wildcards.yml @@ -0,0 +1,2 @@ +minor_changes: + - "Allow specifying wildcards for the collection names for the ``collections`` subcommand if ``--use-current`` is specified (https://github.com/ansible-community/antsibull-docs/pull/219)." diff --git a/src/antsibull_docs/cli/antsibull_docs.py b/src/antsibull_docs/cli/antsibull_docs.py index 3af12936..dc1c2834 100644 --- a/src/antsibull_docs/cli/antsibull_docs.py +++ b/src/antsibull_docs/cli/antsibull_docs.py @@ -35,7 +35,11 @@ import antsibull_docs # noqa: E402 from ..constants import DOCUMENTABLE_PLUGINS # noqa: E402 -from ..docs_parsing.fqcn import is_collection_name, is_fqcn # noqa: E402 +from ..docs_parsing.fqcn import ( # noqa: E402 + is_collection_name, + is_fqcn, + is_wildcard_collection_name, +) from ..schemas.app_context import DocsAppContext # noqa: E402 from .doc_commands import ( # noqa: E402 collection, @@ -154,6 +158,8 @@ def _normalize_collection_options(args: argparse.Namespace) -> None: for collection_name in args.collections: if not is_collection_name(collection_name): + if args.use_current and is_wildcard_collection_name(collection_name): + continue raise InvalidArgumentError( f"The collection, {collection_name}, is not a valid collection name." ) @@ -444,7 +450,9 @@ def parse_args(program_name: str, args: list[str]) -> argparse.Namespace: dest="collections", help="One or more collections to document. No paths or URLs are" " supported. Collections are assumed to exist on Galaxy, or be" - " installed locally when --use-current is used.", + " installed locally when --use-current is used. When --use-current" + " is used, the wildcard '*' can be used for the namespace, the" + " collection name, or both ('foo.*', '*.bar', '*.*').", ) # diff --git a/src/antsibull_docs/cli/doc_commands/_build.py b/src/antsibull_docs/cli/doc_commands/_build.py index b632906b..f6d282e8 100644 --- a/src/antsibull_docs/cli/doc_commands/_build.py +++ b/src/antsibull_docs/cli/doc_commands/_build.py @@ -164,7 +164,10 @@ def generate_docs_for_all_collections( # noqa: C901 _validate_options(collection_names, exclude_collection_names, use_html_blobs) - if collection_names is not None and "ansible.builtin" not in collection_names: + if collection_names is not None and all( + ab not in collection_names + for ab in ("ansible.builtin", "ansible.*", "*.builtin", "*.*") + ): exclude_collection_names = ["ansible.builtin"] app_ctx = app_context.app_ctx.get() diff --git a/src/antsibull_docs/docs_parsing/ansible_doc_core_213.py b/src/antsibull_docs/docs_parsing/ansible_doc_core_213.py index cafe7fe8..cfa12285 100644 --- a/src/antsibull_docs/docs_parsing/ansible_doc_core_213.py +++ b/src/antsibull_docs/docs_parsing/ansible_doc_core_213.py @@ -19,7 +19,7 @@ from ..constants import DOCUMENTABLE_PLUGINS from . import AnsibleCollectionMetadata, _get_environment from .ansible_doc import get_collection_metadata -from .fqcn import get_fqcn_parts +from .fqcn import get_fqcn_parts, is_collection_name, is_wildcard_collection_name if t.TYPE_CHECKING: from antsibull_core.venv import FakeVenvRunner, VenvRunner @@ -82,6 +82,57 @@ def _get_ansible_doc_filters( return [] +def _get_matcher(wildcard: str) -> t.Callable[[str], bool]: + namespace, collection = wildcard.split(".", 1) + + if namespace == "*": + if collection == "*": + return lambda collection_name: True + postfix = f".{collection}" + return lambda collection_name: collection_name.endswith(postfix) + + if collection == "*": + prefix = f"{namespace}." + return lambda collection_name: collection_name.startswith(prefix) + + return lambda collection_name: collection_name == wildcard + + +def _limit_by_wildcards( + collection_metadata: dict[str, AnsibleCollectionMetadata], + collection_names: list[str], +) -> tuple[dict[str, AnsibleCollectionMetadata], list[str]]: + wildcard_matchers: dict[str, t.Callable[[str], bool]] = {} + wildcard_counters: dict[str, int] = {} + for wildcard in collection_names: + wildcard_matchers[wildcard] = _get_matcher(wildcard) + wildcard_counters[wildcard] = 0 + + ret_collection_metadata = {} + ret_collection_names = [] + + for collection_name, collection_data in collection_metadata.items(): + found = False + for wildcard, matcher in wildcard_matchers.items(): + if matcher(collection_name): + wildcard_counters[wildcard] += 1 + found = True + if not found: + if collection_name == "ansible.builtin": + ret_collection_metadata[collection_name] = collection_data + continue + ret_collection_metadata[collection_name] = collection_data + ret_collection_names.append(collection_name) + + for wildcard, count in wildcard_counters.items(): + if count == 0: + raise RuntimeError( + f"{wildcard} does not match any of the collections" + f" in {', '.join(sorted(collection_metadata))}" + ) + return ret_collection_metadata, ret_collection_names + + async def get_ansible_plugin_info( venv: VenvRunner | FakeVenvRunner, ansible_core_version: PypiVer, @@ -121,14 +172,27 @@ async def get_ansible_plugin_info( collection_dir, keep_current_collections_path=fetch_all_installed, venv=venv ) + has_wildcards = collection_names is not None and any( + is_wildcard_collection_name(cn) and not is_collection_name(cn) + for cn in collection_names + ) + + flog.debug("Retrieving collection metadata") + collection_metadata = await get_collection_metadata( + venv, env, None if has_wildcards else collection_names + ) + + if has_wildcards: + flog.debug("Restricting collection list by wildcards") + collection_metadata, collection_names = _limit_by_wildcards( + collection_metadata, collection_names or [] + ) + flog.debug("Retrieving and loading plugin documentation") ansible_doc_output = await _call_ansible_doc( venv, env, *_get_ansible_doc_filters(ansible_core_version, collection_names) ) - flog.debug("Retrieving collection metadata") - collection_metadata = await get_collection_metadata(venv, env, collection_names) - flog.debug("Processing plugin documentation") plugin_map: MutableMapping[str, MutableMapping[str, t.Any]] = {} for plugin_type in DOCUMENTABLE_PLUGINS: diff --git a/src/antsibull_docs/docs_parsing/fqcn.py b/src/antsibull_docs/docs_parsing/fqcn.py index 8da0b971..6e7c5532 100644 --- a/src/antsibull_docs/docs_parsing/fqcn.py +++ b/src/antsibull_docs/docs_parsing/fqcn.py @@ -24,6 +24,9 @@ NAMESPACE_RE_STR = "[a-z0-9][a-z0-9_]+" #: Format of a FQCN COLLECTION_NAME_RE = re.compile(rf"^({NAMESPACE_RE_STR})\.({NAMESPACE_RE_STR})$") +COLLECTION_WILDCARD_RE = re.compile( + rf"^({NAMESPACE_RE_STR}|\*)\.({NAMESPACE_RE_STR}|\*)$" +) FQCN_RE = re.compile(rf"^({NAMESPACE_RE_STR})\.({NAMESPACE_RE_STR})\.(.*)$") FQCN_STRICT_RE = re.compile( rf"^({NAMESPACE_RE_STR})\.({NAMESPACE_RE_STR})\.({NAMESPACE_RE_STR}(?:\.{NAMESPACE_RE_STR})*)$" @@ -82,3 +85,15 @@ def is_collection_name(value: str) -> bool: :returns: ``True`` if the value is a collection name, ``False`` if it is not. """ return bool(COLLECTION_NAME_RE.match(value)) + + +def is_wildcard_collection_name(value: str) -> bool: + """ + Return whether ``value`` is a collection name, where the namespace or the name + can be a wildcard. + + :arg value: The value to test. + :returns: ``True`` if the value is a collection name or wildcard, ``False`` + if it is not. + """ + return bool(COLLECTION_WILDCARD_RE.match(value))