Files
unshackle/tests/core/test_themes.py
T
imSp4rky 6029acd40b feat(themes): load CLI themes from YAML files, with gradients
Ship 11 themes as YAML and read user themes from directories.themes, with extends, styles and gradients (banner, bar, pulse, rule, panel); env theme lists themes and plays an animated sample, and dl pins a moving banner.
2026-10-22 18:57:07 -06:00

553 lines
21 KiB
Python

"""Theme files: shipped themes, user files on top of them, and the errors that reject a file."""
from __future__ import annotations
import io
import logging
import subprocess
import sys
import time
from pathlib import Path
import pytest
from click.testing import CliRunner
from rich import box
from rich.console import Console, RenderableType
from rich.panel import Panel
from rich.progress import Progress
from rich.rule import Rule
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from rich.tree import Tree
from unshackle.commands.env import env
from unshackle.core.config import config
from unshackle.core.console import (
ComfyConsole,
Gradient,
GradientPulseBarColumn,
PinnedLive,
SyncLive,
gradient_fill,
gradient_lut,
slide_gradients,
use_gradients,
)
from unshackle.core.themes import (
ALIASES,
DEFAULT_THEME,
ROLES,
apply_theme,
build_console_theme,
build_help_config,
load_theme,
read_theme_file,
shipped_themes,
user_themes,
)
@pytest.fixture
def themes_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(config.directories, "themes", tmp_path)
return tmp_path
def write(folder: Path, name: str, text: str) -> None:
(folder / f"{name}.yaml").write_text(text, encoding="utf-8")
@pytest.mark.parametrize("name", sorted(shipped_themes()))
def test_shipped_theme_sets_every_role(name: str) -> None:
assert set(read_theme_file(shipped_themes()[name])) >= set(ROLES)
palette, styles, _ = load_theme(name, user_files=False)
for set_terminal_bg in (False, True):
build_console_theme(palette, styles, set_terminal_bg)
build_help_config(palette)
def test_the_shipped_themes_ship() -> None:
# the root .gitignore hides unshackle.yaml; the default theme needs its exception
assert {DEFAULT_THEME, "nord", "rainbow", "dracula", "tokyo-night"} <= set(shipped_themes())
@pytest.mark.parametrize("alias", sorted(ALIASES))
def test_every_alias_names_a_shipped_theme(alias: str) -> None:
assert load_theme(alias) == load_theme(ALIASES[alias], user_files=False)
def test_user_theme_extends_a_shipped_theme(themes_dir: Path) -> None:
write(themes_dir, "My_Nord", 'extends: nord\naccent: "#88c0d0"\n')
nord, nord_styles, _ = load_theme("nord")
palette, styles, _ = load_theme("my-nord")
assert palette == {**nord, "pink": "#88c0d0"}
assert styles == nord_styles
def test_without_extends_a_new_theme_starts_from_the_default(themes_dir: Path) -> None:
write(themes_dir, "mine", 'text: "#ffffff"\n')
assert load_theme("mine")[0] == {**load_theme(DEFAULT_THEME)[0], "text": "#ffffff"}
def test_a_user_file_replaces_a_shipped_theme_that_it_extends(themes_dir: Path) -> None:
write(themes_dir, "nord", 'extends: nord\ntext: "#ffffff"\n')
assert load_theme("nord")[0]["text"] == "#ffffff"
assert load_theme("nord", user_files=False)[0]["text"] != "#ffffff"
def test_alias_names_are_reserved(themes_dir: Path) -> None:
write(themes_dir, "default", 'text: "#000000"\n')
assert "default" not in user_themes()
assert load_theme("default") == load_theme(DEFAULT_THEME, user_files=False)
def test_styles_resolve_role_names_to_the_theme_colours(themes_dir: Path) -> None:
write(themes_dir, "mine", 'extends: nord\ncyan: "#00ffff"\nstyles:\n bar.complete: bold accent on cyan\n')
palette, styles, _ = load_theme("mine")
assert styles["bar.complete"] == f"bold {palette['pink']} on #00ffff"
@pytest.mark.parametrize(
"text, reason",
[
("pink: #b48ead\n", "put hex values in quotes"),
('pink: "#fff"\n', "pink:"),
('pink: "#ff79c6"\naccent: "#ff79c6"\n', "pink or accent, not both"),
('pnik: "#ff79c6"\n', "unknown key pnik"),
('styles:\n pink: "#ff79c6"\n', "set pink at the top level"),
("styles:\n rule.line: shiny\n", "styles: rule.line"),
("extends: no-such-theme\n", "not a shipped theme"),
("extends: [nord]\n", "extends must be a quoted theme name"),
("- pink\n", "not a mapping"),
('red: "rgb(1,,2)"\n', "is not a colour"),
("red: off\n", "red must be a quoted colour"), # YAML reads an unquoted off as False
("styles:\n on: bold\n", "quote .* and its value"),
("red: " + "[" * 600 + "]" * 600 + "\n", "cannot read"),
("styles:\n none: link https://example.com\n", "link and conceal not allowed"),
("styles:\n logging.level.error: conceal\n", "link and conceal not allowed"),
("gradients: [red, blue]\n", "gradients must be a mapping"),
("gradients:\n logo: [red]\n", "gradients: unknown key 'logo'"),
("gradients:\n direction: sideways\n", "direction must be horizontal or vertical or diagonal"),
("gradients:\n motion: true\n", "motion must be static or scroll"),
("gradients:\n bar: []\n", "bar must be a list of quoted colours"),
("gradients:\n bar: [[red]]\n", "bar must be a list of quoted colours"),
('gradients:\n bar: [red, "#12"]\n', "gradients: bar: '#12' is not a colour"),
],
)
def test_a_bad_file_is_rejected_with_its_reason(themes_dir: Path, text: str, reason: str) -> None:
write(themes_dir, "bad", text)
with pytest.raises(ValueError, match=f"(?i)bad.yaml: .*{reason}"):
load_theme("bad")
def test_a_yml_file_is_a_theme_too(themes_dir: Path) -> None:
(themes_dir / "mine.yml").write_text('text: "#ffffff"\n', encoding="utf-8")
assert load_theme("mine")[0]["text"] == "#ffffff"
def test_role_names_in_styles_ignore_case(themes_dir: Path) -> None:
write(themes_dir, "mine", "extends: nord\nstyles:\n bar.complete: Bold RED on Accent\n")
palette, styles, _ = load_theme("mine")
assert styles["bar.complete"] == f"Bold {palette['red']} on {palette['pink']}"
def test_rgb_with_spaces_is_stored_without_them(themes_dir: Path) -> None:
write(themes_dir, "mine", 'cyan: "rgb(1, 2, 3)"\n')
palette, styles, _ = load_theme("mine")
assert palette["cyan"] == "rgb(1,2,3)"
build_console_theme(palette, styles, set_terminal_bg=True)
def test_a_file_that_is_not_utf8_is_rejected(themes_dir: Path) -> None:
(themes_dir / "bad.yaml").write_bytes(b"red: \xff\n")
with pytest.raises(ValueError, match="bad.yaml: cannot read"):
load_theme("bad")
def test_yaml_anchors_cannot_expand_a_role_value(themes_dir: Path) -> None:
# an alias bomb: a thousand strings from a few hundred bytes
write(
themes_dir,
"bad",
"description: &a [" + ", ".join(['"lol"'] * 10) + "]\n"
"author: &b [" + ", ".join(["*a"] * 10) + "]\n"
"red: [" + ", ".join(["*b"] * 10) + "]\n",
)
start = time.monotonic()
with pytest.raises(ValueError) as error:
load_theme("bad")
assert time.monotonic() - start < 1
assert len(str(error.value)) < 200
def test_an_unknown_name_is_rejected(themes_dir: Path) -> None:
with pytest.raises(ValueError, match="^unknown theme 'no-such-theme'$"):
load_theme("no-such-theme")
def test_console_falls_back_to_the_shipped_default(themes_dir: Path) -> None:
# console builds its theme at import, so this needs a fresh interpreter
write(themes_dir, DEFAULT_THEME, "pink: #b48ead\n")
script = (
"import sys\n"
"from unshackle.core.config import config\n"
"config.theme = 'default'\n"
"config.directories.themes = sys.argv[1]\n"
"import unshackle.core.console as c\n"
"print(c.primary_scheme['pink'])\n"
)
result = subprocess.run(
[sys.executable, "-c", script, str(themes_dir)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == load_theme(DEFAULT_THEME, user_files=False)[0]["pink"]
assert "unshackle.yaml: pink is empty" in result.stderr
def test_env_theme_lists_themes_and_flags_a_broken_file(themes_dir: Path) -> None:
write(themes_dir, "mine", "extends: nord\n")
write(themes_dir, "bad", "pink: #b48ead\n")
result = CliRunner().invoke(env, ["theme"])
assert result.exit_code == 0, result.output
assert "mine" in result.output and "nord" in result.output
assert "not valid" in result.output
def test_env_theme_marks_the_fallback_as_active(themes_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import unshackle.core.console as console_module
monkeypatch.setattr(config, "theme", "no-such-theme")
monkeypatch.setattr(console_module, "active_theme", DEFAULT_THEME)
result = CliRunner().invoke(env, ["theme"])
assert result.exit_code == 0, result.output
assert "(active, fallback)" in result.output
def test_env_theme_previews_one_theme(themes_dir: Path) -> None:
result = CliRunner().invoke(env, ["theme", "nord"])
assert result.exit_code == 0, result.output
assert "Available Tracks" in result.output
def test_env_theme_names_the_error_for_a_bad_theme(themes_dir: Path) -> None:
result = CliRunner().invoke(env, ["theme", "no-such-theme"])
assert result.exit_code == 1
assert "unknown theme 'no-such-theme'" in result.output
def test_progress_bar_accepts_a_named_colour(monkeypatch: pytest.MonkeyPatch) -> None:
import unshackle.core.console as console_module
palette = {**console_module.primary_scheme, "pink": "magenta", "dark_gray": "default"}
monkeypatch.setattr(console_module, "primary_scheme", palette)
GradientPulseBarColumn()
def test_help_ignores_the_rich_click_theme_env_var() -> None:
assert build_help_config(load_theme(DEFAULT_THEME, user_files=False)[0]).enable_theme_env_var is False
def test_gradients_resolve_role_names_and_take_one_colour(themes_dir: Path) -> None:
write(
themes_dir,
"mine",
'gradients:\n direction: diagonal\n motion: scroll\n bar: [accent, "rgb(1, 2, 3)"]\n rule: Blue\n',
)
palette, _, gradients = load_theme("mine")
assert gradients == {
"direction": "diagonal",
"motion": "scroll",
"bar": (palette["pink"], "rgb(1,2,3)"),
"rule": (palette["blue"],),
}
def test_gradients_merge_over_the_base_theme() -> None:
palette = load_theme(DEFAULT_THEME, user_files=False)[0]
_, _, gradients = apply_theme("x.yaml", {"gradients": {"rule": "#123456"}}, palette, {}, {"bar": ("#000000",)})
assert gradients == {"bar": ("#000000",), "rule": ("#123456",)}
def test_gradient_lut_runs_from_the_first_stop_to_the_last() -> None:
lut = gradient_lut(("#ff0000", "#00ff00", "#0000ff"))
assert [hex_of(lut[0]), hex_of(lut[-1])] == ["#ff0000", "#0000ff"]
cyclic = gradient_lut(("#ff0000", "#0000ff"), cyclic=True)
assert cyclic[0] == cyclic[-1]
assert len(gradient_lut(("red",))) == 1
def hex_of(style: Style) -> str:
assert style.color is not None
return style.color.get_truecolor().hex
def render(renderable: RenderableType) -> list[Segment]:
return list(Console(file=io.StringIO(), color_system="truecolor", width=40).render(renderable))
def colours_of(segments: list[Segment], characters: str) -> list[str]:
"""The colour of each segment whose text is one of *characters*, or is *characters*."""
return [
hex_of(s.style)
for s in segments
if (s.text in characters or s.text == characters) and s.text.strip() and s.style and s.style.color
]
def test_panel_gradient_paints_the_frame_and_not_the_content() -> None:
tree = Tree("root", guide_style="#888888")
tree.add("leaf")
panel = Panel(tree, title="Title", border_style="#888888", box=box.SQUARE, expand=False)
with use_gradients({"panel": ("#ff0000", "#0000ff")}):
segments = render(panel)
assert colours_of(segments, "┌") == ["#ff0000"]
assert colours_of(segments, "┘") == ["#0000ff"]
assert "#888888" in colours_of(segments, "└── ") # the tree guide keeps its colour
assert any("Title" in s.text for s in segments)
def test_rule_gradient_paints_the_line_and_not_the_title() -> None:
with use_gradients({"rule": ("#ff0000", "#0000ff"), "direction": "vertical"}):
segments = render(Rule("[#00ff00]Title", style="#888888"))
line = colours_of(segments, "─")
assert line[0] == "#ff0000" and line[-1] == "#0000ff" # always left to right, whatever the direction
assert colours_of(segments, "Title") == ["#00ff00"]
def test_without_gradients_a_panel_renders_as_rich_draws_it() -> None:
with use_gradients({}):
assert set(colours_of(render(Panel("x", border_style="#888888")), "─│╭╮╰╯")) == {"#888888"}
def test_gradient_fill_static_and_scrolling() -> None:
bar = gradient_fill(gradient_lut(("#ff0000", "#0000ff")), 0.5, 10, None, "#333333")
assert bar.plain == "━━━━━╺━━━━"
assert hex_of(bar.get_style_at_offset(Console(), 0)) == "#ff0000"
scrolled = gradient_fill(gradient_lut(("#ff0000", "#0000ff"), cyclic=True), 0.5, 10, 3.0, "#333333")
assert hex_of(scrolled.get_style_at_offset(Console(), 3)) == "#ff0000"
def test_bar_column_uses_the_gradient_until_the_task_finishes(monkeypatch: pytest.MonkeyPatch) -> None:
import unshackle.core.console as console_module
monkeypatch.setattr(console_module, "theme_gradients", {"bar": ("#ff0000", "#0000ff")})
column = GradientPulseBarColumn()
progress = Progress(column, console=Console(file=io.StringIO()))
progress.add_task("x", total=10, completed=5)
progress.add_task("y", total=10, completed=10)
running, done = progress.tasks
assert type(column.render(running)).__name__ == "_GradientFill"
assert type(column.render(done)).__name__ == "ProgressBar"
def slid(renderable: RenderableType, offset: float) -> list[Segment]:
lines = Console(file=io.StringIO(), color_system="truecolor", width=40).render_lines(renderable, pad=False)
return [segment for line in slide_gradients(lines, offset) for segment in line]
def test_sliding_a_gradient_moves_the_colours_and_loops() -> None:
text = Text("█" * 20, style="#888888")
with use_gradients({"banner": ("#ff0000", "#0000ff")}):
frames = [colours_of(slid(Gradient(text, "banner", "#888888"), o), "█") for o in (0.0, 0.5, 1.0)]
assert frames[0] != frames[1]
assert frames[0] == frames[2]
def test_sliding_moves_a_nested_panel_border_and_nothing_else() -> None:
from rich.padding import Padding
panel = Padding(Panel("x", border_style="#888888", box=box.SQUARE), (0, 3))
with use_gradients({"panel": ("#ff0000", "#0000ff")}):
still = colours_of(render(panel), "┌")
moved = colours_of(slid(panel, 0.5), "┌")
text = colours_of(slid(Text("x", style="#888888"), 0.5), "x")
assert still == ["#ff0000"] and moved != still
assert text == ["#888888"]
def test_an_unreadable_themes_folder_is_skipped_with_a_warning(
themes_dir: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def refuse(self: Path) -> None:
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(Path, "iterdir", refuse)
with caplog.at_level(logging.WARNING, logger="console"):
assert user_themes() == {}
assert "cannot read the themes folder" in caplog.text
def test_a_file_name_with_a_control_character_is_skipped(themes_dir: Path) -> None:
write(themes_dir, "\u202emine", 'text: "#ffffff"\n')
write(themes_dir, "mine", 'text: "#ffffff"\n')
assert list(user_themes()) == ["mine"]
def pinned_console(height: int = 12) -> tuple[io.StringIO, Console]:
out = io.StringIO()
return out, Console(
file=out, force_terminal=True, force_interactive=True, width=60, height=height, color_system="truecolor"
)
def test_pinned_live_sends_old_lines_to_the_scrollback_once_the_screen_is_full() -> None:
out, con = pinned_console()
pinned = PinnedLive(Text("HEADER"), con)
pinned.start(refresh=True)
try:
panel = Panel("first state")
con.print(panel)
panel.renderable = "CHANGED" # a kept print shows the state it had when it was printed
con.print("narrow", width=4)
assert ["".join(segment.text for segment in line) for line in pinned.kept[-2:]] == ["narr", "ow"]
for i in range(30):
con.print(f"line {i:02d}")
assert pinned.header_sent
assert len(pinned.kept) == pinned.room # exactly the lines that fit; the rest went to the scrollback
finally:
pinned.close()
assert pinned.kept == []
plain = Text.from_ansi(out.getvalue()).plain
assert "first state" in plain and "CHANGED" not in plain
assert "line 29" in plain
def test_pinned_live_prints_the_header_at_close_when_nothing_scrolled() -> None:
out, con = pinned_console(height=40)
pinned = PinnedLive(Text("HEADER"), con)
pinned.start(refresh=True)
con.print("one line")
pinned.close()
assert Text.from_ansi(out.getvalue()).plain.rstrip().endswith("HEADER\none line")
def test_a_prompt_inside_a_spinner_keeps_the_live_areas_in_order() -> None:
con = ComfyConsole(file=io.StringIO(), force_terminal=True, force_interactive=True, width=60, height=20)
pinned = PinnedLive(Text("HEADER"), con)
pinned.start()
try:
with con.status("working"):
assert con.input("code: ", stream=io.StringIO("123\n")).strip() == "123"
assert con._live_stack[0] is pinned and not pinned._nested
assert con._live_stack[1]._nested
finally:
pinned.close()
assert con._live_stack == []
def test_sync_codes_wrap_each_print_but_not_on_the_old_windows_console() -> None:
out, con = pinned_console()
with SyncLive(Text("live"), console=con, auto_refresh=False) as live:
con.print("hello")
value = out.getvalue()
assert value.index("\x1b[?2026h") < value.index("hello") < value.index("\x1b[?2026l", value.index("hello"))
con.legacy_windows = True
printed = Text("x")
assert live.synced([printed]) == [printed]
@pytest.mark.parametrize(
"subcommand, args, pinned",
[
("dl", ["dl", "EXAMPLE", "title"], True),
("dl", ["--debug", "dl", "-q", "1080", "EXAMPLE", "title"], True),
("dl", ["dl"], True), # Click prints the usage text after the pinned area closes
("dl", ["dl", "EXAMPLE", "--help"], False),
("dl", ["dl", "-?"], False),
("env", ["env", "info"], False),
],
)
def test_only_a_dl_run_pins_the_banner(
monkeypatch: pytest.MonkeyPatch, subcommand: str, args: list[str], pinned: bool
) -> None:
import unshackle.core.console as console_module
from unshackle.core.__main__ import pins_banner
monkeypatch.setattr(console_module, "theme_gradients", {"banner": ("#ff0000",)})
monkeypatch.setattr(PinnedLive, "wanted", staticmethod(lambda live_console: True))
assert pins_banner(subcommand, args) is pinned
monkeypatch.setattr(console_module, "theme_gradients", {})
assert pins_banner(subcommand, args) is False
def test_the_banner_pins_only_on_an_interactive_colour_terminal() -> None:
_, con = pinned_console()
assert PinnedLive.wanted(con)
assert not PinnedLive.wanted(
Console(file=io.StringIO(), force_terminal=True, force_interactive=True, no_color=True)
)
assert not PinnedLive.wanted(Console(file=io.StringIO(), force_terminal=True, force_interactive=False))
@pytest.mark.parametrize("height", [5, 500])
def test_env_theme_previews_in_a_short_and_a_tall_terminal(
themes_dir: Path, monkeypatch: pytest.MonkeyPatch, height: int
) -> None:
import unshackle.core.console as console_module
monkeypatch.setattr(console_module.console, "_height", height)
result = CliRunner().invoke(env, ["theme", "rainbow"])
assert result.exit_code == 0, result.output
assert result.output.count("github.com/unshackle") == 1
assert "Available Tracks" in result.output
def test_a_narrow_terminal_gets_no_live_animation(monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core.console import fits_terminal
_, con = pinned_console()
monkeypatch.setenv("COLUMNS", str(con.width - 1))
assert not fits_terminal(con) and not PinnedLive.wanted(con)
monkeypatch.setenv("COLUMNS", str(con.width))
assert fits_terminal(con) and PinnedLive.wanted(con)
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
def test_sigterm_during_a_pinned_run_still_prints_the_kept_lines(tmp_path: Path) -> None:
import os
import pty
import signal
script = tmp_path / "pinned.py"
script.write_text(
"import signal, sys, time\n"
"from rich.text import Text\n"
"from unshackle.core.console import PinnedLive, console\n"
"pinned = PinnedLive(Text('HEADER'), console)\n"
"pinned.start(refresh=True)\n"
"signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))\n"
"try:\n"
" console.print('kept line')\n"
" print('READY', file=sys.__stderr__, flush=True)\n"
" time.sleep(30)\n"
"finally:\n"
" pinned.close()\n",
encoding="utf-8",
)
master, slave = pty.openpty()
proc = subprocess.Popen([sys.executable, str(script)], stdin=slave, stdout=slave, stderr=subprocess.PIPE)
os.close(slave)
assert proc.stderr is not None and b"READY" in proc.stderr.readline()
proc.send_signal(signal.SIGTERM)
out = b""
while True:
try:
chunk = os.read(master, 65536)
except OSError:
break
if not chunk:
break
out += chunk
assert proc.wait(timeout=30) == 128 + signal.SIGTERM
tail = out[out.rfind(b"\x1b[?25h") :]
assert b"HEADER" in tail and b"kept line" in tail