# 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 shutil
import subprocess
import pytest
from click_extra import (
Choice,
argument,
command,
group,
man_option,
option,
option_group,
)
from click_extra.commands import Group
from click_extra.man_page import render_manpage, render_manpages, write_manpages
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
[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]
def test_man_option():
@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
[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}"