Skip to content

Commit

Permalink
feat: generate our own CLI docs for Click / Typer
Browse files Browse the repository at this point in the history
  • Loading branch information
phil65 committed Sep 19, 2023
1 parent 0c7e960 commit 1131d83
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 47 deletions.
1 change: 0 additions & 1 deletion configs/mkdocs_multipage.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
INHERIT: mkdocs_basic.yml
markdown_extensions:
- mkdocs-click
- mkdocs-typer

plugins:
- mknodes:
Expand Down
42 changes: 13 additions & 29 deletions mknodes/basenodes/mkclickdoc.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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,
Expand All @@ -34,28 +28,21 @@ 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
"""
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

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:
Expand All @@ -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):
Expand All @@ -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)
85 changes: 69 additions & 16 deletions mknodes/utils/clihelpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)


Expand All @@ -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

Expand All @@ -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())
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ dependencies = [
# Docs
"pymdown-extensions",
"markdown-exec[ansi]",
"mkdocs-typer",
"mkdocs-click",
"pygments",
"pydeps",
Expand Down

0 comments on commit 1131d83

Please sign in to comment.