Color is automatically adapted to the theme of the console.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 11:50:28 +02:00
parent 88cc410eed
commit 95107117fa

View File

@@ -1,32 +1,102 @@
import colorama
import os
import re
import sys
import colorama
from colorama import Fore, Style
COLOR_DEFAULT = Fore.WHITE
COLOR_RESET = Fore.RESET + Style.RESET_ALL + COLOR_DEFAULT
def _detect_dark_background() -> bool:
"""Detect whether the terminal has a dark background.
def colored_string(string: str, inputs: list) -> None:
"""Function which calculate the coloring of strings with many layers.
Overlap of layers and inner layers are managed.
Tries the following methods in order:
1. ``COLORFGBG`` environment variable (Konsole, rxvt, …)
2. OSC 11 terminal query — reads the actual background colour from the
terminal emulator (xterm, VTE, kitty, WezTerm, …)
3. ``darkdetect`` module — OS-level dark-mode preference (optional dep)
Returns ``True`` for a dark background (default assumption).
"""
cols = [COLOR_DEFAULT for i in range(len(string))]
for input in inputs:
for i in range(input[0][0], input[0][1]):
cols[i] = input[1]
# --- Method 1: COLORFGBG ---
colorfgbg = os.environ.get("COLORFGBG", "")
if colorfgbg:
try:
bg = int(colorfgbg.split(";")[-1])
# 0-6: dark palette entries, 7-15: light palette entries
return bg < 7
except (ValueError, IndexError):
pass
# --- Method 2: OSC 11 terminal query ---
if sys.stdin.isatty() and sys.stdout.isatty():
try:
import select
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
# Query background colour
sys.stdout.write("\033]11;?\007")
sys.stdout.flush()
ready, _, _ = select.select([sys.stdin], [], [], 0.2)
if ready:
response = ""
while True:
r2, _, _ = select.select([sys.stdin], [], [], 0.05)
if not r2:
break
chunk = os.read(fd, 64).decode("latin-1", errors="replace")
response += chunk
# Terminal answers with ESC]11;rgb:RR../GG../BB..<BEL|ST>
if response.endswith("\007") or response.endswith("\033\\"):
break
m = re.search(
r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)",
response,
)
if m:
# Components are 8- or 16-bit hex; normalise to 0-255
def _norm(h: str) -> float:
return int(h[:2], 16)
r_v = _norm(m.group(1))
g_v = _norm(m.group(2))
b_v = _norm(m.group(3))
luminance = 0.299 * r_v + 0.587 * g_v + 0.114 * b_v
return luminance < 128
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
except Exception:
pass
# Default: assume dark terminal
return True
def _colored_string(string: str, inputs: list, color_default: str, color_reset: str) -> str:
"""Return *string* with ANSI colour codes applied according to *inputs*.
*inputs* is a list of ``[[start, end], color_code]`` pairs.
Overlapping layers are handled: the last listed colour wins.
"""
cols = [color_default for _ in range(len(string))]
for span, color in inputs:
for i in range(span[0], span[1]):
cols[i] = color
# construction of the string
s = ""
ilast = 0
last_col = COLOR_DEFAULT
last_col = color_default
for i in range(len(string)):
if last_col != cols[i]:
s = s + string[ilast:i] + COLOR_RESET + cols[i]
s = s + string[ilast:i] + color_reset + cols[i]
ilast = i
last_col = cols[i]
return s + string[ilast:] + COLOR_RESET
return s + string[ilast:] + color_reset
class TermLog:
@@ -37,46 +107,74 @@ class TermLog:
DEBUG = ["DEBUG"]
BOOL = ["False", "True", "false", "true", "FALSE", "TRUE"]
def __init__(self, out) -> None:
"""Class used to color the stdout in batch and terminal mode."""
def __init__(self, out, dark_bg: bool = None) -> None:
"""Class used to colour the stdout in batch and terminal mode.
:param out: Underlying output stream.
:param dark_bg: ``True`` for dark background, ``False`` for light.
``None`` (default) triggers auto-detection.
"""
colorama.init()
self.out = out
self.pats = []
self.pats = self.pats + [
[re.compile('(\\"[^\\"]+\\")'), Fore.LIGHTBLUE_EX + Style.BRIGHT],
[re.compile("(\\'[^\\']+\\')"), Fore.LIGHTBLUE_EX + Style.BRIGHT],
[re.compile("(<-----|----->) step"), Fore.BLUE],
[
re.compile(
r"([\d\.]+)",
),
Fore.MAGENTA,
],
[re.compile(r"(@@\d+@@)"), Fore.BLACK],
self.residue = ""
if dark_bg is None:
dark_bg = _detect_dark_background()
if dark_bg:
color_default = Fore.WHITE
color_string = Fore.LIGHTBLUE_EX + Style.BRIGHT
color_number = Fore.MAGENTA
color_bool = Fore.MAGENTA
color_step = Fore.BLUE
color_marker = Fore.BLACK
color_warn = Fore.YELLOW
color_info = Style.BRIGHT
color_debug = Fore.BLUE + Style.BRIGHT
color_pass = Fore.GREEN + Style.BRIGHT
color_fail = Fore.RED + Style.BRIGHT
else:
color_default = Fore.RESET
color_string = Fore.BLUE
color_number = Fore.MAGENTA
color_bool = Fore.MAGENTA
color_step = Fore.BLUE
color_marker = Fore.RESET
color_warn = Fore.YELLOW + Style.BRIGHT
color_info = Fore.CYAN
color_debug = Fore.BLUE
color_pass = Fore.GREEN
color_fail = Fore.RED + Style.BRIGHT
self._color_default = color_default
self._color_reset = Fore.RESET + Style.RESET_ALL + color_default
self.pats = [
[re.compile(r'("(?:[^"]+)")'), color_string],
[re.compile(r"('(?:[^']+)')"), color_string],
[re.compile(r"(<-----|----->) step"), color_step],
[re.compile(r"([\d\.]+)"), color_number],
[re.compile(r"(@@\d+@@)"), color_marker],
]
for word in self.BOOL:
self.pats.append([re.compile("({})".format(word)), Fore.MAGENTA])
self.pats.append([re.compile(r"({})".format(word)), color_bool])
for word in self.WARN:
self.pats.append([re.compile("({})".format(word)), Fore.YELLOW])
self.pats.append([re.compile(r"({})".format(word)), color_warn])
for word in self.INFO:
self.pats.append([re.compile("({})".format(word)), Style.BRIGHT])
self.pats.append([re.compile(r"({})".format(word)), color_info])
for word in self.DEBUG:
self.pats.append([re.compile("({})".format(word)), Fore.BLUE + Style.BRIGHT])
self.pats.append([re.compile(r"({})".format(word)), color_debug])
for word in self.PASS:
self.pats.append(
[re.compile("({})".format(word)), Fore.GREEN + Style.BRIGHT]
)
self.pats.append([re.compile(r"({})".format(word)), color_pass])
for word in self.FAIL:
self.pats.append([re.compile("({})".format(word)), Fore.RED + Style.BRIGHT])
self.residue = ""
self.pats.append([re.compile(r"({})".format(word)), color_fail])
def find_pats(self, line):
spans = []
for p in self.pats:
it = p[0].finditer(line)
for m in it:
for p, color in self.pats:
for m in p.finditer(line):
if m:
spans.append([m.span(), p[1]])
spans.append([m.span(), color])
return spans
def write(self, s: str) -> None:
@@ -87,15 +185,19 @@ class TermLog:
if s[-1:] != "\n":
pos = s.rfind("\n")
if pos >= 0:
self.residue = s[pos:]
s = s[:pos]
self.residue = s[pos + 1:]
s = s[:pos + 1]
else:
# only one line
self.out.write(colored_string(s, self.find_pats(s)))
# single incomplete line — output immediately
self.out.write(_colored_string(s, self.find_pats(s),
self._color_default, self._color_reset))
return
# multiline case
for l in s.splitlines():
self.out.write(colored_string(l, self.find_pats(l)) + "\n")
# one or more complete lines
for line in s.splitlines():
self.out.write(
_colored_string(line, self.find_pats(line),
self._color_default, self._color_reset) + "\n"
)
def flush(self):
if self.residue != "":