# 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.
"""Tests for :mod:`click_extra.sphinx.manpages`."""
from __future__ import annotations
from inspect import cleandoc
from pathlib import Path
from typing import Any
import pytest
from sphinx.application import Sphinx
from sphinx.util.docutils import docutils_namespace
from click_extra.sphinx import manpages
_HAS_RENDERER = manpages._find_renderer() is not None
"""``True`` if at least one roff β HTML renderer is on ``PATH`` and
actually produces output. Drives the ``skipif`` on tests that need the
HTML sibling to exist."""
def _write_source(
tmp_path: Path,
manpages_config: list[dict[str, Any]],
*,
index_body: str = "Hi\n==\n\nstub.\n",
extra_docs: dict[str, str] | None = None,
) -> Path:
"""Write a tiny Sphinx project that declares ``click_extra_manpages``.
Returns the source directory. Uses a CLI shipped with click-extra
(``click_extra.cli:demo``) as the target, so no external project has
to be importable for the test to run.
``index_body`` overrides the contents of ``index.rst`` for tests that
exercise directives or roles inside the page.
``extra_docs`` maps a docname to its body for tests needing a page
that is not the root document, whose own URL is where a builder's
output layout shows.
"""
srcdir = tmp_path / "source"
srcdir.mkdir()
conf = {
"master_doc": "index",
"extensions": ["click_extra.sphinx"],
"click_extra_manpages": manpages_config,
}
(srcdir / "conf.py").write_text(
"\n".join(f"{key} = {value!r}" for key, value in conf.items()),
encoding="utf-8",
)
(srcdir / "index.rst").write_text(index_body, encoding="utf-8")
for docname, body in (extra_docs or {}).items():
doc_path = srcdir / f"{docname}.rst"
doc_path.parent.mkdir(parents=True, exist_ok=True)
doc_path.write_text(body, encoding="utf-8")
return srcdir
def _run_build(srcdir: Path, outdir: Path, builder: str) -> Path:
"""Build ``srcdir`` into ``outdir`` with ``builder`` and return ``outdir``.
The doctree cache lives under ``outdir``, so calling this twice on one
``outdir`` reproduces what a project gets from two ``sphinx-build`` runs
sharing a ``-d`` directory: the second builder reuses the doctrees the
first one pickled, and no directive re-runs.
"""
outdir.mkdir(exist_ok=True)
with docutils_namespace():
app = Sphinx(
str(srcdir),
str(srcdir),
str(outdir),
str(outdir / ".doctrees"),
builder,
verbosity=0,
warning=None,
)
app.build()
return outdir
def _build_with_manpages(
tmp_path: Path,
manpages_config: list[dict[str, Any]],
*,
builder: str = "html",
index_body: str = "Hi\n==\n\nstub.\n",
extra_docs: dict[str, str] | None = None,
) -> Path:
"""Write and build a project in one call, returning the output directory."""
srcdir = _write_source(
tmp_path,
manpages_config,
index_body=index_body,
extra_docs=extra_docs,
)
return _run_build(srcdir, tmp_path / "build", builder)
[docs]
def test_manpages_hook_writes_tree_into_outdir(tmp_path):
"""An entry with just ``script`` writes the whole tree under ``man/``."""
outdir = _build_with_manpages(
tmp_path,
[{"script": "click_extra.cli:demo"}],
)
man_dir = outdir / "man"
assert man_dir.is_dir(), f"missing {man_dir}"
names = {p.name for p in man_dir.iterdir()}
# The default prog_name falls back to the resolved Click command's own
# ``name`` attribute. The ``demo`` group declares ``name="click-extra"``,
# so the root page is ``click-extra.1`` and each subcommand hyphenates
# from there (``click-extra-wrap.1``, ``click-extra-colors.1``...).
assert "click-extra.1" in names, names
assert "click-extra-wrap.1" in names, names
[docs]
def test_manpages_hook_honors_prog_name_and_output_dir(tmp_path):
"""An entry can override both the basename and the subdirectory."""
outdir = _build_with_manpages(
tmp_path,
[
{
"script": "click_extra.cli:demo",
"prog_name": "demo",
"output_dir": "share/man/man1",
}
],
)
target = outdir / "share" / "man" / "man1"
assert target.is_dir(), f"missing {target}"
assert (target / "demo.1").is_file()
# Pick a known subcommand. ``wrap`` is part of the click-extra demo group.
assert (target / "demo-wrap.1").is_file()
[docs]
def test_manpages_hook_skips_non_html_builder(tmp_path):
"""Non-HTML builders (like ``linkcheck``) must not emit man pages."""
outdir = _build_with_manpages(
tmp_path,
[{"script": "click_extra.cli:demo"}],
builder="linkcheck",
)
# The hook is gated on the builder name so nothing under outdir/man/
# should have been created.
assert not (outdir / "man").exists()
[docs]
def test_manpages_hook_empty_config_is_noop(tmp_path):
"""An empty list leaves the build untouched."""
outdir = _build_with_manpages(tmp_path, [])
assert not (outdir / "man").exists()
[docs]
def test_manpages_hook_skips_entry_without_script(tmp_path):
"""An entry missing ``script`` is skipped (logged as a warning) instead
of aborting the build."""
outdir = _build_with_manpages(
tmp_path,
[{"prog_name": "demo"}],
)
# No man pages produced (the entry was unusable), but the build still
# finished and wrote the index page Sphinx normally writes.
assert not (outdir / "man").exists()
assert (outdir / "index.html").is_file()
[docs]
def test_manpages_module_help_documents_config_shape():
"""The module docstring spells out every supported key.
A safety net against silent removal: distributors and downstream
projects depend on the docstring as the canonical reference, since
it is what ``help(click_extra.sphinx.manpages)`` prints.
"""
body = cleandoc(manpages.__doc__ or "")
for key in ("script", "prog_name", "output_dir", "render_html"):
assert key in body, f"missing documentation for {key!r}"
[docs]
@pytest.mark.skipif(
not _HAS_RENDERER,
reason="needs mandoc or groff on PATH to render the HTML siblings",
)
def test_manpages_hook_emits_html_siblings(tmp_path):
"""When a renderer is available, every ``.1`` gets a ``.1.html`` next
to it whose body carries the section headings from the source roff."""
outdir = _build_with_manpages(
tmp_path,
[{"script": "click_extra.cli:demo"}],
)
man_dir = outdir / "man"
root_roff = man_dir / "click-extra.1"
root_html = man_dir / "click-extra.1.html"
assert root_roff.is_file()
assert root_html.is_file()
body = root_html.read_text(encoding="utf-8")
# mandoc emits per-section <h1> blocks; groff -Thtml emits <h2>. Either
# way the section title appears verbatim in the page so a simple "in"
# check is portable across renderers.
assert "NAME" in body
assert "SYNOPSIS" in body
assert "OPTIONS" in body
# Every subcommand page gets an HTML sibling too.
assert (man_dir / "click-extra-wrap.1.html").is_file()
[docs]
def test_manpages_hook_respects_render_html_opt_out(tmp_path):
"""``render_html=False`` skips the HTML pass even when a renderer is
present, keeping the build to roff only."""
outdir = _build_with_manpages(
tmp_path,
[{"script": "click_extra.cli:demo", "render_html": False}],
)
man_dir = outdir / "man"
assert (man_dir / "click-extra.1").is_file()
# No .html sibling, regardless of mandoc availability.
assert not any(man_dir.glob("*.html"))
_INDEX_WITH_DIRECTIVE = """\
Hi
==
.. click-extra-manpages::
"""
"""``index.rst`` body that hosts a single bare ``click-extra-manpages``
directive call. Used by the directive tests to render the auto-generated
link list into ``index.html``."""
[docs]
def test_manpages_directive_renders_one_link_per_command(tmp_path):
"""The directive emits a bullet list with one entry per (sub)command
of every script declared in ``click_extra_manpages``."""
outdir = _build_with_manpages(
tmp_path,
# render_html=False keeps the test independent of mandoc
# availability: the directive points at the .html siblings
# regardless of whether they were actually written.
[{"script": "click_extra.cli:demo", "render_html": False}],
index_body=_INDEX_WITH_DIRECTIVE,
)
body = (outdir / "index.html").read_text(encoding="utf-8")
# One link per page the emit hook would have written: the root
# command plus every visible subcommand.
assert 'href="man/click-extra.1.html"' in body
assert 'href="man/click-extra-wrap.1.html"' in body
assert 'href="man/click-extra-prebake.1.html"' in body
assert 'href="man/click-extra-prebake-all.1.html"' in body
# The visible label uses the ``name(section)`` convention so it
# reads naturally next to the short-help suffix.
assert "click-extra(1)" in body
assert "click-extra-wrap(1)" in body
_NESTED_INDEX = "Hi\n==\n\n.. toctree::\n\n guide/manuals\n"
"""``index.rst`` body pulling a nested ``guide/manuals`` page into the
toctree. Hosting the directive on that nested page is what separates the
builder layouts: the root document renders at the site root under all of
them, so it cannot catch a link computed from the docname alone."""
_BUILDER_LAYOUTS = (
# `html` publishes `guide/manuals.html`, one directory into the site,
# so `man/` sits one level up.
("html", Path("guide/manuals.html"), "../"),
# `dirhtml` publishes the same page as `guide/manuals/index.html`,
# one directory deeper again.
("dirhtml", Path("guide/manuals/index.html"), "../../"),
# `singlehtml` folds every document into one root page, so the links
# resolve from the site root no matter how deep the docname is.
("singlehtml", Path("index.html"), ""),
)
"""Each HTML-family builder, the file it publishes ``guide/manuals`` into,
and the prefix its layout requires on a link to ``man/``."""
[docs]
@pytest.mark.parametrize(("builder", "page_file", "prefix"), _BUILDER_LAYOUTS)
def test_manpages_directive_links_follow_the_builder_layout(
tmp_path, builder, page_file, prefix
):
"""Links resolve from wherever the builder publishes the page."""
outdir = _build_with_manpages(
tmp_path,
[{"script": "click_extra.cli:demo", "render_html": False}],
builder=builder,
index_body=_NESTED_INDEX,
extra_docs={"guide/manuals": _INDEX_WITH_DIRECTIVE},
)
body = (outdir / page_file).read_text(encoding="utf-8")
assert f'href="{prefix}man/click-extra.1.html"' in body
assert f'href="{prefix}man/click-extra-wrap.1.html"' in body
[docs]
@pytest.mark.parametrize(("builder", "page_file", "prefix"), _BUILDER_LAYOUTS)
def test_manpages_directive_links_survive_a_shared_doctree_cache(
tmp_path, builder, page_file, prefix
):
"""A second builder reusing pickled doctrees still gets its own layout.
Sphinx does not invalidate its doctree cache when the builder changes, so
the directive never re-runs on the second pass. A link resolved at parse
time would keep the first builder's layout and point at nothing.
"""
srcdir = _write_source(
tmp_path,
[{"script": "click_extra.cli:demo", "render_html": False}],
index_body=_NESTED_INDEX,
extra_docs={"guide/manuals": _INDEX_WITH_DIRECTIVE},
)
outdir = tmp_path / "build"
# Warm the cache with every other builder before the one under test, so
# each layout is exercised as the follower of all the others.
for warmup, _page_file, _prefix in _BUILDER_LAYOUTS:
if warmup != builder:
_run_build(srcdir, outdir, warmup)
_run_build(srcdir, outdir, builder)
body = (outdir / page_file).read_text(encoding="utf-8")
assert f'href="{prefix}man/click-extra.1.html"' in body
assert f'href="{prefix}man/click-extra-wrap.1.html"' in body
[docs]
def test_manpages_directive_is_noop_when_config_empty(tmp_path):
"""An empty config means no bullet list, no warning, build still
finishes."""
outdir = _build_with_manpages(
tmp_path,
[],
index_body=_INDEX_WITH_DIRECTIVE,
)
body = (outdir / "index.html").read_text(encoding="utf-8")
assert (outdir / "index.html").is_file()
# No anchors pointing at man/* because the directive had nothing to
# enumerate. The rest of index.html still renders.
assert 'href="man/' not in body
[docs]
def test_manpages_directive_renders_inline_literals_as_code(tmp_path):
"""Inline reST literals in a command's short_help land as <code>
spans in the rendered index, not as raw backticks rendered like
quotes."""
outdir = _build_with_manpages(
tmp_path,
# The click-extra prebake-all subcommand has the short help
# ``Pre-bake __version__ and all git fields in one pass.``,
# which is what triggered the original bug report.
[{"script": "click_extra.cli:demo", "render_html": False}],
index_body=_INDEX_WITH_DIRECTIVE,
)
body = (outdir / "index.html").read_text(encoding="utf-8")
# The literal token rendered as a <code> span. The raw markers (the
# paired backticks) must not appear next to ``__version__`` in the
# output.
assert "<code" in body
assert "__version__" in body
assert "``__version__``" not in body