Source code for tests.test_man_page

# 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_choice_metavar_rendered(): roff = render_manpage(weather) assert "\\fI[celsius|fahrenheit]\\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_authors_omitted_without_metadata(): """No distribution matches the command name, so AUTHORS is dropped rather than synthesized from a fallback.""" assert ".SH AUTHORS" 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}"