# 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.
from __future__ import annotations
import json
import re
import shutil
import subprocess
import pytest
from click_extra import (
Choice,
argument,
command,
command_doc as command_doc_module,
group,
man_option,
option,
option_group,
)
from click_extra.command_doc import (
HELP_FORMATS,
MAN_FORMATTERS,
OVERSTRIKE_RE,
render_help,
render_manpage,
render_manpages,
write_manpages,
)
from click_extra.commands import Group
from click_extra.testing import CliRunner
@command
@argument("city", help="The city to report on.")
@option(
"--units",
type=Choice(["celsius", "fahrenheit"]),
default="celsius",
help="Temperature scale.\b\nline one\nline two",
)
@option("--ascii/--no-ascii", "ascii_art", default=False, help="Toggle ASCII art.")
@option("--secret", hidden=True, help="should never appear")
def weather(city, units, ascii_art, secret):
"""Report the forecast for a CITY."""
@group
def station():
"""Weather station controller."""
@station.command()
def calibrate():
"""Recalibrate the sensors."""
@command
@option_group(
"Location",
option("--city", help="City to report on."),
option("--country", help="Two-letter country code."),
help="Where to read the weather.",
)
@option("--fahrenheit", is_flag=True, help="Report in Fahrenheit.")
def forecast(city, country, fahrenheit):
"""Report the forecast."""
[docs]
def test_render_manpage_header_and_sections():
roff = render_manpage(weather)
assert roff.startswith('.\\" Generated by Click Extra ')
assert "<https://github.com/kdeldycke/click-extra>" in roff
assert '.TH "WEATHER" "1"' in roff
assert ".SH NAME" in roff
assert "weather \\- Report the forecast for a CITY." in roff
assert ".SH SYNOPSIS" in roff
assert "\\fBweather\\fR \\fI[OPTIONS]\\fR \\fICITY\\fR" in roff
assert '.SH "EXIT STATUS"' in roff
[docs]
def test_option_names_are_escaped_and_bold():
roff = render_manpage(weather)
assert "\\fB\\-\\-units\\fR" in roff
@command
@option(
"--color",
is_flag=False,
flag_value="always",
type=Choice(["auto", "always", "never"]),
default="auto",
help="Colorize the output.",
)
@option("--verbose", "-v", count=True, help="Increase verbosity.")
def palette(color, verbose):
"""Render a palette."""
[docs]
def test_no_rewrap_marker_becomes_no_fill():
"""Click's ``\\b`` marker must produce a roff ``.nf`` / ``.fi`` block (click-man #9)."""
roff = render_manpage(weather)
assert ".nf" in roff
assert ".fi" in roff
assert "line one\nline two" in roff
[docs]
def test_no_rewrap_marker_does_not_leak_into_surrounding_paragraphs():
"""A ``\\b`` paragraph must not switch the whole DESCRIPTION to
preformatted mode: prose before and after stays filled, only the
marked paragraph lands between ``.nf`` / ``.fi``."""
@command
def harbor():
"""Tidal harbor report.
First paragraph that should render as filled prose.
\b
Marked block keeps its
original line breaks.
Trailing paragraph that should also render as filled prose.
"""
roff = render_manpage(harbor)
# Scope the assertions to the DESCRIPTION block: the FILES section
# uses its own ``.nf`` / ``.fi`` pair for the config-glob path so a
# roff-wide count would conflate the two regions.
description = roff.split(".SH DESCRIPTION", 1)[1].split(".SH ", 1)[0]
assert description.count(".nf") == 1
assert description.count(".fi") == 1
nf_pos = description.index(".nf")
fi_pos = description.index(".fi")
assert "First paragraph" in description[:nf_pos]
assert "Marked block" in description[nf_pos:fi_pos]
assert "Trailing paragraph" in description[fi_pos:]
[docs]
def test_name_line_keeps_full_short_help():
"""The NAME ``.SH`` line carries the canonical description without
Click's 45-char terminal truncation."""
@command
def harbor():
"""Tidal harbor report covering the whole Atlantic coastline."""
roff = render_manpage(harbor)
assert "Tidal harbor report covering the whole Atlantic coastline." in roff
# No truncation marker on the NAME line.
assert "...\n" not in roff.split(".SH SYNOPSIS")[0]
[docs]
def test_inline_literal_in_short_help_renders_as_bold():
"""Inline reST literals (``..``) in the first docstring line land on
the NAME ``.SH`` line as ``\\fB..\\fR``, not as raw backticks (which
mandoc renders as quote characters)."""
@command
def harbor():
"""Inject the ``tide_level`` reading into the daily report."""
roff = render_manpage(harbor)
# Bold rendering for the literal token.
assert "\\fBtide_level\\fR" in roff
# No raw backticks should survive in the NAME line.
name_block = roff.split(".SH SYNOPSIS")[0]
assert "``" not in name_block
[docs]
def test_inline_literal_in_description_renders_as_bold():
"""Inline reST literals in the description body become ``\\fB..\\fR``
so mandoc's HTML output shows them in bold rather than wrapped in
Unicode quote characters."""
@command
def harbor():
"""Tidal report.
The flag ``--tide`` toggles ``high_tide`` injection.
"""
roff = render_manpage(harbor)
assert "\\fB\\-\\-tide\\fR" in roff
assert "\\fBhigh_tide\\fR" in roff
# Raw backticks must not survive in the description body.
description = roff.split(".SH DESCRIPTION")[1].split(".SH ", 1)[0]
assert "``" not in description
[docs]
def test_boolean_flag_renders_both_spellings():
"""``--ascii`` / ``--no-ascii`` must both appear (click-man #41)."""
roff = render_manpage(weather)
assert "\\fB\\-\\-ascii\\fR / \\fB\\-\\-no\\-ascii\\fR" in roff
[docs]
def test_hidden_option_skipped():
assert "secret" not in render_manpage(weather)
[docs]
def test_option_groups_become_subsections():
"""An explicit option group renders as a roff ``.SS`` subsection of OPTIONS,
with the ungrouped remainder gathered under ``Other options``."""
roff = render_manpage(forecast)
assert '.SS "Location"' in roff
# The group's own help renders under its heading.
assert "Where to read the weather." in roff
assert "\\fB\\-\\-city\\fR" in roff
# Ungrouped options (the --fahrenheit flag plus the injected defaults) land
# under the default group, after the explicit one.
assert '.SS "Other options"' in roff
assert "\\fB\\-\\-fahrenheit\\fR" in roff
assert roff.index('.SS "Location"') < roff.index('.SS "Other options"')
[docs]
def test_ungrouped_command_has_no_subsections():
"""A command with no explicit option group keeps a flat OPTIONS list."""
roff = render_manpage(weather)
assert ".SH OPTIONS" in roff
assert ".SS" not in roff
[docs]
def test_operand_documented():
roff = render_manpage(weather)
assert "The city to report on." in roff
[docs]
def test_render_manpages_tree_filenames():
pages = render_manpages(station, prog_name="station")
assert "station.1" in pages
assert "station-calibrate.1" in pages
[docs]
def test_subcommand_page_uses_full_path():
pages = render_manpages(station, prog_name="station")
assert "\\fBstation calibrate\\fR" in pages["station-calibrate.1"]
[docs]
def test_hidden_command_skipped():
@group
def cli():
"""Root."""
@cli.command(hidden=True)
def buried():
"""Hidden command."""
assert "cli-buried.1" not in render_manpages(cli, prog_name="cli")
[docs]
def test_dynamic_subcommand_discovered():
"""Dynamically-resolved subcommands must be generated (click-man #14 / #56)."""
@command(name="probe")
def probe():
"""A dynamically resolved command."""
class DynamicGroup(Group):
def list_commands(self, ctx):
return [*super().list_commands(ctx), "probe"]
def get_command(self, ctx, name):
if name == "probe":
return probe
return super().get_command(ctx, name)
@group(cls=DynamicGroup)
def sensors():
"""Sensor hub."""
pages = render_manpages(sensors, prog_name="sensors")
assert "sensors-probe.1" in pages
assert "A dynamically resolved command." in pages["sensors-probe.1"]
[docs]
def test_environment_section_deduplicated():
"""A shared env var (``--config`` / ``--no-config``) appears only once."""
@command
def app():
"""An app with default options."""
roff = render_manpage(app)
assert ".SH ENVIRONMENT" in roff
assert roff.count("\\fBAPP_CONFIG\\fR") == 1
[docs]
def test_files_section_from_config_option():
@command
def app():
"""An app with a --config option."""
roff = render_manpage(app)
assert ".SH FILES" in roff
# Portable, home-relative search pattern (never an absolute home path).
assert "~" in roff
assert "pyproject.toml" in roff
[docs]
def test_version_and_authors_overrides():
roff = render_manpage(
weather, version="9.9.9", authors="Jane Roe", copyright="Copyright 2026."
)
assert '"9.9.9"' in roff
assert ".SH AUTHORS" in roff
assert "Jane Roe" in roff
assert ".SH COPYRIGHT" in roff
assert "Copyright 2026." in roff
[docs]
def test_copyright_omitted_by_default():
assert ".SH COPYRIGHT" not in render_manpage(weather)
[docs]
def test_source_date_epoch_is_honored(monkeypatch):
monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000")
assert '"2023-11-14"' in render_manpage(weather)
[docs]
def test_write_manpages(tmp_path):
written = write_manpages(station, tmp_path, prog_name="station")
names = {path.name for path in written}
assert "station.1" in names
assert "station-calibrate.1" in names
for path in written:
assert path.read_text(encoding="utf-8").startswith('.\\" Generated')
[docs]
@pytest.mark.skipif(
not any(shutil.which(tool[0]) for tool in MAN_FORMATTERS),
reason="No man page typesetter installed.",
)
def test_man_option_reads_the_manual():
"""``--man`` typesets the page for reading, the way ``man`` itself does."""
@command
@man_option
def greet():
"""Greet the world."""
result = CliRunner().invoke(greet, ["--man"], color=False)
assert result.exit_code == 0
# Typeset output, not the roff that produced it. Drop the emphasis first:
# groff underlines the page header, which no plain substring would match,
# while mandoc leaves it alone.
plain = OVERSTRIKE_RE.sub("", result.stdout)
assert ".TH" not in plain
assert "GREET(1)" in plain
assert "Greet the world." in plain
[docs]
def test_man_option_falls_back_to_the_source(monkeypatch):
"""With no typesetter installed, the source beats an error."""
monkeypatch.setattr(command_doc_module.shutil, "which", lambda tool: None)
@command
@man_option
def greet():
"""Greet the world."""
result = CliRunner().invoke(greet, ["--man"], color=False)
assert result.exit_code == 0
assert ".TH" in result.stdout
assert "greet \\- Greet the world." in result.stdout
assert "No man page typesetter found" in result.stderr
[docs]
@pytest.mark.skipif(
not any(shutil.which(tool[0]) for tool in MAN_FORMATTERS),
reason="No man page typesetter installed.",
)
def test_accessible_manual_drops_overstrike():
"""Accessible mode strips the emphasis a screen reader would voice as noise."""
@command
def greet():
"""Greet the world."""
plain = CliRunner().invoke(greet, ["--man"], color=False)
assert re.search(r".\x08", plain.stdout)
accessible = CliRunner().invoke(greet, ["--accessible", "--man"], color=False)
assert accessible.exit_code == 0
assert not re.search(r".\x08", accessible.stdout)
assert "NAME" in accessible.stdout
[docs]
@pytest.mark.skipif(shutil.which("groff") is None, reason="groff not installed")
@pytest.mark.parametrize(
("cli", "prog_name"),
(
(station, "station"),
(forecast, "forecast"),
),
)
def test_generated_roff_passes_groff_lint(cli, prog_name):
"""Every generated page must parse cleanly under groff (no warnings).
``forecast`` exercises the ``.SS`` option-group subsections.
"""
for filename, roff in render_manpages(cli, prog_name=prog_name).items():
proc = subprocess.run(
["groff", "-man", "-ww", "-z", "-Tutf8"],
input=roff,
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, f"{filename}: {proc.stderr}"
assert not proc.stderr.strip(), f"{filename}: {proc.stderr}"
# --- Machine-readable formats -----------------------------------------------
@command(
examples=[
("Report the forecast in Fahrenheit", "weather Oslo --units fahrenheit"),
("Report the default forecast", "weather Oslo"),
]
)
@argument("city", help="The city to report on.")
@option("--units", type=Choice(["celsius", "fahrenheit"]), help="Temperature scale.")
def documented(city, units):
"""Report the forecast for a CITY."""
[docs]
def test_json_carries_every_section():
"""The JSON document covers what the man page covers, as native types."""
doc = json.loads(render_help(documented, "json", prog_name="weather"))
assert doc["name"] == "weather"
assert doc["short_help"] == "Report the forecast for a CITY."
assert doc["synopsis"].startswith("weather ")
assert doc["arguments"] == [{"metavar": "CITY", "help": "The city to report on."}]
assert doc["examples"][0] == {
"description": "Report the forecast in Fahrenheit",
"command": "weather Oslo --units fahrenheit",
}
options = [opt for group in doc["option_groups"] for opt in group["options"]]
units = next(opt for opt in options if "--units" in opt["names"])
assert units["help"] == "Temperature scale."
assert units["metavar"] == "[celsius|fahrenheit]"
assert units["required"] is False
[docs]
def test_json_lists_subcommands_by_name_only():
"""Progressive disclosure: children are named, never recursively expanded."""
doc = json.loads(render_help(station, "json", prog_name="station"))
assert {
"name": "calibrate",
"short_help": "Recalibrate the sensors.",
} in doc["subcommands"]
# Named, never expanded: no child carries its own options or children.
assert all(set(sub) == {"name", "short_help"} for sub in doc["subcommands"])
[docs]
def test_json_full_walks_the_whole_tree():
"""The -full variant is the one that expands every command of the tree."""
doc = json.loads(render_help(station, "json-full", prog_name="station"))
names = [command_doc["name"] for command_doc in doc["commands"]]
assert names[0] == "station"
assert "station calibrate" in names
# Unlike the plain variant, every entry is a whole document.
assert all("option_groups" in command_doc for command_doc in doc["commands"])
[docs]
def test_markdown_renders_every_section():
"""The Markdown document carries the same sections as headings."""
md = render_help(documented, "markdown", prog_name="weather")
assert md.startswith("# weather\n")
for heading in ("## Synopsis", "## Description", "## Arguments", "## Options"):
assert f"\n{heading}\n" in md
assert "- `CITY`: The city to report on." in md
assert "- `--units [celsius|fahrenheit]`: Temperature scale." in md
assert "$ weather Oslo --units fahrenheit" in md
[docs]
def test_markdown_full_walks_the_whole_tree():
"""Every command of the tree gets its own title in one document."""
md = render_help(station, "markdown-full", prog_name="station")
assert "# station\n" in md
assert "# station calibrate\n" in md
[docs]
def test_no_rewrap_marker_keeps_its_shape_in_markdown():
"""A `\\b` region is fenced rather than reflowed into a paragraph."""
md = render_help(weather, "markdown", prog_name="weather")
assert " ```text\n line one\n line two\n ```" in md
[docs]
def test_examples_render_in_every_backend():
"""One `examples=` declaration feeds the help screen, roff, Markdown and JSON."""
example = "weather Oslo --units fahrenheit"
help_screen = CliRunner().invoke(documented, ["--help"], color=False).stdout
assert "Examples:" in help_screen
assert f"$ {example}" in help_screen
# roff escapes the option dashes, so match the rendered spelling.
roff = render_help(documented, "man", prog_name="weather")
assert ".SH EXAMPLES" in roff
assert "weather Oslo" in roff
assert f"$ {example}" in render_help(documented, "markdown", prog_name="weather")
doc = json.loads(render_help(documented, "json", prog_name="weather"))
assert doc["examples"][0]["command"] == example
[docs]
def test_command_without_examples_grows_no_section():
"""A command declaring none renders exactly as it did before the feature."""
assert "Examples:" not in CliRunner().invoke(weather, ["--help"]).stdout
assert ".SH EXAMPLES" not in render_help(weather, "man", prog_name="weather")
assert "## Examples" not in render_help(weather, "markdown", prog_name="weather")
doc = json.loads(render_help(weather, "json", prog_name="weather"))
assert doc["examples"] == []