From fddaa3a5bbb7b984869772bc1e3f8301d27167b7 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Wed, 27 Nov 2024 12:42:10 +0100 Subject: [PATCH] clan-cli: Refactor colors to a subset of colors that work in light and dark mode --- pkgs/clan-cli/clan_cli/cmd.py | 26 ++- pkgs/clan-cli/clan_cli/colors.py | 172 +++++++++++++++++++ pkgs/clan-cli/clan_cli/colors/__init__.py | 2 - pkgs/clan-cli/clan_cli/colors/colors.py | 180 -------------------- pkgs/clan-cli/clan_cli/colors/csscolors.py | 183 --------------------- pkgs/clan-cli/clan_cli/custom_logger.py | 43 +++-- pkgs/clan-cli/clan_cli/machines/update.py | 18 +- pkgs/clan-cli/clan_cli/ssh/host.py | 6 +- pkgs/clan-cli/clan_cli/ssh/host_group.py | 2 +- pkgs/clan-cli/shell.nix | 1 + 10 files changed, 245 insertions(+), 388 deletions(-) create mode 100644 pkgs/clan-cli/clan_cli/colors.py delete mode 100644 pkgs/clan-cli/clan_cli/colors/__init__.py delete mode 100644 pkgs/clan-cli/clan_cli/colors/colors.py delete mode 100644 pkgs/clan-cli/clan_cli/colors/csscolors.py diff --git a/pkgs/clan-cli/clan_cli/cmd.py b/pkgs/clan-cli/clan_cli/cmd.py index 78944a8aa..81ee998a5 100644 --- a/pkgs/clan-cli/clan_cli/cmd.py +++ b/pkgs/clan-cli/clan_cli/cmd.py @@ -1,4 +1,5 @@ import contextlib +import json import logging import math import os @@ -11,10 +12,12 @@ import timeit import weakref from collections.abc import Iterator from contextlib import ExitStack, contextmanager +from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import IO, Any +from clan_cli.colors import Color from clan_cli.custom_logger import print_trace from clan_cli.errors import ClanCmdError, ClanError, CmdOut, indent_command @@ -43,16 +46,23 @@ class Log(Enum): NONE = 4 +@dataclass +class MsgColor: + stderr: Color | None = None + stdout: Color | None = None + + def handle_io( process: subprocess.Popen, log: Log, cmdlog: logging.Logger, - prefix: str, *, + prefix: str | None, input_bytes: bytes | None, stdout: IO[bytes] | None, stderr: IO[bytes] | None, timeout: float = math.inf, + msg_color: MsgColor | None = None, ) -> tuple[str, str]: rlist = [process.stdout, process.stderr] wlist = [process.stdin] if input_bytes is not None else [] @@ -85,6 +95,12 @@ def handle_io( rlist.remove(fd) return b"" + extra = {"command_prefix": prefix} + if msg_color and msg_color.stderr: + extra["stderr_color"] = json.dumps(msg_color.stderr.value) + if msg_color and msg_color.stdout: + extra["stdout_color"] = json.dumps(msg_color.stdout.value) + # # Process stdout # @@ -92,7 +108,7 @@ def handle_io( if ret and log in [Log.STDOUT, Log.BOTH]: lines = ret.decode("utf-8", "replace").rstrip("\n").split("\n") for line in lines: - cmdlog.info(line, extra={"command_prefix": prefix}) + cmdlog.info(line, extra=extra) if ret and stdout: stdout.write(ret) stdout.flush() @@ -106,7 +122,7 @@ def handle_io( if ret and log in [Log.STDERR, Log.BOTH]: lines = ret.decode("utf-8", "replace").rstrip("\n").split("\n") for line in lines: - cmdlog.error(line, extra={"command_prefix": prefix}) + cmdlog.error(line, extra=extra) if ret and stderr: stderr.write(ret) stderr.flush() @@ -224,6 +240,7 @@ def run( log: Log = Log.STDERR, logger: logging.Logger = cmdlog, prefix: str | None = None, + msg_color: MsgColor | None = None, check: bool = True, error_msg: str | None = None, needs_user_terminal: bool = False, @@ -234,7 +251,7 @@ def run( cwd = Path.cwd() if prefix is None: - prefix = "localhost" + prefix = "$" if input: if any(not ch.isprintable() for ch in input.decode("ascii", "replace")): @@ -273,6 +290,7 @@ def run( process, log, prefix=prefix, + msg_color=msg_color, cmdlog=logger, timeout=timeout, input_bytes=input, diff --git a/pkgs/clan-cli/clan_cli/colors.py b/pkgs/clan-cli/clan_cli/colors.py new file mode 100644 index 000000000..171379c75 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/colors.py @@ -0,0 +1,172 @@ +import re +from enum import Enum +from typing import Any + +ANSI16_MARKER = 300 +ANSI256_MARKER = 301 +DEFAULT_MARKER = 302 + + +class RgbColor(Enum): + """ + A subset of CSS colors with RGB values that work well in Dark and Light mode. + """ + + TEAL = (0, 130, 128) + OLIVEDRAB = (113, 122, 57) + CHOCOLATE = (198, 77, 45) + MEDIUMORCHID = (170, 74, 198) + SEAGREEN = (49, 128, 125) + SLATEBLUE = (127, 97, 206) + DARKCYAN = (25, 128, 145) + STEELBLUE = (51, 118, 193) + MEDIUMPURPLE = (149, 82, 220) + INDIANRED = (199, 74, 77) + FORESTGREEN = (42, 134, 44) + SLATEGRAY = (75, 123, 140) + LIGHTSLATEGRAY = (125, 106, 170) + MEDIUMSLATEBLUE = (100, 92, 255) + GRAY = (144, 102, 146) + DARKORCHID = (188, 36, 228) + SIENNA = (178, 94, 30) + OLIVE = (133, 116, 33) + PALEVIOLETRED = (186, 77, 136) + DARKGOLDENROD = (180, 93, 0) + MEDIUMVIOLETRED = (212, 2, 184) + BLUEVIOLET = (165, 50, 255) + DIMGRAY = (95, 122, 115) + DARKVIOLET = (202, 12, 211) + DODGERBLUE = (0, 106, 255) + DARKOLIVEGREEN = (88, 128, 41) + + @classmethod + def get_by_name(cls: Any, name: str) -> "RgbColor": + try: + return cls[name.upper()] + except KeyError as ex: + msg = f"Color '{name}' is not a valid color name" + raise ValueError(msg) from ex + + @classmethod + def list_values(cls: Any) -> list[tuple[int, int, int]]: + return [color.value for color in cls] + + +class AnsiColor(Enum): + """Enum representing ANSI colors.""" + + # Standard 16-bit colors + BLACK = (ANSI16_MARKER, 0, 0) + RED = (ANSI16_MARKER, 1, 0) + GREEN = (ANSI16_MARKER, 2, 0) + YELLOW = (ANSI16_MARKER, 3, 0) + BLUE = (ANSI16_MARKER, 4, 0) + MAGENTA = (ANSI16_MARKER, 5, 0) + CYAN = (ANSI16_MARKER, 6, 0) + WHITE = (ANSI16_MARKER, 7, 0) + DEFAULT = (DEFAULT_MARKER, 9, 0) + + # Subset of 256-bit colors + BRIGHT_BLACK = (ANSI256_MARKER, 8, 0) + BRIGHT_RED = (ANSI256_MARKER, 9, 0) + BRIGHT_GREEN = (ANSI256_MARKER, 10, 0) + BRIGHT_YELLOW = (ANSI256_MARKER, 11, 0) + BRIGHT_BLUE = (ANSI256_MARKER, 12, 0) + BRIGHT_MAGENTA = (ANSI256_MARKER, 13, 0) + BRIGHT_CYAN = (ANSI256_MARKER, 14, 0) + BRIGHT_WHITE = (ANSI256_MARKER, 15, 0) + + +Color = AnsiColor | RgbColor + + +class ColorType(Enum): + BG = 40 + FG = 30 + + +def _join(*values: int | str) -> str: + """ + Join a series of values with semicolons. The values + are either integers or strings, so stringify each for + good measure. Worth breaking out as its own function + because semicolon-joined lists are core to ANSI coding. + """ + return ";".join(str(v) for v in values) + + +def color_code(spec: tuple[int, int, int], base: ColorType) -> str: + """ + Workhorse of encoding a color. Give preference to named colors from + ANSI, then to specific numeric or tuple specs. If those don't work, + try looking up CSS color names or parsing CSS color specifications + (hex or rgb). + """ + red = spec[0] + green = spec[1] + blue = spec[2] + val = None + if red == ANSI16_MARKER: + val = _join(base.value + green) + elif red == ANSI256_MARKER: + val = _join(base.value + 8, 5, green) + elif red == DEFAULT_MARKER: + val = _join(base.value + 9) + elif 0 <= red <= 255 and 0 <= green <= 255 and 0 <= blue <= 255: + val = _join(base.value + 8, 2, red, green, blue) + else: + msg = f"Invalid color specification: {spec}" + raise ValueError(msg) + + return val + + +def color_by_tuple( + message: str, + fg: tuple[int, int, int] = AnsiColor.DEFAULT.value, + bg: tuple[int, int, int] = AnsiColor.DEFAULT.value, +) -> str: + codes: list[str] = [] + if fg[0] != DEFAULT_MARKER: + codes.append(color_code(fg, ColorType.FG)) + + if bg[0] != DEFAULT_MARKER: + codes.append(color_code(bg, ColorType.BG)) + + if codes: + template = "\x1b[{0}m{1}\x1b[0m" + return template.format(_join(*codes), message) + return message + + +def color( + message: str, + fg: Color = AnsiColor.DEFAULT, + bg: Color = AnsiColor.DEFAULT, +) -> str: + """ + Add ANSI colors and styles to a string. + """ + return color_by_tuple(message, fg.value, bg.value) + + +def strip_color(message: str) -> str: + """ + Remove ANSI color/style sequences from a string. The set of all possible + ANSI sequences is large, so does not try to strip every possible one. But + does strip some outliers seen not just in text generated by this module, but + by other ANSI colorizers in the wild. Those include `\x1b[K` (aka EL or + erase to end of line) and `\x1b[m`, a terse version of the more common + `\x1b[0m`. + """ + return re.sub("\x1b\\[(K|.*?m)", "", message) + + +if __name__ == "__main__": + print("====ANSI Colors====") + for _, value in AnsiColor.__members__.items(): + print(color_by_tuple(f"{value}", fg=value.value)) + + print("====CSS Colors====") + for _, cs_value in RgbColor.__members__.items(): + print(color_by_tuple(f"{cs_value}", fg=cs_value.value)) diff --git a/pkgs/clan-cli/clan_cli/colors/__init__.py b/pkgs/clan-cli/clan_cli/colors/__init__.py deleted file mode 100644 index e8426f951..000000000 --- a/pkgs/clan-cli/clan_cli/colors/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .colors import * # noqa -from .csscolors import * # noqa diff --git a/pkgs/clan-cli/clan_cli/colors/colors.py b/pkgs/clan-cli/clan_cli/colors/colors.py deleted file mode 100644 index f91438418..000000000 --- a/pkgs/clan-cli/clan_cli/colors/colors.py +++ /dev/null @@ -1,180 +0,0 @@ -# Copyright (c) 2012 Giorgos Verigakis -# -# Permission to use, copy, modify, and distribute this software for any -# purpose with or without fee is hereby granted, provided that the above -# copyright notice and this permission notice appear in all copies. -# -# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -import re -from functools import partial - -from .csscolors import parse_rgb - -# ANSI color names. There is also a "default" -COLORS: tuple[str, ...] = ( - "black", - "red", - "green", - "yellow", - "blue", - "magenta", - "cyan", - "white", -) - -# ANSI style names -STYLES: tuple[str, ...] = ( - "none", - "bold", - "faint", - "italic", - "underline", - "blink", - "blink2", - "negative", - "concealed", - "crossed", -) - - -def is_string(obj: str | bytes) -> bool: - """ - Is the given object a string? - """ - return isinstance(obj, str) - - -def _join(*values: int | str) -> str: - """ - Join a series of values with semicolons. The values - are either integers or strings, so stringify each for - good measure. Worth breaking out as its own function - because semicolon-joined lists are core to ANSI coding. - """ - return ";".join(str(v) for v in values) - - -def color_code(spec: str | int | tuple[int, int, int] | list, base: int) -> str: - """ - Workhorse of encoding a color. Give preference to named colors from - ANSI, then to specific numeric or tuple specs. If those don't work, - try looking up CSS color names or parsing CSS color specifications - (hex or rgb). - - :param str|int|tuple|list spec: Unparsed color specification - :param int base: Either 30 or 40, signifying the base value - for color encoding (foreground and background respectively). - Low values are added directly to the base. Higher values use ` - base + 8` (i.e. 38 or 48) then extended codes. - :returns: Discovered ANSI color encoding. - :rtype: str - :raises: ValueError if cannot parse the color spec. - """ - if isinstance(spec, str | bytes): - spec = spec.strip().lower() - - if spec == "default": - return _join(base + 9) - if spec in COLORS: - return _join(base + COLORS.index(spec)) - if isinstance(spec, int) and 0 <= spec <= 255: - return _join(base + 8, 5, spec) - if isinstance(spec, tuple | list): - return _join(base + 8, 2, _join(*spec)) - rgb = parse_rgb(str(spec)) - # parse_rgb raises ValueError if cannot parse spec - # or returns an rgb tuple if it can - return _join(base + 8, 2, _join(*rgb)) - - -def color( - s: str | None = None, - fg: str | int | tuple[int, int, int] | None = None, - bg: str | int | tuple[int, int, int] | None = None, - style: str | None = None, - reset: bool = True, -) -> str: - """ - Add ANSI colors and styles to a string. - - :param str s: String to format. - :param str|int|tuple fg: Foreground color specification. - :param str|int|tuple bg: Background color specification. - :param str: Style names, separated by '+' - :returns: Formatted string. - :rtype: str (or unicode in Python 2, if s is unicode) - """ - codes: list[int | str] = [] - - if fg: - codes.append(color_code(fg, 30)) - if bg: - codes.append(color_code(bg, 40)) - if style: - for style_part in style.split("+"): - if style_part in STYLES: - codes.append(STYLES.index(style_part)) - else: - msg = f'Invalid style "{style_part}"' - raise ValueError(msg) - - if not s: - s = "" - - if codes: - if reset: - template = "\x1b[{0}m{1}\x1b[0m" - else: - template = "\x1b[{0}m{1}" - return template.format(_join(*codes), s) - return s - - -def strip_color(s: str) -> str: - """ - Remove ANSI color/style sequences from a string. The set of all possible - ANSI sequences is large, so does not try to strip every possible one. But - does strip some outliers seen not just in text generated by this module, but - by other ANSI colorizers in the wild. Those include `\x1b[K` (aka EL or - erase to end of line) and `\x1b[m`, a terse version of the more common - `\x1b[0m`. - """ - return re.sub("\x1b\\[(K|.*?m)", "", s) - - -def ansilen(s: str) -> int: - """ - Given a string with embedded ANSI codes, what would its - length be without those codes? - """ - return len(strip_color(s)) - - -# Foreground color shortcuts -black = partial(color, fg="black") -red = partial(color, fg="red") -green = partial(color, fg="green") -yellow = partial(color, fg="yellow") -blue = partial(color, fg="blue") -magenta = partial(color, fg="magenta") -cyan = partial(color, fg="cyan") -white = partial(color, fg="white") - -# Style shortcuts -bold = partial(color, style="bold") -none = partial(color, style="none") -faint = partial(color, style="faint") -italic = partial(color, style="italic") -underline = partial(color, style="underline") -blink = partial(color, style="blink") -blink2 = partial(color, style="blink2") -negative = partial(color, style="negative") -concealed = partial(color, style="concealed") -crossed = partial(color, style="crossed") diff --git a/pkgs/clan-cli/clan_cli/colors/csscolors.py b/pkgs/clan-cli/clan_cli/colors/csscolors.py deleted file mode 100644 index ae7c2c264..000000000 --- a/pkgs/clan-cli/clan_cli/colors/csscolors.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -Map of CSS color names to RGB integer values. -""" - -import re - -css_colors: dict[str, tuple[int, int, int]] = { - "aliceblue": (240, 248, 255), - "antiquewhite": (250, 235, 215), - "aqua": (0, 255, 255), - "aquamarine": (127, 255, 212), - "azure": (240, 255, 255), - "beige": (245, 245, 220), - "bisque": (255, 228, 196), - "black": (0, 0, 0), - "blanchedalmond": (255, 235, 205), - "blue": (0, 0, 255), - "blueviolet": (138, 43, 226), - "brown": (165, 42, 42), - "burlywood": (222, 184, 135), - "cadetblue": (95, 158, 160), - "chartreuse": (127, 255, 0), - "chocolate": (210, 105, 30), - "coral": (255, 127, 80), - "cornflowerblue": (100, 149, 237), - "cornsilk": (255, 248, 220), - "crimson": (220, 20, 60), - "cyan": (0, 255, 255), - "darkblue": (0, 0, 139), - "darkcyan": (0, 139, 139), - "darkgoldenrod": (184, 134, 11), - "darkgray": (169, 169, 169), - "darkgreen": (0, 100, 0), - "darkgrey": (169, 169, 169), - "darkkhaki": (189, 183, 107), - "darkmagenta": (139, 0, 139), - "darkolivegreen": (85, 107, 47), - "darkorange": (255, 140, 0), - "darkorchid": (153, 50, 204), - "darkred": (139, 0, 0), - "darksalmon": (233, 150, 122), - "darkseagreen": (143, 188, 143), - "darkslateblue": (72, 61, 139), - "darkslategray": (47, 79, 79), - "darkslategrey": (47, 79, 79), - "darkturquoise": (0, 206, 209), - "darkviolet": (148, 0, 211), - "deeppink": (255, 20, 147), - "deepskyblue": (0, 191, 255), - "dimgray": (105, 105, 105), - "dimgrey": (105, 105, 105), - "dodgerblue": (30, 144, 255), - "firebrick": (178, 34, 34), - "floralwhite": (255, 250, 240), - "forestgreen": (34, 139, 34), - "fuchsia": (255, 0, 255), - "gainsboro": (220, 220, 220), - "ghostwhite": (248, 248, 255), - "gold": (255, 215, 0), - "goldenrod": (218, 165, 32), - "gray": (128, 128, 128), - "green": (0, 128, 0), - "greenyellow": (173, 255, 47), - "grey": (128, 128, 128), - "honeydew": (240, 255, 240), - "hotpink": (255, 105, 180), - "indianred": (205, 92, 92), - "indigo": (75, 0, 130), - "ivory": (255, 255, 240), - "khaki": (240, 230, 140), - "lavender": (230, 230, 250), - "lavenderblush": (255, 240, 245), - "lawngreen": (124, 252, 0), - "lemonchiffon": (255, 250, 205), - "lightblue": (173, 216, 230), - "lightcoral": (240, 128, 128), - "lightcyan": (224, 255, 255), - "lightgoldenrodyellow": (250, 250, 210), - "lightgray": (211, 211, 211), - "lightgreen": (144, 238, 144), - "lightgrey": (211, 211, 211), - "lightpink": (255, 182, 193), - "lightsalmon": (255, 160, 122), - "lightseagreen": (32, 178, 170), - "lightskyblue": (135, 206, 250), - "lightslategray": (119, 136, 153), - "lightslategrey": (119, 136, 153), - "lightsteelblue": (176, 196, 222), - "lightyellow": (255, 255, 224), - "lime": (0, 255, 0), - "limegreen": (50, 205, 50), - "linen": (250, 240, 230), - "magenta": (255, 0, 255), - "maroon": (128, 0, 0), - "mediumaquamarine": (102, 205, 170), - "mediumblue": (0, 0, 205), - "mediumorchid": (186, 85, 211), - "mediumpurple": (147, 112, 219), - "mediumseagreen": (60, 179, 113), - "mediumslateblue": (123, 104, 238), - "mediumspringgreen": (0, 250, 154), - "mediumturquoise": (72, 209, 204), - "mediumvioletred": (199, 21, 133), - "midnightblue": (25, 25, 112), - "mintcream": (245, 255, 250), - "mistyrose": (255, 228, 225), - "moccasin": (255, 228, 181), - "navajowhite": (255, 222, 173), - "navy": (0, 0, 128), - "oldlace": (253, 245, 230), - "olive": (128, 128, 0), - "olivedrab": (107, 142, 35), - "orange": (255, 165, 0), - "orangered": (255, 69, 0), - "orchid": (218, 112, 214), - "palegoldenrod": (238, 232, 170), - "palegreen": (152, 251, 152), - "paleturquoise": (175, 238, 238), - "palevioletred": (219, 112, 147), - "papayawhip": (255, 239, 213), - "peachpuff": (255, 218, 185), - "peru": (205, 133, 63), - "pink": (255, 192, 203), - "plum": (221, 160, 221), - "powderblue": (176, 224, 230), - "purple": (128, 0, 128), - "rebeccapurple": (102, 51, 153), - "red": (255, 0, 0), - "rosybrown": (188, 143, 143), - "royalblue": (65, 105, 225), - "saddlebrown": (139, 69, 19), - "salmon": (250, 128, 114), - "sandybrown": (244, 164, 96), - "seagreen": (46, 139, 87), - "seashell": (255, 245, 238), - "sienna": (160, 82, 45), - "silver": (192, 192, 192), - "skyblue": (135, 206, 235), - "slateblue": (106, 90, 205), - "slategray": (112, 128, 144), - "slategrey": (112, 128, 144), - "snow": (255, 250, 250), - "springgreen": (0, 255, 127), - "steelblue": (70, 130, 180), - "tan": (210, 180, 140), - "teal": (0, 128, 128), - "thistle": (216, 191, 216), - "tomato": (255, 99, 71), - "turquoise": (64, 224, 208), - "violet": (238, 130, 238), - "wheat": (245, 222, 179), - "white": (255, 255, 255), - "whitesmoke": (245, 245, 245), - "yellow": (255, 255, 0), - "yellowgreen": (154, 205, 50), -} - - -def parse_rgb(s: str) -> tuple[int, ...]: - s = s.strip().replace(" ", "").lower() - # simple lookup - rgb = css_colors.get(s) - if rgb is not None: - return rgb - - # 6-digit hex - match = re.match("#([a-f0-9]{6})$", s) - if match: - core = match.group(1) - return tuple(int(core[i : i + 2], 16) for i in range(0, 6, 2)) - - # 3-digit hex - match = re.match("#([a-f0-9]{3})$", s) - if match: - return tuple(int(c * 2, 16) for c in match.group(1)) - - # rgb(x,y,z) - match = re.match(r"rgb\((\d+,\d+,\d+)\)", s) - if match: - return tuple(int(v) for v in match.group(1).split(",")) - - msg = f"Could not parse color '{s}'" - raise ValueError(msg) diff --git a/pkgs/clan-cli/clan_cli/custom_logger.py b/pkgs/clan-cli/clan_cli/custom_logger.py index 441bdb862..e79870092 100644 --- a/pkgs/clan-cli/clan_cli/custom_logger.py +++ b/pkgs/clan-cli/clan_cli/custom_logger.py @@ -1,11 +1,12 @@ import inspect +import json import logging import os import sys from pathlib import Path from typing import Any -from clan_cli.colors import color, css_colors +from clan_cli.colors import AnsiColor, RgbColor, color_by_tuple # https://no-color.org DISABLE_COLOR = not sys.stderr.isatty() or os.environ.get("NO_COLOR", "") != "" @@ -25,7 +26,9 @@ class PrefixFormatter(logging.Formatter): print errors in red and warnings in yellow """ - def __init__(self, trace_prints: bool = False, default_prefix: str = "") -> None: + def __init__( + self, trace_prints: bool = False, default_prefix: str | None = None + ) -> None: self.default_prefix = default_prefix self.trace_prints = trace_prints @@ -37,22 +40,35 @@ class PrefixFormatter(logging.Formatter): filepath = _get_filepath(record) if record.levelno == logging.DEBUG: - color_str = "blue" + ansi_color_str = getattr(record, "stdout_color", None) + if ansi_color_str is not None: + ansi_color = json.loads(ansi_color_str) + else: + ansi_color = AnsiColor.BLUE.value elif record.levelno == logging.ERROR: - color_str = "red" + ansi_color_str = getattr(record, "stderr_color", None) + if ansi_color_str is not None: + ansi_color = json.loads(ansi_color_str) + else: + ansi_color = AnsiColor.RED.value elif record.levelno == logging.WARNING: - color_str = "yellow" + ansi_color = AnsiColor.YELLOW.value else: - color_str = None + ansi_color = AnsiColor.DEFAULT.value command_prefix = getattr(record, "command_prefix", self.default_prefix) - if not DISABLE_COLOR: + if DISABLE_COLOR: + if command_prefix: + format_str = f"[{command_prefix}] %(message)s" + else: + format_str = "%(message)s" + elif command_prefix: prefix_color = self.hostname_colorcode(command_prefix) - format_str = color(f"[{command_prefix}]", fg=prefix_color) - format_str += color(" %(message)s", fg=color_str) + format_str = color_by_tuple(f"[{command_prefix}]", fg=prefix_color) + format_str += color_by_tuple(" %(message)s", fg=ansi_color) else: - format_str = f"[{command_prefix}] %(message)s" + format_str = color_by_tuple("%(message)s", fg=ansi_color) if self.trace_prints: format_str += f"\nSource: {filepath}:%(lineno)d::%(funcName)s\n" @@ -60,13 +76,14 @@ class PrefixFormatter(logging.Formatter): return logging.Formatter(format_str).format(record) def hostname_colorcode(self, hostname: str) -> tuple[int, int, int]: + colorcodes = RgbColor.list_values() try: index = self.hostnames.index(hostname) except ValueError: self.hostnames += [hostname] index = self.hostnames.index(hostname) - coloroffset = (index + self.hostname_color_offset) % len(css_colors) - colorcode = list(css_colors.values())[coloroffset] + coloroffset = (index + self.hostname_color_offset) % len(colorcodes) + colorcode = colorcodes[coloroffset] return colorcode @@ -116,7 +133,7 @@ def get_callers(start: int = 2, end: int = 2) -> list[str]: return callers -def print_trace(msg: str, logger: logging.Logger, prefix: str) -> None: +def print_trace(msg: str, logger: logging.Logger, prefix: str | None) -> None: trace_depth = int(os.environ.get("TRACE_DEPTH", "0")) callers = get_callers(3, 4 + trace_depth) diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index cbbf1f0b2..8e226e3c5 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -7,7 +7,8 @@ import sys from clan_cli.api import API from clan_cli.clan_uri import FlakeId -from clan_cli.cmd import run +from clan_cli.cmd import MsgColor, run +from clan_cli.colors import AnsiColor from clan_cli.completions import ( add_dynamic_completer, complete_machines, @@ -147,17 +148,26 @@ def deploy_machine(machines: MachineGroup) -> None: test_cmd.extend(["--target-host", target_host.target]) env = host.nix_ssh_env(None) - ret = host.run(switch_cmd, extra_env=env, check=False) + ret = host.run( + switch_cmd, + extra_env=env, + check=False, + msg_color=MsgColor(stderr=AnsiColor.DEFAULT), + ) # if the machine is mobile, we retry to deploy with the quirk method is_mobile = machine.deployment.get("nixosMobileWorkaround", False) if is_mobile and ret.returncode != 0: log.info("Mobile machine detected, applying quirk deployment method") - ret = host.run(test_cmd, extra_env=env) + ret = host.run( + test_cmd, extra_env=env, msg_color=MsgColor(stderr=AnsiColor.DEFAULT) + ) # retry nixos-rebuild switch if the first attempt failed elif ret.returncode != 0: - ret = host.run(switch_cmd, extra_env=env) + ret = host.run( + switch_cmd, extra_env=env, msg_color=MsgColor(stderr=AnsiColor.DEFAULT) + ) if len(machines.group.hosts) > 1: machines.run_function(deploy) diff --git a/pkgs/clan-cli/clan_cli/ssh/host.py b/pkgs/clan-cli/clan_cli/ssh/host.py index a45aefc26..6ad4c8778 100644 --- a/pkgs/clan-cli/clan_cli/ssh/host.py +++ b/pkgs/clan-cli/clan_cli/ssh/host.py @@ -10,7 +10,7 @@ from pathlib import Path from shlex import quote from typing import IO, Any -from clan_cli.cmd import Log +from clan_cli.cmd import Log, MsgColor from clan_cli.cmd import run as local_run from clan_cli.ssh.host_key import HostKeyCheck @@ -68,6 +68,7 @@ class Host: check: bool = True, error_msg: str | None = None, needs_user_terminal: bool = False, + msg_color: MsgColor | None = None, shell: bool = False, timeout: float = math.inf, ) -> subprocess.CompletedProcess[str]: @@ -85,6 +86,7 @@ class Host: logger=cmdlog, check=check, error_msg=error_msg, + msg_color=msg_color, needs_user_terminal=needs_user_terminal, ) return subprocess.CompletedProcess( @@ -141,6 +143,7 @@ class Host: timeout: float = math.inf, verbose_ssh: bool = False, tty: bool = False, + msg_color: MsgColor | None = None, shell: bool = False, log: Log = Log.BOTH, ) -> subprocess.CompletedProcess[str]: @@ -192,6 +195,7 @@ class Host: cwd=cwd, check=check, timeout=timeout, + msg_color=msg_color, needs_user_terminal=True, # ssh asks for a password ) diff --git a/pkgs/clan-cli/clan_cli/ssh/host_group.py b/pkgs/clan-cli/clan_cli/ssh/host_group.py index c6434441d..bf1890c16 100644 --- a/pkgs/clan-cli/clan_cli/ssh/host_group.py +++ b/pkgs/clan-cli/clan_cli/ssh/host_group.py @@ -116,7 +116,7 @@ class HostGroup: errors += 1 if errors > 0: msg = f"{errors} hosts failed with an error. Check the logs above" - raise ClanError(msg) + raise ClanError(msg) from e def _run( self, diff --git a/pkgs/clan-cli/shell.nix b/pkgs/clan-cli/shell.nix index 1f51b4edd..1e1b4dd8c 100644 --- a/pkgs/clan-cli/shell.nix +++ b/pkgs/clan-cli/shell.nix @@ -15,6 +15,7 @@ let rope setuptools wheel + webcolors pip ]); in