Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow using full command paths in headers #36

Merged
merged 3 commits into from
Feb 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ If you are inserting documentation within other Markdown content, you can set th

By default it is set to `0`, i.e. headers start at `<h1>`. If set to `1`, headers will start at `<h2>`, and so on. Note that if you insert your own first level heading and leave depth at its default value of 0, the page will have multiple `<h1>` tags, which is not compatible with themes that generate page-internal menus such as the ReadTheDocs and mkdocs-material themes.

### Full command path headers

By default, `mkdocs-click` outputs headers that contain the command name. For nested commands such as `$ cli build all`, this also means the heading would be `## all`. This might be surprising, and may be harder to navigate at a glance for highly nested CLI apps.

If you'd like to show the full command path instead, turn on the [Attribute Lists extension](https://python-markdown.github.io/extensions/attr_list/):

```yaml
# mkdocs.yaml

markdown_extensions:
- attr_list
- mkdocs-click
```

`mkdocs-click` will then output the full command path in headers (e.g. `## cli build all`) and permalinks (e.g. `#cli-build-all`).

Note that the table of content (TOC) will still use the command name: the TOC is naturally hierarchal, so full command paths would be redundant. (This exception is why the `attr_list` extension is required.)

## Reference

### Block syntax
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ theme: readthedocs
docs_dir: example

markdown_extensions:
- attr_list
- mkdocs-click
61 changes: 53 additions & 8 deletions mkdocs_click/_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,47 @@
from typing import Iterator, List, cast

import click
from markdown.extensions.toc import slugify

from ._exceptions import MkDocsClickException


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


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

yield from _make_title(prog_name, depth)
yield from _make_title(ctx, depth, has_attr_list=has_attr_list)
yield from _make_description(ctx)
yield from _make_usage(ctx)
yield from _make_options(ctx, style)

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, depth=depth + 1, style=style)
yield from _recursively_make_command_docs(
command.name, command, parent=ctx, depth=depth + 1, style=style, has_attr_list=has_attr_list
)


def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.Command]:
Expand All @@ -52,9 +66,40 @@ def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.
return subcommands


def _make_title(prog_name: str, depth: int) -> Iterator[str]:
"""Create the first markdown lines describing a command."""
yield f"{'#' * (depth + 1)} {prog_name}"
def _make_title(ctx: click.Context, depth: int, *, has_attr_list: bool) -> Iterator[str]:
"""Create the Markdown heading for a command."""
if has_attr_list:
yield from _make_title_full_command_path(ctx, depth)
else:
yield from _make_title_basic(ctx, depth)


def _make_title_basic(ctx: click.Context, depth: int) -> Iterator[str]:
"""Create a basic Markdown heading for a command."""
yield f"{'#' * (depth + 1)} {ctx.info_name}"
yield ""


def _make_title_full_command_path(ctx: click.Context, depth: int) -> Iterator[str]:
"""Create the markdown heading for a command, showing the full command path.

This style accomodates nested commands by showing:
* The full command path for headers and permalinks (eg `# git commit` and `http://localhost:8000/#git-commit`)
* The command leaf name only for TOC entries (eg `* commit`).

We do this because a TOC naturally conveys the hierarchy, whereas headings and permalinks should be namespaced to
convey the hierarchy.

See: https://github.com/DataDog/mkdocs-click/issues/35
"""
text = ctx.command_path # 'git commit'
permalink = slugify(ctx.command_path, "-") # 'git-commit'
toc_label = ctx.info_name # 'commit'

# Requires `attr_list` extension, see: https://python-markdown.github.io/extensions/toc/#custom-labels
attributes = f"#{permalink} data-toc-label='{toc_label}'"

yield f"{'#' * (depth + 1)} {text} {{ {attributes} }}"
yield ""


Expand Down
21 changes: 17 additions & 4 deletions mkdocs_click/_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, Iterator, List

from markdown.extensions import Extension
from markdown.extensions.attr_list import AttrListExtension
from markdown.preprocessors import Preprocessor

from ._docs import make_command_docs
Expand All @@ -12,7 +13,7 @@
from ._processing import replace_blocks


def replace_command_docs(**options: Any) -> Iterator[str]:
def replace_command_docs(has_attr_list: bool = False, **options: Any) -> Iterator[str]:
for option in ("module", "command"):
if option not in options:
raise MkDocsClickException(f"Option {option!r} is required")
Expand All @@ -27,12 +28,24 @@ def replace_command_docs(**options: Any) -> Iterator[str]:

prog_name = prog_name or command_obj.name or command

return make_command_docs(prog_name=prog_name, command=command_obj, depth=depth, style=style)
return make_command_docs(
prog_name=prog_name, command=command_obj, depth=depth, style=style, has_attr_list=has_attr_list
)


class ClickProcessor(Preprocessor):
def __init__(self, md: Any) -> None:
super().__init__(md)
self._has_attr_list = any(isinstance(ext, AttrListExtension) for ext in md.registeredExtensions)

def run(self, lines: List[str]) -> List[str]:
return list(replace_blocks(lines, title="mkdocs-click", replace=replace_command_docs))
return list(
replace_blocks(
lines,
title="mkdocs-click",
replace=lambda **options: replace_command_docs(has_attr_list=self._has_attr_list, **options),
)
)


class MKClickExtension(Extension):
Expand All @@ -48,7 +61,7 @@ class MKClickExtension(Extension):

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


Expand Down
63 changes: 63 additions & 0 deletions tests/app/expected-enhanced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# cli { #cli data-toc-label="cli" }

Main entrypoint for this dummy program

**Usage:**

```
cli [OPTIONS] COMMAND [ARGS]...
```

**Options:**

```
--help Show this message and exit.
```

## cli bar { #cli-bar data-toc-label="bar" }

The bar command

**Usage:**

```
cli bar [OPTIONS] COMMAND [ARGS]...
```

**Options:**

```
--help Show this message and exit.
```

### cli bar hello { #cli-bar-hello data-toc-label="hello" }

Simple program that greets NAME for a total of COUNT times.

**Usage:**

```
cli bar hello [OPTIONS]
```

**Options:**

```
--count INTEGER Number of greetings.
--name TEXT The person to greet.
--help Show this message and exit.
```

## cli foo { #cli-foo data-toc-label="foo" }

**Usage:**

```
cli foo [OPTIONS]
```

**Options:**

```
--help Show this message and exit.
```
23 changes: 23 additions & 0 deletions tests/test_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import mkdocs_click

EXPECTED = (Path(__file__).parent / "app" / "expected.md").read_text()
EXPECTED_ENHANCED = (Path(__file__).parent / "app" / "expected-enhanced.md").read_text()


@pytest.mark.parametrize(
Expand Down Expand Up @@ -101,3 +102,25 @@ def test_required_options(option):

with pytest.raises(mkdocs_click.MkDocsClickException):
md.convert(source)


def test_enhanced_titles():
"""
If `attr_list` extension is registered, section titles are enhanced with full command paths.

See: https://github.com/DataDog/mkdocs-click/issues/35
"""
md = Markdown(extensions=["attr_list"])
# Register our extension as a second step, so that we see `attr_list`.
# This is what MkDocs does, so there's no hidden usage constraint here.
md.registerExtensions([mkdocs_click.makeExtension()], {})

source = dedent(
"""
::: mkdocs-click
:module: tests.app.cli
:command: cli
"""
)

assert md.convert(source) == md.convert(EXPECTED_ENHANCED)