# 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 color themes for Click Extra.
Holds the :class:`HelpTheme` dataclass (pure data, no factory methods),
the :data:`nocolor_theme` constant, the process-wide fallback accessed via
:func:`get_default_theme` / :func:`set_default_theme`, the named-theme
:data:`theme_registry` plus :func:`register_theme` helper, and the
:class:`ThemeOption` that exposes ``--theme`` on every Click Extra command.
The built-in themes (``dark``, ``dracula``, ``light``, ``manpage``,
``monokai``, ``nord``, ``solarized_dark``) live in the package data file
``click_extra/themes.toml`` and are loaded at module import time via
:meth:`HelpTheme.from_dict`. ``manpage`` is a colorless theme that
shadows man-pages(7) typography (bold literals, italic replaceable); the
others apply that same bold/italic split on top of their color palettes.
Adding a new built-in theme is a one-file edit in that TOML file: no Python
needed. The same TOML schema is used for user-defined themes loaded from
configuration: see :doc:`/theme` for the user guide.
.. note::
The active theme for a CLI invocation is stored on the Click context's
``meta`` dict under :data:`click_extra.context.THEME` by
:class:`ThemeOption`. Use :func:`get_current_theme` to retrieve it: that
helper consults the active Click context first and falls back to
:func:`get_default_theme` when no context is in flight (like at import
time, in ``wrap`` patching, or in bare REPL usage). Per-invocation
context storage means concurrent invocations of the same CLI in one
process (Sphinx builds, test runners, REPLs) do not leak ``--theme``
choices into each other.
"""
from __future__ import annotations
import dataclasses
import logging
import sys
from dataclasses import dataclass
from gettext import gettext as _
from importlib import resources
from typing import cast
import click
import cloup
from cloup._util import identity
from . import color, context
from .parameters import ExtraOption
from .styling import Style, dict_to_fields, fields_to_dict
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib # type: ignore[import-not-found]
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Any
from cloup.styling import IStyle
[docs]
@dataclass(frozen=True)
class HelpTheme(cloup.HelpTheme):
"""Extends ``cloup.HelpTheme`` with slots for log levels and the
structural elements Click Extra highlights in help screens.
Each slot below documents *what* it colors. The built-in themes shipped
in :data:`~click_extra.theme.BUILTIN_THEMES` provide the visual styling
by setting the relevant slots; user-defined themes can be authored as
plain mappings and loaded via :meth:`from_dict`.
"""
# --- Log-level slots -----------------------------------------------------
# Applied by :class:`~click_extra.logging.Formatter` to ``levelname``
# before each record is emitted, so the visible level in the formatted
# message picks up the matching style.
critical: IStyle = identity
"""Style applied to the ``CRITICAL`` level name in log records.
Example: ``CRITICAL: Database connection lost.``
"""
error: IStyle = identity
"""Style applied to the ``ERROR`` level name in log records.
Example: ``ERROR: Configuration file not found.``
"""
warning: IStyle = identity
"""Style applied to the ``WARNING`` level name in log records.
Example: ``WARNING: Requested 16 jobs exceeds available CPU cores (8).``
"""
info: IStyle = identity
"""Style applied to the ``INFO`` level name in log records.
Usually left at :func:`identity <cloup._util.identity>`: ``INFO`` is the
default verbosity and shouldn't stand out from regular output.
"""
debug: IStyle = identity
"""Style applied to the ``DEBUG`` level name in log records.
Example: ``DEBUG: Resolved /etc/myapp/config.toml.``
"""
# --- Help-screen structural slots ----------------------------------------
# Applied by :class:`~click_extra.highlight.HelpFormatter` while
# rendering each command's help output. The post-wrap formatter pass
# walks the rendered help text and styles the matching tokens.
option: IStyle = identity
"""Style applied to option names (``--config``, ``-v``, ``--color/--no-color``)
wherever they appear: synopsis column, free-form descriptions, and
docstrings (when :attr:`cross_ref_highlight` is enabled).
"""
subcommand: IStyle = identity
"""Style applied to subcommand names: in a group's command list and
wherever they are referenced in prose.
"""
choice: IStyle = identity
"""Style applied to each individual value inside a :class:`click.Choice`
metavar (like ``json``, ``csv``, ``xml`` within ``[json|csv|xml]``) and
to those values referenced in option descriptions.
"""
metavar: IStyle = identity
"""Style applied to type metavars (``INTEGER``, ``TEXT``, ``PATH``,
``FILE``, ...) that follow an option name in the synopsis column.
"""
bracket: IStyle = identity
"""Style applied to the literal bracket characters and label prefixes of
trailing fields: ``[``, ``]``, ``default:``, ``env var:``, and the field
separators between them. Also acts as the **fallback** for the four
inner bracket-field slots (:attr:`envvar`, :attr:`default`,
:attr:`required`, :attr:`range_label`) whenever any of them is left at
:func:`identity <cloup._util.identity>`. A theme that only sets
``bracket`` therefore renders the whole bracket field with a single
uniform style; richer themes layer specific colors on top by setting
the inner slots.
"""
envvar: IStyle = identity
"""Style applied to environment-variable values inside ``[env var: ...]``
bracket fields, and to envvar names mentioned in option descriptions.
Falls back to :attr:`bracket` when left at
:func:`identity <cloup._util.identity>`, so a theme that only styles
``bracket`` still gets a consistent rendering inside bracket fields.
"""
default: IStyle = identity
"""Style applied to the default-value content inside ``[default: ...]``
bracket fields. Falls back to :attr:`bracket` when left at
:func:`identity <cloup._util.identity>`.
"""
range_label: IStyle = identity
"""Style applied to range expressions (``0<=x<=9``, ``x>=1024``,
``0<=x<100``) that appear inside bracket fields for ``IntRange`` and
``FloatRange`` options. Falls back to :attr:`bracket` when left at
:func:`identity <cloup._util.identity>`.
"""
required: IStyle = identity
"""Style applied to the ``required`` label inside bracket fields on
mandatory options. Falls back to :attr:`bracket` when left at
:func:`identity <cloup._util.identity>`.
"""
argument: IStyle = identity
"""Style applied to argument metavars (positional parameter names like
``MY_ARG``, ``SCRIPT``, ``[FILENAMES]...``) in the synopsis column and
when referenced in prose.
"""
deprecated: IStyle = identity
"""Style applied to ``(DEPRECATED)`` / ``(Deprecated: reason)`` markers
appended to options and commands.
"""
search: IStyle = identity
"""Style applied to substring matches in :command:`<cli> help --search`
output, so users can spot where their query matched.
"""
success: IStyle = identity
"""Style applied to success glyphs in pre-rendered UI elements (the ``β``
in :data:`~click_extra.theme.OK_GLYPH`) and any text passed through this
slot by downstream code.
"""
cross_ref_highlight: bool = True
"""Highlight options, choices, arguments, metavars and CLI names in
free-form text (descriptions, docstrings).
When ``False``, only structural elements are styled: bracket fields
(``[default: ...]``, ``[env var: ...]``, ranges, ``[required]``),
deprecated messages, and subcommand names in definition lists.
"""
subheading: IStyle = identity
"""Style for sub-section headings inside log output or inline help.
Distinct from ``heading`` (which styles the top-level help-screen
section titles): :attr:`subheading` is intended for downstream code that
wants a second styling level for its own narrative output.
.. seealso::
Used by `mail-deduplicate
<https://github.com/kdeldycke/mail-deduplicate/blob/main/mail_deduplicate/deduplicate.py>`_
to style ``βΌ N mails sharing hash β¦`` log lines.
"""
[docs]
def with_( # type: ignore[override]
self,
**kwargs: IStyle | bool | None,
) -> HelpTheme:
"""Derives a new theme from the current one, with some styles overridden.
Returns the same instance if the provided styles are the same as the current.
"""
# Check for unrecognized arguments.
unrecognized_args = set(kwargs).difference(self.__dataclass_fields__)
if unrecognized_args:
raise TypeError(
f"Got unexpected keyword argument(s): {', '.join(unrecognized_args)}"
)
# List of styles that are different from the base theme.
new_styles = {
field_id: new_style
for field_id, new_style in kwargs.items()
if new_style != getattr(self, field_id)
}
if new_styles:
return dataclasses.replace(self, **new_styles) # type: ignore[arg-type]
# No new styles, return the same instance.
return self
@staticmethod
def _encode_slot(field: Any, value: Any) -> Any:
"""Encode a slot value for :meth:`to_dict`.
:class:`~click_extra.styling.Style` instances become their
:meth:`Style.to_dict <click_extra.styling.Style.to_dict>` mapping;
``cross_ref_highlight``'s ``bool`` passes through as-is. Anything
else (an opaque ``IStyle`` callable that isn't a :class:`Style`)
raises :class:`TypeError` since those cannot be serialized.
"""
if isinstance(value, Style):
return value.to_dict()
if field.name == "cross_ref_highlight":
return value
raise TypeError(
f"Cannot serialize HelpTheme.{field.name}: "
f"{value!r} is not a Style instance."
)
@staticmethod
def _decode_slot(field: Any, raw: Any) -> Any:
"""Decode a slot value for :meth:`from_dict`.
Mappings become :class:`~click_extra.styling.Style` instances via
:meth:`Style.from_dict <click_extra.styling.Style.from_dict>`;
``cross_ref_highlight`` is coerced to ``bool``; anything else
raises :class:`TypeError`.
"""
if field.name == "cross_ref_highlight":
return bool(raw)
if isinstance(raw, dict):
return Style.from_dict(raw)
raise TypeError(
f"Cannot deserialize HelpTheme.{field.name}: "
f"{raw!r} is neither a mapping nor a recognized scalar."
)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize the theme to a plain dict suitable for TOML/JSON/YAML.
Each :class:`~click_extra.styling.Style` slot is emitted via
:meth:`Style.to_dict <click_extra.styling.Style.to_dict>`. Slots left
at their default (``identity`` or
``None``) are omitted, so the output only carries what the theme
actually overrides. Pair with :meth:`from_dict` to round-trip.
:raises TypeError: when a slot holds an opaque ``IStyle`` callable
that is not a :class:`~click_extra.styling.Style` (those cannot be
serialized).
"""
return fields_to_dict(self, encode=self._encode_slot)
[docs]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> HelpTheme:
"""Build a theme from the plain dict produced by :meth:`to_dict`.
Each value is interpreted by field type: a mapping becomes a
:class:`~click_extra.styling.Style` via
:meth:`Style.from_dict <click_extra.styling.Style.from_dict>`, while
``cross_ref_highlight`` is read as a plain ``bool``. Unknown keys
raise :class:`TypeError` so typos surface immediately.
"""
return cls(**dict_to_fields(cls, data, decode=cls._decode_slot))
[docs]
def cascade(self, base: HelpTheme) -> HelpTheme:
"""Layer this theme's set slots on top of *base*.
Mirrors :meth:`Style.cascade <click_extra.styling.Style.cascade>` at
the slot level: this theme's non-default slots win, *base* fills the
rest. Useful for layering a sparse override (typically parsed from a
config file's ``[tool.<cli>.themes.<name>]`` table) on top of a
full built-in palette.
:raises TypeError: when *base* is not a :class:`HelpTheme`.
"""
if not isinstance(base, HelpTheme):
raise TypeError(
f"Cannot cascade onto {type(base).__name__}: not a HelpTheme."
)
merged = {**base.to_dict(), **self.to_dict()}
return type(self).from_dict(merged)
LITERAL_STYLES: frozenset[str] = frozenset({
"invoked_command",
"subcommand",
"alias",
"alias_secondary",
"option",
"choice",
})
r"""Names of the :class:`HelpTheme` slots that color *literal* tokens:
text the user types verbatim on the command line.
Covers the command and subcommand names, their aliases, option flags
(``--config``, ``-v``), and the concrete values of a :class:`click.Choice`
(``json`` in ``[json|csv|xml]``).
These map to the **bold** font of the man-pages(7) typographic convention,
which sets text "typed literally" in bold (``\fB`` in roff) "even in the
SYNOPSIS section". Paired with :data:`REPLACEABLE_STYLES`: the two are
disjoint, and every remaining slot is an annotation, prose, or chrome style
that the literal/replaceable dichotomy does not classify (log levels,
``[default: ...]`` / ``[env var: ...]`` fields, headings, ...).
.. note::
Every built-in theme applies this classification: literal slots render
bold and :data:`REPLACEABLE_STYLES` italic, mirroring a man page even in
the color palettes. ``tests/test_theme.py`` enforces the invariant, and
the ``manpage`` built-in theme is its pure-monochrome embodiment
(bold/italic, no color). A man-page generator can reuse the same two sets
to map each styled token to ``\fB`` / ``\fI``. See :doc:`/benchmark` for
the man-page generation gap.
"""
REPLACEABLE_STYLES: frozenset[str] = frozenset({
"metavar",
"argument",
})
r"""Names of the :class:`HelpTheme` slots that color *replaceable* tokens:
placeholders the user substitutes with a real value.
Covers type metavars on options (``INTEGER``, ``CONFIG_PATH``) and positional
argument metavars (``SOURCE``, ``[FILENAMES]...``).
These map to the *italic* font of the man-pages(7) convention, which sets
replaceable arguments in italic (``\fI`` in roff). See :data:`LITERAL_STYLES`
for the bold counterpart and the full rationale.
"""
nocolor_theme: HelpTheme = HelpTheme()
"""Color theme for Click Extra to force no colors.
All style slots default to ``identity``, so styling
calls return the raw text unchanged.
"""
_default_theme: HelpTheme = nocolor_theme
"""Process-wide fallback theme. See :func:`get_default_theme`.
Initialized to :data:`nocolor_theme` here, then reassigned to the ``dark``
built-in theme at the bottom of this module once :file:`themes.toml` is loaded.
"""
[docs]
def get_default_theme() -> HelpTheme:
"""Return the process-wide fallback theme.
Read by :func:`get_current_theme` when no Click context is active or
when the active context has no theme set. The default is the built-in
``dark`` palette; :func:`~click_extra.cli_wrapper.patch_click` overrides it
via :func:`set_default_theme` for the duration of a patched session.
Resolved through a function rather than a module attribute so callers
always observe the current value: capturing ``default_theme`` as a
default function parameter (the previous pattern) would freeze whatever
was set at import time.
"""
return _default_theme
[docs]
def set_default_theme(theme: HelpTheme) -> None:
"""Override the process-wide fallback theme.
:class:`ThemeOption` writes its picked theme to ``ctx.meta`` rather
than calling this helper, so per-invocation choices do not leak across
invocations sharing the same process. Use this only for genuinely
process-wide overrides: :func:`~click_extra.cli_wrapper.patch_click` is the
canonical caller.
"""
global _default_theme
_default_theme = theme
[docs]
def get_current_theme() -> HelpTheme:
"""Return the theme active for the current CLI invocation.
Resolution order:
1. The theme stored on the active Click context under
:data:`click_extra.context.THEME` (set by :class:`ThemeOption`
from ``--theme``).
2. The process-wide fallback returned by :func:`get_default_theme`
(the dark default, or whatever :func:`~click_extra.cli_wrapper.patch_click`
set at process start).
Falling back through the active context (instead of reading a module
attribute) keeps ``--theme`` scoped to the invocation that received it,
so a second invocation in the same process starts from the default
again.
"""
ctx = click.get_current_context(silent=True)
if ctx is not None:
active = context.get(ctx, context.THEME)
if active is not None:
return cast("HelpTheme", active)
return _default_theme
theme_registry: dict[str, HelpTheme] = {}
"""Process-wide registry of named themes used by :class:`ThemeOption`.
Each entry maps a theme name to its :class:`HelpTheme` instance.
Built-in themes are seeded here at module load time from
:data:`BUILTIN_THEMES` (loaded from ``click_extra/themes.toml``).
Use :func:`register_theme` to add your own at import time, *or* declare
them in your CLI's config file under ``[tool.<cli>.themes.<name>]``: the
latter goes through :class:`ConfigOption <click_extra.config.option.ConfigOption>`,
lands on ``ctx.meta`` (see :data:`click_extra.context.THEME_OVERRIDES`),
and never mutates this module-level dict, so per-invocation choices don't
leak between sibling invocations sharing the same process.
"""
[docs]
def register_theme(name: str, theme: HelpTheme) -> None:
"""Register a named theme in the module-level
:data:`~click_extra.theme.theme_registry`.
:param name: Lowercase identifier used as the ``--theme`` choice value.
:param theme: A :class:`HelpTheme` instance.
"""
theme_registry[name] = theme
[docs]
def get_theme_registry(
ctx: click.Context | None = None,
) -> dict[str, HelpTheme]:
"""Return the theme registry visible to *ctx*.
Merges the module-level :data:`theme_registry` with any per-invocation
overrides stored on ``ctx.meta`` under
:data:`click_extra.context.THEME_OVERRIDES`. Per-invocation entries win
on key collisions, which is what lets a config file's
``[tool.<cli>.themes.dark]`` table override the built-in ``dark`` palette
for one invocation without touching the global registry.
When *ctx* is ``None`` or has no overrides, returns a copy of the
module-level registry.
"""
merged: dict[str, HelpTheme] = dict(theme_registry)
if ctx is not None:
overrides = context.get(ctx, context.THEME_OVERRIDES)
if overrides:
merged.update(overrides)
return merged
AUTO_THEME: str = "auto"
"""Reserved ``--theme`` value resolving the palette from the terminal background.
Unlike the named built-in palettes, ``auto`` is not a registry entry: it is a
directive handled by :class:`ThemeOption`, which calls :func:`resolve_auto_theme`
to pick ``dark`` or ``light`` from the detected background, mirroring
``--color=auto``. Kept out of the ``--help`` metavar and shell completion (which
only advertise registered palettes), so a CLI that does not opt into auto
detection keeps an unchanged help screen, yet accepted by
:meth:`ThemeChoice.convert` so ``--theme=auto`` works on every Click Extra CLI.
"""
[docs]
def resolve_auto_theme(
ctx: click.Context | None = None,
query_background: bool = False,
) -> HelpTheme | None:
"""Pick a built-in palette from the detected terminal background.
Resolves the background mode via
:func:`~click_extra.color.resolve_background` and maps ``"dark"`` /
``"light"`` to the same-named theme in the registry visible to *ctx*
(built-ins plus any ``[tool.<cli>.themes.<name>]`` config overlays). An
undetected background falls back to ``"dark"``, preserving Click Extra's
default.
:param ctx: context whose theme registry is consulted.
:param query_background: when true, allow the live OSC 11 terminal query
(:func:`~click_extra.color.query_osc_background`) on top of the
environment-variable signals. Off by default because the query reads
stdin.
:return: the chosen :class:`HelpTheme`, or ``None`` when neither the
detected palette nor the ``dark`` fallback is registered (the
``themes.toml`` data file was dropped). A ``None`` leaves ``ctx.meta``
untouched so :func:`get_current_theme` keeps the no-color default.
"""
registry = get_theme_registry(ctx)
mode = color.resolve_background(allow_query=query_background)
for name in (mode, "dark"):
if name is not None and name in registry:
return registry[name]
return None
[docs]
def themes_from_config(
table: dict[str, Any],
) -> dict[str, HelpTheme]:
"""Build a ``{name: HelpTheme}`` mapping from a ``[tool.<cli>.themes]`` sub-tree.
For each entry, build a :class:`HelpTheme` via :meth:`HelpTheme.from_dict`. If
*name* matches an existing key in :data:`theme_registry`, the new theme
is layered on top via :meth:`HelpTheme.cascade` so partial overrides
(like just one slot) inherit the rest from the built-in palette.
Stand-alone names produce theme instances with the unset slots left at
their defaults.
"""
result: dict[str, HelpTheme] = {}
for name, slots in table.items():
overlay = HelpTheme.from_dict(slots)
if name in theme_registry:
result[name] = overlay.cascade(theme_registry[name])
else:
result[name] = overlay
return result
[docs]
def validate_themes_config(themes_subtree: dict[str, Any]) -> None:
"""Validate a ``[tool.<cli>.themes]`` sub-tree.
Registered as a built-in :class:`ConfigValidator <click_extra.config.schema.ConfigValidator>`
by :class:`ConfigOption <click_extra.config.option.ConfigOption>` so malformed
theme tables surface as :class:`~click_extra.config.schema.ValidationError` with
a rooted path (``my-cli.themes.<name>``) rather than a deep
:class:`TypeError` from :meth:`HelpTheme.from_dict`.
"""
# Lazy-imported to avoid a load-time cycle with the config package.
from .config import ValidationError
for name, slots in themes_subtree.items():
if not isinstance(slots, dict):
raise ValidationError(
path=name,
message=(
f"theme definition must be a table, got {type(slots).__name__}"
),
code="invalid-theme-shape",
)
try:
HelpTheme.from_dict(slots)
except TypeError as exc:
raise ValidationError(
path=name,
message=str(exc),
code="invalid-theme",
) from exc
[docs]
class ThemeChoice(click.ParamType):
"""A :class:`click.ParamType` whose ``choices`` track the live theme registry.
Implements the ``click.Choice``-shaped duck interface (``choices``,
``case_sensitive``, ``normalize_choice``) so :mod:`click_extra.highlight`
can collect theme names for per-token highlighting through the same
code path it uses for Click's own ``Choice``. The ``choices`` attribute
is a property that queries :func:`get_theme_registry` at every lookup,
so themes registered late (typically by
:class:`ConfigOption <click_extra.config.option.ConfigOption>` parsing
``[tool.<cli>.themes.<name>]`` tables before ``--theme`` is processed)
are valid choices and appear in the ``--help`` metavar.
Implemented as a fresh :class:`click.ParamType` rather than a
:class:`click.Choice` subclass to avoid relying on Click's setter
semantics for ``self.choices``: the previous subclass design swallowed
Click's ``__init__``-time assignment with a no-op setter, which
would silently break under any future Click version that uses
``object.__setattr__`` (like for slots) instead of regular attribute
assignment.
"""
# Match ``click.Choice.name`` so machinery that branches on parameter
# type (like metavar generation) treats this the same way.
name: str = "choice"
def __init__(self, case_sensitive: bool = False) -> None:
self.case_sensitive = case_sensitive
@property
def choices(self) -> tuple[str, ...]:
"""Theme names visible in the current context, alphabetically sorted."""
try:
ctx = click.get_current_context(silent=True)
except RuntimeError:
ctx = None
return tuple(sorted(get_theme_registry(ctx)))
def _normalize(self, value: str) -> str:
return value if self.case_sensitive else value.casefold()
[docs]
def normalize_choice(self, choice: Any, ctx: click.Context | None) -> str:
"""Mirrors :meth:`click.Choice.normalize_choice` for highlight compatibility."""
normed = str(choice)
if ctx is not None and ctx.token_normalize_func is not None:
normed = ctx.token_normalize_func(normed)
return self._normalize(normed)
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> str | None:
"""Resolve *value* to a canonical theme name from the live registry.
Normalizes *value* and looks it up against the names visible in
*ctx*. Returns the canonical name, ``None`` when no themes are
registered at all (an inert ``--theme``), and calls ``fail`` when
*value* is not a string or does not match any registered theme.
"""
if value is None:
return None
if not isinstance(value, str):
self.fail(f"{value!r} is not a string.", param, ctx)
# "auto" is a reserved directive resolved from the terminal background by
# ThemeOption, not a registered palette, so it bypasses the registry lookup.
if self._normalize(value) == self._normalize(AUTO_THEME):
return AUTO_THEME
registry = get_theme_registry(ctx)
# No themes available at all: themes.toml was dropped by the packaging
# step and no config themes are defined. The --theme option is inert,
# so ignore the value (including the built-in "dark" default that can
# no longer be resolved) and let get_current_theme() fall back to the
# no-color default instead of failing the whole invocation.
if not registry:
return None
lookup = {self._normalize(name): name for name in registry}
canonical = lookup.get(self._normalize(value))
if canonical is None:
choices = "|".join(sorted(registry))
self.fail(
f"{value!r} is not one of [{choices}].",
param,
ctx,
)
return canonical
[docs]
def shell_complete(
self,
ctx: click.Context,
param: click.Parameter,
incomplete: str,
) -> list[click.shell_completion.CompletionItem]:
from click.shell_completion import CompletionItem
prefix = self._normalize(incomplete)
return [
CompletionItem(name)
for name in sorted(get_theme_registry(ctx))
if self._normalize(name).startswith(prefix)
]
[docs]
class ThemeOption(ExtraOption):
"""A pre-configured option that adds ``--theme`` to select the help-screen palette.
Accepts any name registered in :data:`~click_extra.theme.theme_registry`
*or* in the per-invocation overrides loaded by
:class:`ConfigOption <click_extra.config.option.ConfigOption>` from
``[tool.<cli>.themes.<name>]``. Validation goes through
:class:`~click_extra.theme.ThemeChoice`, which reads the live registry at
parse time, so
config-defined themes appear as valid choices and in the ``--help``
metavar without any further wiring.
The resolved :class:`HelpTheme` lands on the Click context under
:data:`click_extra.context.THEME` and applies for the duration of the
current invocation only.
The reserved value :data:`~click_extra.theme.AUTO_THEME` (``--theme=auto``)
is also accepted on every CLI: it resolves the palette from the terminal
background via :func:`~click_extra.theme.resolve_auto_theme` instead of
naming a registered theme. Background detection reads environment variables
by default; pass ``query_background=True`` to additionally allow the live
OSC 11 terminal query (:func:`~click_extra.color.query_osc_background`),
which is opt-in because it reads stdin.
"""
[docs]
def set_theme(
self,
ctx: click.Context,
param: click.Parameter,
value: str | None,
) -> None:
"""Resolve the chosen theme name and store it on the Click context.
:class:`~click_extra.theme.ThemeChoice` has already validated *value*
against the live registry (or accepted the
:data:`~click_extra.theme.AUTO_THEME` directive) by the time this fires.
A plain palette name is looked up unconditionally; ``auto`` is resolved
from the terminal background via
:func:`~click_extra.theme.resolve_auto_theme`, leaving ``ctx.meta``
untouched when no palette can be resolved so :func:`get_current_theme`
keeps its default.
"""
if value is None or ctx.resilient_parsing:
return
if value == AUTO_THEME:
theme = resolve_auto_theme(ctx, query_background=self.query_background)
if theme is not None:
context.set(ctx, context.THEME, theme)
return
context.set(ctx, context.THEME, get_theme_registry(ctx)[value])
def __init__(
self,
param_decls: Sequence[str] | None = None,
default: str = "dark",
is_eager: bool = True,
expose_value: bool = False,
query_background: bool = False,
help: str = _("Color theme used for help screens."),
**kwargs,
) -> None:
if not param_decls:
param_decls = ("--theme",)
# Read by set_theme when resolving the "auto" directive: gates the
# live OSC 11 query on top of the environment-variable detection.
self.query_background = query_background
kwargs.setdefault("callback", self.set_theme)
super().__init__(
param_decls=param_decls,
type=ThemeChoice(),
default=default,
is_eager=is_eager,
expose_value=expose_value,
help=help,
**kwargs,
)
def _load_builtin_themes() -> dict[str, HelpTheme]:
"""Parse ``themes.toml`` into a ``{name: HelpTheme}`` mapping.
Each top-level table maps to a :class:`HelpTheme` via
:meth:`HelpTheme.from_dict`. A malformed payload still surfaces
immediately at import time so a corrupt shipped TOML cannot silently
degrade ``--theme``.
Reads the file via :mod:`importlib.resources` so the load works under
zipped imports (PyOxidizer, PEX, certain Nuitka modes) where
``Path(__file__).parent`` doesn't resolve to a real filesystem location.
.. note::
Some packaging and distribution setups drop the ``themes.toml`` data
file because they don't bundle package data (Nuitka onefile without
``--include-package-data``, trimmed downstream rebuilds). A missing
file is tolerated rather than fatal: the function logs a warning on
the ``click_extra`` logger and returns an empty mapping, so importing
the package never aborts. The CLI then keeps the colorless
:data:`nocolor_theme` as its default (see the module footer) and
``--theme`` offers no built-in choices, though themes declared in a
CLI's config file (``[tool.<cli>.themes.<name>]``) remain available.
"""
resource = resources.files(__package__).joinpath("themes.toml")
try:
payload = resource.read_text(encoding="utf-8")
except OSError as error:
logging.getLogger("click_extra").warning(
"Could not read the packaged %r data file (%s). Built-in themes "
"are unavailable: falling back to the no-color theme. The file was "
"likely dropped by a packaging or distribution step. You can still "
"define your own themes in your config file, like a "
"[tool.<cli>.themes.<name>] table in pyproject.toml.",
f"{__package__}/themes.toml",
error,
)
return {}
raw = tomllib.loads(payload)
return {name: HelpTheme.from_dict(data) for name, data in raw.items()}
BUILTIN_THEMES: dict[str, HelpTheme] = _load_builtin_themes()
"""Mapping of built-in theme names to their :class:`HelpTheme` instances.
Loaded from the package data file ``click_extra/themes.toml`` at module
import time and seeded into :data:`theme_registry`. Adding a new built-in
theme is a one-file edit in that TOML file: declare a new ``[<name>]``
table with one inline-table per styled slot.
Index by name to access any palette, like ``BUILTIN_THEMES["dark"]`` or
``BUILTIN_THEMES["solarized_dark"]``.
Empty when the ``themes.toml`` data file is absent (some packaging and
distribution setups drop it); see ``_load_builtin_themes`` for the
fallback behavior.
"""
OK_GLYPH: str = "β"
"""Plain check-mark glyph for success indicators.
Style at the call site with the active theme's ``success`` slot:
``get_current_theme().success(OK_GLYPH)``. Stored as a raw string so
downstream code can render it under whichever theme is active rather
than the (frozen) theme that happened to be loaded at import time.
"""
KO_GLYPH: str = "β"
"""Plain heavy-ballot-X glyph for failure indicators.
Style at the call site with the active theme's ``error`` slot:
``get_current_theme().error(KO_GLYPH)``. See :data:`~click_extra.theme.OK_GLYPH` for why
the glyph is exposed unstyled.
"""
theme_registry.update(BUILTIN_THEMES)
# When themes.toml is missing (some packaging/distribution setups drop the data
# file), BUILTIN_THEMES is empty: keep the no-color fallback installed above
# rather than crashing on a missing "dark" key.
if "dark" in BUILTIN_THEMES:
set_default_theme(BUILTIN_THEMES["dark"])