Skip to content

Commit

Permalink
Implementation revamp (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
florimondmanca authored Jun 5, 2020
1 parent eee916e commit adbc3b8
Show file tree
Hide file tree
Showing 20 changed files with 550 additions and 361 deletions.
5 changes: 3 additions & 2 deletions mkdocs_click/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# All rights reserved
# Licensed under the Apache license (see LICENSE)
from .__version__ import __version__
from .extension import MKClickExtension, makeExtension
from ._exceptions import MkDocsClickException
from ._extension import MKClickExtension, makeExtension

__all__ = ["__version__", "MKClickExtension", "makeExtension"]
__all__ = ["__version__", "MKClickExtension", "MkDocsClickException", "makeExtension"]
118 changes: 118 additions & 0 deletions mkdocs_click/_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under the Apache license (see LICENSE)
from typing import Iterator, List, Optional, cast

import click

from ._exceptions import MkDocsClickException


def make_command_docs(prog_name: str, command: click.BaseCommand, level: int = 0) -> Iterator[str]:
"""Create the Markdown lines for a command and its sub-commands."""
for line in _recursively_make_command_docs(prog_name, command, level=level):
yield line.replace("\b", "")


def _recursively_make_command_docs(
prog_name: str, command: click.BaseCommand, parent: click.Context = None, level: int = 0
) -> Iterator[str]:
"""Create the raw Markdown lines for a command and its sub-commands."""
ctx = click.Context(cast(click.Command, command), parent=parent)

yield from _make_title(prog_name, level)
yield from _make_description(ctx)
yield from _make_usage(ctx)
yield from _make_options(ctx)

subcommands = _get_sub_commands(ctx.command, ctx)

for command in sorted(subcommands, key=lambda cmd: cmd.name):
yield from _recursively_make_command_docs(command.name, command, parent=ctx, level=level + 1)


def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.Command]:
"""Return subcommands of a Click command."""
subcommands = getattr(command, "commands", {})
if subcommands:
return subcommands.values()

if not isinstance(command, click.MultiCommand):
return []

subcommands = []

for name in command.list_commands(ctx):
subcommand = command.get_command(ctx, name)
assert subcommand is not None
subcommands.append(subcommand)

return subcommands


def _make_title(prog_name: str, level: int) -> Iterator[str]:
"""Create the first markdown lines describing a command."""
yield _make_header(prog_name, level)
yield ""


def _make_header(text: str, level: int) -> str:
"""Create a markdown header at a given level"""
return f"{'#' * (level + 1)} {text}"


def _make_description(ctx: click.Context) -> Iterator[str]:
"""Create markdown lines based on the command's own description."""
help_string = ctx.command.help or ctx.command.short_help

if help_string:
yield from help_string.splitlines()
yield ""


def _make_usage(ctx: click.Context) -> Iterator[str]:
"""Create the Markdown lines from the command usage string."""

# Gets the usual 'Usage' string without the prefix.
formatter = ctx.make_formatter()
pieces = ctx.command.collect_usage_pieces(ctx)
formatter.write_usage(ctx.command_path, " ".join(pieces), prefix="")
usage = formatter.getvalue().rstrip("\n")

# Generate the full usage string based on parents if any, i.e. `root sub1 sub2 ...`.
full_path = []
current: Optional[click.Context] = ctx
while current is not None:
name = current.command.name
if name is None:
raise MkDocsClickException(f"command {current.command} has no `name`")
full_path.append(name)
current = current.parent

full_path.reverse()
usage_snippet = " ".join(full_path) + usage

yield "Usage:"
yield ""
yield "```"
yield usage_snippet
yield "```"
yield ""


def _make_options(ctx: click.Context) -> Iterator[str]:
"""Create the Markdown lines describing the options for the command."""
formatter = ctx.make_formatter()
click.Command.format_options(ctx.command, ctx, formatter)
# First line is redundant "Options"
# Last line is `--help`
option_lines = formatter.getvalue().splitlines()[1:-1]
if not option_lines:
return

yield "Options:"
yield ""
yield "```"
yield from option_lines
yield "```"
yield ""
4 changes: 4 additions & 0 deletions mkdocs_click/_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class MkDocsClickException(Exception):
"""
Generic exception class for mkdocs-click errors.
"""
53 changes: 53 additions & 0 deletions mkdocs_click/_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under the Apache license (see LICENSE)
from typing import Any, List, Iterator

from markdown import Markdown
from markdown.extensions import Extension
from markdown.preprocessors import Preprocessor

from ._docs import make_command_docs
from ._exceptions import MkDocsClickException
from ._loader import load_command
from ._processing import replace_blocks


def replace_command_docs(**options: Any) -> Iterator[str]:
for option in ("module", "command"):
if option not in options:
raise MkDocsClickException(f"Option {option!r} is required")

module = options["module"]
command = options["command"]
depth = int(options.get("depth", 0))

command_obj = load_command(module, command)

return make_command_docs(prog_name=command, command=command_obj, level=depth)


class ClickProcessor(Preprocessor):
def run(self, lines: List[str]) -> List[str]:
return list(replace_blocks(lines, title="mkdocs-click", replace=replace_command_docs))


class MKClickExtension(Extension):
"""
Replace blocks like the following:
::: mkdocs-click
:module: example.main
:command: cli
by Markdown documentation generated from the specified Click application.
"""

def extendMarkdown(self, md: Markdown) -> None:
md.registerExtension(self)
processor = ClickProcessor(md.parser)
md.preprocessors.register(processor, "mk_click", 141)


def makeExtension() -> Extension:
return MKClickExtension()
32 changes: 32 additions & 0 deletions mkdocs_click/_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under the Apache license (see LICENSE)
import importlib
from typing import Any

import click
from ._exceptions import MkDocsClickException


def load_command(module: str, attribute: str) -> click.BaseCommand:
"""
Load and return the Click command object located at '<module>:<attribute>'.
"""
command = _load_obj(module, attribute)

if not isinstance(command, click.BaseCommand):
raise MkDocsClickException(f"{attribute!r} must be a 'click.BaseCommand' object, got {type(command)}")

return command


def _load_obj(module: str, attribute: str) -> Any:
try:
mod = importlib.import_module(module)
except SystemExit:
raise MkDocsClickException("the module appeared to call sys.exit()") # pragma: no cover

try:
return getattr(mod, attribute)
except AttributeError:
raise MkDocsClickException(f"Module {module!r} has no attribute {attribute!r}")
45 changes: 45 additions & 0 deletions mkdocs_click/_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under the Apache license (see LICENSE)
import re
from typing import Callable, Iterable, Iterator


def replace_blocks(lines: Iterable[str], title: str, replace: Callable[..., Iterable[str]]) -> Iterator[str]:
"""
Find blocks of lines in the form of:
::: <title>
:<key1>: <value>
:<key2>:
...
And replace them with the lines returned by `replace(key1="<value1>", key2="", ...)`.
"""

options = {}
in_block_section = False

for line in lines:
if in_block_section:
match = re.search(r"^\s+:(?P<key>.+):(?:\s+(?P<value>\S+))?", line)
if match is not None:
# New ':key:' or ':key: value' line, ingest it.
key = match.group("key")
value = match.group("value") or ""
options[key] = value
continue

# Block is finished, flush it.
in_block_section = False
yield from replace(**options)
yield line
continue

match = re.search(rf"^::: {title}", line)
if match is not None:
# Block header, ingest it.
in_block_section = True
options = {}
else:
yield line
55 changes: 0 additions & 55 deletions mkdocs_click/extension.py

This file was deleted.

Loading

0 comments on commit adbc3b8

Please sign in to comment.