From adbc3b8336f640dfd6a2cdca9f538d83b543d86d Mon Sep 17 00:00:00 2001 From: Florimond Manca Date: Fri, 5 Jun 2020 15:56:49 +0200 Subject: [PATCH] Implementation revamp (#4) --- mkdocs_click/__init__.py | 5 +- mkdocs_click/_docs.py | 118 ++++++++++++++++ mkdocs_click/_exceptions.py | 4 + mkdocs_click/_extension.py | 53 +++++++ mkdocs_click/_loader.py | 32 +++++ mkdocs_click/_processing.py | 45 ++++++ mkdocs_click/extension.py | 55 -------- mkdocs_click/parser.py | 165 ---------------------- requirements.txt | 1 + setup.cfg | 7 +- tests/{click => app}/__init__.py | 0 tests/{click => app}/cli.py | 12 +- tests/{click/docs.txt => app/expected.md} | 4 +- tests/conftest.py | 11 -- tests/test_extension.py | 142 +++++++++---------- tests/test_parser.py | 44 ------ tests/unit/__init__.py | 3 + tests/unit/test_docs.py | 109 ++++++++++++++ tests/unit/test_loader.py | 23 +++ tests/unit/test_processing.py | 78 ++++++++++ 20 files changed, 550 insertions(+), 361 deletions(-) create mode 100644 mkdocs_click/_docs.py create mode 100644 mkdocs_click/_exceptions.py create mode 100644 mkdocs_click/_extension.py create mode 100644 mkdocs_click/_loader.py create mode 100644 mkdocs_click/_processing.py delete mode 100644 mkdocs_click/extension.py delete mode 100644 mkdocs_click/parser.py rename tests/{click => app}/__init__.py (100%) rename tests/{click => app}/cli.py (81%) rename tests/{click/docs.txt => app/expected.md} (94%) delete mode 100644 tests/conftest.py delete mode 100644 tests/test_parser.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_docs.py create mode 100644 tests/unit/test_loader.py create mode 100644 tests/unit/test_processing.py diff --git a/mkdocs_click/__init__.py b/mkdocs_click/__init__.py index ca8aa09..b11f90f 100644 --- a/mkdocs_click/__init__.py +++ b/mkdocs_click/__init__.py @@ -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"] diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py new file mode 100644 index 0000000..a1d19d9 --- /dev/null +++ b/mkdocs_click/_docs.py @@ -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 "" diff --git a/mkdocs_click/_exceptions.py b/mkdocs_click/_exceptions.py new file mode 100644 index 0000000..3c3031e --- /dev/null +++ b/mkdocs_click/_exceptions.py @@ -0,0 +1,4 @@ +class MkDocsClickException(Exception): + """ + Generic exception class for mkdocs-click errors. + """ diff --git a/mkdocs_click/_extension.py b/mkdocs_click/_extension.py new file mode 100644 index 0000000..45f9bb1 --- /dev/null +++ b/mkdocs_click/_extension.py @@ -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() diff --git a/mkdocs_click/_loader.py b/mkdocs_click/_loader.py new file mode 100644 index 0000000..da141be --- /dev/null +++ b/mkdocs_click/_loader.py @@ -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 ':'. + """ + 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}") diff --git a/mkdocs_click/_processing.py b/mkdocs_click/_processing.py new file mode 100644 index 0000000..7fb7b28 --- /dev/null +++ b/mkdocs_click/_processing.py @@ -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: + + ::: + :<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 diff --git a/mkdocs_click/extension.py b/mkdocs_click/extension.py deleted file mode 100644 index e51ee7e..0000000 --- a/mkdocs_click/extension.py +++ /dev/null @@ -1,55 +0,0 @@ -# (C) Datadog, Inc. 2020-present -# All rights reserved -# Licensed under the Apache license (see LICENSE) -import re -from typing import List - -from markdown import Markdown -from markdown.extensions import Extension -from markdown.preprocessors import Preprocessor - -from .parser import generate_command_docs - - -class ClickProcessor(Preprocessor): - - PATTERN_PLUGIN_IDENTIFIER = re.compile(r"^::: mkdocs-click") - PATTERN_PLUGIN_OPTIONS = re.compile(r"^(?:\t|\s{4}):(.+):(\s\S+|\s*)$") - - def run(self, lines: List[str]) -> List[str]: - new_lines = [] - in_block_section = False - block_options = {} - for i, line in enumerate(lines): - if in_block_section: - m = self.PATTERN_PLUGIN_OPTIONS.search(line) - if m: - option_name, option_value = m.groups() - block_options[option_name] = option_value.strip() - else: - # Reached end of block, generate documentation - new_lines.extend(generate_command_docs(block_options)) - new_lines.append(line) - in_block_section = False - continue - - m = self.PATTERN_PLUGIN_IDENTIFIER.search(line) - if m: - # Just found the plugin identifier, start a block - in_block_section = True - block_options = {} - else: - new_lines.append(line) - - return new_lines - - -class MKClickExtension(Extension): - 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() diff --git a/mkdocs_click/parser.py b/mkdocs_click/parser.py deleted file mode 100644 index 8fc7915..0000000 --- a/mkdocs_click/parser.py +++ /dev/null @@ -1,165 +0,0 @@ -# (C) Datadog, Inc. 2020-present -# All rights reserved -# Licensed under the Apache license (see LICENSE) -""" -Inspired by the click plugin for Sphinx, see: https://github.com/click-contrib/sphinx-click - -This module contains all the required functions to parse a Click command recursively. -""" -import logging -import traceback -from typing import cast, Dict, List, Optional, Iterator - -import click - -logger = logging.getLogger(f"MARKDOWN.{__name__}") - - -class MKClickConfigException(Exception): - pass - - -def _load_command(module_path: str, module_name: str) -> click.BaseCommand: - """Load a module at a given path.""" - logger.info(f"Loading module {module_path}:{module_name}") - try: - mod = __import__(module_path, globals(), locals(), [module_name]) - except (Exception, SystemExit) as exc: - err_msg = f"Failed to import '{module_name}' from '{module_path}'. " - if isinstance(exc, SystemExit): - err_msg += "The module appeared to call sys.exit()" - else: - err_msg += f"The following exception was raised:\n{traceback.format_exc()}" - - raise MKClickConfigException(err_msg) - - if not hasattr(mod, module_name): - raise MKClickConfigException(f"Module '{module_path}' has no attribute '{module_name}'") - - parser = getattr(mod, module_name) - - if not isinstance(parser, click.BaseCommand): - raise MKClickConfigException( - f"'{module_path}' of type '{type(parser)}' is not derived from 'click.BaseCommand'" - ) - return parser - - -def _get_lazyload_commands(multicommand: click.MultiCommand, ctx: click.Context) -> Dict[str, click.Command]: - """Obtain click.Command references to the subcommands of a given command.""" - commands = {} - - for name in multicommand.list_commands(ctx): - command = multicommand.get_command(ctx, name) - assert command is not None - commands[name] = command - - return commands - - -def _make_header(text: str, level: int) -> str: - """Create a markdown header at a given level""" - return f"{'#' * (level + 1)} {text}" - - -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_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. `ddev meta snmp ...` - full_path = [] - current: Optional[click.Context] = ctx - while current is not None: - full_path.append(current.command.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 "```code" - yield from option_lines - yield "```" - yield "" - - -def _parse_recursively( - prog_name: str, command: click.BaseCommand, parent: click.Context = None, level: int = 0 -) -> Iterator[str]: - 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) - - # Get subcommands - lookup = getattr(ctx.command, "commands", {}) - if not lookup and isinstance(ctx.command, click.MultiCommand): - lookup = _get_lazyload_commands(ctx.command, ctx) - commands = sorted(lookup.values(), key=lambda item: item.name) - - for command in commands: - yield from _parse_recursively(command.name, command, parent=ctx, level=level + 1) - - -def _make_command_docs(prog_name: str, command: click.BaseCommand, level: int = 0) -> Iterator[str]: - for line in _parse_recursively(prog_name, command, level=level): - yield line.replace("\b", "") - - -def generate_command_docs(block_options: Dict[str, str]) -> List[str]: - """Entry point for generating Markdown doumentation for a given command.""" - - required_options = ("module", "command") - for option in required_options: - if option not in block_options: - raise MKClickConfigException( - "Parameter {} is required for mkdocs-click. Provided configuration was {}".format(option, block_options) - ) - - module_path = block_options["module"] - command = block_options["command"] - depth = int(block_options.get("depth", 0)) - command_obj = _load_command(module_path, command) - - return list(_make_command_docs(command, command_obj, level=depth)) diff --git a/requirements.txt b/requirements.txt index d4ed14d..43ee585 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ mypy # Testing mock==4.* pytest==5.* +pytest-cov diff --git a/setup.cfg b/setup.cfg index 1f1d54b..b0e9cf4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,4 +11,9 @@ disallow_untyped_defs = False check_untyped_defs = True [tool:pytest] -addopts = -rxXs +addopts = + -rxXs + --cov=mkdocs_click + --cov=tests + --cov-report=term-missing + --cov-fail-under=95 diff --git a/tests/click/__init__.py b/tests/app/__init__.py similarity index 100% rename from tests/click/__init__.py rename to tests/app/__init__.py diff --git a/tests/click/cli.py b/tests/app/cli.py similarity index 81% rename from tests/click/cli.py rename to tests/app/cli.py index 655ee3f..1dd2f69 100644 --- a/tests/click/cli.py +++ b/tests/app/cli.py @@ -4,31 +4,29 @@ import click +NOT_A_COMMAND = "not-a-command" + + @click.command() @click.option("--count", default=1, help="Number of greetings.") @click.option("--name", prompt="Your name", help="The person to greet.") def hello(count, name): """Simple program that greets NAME for a total of COUNT times.""" - for x in range(count): - click.echo("Hello %s!" % name) @click.group() def cli(): """Main entrypoint for this dummy program""" - pass @click.command() -def foo(): - """The foo command""" - pass +def foo(): # No description + pass # pragma: no cover @click.group() def bar(): """The bar command""" - pass bar.add_command(hello) diff --git a/tests/click/docs.txt b/tests/app/expected.md similarity index 94% rename from tests/click/docs.txt rename to tests/app/expected.md index ec97deb..95b47a0 100644 --- a/tests/click/docs.txt +++ b/tests/app/expected.md @@ -30,15 +30,13 @@ cli bar hello [OPTIONS] Options: -```code +``` --count INTEGER Number of greetings. --name TEXT The person to greet. ``` ## foo -The foo command - Usage: ``` diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e226200..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -# (C) Datadog, Inc. 2020-present -# All rights reserved -# Licensed under the Apache license (see LICENSE) -import mock -import pytest - - -@pytest.fixture -def generate_docs(): - with mock.patch("mkdocs_click.extension.generate_command_docs") as generate_docs: - yield generate_docs diff --git a/tests/test_extension.py b/tests/test_extension.py index 79615a9..d8652f8 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,76 +1,72 @@ # (C) Datadog, Inc. 2020-present # All rights reserved # Licensed under the Apache license (see LICENSE) -from mkdocs_click.extension import ClickProcessor - - -click_processor = ClickProcessor() - - -def test_no_options(generate_docs): - data = """ -# Some content -foo -::: mkdocs-click -bar -""".splitlines() - - expected = """ -# Some content -foo -> mocked_data -bar""".splitlines() - - generate_docs.return_value = ["> mocked_data"] - processed = click_processor.run(data) - assert processed == expected - - -def test_options(generate_docs): - data = """ -# Some content -foo -::: mkdocs-click - :option1: value1 - :optiøn2: value2 -\t:option3: - :option4:\x20 -bar -""".splitlines() - expected = """ -# Some content -foo -{'option1': 'value1', 'optiøn2': 'value2', 'option3': '', 'option4': ''} -bar -""".splitlines() - - generate_docs.side_effect = lambda mapping: [str(mapping)] - processed = click_processor.run(data) - assert processed == expected - - -def test_do_not_affect_other_blocks(generate_docs): - data = """ -# Some content -::: mkdocs-click -::: plugin1 - :option1: value1 -::: mkdocs-click - :option: value -::: plugin2 - :option2: value2 -bar -""".splitlines() - - expected = """ -# Some content -::: plugin1 - :option1: value1 -::: plugin2 - :option2: value2 -bar -""".splitlines() - - generate_docs.side_effect = lambda _: [] - processed = click_processor.run(data) - assert processed == expected # no change +from pathlib import Path +from textwrap import dedent + +import pytest +from markdown import Markdown + +import mkdocs_click + +EXPECTED = (Path(__file__).parent / "app" / "expected.md").read_text() + + +def test_extension(): + """ + Markdown output for a relatively complex Click application is correct. + """ + md = Markdown(extensions=[mkdocs_click.makeExtension()]) + + source = dedent( + """ + ::: mkdocs-click + :module: tests.app.cli + :command: cli + """ + ) + + assert md.convert(source) == md.convert(EXPECTED) + + +def test_depth(): + """ + The :depth: attribute increases the level of headers. + """ + md = Markdown(extensions=[mkdocs_click.makeExtension()]) + + source = dedent( + """ + # CLI Reference + + ::: mkdocs-click + :module: tests.app.cli + :command: cli + :depth: 1 + """ + ) + + expected = f"# CLI Reference\n\n{EXPECTED.replace('# ', '## ')}" + + assert md.convert(source) == md.convert(expected) + + +@pytest.mark.parametrize("option", ["module", "command"]) +def test_required_options(option): + """ + The module and command options are required. + """ + md = Markdown(extensions=[mkdocs_click.makeExtension()]) + + source = dedent( + """ + ::: mkdocs-click + :module: tests.app.cli + :command: cli + """ + ) + + source = source.replace(f":{option}:", ":somethingelse:") + + with pytest.raises(mkdocs_click.MkDocsClickException): + md.convert(source) diff --git a/tests/test_parser.py b/tests/test_parser.py deleted file mode 100644 index 6a6eaed..0000000 --- a/tests/test_parser.py +++ /dev/null @@ -1,44 +0,0 @@ -# (C) Datadog, Inc. 2020-present -# All rights reserved -# Licensed under the Apache license (see LICENSE) -from contextlib import nullcontext -from pathlib import Path - -import pytest - -from mkdocs_click.parser import _make_header, _make_title, _load_command, MKClickConfigException, _make_command_docs - - -def test__make_header(): - assert _make_header("foo", 0) == "# foo" - assert _make_header("foo", 3) == "#### foo" - - -def test__make_title(): - assert list(_make_title("foo", 0)) == ["# foo", ""] - assert list(_make_title("foo", 2)) == ["### foo", ""] - - -@pytest.mark.parametrize( - "module, command, exc", - [ - pytest.param("tests.click.cli", "cli", None, id="ok"), - pytest.param("tests.click.cli", "doesnotexist", MKClickConfigException, id="command-does-not-exist"), - pytest.param("doesnotexist", "cli", MKClickConfigException, id="module-does-not-exist"), - ], -) -def test__load_command(module: str, command: str, exc): - with pytest.raises(exc) if exc is not None else nullcontext(): - _load_command(module, command) - - -@pytest.mark.parametrize("level", range(6)) -def test_parse(level: int): - expected = (Path(__file__).parent / "click" / "docs.txt").read_text() - expected = expected.replace("# ", f"{'#' * (level + 1)} ") - expected = f"{expected}\n" # Include final newline. - - click_command = _load_command("tests.click.cli", "cli") - - docs = list(_make_command_docs("cli", click_command, level=level)) - assert docs == expected.splitlines() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e919e27 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2020-present +# All rights reserved +# Licensed under the Apache license (see LICENSE) diff --git a/tests/unit/test_docs.py b/tests/unit/test_docs.py new file mode 100644 index 0000000..f145777 --- /dev/null +++ b/tests/unit/test_docs.py @@ -0,0 +1,109 @@ +# (C) Datadog, Inc. 2020-present +# All rights reserved +# Licensed under the Apache license (see LICENSE) +from textwrap import dedent + +import click +import pytest + +from mkdocs_click._docs import make_command_docs +from mkdocs_click._exceptions import MkDocsClickException + + +@click.command() +@click.option("-d", "--debug", help="Include debug output") +def hello(): + """Hello, world!""" + + +HELLO_EXPECTED = dedent( + """ + # hello + + Hello, world! + + Usage: + + ``` + hello [OPTIONS] + ``` + + Options: + + ``` + -d, --debug TEXT Include debug output + ``` + + """ +).strip() + + +def test_make_command_docs(): + output = "\n".join(make_command_docs("hello", hello)).strip() + assert output == HELLO_EXPECTED + + +def test_depth(): + output = "\n".join(make_command_docs("hello", hello, level=2)).strip() + assert output == HELLO_EXPECTED.replace("# ", "### ") + + +def test_prog_name(): + output = "\n".join(make_command_docs("hello-world", hello)).strip() + assert output == HELLO_EXPECTED.replace("# hello", "# hello-world") + + +class MultiCLI(click.MultiCommand): + def list_commands(self, ctx): + return ["single-command"] + + def get_command(self, ctx, name): + return hello + + +def test_custom_multicommand(): + """ + Custom `MultiCommand` objects are supported (i.e. not just `Group` multi-commands). + """ + + multi = MultiCLI("multi", help="Multi help") + + expected = dedent( + """ + # multi + + Multi help + + Usage: + + ``` + multi [OPTIONS] COMMAND [ARGS]... + ``` + + ## hello + + Hello, world! + + Usage: + + ``` + multi hello [OPTIONS] + ``` + + Options: + + ``` + -d, --debug TEXT Include debug output + ``` + """ + ).lstrip() + + output = "\n".join(make_command_docs("multi", multi)) + assert output == expected + + +def test_custom_multicommand_name(): + """Custom multi commands must be given a name.""" + multi = MultiCLI() + with pytest.raises(MkDocsClickException): + list(make_command_docs("multi", multi)) diff --git a/tests/unit/test_loader.py b/tests/unit/test_loader.py new file mode 100644 index 0000000..f974113 --- /dev/null +++ b/tests/unit/test_loader.py @@ -0,0 +1,23 @@ +# (C) Datadog, Inc. 2020-present +# All rights reserved +# Licensed under the Apache license (see LICENSE) +from contextlib import nullcontext + +import pytest + +from mkdocs_click._exceptions import MkDocsClickException +from mkdocs_click._loader import load_command + + +@pytest.mark.parametrize( + "module, command, exc", + [ + pytest.param("tests.app.cli", "cli", None, id="ok"), + pytest.param("tests.app.cli", "doesnotexist", MkDocsClickException, id="command-does-not-exist"), + pytest.param("doesnotexist", "cli", ImportError, id="module-does-not-exist"), + pytest.param("tests.app.cli", "NOT_A_COMMAND", MkDocsClickException, id="not-a-command"), + ], +) +def test_load_command(module: str, command: str, exc): + with pytest.raises(exc) if exc is not None else nullcontext(): + load_command(module, command) diff --git a/tests/unit/test_processing.py b/tests/unit/test_processing.py new file mode 100644 index 0000000..b78b052 --- /dev/null +++ b/tests/unit/test_processing.py @@ -0,0 +1,78 @@ +# (C) Datadog, Inc. 2020-present +# All rights reserved +# Licensed under the Apache license (see LICENSE) +from mkdocs_click._processing import replace_blocks + + +def test_replace_options(): + """Replace a block with options.""" + + source = """ +# Some content +foo +::: target + :option1: value1 + :optiøn2: value2 +\t:option3: + :option4:\x20 +bar +""".strip() + + expected = """ +# Some content +foo +{'option1': 'value1', 'optiøn2': 'value2', 'option3': '', 'option4': ''} +bar +""".strip() + + output = list(replace_blocks(source.splitlines(), title="target", replace=lambda **options: [str(options)])) + assert output == expected.splitlines() + + +def test_replace_no_options(): + """Replace a block that has no options.""" + + source = """ +# Some content +foo +::: target +bar +""".strip() + + expected = """ +# Some content +foo +> mock +bar +""".strip() + + output = list(replace_blocks(source.splitlines(), title="target", replace=lambda **options: ["> mock"])) + assert output == expected.splitlines() + + +def test_other_blocks_unchanged(): + """Blocks other than the target block are left unchanged.""" + + source = """ +# Some content +::: target +::: plugin1 + :option1: value1 +::: target + :option: value +::: plugin2 + :option2: value2 +bar +""".strip() + + expected = """ +# Some content +::: plugin1 + :option1: value1 +::: plugin2 + :option2: value2 +bar +""".strip() + + output = list(replace_blocks(source.splitlines(), title="target", replace=lambda **kwargs: [])) + assert output == expected.splitlines()