diff options
Diffstat (limited to 'packaging/cli-doc/build.py')
-rwxr-xr-x | packaging/cli-doc/build.py | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/packaging/cli-doc/build.py b/packaging/cli-doc/build.py new file mode 100755 index 00000000..878ba8ea --- /dev/null +++ b/packaging/cli-doc/build.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +# PYTHON_ARGCOMPLETE_OK +"""Build documentation for ansible-core CLI programs.""" + +from __future__ import annotations + +import argparse +import dataclasses +import importlib +import inspect +import io +import itertools +import json +import pathlib +import sys +import typing as t +import warnings + +import jinja2 + +if t.TYPE_CHECKING: + from ansible.cli import CLI # pragma: nocover + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +SOURCE_DIR = SCRIPT_DIR.parent.parent + + +def main() -> None: + """Main program entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(required=True, metavar='command') + + man_parser = subparsers.add_parser('man', description=build_man.__doc__, help=build_man.__doc__) + man_parser.add_argument('--output-dir', required=True, type=pathlib.Path, metavar='DIR', help='output directory') + man_parser.add_argument('--template-file', default=SCRIPT_DIR / 'man.j2', type=pathlib.Path, metavar='FILE', help='template file') + man_parser.set_defaults(func=build_man) + + rst_parser = subparsers.add_parser('rst', description=build_rst.__doc__, help=build_rst.__doc__) + rst_parser.add_argument('--output-dir', required=True, type=pathlib.Path, metavar='DIR', help='output directory') + rst_parser.add_argument('--template-file', default=SCRIPT_DIR / 'rst.j2', type=pathlib.Path, metavar='FILE', help='template file') + rst_parser.set_defaults(func=build_rst) + + json_parser = subparsers.add_parser('json', description=build_json.__doc__, help=build_json.__doc__) + json_parser.add_argument('--output-file', required=True, type=pathlib.Path, metavar='FILE', help='output file') + json_parser.set_defaults(func=build_json) + + try: + # noinspection PyUnresolvedReferences + import argcomplete + except ImportError: + pass + else: + argcomplete.autocomplete(parser) + + args = parser.parse_args() + kwargs = {name: getattr(args, name) for name in inspect.signature(args.func).parameters} + + sys.path.insert(0, str(SOURCE_DIR / 'lib')) + + args.func(**kwargs) + + +def build_man(output_dir: pathlib.Path, template_file: pathlib.Path) -> None: + """Build man pages for ansible-core CLI programs.""" + if not template_file.resolve().is_relative_to(SCRIPT_DIR): + warnings.warn("Custom templates are intended for debugging purposes only. The data model may change in future releases without notice.") + + import docutils.core + import docutils.writers.manpage + + output_dir.mkdir(exist_ok=True, parents=True) + + for cli_name, source in generate_rst(template_file).items(): + with io.StringIO(source) as source_file: + docutils.core.publish_file( + source=source_file, + destination_path=output_dir / f'{cli_name}.1', + writer=docutils.writers.manpage.Writer(), + ) + + +def build_rst(output_dir: pathlib.Path, template_file: pathlib.Path) -> None: + """Build RST documentation for ansible-core CLI programs.""" + if not template_file.resolve().is_relative_to(SCRIPT_DIR): + warnings.warn("Custom templates are intended for debugging purposes only. The data model may change in future releases without notice.") + + output_dir.mkdir(exist_ok=True, parents=True) + + for cli_name, source in generate_rst(template_file).items(): + (output_dir / f'{cli_name}.rst').write_text(source) + + +def build_json(output_file: pathlib.Path) -> None: + """Build JSON documentation for ansible-core CLI programs.""" + warnings.warn("JSON output is intended for debugging purposes only. The data model may change in future releases without notice.") + + output_file.parent.mkdir(exist_ok=True, parents=True) + output_file.write_text(json.dumps(collect_programs(), indent=4)) + + +def generate_rst(template_file: pathlib.Path) -> dict[str, str]: + """Generate RST pages using the provided template.""" + results: dict[str, str] = {} + + for cli_name, template_vars in collect_programs().items(): + env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_file.parent)) + template = env.get_template(template_file.name) + results[cli_name] = template.render(template_vars) + + return results + + +def collect_programs() -> dict[str, dict[str, t.Any]]: + """Return information about CLI programs.""" + programs: list[tuple[str, dict[str, t.Any]]] = [] + cli_bin_name_list: list[str] = [] + + for source_file in (SOURCE_DIR / 'lib/ansible/cli').glob('*.py'): + if source_file.name != '__init__.py': + programs.append(generate_options_docs(source_file, cli_bin_name_list)) + + return dict(programs) + + +def generate_options_docs(source_file: pathlib.Path, cli_bin_name_list: list[str]) -> tuple[str, dict[str, t.Any]]: + """Generate doc structure from CLI module options.""" + import ansible.release + + if str(source_file).endswith('/lib/ansible/cli/adhoc.py'): + cli_name = 'ansible' + cli_class_name = 'AdHocCLI' + cli_module_fqn = 'ansible.cli.adhoc' + else: + cli_module_name = source_file.with_suffix('').name + cli_name = f'ansible-{cli_module_name}' + cli_class_name = f'{cli_module_name.capitalize()}CLI' + cli_module_fqn = f'ansible.cli.{cli_module_name}' + + cli_bin_name_list.append(cli_name) + + cli_module = importlib.import_module(cli_module_fqn) + cli_class: type[CLI] = getattr(cli_module, cli_class_name) + + cli = cli_class([cli_name]) + cli.init_parser() + + parser: argparse.ArgumentParser = cli.parser + long_desc = cli.__doc__ + arguments: dict[str, str] | None = getattr(cli, 'ARGUMENTS', None) + + action_docs = get_action_docs(parser) + option_names: tuple[str, ...] = tuple(itertools.chain.from_iterable(opt.options for opt in action_docs)) + actions: dict[str, dict[str, t.Any]] = {} + + content_depth = populate_subparser_actions(parser, option_names, actions) + + docs = dict( + version=ansible.release.__version__, + source=str(source_file.relative_to(SOURCE_DIR)), + cli_name=cli_name, + usage=parser.format_usage(), + short_desc=parser.description, + long_desc=trim_docstring(long_desc), + actions=actions, + options=[item.__dict__ for item in action_docs], + arguments=arguments, + option_names=option_names, + cli_bin_name_list=cli_bin_name_list, + content_depth=content_depth, + inventory='-i' in option_names, + library='-M' in option_names, + ) + + return cli_name, docs + + +def populate_subparser_actions(parser: argparse.ArgumentParser, shared_option_names: tuple[str, ...], actions: dict[str, dict[str, t.Any]]) -> int: + """Generate doc structure from CLI module subparser options.""" + try: + # noinspection PyProtectedMember + subparsers: dict[str, argparse.ArgumentParser] = parser._subparsers._group_actions[0].choices # type: ignore + except AttributeError: + subparsers = {} + + depth = 0 + + for subparser_action, subparser in subparsers.items(): + subparser_option_names: set[str] = set() + subparser_action_docs: set[ActionDoc] = set() + subparser_actions: dict[str, dict[str, t.Any]] = {} + + for action_doc in get_action_docs(subparser): + for option_alias in action_doc.options: + if option_alias in shared_option_names: + continue + + subparser_option_names.add(option_alias) + subparser_action_docs.add(action_doc) + + depth = populate_subparser_actions(subparser, shared_option_names, subparser_actions) + + actions[subparser_action] = dict( + option_names=list(subparser_option_names), + options=[item.__dict__ for item in subparser_action_docs], + actions=subparser_actions, + name=subparser_action, + desc=trim_docstring(subparser.get_default("func").__doc__), + ) + + return depth + 1 + + +@dataclasses.dataclass(frozen=True) +class ActionDoc: + """Documentation for an action.""" + desc: str | None + options: tuple[str, ...] + arg: str | None + + +def get_action_docs(parser: argparse.ArgumentParser) -> list[ActionDoc]: + """Get action documentation from the given argument parser.""" + action_docs = [] + + # noinspection PyProtectedMember + for action in parser._actions: + if action.help == argparse.SUPPRESS: + continue + + # noinspection PyProtectedMember, PyUnresolvedReferences + args = action.dest.upper() if isinstance(action, argparse._StoreAction) else None + + if args or action.option_strings: + action_docs.append(ActionDoc( + desc=action.help, + options=tuple(action.option_strings), + arg=args, + )) + + return action_docs + + +def trim_docstring(docstring: str | None) -> str: + """Trim and return the given docstring using the implementation from https://peps.python.org/pep-0257/#handling-docstring-indentation.""" + if not docstring: + return '' # pragma: nocover + + # Convert tabs to spaces (following the normal Python rules) and split into a list of lines + lines = docstring.expandtabs().splitlines() + + # Determine minimum indentation (first line doesn't count) + indent = sys.maxsize + + for line in lines[1:]: + stripped = line.lstrip() + + if stripped: + indent = min(indent, len(line) - len(stripped)) + + # Remove indentation (first line is special) + trimmed = [lines[0].strip()] + + if indent < sys.maxsize: + for line in lines[1:]: + trimmed.append(line[indent:].rstrip()) + + # Strip off trailing and leading blank lines + while trimmed and not trimmed[-1]: + trimmed.pop() + + while trimmed and not trimmed[0]: + trimmed.pop(0) + + # Return a single string + return '\n'.join(trimmed) + + +if __name__ == '__main__': + main() |