Files
gitea-ci/style.py
2026-01-18 17:50:00 +01:00

348 lines
11 KiB
Python

#!/usr/bin/env python3
"""style.py - Shared styling utilities for Python CLI tools.
Provides consistent color handling, Unicode symbols, and output formatting.
Respects NO_COLOR environment variable (https://no-color.org/).
Usage:
from style import Color, Symbol, Diff, Cursor, Spinner, progress_bar
print(f"{Color.BLD}Header{Color.RST}")
print(f"{Symbol.OK} Success")
print(f"{Diff.ADD}+ added{Color.RST}")
print(f"{Color.fg256(208)}orange{Color.RST}")
print(f"{Cursor.HIDE}Working...{Cursor.SHOW}")
print(f"Loading: {progress_bar(0.5)}")
"""
import os
import sys
def _has_color() -> bool:
"""Check if colors should be enabled."""
if os.environ.get("NO_COLOR"):
return False
return sys.stdout.isatty()
class Color:
"""ANSI color codes - respects NO_COLOR and TTY detection.
Curated palette optimized for dark terminals: non-invasive, professional,
readable. Prefer the refined 256-color variants over standard ANSI.
Usage:
print(f"{Color.BLD}Bold text{Color.RST}")
print(f"{Color.OK}Success{Color.RST}") # Refined sage green
print(f"{Color.ERR}Error{Color.RST}") # Refined coral red
"""
_enabled = _has_color()
# Reset and styles
RST = "\033[0m" if _enabled else ""
BLD = "\033[1m" if _enabled else ""
DIM = "\033[2m" if _enabled else ""
ITL = "\033[3m" if _enabled else ""
UND = "\033[4m" if _enabled else ""
STK = "\033[9m" if _enabled else ""
# ─────────────────────────────────────────────────────────────────────────
# Refined palette (256-color) - Curated for dark terminals
# Non-invasive, professional, highly readable
# ─────────────────────────────────────────────────────────────────────────
OK = "\033[38;5;108m" if _enabled else "" # Sage green - success, calm
ERR = "\033[38;5;167m" if _enabled else "" # Soft coral - errors, clear
WARN = "\033[38;5;179m" if _enabled else "" # Muted gold - warnings
INFO = "\033[38;5;67m" if _enabled else "" # Steel blue - info, links
NOTE = "\033[38;5;139m" if _enabled else "" # Dusty lavender - notes
MUTE = "\033[38;5;102m" if _enabled else "" # Gray - secondary text
# ─────────────────────────────────────────────────────────────────────────
# Standard ANSI colors (legacy compatibility)
# ─────────────────────────────────────────────────────────────────────────
RED = "\033[31m" if _enabled else ""
GRN = "\033[32m" if _enabled else ""
YLW = "\033[33m" if _enabled else ""
BLU = "\033[34m" if _enabled else ""
MAG = "\033[35m" if _enabled else ""
CYN = "\033[36m" if _enabled else ""
WHT = "\033[37m" if _enabled else ""
# Bright variants (use sparingly - can be harsh)
BRED = "\033[1;31m" if _enabled else ""
BGRN = "\033[1;32m" if _enabled else ""
BYLW = "\033[1;33m" if _enabled else ""
# Long name aliases for compatibility
BOLD = BLD
GREEN = GRN
YELLOW = YLW
BLUE = BLU
MAGENTA = MAG
CYAN = CYN
WHITE = WHT
STRIKE = STK
@classmethod
def enabled(cls) -> bool:
"""Check if colors are currently enabled."""
return cls._enabled
@classmethod
def fg256(cls, n: int) -> str:
"""256-color foreground. Usage: Color.fg256(208) for orange."""
if not cls._enabled:
return ""
return f"\033[38;5;{n}m"
@classmethod
def bg256(cls, n: int) -> str:
"""256-color background. Usage: Color.bg256(52) for dark red."""
if not cls._enabled:
return ""
return f"\033[48;5;{n}m"
@classmethod
def fg_rgb(cls, r: int, g: int, b: int) -> str:
"""True color (24-bit) foreground."""
if not cls._enabled:
return ""
return f"\033[38;2;{r};{g};{b}m"
@classmethod
def bg_rgb(cls, r: int, g: int, b: int) -> str:
"""True color (24-bit) background."""
if not cls._enabled:
return ""
return f"\033[48;2;{r};{g};{b}m"
class Diff:
"""Muted diff colors for professional output.
Usage:
print(f"{Diff.DEL}- removed line{Color.RST}")
print(f"{Diff.ADD}+ added line{Color.RST}")
"""
_enabled = _has_color()
# Muted backgrounds (256-color palette)
DEL = "\033[48;5;52m" if _enabled else "" # Dark red
ADD = "\033[48;5;22m" if _enabled else "" # Dark green
CHG = "\033[48;5;58m" if _enabled else "" # Dark yellow/olive
class Cursor:
"""ANSI cursor control sequences.
Usage:
print(f"{Cursor.HIDE}Processing...{Cursor.SHOW}")
print(f"Line 1{Cursor.UP}Replaced")
print(f"{Cursor.COL1}Start of line")
"""
_enabled = _has_color()
# Cursor visibility
HIDE = "\033[?25l" if _enabled else ""
SHOW = "\033[?25h" if _enabled else ""
# Line control
CLEAR_LINE = "\033[2K" if _enabled else ""
CLEAR_TO_END = "\033[0K" if _enabled else ""
COL1 = "\033[1G" if _enabled else "" # Move to column 1
# Single movement
UP = "\033[1A" if _enabled else ""
DOWN = "\033[1B" if _enabled else ""
RIGHT = "\033[1C" if _enabled else ""
LEFT = "\033[1D" if _enabled else ""
@classmethod
def up(cls, n: int = 1) -> str:
"""Move cursor up n lines."""
if not cls._enabled or n < 1:
return ""
return f"\033[{n}A"
@classmethod
def down(cls, n: int = 1) -> str:
"""Move cursor down n lines."""
if not cls._enabled or n < 1:
return ""
return f"\033[{n}B"
@classmethod
def right(cls, n: int = 1) -> str:
"""Move cursor right n columns."""
if not cls._enabled or n < 1:
return ""
return f"\033[{n}C"
@classmethod
def left(cls, n: int = 1) -> str:
"""Move cursor left n columns."""
if not cls._enabled or n < 1:
return ""
return f"\033[{n}D"
@classmethod
def col(cls, n: int = 1) -> str:
"""Move cursor to column n (1-indexed)."""
if not cls._enabled or n < 1:
return ""
return f"\033[{n}G"
@classmethod
def pos(cls, row: int, col: int) -> str:
"""Move cursor to absolute position (1-indexed)."""
if not cls._enabled or row < 1 or col < 1:
return ""
return f"\033[{row};{col}H"
@classmethod
def save(cls) -> str:
"""Save cursor position."""
return "\033[s" if cls._enabled else ""
@classmethod
def restore(cls) -> str:
"""Restore cursor position."""
return "\033[u" if cls._enabled else ""
class Symbol:
"""Unicode symbols for CLI output.
Usage:
print(f"{Symbol.OK} Task complete")
print(f"{Symbol.FAIL} Task failed")
"""
# Status indicators
OK = ""
FAIL = ""
WARN = ""
# State indicators
ACTIVE = ""
INACTIVE = ""
PARTIAL = ""
# Flow indicators
ARROW = ""
LARROW = ""
# Box drawing (light)
H_LINE = ""
V_LINE = ""
TL = ""
TR = ""
BL = ""
BR = ""
L_TEE = ""
R_TEE = ""
# Separators
SEP = "" * 40
SEP_DOUBLE = "" * 40
@classmethod
def status(cls, success: bool) -> str:
"""Get colored status symbol using refined palette."""
if success:
return f"{Color.OK}{cls.OK}{Color.RST}"
return f"{Color.ERR}{cls.FAIL}{Color.RST}"
@classmethod
def status_str(cls, status: str) -> str:
"""Get colored symbol for status string using refined palette."""
styles = {
"success": (Color.OK, cls.OK),
"ok": (Color.OK, cls.OK),
"failure": (Color.ERR, cls.FAIL),
"failed": (Color.ERR, cls.FAIL),
"error": (Color.ERR, cls.FAIL),
"warning": (Color.WARN, cls.WARN),
"warn": (Color.WARN, cls.WARN),
"running": (Color.INFO, cls.ACTIVE),
"active": (Color.INFO, cls.ACTIVE),
"pending": (Color.MUTE, cls.INACTIVE),
"waiting": (Color.MUTE, cls.INACTIVE),
"cancelled": (Color.WARN, ""),
"skipped": (Color.MUTE, ""),
"queued": (Color.MUTE, ""),
}
color, sym = styles.get(status.lower(), (Color.MUTE, cls.INACTIVE))
return f"{color}{sym}{Color.RST}"
def sep_line(width: int = 40, double: bool = False) -> str:
"""Generate separator line."""
char = "" if double else ""
return char * width
class Spinner:
"""Animated spinner for progress indication.
Usage:
spinner = Spinner()
for item in items:
print(f"{Cursor.COL1}{spinner.next()} Processing...", end="", flush=True)
print(f"{Cursor.CLEAR_LINE}{Cursor.COL1}Done")
"""
# Pre-defined frame sets
BRAILLE = ("", "", "", "", "", "", "", "", "", "")
DOTS = ("", "", "", "", "", "", "", "")
CIRCLE = ("", "", "", "")
LINE = ("", "\\", "", "/")
ARROW = ("", "", "", "", "", "", "", "")
def __init__(self, frames: tuple[str, ...] | None = None) -> None:
"""Initialize spinner with optional custom frames."""
self.frames = frames or self.BRAILLE
self.index = 0
def next(self) -> str:
"""Get next frame and advance."""
frame = self.frames[self.index]
self.index = (self.index + 1) % len(self.frames)
return frame
def reset(self) -> None:
"""Reset to first frame."""
self.index = 0
def progress_bar(
percent: float,
width: int = 20,
fill: str = "",
empty: str = "",
show_percent: bool = True,
) -> str:
"""Generate a progress bar string.
Usage:
print(f"Progress: {progress_bar(0.75)}")
# Output: Progress: ███████████████░░░░░ 75%
Args:
percent: Progress value 0.0 to 1.0
width: Bar width in characters
fill: Character for filled portion
empty: Character for empty portion
show_percent: Append percentage text
"""
percent = max(0.0, min(1.0, percent)) # Clamp to 0-1
filled = int(width * percent)
bar = fill * filled + empty * (width - filled)
if show_percent:
return f"{bar} {int(percent * 100):3d}%"
return bar