Source code for click_extra.theme_docs

# 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.
"""Render a theme palette as an inline-styled HTML fragment for documentation.

:func:`palette_html` is called from ``docs/theme.md`` to render every
built-in theme's palette at Sphinx build time.
:func:`inject_slot_example_docstring` is registered as a Sphinx
``autodoc-process-docstring`` hook from ``docs/conf.py`` to inject a colored
example into each HelpTheme slot's autodoc block. This is build-time
documentation code, kept out of the runtime theme module.
"""

from __future__ import annotations

import dataclasses
import html
import re

from cloup._util import identity

from .styling import _ATTR_CSS, Style, _color_to_css, _rgb_to_hex
from .theme import BUILTIN_THEMES, HelpTheme

TYPE_CHECKING = False
if TYPE_CHECKING:
    from typing import Any


_PALETTE_SKIP_SLOTS: frozenset[str] = frozenset({
    # Inherited cloup slots that built-in themes leave at identity.
    "command_help",
    "section_help",
    "col1",
    "col2",
    "epilog",
    # Log-level slot intentionally left unstyled by every built-in (INFO is
    # the default verbosity and shouldn't stand out).
    "info",
    # Internal cache from cloup; never rendered.
    "_style_kwargs",
    # Boolean toggle, not a Style.
    "cross_ref_highlight",
})

_PALETTE_SWATCH = (
    '<span style="display:inline-block;width:0.85em;height:0.85em;'
    "background:{css};border:1px solid var(--color-foreground-muted,#888);"
    'border-radius:2px;vertical-align:-0.15em;margin-right:0.35em"></span>'
)

# Per-attribute inline CSS so each attribute's label visually demonstrates
# itself in the rendered swatch row (``bold`` shown in bold, ``italic``
# in italic, ``underline`` with an underline, …). Iteration order doubles
# as the visual order of the attribute pills next to each slot.
#
# Derived from the shared ``_ATTR_CSS`` source of truth in
# ``click_extra.styling`` so the documentation pills and ``Style.to_css``
# never drift. ``blink`` has no standard CSS and maps to an empty string,
# leaving its pill unstyled (no built-in theme sets it).
_PALETTE_ATTR_CSS: dict[str, str] = {
    attr: f"{prop}: {value}" if prop else ""
    for attr, (prop, value) in _ATTR_CSS.items()
}

# Slots inherited from cloup's HelpTheme. Each one links to cloup's
# autoapi-generated docs (which expose per-field anchors), instead of
# click-extra's local HelpTheme autodoc anchor (which only covers the
# slots HelpTheme adds on top of cloup's base class).
_PALETTE_CLOUP_SLOTS: frozenset[str] = frozenset({
    "invoked_command",
    "command_help",
    "heading",
    "constraint",
    "section_help",
    "col1",
    "col2",
    "alias",
    "alias_secondary",
    "epilog",
})

# Per-slot example templates. Substrings wrapped in ``«…»`` (U+00AB / U+00BB
# guillemets, picked because they never appear in real CLI help output) are
# styled by calling ``theme.<slot>(text)`` at render time, so the example
# faithfully reflects whatever the active theme's slot produces. Each slot
# that the built-in themes actually style has an entry; unstyled inherited
# slots and the boolean ``cross_ref_highlight`` toggle are intentionally
# left out and the renderer falls back to a plain "(no example available)"
# placeholder for them.
_PALETTE_EXAMPLES: dict[str, str] = {
    # Log-level slots: rendered by Formatter on the levelname token.
    "critical": "«CRITICAL»: Database connection lost.",
    "error": "«ERROR»: Configuration file not found.",
    "warning": "«WARNING»: Requested 16 jobs exceeds available CPU cores (8).",
    "info": "INFO: Loaded 23 records.",
    "debug": "«DEBUG»: Resolved /etc/myapp/config.toml.",
    # Cloup-inherited help-screen slots: rendered by HelpFormatter on
    # the matching tokens of help-screen output.
    "invoked_command": "Usage: «my-cli» [OPTIONS] COMMAND [ARGS]...",
    "heading": "«Options:»",
    "constraint": "(«mutually exclusive»)",
    "alias": "  show, «ls»     Show the current state.",
    "alias_secondary": "  show «(»ls«)»     Show the current state.",
    # HelpTheme-native help-screen slots.
    "option": (
        "«--config» CONFIG_PATH    Location of the configuration file.\n"
        "«-v», «--verbose»           Increase verbosity."
    ),
    "subcommand": (
        "Commands:\n"
        "  «backup»        Snapshot the data store.\n"
        "  «restore»       Restore from a snapshot."
    ),
    "choice": ("--format [«json»|«csv»|«xml»]   Output format. Defaults to «json»."),
    "metavar": (
        "--output «TEXT»       Destination file.\n--workers «INTEGER»   Worker count."
    ),
    # The single wide marker covers the entire bracket field because
    # ``bracket`` is the runtime fallback for every inner slot (``envvar``,
    # ``default``, ``required``, ``range_label``) when those slots are left
    # at ``identity``. A theme that only sets ``bracket`` therefore colors
    # the whole field (structural tokens and value tokens alike) with the
    # same style. See ``_bracket_or`` in ``highlight.py`` and
    # ``test_bracket_field_inner_slot_fallback_to_bracket`` in the test suite.
    "bracket": "--port INTEGER    «[env var: PORT; default: 8080; required]»",
    "envvar": (
        "--threshold INTEGER   Acceptable error rate.\n"
        "                      [env var: «THRESHOLD, TEST_THRESHOLD»]"
    ),
    "default": (
        "--output FILENAME    Destination file.  [default: «out.csv»]\n"
        "--retries INTEGER    Retry budget.  [default: «5»]"
    ),
    "range_label": (
        "--level INTEGER RANGE    Verbosity level.  [«0<=x<=9»]\n"
        "--port INTEGER RANGE     Bind port.        [«x>=1024»]"
    ),
    "required": "--token TEXT    Authentication token.  [«required»]",
    "argument": (
        "Usage: cp [OPTIONS] «SRC» «DST»\nUsage: pack [OPTIONS] «[FILES]...» «[OUTPUT]»"
    ),
    "deprecated": (
        "--old-api    Legacy endpoint. «(DEPRECATED: use --new-api instead)»\n"
        "--legacy     Kept for compatibility. «(deprecated: removed in v9)»"
    ),
    "search": "--«retry»-budget INTEGER    The «retry» budget.",
    "success": "«✓» database migration completed\n«✓» 1,245 records imported",
    "subheading": (
        "«◼ 3 mails sharing hash a1b2c3d4»\n«◼ 7 mails sharing hash e5f6a7b8»"
    ),
}

# Matches a single ``«…»`` segment for the slot-example renderer.
_PALETTE_EXAMPLE_RE: re.Pattern[str] = re.compile("«([^»]+)»")

_PALETTE_CLOUP_URL = "https://cloup.readthedocs.io/en/stable/autoapi/cloup/index.html"


def _palette_slot_link(name: str) -> str:
    """Wrap a slot name in an anchor pointing at its dataclass-field definition.

    Cloup-inherited slots resolve to a per-field anchor on cloup's autoapi
    page (like ``#cloup.HelpTheme.invoked_command``); HelpTheme-native
    slots resolve to the local autodoc anchor on the same page that hosts
    the palette (``docs/theme.md``), so the link works inside click-extra's
    own documentation without an external roundtrip.
    """
    if name in _PALETTE_CLOUP_SLOTS:
        href = f"{_PALETTE_CLOUP_URL}#cloup.HelpTheme.{name}"
    else:
        href = f"#click_extra.theme.HelpTheme.{name}"
    return f'<a href="{href}"><code>{name}</code></a>'


def _render_slot_ansi(theme: HelpTheme, slot: str) -> str:
    """Render a slot's example template with literal ANSI SGR escapes.

    For each ``«…»`` segment, calls ``theme.<slot>(text)`` (the same code
    path click-extra uses to style real help-screen output at runtime)
    and splices the resulting escape-bearing string into the template.
    Used by the Sphinx ``autodoc-process-docstring`` hook in
    :func:`inject_slot_example_docstring` to inject a colored example into
    each :class:`~click_extra.theme.HelpTheme` slot's autodoc block, mirroring the
    HTML rendering :func:`_render_slot_example` produces for the
    palette tables.

    Returns an empty string when the slot has no template; callers treat
    that as "skip" rather than emitting an empty code block.
    """
    template = _PALETTE_EXAMPLES.get(slot)
    if template is None:
        return ""
    style = getattr(theme, slot)
    return _PALETTE_EXAMPLE_RE.sub(
        lambda m: style(m.group(1)) if callable(style) else m.group(1),
        template,
    )


[docs] def inject_slot_example_docstring( app: Any, what: str, name: str, obj: Any, options: Any, lines: list[str], ) -> None: """Sphinx ``autodoc-process-docstring`` hook injecting per-slot colored examples. For every :class:`~click_extra.theme.HelpTheme` slot that has an entry in ``_PALETTE_EXAMPLES``, append an ``ansi-color`` code block to the slot's autodoc lines. The example is rendered through ``_render_slot_ansi``, which calls ``BUILTIN_THEMES["dark"].<slot>(text)`` to obtain the actual ANSI escapes click-extra would emit at runtime. Wire this up from a Sphinx ``conf.py`` with: .. code-block:: python from click_extra.theme_docs import inject_slot_example_docstring def setup(app): app.connect("autodoc-process-docstring", inject_slot_example_docstring) The hook intentionally targets *only* ``click_extra.theme.HelpTheme.<slot>`` names so it won't accidentally rewrite unrelated docstrings; downstream projects can register the hook in their own ``conf.py`` if they consume HelpTheme docstrings. """ prefix = "click_extra.theme.HelpTheme." if not name.startswith(prefix): return slot = name[len(prefix) :] # Skip the example when themes.toml is absent: there is no built-in # "dark" palette to render the slot with. default_theme = BUILTIN_THEMES.get("dark") if default_theme is None: return rendered = _render_slot_ansi(default_theme, slot) if not rendered: return lines.append("") lines.append("Rendered with the default ``dark`` theme:") lines.append("") lines.append(".. code-block:: ansi-color") lines.append("") lines.extend(f" {ansi_line}" for ansi_line in rendered.split("\n"))
def _render_slot_example(theme: HelpTheme, slot: str) -> str: """Render a slot's example template as inline-styled HTML. Looks up the slot's template in :data:`_PALETTE_EXAMPLES`, then for every ``«…»``-marked segment calls ``theme.<slot>(text)`` to obtain the actual styling click-extra would apply at runtime. The styled bytes are converted to an inline CSS ``<span>`` via :meth:`Style.to_css`, so the rendered HTML faithfully reflects whatever the theme's slot produces, including the dim/italic/bold attribute mix and the foreground color. Returns an empty string when the slot has no example template (cloup slots that built-ins leave at identity, the boolean toggle, …); the palette renderer treats that as "no example row" rather than emitting an empty cell. """ template = _PALETTE_EXAMPLES.get(slot) if template is None: return "" style = getattr(theme, slot) css = style.to_css() if isinstance(style, Style) else "" parts: list[str] = [] last = 0 for match in _PALETTE_EXAMPLE_RE.finditer(template): # Plain text between the previous segment and this match. parts.append(html.escape(template[last : match.start()])) token = html.escape(match.group(1)) if css: parts.append(f'<span style="{css}">{token}</span>') else: # Slot is identity (or a non-Style callable we can't introspect): # emit the token plain, matching the runtime no-op behavior. parts.append(token) last = match.end() parts.append(html.escape(template[last:])) return ( '<pre class="slot-example" style="margin:0.3em 0 0;' "padding:0.4em 0.6em;background:var(--color-code-background,#f5f5f5);" "border-radius:4px;font-size:0.85em;line-height:1.4;" 'white-space:pre-wrap">' + "".join(parts) + "</pre>" )
[docs] def palette_html(theme: HelpTheme) -> str: """Render a theme's palette as an inline-styled HTML ``<dl>`` fragment. The output is a two-column definition list (slot name → styled swatch plus attribute decorations) safe to inject into MyST or reST host documents via the ``python:render`` Sphinx directive (or any ``raw:: html`` block). Used by ``docs/theme.md`` to render every built-in theme's palette at Sphinx build time without hand-maintaining swatch tables. Downstream projects with their own custom themes can call the same helper to get matching swatch listings in their own docs: .. code-block:: markdown ```{python:render} from click_extra.theme_docs import palette_html from my_app.themes import MY_THEME print(palette_html(MY_THEME)) ``` Slots that hold ``identity`` (no styling applied), the boolean :attr:`~click_extra.theme.HelpTheme.cross_ref_highlight` toggle, the internal ``_style_kwargs`` cache, and a handful of inherited cloup slots that built-ins never style are skipped: every emitted row corresponds to a real palette choice in the theme. """ rows: list[str] = [] for f in dataclasses.fields(theme): if f.name in _PALETTE_SKIP_SLOTS: continue value = getattr(theme, f.name) if value is identity or value is None: continue fg = getattr(value, "fg", None) cell_parts: list[str] = [] if fg is not None: css = _color_to_css(fg) if isinstance(fg, tuple) and len(fg) == 3: color_label = _rgb_to_hex(fg) else: color_label = str(fg) cell_parts.append( _PALETTE_SWATCH.format(css=css) + f"<code>{color_label}</code>" ) attrs = [ f'<span style="{css}">{name}</span>' for name, css in _PALETTE_ATTR_CSS.items() if getattr(value, name, None) ] if attrs: cell_parts.append(" ".join(attrs)) # Tack the styled example onto the <dd> body when one exists. # Each example is rendered by replaying ``theme.<slot>(text)`` on # the marked segments of ``_PALETTE_EXAMPLES[slot]``, so the visual # is computed from the same code path that styles real help-screen # output at runtime: no hand-authored ANSI escapes anywhere. example_html = _render_slot_example(theme, f.name) body = " ".join(cell_parts) or "—" if example_html: body = body + example_html rows.append(f"<dt>{_palette_slot_link(f.name)}</dt><dd>{body}</dd>") return ( '<dl class="theme-palette" style="display:grid;' "grid-template-columns:max-content 1fr;gap:0.2em 1em;" 'margin:0.5em 0">' + "".join(rows) + "</dl>" )