annet 0.16.35__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of annet might be problematic. Click here for more details.

annet/deploy_ui.py ADDED
@@ -0,0 +1,774 @@
1
+
2
+ import re
3
+ import tempfile
4
+ import os
5
+ import sys
6
+ import math
7
+ import asyncio
8
+ import textwrap
9
+ import time
10
+ from contextlib import contextmanager
11
+ from typing import Dict, List, Optional, Any
12
+
13
+ from contextlog import get_logger
14
+
15
+ from annet import text_term_format
16
+ from annet.output import TextArgs
17
+ try:
18
+ import curses
19
+ except ImportError:
20
+ curses = None
21
+
22
+ uname = os.uname()[0]
23
+ NCURSES_SIZE_T = 2 ** 15 - 1
24
+
25
+
26
+ class AskConfirm:
27
+ CUT_WARN_MSG = "WARNING: the text was cut because of curses limits."
28
+
29
+ def __init__(self, text: str, text_type="diff", alternative_text: str = "",
30
+ alternative_text_type: str = "diff", allow_force_yes: bool = False):
31
+ self.text = [text, text_type]
32
+ self.alternative_text = [alternative_text, alternative_text_type]
33
+ self.color_to_curses: Dict[Optional[str], int] = {}
34
+ self.lines: Dict[int, List[TextArgs]] = {}
35
+ self.rows = 0
36
+ self.cols = None
37
+ self.top = 0
38
+ self.left = 0
39
+ self.pad: Optional["curses.window"] = None
40
+ self.screen = None
41
+ self.found_pos: dict[int, list[TextArgs]] = {}
42
+ self.curses_lines = None
43
+ self.debug_prompt = TextArgs("")
44
+ self.page_position = TextArgs("")
45
+ s_force = "/f" if allow_force_yes else ""
46
+ self.prompt = [
47
+ TextArgs("Execute these commands? [Y%s/q] (/ - search, a - patch/cmds)" % s_force, "blue", offset=0),
48
+ self.page_position,
49
+ self.debug_prompt]
50
+
51
+ def _parse_text(self):
52
+ txt = self.text[0]
53
+ txt_split = txt.splitlines()
54
+ # curses pad, который тут используется, имеет ограничение на количество линий
55
+ if (len(txt_split) + 1) >= NCURSES_SIZE_T: # +1 для того чтобы курсор можно было переместить на пустую строку
56
+ del txt_split[NCURSES_SIZE_T - 3:]
57
+ txt_split.insert(0, self.CUT_WARN_MSG)
58
+ txt_split.append(self.CUT_WARN_MSG)
59
+ txt = "\n".join(txt_split)
60
+ self.rows = len(txt_split)
61
+ self.cols = max(len(line) for line in txt_split)
62
+ res = text_term_format.curses_format(txt, self.text[1])
63
+ self.lines = res
64
+
65
+ def _update_search_pos(self, pattern: str) -> None:
66
+ self.found_pos = {}
67
+ if not pattern:
68
+ return
69
+ try:
70
+ expr = re.compile(pattern)
71
+ except Exception:
72
+ return None
73
+ lines = self.text[0].splitlines()
74
+ for (line_no, line) in enumerate(lines):
75
+ for match in re.finditer(expr, line):
76
+ if line_no not in self.found_pos:
77
+ self.found_pos[line_no] = []
78
+ self.found_pos[line_no].append(TextArgs(match.group(0), "highlight", match.start()))
79
+
80
+ def _init_colors(self):
81
+ self.color_to_curses = init_colors()
82
+
83
+ def _init_pad(self):
84
+ import curses
85
+
86
+ with self._store_xy():
87
+ self.pad = curses.newpad(self.rows + 1, self.cols)
88
+ self.pad.keypad(True) # accept arrow keys
89
+ self._render_to_pad(self.lines)
90
+
91
+ def _render_to_pad(self, lines: dict):
92
+ """
93
+ Рендерим данный на pad
94
+ :param lines: словарь проиндексированный по номерам линий
95
+ :return:
96
+ """
97
+ with self._store_xy():
98
+ for line_no, line_data in sorted(lines.items()):
99
+ line_pos_calc = 0
100
+ for line_part in line_data:
101
+ if line_part.offset is not None:
102
+ line_pos = line_part.offset
103
+ else:
104
+ line_pos = line_pos_calc
105
+ if line_part.color:
106
+ self.pad.addstr(line_no, line_pos, line_part.text, self.color_to_curses[line_part.color])
107
+ else:
108
+ self.pad.addstr(line_no, line_pos, line_part.text)
109
+ line_pos_calc += len(line_part.text)
110
+
111
+ def _add_prompt(self):
112
+ for prompt_part in self.prompt:
113
+ if not prompt_part:
114
+ continue
115
+ if prompt_part.offset is None:
116
+ offset = 0
117
+ else:
118
+ offset = prompt_part.offset
119
+ self.screen.addstr(self.curses_lines - 1, offset, prompt_part.text, self.color_to_curses[prompt_part.color])
120
+
121
+ def _clear_prompt(self):
122
+ with self._store_xy():
123
+ self.screen.move(self.curses_lines - 1, 0)
124
+ self.screen.clrtoeol()
125
+
126
+ def show(self):
127
+ self._add_prompt()
128
+ self.screen.refresh()
129
+ size = self.screen.getmaxyx()
130
+ self.pad.refresh(self.top, self.left, 0, 0, size[0] - 2, size[1] - 2)
131
+
132
+ @contextmanager
133
+ def _store_xy(self):
134
+ if self.pad is not None:
135
+ current_y, current_x = self.pad.getyx()
136
+ yield current_y, current_x
137
+ max_y, max_x = self.pad.getmaxyx()
138
+ current_y = min(max_y - 1, current_y)
139
+ current_x = min(max_x - 1, current_x)
140
+
141
+ self.pad.move(current_y, current_x)
142
+ else:
143
+ yield
144
+
145
+ def search_next(self, prev=False):
146
+ to = None
147
+ current_y, current_x = self.pad.getyx()
148
+ if prev:
149
+ for line_index in sorted(self.found_pos, reverse=True):
150
+ for text_args in self.found_pos[line_index]:
151
+ if line_index > current_y:
152
+ continue
153
+
154
+ if line_index < current_y or line_index == current_y and text_args.offset < current_x:
155
+ to = line_index, text_args.offset
156
+ break
157
+ if to:
158
+ break
159
+ else:
160
+ for line_index in sorted([i for i in self.found_pos if i >= current_y]):
161
+ for text_args in self.found_pos[line_index]:
162
+ if line_index > current_y or line_index == current_y and text_args.offset > current_x:
163
+ to = line_index, text_args.offset
164
+ break
165
+ if to:
166
+ break
167
+ if to:
168
+ return to[0] - current_y, to[1] - current_x
169
+ else:
170
+ return 0, 0
171
+
172
+ def _search_prompt(self) -> tuple[int, int]:
173
+ import curses
174
+
175
+ search_prompt = [TextArgs("Search: ", "green_bold", offset=0)]
176
+ current_prompt = self.prompt
177
+ self.prompt = search_prompt
178
+ with self._store_xy():
179
+ self._clear_prompt()
180
+ self.show()
181
+ curses.echo()
182
+ expr = self.screen.getstr().decode()
183
+ curses.noecho()
184
+ self._update_search_pos(expr)
185
+ self._parse_text()
186
+ self._init_pad()
187
+ # срендерем поверх pad слой с подстветкой
188
+ self._render_to_pad(self.found_pos)
189
+ y_offset, x_offset = self.search_next()
190
+ self.prompt = current_prompt
191
+ return y_offset, x_offset
192
+
193
+ def _do_commands(self) -> str:
194
+ import curses
195
+
196
+ while True:
197
+ self._clear_prompt()
198
+ try:
199
+ ch = self.pad.getch()
200
+ except KeyboardInterrupt:
201
+ return "n"
202
+ max_y, max_x = self.screen.getmaxyx()
203
+ _, pad_max_x = self.pad.getmaxyx()
204
+ max_y -= 2 # prompt
205
+ y_offset = 0
206
+ x_offset = 0
207
+ margin = 0
208
+ y_delta = 0
209
+ x_delta = 0
210
+
211
+ y, x = self.pad.getyx()
212
+ if ch == ord("q"):
213
+ return "exit"
214
+ elif ch in [ord("y"), ord("Y")]:
215
+ return "y"
216
+ elif ch in [ord("f"), ord("F")]:
217
+ return "force-yes"
218
+ elif ch == ord("a"):
219
+ if self.alternative_text:
220
+ self.text, self.alternative_text = self.alternative_text, self.text
221
+ self.screen.clear()
222
+ self._parse_text()
223
+ self._init_pad()
224
+ elif ch == ord("d"):
225
+ if self.debug_prompt.text == "":
226
+ self.debug_prompt.text = "init"
227
+ else:
228
+ self.debug_prompt.text = ""
229
+ elif ch == ord("n"):
230
+ y_offset, x_offset = self.search_next()
231
+ margin = 10
232
+ elif ch == ord("N"):
233
+ y_offset, x_offset = self.search_next(prev=True)
234
+ margin = 10
235
+ elif ch == ord("/"):
236
+ y_offset, x_offset = self._search_prompt()
237
+ margin = 10
238
+ elif ch == curses.KEY_UP:
239
+ y_offset = -1
240
+ elif ch == curses.KEY_PPAGE:
241
+ y_offset = -10
242
+ elif ch == curses.KEY_HOME:
243
+ y_offset = -len(self.lines)
244
+ elif ch == curses.KEY_DOWN:
245
+ y_offset = 1
246
+ elif ch == curses.KEY_NPAGE:
247
+ y_offset = 10
248
+ elif ch == curses.KEY_END:
249
+ y_offset = len(self.lines)
250
+ elif ch == curses.KEY_LEFT:
251
+ x_offset = -1
252
+ elif ch == curses.KEY_RIGHT:
253
+ x_offset = 1
254
+
255
+ if y_offset or x_offset:
256
+ y = max(0, y + y_offset)
257
+ y = min(self.rows, y)
258
+ x = max(0, x + x_offset)
259
+ x = min(self.cols, x)
260
+
261
+ y_delta = y - (self.top + max_y - margin)
262
+ if y_delta > 0:
263
+ self.top += y_delta
264
+ elif (y - margin) < self.top:
265
+ self.top = y
266
+
267
+ self.top = min(self.top, len(self.lines) - max_y)
268
+
269
+ x_delta = x - (self.left + max_x)
270
+ if x_delta > 0:
271
+ self.left += x_delta
272
+ elif x < self.left:
273
+ self.left = x
274
+
275
+ x = min(x, pad_max_x - 1)
276
+ self.pad.move(y, x)
277
+
278
+ if self.debug_prompt.text != "":
279
+ debug_line = "y=%s x=%s, x_delta=%s y_delta=%s top=%s, max_y=%s max_x=%s lines=%s" % \
280
+ (y, x, x_delta, y_delta, self.top, max_y, max_x, len(self.lines))
281
+ self.debug_prompt.text = debug_line
282
+ self.debug_prompt.color = "green_bold"
283
+ self.debug_prompt.offset = max_x - len(debug_line) - 1
284
+
285
+ if self.debug_prompt.text == "":
286
+ self.page_position.color = "highlight"
287
+ self.page_position.text = "line %s/%s" % (y, len(self.lines))
288
+ self.page_position.offset = max_x - len(self.page_position.text) - 1
289
+
290
+ self.show()
291
+
292
+ def loop(self) -> str:
293
+ import curses
294
+
295
+ res = ""
296
+ old_cursor = None
297
+ try:
298
+ self.screen = curses.initscr()
299
+ self.screen.leaveok(True)
300
+ self.curses_lines = curses.LINES # pylint: disable=maybe-no-member
301
+ curses.start_color()
302
+ curses.noecho() # no echo key input
303
+ curses.cbreak() # input with no-enter keyed
304
+ try:
305
+ old_cursor = curses.curs_set(2)
306
+ except Exception:
307
+ pass
308
+ self._init_colors()
309
+ self._parse_text()
310
+ self._init_pad()
311
+ self.pad.move(0, 0)
312
+ self.show()
313
+ res = self._do_commands()
314
+ except Exception as err:
315
+ get_logger().exception("%s", err)
316
+ finally:
317
+ if old_cursor is not None:
318
+ curses.curs_set(old_cursor)
319
+ curses.nocbreak()
320
+ curses.echo()
321
+ curses.endwin()
322
+ return res
323
+
324
+
325
+ def init_colors():
326
+ import curses
327
+
328
+ curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
329
+ curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)
330
+ curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK)
331
+ curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
332
+ curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLACK)
333
+ curses.init_pair(6, curses.COLOR_BLUE, curses.COLOR_WHITE)
334
+ curses.init_pair(7, curses.COLOR_RED, curses.COLOR_WHITE)
335
+ curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_WHITE)
336
+ curses.init_pair(9, curses.COLOR_CYAN, curses.COLOR_BLUE)
337
+ return {
338
+ "green": curses.color_pair(1),
339
+ "green_bold": curses.color_pair(1) | curses.A_BOLD,
340
+ "cyan": curses.color_pair(2),
341
+ "red": curses.color_pair(3),
342
+ "magenta": curses.color_pair(4),
343
+ "yellow": curses.color_pair(5),
344
+ "blue": curses.color_pair(6),
345
+ "highlight": curses.color_pair(7),
346
+ None: curses.color_pair(8),
347
+ "cyan_blue": curses.color_pair(9),
348
+ }
349
+
350
+
351
+ class ProgressBars:
352
+ TAIL_MODE_UNIFORM = 1 # экран разбивается на несколько равнозначных частей и в них есть заголовок и текст
353
+ TAIL_MODE_ONE_CONTENT = 2 # есть только одно окно с контентом, остальные только с заголовками
354
+ TAIL_MODE_NO_CONTENT_ONE_COLUMN = 3
355
+
356
+ def __init__(self, tiles_params: dict[str, dict[Any, Any]]):
357
+ self.tiles_params = tiles_params
358
+ self.mode = self.TAIL_MODE_UNIFORM
359
+ self.screen: "curses.window" = None
360
+ self.tiles: dict[str, dict[str, Any]] = {}
361
+ self.offset = [0, 0]
362
+ self.terminal_refresher_coro = None
363
+ self.color_to_curses: dict[Optional[str], int] = {}
364
+ self.state = "INIT"
365
+
366
+ # context
367
+ self.enter_ok = False
368
+ self._new_stderr = None
369
+ self._saved_stderr_fd = None
370
+ self.progress_length = 10
371
+
372
+ def __enter__(self):
373
+ self.enter_ok = False
374
+ sys.stderr.flush()
375
+ self._new_stderr = tempfile.TemporaryFile()
376
+ self._saved_stderr_fd = os.dup(sys.stderr.fileno())
377
+ os.dup2(self._new_stderr.fileno(), sys.stderr.fileno())
378
+ self.enter_ok = True
379
+ try:
380
+ self.init()
381
+ except Exception:
382
+ self.__exit__(*sys.exc_info())
383
+ raise
384
+ return self
385
+
386
+ def __exit__(self, exc_type, exc_value, trace):
387
+ try:
388
+ self.stop_curses()
389
+ except Exception:
390
+ pass
391
+ if self.enter_ok:
392
+ sys.stderr.flush()
393
+ self._new_stderr.seek(0)
394
+ stderr_data = self._new_stderr.read().decode()
395
+ os.dup2(self._saved_stderr_fd, sys.stderr.fileno())
396
+ sys.stderr.write(stderr_data)
397
+
398
+ def evaluate_mode(self, scree_size):
399
+ height = scree_size[0] // len(self.tiles_params)
400
+ if height > 20:
401
+ return self.TAIL_MODE_UNIFORM
402
+ if scree_size[0] - len(self.tiles_params) > 20:
403
+ return self.TAIL_MODE_ONE_CONTENT
404
+ if scree_size[0] // len(self.tiles_params) > 1:
405
+ return self.TAIL_MODE_NO_CONTENT_ONE_COLUMN
406
+ else:
407
+ return self.TAIL_MODE_NO_CONTENT_ONE_COLUMN
408
+
409
+ def make_tiles(self):
410
+ import curses
411
+
412
+ i = 0
413
+ scree_size = self.screen.getmaxyx()
414
+ scree_size = scree_size[0] - 1, scree_size[1]
415
+ scree_offset = self.screen.getbegyx()
416
+ mode = self.evaluate_mode(scree_size)
417
+ tiles_count = len(self.tiles_params)
418
+ max_height = scree_size[0]
419
+ width = scree_size[1]
420
+ begin_y = 0
421
+ begin_x = 0
422
+ tile_no = 0
423
+ status_bar_win = curses.newwin(1, width, scree_size[0], 0)
424
+ self.tiles["status:"] = {"win": status_bar_win, "content": "init", "title": [""], "height": 1,
425
+ "width": width, "need_draw": True}
426
+ max_tile_name_len = max(len(tile_name) for tile_name in self.tiles_params)
427
+
428
+ for tile_name in self.tiles_params:
429
+ win = None
430
+ height = 0
431
+ tile_no += 1
432
+
433
+ if mode == self.TAIL_MODE_UNIFORM:
434
+ height = int(scree_size[0] // len(self.tiles_params)) # TODO:остаток от деления прибавить к последнему
435
+ win = curses.newwin(height, width, begin_y, begin_x)
436
+ elif mode == self.TAIL_MODE_ONE_CONTENT:
437
+ if i == tiles_count - 1:
438
+ height = scree_size[0] - tiles_count + 1
439
+ else:
440
+ height = 1
441
+ win = curses.newwin(height, width, begin_y, begin_x)
442
+ elif mode == self.TAIL_MODE_NO_CONTENT_ONE_COLUMN:
443
+ height = 1
444
+
445
+ if tile_no < max_height:
446
+ begin_y = scree_offset[0] + begin_y
447
+ get_logger().debug("newwin height=%s, width=%s, begin_y=%s, begin_x=%s", height, width, begin_y,
448
+ begin_x)
449
+ win = curses.newwin(height, width, begin_y, begin_x)
450
+ if tile_no == max_height:
451
+ left = len(self.tiles_params) - max_height + 1
452
+ self.tiles["dumb"] = {"win": curses.newwin(height, width, begin_y, begin_x),
453
+ "content": "init",
454
+ "title": ["... and %s more" % left],
455
+ "height": height,
456
+ "width": width,
457
+ "need_draw": True}
458
+
459
+ title = [("{:<%s}" % (max_tile_name_len)).format(tile_name)]
460
+
461
+ self.tiles[tile_name] = {
462
+ "win": win,
463
+ "content": "init",
464
+ "title": title,
465
+ "height": height,
466
+ "width": width,
467
+ "need_draw": True
468
+ }
469
+ i += 1
470
+ begin_y += height
471
+
472
+ def set_status(self):
473
+ total = 0
474
+ iteration = 0
475
+ done_percent = 0
476
+ for tile_name, tile in self.tiles.items():
477
+ if tile_name == "status:":
478
+ continue
479
+ if "total" not in tile:
480
+ continue
481
+ total += tile["total"]
482
+ iteration += tile["iteration"]
483
+ if total > 0 and iteration > 0:
484
+ done_percent = float(iteration) / total * 100
485
+
486
+ if done_percent == 100:
487
+ msg = "All is done. Press q for exit."
488
+ else:
489
+ msg = "Deploying... %3.0f%%" % math.floor(done_percent)
490
+ self.set_title("status:", msg)
491
+
492
+ def start_terminal_refresher(self, max_refresh_rate=200):
493
+ if self.terminal_refresher_coro:
494
+ return
495
+ self.terminal_refresher_coro = asyncio.ensure_future(self._terminal_refresher(max_refresh_rate))
496
+
497
+ def stop_terminal_refresher(self):
498
+ if not self.terminal_refresher_coro:
499
+ return
500
+ self.terminal_refresher_coro.cancel()
501
+ self.terminal_refresher_coro = None
502
+ self.refresh_all()
503
+
504
+ async def _terminal_refresher(self, max_refresh_rate: int):
505
+ sleep = 1 / max_refresh_rate
506
+ try:
507
+ while True:
508
+ await asyncio.sleep(sleep)
509
+ self.refresh_all()
510
+ except asyncio.CancelledError:
511
+ return
512
+
513
+ def init(self):
514
+ import curses
515
+
516
+ self.screen = curses.initscr()
517
+ self.screen.nodelay(True) # getch() will be non-blocking
518
+ curses.noecho()
519
+ curses.cbreak()
520
+ scree_size = self.screen.getmaxyx()
521
+ get_logger().debug("orig scree_size y=%s, x=%s offset=%s %s", scree_size[0], scree_size[1], self.offset[0],
522
+ self.offset[1])
523
+
524
+ if self.offset[0] == 0:
525
+ new_y = 0
526
+ nlines = scree_size[0]
527
+ elif self.offset[0] < 0:
528
+ nlines = abs(self.offset[0])
529
+ new_y = scree_size[0] - nlines
530
+ else:
531
+ nlines = self.offset[0]
532
+ new_y = scree_size[0]
533
+
534
+ if self.offset[1] == 0:
535
+ new_x = 0
536
+ ncols = scree_size[1]
537
+ elif self.offset[1] < 0:
538
+ ncols = abs(self.offset[1])
539
+ new_x = scree_size[1] - ncols
540
+ else:
541
+ ncols = self.offset[1]
542
+ new_x = scree_size[1]
543
+
544
+ get_logger().debug("nlines=%s, ncols=%s, new_y=%s, new_x=%s", nlines, ncols, new_y, new_x)
545
+
546
+ self.screen.mvwin(new_y, new_x)
547
+
548
+ curses.curs_set(0)
549
+ curses.start_color()
550
+ self.color_to_curses = init_colors()
551
+ self.state = "OK"
552
+ self.make_tiles()
553
+ self.progress_length = scree_size[1] // 3
554
+
555
+ def stop_curses(self):
556
+ import curses
557
+
558
+ curses.curs_set(1)
559
+ curses.nocbreak()
560
+ curses.echo()
561
+ curses.endwin()
562
+ self.state = "CLOSED"
563
+
564
+ def draw_content(self, tile_name):
565
+ tile = self.tiles[tile_name]
566
+ win = tile["win"]
567
+ size = win.getmaxyx()
568
+ margin = 1
569
+ if (size[0] - 2 * margin) <= 0:
570
+ return
571
+ res = text_term_format.curses_format(tile["content"], "switch_out")
572
+ draw_lines_in_win(res, win, color_to_curses=self.color_to_curses, margin=margin)
573
+
574
+ def draw_title(self, tile_name):
575
+ tile = self.tiles[tile_name]
576
+ title = tile["title"]
577
+ win = tile["win"]
578
+ if not isinstance(title, (tuple, list)):
579
+ title = [title]
580
+ draw_lines_in_win({0: title}, win, color_to_curses=self.color_to_curses, x_margin=1)
581
+
582
+ def refresh(self, tile_name: str, noutrefresh: bool = False):
583
+ # see noutrefresh in curses doc
584
+ tile = self.tiles[tile_name]
585
+ win = tile["win"]
586
+ if not tile["need_draw"] or win is None:
587
+ return
588
+ win.clear()
589
+ if tile["height"] > 1:
590
+ win.border()
591
+ self.draw_title(tile_name)
592
+ self.draw_content(tile_name)
593
+ if noutrefresh:
594
+ win.noutrefresh()
595
+ else:
596
+ win.refresh()
597
+ tile["need_draw"] = False
598
+
599
+ def refresh_all(self):
600
+ if self.state != "OK":
601
+ return
602
+ self.get_pressed_keys()
603
+ self.screen.refresh()
604
+ self.set_status()
605
+ tile_name = None
606
+ for tile_name in self.tiles:
607
+ self.refresh(tile_name, True)
608
+ if tile_name is not None:
609
+ self.refresh(tile_name, False)
610
+
611
+ def set_title(self, tile_name, title):
612
+ tile = self.tiles[tile_name]
613
+ # в 0 элементе хранится выровненный хостнейм
614
+ title0 = tile["title"][0]
615
+ new_title = (title0, title)
616
+ if new_title == tile["title"]:
617
+ return
618
+ tile["title"] = new_title
619
+ tile["need_draw"] = True
620
+ if not self.terminal_refresher_coro:
621
+ self.refresh(tile_name)
622
+
623
+ def set_content(self, tile_name: str, content: str):
624
+ tile = self.tiles[tile_name]
625
+ if content == tile["content"]:
626
+ return
627
+ tile["need_draw"] = True
628
+ tile["content"] = content
629
+ if not self.terminal_refresher_coro:
630
+ self.refresh(tile_name)
631
+
632
+ def set_progress(self,
633
+ tile_name: str,
634
+ iteration: int,
635
+ total: int,
636
+ prefix="",
637
+ suffix="",
638
+ fill="█",
639
+ error=False,
640
+ ):
641
+ """
642
+ Call in a loop to create terminal progress bar
643
+ @params:
644
+ iteration - Required : current iteration (Int)
645
+ total - Required : total iterations (Int)
646
+ prefix - Optional : prefix string (Str)
647
+ suffix - Optional : suffix string (Str)
648
+ length - Optional : character length of bar (Int)
649
+ fill - Optional : bar fill character (Str)
650
+ """
651
+ if uname == "FreeBSD":
652
+ fill = "#"
653
+ percent = "{0:.1f}".format(100 * (iteration / float(total)))
654
+ filled_length = int(self.progress_length * iteration // total)
655
+ bar = fill * filled_length + "-" * (self.progress_length - filled_length)
656
+ res = "%s |%s| %s%% %s" % (prefix, bar, percent, suffix)
657
+ tile = self.tiles[tile_name]
658
+ tile["total"] = total
659
+ tile["iteration"] = iteration
660
+ if error:
661
+ res = TextArgs(res, "red")
662
+ else:
663
+ res = TextArgs(res, "cyan")
664
+ self.set_title(tile_name, res)
665
+
666
+ def set_exception(self, fqdn, cmd_exc, last_cmd, progress_max):
667
+ suffix = "cmd error: %s %s" % (str(cmd_exc).strip().replace("\n", "--"), last_cmd)
668
+ self.set_progress(fqdn, progress_max, progress_max, suffix=suffix, error=True)
669
+
670
+ def get_pressed_keys(self):
671
+ ch_list = []
672
+ while True:
673
+ try:
674
+ ch = self.screen.getkey()
675
+ ch_list.append(ch)
676
+ except Exception:
677
+ time.sleep(0.01)
678
+ break
679
+ return ch_list
680
+
681
+ async def wait_for_exit(self):
682
+ while True:
683
+ ch_list = self.get_pressed_keys()
684
+ if ch_list:
685
+ get_logger().debug("read ch %s", ch_list)
686
+ if "q" in ch_list:
687
+ return
688
+ else:
689
+ await asyncio.sleep(0.001)
690
+
691
+
692
+ def draw_lines_in_win(lines, win, color_to_curses: dict[Optional[str], int], margin=0, x_margin=0, y_margin=0):
693
+ max_y, max_x = win.getmaxyx()
694
+ max_y -= 2 * (margin or y_margin)
695
+ max_x -= 2 * (margin or x_margin)
696
+ lines_count = max(lines.keys())
697
+ if lines_count > max_y:
698
+ start_line = lines_count - max_y + 1
699
+ else:
700
+ start_line = 0
701
+ for line_no, line_data in sorted(lines.items())[start_line:]:
702
+ line_no = line_no - start_line
703
+ line_pos_calc = 0
704
+ for line_part in line_data:
705
+ if not isinstance(line_part, TextArgs):
706
+ line_part = TextArgs(line_part)
707
+ if line_part.offset is not None:
708
+ line_pos = line_part.offset
709
+ else:
710
+ line_pos = line_pos_calc
711
+ y = (margin or y_margin) + line_no
712
+ x = (margin or x_margin) + line_pos
713
+ max_line_len = max_x - x + 1
714
+ text = line_part.text[0:max_line_len]
715
+ try:
716
+ if line_part.color:
717
+ win.addstr(y, x, text, color_to_curses[line_part.color])
718
+ else:
719
+ win.addstr(y, x, text)
720
+ except Exception as exp:
721
+ get_logger().error("y=%s, x=%s, text=%s %s", y, x, text, exp)
722
+ line_pos_calc += len(text)
723
+
724
+
725
+ def wrap_to_windows(window, text: str, margin: int = 2) -> List[str]:
726
+ y, x = window.getmaxyx()
727
+ wrapped_text = textwrap.wrap(text, x - margin, max_lines=y, subsequent_indent="+")
728
+ return wrapped_text
729
+
730
+
731
+ def curses_input(prompt: str, screen, header: Dict[int, str], hide_input=False):
732
+ import curses
733
+
734
+ if not header:
735
+ text = {0: prompt}
736
+ else:
737
+ text = header.copy()
738
+ text[max(header) + 1] = prompt
739
+ lines = [line for text_item in text.values() for line in wrap_to_windows(screen, text_item)]
740
+ text = dict(enumerate(lines))
741
+ maxy, maxx = screen.getmaxyx()
742
+ curses.init_pair(12, curses.COLOR_RED, curses.COLOR_WHITE)
743
+ input_len = 35
744
+ res: list[str] = []
745
+ text_width = max([len(x) for x in text.values()] + [len(prompt) + input_len])
746
+ header_height = max(list(text) + [0]) + 1
747
+ begin_y = int(maxy / 2)
748
+ begin_x = int(maxx / 2 - text_width / 2)
749
+ nlines = header_height + 2
750
+ ncols = text_width + 2
751
+ win = curses.newwin(nlines, ncols, begin_y, begin_x)
752
+ curses.noecho()
753
+ prev_curs = curses.curs_set(1)
754
+ while True:
755
+ win.clear()
756
+ win.refresh()
757
+ win.bkgd(curses.color_pair(12))
758
+ win.box()
759
+ draw_lines_in_win(text, win, margin=1, color_to_curses={})
760
+ if hide_input:
761
+ win.addstr(header_height, len(prompt) + 1, "*" * len(res))
762
+ else:
763
+ win.addstr(header_height, len(prompt) + 1, "".join(res))
764
+ ch = win.getkey()
765
+ if ch == "\x7f":
766
+ if res:
767
+ res.pop(-1)
768
+ elif ch == "\n":
769
+ break
770
+ else:
771
+ res.append(ch)
772
+ win.erase()
773
+ curses.curs_set(prev_curs)
774
+ return "".join(res)