Source code for click_extra.highlight
# 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.
"""Help-screen keyword highlighting and the colorized help formatter.
Hosts the engine that collects highlightable keywords from a Click context
({class}`HelpKeywords`, `_HelpColorsMixin`) and renders them with the
active theme: {class}`HelpFormatter` styles `--help` output and
{func}`highlight` applies a styling function to arbitrary matches. Split out of
{mod}`click_extra.color`, which now focuses on `--color`/`--no-color`
resolution.
"""
from __future__ import annotations
import inspect
import re
from dataclasses import dataclass, field, fields
from enum import Enum
from functools import lru_cache
import click
import cloup
from click.core import _format_deprecated_label
from cloup._util import identity
from . import theme as _theme
from .styling import open_ansi
from .theme import HelpTheme, ThemeChoice
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Collection, Iterable
from typing import ClassVar
from cloup.styling import IStyle
DEPRECATED_RE = re.compile(r"\(deprecated(?::\s[^)]+)?\)", re.IGNORECASE)
"""Matches `(DEPRECATED)` and `(DEPRECATED: reason)` markers, whatever their case.
The canonical spelling comes from Click's shared `_format_deprecated_label`
helper; the case-insensitive flag also catches the variants a CLI author writes
by hand in a help string.
Read by the help screen through {attr}`HelpFormatter._deprecated_re` and by
{func}`~click_extra.tree.render_command_tree`, so one marker is painted the same
wherever a command's description is drawn.
"""
[docs]
@dataclass
class HelpKeywords:
"""Structured collection of keywords extracted from a Click context for
help screen highlighting.
Each field corresponds to a semantic category with its own styling.
"""
cli_names: set[str] = field(default_factory=set)
subcommands: set[str] = field(default_factory=set)
command_aliases: set[str] = field(default_factory=set)
arguments: set[str] = field(default_factory=set)
long_options: set[str] = field(default_factory=set)
short_options: set[str] = field(default_factory=set)
choices: set[str] = field(default_factory=set)
choice_metavars: set[str] = field(default_factory=set)
metavars: set[str] = field(default_factory=set)
envvars: set[str] = field(default_factory=set)
defaults: set[str] = field(default_factory=set)
deprecated: set[str] = field(default_factory=set)
"""Markers a CLI paints with the `deprecated` slot beside Click's own.
{data}`DEPRECATED_RE` covers the spellings Click writes, and only those. A
project marking a parameter in its own vocabulary (`(unmaintained)` for a
tool whose upstream is gone, where the option itself is not being retired)
otherwise gets no color for it, since the word is the whole pattern.
Each entry is matched literally, so a marker is written the way it reads on
the screen, punctuation included.
"""
[docs]
def merge(self, other: HelpKeywords) -> None:
"""Merge another `HelpKeywords` into this one.
Each set field is updated with the corresponding set from `other`.
"""
for f in fields(self):
getattr(self, f.name).update(getattr(other, f.name))
[docs]
def subtract(self, other: HelpKeywords) -> None:
"""Remove keywords found in `other` from this instance.
Each set field is difference-updated with the corresponding set from
`other`. Mirror of {meth}`merge`.
"""
for f in fields(self):
getattr(self, f.name).difference_update(getattr(other, f.name))
class _HelpColorsMixin:
"""Adds extra-keywords highlighting to Click commands.
This mixin for `click.Command`-like classes intercepts the top-level helper-
generation method to initialize the formatter with dynamic settings. This is
implemented at this stage so we have access to the global context.
"""
#: Extra keywords to merge into the auto-collected set. Consumers can set
#: this attribute on a command instance to inject additional keywords for
#: help screen highlighting (like placeholder option names such as
#: `--<manager-id>` that appear in prose but are not real parameters).
extra_keywords: HelpKeywords | None = None
#: Keywords to remove from the auto-collected set. Mirror of
#: {attr}`extra_keywords`: any string listed here will not be highlighted
#: even if it was collected from the Click context.
excluded_keywords: HelpKeywords | None = None
def collect_keywords(self, ctx: click.Context) -> HelpKeywords:
"""Parse click context to collect option names, choices and metavar keywords.
Override this method to customize keyword collection. Call `super()` and
mutate the returned `HelpKeywords` to extend the default set.
"""
kw = HelpKeywords()
subcommand_objs: set[click.Command] = set()
# Includes the full command path and each ancestor name, so that
# individual components are highlighted even when interleaved with
# options (like "repomatic --table-format github sync-uv-lock").
if ctx.command_path:
kw.cli_names.add(ctx.command_path)
ancestor: click.Context | None = ctx
while ancestor:
if ancestor.info_name:
kw.cli_names.add(ancestor.info_name)
ancestor = ancestor.parent
command = ctx.command
# Will fetch command's metavar (the "[OPTIONS]" after the CLI name in
# "Usage:") and dig into subcommands to get subcommand_metavar:
# ("COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...").
kw.metavars.update(command.collect_usage_pieces(ctx))
# Get subcommands and their aliases. Process in listed order for stable
# and predictable loading, which is important on lazy-loading.
if isinstance(command, click.Group):
for sub_id in command.list_commands(ctx):
subcommand = command.get_command(ctx, sub_id)
if not subcommand:
raise RuntimeError(f"Subcommand {sub_id!r} not found.")
kw.subcommands.add(sub_id)
kw.command_aliases.update(getattr(subcommand, "aliases", []))
# Keep reference to subcommand object for deprecated message
# collection below.
subcommand_objs.add(subcommand)
# Collect options, choices, metavars, envvars, defaults from current
# command parameters. User-defined help options (like -h, --help) are
# seeded into the options set.
options: set[str] = set(ctx.help_option_names)
# Add the option names of each direct subcommand, so an example
# invoking one ("$ basket pick --ripe") colors the options that
# invocation passes. Names only: a subcommand's choices, metavars,
# envvars and defaults would feed the cross-reference passes values
# this screen never renders, and paint them wherever the prose reuses
# the word.
for subcommand in subcommand_objs:
for param in subcommand.get_params(ctx):
if isinstance(param, click.Option) and not param.hidden:
options.update(param.opts)
options.update(param.secondary_opts)
# Static methods are qualified with the class name (not `self`) so
# `collect_keywords` can be called on commands that don't inherit the
# mixin (used by `cli_wrapper.patch_click` for third-party CLIs).
_HelpColorsMixin._collect_params(
command.get_params(ctx),
ctx,
kw,
options,
)
# Collect option names and choices from parent groups. Subcommand
# docstrings often reference parent options in usage examples (like
# "myapp --table-format github sub").
parent_ctx = ctx.parent
while parent_ctx:
for param in parent_ctx.command.get_params(parent_ctx):
if isinstance(param, click.Option) and not param.hidden:
options.update(param.opts)
options.update(param.secondary_opts)
if isinstance(param.type, (click.Choice, ThemeChoice)):
_HelpColorsMixin._collect_choice_keywords(
param,
parent_ctx,
kw,
)
elif type_choices := getattr(param.type, "choices", None):
kw.choices.update(type_choices)
kw.choice_metavars.add(param.make_metavar(ctx=parent_ctx))
parent_ctx = parent_ctx.parent
# Split options into short and long by length heuristic. Short options
# are no longer than 2 characters like "-D", "/d", "/?", "+w", "-w".
# XXX We cannot reuse the _short_opts and _long_opts attributes from
# Click's parser because their values are not passed when the context
# is updated. So we rely on simple heuristics to guess the category.
for name in options:
if len(name) <= 2:
kw.short_options.add(name)
else:
kw.long_options.add(name)
# Merge consumer-provided extra keywords. Uses `getattr` so the
# method works on commands that don't inherit the mixin.
extra_kw = getattr(self, "extra_keywords", None)
if extra_kw is not None:
kw.merge(extra_kw)
# Note: excluded_keywords is NOT applied here. It is applied later
# in highlight_extra_keywords(), after choice metavars have been
# placeholdered, so that exclusions only affect cross-ref passes.
return kw
@staticmethod
def _collect_choice_keywords(
param: click.Parameter,
ctx: click.Context,
kw: HelpKeywords,
) -> None:
"""Collect choice keywords from a `click.Choice` parameter.
When a custom metavar (like `LEVEL`) replaces the standard
`[choice1|choice2]` rendering, original-case choice strings are
collected to match developer-written prose (such as "Either CRITICAL,
ERROR, ...") without producing false-positive highlights for common
English words like "error" and "info".
"""
assert isinstance(param.type, (click.Choice, ThemeChoice))
if isinstance(param, click.Option) and param.metavar:
# Custom metavar hides the normalized choice list. Collect
# original-case values. This is the first step of Click's own
# `normalize_choice()` before case folding is applied.
kw.choices.update(
c.name if isinstance(c, Enum) else str(c) for c in param.type.choices
)
else:
# Standard metavar: collect the normalized forms that
# match what Click renders in `[choice1|choice2]`.
kw.choices.update(
param.type.normalize_choice(c, ctx) for c in param.type.choices
)
# Also collect the rendered metavar string (like
# `[json|xml|csv]`) so it can be styled and placeholdered
# before cross-ref highlighting. This protects choices that
# appear in `excluded_keywords` from losing their
# highlight inside their own metavar.
kw.choice_metavars.add(param.make_metavar(ctx=ctx))
@staticmethod
def _collect_params(
params: list[click.Parameter],
ctx: click.Context,
kw: HelpKeywords,
options: set[str],
) -> None:
"""Extract keywords from a list of parameters into `kw` and `options`."""
for param in params:
# Ignore hidden options that are not meant to be displayed.
if isinstance(param, click.Option) and param.hidden:
continue
# Only collect option names from actual Option parameters, not from
# Arguments. An Argument's opts contains the bare parameter name
# (like "keys") which would pollute the option keywords and
# interfere with highlighting of real options like "--list-keys".
if isinstance(param, click.Option):
options.update(param.opts)
options.update(param.secondary_opts)
elif isinstance(param, click.Argument):
# Collect argument metavars (like "MY_ARG") as a distinct
# category from option metavars.
metavar = param.make_metavar(ctx=ctx)
kw.arguments.add(metavar)
# A hand-written metavar can name several operands at once,
# like the `SCRIPT [ARGS]...` of a command taking a target and
# forwarding the rest. The prose then refers to one of them by
# name, so each is worth a keyword of its own. The whole string
# stays one too, and the usage line still paints it as a single
# run: `highlight` merges the spans it overlaps.
kw.arguments.update(_metavar_operands(metavar))
# Only Choice and DateTime types produce their own structured
# metavar (with delimiters like brackets and pipes). All other
# types fall back to a plain uppercased name (like TEXT, INTEGER).
if isinstance(param.type, (click.Choice, ThemeChoice)):
_HelpColorsMixin._collect_choice_keywords(param, ctx, kw)
elif isinstance(param.type, click.DateTime):
# Highlight each datetime format string as a choice.
kw.choices.update(param.type.formats)
elif type_choices := getattr(param.type, "choices", None):
# Duck-typed choice-like `click.ParamType` (such as
# {class}`click_extra.types.MultiChoice` and its subclasses):
# each accepted value is worth highlighting individually, and
# the rendered `[a,b,c]` metavar protects the brackets +
# separators from later passes. `click.Choice` subclasses are
# already handled by the branch above.
kw.choices.update(type_choices)
kw.choice_metavars.add(param.make_metavar(ctx=ctx))
elif not isinstance(param, click.Argument):
# Argument metavars are collected in the arguments set.
kw.metavars.add(param.make_metavar(ctx=ctx))
# A user-provided metavar (like `metavar="LEVEL"`) is always
# worth highlighting, even for Choice/DateTime types. One that
# enumerates its parts (`INTEGER|auto`, from a hybrid type Click
# renders nothing structured for) is styled part by part instead:
# the cross-reference pass would otherwise paint the value it
# recognizes and leave the type placeholder beside it bare.
if param.metavar and not isinstance(param, click.Argument):
if "|" in param.metavar or "," in param.metavar:
kw.choice_metavars.add(param.metavar)
else:
kw.metavars.add(param.metavar)
if param.envvar:
if isinstance(param.envvar, str):
kw.envvars.add(param.envvar)
else:
kw.envvars.update(param.envvar)
if isinstance(param, click.Option):
default_string = param.get_help_extra(ctx).get("default")
if default_string:
kw.defaults.add(default_string)
def get_help(self, ctx: click.Context) -> str:
"""Replace default formatter by our own."""
ctx.formatter_class = HelpFormatter
return super().get_help(ctx) # type: ignore[no-any-return,misc]
@staticmethod
def _collect_excluded_keywords(ctx: click.Context) -> HelpKeywords | None:
"""Merge `excluded_keywords` from the current command and all ancestors.
Mirrors the parent-context traversal that collects parent choices in
{meth}`collect_keywords`. Returns a fresh {class}`HelpKeywords` so that
no command's original `excluded_keywords` is mutated.
"""
excluded: HelpKeywords | None = None
cmd_ctx: click.Context | None = ctx
while cmd_ctx:
cmd_excluded = getattr(cmd_ctx.command, "excluded_keywords", None)
if cmd_excluded is not None:
if excluded is None:
excluded = HelpKeywords()
excluded.merge(cmd_excluded)
cmd_ctx = cmd_ctx.parent
return excluded
def format_help(self, ctx: click.Context, formatter: HelpFormatter) -> None:
"""Feed our custom formatter instance with the keywords to highlight."""
formatter.keywords = self.collect_keywords(ctx)
formatter.excluded_keywords = self._collect_excluded_keywords(ctx)
super().format_help(ctx, formatter) # type: ignore[misc]
#: Matches an operand name inside a compound metavar, ignoring the brackets and
#: ellipsis marking it optional or variadic.
_OPERAND_RE = re.compile(r"[A-Z][A-Z0-9_]*")
def _metavar_operands(metavar: str) -> set[str]:
"""Split a compound metavar into the operand names it holds.
`SCRIPT [ARGS]...` names two, and a plain `MY_ARG` names itself, which the
caller already has. Returns nothing for a single-operand metavar, so no
keyword is added twice.
"""
operands = set(_OPERAND_RE.findall(metavar))
if operands == {metavar}:
return set()
return operands
@lru_cache(maxsize=512)
def _escape_for_help_screen(text: str) -> str:
"""Prepares a string to be used in a regular expression for matches in help screen.
Applies [re.escape](https://docs.python.org/3/library/re.html#re.escape), then
accounts for long strings being wrapped on multiple lines and padded with spaces to
fit the columnar layout.
It allows for:
- additional number of optional blank characters (line-returns, spaces, tabs, ...)
after a dash, as the help renderer is free to wrap strings after a dash.
- a space to be replaced by any number of blank characters.
"""
return re.escape(text).replace("-", "-\\s*").replace("\\ ", "\\s+")
[docs]
class HelpFormatter(cloup.HelpFormatter):
"""Extends Cloup's custom HelpFormatter to highlights options, choices, metavars and
default values.
This is being discussed for upstream integration at:
- https://github.com/janluke/cloup/issues/97
- https://github.com/click-contrib/click-help-colors/issues/17
- https://github.com/janluke/cloup/issues/95
"""
theme: HelpTheme
keywords: HelpKeywords
"""Keywords to highlight, collected from the rendered command's context.
Instance state, initialized per formatter: `_HelpColorsMixin.format_help`
fills it before rendering, and {meth}`highlight_extra_keywords` mutates it
(see the `excluded_keywords` subtraction), so a shared class-level default
would leak keywords across formatters.
"""
excluded_keywords: HelpKeywords | None
"""Keywords subtracted from the cross-reference passes, or `None`."""
def __init__(self, *args, **kwargs) -> None:
"""Forces theme to the active one for the current Click context.
Also transform Cloup's standard `HelpTheme` to our own `HelpTheme`.
Resolves the active theme via {func}`click_extra.theme.get_current_theme`,
which reads the per-invocation pick from the Click context (set by
{class}`~click_extra.theme.ThemeOption`) and falls back to the module-level
default when no context is active.
"""
active_theme = _theme.get_current_theme()
theme = kwargs.get("theme", active_theme)
if not isinstance(theme, HelpTheme):
theme = active_theme.with_(**theme._asdict())
kwargs["theme"] = theme
self.keywords = HelpKeywords()
self.excluded_keywords = None
super().__init__(*args, **kwargs)
#: Matches range expressions like `0<=x<=9`, `x>=1024`, `0<=x<100`.
#:
#: Bounds use `[^\]\s]+` (not `\S+`) so a bound can't absorb the closing
#: `]` of its own field: with `\S+`, `[x>=1]` matches `1]` and the
#: enclosing bracket regex then runs on to the next `]` on screen. Keep this
#: exclusion in sync with the range branch embedded in `_bracket_re`.
_range_re: ClassVar[re.Pattern] = re.compile(
r"(?:[^\]\s]+(?:<|<=))?x(?:<|<=|>|>=)[^\]\s]+"
)
_bracket_re: ClassVar[re.Pattern] = re.compile(
r"( )" # 2 spaces (column or description spacing).
r"\[" # Opening bracket.
r"(" # Capture the bracket content.
r"(?:env\s+var:|default:|required" # Must start with a recognized label.
r"|(?:[^\]\s]+(?:<|<=))?x(?:<|<=|>|>=)[^\]\s]+)" # Or a range (see _range_re).
# Followed by any non-] characters, plus whole bracketed runs, so a
# default that is itself bracketed (`[default: [...]]`) closes on its
# own `]` instead of the first one on screen. The alternation cannot
# eat a bare `]`, so a field still stops at its own end.
r"(?:[^\]\[]|\[[^\]]*\])*"
r")"
r"\]", # Closing bracket.
re.DOTALL,
)
_sep_re: ClassVar[re.Pattern] = re.compile(r";\s+")
_envvar_re: ClassVar[re.Pattern] = re.compile(r"(env\s+var:\s+)(.*)", re.DOTALL)
_default_re: ClassVar[re.Pattern] = re.compile(r"(default:\s+)(.*)", re.DOTALL)
#: Matches a lone `--`, the POSIX end-of-options separator.
_separator_re: ClassVar[re.Pattern] = re.compile(r"(?<!\S)--(?!\S)")
#: Matches what an example line puts before the CLI name: an indent, and
#: the shell prompt a transcript is written with.
_prompt_re: ClassVar[re.Pattern] = re.compile(r"[ \t]*(?:[$>#]\s+)?")
#: The marker pattern the help screen paints, see {data}`DEPRECATED_RE`.
_deprecated_re: ClassVar[re.Pattern] = DEPRECATED_RE
def _bracket_or(self, slot_name: str) -> IStyle:
"""Return `theme.<slot_name>` or fall back to `theme.bracket`.
When a theme leaves an inner bracket-field slot (`envvar`,
`default`, `required`, `range_label`) at
{func}`identity <cloup._util.identity>`, value tokens inside the
bracket block default to the `bracket` styling rather than
rendering plain. This lets a theme set only `bracket` and get a
uniformly dim bracket field for free; richer themes layer specific
styles on top by setting the inner slots.
"""
slot: IStyle = getattr(self.theme, slot_name)
if slot is identity:
return self.theme.bracket
return slot
def _style_bracket_fields(self, match: re.Match) -> str:
"""Style a trailing `[env var: ...; default: ...; ...]` block.
Parses the bracket content by splitting on `;` separators and
matching each field by its label prefix. Applied post-wrapping because
Click's text wrapper splits lines after `get_help_record()` returns,
which would break pre-styled ANSI codes.
Inner-slot fallback: when a theme leaves `envvar` / `default` /
`required` / `range_label` at {func}`identity <cloup._util.identity>`,
the value token inherits the `bracket` styling via
:py:meth:`_bracket_or`. The bracket slot acts as the structural
default for the whole field; the other four slots override
piecemeal.
"""
prefix = match.group(1)
content = match.group(2)
# Split on semicolons, keeping the separators.
parts = re.split(r"(;\s+)", content)
styled: list[str] = []
for part in parts:
# Separator between fields.
if self._sep_re.fullmatch(part):
styled.append(self.theme.bracket(part))
# Environment variable field.
elif m := self._envvar_re.match(part):
styled.append(
self.theme.bracket(m.group(1))
+ self._bracket_or("envvar")(m.group(2))
)
# Default value field.
elif m := self._default_re.match(part):
styled.append(
self.theme.bracket(m.group(1))
+ self._bracket_or("default")(m.group(2))
)
# Required label.
elif part == "required":
styled.append(self._bracket_or("required")(part))
# Range expression.
elif self._range_re.fullmatch(part):
styled.append(self._bracket_or("range_label")(part))
# Fallback: style as generic bracket content.
else:
styled.append(self.theme.bracket(part))
return ( # type: ignore[no-any-return]
prefix + self.theme.bracket("[") + "".join(styled) + self.theme.bracket("]")
)
def _style_choice_metavar(self, metavar: str, choices: set[str]) -> str | None:
"""Style individual choices inside a choice metavar string.
Thin wrapper over {func}`style_choice_metavar`, bound to this
formatter's theme.
"""
return style_choice_metavar(metavar, choices, self.theme)
@staticmethod
def _add_placeholder(styled: str, store: dict[str, str]) -> str:
"""Register a styled fragment as a null-byte placeholder.
Returns the placeholder key. Used to protect already-styled regions
from subsequent regex passes.
"""
key = f"\x00B{len(store)}\x00"
store[key] = styled
return key
def _highlight_invoked_subcommands(
self,
help_text: str,
kw: HelpKeywords,
) -> str:
"""Paint subcommand names written as part of an invocation.
An invocation line opens on the CLI name, under an optional indent and
shell prompt, and every subcommand it names after that is styled,
whatever sits in between: `$ basket --lang fr pick --ripe` colors
`pick`.
A subcommand often carries the name of what it does, so reading a whole
line, or reading prose at all, colors the word where it is a plain
English one. The `installed` of "List installed packages." describes
the subcommand rather than running it.
"""
if not kw.cli_names:
return help_text
cli_alt = "|".join(
re.escape(name) for name in sorted(kw.cli_names, key=len, reverse=True)
)
subcommand_re = re.compile(
r"(?<![\w\-])(?:"
+ "|".join(
re.escape(name)
for name in sorted(kw.subcommands, key=len, reverse=True)
)
+ r")(?![\w\-])"
)
invocation_re = re.compile(
rf"^(?P<head>{self._prompt_re.pattern}(?:{cli_alt})(?![\w\-]))"
r"(?P<tail>.*)$",
re.MULTILINE,
)
def style_tail(match: re.Match[str]) -> str:
return match.group("head") + subcommand_re.sub(
lambda m: self.theme.subcommand(m.group()),
match.group("tail"),
)
return invocation_re.sub(style_tail, help_text)
[docs]
def highlight_extra_keywords(self, help_text: str) -> str:
"""Highlight extra keywords in help screens based on the theme.
Uses the `highlight()` function for all keyword categories. Each
category is processed as a batch of regex patterns with a single styling
function, which handles overlapping matches and prevents double-styling.
"""
kw = self.keywords
# Highlight deprecated messages. Uses a case-insensitive regex to catch
# both Click-native "(DEPRECATED)" markers and manually-added variants
# like "(Deprecated)" in help strings. A CLI's own markers ride in the
# same pass rather than a second one, so an entry overlapping Click's
# spelling is painted once instead of twice.
help_text = highlight(
help_text,
[
self._deprecated_re,
*(
re.compile(re.escape(marker))
for marker in sorted(kw.deprecated, key=len, reverse=True)
),
],
self.theme.deprecated,
)
# Highlight subcommand names in the list a group draws, where each
# entry gets a column of its own at a two-space indent. The other
# place a name appears, an invocation line, is prose, so it rides
# with the cross-reference passes below.
if kw.subcommands:
help_text = highlight(
help_text,
(
re.compile(rf"(?<= ){re.escape(name)}(?=\s)")
for name in sorted(kw.subcommands, key=len, reverse=True)
),
self.theme.subcommand,
)
# Style the `--` end-of-options separator. Whitespace on each side is
# what tells the separator apart from an option name, so `--help` and
# a `--` inside a word are both left alone.
help_text = self._separator_re.sub(
lambda match: self.theme.separator(match.group()),
help_text,
)
# Style command aliases and their parenthetical punctuation, like
# "(lock, freeze, snapshot)". The whole group is rebuilt through
# Cloup's own `format_subcommand_aliases`, the canonical consumer of
# the `alias` / `alias_secondary` theme slots, so help screens and
# the `--tree` view render aliases identically. The group only
# matches when every comma-separated word is a known alias, so a
# parenthetical in prose that merely contains an alias is left alone.
if kw.command_aliases:
alias_alt = "|".join(
re.escape(name)
for name in sorted(kw.command_aliases, key=len, reverse=True)
)
alias_group_re = re.compile(
rf"\((?P<aliases>(?:{alias_alt})(?:, (?:{alias_alt}))*)\)"
)
def restyle_alias_group(match: re.Match) -> str:
return cloup.Group.format_subcommand_aliases(
match.group("aliases").split(", "),
self.theme,
)
help_text = alias_group_re.sub(restyle_alias_group, help_text)
# Style trailing bracket fields [env var: ...; default: ...; ...].
# This must happen post-wrapping because Click's text wrapper splits
# lines after get_help_record() returns, which would break pre-styled
# ANSI codes.
#
# To prevent cross-reference highlighting from restyling keywords that
# appear inside bracket field content (such as a choice value like
# "outline" within a default value "rounded-outline"), we replace each
# styled bracket field with a null-byte placeholder, run all cross-ref
# passes on the placeholder text, then restore the styled fields.
bracket_placeholders: dict[str, str] = {}
def _bracket_to_placeholder(match: re.Match) -> str:
return self._add_placeholder(
self._style_bracket_fields(match), bracket_placeholders
)
help_text = self._bracket_re.sub(_bracket_to_placeholder, help_text)
# Style and placeholder choice metavars (like `[json|xml|csv]`)
# before applying excluded_keywords and running cross-ref passes.
# This ensures that choices excluded from cross-ref highlighting
# (like "version") are still highlighted inside their own metavar.
for metavar_str in kw.choice_metavars:
styled = self._style_choice_metavar(metavar_str, kw.choices)
if styled is None:
continue
pattern = re.compile(_escape_for_help_screen(metavar_str))
help_text = pattern.sub(
lambda m, s=styled: self._add_placeholder(s, bracket_placeholders), # type: ignore[misc]
help_text,
)
# Apply excluded_keywords after metavar placeholdering so that
# exclusions only affect the cross-ref passes below.
if self.excluded_keywords is not None:
kw.subtract(self.excluded_keywords)
# The remaining passes search free-form text (descriptions, docstrings)
# for option names, choices, arguments, metavars and CLI names.
# Cross-reference highlighting can be disabled via the theme to avoid
# over-interpretation in help text that references external identifiers.
if self.theme.cross_ref_highlight:
# Highlight subcommands invoked in an example line. Runs before
# the CLI names it reads are styled, since the pattern anchors on
# the bare name.
if kw.subcommands:
help_text = self._highlight_invoked_subcommands(help_text, kw)
# Highlight CLI names and commands. A path of several words is
# split the way the usage line splits it: the command it ends on
# is the one a parent screen lists under `subcommand`.
if kw.cli_names:
help_text = highlight(
help_text,
(
re.compile(rf"(?<=\s){re.escape(name)}(?=\s)")
for name in sorted(kw.cli_names, key=len, reverse=True)
),
self._style_command_path,
)
all_options = sorted(
kw.long_options | kw.short_options, key=len, reverse=True
)
if all_options:
# Paint a numeric value spelled out beside the option it feeds,
# like the `1` of "--jobs 1 for sequential execution". A number
# is the one value shape that cannot be read as prose, so the
# pass stops there: the word after an option name is a value in
# "--jobs max" and an ordinary verb in "--wrap routes it".
option_alt = "|".join(re.escape(name) for name in all_options)
option_value_re = re.compile(
rf"(?<![\w\-])(?:{option_alt})(?:\s+|=)"
r"(?P<value>\d+(?:\.\d+)?)(?![\w\-])"
)
def style_option_value(match: re.Match[str]) -> str:
value = match.group("value")
return match.group()[: -len(value)] + self.theme.choice(value)
help_text = option_value_re.sub(style_option_value, help_text)
# Highlight options (long and short combined). Per-keyword
# lookbehind excludes the option's own leading symbol to prevent
# matching repeated prefixes (for example, "---debug" should not
# match "--debug").
help_text = highlight(
help_text,
(
re.compile(
rf"(?<=[^\w{re.escape(kw[0])}])"
rf"{_escape_for_help_screen(kw)}"
rf"(?=[^\w\-])"
)
for kw in all_options
),
self.theme.option,
)
# Highlight other keywords, which are expected to be separated by
# any character but word characters.
for keywords, style_func in (
# Arguments before metavars: argument names like MY_ARG are a
# subset of metavars, so highlighting them first with a distinct
# style takes priority.
(kw.arguments, self.theme.argument),
# Choices are already featured in metavars, so we process them
# before metavars to avoid double-highlighting.
(kw.choices, self.theme.choice),
(kw.metavars, self.theme.metavar),
):
if keywords:
# Transform keywords into regex patterns.
patterns = (
# Negative lookbehind rejects matches preceded by:
# - a word character (\w),
# - a dot: "pyproject.toml" (\.),
# - a hyphen: "rounded-outline" (\-),
# - a slash: "https://github.com" (\/),
# - an exclamation mark: "[!WARNING]" (!),
# - an ANSI escape: already-styled text (\x1b).
# Negative lookahead rejects matches followed by:
# - a word character (\w),
# - a hyphen: "github-actions" (\-).
re.compile(
rf"(?<![\w\.\x1b\-/!])"
rf"{_escape_for_help_screen(keyword)}"
rf"(?![\w\-])"
)
for keyword in sorted(keywords, reverse=True)
)
help_text = highlight(
content=help_text,
patterns=patterns,
styling_func=style_func,
)
# Restore styled bracket fields.
for key, styled in bracket_placeholders.items():
help_text = help_text.replace(key, styled)
return help_text
def _style_command_path(self, path: str) -> str:
"""Paint a command path, naming its last component a subcommand.
A root CLI is one word and keeps `invoked_command` whole. Shared by the
usage line and the cross-reference pass, so `my-cli pick` reads the same
wherever a screen writes it.
"""
parent, separator, subcommand = path.rpartition(" ")
if not separator:
return self.theme.invoked_command(path)
return (
self.theme.invoked_command(parent)
+ separator
+ self.theme.subcommand(subcommand)
)
[docs]
def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None:
"""Draw the usage line, naming the command the same way the rest does.
Reimplements `cloup.HelpFormatter.write_usage`, which paints the whole
command path with the `invoked_command` slot. A subcommand's path ends
on a name the parent screen lists under `subcommand`, and the same word
in two colors across two screens reads as two different things.
"""
prefix = self.theme.heading("Usage:" if prefix is None else prefix) + " "
click.HelpFormatter.write_usage(
self, self._style_command_path(prog), args, prefix
)
[docs]
def write_command_help_text(self, cmd: click.Command) -> None:
"""Draw the command's description, with Click's deprecation label.
Reimplements `cloup.HelpFormatter.write_command_help_text`, which prefixes
`(Deprecated) ` to the description. Click 8.2.0 moved that marker to a
suffix built by `_format_deprecated_label`, and gave `deprecated` a `str`
form carrying a reason the prefix has nowhere to put. See
https://github.com/janluke/cloup/issues/211.
Reusing Click's own helper keeps one marker across the whole screen: the
options already carry it through `Option.get_help_record`, and
{attr}`HelpFormatter._deprecated_re` highlights that spelling.
"""
help_text = cmd.help or ""
if help_text:
help_text = inspect.cleandoc(help_text).partition("\f")[0]
if cmd.deprecated:
label = _format_deprecated_label(cmd.deprecated)
help_text = f"{help_text} {label}" if help_text else label
if help_text:
self.write_paragraph()
with self.indentation():
self.write_text(help_text, style=self.theme.command_help)
[docs]
def getvalue(self) -> str:
"""Wrap original `Click.HelpFormatter.getvalue()` to force extra-colorization on
rendering."""
help_text = super().getvalue()
return self.highlight_extra_keywords(help_text)
[docs]
def style_choice_metavar(
metavar: str,
choices: Collection[str],
theme: HelpTheme,
) -> str | None:
"""Style each part of a metavar that enumerates the values it accepts.
Takes a rendered metavar like `{json|xml|csv}` (a required `click.Choice`
operand), `[json|xml|csv]` (an optional one) or `[id,spec,value]` (Click
Extra's multi-pick `MultiChoice`), and paints every known value with
`theme.choice`. A part that is not one is a type placeholder, like the
`INTEGER` of a hybrid `[auto|max|INTEGER]`, and takes `theme.metavar`.
Shared by the help screen and by
{func}`~click_extra.tree.render_command_tree`, so one operand reads the same
wherever it is drawn.
:param metavar: the rendered metavar to style.
:param choices: the values the parameter accepts.
:param theme: the theme whose `choice` and `metavar` slots paint the parts.
:return: the styled metavar, or `None` when it enumerates nothing.
"""
wrappers = {"[": "]", "{": "}"}
closing = wrappers.get(metavar[:1])
if closing is not None and metavar.endswith(closing):
opening, inner = metavar[:1], metavar[1:-1]
elif "|" in metavar or "," in metavar:
# A hybrid type is free to declare its enumeration unwrapped, like the
# `INTEGER|auto` of a width that also takes a keyword. The separator is
# what makes it one, so the parts are painted with no brackets to read.
opening = closing = ""
inner = metavar
else:
return None
# Read the separator off the metavar itself: a pipe for a pick-one
# `click.Choice`, a comma for a multi-pick `MultiChoice`.
sep = "|" if "|" in inner else ","
parts = inner.split(sep)
styled = [
theme.choice(part) if part in choices else theme.metavar(part) for part in parts
]
return opening + sep.join(styled) + closing
[docs]
def highlight(
content: str,
patterns: Iterable[str | re.Pattern] | str | re.Pattern,
styling_func: Callable,
ignore_case: bool = False,
) -> str:
"""Highlights parts of the `content` that matches `patterns`.
Takes care of overlapping parts within the `content`, so that the styling function
is applied only once to each contiguous range of matching characters.
```{todo}
Support case-folding, so we can have the `Straße` string matching the
`Strasse` content.
This could be tricky as it messes with string length and characters index, which
our logic relies on.
.. danger::
Roundtrip through lower-casing/upper-casing is a can of worms, because some
characters change length when their case is changed:
- [Unicode roundtrip-unsafe characters](https://gist.github.com/rendello/4d8266b7c52bf0e98eab2073b38829d9)
- [Unicode codepoints expanding or contracting on case changes](https://gist.github.com/rendello/d37552507a389656e248f3255a618127)
```
"""
# Normalize input to a set of patterns.
if isinstance(patterns, (str, re.Pattern)):
pattern_list = {patterns}
else:
pattern_list = set(patterns)
# Character spans flagged for highlighting, as (start, end) intervals.
spans: list[tuple[int, int]] = []
# Normalize patterns into regular expressions and find matches.
for pattern in pattern_list:
# Pattern is already a compiled regex.
if isinstance(pattern, re.Pattern):
regex = pattern
# Treat as literal string and escape for regex.
elif isinstance(pattern, str):
regex = re.compile(re.escape(pattern), re.IGNORECASE if ignore_case else 0)
else:
raise TypeError(f"Unsupported pattern type: {pattern!r}")
# Force IGNORECASE flag if not already compiled with it.
if ignore_case and not (regex.flags & re.IGNORECASE):
regex = re.compile(regex.pattern, regex.flags | re.IGNORECASE)
# Find all matches, including overlapping ones. Because re.search()
# returns only the first match, we skip ahead one character past the
# start of each match to find overlapping occurrences.
start_pos = 0
while start_pos < len(content):
match = regex.search(content, start_pos)
if not match:
break
start_idx = match.start()
end_idx = match.end()
# Skip zero-length matches (like those from pure lookbehind/lookahead).
if start_idx >= end_idx:
start_pos = start_idx + 1
continue
spans.append((start_idx, end_idx))
start_pos = start_idx + 1
if not spans:
return content
# Coalesce overlapping and touching spans, so the styling function is
# applied once per contiguous run of matched characters, exactly as if the
# matches had been unioned character by character.
spans.sort()
merged: list[list[int]] = [list(spans[0])]
for start, end in spans[1:]:
if start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
# Stitch the result back: unmatched gaps verbatim, matched runs styled.
# A styled run closes with a reset, which also closes whatever styling the
# run was sitting inside. Re-open what `content` left dangling there, else
# a keyword matched inside an already-painted region (an option name quoted
# in a deprecation message) strips the color off the rest of that region.
parts: list[str] = []
cursor = 0
opened = ""
for start, end in merged:
if start > cursor:
gap = content[cursor:start]
parts.append(gap)
opened = open_ansi(gap, opened)
raw = content[start:end]
styled = styling_func(raw)
parts.append(styled)
opened = open_ansi(raw, opened)
if opened and styled != raw:
parts.append(opened)
cursor = end
if cursor < len(content):
parts.append(content[cursor:])
return "".join(parts)