feat(cli): add named color themes and rich-formatted help

Render all click help through rich-click, themed by a new `theme` config key (9 palettes + aliases, previewable via `unshackle env theme`), and show service docstrings in local and remote `dl <TAG> -h`.
This commit is contained in:
imSp4rky
2026-08-13 16:25:14 -06:00
parent a248ae79bd
commit db9ec3e334
16 changed files with 439 additions and 40 deletions
+9
View File
@@ -322,6 +322,14 @@ unshackle env info
Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more).
### `env theme`
```
unshackle env theme
```
Prints a sample of every available CLI theme: colour swatches, help text with option rows, a track listing with video/audio/subtitle entries, log lines, a progress bar, and the gradient pulse bar. The active theme is marked and each theme's aliases are listed. Set your choice with the [`theme`](../reference/configuration/misc.md#appearance) config key.
### `env clear`
Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported.
@@ -335,6 +343,7 @@ Clear an environment directory. The directory is emptied and recreated, and the
```shell
unshackle env check
unshackle env info
unshackle env theme
unshackle env clear cache
unshackle env clear cache EXAMPLE # one service only
unshackle env clear temp
+1
View File
@@ -38,5 +38,6 @@ Where the file lives, how unshackle discovers it, and how to edit it:
| [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` |
| [External API keys](misc.md#external-api-keys) | `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `anilist_title_language`, `disable_metadata`, `decrypt_labs_api_key`, `ipinfo_api_key` |
| [Caching & updates](misc.md#caching-updates) | `title_cache_enabled`, `title_cache_time`, `title_cache_max_retention`, `update_checks`, `update_check_interval` |
| [Appearance](misc.md#appearance) | `theme` |
| [Logging, privacy & debug](misc.md#logging-privacy-debug) | `redact_paths`, `debug`, `debug_keys`, `debug_requests`, `set_terminal_bg` |
| [Deprecated & removed](misc.md#deprecated-removed-keys) | `curl_impersonate`, `downloader`, `scene_naming` |
+8
View File
@@ -141,6 +141,14 @@ calls.
---
## Appearance { #appearance }
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `theme` | str | `catppuccin-mocha` | Named colour theme for all CLI output: banner, logs, progress bars, and help screens. One of `catppuccin-mocha` (aliases `default`, `catppuccin`, `mocha`), `dracula`, `nord`, `gruvbox`, `one-dark` (alias `onedark`), `tokyo-night` (alias `tokyonight`), `monokai`, `flexoki`, `solarized-dark` (alias `solarized`). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all with `unshackle env theme`. |
---
## Logging, privacy & debug { #logging-privacy-debug }
| Key | Type | Default | Description |
+3 -1
View File
@@ -95,7 +95,9 @@ not repeated in the fallbacks, so `auth_headers: ["Authorization", "x-secret-key
In `--remote` mode unshackle turns the server's service list into synthetic CLI
commands that run against it, falling back to the tags in that `services` sub-dict when the
list cannot be fetched. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the
list cannot be fetched. Each synthetic command carries the server-side service's options and
documentation, so `unshackle dl --remote <TAG> -h` shows the same help text as it does on the
server. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the
full setup.
## `serve`
+1
View File
@@ -69,6 +69,7 @@ dependencies = [
"jsonpath-ng>=1.8.0",
"mutagen>=1.47.0,<2",
"defusedxml>=0.7.1",
"rich-click>=1.9.8",
]
[project.urls]
+9 -3
View File
@@ -30,6 +30,7 @@ import yaml
from click.core import ParameterSource
from langcodes import Language, tag_is_valid
from pymediainfo import MediaInfo
from rich import box
from rich.console import Group
from rich.padding import Padding
from rich.panel import Panel
@@ -1766,7 +1767,7 @@ class dl:
# Note: Headers are not mapped to actual title indices
# Format display name
display_name = ((t.name[:30].rstrip() + "…") if len(t.name) > 30 else t.name) if t.name else None
display_name = ((t.name[:30].rstrip() + "...") if len(t.name) > 30 else t.name) if t.name else None
# Apply indentation only for multiple seasons
prefix = " " if multiple_seasons else ""
@@ -2582,7 +2583,12 @@ class dl:
if list_:
available_tracks, _ = title.tracks.tree()
console.print(Padding(Panel(available_tracks, title="Available Tracks"), (0, 5)))
console.print(
Padding(
Panel(available_tracks, title="Available Tracks", box=box.SQUARE, border_style="bright_black"),
(0, 5),
)
)
continue
# Determine which tracks to keep
@@ -4166,7 +4172,7 @@ class dl:
Carries no session/cookies/dl-flags. Region (country code) is stored only when the
export used ``--proxy``, as an import geofence. Each track records only the licensed
DRM system; content keys live once under the track's ``keys``. ``drm`` may be None
(DRM-free track) or a DRM system without ``to_dict``/``content_keys`` (e.g. ClearKey) —
(DRM-free track) or a DRM system without ``to_dict``/``content_keys`` (e.g. ClearKey) -
the track, manifest, chapter and attachment info is still exported.
"""
with self.EXPORT_LOCK:
+105
View File
@@ -188,6 +188,111 @@ def check() -> None:
console.print(Padding(" ".join(summary_parts), (1, 2)))
@env.command()
def theme() -> None:
"""Preview the available CLI themes."""
import math
from rich import box
from rich.color import Color, blend_rgb
from rich.console import Group
from rich.panel import Panel
from rich.rule import Rule
from rich.style import Style
from rich.text import Text
from unshackle.core.themes import ALIASES, DEFAULT_THEME, PALETTES, resolve_palette
active = resolve_palette(config.theme) or PALETTES[DEFAULT_THEME]
for name, palette in PALETTES.items():
text, text2, gray, guide = palette["text"], palette["text2"], palette["gray"], palette["bright_black"]
header = Text(name, style=palette["pink"])
if palette == active:
header.append(" (active)", style=palette["green"])
aliases = sorted(alias for alias, target in ALIASES.items() if target == name)
if aliases:
header.append(f" aliases: {', '.join(aliases)}", style=f"dim {gray}")
swatch = Text()
for role in ("red", "green", "yellow", "blue", "pink", "cyan", "text", "gray"):
swatch.append("██ ", style=palette[role])
docstring = Text()
docstring.append("Service docstrings render like this, ", style=text)
docstring.append("with secondary detail lines like this.", style=text2)
separator = Text("─" * 60, style=palette["dark_gray"])
options: list[Text] = []
rows = (
("-q, ", "--quality ", "TEXT ", "Video quality to download", " [default: best]", f"dim {gray}"),
(
"-r, ",
"--range ",
"[SDR|HDR10|DV] ",
"Video color range",
" [env: RANGE]",
f"dim {palette['yellow']}",
),
)
for short, long_opt, metavar, help_text, extra, extra_style in rows:
row = Text()
row.append(short, style=palette["green"])
row.append(long_opt, style=text)
row.append(metavar, style=palette["yellow"])
row.append(f" {help_text}", style=text)
row.append(extra, style=extra_style)
options.extend((row, separator))
listing = Tree(Text("1 season, S1(1)", style=text), guide_style=guide)
episode = listing.add(Text.assemble(("1. ", palette["blue"]), ("Pilot", text)), guide_style=guide)
track_rows = (
("VID", "H.264 | 1920x1080 @ 4523 kb/s"),
("AUD", "AAC 2.0 | en @ 192 kb/s"),
("SUB", "WebVTT | en (SDH)"),
)
for kind, desc in track_rows:
episode.add(Text.assemble((kind, palette["pink"]), (" | ", guide), (desc, text)), guide_style=guide)
tracks = Panel(listing, title="Available Tracks", box=box.SQUARE, border_style=guide, expand=False)
logs = []
log_rows = (
("INFO", palette["green"], "Downloading 3 tracks"),
("WARNING", palette["yellow"], "Subtitle cues overlapped, fixed 2"),
("ERROR", palette["red"], "License request denied (403)"),
)
for level, level_style, message in log_rows:
line = Text()
line.append("18:00:01 ", style=gray)
line.append(f"{level:<8} ", style=level_style)
line.append(message, style=text if level != "ERROR" else palette["red"])
logs.append(line)
progress = Text()
progress.append("Pilot ", style=text)
progress.append("━" * 24, style=palette["green"])
progress.append("╸", style=palette["green"])
progress.append("━" * 14, style=guide)
progress.append(" 62%", style=palette["yellow"])
progress.append(" 12.4 MB/s", style=palette["cyan"])
progress.append(" 0:00:42", style=text2)
# one frozen frame of GradientPulseBarColumn's dark_gray-to-pink cosine sweep
lo = Color.parse(palette["dark_gray"]).triplet
hi = Color.parse(palette["pink"]).triplet
assert lo and hi
lut = [Style(color=Color.from_triplet(blend_rgb(lo, hi, i / 31))) for i in range(32)]
pulse = Text("Muxing ", style=text)
for i in range(39):
fade = (math.cos((i - 8) * math.tau / 40.0) + 1) / 2
pulse.append("━", style=lut[int(fade * 31)])
block = Group(header, swatch, Text(), docstring, *options, tracks, *logs, Text(), progress, pulse)
console.print(Padding(block, (1, 2, 1, 2)))
console.print(Rule(style=guide, characters="─"))
console.print()
@env.command()
def info() -> None:
"""Displays information about the current environment."""
+4
View File
@@ -3,6 +3,10 @@ import os
from pathlib import Path
from typing import Callable, Optional
from rich_click.patch import patch as _patch_click_help
_patch_click_help()
__version__ = "5.3.0"
_PKG = Path(__file__).parent.parent
+5 -6
View File
@@ -23,12 +23,12 @@ def load_command(path: Path) -> object:
try:
module = import_module_by_path(path)
except Exception as e:
raise RuntimeError(f"{path.stem}: failed to import — {type(e).__name__}: {e} ({path})") from e
raise RuntimeError(f"{path.stem}: failed to import - {type(e).__name__}: {e} ({path})") from e
try:
return getattr(module, path.stem)
except AttributeError as e:
raise RuntimeError(
f"{path.stem}: no object named '{path.stem}' found in {path} — it must match the filename"
f"{path.stem}: no object named '{path.stem}' found in {path} - it must match the filename"
) from e
@@ -74,10 +74,9 @@ class Commands(click.Group):
if not module:
raise click.ClickException(f"Unable to find command by the name '{name}'")
if hasattr(module, "cli"):
return module.cli
return module
cmd = module.cli if hasattr(module, "cli") else module
cmd.name = name
return cmd
# Hide direct access to commands from quick import form, they shouldn't be accessed directly
+1
View File
@@ -110,6 +110,7 @@ class Config:
self.decryption_map = {}
self.decryption = decryption_cfg or "shaka"
self.theme: str = kwargs.get("theme") or "default"
self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False)
self.tag: str = kwargs.get("tag") or ""
self.tag_group_name: bool = kwargs.get("tag_group_name", True)
+11 -19
View File
@@ -21,6 +21,7 @@ from rich.text import Text, TextType
from rich.theme import Theme
from unshackle.core.config import config
from unshackle.core.themes import DEFAULT_THEME, PALETTES, apply_help_theme, resolve_palette
class ComfyLogRenderer(LogRender):
@@ -317,25 +318,16 @@ class ComfyConsole(Console):
live.start()
catppuccin_mocha = {
# Colors based on "CatppuccinMocha" from Gogh themes
"bg": "rgb(30,30,46)",
"text": "rgb(205,214,244)",
"text2": "rgb(162,169,193)", # slightly darker
"black": "rgb(69,71,90)",
"bright_black": "rgb(88,91,112)",
"red": "rgb(243,139,168)",
"green": "rgb(166,227,161)",
"yellow": "rgb(249,226,175)",
"blue": "rgb(137,180,250)",
"pink": "rgb(245,194,231)",
"cyan": "rgb(148,226,213)",
"gray": "rgb(166,173,200)",
"bright_gray": "rgb(186,194,222)",
"dark_gray": "rgb(54,54,84)",
}
primary_scheme = catppuccin_mocha
primary_scheme = resolve_palette(config.theme)
if primary_scheme is None:
logging.getLogger("console").warning(
"Unknown theme %r, falling back to %s (available: %s)",
config.theme,
DEFAULT_THEME,
", ".join(PALETTES),
)
primary_scheme = dict(PALETTES[DEFAULT_THEME])
apply_help_theme(primary_scheme)
primary_scheme["none"] = primary_scheme["text"]
primary_scheme["grey23"] = primary_scheme["black"]
primary_scheme["magenta"] = primary_scheme["pink"]
+2 -1
View File
@@ -14,6 +14,7 @@ from io import BytesIO
from typing import Any, Optional
# RenderableType is rich's union of "things that can be printed to a console".
from rich import box
from rich.console import Group, RenderableType
from rich.padding import Padding
from rich.panel import Panel
@@ -145,7 +146,7 @@ def render_track_panel(rows: list[TrackRow], total: int) -> Panel:
detail.append(" | ")
detail.append("Hi-Res", style="gold1")
node.add(detail, guide_style="bright_black")
return Panel(tree, title="Available Tracks")
return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black")
def render_album_header(
+3 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import re
from typing import Any, Optional
from rich import box
from rich.console import Group, RenderableType
from rich.markup import escape
from rich.panel import Panel
@@ -112,7 +113,7 @@ class MusicRenderer:
suffix = "s" if hidden != 1 else ""
tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black")
return Panel(tree, title="Available Tracks")
return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black")
def render_plan_header(self, plan: MusicDownloadPlan) -> Optional[Table]:
title = plan.title
@@ -174,7 +175,7 @@ class MusicRenderer:
suffix = "s" if hidden != 1 else ""
tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black")
return Panel(tree, title="Available Tracks")
return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black")
@staticmethod
def _metadata_grid() -> Table:
+30 -8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
import logging
import re
from pathlib import Path
@@ -17,7 +18,7 @@ log = logging.getLogger("services")
_raw = config.directories.services
if not isinstance(_raw, list):
_raw = [_raw]
# resolve in config order — priority IS list order: the first source to define a tag is the source,
# resolve in config order - priority IS list order: the first source to define a tag is the source,
# later sources (local or repo) with the same tag are shadowed. List local last to make it a fallback.
_service_dirs: list[Path] = []
DIRTY_REPOS: list[str] = []
@@ -26,7 +27,7 @@ for _entry in _raw:
try:
_resolved = resolve_service_repo(_entry)
except DirtyServiceRepo as e:
# local edits in the clone — record it and let check_load_errors() exit cleanly
# local edits in the clone - record it and let check_load_errors() exit cleanly
DIRTY_REPOS.append(redact_path(str(e.path)))
_resolved = None
if _resolved:
@@ -59,12 +60,12 @@ def load_service(path: Path) -> object:
try:
module = import_module_by_path(path)
except Exception as e:
raise RuntimeError(f"{tag}: failed to import — {type(e).__name__}: {e} ({path})") from e
raise RuntimeError(f"{tag}: failed to import - {type(e).__name__}: {e} ({path})") from e
try:
return getattr(module, tag)
except AttributeError as e:
raise RuntimeError(
f"{tag}: no class named '{tag}' found in {path} — the class name must match the directory name"
f"{tag}: no class named '{tag}' found in {path} - the class name must match the directory name"
) from e
@@ -101,12 +102,12 @@ def check_load_errors() -> None:
is rendered once by Click, without a traceback and without cascading through
every command that imports this module. Duplicate services (a tag also served by an
earlier, higher-priority source, which is ignored) are summarised here; the full
list — naming the path used and the duplicate ignored — is debug-only.
list - naming the path used and the duplicate ignored - is debug-only.
"""
if DIRTY_REPOS:
joined = "\n".join(f" - {p}" for p in DIRTY_REPOS)
raise click.ClickException(
"Service repo has local changes — refusing to refresh so your edits are not lost.\n"
"Service repo has local changes - refusing to refresh so your edits are not lost.\n"
"Commit and push them to the upstream repo (or revert them), then retry:\n" + joined
)
global _SUMMARY_LOGGED
@@ -191,10 +192,28 @@ class Services(click.Group):
raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}")
if hasattr(service, "cli"):
return service.cli
cli = service.cli
cli.name = tag
doc = service.__doc__
if doc and doc.strip() and cli.help in (None, "", doc):
cli.help = Services._docstring_help(doc)
return cli
raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.")
@staticmethod
def _docstring_help(doc: str) -> str:
"""Format a service docstring for Click help, one \\b per paragraph so Click keeps the layout.
The first paragraph stays unprefixed: it is the summary line, and a \\b there
renders as a stray blank line and empties click's derived short help.
"""
doc = inspect.cleandoc(doc)
if "\b" in doc:
return doc
first, *rest = doc.split("\n\n")
return "\n\n".join([first] + [f"\b\n{p}" for p in rest])
@staticmethod
def _fetch_remote_services(ctx: click.Context) -> list[dict] | None:
"""Fetch the service list from the remote server (cached per process)."""
@@ -219,9 +238,12 @@ class Services(click.Group):
"""Create a Click command for a remote service with server-provided options."""
svc_info = Services._fetch_remote_service_info(tag, ctx)
short_help = svc_info.get("url") if svc_info else None
help_text = svc_info.get("help") if svc_info else None
if help_text:
help_text = Services._docstring_help(help_text)
cli_params = svc_info.get("cli_params") if svc_info else None
@click.command(name=tag, short_help=short_help)
@click.command(name=tag, short_help=short_help, help=help_text)
@click.argument("title", type=str)
@click.pass_context
def remote_cli(ctx: click.Context, title: str, **kwargs: object) -> object:
+231
View File
@@ -0,0 +1,231 @@
"""
Named CLI color themes.
Each palette maps the same role keys to colors: `text`/`text2` for body copy, `pink` for the
brand accent, the six ANSI hues, and the gray ramp used for rules and dim details. The active
palette drives both the rich Console theme (banner, logs, progress) and the click help screens,
so a single `theme` config value restyles the whole CLI.
"""
from __future__ import annotations
from rich_click import RichHelpConfiguration
DEFAULT_THEME = "catppuccin-mocha"
PALETTES: dict[str, dict[str, str]] = {
# Colors based on "CatppuccinMocha" from Gogh themes
"catppuccin-mocha": {
"bg": "rgb(30,30,46)",
"text": "rgb(205,214,244)",
"text2": "rgb(162,169,193)", # slightly darker
"black": "rgb(69,71,90)",
"bright_black": "rgb(88,91,112)",
"red": "rgb(243,139,168)",
"green": "rgb(166,227,161)",
"yellow": "rgb(249,226,175)",
"blue": "rgb(137,180,250)",
"pink": "rgb(245,194,231)",
"cyan": "rgb(148,226,213)",
"gray": "rgb(166,173,200)",
"bright_gray": "rgb(186,194,222)",
"dark_gray": "rgb(54,54,84)",
},
# draculatheme.com spec; purple stands in for blue, comment for the dim grays
"dracula": {
"bg": "#282a36",
"text": "#f8f8f2",
"text2": "#b8bfd8",
"black": "#21222c",
"bright_black": "#6272a4",
"red": "#ff5555",
"green": "#50fa7b",
"yellow": "#f1fa8c",
"blue": "#bd93f9",
"pink": "#ff79c6",
"cyan": "#8be9fd",
"gray": "#6272a4",
"bright_gray": "#e9e9f4",
"dark_gray": "#44475a",
},
# nordtheme.com: polar night / snow storm / frost / aurora
"nord": {
"bg": "#2e3440",
"text": "#d8dee9",
"text2": "#9aa4b8",
"black": "#3b4252",
"bright_black": "#4c566a",
"red": "#bf616a",
"green": "#a3be8c",
"yellow": "#ebcb8b",
"blue": "#81a1c1",
"pink": "#b48ead",
"cyan": "#88c0d0",
"gray": "#616e88",
"bright_gray": "#e5e9f0",
"dark_gray": "#434c5e",
},
# github.com/morhetz/gruvbox dark, bright variants
"gruvbox": {
"bg": "#282828",
"text": "#ebdbb2",
"text2": "#bdae93",
"black": "#504945",
"bright_black": "#665c54",
"red": "#fb4934",
"green": "#b8bb26",
"yellow": "#fabd2f",
"blue": "#83a598",
"pink": "#d3869b",
"cyan": "#8ec07c",
"gray": "#928374",
"bright_gray": "#d5c4a1",
"dark_gray": "#3c3836",
},
# Atom One Dark syntax palette
"one-dark": {
"bg": "#282c34",
"text": "#abb2bf",
"text2": "#818896",
"black": "#3b4048",
"bright_black": "#5c6370",
"red": "#e06c75",
"green": "#98c379",
"yellow": "#e5c07b",
"blue": "#61afef",
"pink": "#c678dd",
"cyan": "#56b6c2",
"gray": "#5c6370",
"bright_gray": "#c8ccd4",
"dark_gray": "#3e4451",
},
# github.com/enkia/tokyo-night-vscode-theme, night variant
"tokyo-night": {
"bg": "#1a1b26",
"text": "#c0caf5",
"text2": "#a9b1d6",
"black": "#15161e",
"bright_black": "#414868",
"red": "#f7768e",
"green": "#9ece6a",
"yellow": "#e0af68",
"blue": "#7aa2f7",
"pink": "#bb9af7",
"cyan": "#7dcfff",
"gray": "#565f89",
"bright_gray": "#a9b1d6",
"dark_gray": "#292e42",
},
# classic Monokai; purple stands in for pink, 66d9ef doubles as blue
"monokai": {
"bg": "#272822",
"text": "#f8f8f2",
"text2": "#cfcfc2",
"black": "#2e2e2e",
"bright_black": "#75715e",
"red": "#f92672",
"green": "#a6e22e",
"yellow": "#e6db74",
"blue": "#66d9ef",
"pink": "#ae81ff",
"cyan": "#a1efe4",
"gray": "#a59f85",
"bright_gray": "#cfcfc2",
"dark_gray": "#3e3d32",
},
# flexoki.com dark, 400-level accents
"flexoki": {
"bg": "#100f0f",
"text": "#cecdc3",
"text2": "#878580",
"black": "#282726",
"bright_black": "#403e3c",
"red": "#d14d41",
"green": "#879a39",
"yellow": "#d0a215",
"blue": "#4385be",
"pink": "#ce5d97",
"cyan": "#3aa99f",
"gray": "#6f6e69",
"bright_gray": "#b7b5ac",
"dark_gray": "#343331",
},
# ethanschoonover.com/solarized dark; magenta stands in for pink
"solarized-dark": {
"bg": "#002b36",
"text": "#839496",
"text2": "#586e75",
"black": "#073642",
"bright_black": "#586e75",
"red": "#dc322f",
"green": "#859900",
"yellow": "#b58900",
"blue": "#268bd2",
"pink": "#d33682",
"cyan": "#2aa198",
"gray": "#657b83",
"bright_gray": "#93a1a1",
"dark_gray": "#073642",
},
}
ALIASES = {
"default": DEFAULT_THEME,
"catppuccin": DEFAULT_THEME,
"mocha": DEFAULT_THEME,
"onedark": "one-dark",
"tokyonight": "tokyo-night",
"solarized": "solarized-dark",
}
def resolve_palette(name: object) -> dict[str, str] | None:
"""Look up a palette by theme name or alias, case-insensitively.
Takes any YAML scalar since config values are unvalidated; non-strings never match.
Returns a copy: console.py extends the active palette in place, and that must
not pollute the master definitions.
"""
key = str(name or "").strip().lower().replace("_", "-")
palette = PALETTES.get(ALIASES.get(key, key))
return dict(palette) if palette else None
def build_help_config(palette: dict[str, str]) -> RichHelpConfiguration:
"""Build the rich-click help styling for a palette."""
return RichHelpConfiguration(
style_option=palette["text"],
style_switch=palette["green"],
style_argument=palette["cyan"],
style_command=palette["pink"],
style_metavar=palette["yellow"],
style_metavar_separator=f"dim {palette['gray']}",
style_usage=palette["yellow"],
style_usage_command=palette["text"],
style_helptext_first_line=palette["text"],
style_helptext=palette["text2"],
style_option_help=palette["text"],
style_command_help=palette["text"],
style_option_default=f"dim {palette['gray']}",
style_option_envvar=f"dim {palette['yellow']}",
style_required_short=palette["red"],
style_required_long=f"dim {palette['red']}",
style_errors_suggestion=palette["text2"],
style_aborted=palette["red"],
style_options_panel_box=None,
style_options_panel_border=palette["pink"],
style_options_panel_title_style=palette["pink"],
style_commands_panel_box=None,
style_commands_panel_border=palette["pink"],
style_commands_panel_title_style=palette["pink"],
style_errors_panel_box="SQUARE",
style_errors_panel_border=palette["red"],
style_options_table_box="HORIZONTALS",
style_options_table_show_lines=True,
style_options_table_border_style=palette["dark_gray"],
)
def apply_help_theme(palette: dict[str, str]) -> None:
"""Point the patched click help renderer at this palette (read at render time)."""
build_help_config(palette).dump_to_globals()
Generated
+16
View File
@@ -1828,6 +1828,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-click"
version = "1.9.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" },
]
[[package]]
name = "rlaphoenix-m3u8"
version = "3.4.0"
@@ -2157,6 +2171,7 @@ dependencies = [
{ name = "pyyaml" },
{ name = "requests", extra = ["socks"] },
{ name = "rich" },
{ name = "rich-click" },
{ name = "rlaphoenix-m3u8" },
{ name = "rnet" },
{ name = "ruamel-yaml" },
@@ -2224,6 +2239,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.1,<7" },
{ name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" },
{ name = "rich", specifier = ">=13.7.1,<15" },
{ name = "rich-click", specifier = ">=1.9.8" },
{ name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" },
{ name = "rnet", specifier = ">=2.4.2" },
{ name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" },