"""Builds automatic documentation of the installed webviz-config plugins. The documentation is designed to be used by the YAML configuration file end user. Sphinx has not been used, as the documentation from Sphinx is geared mostly towards Python end users. It is also a small task generating `webviz` documentation, and most of the Sphinx machinery is not needed. Overall workflow is: * Find all installed plugins. * Automatically read docstring and __init__ function signatures (both argument names and which arguments have default values). * Output the extracted plugin information into docsify input using jinja2. """ import shutil import inspect import pathlib from importlib import import_module from collections import defaultdict from typing import Any, Dict, Optional, Tuple import pkg_resources import jinja2 from typing_extensions import TypedDict import webviz_config.plugins from webviz_config._config_parser import SPECIAL_ARGS class PluginInfo(TypedDict): arg_strings: Dict[str, str] argument_description: Optional[str] data_input: Optional[str] description: Optional[str] module: str name: str package: str package_doc: Optional[str] package_version: str def _document_plugin(plugin: Tuple[str, Any]) -> PluginInfo: """Takes in a tuple (from e.g. inspect.getmembers), and returns a dictionary according to the type definition PluginInfo. """ name, reference = plugin docstring = reference.__doc__ if reference.__doc__ is not None else "" docstring_parts = docstring.strip().split("\n---\n") argspec = inspect.getfullargspec(reference.__init__) module = inspect.getmodule(reference) subpackage = inspect.getmodule(module).__package__ # type: ignore top_package_name = subpackage.split(".")[0] # type: ignore plugin_info: PluginInfo = { "arg_strings": {arg: "" for arg in argspec.args if arg not in SPECIAL_ARGS}, "argument_description": docstring_parts[1] if len(docstring_parts) > 1 else None, "data_input": docstring_parts[2] if len(docstring_parts) > 2 else None, "description": docstring_parts[0] if docstring != "" else None, "name": name, "module": module.__name__, # type: ignore "package": top_package_name, "package_doc": import_module(subpackage).__doc__, # type: ignore "package_version": pkg_resources.get_distribution(top_package_name).version, } # Add default value and the string '# Optional' to plugin # arguments with default values: if argspec.defaults is not None: for arg, default in dict( zip(reversed(argspec.args), reversed(argspec.defaults)) ).items(): if default == "": default = "''" plugin_info["arg_strings"][arg] = f"{default} # Optional." # ...and for the other arguments add '# Required': for arg, string in plugin_info["arg_strings"].items(): if string == "": plugin_info["arg_strings"][arg] = " # Required." # Add a human readable type hint (for arguments with type annotation): for arg, annotation in argspec.annotations.items(): if arg in plugin_info["arg_strings"]: plugin_info["arg_strings"][ arg ] += f" Type {_annotation_to_string(annotation)}." return plugin_info def get_plugin_documentation() -> defaultdict: """Find all installed webviz plugins, and then document them by grabbing docstring and input arguments / function signature. """ plugin_doc = [ _document_plugin(plugin) for plugin in inspect.getmembers(webviz_config.plugins, inspect.isclass) if not plugin[0].startswith("Example") ] # Sort the plugins by package: package_ordered: defaultdict = defaultdict(lambda: {"plugins": []}) for sorted_plugin in sorted(plugin_doc, key=lambda x: (x["module"], x["name"])): package = sorted_plugin["package"] package_ordered[package]["plugins"].append(sorted_plugin) package_ordered[package]["doc"] = sorted_plugin["package_doc"] package_ordered[package]["version"] = sorted_plugin["package_version"] return package_ordered def _annotation_to_string(annotation: Any) -> str: """Takes in a type annotation (that could come from e.g. inspect.getfullargspec) and transforms it into a human readable string. """ def remove_fix(string: str, fix: str, prefix: bool = True) -> str: if prefix and string.startswith(fix): return string[len(fix) :] if not prefix and string.endswith(fix): return string[: -len(fix)] return string text_type = str(annotation) text_type = remove_fix(text_type, "typing.") text_type = remove_fix(text_type, "<class '") text_type = remove_fix(text_type, "'>", prefix=False) text_type = text_type.replace("pathlib.Path", "str (corresponding to a path)") return text_type def build_docs(build_directory: pathlib.Path) -> None: # From Python 3.8, copytree gets an argument dirs_exist_ok. # Then the rmtree command can be removed. shutil.rmtree(build_directory) shutil.copytree( pathlib.Path(__file__).resolve().parent / "static", build_directory, ) template_environment = jinja2.Environment( # nosec loader=jinja2.PackageLoader("webviz_config", "templates"), undefined=jinja2.StrictUndefined, autoescape=False, ) plugin_documentation = get_plugin_documentation() template = template_environment.get_template("README.md.jinja2") for package_name, package_doc in plugin_documentation.items(): (build_directory / (package_name + ".md")).write_text( template.render({"package_name": package_name, "package_doc": package_doc}) ) template = template_environment.get_template("sidebar.md.jinja2") (build_directory / "sidebar.md").write_text( template.render({"packages": plugin_documentation.keys()}) )