Source code for tests.test_docs_directives
# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Every `{click:run}` block pins the outcome of the CLI it invokes.
The directive executes its body at build time through a bare `exec()`
(`click_extra.sphinx.click_runner`), so any exception a block raises aborts
`sphinx-build`. That makes the documentation build a real gate, but only over
the blocks that raise: a block asserting nothing renders whatever the CLI
printed, and a broken invocation prints a usage error into the published page
while the build stays green. A stale option has shipped that way.
So the build cannot be the only check, and this test supplies the half it
misses: it reads the Markdown as text and requires each block to carry at
least one assertion. What that assertion pins is the author's call. An exact
`result.exit_code` is the strongest form, a containment check on
`result.stdout` still fails when the CLI errors instead of running, and either
one turns a silent page-level regression into a red build.
The check runs on text alone: no build, no network, no platform floor. It
parses the fences through {func}`click_extra.markdown.blocks.fence_spans`, which
consumes a fence as an opaque unit, so a `{click:run}` example quoted inside a
longer `code-block` fence is never mistaken for a live directive.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from click_extra.markdown.blocks import fence_spans, split_options
PROJECT_ROOT = Path(__file__).parent.parent
# Every page a documentation build renders, plus the readme, which carries the
# same directives whenever a feature is showcased there.
MARKDOWN_FILES = (
*sorted(PROJECT_ROOT.glob("*.md")),
*sorted((PROJECT_ROOT / "docs").glob("*.md")),
)
# Name of a live directive, the first word of its opening line once the
# backticks are stripped: an argument may follow it, like the lexer of
# `{click:run} sql`. A `{click:source}` block only defines a CLI, so it asserts
# nothing by design.
RUN_DIRECTIVE = "{click:run}"
[docs]
def run_blocks(path: Path) -> list[tuple[int, str]]:
"""Return each `{click:run}` body in `path`, as `(line number, source)`.
The leading `:option:` lines the directive consumes are dropped, so the
remainder parses as plain Python. Line numbers are 1-based, naming the
opening fence.
"""
lines = path.read_text(encoding="UTF-8").splitlines()
blocks = []
for start, span in sorted(fence_spans(lines).items()):
if lines[start].strip().lstrip("`").split(maxsplit=1)[:1] != [RUN_DIRECTIVE]:
continue
_options, body = split_options(lines[span.body])
blocks.append((start + 1, "\n".join(body)))
return blocks
[docs]
def test_markdown_files_are_collected():
"""The glob feeding the check still finds the documentation."""
assert len(MARKDOWN_FILES) > 40, "documentation pages went missing"
[docs]
def test_run_directives_are_collected():
"""The fence parser still recognizes the directives it is pointed at."""
total = sum(len(run_blocks(path)) for path in MARKDOWN_FILES)
assert total > 300, f"only {total} {RUN_DIRECTIVE} blocks found"
[docs]
@pytest.mark.parametrize(
"path",
MARKDOWN_FILES,
ids=lambda path: str(path.relative_to(PROJECT_ROOT)),
)
def test_run_directives_assert_their_outcome(path):
"""No `{click:run}` block renders its CLI's output unchecked."""
unchecked = []
for number, body in run_blocks(path):
location = f"{path.relative_to(PROJECT_ROOT)}:{number}"
try:
tree = ast.parse(body)
except SyntaxError as error:
pytest.fail(f"{location} does not parse as Python: {error}")
if not any(isinstance(node, ast.Assert) for node in ast.walk(tree)):
unchecked.append(location)
assert not unchecked, (
"These {} blocks assert nothing, so a broken invocation renders its "
"usage error into the page while the build stays green:\n {}".format(
RUN_DIRECTIVE,
"\n ".join(unchecked),
)
)