mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user