Skip to content

Commit adbc3b8

Browse files
Implementation revamp (#4)
1 parent eee916e commit adbc3b8

20 files changed

Lines changed: 550 additions & 361 deletions

mkdocs_click/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# All rights reserved
33
# Licensed under the Apache license (see LICENSE)
44
from .__version__ import __version__
5-
from .extension import MKClickExtension, makeExtension
5+
from ._exceptions import MkDocsClickException
6+
from ._extension import MKClickExtension, makeExtension
67

7-
__all__ = ["__version__", "MKClickExtension", "makeExtension"]
8+
__all__ = ["__version__", "MKClickExtension", "MkDocsClickException", "makeExtension"]

mkdocs_click/_docs.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# (C) Datadog, Inc. 2020-present
2+
# All rights reserved
3+
# Licensed under the Apache license (see LICENSE)
4+
from typing import Iterator, List, Optional, cast
5+
6+
import click
7+
8+
from ._exceptions import MkDocsClickException
9+
10+
11+
def make_command_docs(prog_name: str, command: click.BaseCommand, level: int = 0) -> Iterator[str]:
12+
"""Create the Markdown lines for a command and its sub-commands."""
13+
for line in _recursively_make_command_docs(prog_name, command, level=level):
14+
yield line.replace("\b", "")
15+
16+
17+
def _recursively_make_command_docs(
18+
prog_name: str, command: click.BaseCommand, parent: click.Context = None, level: int = 0
19+
) -> Iterator[str]:
20+
"""Create the raw Markdown lines for a command and its sub-commands."""
21+
ctx = click.Context(cast(click.Command, command), parent=parent)
22+
23+
yield from _make_title(prog_name, level)
24+
yield from _make_description(ctx)
25+
yield from _make_usage(ctx)
26+
yield from _make_options(ctx)
27+
28+
subcommands = _get_sub_commands(ctx.command, ctx)
29+
30+
for command in sorted(subcommands, key=lambda cmd: cmd.name):
31+
yield from _recursively_make_command_docs(command.name, command, parent=ctx, level=level + 1)
32+
33+
34+
def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.Command]:
35+
"""Return subcommands of a Click command."""
36+
subcommands = getattr(command, "commands", {})
37+
if subcommands:
38+
return subcommands.values()
39+
40+
if not isinstance(command, click.MultiCommand):
41+
return []
42+
43+
subcommands = []
44+
45+
for name in command.list_commands(ctx):
46+
subcommand = command.get_command(ctx, name)
47+
assert subcommand is not None
48+
subcommands.append(subcommand)
49+
50+
return subcommands
51+
52+
53+
def _make_title(prog_name: str, level: int) -> Iterator[str]:
54+
"""Create the first markdown lines describing a command."""
55+
yield _make_header(prog_name, level)
56+
yield ""
57+
58+
59+
def _make_header(text: str, level: int) -> str:
60+
"""Create a markdown header at a given level"""
61+
return f"{'#' * (level + 1)} {text}"
62+
63+
64+
def _make_description(ctx: click.Context) -> Iterator[str]:
65+
"""Create markdown lines based on the command's own description."""
66+
help_string = ctx.command.help or ctx.command.short_help
67+
68+
if help_string:
69+
yield from help_string.splitlines()
70+
yield ""
71+
72+
73+
def _make_usage(ctx: click.Context) -> Iterator[str]:
74+
"""Create the Markdown lines from the command usage string."""
75+
76+
# Gets the usual 'Usage' string without the prefix.
77+
formatter = ctx.make_formatter()
78+
pieces = ctx.command.collect_usage_pieces(ctx)
79+
formatter.write_usage(ctx.command_path, " ".join(pieces), prefix="")
80+
usage = formatter.getvalue().rstrip("\n")
81+
82+
# Generate the full usage string based on parents if any, i.e. `root sub1 sub2 ...`.
83+
full_path = []
84+
current: Optional[click.Context] = ctx
85+
while current is not None:
86+
name = current.command.name
87+
if name is None:
88+
raise MkDocsClickException(f"command {current.command} has no `name`")
89+
full_path.append(name)
90+
current = current.parent
91+
92+
full_path.reverse()
93+
usage_snippet = " ".join(full_path) + usage
94+
95+
yield "Usage:"
96+
yield ""
97+
yield "```"
98+
yield usage_snippet
99+
yield "```"
100+
yield ""
101+
102+
103+
def _make_options(ctx: click.Context) -> Iterator[str]:
104+
"""Create the Markdown lines describing the options for the command."""
105+
formatter = ctx.make_formatter()
106+
click.Command.format_options(ctx.command, ctx, formatter)
107+
# First line is redundant "Options"
108+
# Last line is `--help`
109+
option_lines = formatter.getvalue().splitlines()[1:-1]
110+
if not option_lines:
111+
return
112+
113+
yield "Options:"
114+
yield ""
115+
yield "```"
116+
yield from option_lines
117+
yield "```"
118+
yield ""

mkdocs_click/_exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class MkDocsClickException(Exception):
2+
"""
3+
Generic exception class for mkdocs-click errors.
4+
"""

mkdocs_click/_extension.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# (C) Datadog, Inc. 2020-present
2+
# All rights reserved
3+
# Licensed under the Apache license (see LICENSE)
4+
from typing import Any, List, Iterator
5+
6+
from markdown import Markdown
7+
from markdown.extensions import Extension
8+
from markdown.preprocessors import Preprocessor
9+
10+
from ._docs import make_command_docs
11+
from ._exceptions import MkDocsClickException
12+
from ._loader import load_command
13+
from ._processing import replace_blocks
14+
15+
16+
def replace_command_docs(**options: Any) -> Iterator[str]:
17+
for option in ("module", "command"):
18+
if option not in options:
19+
raise MkDocsClickException(f"Option {option!r} is required")
20+
21+
module = options["module"]
22+
command = options["command"]
23+
depth = int(options.get("depth", 0))
24+
25+
command_obj = load_command(module, command)
26+
27+
return make_command_docs(prog_name=command, command=command_obj, level=depth)
28+
29+
30+
class ClickProcessor(Preprocessor):
31+
def run(self, lines: List[str]) -> List[str]:
32+
return list(replace_blocks(lines, title="mkdocs-click", replace=replace_command_docs))
33+
34+
35+
class MKClickExtension(Extension):
36+
"""
37+
Replace blocks like the following:
38+
39+
::: mkdocs-click
40+
:module: example.main
41+
:command: cli
42+
43+
by Markdown documentation generated from the specified Click application.
44+
"""
45+
46+
def extendMarkdown(self, md: Markdown) -> None:
47+
md.registerExtension(self)
48+
processor = ClickProcessor(md.parser)
49+
md.preprocessors.register(processor, "mk_click", 141)
50+
51+
52+
def makeExtension() -> Extension:
53+
return MKClickExtension()

mkdocs_click/_loader.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# (C) Datadog, Inc. 2020-present
2+
# All rights reserved
3+
# Licensed under the Apache license (see LICENSE)
4+
import importlib
5+
from typing import Any
6+
7+
import click
8+
from ._exceptions import MkDocsClickException
9+
10+
11+
def load_command(module: str, attribute: str) -> click.BaseCommand:
12+
"""
13+
Load and return the Click command object located at '<module>:<attribute>'.
14+
"""
15+
command = _load_obj(module, attribute)
16+
17+
if not isinstance(command, click.BaseCommand):
18+
raise MkDocsClickException(f"{attribute!r} must be a 'click.BaseCommand' object, got {type(command)}")
19+
20+
return command
21+
22+
23+
def _load_obj(module: str, attribute: str) -> Any:
24+
try:
25+
mod = importlib.import_module(module)
26+
except SystemExit:
27+
raise MkDocsClickException("the module appeared to call sys.exit()") # pragma: no cover
28+
29+
try:
30+
return getattr(mod, attribute)
31+
except AttributeError:
32+
raise MkDocsClickException(f"Module {module!r} has no attribute {attribute!r}")

mkdocs_click/_processing.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# (C) Datadog, Inc. 2020-present
2+
# All rights reserved
3+
# Licensed under the Apache license (see LICENSE)
4+
import re
5+
from typing import Callable, Iterable, Iterator
6+
7+
8+
def replace_blocks(lines: Iterable[str], title: str, replace: Callable[..., Iterable[str]]) -> Iterator[str]:
9+
"""
10+
Find blocks of lines in the form of:
11+
12+
::: <title>
13+
:<key1>: <value>
14+
:<key2>:
15+
...
16+
17+
And replace them with the lines returned by `replace(key1="<value1>", key2="", ...)`.
18+
"""
19+
20+
options = {}
21+
in_block_section = False
22+
23+
for line in lines:
24+
if in_block_section:
25+
match = re.search(r"^\s+:(?P<key>.+):(?:\s+(?P<value>\S+))?", line)
26+
if match is not None:
27+
# New ':key:' or ':key: value' line, ingest it.
28+
key = match.group("key")
29+
value = match.group("value") or ""
30+
options[key] = value
31+
continue
32+
33+
# Block is finished, flush it.
34+
in_block_section = False
35+
yield from replace(**options)
36+
yield line
37+
continue
38+
39+
match = re.search(rf"^::: {title}", line)
40+
if match is not None:
41+
# Block header, ingest it.
42+
in_block_section = True
43+
options = {}
44+
else:
45+
yield line

mkdocs_click/extension.py

Lines changed: 0 additions & 55 deletions
This file was deleted.

0 commit comments

Comments
 (0)