summaryrefslogtreecommitdiff
path: root/packaging/cli-doc/build.py
diff options
context:
space:
mode:
Diffstat (limited to 'packaging/cli-doc/build.py')
-rwxr-xr-xpackaging/cli-doc/build.py279
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()