Coverage for src/edwh/helpers.py: 22%
315 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 17:03 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 17:03 +0200
1"""
2This file contains re-usable helpers.
3"""
5import abc
6import datetime as dt
7import functools
8import io
9import itertools
10import os
11import re
12import sys
13import typing as t
14from pathlib import Path
16import click
17import diceware
18import invoke
19import yaml
20from ewok import Context
21from more_itertools import flatten as _flatten
23from .constants import DOCKER_COMPOSE, AnyDict
26def confirm(prompt: str, default: bool = False, allowed: set[str] | None = None, strict: bool = False) -> bool:
27 """
28 Prompt a user to confirm a (dangerous) action.
29 By default, entering nothing (only enter) will result in False, unless 'default' is set to True.
30 """
31 if os.environ.get("EDWH_NON_INTERACTIVE", "0") == "1": 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true
32 if strict:
33 raise RuntimeError(f"Prevented strict `confirm({prompt})` in --non-interactive mode")
34 else:
35 return default
37 allowed = allowed or {"y", "t", "1"}
38 if default: 38 ↛ 41line 38 didn't jump to line 41 because the condition on line 38 was always true
39 allowed.add(" ")
41 answer = input(prompt).lower().strip()
42 answer += " "
44 confirmed = answer.strip() in allowed or answer[0] in allowed
46 if strict and not confirmed: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true
47 raise RuntimeError(f"Stopping now because '{answer.strip()}' did not match {allowed}.")
49 return confirmed
52def executes_correctly(c: Context, argument: str) -> bool:
53 """returns True if the execution was without error level"""
54 ran = c.run(argument, warn=True, hide=True)
55 return bool(ran and ran.ok)
58def execution_fails(c: Context, argument: str) -> bool:
59 """Returns true if the execution fails based on error level"""
60 return not executes_correctly(c, argument)
63def run_pty(ctx: Context, *command_parts: str, **options) -> invoke.Result | None:
64 try:
65 command = " ".join(command_parts)
66 return ctx.run(command, pty=True, **options)
67 except invoke.exceptions.Failure:
68 # error is already printed due to `pty`
69 return None
72def run_pty_ok(ctx: Context, *command_parts: str, **options) -> bool:
73 result = run_pty(ctx, *command_parts, **options)
74 return bool(result and result.ok)
77def generate_password(silent: bool = True, dice: int = 6) -> str:
78 """Generate a diceware password using --dice 6."""
79 options = diceware.handle_options(args=[])
80 options.num = dice
81 password: str = diceware.get_passphrase(options)
82 if not silent:
83 print("Password:", password)
84 return password
87def _add_dash(flag: str) -> str:
88 if flag.startswith("-"):
89 # don't change
90 return flag
91 if len(flag) == 1:
92 # one letter, -x
93 return f"-{flag}"
94 else:
95 # multiple letters --flag
96 return f"--{flag}"
99def arg_was_passed(flag: str | tuple[str, ...]) -> int | None:
100 """
101 Returns the index of the flag in sys.argv if passed, else None
102 """
103 flag = flag if isinstance(flag, tuple) else (flag,)
104 flag = tuple(_add_dash(f) for f in flag)
105 # flag and sys.argv should now both be in the same format: -x and --flag
106 return next((i for i, item in enumerate(sys.argv) if item in flag), None)
109def kwargs_to_options(data: AnyDict | None = None, **kw: t.Any) -> str:
110 """
111 Convert a dictionary of options to the cli variant
112 e.g. {'a': 1, 'key': 2} -> -a 1 --key 2
113 """
114 if data:
115 kw |= data
117 options = []
118 for key, value in kw.items():
119 if value in (None, "", False):
120 # skip falsey, but keep 0
121 continue
123 pref = ("-" if len(key) == 1 else "--") + key
125 if isinstance(value, bool):
126 options.append(f"{pref}")
128 elif isinstance(value, list):
129 options.extend(f"{pref} {subvalue}" for subvalue in value)
130 else:
131 options.append(f"{pref} {value}")
133 return " " + " ".join(options)
136class Logger(abc.ABC):
137 def log(self, *a: t.Any) -> None:
138 raise NotImplementedError("This is an abstract method")
141class VerboseLogger(Logger):
142 def __init__(self) -> None:
143 self._then = self._now()
144 self._previous = self._now()
146 @staticmethod
147 def _now() -> dt.datetime:
148 return dt.datetime.now(dt.timezone.utc)
150 def log(self, *a: t.Any) -> None:
151 now = self._now()
152 delta_start = now - self._then
153 delta_prev = now - self._previous
154 print(f"[{delta_start}, +{delta_prev}]", *a, file=sys.stderr)
155 self._previous = now
158# usage:
159# logger = VerboseLogger()
160# log = logger.log
161# ...
162# log("some event")
165class NoopLogger(Logger):
166 def log(self, *_: t.Any) -> None:
167 return None
170def noop(*_: t.Any, **__: t.Any) -> None:
171 return None
174@t.overload
175def dump_set_as_list[T](data: set[T]) -> list[T]:
176 """
177 Sets are converted to lists.
178 """
181@t.overload
182def dump_set_as_list[T](data: T) -> T:
183 """
184 Other datatypes remain untouched.
185 """
188def dump_set_as_list[T](data: set[T] | T) -> list[T] | T:
189 if isinstance(data, set):
190 return list(data)
191 else:
192 return data
195KEY_ENTER = "\r"
196KEY_ARROWUP = "\033[A"
197KEY_ARROWDOWN = "\033[B"
200def print_box(label: str, selected: bool, current: bool, number: int, fmt: str = "[%s]", filler: str = "x") -> None:
201 box = fmt % (filler if selected else " ")
202 indicator = ">" if current else " "
203 click.echo(f"{indicator}{number}. {box} {label}")
206def interactive_selected_checkbox_values[H: t.Hashable](
207 options: list[str] | dict[H, str],
208 prompt: str = "Select options (use arrow keys, spacebar, or digit keys, press 'Enter' to finish):",
209 selected: t.Collection[H] = (),
210 allow_empty: bool = False,
211) -> list[str | H] | None:
212 """
213 This function provides an interactive checkbox selection in the console.
215 The user can navigate through the options using the arrow keys,
216 select/deselect options using the spacebar or digit keys, and finish the selection by pressing 'Enter'.
218 Args:
219 options: A list or dict (value: label) of options to be displayed as checkboxes.
220 prompt (str, optional): A string that is displayed as a prompt for the user.
221 allow_empty (bool, optional): If True, adds an extra option "(none)" to deselect all other options.
222 selected: a set (/other iterable) of pre-selected options (set is preferred).
224 `H: Hashable` means the values have to be the same type as the keys of options
225 and they should be hashable (via `hash()`).
226 Example:
227 options = {1: "something", "two": "else"}
228 selected = [2, "three"] # valid type (int and str are keys of options)
229 selected = [1.5, "two"] # invalid type (none of the keys of options are a float)
231 Returns:
232 list[str]: A list of selected option values.
234 Examples:
235 interactive_selected_checkbox_values(["first", "second", "third"])
237 interactive_selected_checkbox_values({100: "first", 211: "second", 355: "third"})
239 interactive_selected_checkbox_values(["first", "second", "third"], selected=["third"])
241 interactive_selected_checkbox_values({1: "first", 2: "second", 3: "third"}, selected=[3])
242 """
243 checked_indices: dict[int, str | H] = {} # instead of set to keep ordering
244 current_index = 0
246 if isinstance(options, list):
247 labels = options
248 option_values = t.cast(t.Sequence[str | H], options)
249 else:
250 labels = list(options.values())
251 option_values = t.cast(t.Sequence[str | H], list(options))
253 for item in selected:
254 if item not in option_values:
255 # invalid
256 continue
258 idx = option_values.index(item)
259 checked_indices[idx] = option_values[idx]
261 if allow_empty:
262 labels.append("(none)")
264 print_checkbox = functools.partial(print_box, fmt="[%s]", filler="x")
266 while True:
267 click.clear()
268 click.echo(prompt)
270 for i, option in enumerate(labels, start=1):
271 print_checkbox(option, i - 1 in checked_indices, i - 1 == current_index, i)
273 key = click.getchar()
275 if key == KEY_ENTER:
276 break
277 elif key == KEY_ARROWUP: # Up arrow
278 current_index = (current_index - 1) % len(labels)
279 elif key == KEY_ARROWDOWN: # Down arrow
280 current_index = (current_index + 1) % len(labels)
281 elif key.isdigit() and 1 <= int(key) <= len(labels):
282 current_index = int(key) - 1
283 elif key == " ":
284 if allow_empty and current_index == len(labels) - 1:
285 checked_indices.clear()
286 checked_indices[len(labels) - 1] = "(none)"
287 else:
288 if len(checked_indices) == 1 and set(checked_indices.values()) == {"(none)"}:
289 checked_indices.clear()
290 if current_index in checked_indices:
291 del checked_indices[current_index]
292 else:
293 checked_indices[current_index] = option_values[current_index]
295 if allow_empty and len(checked_indices) == 1 and set(checked_indices.values()) == {"(none)"}:
296 # None instead of empty list since otherwise it would just ask again
297 return None
299 return list(checked_indices.values())
302def interactive_selected_radio_value[H: t.Hashable](
303 options: list[str] | dict[H, str],
304 prompt: str = "Select an option (use arrow keys, spacebar, or digit keys, press 'Enter' to finish):",
305 selected: H | None = None,
306 allow_empty: bool = False,
307) -> str | H | None:
308 """
309 This function provides an interactive radio box selection in the console.
311 The user can navigate through the options using the arrow keys,
312 select an option using the spacebar or digit keys, and finish the selection by pressing 'Enter'.
314 Args:
315 options: A list or dict (value: label) of options to be displayed as radio boxes.
316 prompt (str, optional): A string that is displayed as a prompt for the user.
317 allow_empty (bool, optional): If True, adds an extra option "(none)" to allow deselecting all options.
318 selected: a pre-selected option.
319 `H: Hashable` means the values have to be the same type as the keys of options
320 and they should be hashable (via `hash()`).
321 Example:
322 options = {1: "something", "two": "else"}
323 selected = 2 # valid type (int is a key of options)
324 selected = 1.5 # invalid type (none of the keys of options are a float)
326 Returns:
327 str: The selected option value, or an empty string if (none) is selected.
329 Examples:
330 interactive_selected_radio_value(["first", "second", "third"])
332 interactive_selected_radio_value({100: "first", 211: "second", 355: "third"})
334 interactive_selected_radio_value(["first", "second", "third"], selected="third")
336 interactive_selected_radio_value({1: "first", 2: "second", 3: "third"}, selected=3)
337 """
338 selected_index: int | None = None
339 current_index = 0
341 if isinstance(options, list):
342 labels = options
343 option_values = t.cast(t.Sequence[str | H], options)
344 else:
345 labels = list(options.values())
346 option_values = t.cast(t.Sequence[str | H], list(options))
348 if selected in option_values:
349 selected_index = current_index = option_values.index(selected)
351 if allow_empty:
352 labels.append("(none)")
354 print_radio_box = functools.partial(print_box, fmt="(%s)", filler="o")
356 while True:
357 click.clear()
358 click.echo(prompt)
360 for i, option in enumerate(labels, start=1):
361 print_radio_box(option, i - 1 == selected_index, i - 1 == current_index, i)
363 key = click.getchar()
365 if key == KEY_ENTER:
366 if selected_index is None:
367 # no you may not leave.
368 continue
369 else:
370 # done!
371 break
373 elif key == KEY_ARROWUP: # Up arrow
374 current_index = (current_index - 1) % len(labels)
375 elif key == KEY_ARROWDOWN: # Down arrow
376 current_index = (current_index + 1) % len(labels)
377 elif key.isdigit() and 1 <= int(key) <= len(labels):
378 selected_index = int(key) - 1
379 elif key == " ":
380 selected_index = current_index
382 if allow_empty and selected_index == len(labels) - 1:
383 return None
385 return option_values[selected_index]
388def yaml_loads(text: str) -> AnyDict:
389 dct = yaml.load(
390 text,
391 Loader=yaml.SafeLoader,
392 )
393 return t.cast(AnyDict, dct)
396def dc_config(ctx: Context) -> AnyDict:
397 if ran := ctx.run(f"{DOCKER_COMPOSE} config", warn=True, echo=False, hide=True):
398 return (
399 yaml_loads(
400 ran.stdout.strip(),
401 )
402 or {}
403 )
404 else:
405 return {}
408def print_aligned(plugin_commands: list[str]) -> None:
409 """
410 Prints a list of plugin commands in an aligned format.
412 This function takes a list of plugin commands, each of which is a string containing two parts separated by a tab.
413 It splits each command into two parts, calculates the maximum length of the first part across all commands,
414 and then prints each command with the first part left-justified to the maximum length. This ensures that the
415 second parts of all commands are aligned in the output.
417 Args:
418 plugin_commands (list[str]): A list of plugin commands. Each command is a string containing two parts
419 separated by a tab.
421 Example:
422 print_aligned(["command1\tdescription1", "command_with_long_name\tdescription2"])
423 # Output:
424 # command1 description1
425 # command_with_long_name description2
426 """
427 splitted = [_.split("\t") for _ in plugin_commands]
428 max_l = max([len(_[0]) for _ in splitted])
430 for before, after in splitted:
431 print("\t", before.ljust(max_l, " "), "\t\t", after)
434def flatten[T](something: t.Iterable[t.Iterable[T]]) -> list[T]:
435 """
436 Like itertools.flatten but eager
437 """
438 return list(_flatten(something))
441def shorten(text: str, max_chars: int) -> str:
442 """
443 textwrap looks at words and stuff, not relevant for commands!
444 """
445 if len(text) <= max_chars:
446 return text
447 else:
448 return f"{text[:max_chars]}..."
451def _fabric_resolve_home(path: str, user: str) -> str:
452 if not path.startswith("~"):
453 return path
455 return path.replace("~", f"/home/{user}", 1)
458def _write_bytes_remote(c: Context, path: str, contents: bytes, parents: bool = False) -> None:
459 f = io.BytesIO(contents)
461 if parents:
462 # ensure path to file exists
463 parent_path = Path(path).parent
464 c.run(f"mkdir -p {parent_path}")
466 c.put(f, path)
469def _write_bytes_local(_: Context, path: str, contents: bytes, parents: bool = False) -> None:
470 p = Path(path)
471 if parents:
472 p.parent.mkdir(parents=True, exist_ok=True)
474 p.write_bytes(contents)
477class WriteBytesFn(t.Protocol):
478 def __call__(self, c: Context, path: str, contents: bytes, parents: bool = False) -> None: ... 478 ↛ exitline 478 didn't return from function '__call__' because
481def fabric_write(c: Context, path: str, contents: str | bytes, parents: bool = False) -> None:
482 """
483 Write some contents to a remote file.
484 ~ will be resolved to the remote user's home
485 """
486 path = _fabric_resolve_home(path, c.user) if c.user else path
488 fn = t.cast(WriteBytesFn, _write_bytes_local if isinstance(c, invoke.Context) else _write_bytes_remote)
490 return fn(c, path, contents if isinstance(contents, bytes) else contents.encode(), parents=parents)
493def _read_bytes_remote(c: Context, path: str) -> bytes:
494 buf = io.BytesIO()
495 c.get(path, buf)
497 buf.seek(0)
498 return buf.read()
501def _read_bytes_local(_: Context, path: str) -> bytes:
502 return Path(path).read_bytes()
505type ReadBytesFn = t.Callable[[Context, str], bytes]
508def fabric_read_bytes(c: Context, path: str, throw: bool = True) -> bytes:
509 """
510 Write some bytes from a remote file.
511 ~ will be resolved to the remote user's home
512 """
513 path = _fabric_resolve_home(path, c.user) if c.user else path
515 fn: ReadBytesFn = _read_bytes_local if isinstance(c, invoke.Context) else _read_bytes_remote
517 try:
518 return fn(c, path)
519 except FileNotFoundError:
520 if throw:
521 raise
522 else:
523 return b""
526def fabric_read(c: Context, path: str, throw: bool = True) -> str:
527 """
528 Write some text from a remote file.
529 ~ will be resolved to the remote user's home
530 """
531 b = fabric_read_bytes(c, path, throw=throw)
532 return b.decode()
535def _add_alias(sometask: t.Any, alias: str):
536 if alias not in sometask.aliases:
537 sometask.aliases = (*sometask.aliases, alias)
540def add_alias(sometask: t.Any, aliases: str | t.Iterable[str]):
541 """
542 Add an extra alias to an existing task (usually in ~/.config/edwh/tasks.py).
544 Example:
545 >>> edwh.add_alias(edwh.tasks.migrate, "migarte")
546 >>> edwh.add_alias(edwh.tasks.migrate, ["migarte"])
547 >>> edwh.add_alias(edwh.tasks.migrate, ("migarte",))
548 """
549 if isinstance(aliases, str):
550 aliases = [aliases]
552 for alias in aliases:
553 _add_alias(sometask, alias)
556type ColorFn = t.Callable[[str], str]
557type FilterFn = t.Callable[[str], bool] | None
560class Handler(abc.ABC):
561 @abc.abstractmethod
562 def process(self, chunk: str):
563 pass
566class NoopHandler(Handler):
567 def process(self, chunk: str): # noqa: ARG002
568 return
571class LineBufferHandler(Handler):
572 def __init__(self, prefix: str, output_stream: t.TextIO, filter_fn: FilterFn = None):
573 self.buffer = ""
574 self.prefix = prefix
575 self.output_stream = output_stream
576 self.filter_fn = filter_fn
578 def process(self, chunk: str):
579 if not chunk:
580 return
582 filter_fn = self.filter_fn
583 self.buffer += chunk
585 if "\n" in self.buffer:
586 lines = self.buffer.splitlines(True)
588 if not self.buffer.endswith("\n"):
589 # last line wasn't finished yet, save it to buffer instead of printing
590 self.buffer = lines.pop()
591 else:
592 # all lines complete, clean buffer
593 self.buffer = ""
595 for line in lines:
596 if filter_fn and not filter_fn(line):
597 continue
599 print(f"{self.prefix}{line}", end="", flush=True, file=self.output_stream)
602POSSIBLE_FLAGS = {
603 # https://docs.python.org/3/library/re.html
604 "a": re.ASCII,
605 "d": re.DEBUG,
606 "i": re.IGNORECASE,
607 "l": re.LOCALE,
608 "m": None, # re.MULTILINE but the logger works line-by-line so this isn't really possible
609 "s": re.DOTALL,
610 "u": re.UNICODE,
611 "x": re.VERBOSE,
612 # custom: 'v' to invert
613}
616def parse_regex(raw: str) -> FilterFn:
617 """
618 Turn `/pattern/flags` into a Regex object.
620 Uses the grep style flags (i for case insensitive, v for invert)
621 """
623 # zero slashes: just a pattern, no flags.
624 # one slash: search term with / in it
625 # two slashes (+ starts with /): regex with flags
626 # more slashes: flags AND / in filter itself
628 if raw.startswith("/") and raw.count("/") > 1:
629 # flag-mode
630 _, *rest, flags_str = raw.split("/")
631 flags = set(flags_str.lower())
632 pattern = "/".join(rest)
633 else:
634 # normal search mode, no flags
635 flags = set()
636 pattern = raw
638 flags_bin = 0 # re.NOFLAG doesn't exist in 3.10 yet
640 for flag in flags:
641 flags_bin |= POSSIBLE_FLAGS.get(flag) or 0 # re.NOFLAG
643 re_compiled = re.compile(pattern, flags_bin)
645 if "v" in flags:
646 # v for inverse like `grep -v`
647 return lambda text: not re_compiled.search(text)
648 else:
649 return lambda text: bool(re_compiled.search(text))
652def ansi_color_code(code: str, format_opts: t.Collection[str] = ()) -> str:
653 res = "\033["
654 for c in format_opts:
655 res += f"{c};"
656 return f"{res}{code}m"
659def make_color_func(code: str) -> ColorFn:
660 return lambda s: f"{ansi_color_code(code)}{s}{ansi_color_code('0')}"
663def build_rainbow() -> tuple[ColorFn, ...]:
664 names = (
665 "grey",
666 "red",
667 "green",
668 "yellow",
669 "blue",
670 "magenta",
671 "cyan",
672 "white",
673 )
675 colors = {}
676 for i, name in enumerate(names):
677 colors[name] = make_color_func(str(30 + i))
678 colors[f"intense_{name}"] = make_color_func(f"{30 + i};1")
680 return (
681 colors["cyan"],
682 colors["yellow"],
683 colors["green"],
684 colors["magenta"],
685 colors["blue"],
686 colors["intense_cyan"],
687 colors["intense_yellow"],
688 colors["intense_green"],
689 colors["intense_magenta"],
690 colors["intense_blue"],
691 )
694def rainbow() -> t.Generator[ColorFn, None, None]:
695 """
696 rainbow = []colorFunc{
697 colors["cyan"],
698 colors["yellow"],
699 colors["green"],
700 colors["magenta"],
701 colors["blue"],
702 colors["intense_cyan"],
703 colors["intense_yellow"],
704 colors["intense_green"],
705 colors["intense_magenta"],
706 colors["intense_blue"],
707 }
709 Yield colors from the docker compose rainbow map in a cyclic way.
710 """
711 yield from itertools.cycle(build_rainbow())