clan-cli: Refactor colors to a subset of colors that work in light and dark mode

This commit is contained in:
Qubasa
2024-11-27 12:42:10 +01:00
parent 781334344c
commit fddaa3a5bb
10 changed files with 245 additions and 388 deletions

View File

@@ -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,

View File

@@ -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))

View File

@@ -1,2 +0,0 @@
from .colors import * # noqa
from .csscolors import * # noqa

View File

@@ -1,180 +0,0 @@
# Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
#
# 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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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,

View File

@@ -15,6 +15,7 @@ let
rope
setuptools
wheel
webcolors
pip
]);
in