From 1131d839634bd01b1d05fd05faa7634cd8605204 Mon Sep 17 00:00:00 2001 From: Philipp Temminghoff Date: Tue, 19 Sep 2023 22:50:25 +0200 Subject: [PATCH] feat: generate our own CLI docs for Click / Typer --- configs/mkdocs_multipage.yml | 1 - mknodes/basenodes/mkclickdoc.py | 42 +++++----------- mknodes/utils/clihelpers.py | 85 ++++++++++++++++++++++++++------- pyproject.toml | 1 - 4 files changed, 82 insertions(+), 47 deletions(-) diff --git a/configs/mkdocs_multipage.yml b/configs/mkdocs_multipage.yml index 170ee553..338e4d72 100644 --- a/configs/mkdocs_multipage.yml +++ b/configs/mkdocs_multipage.yml @@ -1,7 +1,6 @@ INHERIT: mkdocs_basic.yml markdown_extensions: - mkdocs-click -- mkdocs-typer plugins: - mknodes: diff --git a/mknodes/basenodes/mkclickdoc.py b/mknodes/basenodes/mkclickdoc.py index 9719d4d0..cc6587f7 100644 --- a/mknodes/basenodes/mkclickdoc.py +++ b/mknodes/basenodes/mkclickdoc.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Any, Literal +from typing import Any from mknodes.basenodes import mknode -from mknodes.utils import log, reprhelpers, requirements +from mknodes.utils import clihelpers, log, reprhelpers, requirements logger = log.get_logger(__name__) @@ -12,19 +12,13 @@ class MkClickDoc(mknode.MkNode): """Documentation for click / typer CLI apps.""" - REQUIRED_EXTENSIONS = [ - requirements.Extension("mkdocs-typer"), - requirements.Extension("attr_list"), - ] + REQUIRED_EXTENSIONS = [requirements.Extension("attr_list")] ICON = "material/api" def __init__( self, target: str | None = None, prog_name: str | None = None, - depth: int | None = None, - style: Literal["plain", "table"] | None = None, - remove_ascii_art: bool = False, show_hidden: bool = False, show_subcommands: bool = False, **kwargs: Any, @@ -34,10 +28,6 @@ def __init__( Arguments: target: Dotted path to Click command prog_name: Program name - depth: Offset to add when generating headers. - style: Style for the options section. - remove_ascii_art: When docstrings begin with the escape character \b, all - text will be ignored until next blank line is encountered. show_hidden: Show commands and options that are marked as hidden. show_subcommands: List subcommands of a given command. kwargs: Keyword arguments passed to parent @@ -45,9 +35,6 @@ def __init__( super().__init__(**kwargs) self._target = target self._prog_name = prog_name - self._depth = depth - self.style = style - self.remove_ascii_art = remove_ascii_art self.show_hidden = show_hidden self.show_subcommands = show_subcommands @@ -55,7 +42,7 @@ def __repr__(self): return reprhelpers.get_repr(self, target=self._target) @property - def attributes(self) -> dict[str, str | None]: + def attributes(self) -> dict[str, Any]: # sourcery skip: use-named-expression dct: dict[str, Any] = {} match self._target: @@ -70,25 +57,22 @@ def attributes(self) -> dict[str, str | None]: dct = dict(module=module, command=command, prog_name=eps[0].name) if not dct: return {} - dct.update( - depth=self._depth, - style=self.style, - remove_ascii_art=self.remove_ascii_art, - show_hidden=self.show_hidden, - show_subcommands=self.show_subcommands, - ) + dct.update(show_hidden=self.show_hidden, show_subcommands=self.show_subcommands) return dct def _to_markdown(self) -> str: + import importlib + if not self.attributes: return "" app = self.ctx.metadata.cli if not app: return "" - md = f"::: mkdocs-{app}" - option_lines = [f" :{k}: {v}" for k, v in self.attributes.items() if v] - option_text = "\n".join(option_lines) - return f"{md}\n{option_text}\n\n" + attrs = self.attributes + mod = importlib.import_module(attrs["module"]) + instance = getattr(mod, attrs["command"]) + info = clihelpers.get_typer_info(instance, command=attrs["prog_name"]) + return info.to_markdown() @staticmethod def create_example_page(page): @@ -101,5 +85,5 @@ def create_example_page(page): if __name__ == "__main__": - docstrings = MkClickDoc.with_default_context("mknodes.cli:cli", prog_name="sth") + docstrings = MkClickDoc.with_default_context("mknodes.cli:cli", prog_name="build") print(docstrings) diff --git a/mknodes/utils/clihelpers.py b/mknodes/utils/clihelpers.py index 6d1abc69..8ced2c7e 100644 --- a/mknodes/utils/clihelpers.py +++ b/mknodes/utils/clihelpers.py @@ -2,41 +2,97 @@ import dataclasses +from typing import Any + import click import typer from typer.main import get_command +from mknodes.basenodes import mkcode + + +@dataclasses.dataclass +class Param: + count: bool = False + default: Any = None + envvar: str | None = None + flag_value: bool = False + help: str | None = None # noqa: A003 + hidden: bool = False + is_flag: bool = False + multiple: bool = False + name: str = "" + nargs: int = 1 + opts: list[str] = dataclasses.field(default_factory=list) + param_type_name: str = "option" + prompt: Any = None + required: bool = False + secondary_opts: list[str] = dataclasses.field(default_factory=list) + type: dict[str, str] = dataclasses.field(default_factory=dict) # noqa: A003 + + def to_markdown(self): + opt_str = ", ".join(f"`{i}`" for i in reversed(self.opts)) + lines = [f"###{opt_str}"] + if self.required: + lines.append("**REQUIRED**") + if self.envvar: + lines.append(f"**Environment variable:** {self.envvar}") + if self.multiple: + lines.append("**Multiple values allowed.**") + if self.default: + lines.append(f"**Default:** {self.default}") + if self.is_flag: + lines.append(f"**Flag:** {self.flag_value}") + if self.help: + lines.append(self.help) + return "\n\n".join(lines) + @dataclasses.dataclass class CommandInfo: - title: str + name: str description: str usage: str - options: str subcommands: dict[str, CommandInfo] = dataclasses.field(default_factory=dict) + deprecated: bool = False + epilog: str | None = None + hidden: bool = False + params: list[Param] = dataclasses.field(default_factory=list) def __getitem__(self, name): return self.subcommands[name] + def to_markdown(self): + text = self.description + "\n\n" + str(mkcode.MkCode(self.usage)) + params = [i.to_markdown() for i in self.params] + return text + "\n\n\n" + "\n\n\n".join(params) + -def get_typer_info(typer_instance: typer.Typer) -> CommandInfo: - cmd = get_command(typer_instance) - return get_command_info(cmd) +def get_typer_info(instance: typer.Typer, command: str | None = None) -> CommandInfo: + cmd = get_command(instance) if isinstance(instance, typer.Typer) else instance + info = get_command_info(cmd) + if command: + ctx = typer.Context(cmd) + subcommands = getattr(cmd, "commands", {}) + cmds = {k: get_command_info(v, parent=ctx) for k, v in subcommands.items()} + return cmds[command] + return info def get_command_info(command: click.Command, parent=None) -> CommandInfo: ctx = typer.Context(command, parent=parent) subcommands = getattr(command, "commands", {}) - formatter = ctx.make_formatter() - click.Command.format_options(ctx.command, ctx, formatter) + dct = ctx.command.to_info_dict(ctx) return CommandInfo( - title=ctx.command.name or "", + name=ctx.command.name or "", description=ctx.command.help or ctx.command.short_help or "", usage=_make_usage(ctx), - options=formatter.getvalue(), - # ctx.command_path + params=[Param(**i) for i in dct["params"]], subcommands={k: get_command_info(v, parent=ctx) for k, v in subcommands.items()}, + deprecated=dct["deprecated"], + epilog=dct["epilog"], + hidden=dct["hidden"], ) @@ -51,10 +107,7 @@ def _make_usage(ctx: click.Context) -> str: full_path = [] current: click.Context | None = ctx while current is not None: - name = current.command.name - if name is None: - msg = f"command {current.command} has no `name`" - raise RuntimeError(msg) + name = current.command.name.lower() full_path.append(name) current = current.parent @@ -67,5 +120,5 @@ def _make_usage(ctx: click.Context) -> str: from mknodes import cli - info = get_typer_info(cli.cli) - pprint(info) + info = get_typer_info(cli.cli, "build") + pprint(info.to_markdown()) diff --git a/pyproject.toml b/pyproject.toml index 08c644e1..2770b1f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ dependencies = [ # Docs "pymdown-extensions", "markdown-exec[ansi]", - "mkdocs-typer", "mkdocs-click", "pygments", "pydeps",