Source code for tests.test_command_doc

# 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
[docs] def test_choice_metavar_rendered(): roff = render_manpage(weather) assert "\\fI[celsius|fahrenheit]\\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_optional_value_metavar_attached(): # An optional-value option (a bare --color is allowed) renders the attached # [=...] form, not a space-separated mandatory metavar. roff = render_manpage(palette) assert "\\fB\\-\\-color\\fR\\fI[=auto|always|never]\\fR" in roff assert "\\-\\-color\\fR \\fI" not in roff
[docs] def test_count_option_has_no_metavar(): # A counter takes no value, so no metavar trails its names. roff = render_manpage(palette) assert "\\fB\\-\\-verbose\\fR / \\fB\\-v\\fR" in roff assert "INTEGER" not 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] @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_render_help_rejects_unknown_format(): """An unknown format names the ones that exist instead of failing blankly.""" with pytest.raises(ValueError) as raised: render_help(weather, "yaml") assert "yaml" in str(raised.value) for known in HELP_FORMATS: assert known in str(raised.value)
[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"] == []
[docs] @pytest.mark.parametrize( "malformed", ( ["not-a-pair"], [("only-one",)], [("one", "two", "three")], [("description", 42)], ), ) def test_malformed_examples_fail_at_construction(malformed): """A bad pair surfaces on import, not on the first --help a user runs.""" with pytest.raises(TypeError): @command(examples=malformed) def broken(): """Broken."""
[docs] def test_dynamic_help_is_extracted(): """Options computing their help from the context are not left blank. ``-v`` / ``-q`` leave ``Option.help`` at None and build their sentence in ``get_help_record()``. Every backend here reads the extracted model, so the text has to be resolved once, at extraction. """ doc = json.loads(render_help(weather, "json", prog_name="weather")) options = [opt for group in doc["option_groups"] for opt in group["options"]] verbose = next(opt for opt in options if "--verbose" in opt["names"]) assert verbose["help"] assert verbose["help"].startswith("Increase the default") # Click's bracket fields belong to structured keys, not to the prose. assert "[default:" not in verbose["help"]