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.

@@ -9,11 +9,17 @@ class StubFetcher(Fetcher, AdapterWithConfig):
9
9
  def with_config(cls, **kwargs: Dict[str, Any]) -> Fetcher:
10
10
  return cls(**kwargs)
11
11
 
12
- def fetch_packages(self, devices: List[Device],
13
- processes: int = 1, max_slots: int = 0):
12
+ async def fetch_packages(self,
13
+ devices: list[Device],
14
+ processes: int = 1,
15
+ max_slots: int = 0,
16
+ ) -> tuple[dict[Device, str], dict[Device, Any]]:
14
17
  raise NotImplementedError()
15
18
 
16
- def fetch(self, devices: List[Device],
17
- files_to_download: Dict[str, List[str]] = None,
18
- processes: int = 1, max_slots: int = 0):
19
+ async def fetch(self,
20
+ devices: list[Device],
21
+ files_to_download: dict[str, list[str]] | None = None,
22
+ processes: int = 1,
23
+ max_slots: int = 0,
24
+ ):
19
25
  raise NotImplementedError()
annet/api/__init__.py CHANGED
@@ -22,6 +22,8 @@ from typing import (
22
22
  )
23
23
 
24
24
  import colorama
25
+ import annet.deploy
26
+ import annet.deploy_ui
25
27
  import annet.lib
26
28
  from annet.annlib import jsontools
27
29
  from annet.annlib.netdev.views.hardware import HardwareView
@@ -262,7 +264,7 @@ def patch(args: cli_args.ShowPatchOptions, loader: ann_gen.Loader):
262
264
  global live_configs # pylint: disable=global-statement
263
265
  if args.config == "running":
264
266
  fetcher = annet.deploy.get_fetcher()
265
- live_configs = fetcher.fetch(loader.devices, processes=args.parallel)
267
+ live_configs = annet.lib.do_async(fetcher.fetch(loader.devices, processes=args.parallel))
266
268
  stdin = args.stdin(filter_acl=args.filter_acl, config=args.config)
267
269
 
268
270
  filterer = filtering.filterer_connector.get()
@@ -567,7 +569,7 @@ class Deployer:
567
569
  if not diff_obj:
568
570
  self.empty_diff_hostnames.update(dev.hostname for dev in devices)
569
571
  if not self.args.no_ask_deploy:
570
- # разобъем список устройств на несколько линий
572
+ # разобьём список устройств на несколько линий
571
573
  dest_name = ""
572
574
  try:
573
575
  _, term_columns_str = os.popen("stty size", "r").read().split()
@@ -596,18 +598,18 @@ class Deployer:
596
598
  return diff_lines
597
599
 
598
600
  def ask_deploy(self) -> str:
599
- return self._ask("y", annet.deploy.AskConfirm(
601
+ return self._ask("y", annet.deploy_ui.AskConfirm(
600
602
  text="\n".join(self.diff_lines()),
601
603
  alternative_text="\n".join(self.cmd_lines),
602
604
  ))
603
605
 
604
606
  def ask_rollback(self) -> str:
605
- return self._ask("n", annet.deploy.AskConfirm(
607
+ return self._ask("n", annet.deploy_ui.AskConfirm(
606
608
  text="Execute rollback?\n",
607
609
  alternative_text="",
608
610
  ))
609
611
 
610
- def _ask(self, default_ans: str, ask: annet.deploy.AskConfirm) -> str:
612
+ def _ask(self, default_ans: str, ask: annet.deploy_ui.AskConfirm) -> str:
611
613
  # если filter_acl из stdin то с ним уже не получится работать как с терминалом
612
614
  ans = default_ans
613
615
  if not self.args.no_ask_deploy:
@@ -666,11 +668,22 @@ def deploy(
666
668
  filterer: Filterer,
667
669
  fetcher: Fetcher,
668
670
  deploy_driver: DeployDriver,
671
+ ) -> ExitCode:
672
+ return annet.lib.do_async(adeploy(args, loader, deployer, filterer, fetcher, deploy_driver))
673
+
674
+
675
+ async def adeploy(
676
+ args: cli_args.DeployOptions,
677
+ loader: ann_gen.Loader,
678
+ deployer: Deployer,
679
+ filterer: Filterer,
680
+ fetcher: Fetcher,
681
+ deploy_driver: DeployDriver,
669
682
  ) -> ExitCode:
670
683
  """ Сгенерировать конфиг для устройств и задеплоить его """
671
684
  ret: ExitCode = 0
672
685
  global live_configs # pylint: disable=global-statement
673
- live_configs = fetcher.fetch(devices=loader.devices, processes=args.parallel)
686
+ live_configs = await fetcher.fetch(devices=loader.devices, processes=args.parallel)
674
687
  pool = ann_gen.OldNewParallel(args, loader, filterer)
675
688
 
676
689
  for res in pool.generated_configs(loader.devices):
@@ -687,7 +700,18 @@ def deploy(
687
700
  ans = deployer.ask_deploy()
688
701
  if ans != "y":
689
702
  return 2 ** 2
690
- result = annet.lib.do_async(deploy_driver.bulk_deploy(deploy_cmds, args))
703
+ progress_bar = None
704
+ if sys.stdout.isatty() and not args.no_progress:
705
+ progress_bar = annet.deploy_ui.ProgressBars(odict([(device.fqdn, {}) for device in deploy_cmds]))
706
+ progress_bar.init()
707
+ with progress_bar:
708
+ progress_bar.start_terminal_refresher()
709
+ result = await deploy_driver.bulk_deploy(deploy_cmds, args, progress_bar=progress_bar)
710
+ await progress_bar.wait_for_exit()
711
+ progress_bar.screen.clear()
712
+ progress_bar.stop_terminal_refresher()
713
+ else:
714
+ result = await deploy_driver.bulk_deploy(deploy_cmds, args)
691
715
 
692
716
  rolled_back = False
693
717
  rollback_cmds = {deployer.fqdn_to_device[x]: cc for x, cc in result.original_states.items() if cc}
@@ -695,7 +719,7 @@ def deploy(
695
719
  ans = deployer.ask_rollback()
696
720
  if rollback_cmds and ans == "y":
697
721
  rolled_back = True
698
- annet.lib.do_async(deploy_driver.bulk_deploy(rollback_cmds, args))
722
+ await deploy_driver.bulk_deploy(rollback_cmds, args)
699
723
 
700
724
  if not args.no_check_diff and not rolled_back:
701
725
  deployer.check_diff(result, loader)
annet/deploy.py CHANGED
@@ -1,37 +1,50 @@
1
1
  # pylint: disable=unused-argument
2
-
3
-
4
2
  import abc
5
3
  import itertools
6
- import re
7
4
  from collections import namedtuple
8
- from contextlib import contextmanager
9
5
  from typing import Dict, List, Optional, Any, OrderedDict, Tuple, Type
10
6
 
11
7
  from contextlog import get_logger
12
8
 
13
- from annet import text_term_format
14
9
  from annet.annlib.command import Command, Question, CommandList
15
10
  from annet.annlib.netdev.views.hardware import HardwareView
16
11
  from annet.annlib.rbparser.deploying import MakeMessageMatcher, Answer
17
12
  from annet.cli_args import DeployOptions
18
13
  from annet.connectors import Connector, get_connector_from_config
19
- from annet.output import TextArgs
20
14
  from annet.rulebook import get_rulebook, deploying
21
15
  from annet.storage import Device
22
16
 
23
17
 
24
- NCURSES_SIZE_T = 2 ** 15 - 1
18
+ _DeployResultBase = namedtuple("_DeployResultBase", ("hostnames", "results", "durations", "original_states"))
25
19
 
26
20
 
27
- _DeployResultBase = namedtuple("_DeployResultBase", ("hostnames", "results", "durations", "original_states"))
21
+ class ProgressBar(abc.ABC):
22
+ @abc.abstractmethod
23
+ def set_content(self, tile_name: str, content: str):
24
+ ...
25
+
26
+ @abc.abstractmethod
27
+ def set_progress(self,
28
+ tile_name: str,
29
+ iteration: int,
30
+ total: int,
31
+ prefix: str = "",
32
+ suffix: str = "",
33
+ fill: str = "",
34
+ error: bool = False,
35
+ ):
36
+ ...
37
+
38
+ @abc.abstractmethod
39
+ def set_exception(self, tile_name: str, cmd_exc: str, last_cmd: str, progress_max: int):
40
+ ...
28
41
 
29
42
 
30
43
  class DeployResult(_DeployResultBase): # noqa: E302
31
- def add_results(self, results: Dict[str, Optional[Exception]]) -> None:
32
- for hostname, result in results.items():
44
+ def add_results(self, results: dict[str, tuple[list[str], list[Exception]]]) -> None:
45
+ for hostname, (excs, result) in results.items():
33
46
  self.hostnames.append(hostname)
34
- self.results[hostname] = result
47
+ self.results[hostname] = excs
35
48
  self.durations[hostname] = 0.0
36
49
  self.original_states[hostname] = None
37
50
 
@@ -64,14 +77,20 @@ driver_connector = _DriverConnector()
64
77
 
65
78
  class Fetcher(abc.ABC):
66
79
  @abc.abstractmethod
67
- def fetch_packages(self, devices: List[Device],
68
- processes: int = 1, max_slots: int = 0) -> Tuple[Dict[Device, str], Dict[Device, Any]]:
80
+ async def fetch_packages(self,
81
+ devices: list[Device],
82
+ processes: int = 1,
83
+ max_slots: int = 0,
84
+ ) -> tuple[dict[Device, str], dict[Device, Any]]:
69
85
  pass
70
86
 
71
87
  @abc.abstractmethod
72
- def fetch(self, devices: List[Device],
73
- files_to_download: Dict[str, List[str]] = None,
74
- processes: int = 1, max_slots: int = 0):
88
+ async def fetch(self,
89
+ devices: list[Device],
90
+ files_to_download: dict[str, list[str]] | None = None,
91
+ processes: int = 1,
92
+ max_slots: int = 0,
93
+ ):
75
94
  pass
76
95
 
77
96
 
@@ -83,7 +102,7 @@ def get_fetcher() -> Fetcher:
83
102
 
84
103
  class DeployDriver(abc.ABC):
85
104
  @abc.abstractmethod
86
- async def bulk_deploy(self, deploy_cmds: dict, args: DeployOptions) -> DeployResult:
105
+ async def bulk_deploy(self, deploy_cmds: dict, args: DeployOptions, progress_bar: ProgressBar | None = None) -> DeployResult:
87
106
  pass
88
107
 
89
108
  @abc.abstractmethod
@@ -106,7 +125,7 @@ def get_deployer() -> DeployDriver:
106
125
 
107
126
 
108
127
  # ===
109
- def scrub_config(text, breed):
128
+ def scrub_config(text: str, breed: str) -> str:
110
129
  return text
111
130
 
112
131
 
@@ -114,331 +133,6 @@ def show_bulk_report(hostnames, results, durations, log_dir):
114
133
  pass
115
134
 
116
135
 
117
- class AskConfirm:
118
- CUT_WARN_MSG = "WARNING: the text was cut because of curses limits."
119
-
120
- def __init__(self, text: str, text_type="diff", alternative_text: str = "",
121
- alternative_text_type: str = "diff", allow_force_yes: bool = False):
122
- self.text = [text, text_type]
123
- self.alternative_text = [alternative_text, alternative_text_type]
124
- self.color_to_curses: Dict[Optional[str], int] = {}
125
- self.lines: Dict[int, List[TextArgs]] = {}
126
- self.rows = None
127
- self.cols = None
128
- self.top = 0
129
- self.left = 0
130
- self.pad = None
131
- self.screen = None
132
- self.found_pos = {}
133
- self.curses_lines = None
134
- self.debug_prompt = TextArgs("")
135
- self.page_position = TextArgs("")
136
- s_force = "/f" if allow_force_yes else ""
137
- self.prompt = [
138
- TextArgs("Execute these commands? [Y%s/q] (/ - search, a - patch/cmds)" % s_force, "blue", offset=0),
139
- self.page_position,
140
- self.debug_prompt]
141
-
142
- def _parse_text(self):
143
- txt = self.text[0]
144
- txt_split = txt.splitlines()
145
- # curses pad, который тут используется, имеет ограничение на количество линий
146
- if (len(txt_split) + 1) >= NCURSES_SIZE_T: # +1 для того чтобы курсор можно было переместить на пустую строку
147
- del txt_split[NCURSES_SIZE_T - 3:]
148
- txt_split.insert(0, self.CUT_WARN_MSG)
149
- txt_split.append(self.CUT_WARN_MSG)
150
- txt = "\n".join(txt_split)
151
- self.rows = len(txt_split)
152
- self.cols = max(len(line) for line in txt_split)
153
- res = text_term_format.curses_format(txt, self.text[1])
154
- self.lines = res
155
-
156
- def _update_search_pos(self, expr):
157
- self.found_pos = {}
158
- if not expr:
159
- return
160
- try:
161
- expr = re.compile(expr)
162
- except Exception:
163
- return None
164
- lines = self.text[0].splitlines()
165
- for (line_no, line) in enumerate(lines):
166
- for match in re.finditer(expr, line):
167
- if line_no not in self.found_pos:
168
- self.found_pos[line_no] = []
169
- self.found_pos[line_no].append(TextArgs(match.group(0), "highlight", match.start()))
170
-
171
- def _init_colors(self):
172
- self.color_to_curses = init_colors()
173
-
174
- def _init_pad(self):
175
- import curses
176
-
177
- with self._store_xy():
178
- self.pad = curses.newpad(self.rows + 1, self.cols)
179
- self.pad.keypad(True) # accept arrow keys
180
- self._render_to_pad(self.lines)
181
-
182
- def _render_to_pad(self, lines: dict):
183
- """
184
- Рендерим данный на pad
185
- :param lines: словарь проиндексированный по номерам линий
186
- :return:
187
- """
188
- with self._store_xy():
189
- for line_no, line_data in sorted(lines.items()):
190
- line_pos_calc = 0
191
- for line_part in line_data:
192
- if line_part.offset is not None:
193
- line_pos = line_part.offset
194
- else:
195
- line_pos = line_pos_calc
196
- if line_part.color:
197
- self.pad.addstr(line_no, line_pos, line_part.text, self.color_to_curses[line_part.color])
198
- else:
199
- self.pad.addstr(line_no, line_pos, line_part.text)
200
- line_pos_calc += len(line_part.text)
201
-
202
- def _add_prompt(self):
203
- for prompt_part in self.prompt:
204
- if not prompt_part:
205
- continue
206
- if prompt_part.offset is None:
207
- offset = 0
208
- else:
209
- offset = prompt_part.offset
210
- self.screen.addstr(self.curses_lines - 1, offset, prompt_part.text, self.color_to_curses[prompt_part.color])
211
-
212
- def _clear_prompt(self):
213
- with self._store_xy():
214
- self.screen.move(self.curses_lines - 1, 0)
215
- self.screen.clrtoeol()
216
-
217
- def show(self):
218
- self._add_prompt()
219
- self.screen.refresh()
220
- size = self.screen.getmaxyx()
221
- self.pad.refresh(self.top, self.left, 0, 0, size[0] - 2, size[1] - 2)
222
-
223
- @contextmanager
224
- def _store_xy(self):
225
- if self.pad is not None:
226
- current_y, current_x = self.pad.getyx()
227
- yield current_y, current_x
228
- max_y, max_x = self.pad.getmaxyx()
229
- current_y = min(max_y - 1, current_y)
230
- current_x = min(max_x - 1, current_x)
231
-
232
- self.pad.move(current_y, current_x)
233
- else:
234
- yield
235
-
236
- def search_next(self, prev=False):
237
- to = None
238
- current_y, current_x = self.pad.getyx()
239
- if prev:
240
- for line_index in sorted(self.found_pos, reverse=True):
241
- for text_args in self.found_pos[line_index]:
242
- if line_index > current_y:
243
- continue
244
-
245
- if line_index < current_y or line_index == current_y and text_args.offset < current_x:
246
- to = line_index, text_args.offset
247
- break
248
- if to:
249
- break
250
- else:
251
- for line_index in sorted([i for i in self.found_pos if i >= current_y]):
252
- for text_args in self.found_pos[line_index]:
253
- if line_index > current_y or line_index == current_y and text_args.offset > current_x:
254
- to = line_index, text_args.offset
255
- break
256
- if to:
257
- break
258
- if to:
259
- return to[0] - current_y, to[1] - current_x
260
- else:
261
- return 0, 0
262
-
263
- def _search_prompt(self):
264
- import curses
265
-
266
- search_prompt = [TextArgs("Search: ", "green_bold", offset=0)]
267
- current_prompt = self.prompt
268
- self.prompt = search_prompt
269
- with self._store_xy():
270
- self._clear_prompt()
271
- self.show()
272
- curses.echo()
273
- expr = self.screen.getstr().decode()
274
- curses.noecho()
275
- self._update_search_pos(expr)
276
- self._parse_text()
277
- self._init_pad()
278
- # срендерем поверх pad слой с подстветкой
279
- self._render_to_pad(self.found_pos)
280
- y_offset, x_offset = self.search_next()
281
- self.prompt = current_prompt
282
- return y_offset, x_offset
283
-
284
- def _do_commands(self):
285
- import curses
286
-
287
- while True:
288
- self._clear_prompt()
289
- try:
290
- ch = self.pad.getch()
291
- except KeyboardInterrupt:
292
- return "n"
293
- max_y, max_x = self.screen.getmaxyx()
294
- _, pad_max_x = self.pad.getmaxyx()
295
- max_y -= 2 # prompt
296
- y_offset = 0
297
- x_offset = 0
298
- margin = 0
299
- y_delta = 0
300
- x_delta = 0
301
-
302
- y, x = self.pad.getyx()
303
- if ch == ord("q"):
304
- return "exit"
305
- elif ch in [ord("y"), ord("Y")]:
306
- return "y"
307
- elif ch in [ord("f"), ord("F")]:
308
- return "force-yes"
309
- elif ch == ord("a"):
310
- if self.alternative_text:
311
- self.text, self.alternative_text = self.alternative_text, self.text
312
- self.screen.clear()
313
- self._parse_text()
314
- self._init_pad()
315
- elif ch == ord("d"):
316
- if self.debug_prompt.text == "":
317
- self.debug_prompt.text = "init"
318
- else:
319
- self.debug_prompt.text = ""
320
- elif ch == ord("n"):
321
- y_offset, x_offset = self.search_next()
322
- margin = 10
323
- elif ch == ord("N"):
324
- y_offset, x_offset = self.search_next(prev=True)
325
- margin = 10
326
- elif ch == ord("/"):
327
- y_offset, x_offset = self._search_prompt()
328
- margin = 10
329
- elif ch == curses.KEY_UP:
330
- y_offset = -1
331
- elif ch == curses.KEY_PPAGE:
332
- y_offset = -10
333
- elif ch == curses.KEY_HOME:
334
- y_offset = -len(self.lines)
335
- elif ch == curses.KEY_DOWN:
336
- y_offset = 1
337
- elif ch == curses.KEY_NPAGE:
338
- y_offset = 10
339
- elif ch == curses.KEY_END:
340
- y_offset = len(self.lines)
341
- elif ch == curses.KEY_LEFT:
342
- x_offset = -1
343
- elif ch == curses.KEY_RIGHT:
344
- x_offset = 1
345
-
346
- if y_offset or x_offset:
347
- y = max(0, y + y_offset)
348
- y = min(self.rows, y)
349
- x = max(0, x + x_offset)
350
- x = min(self.cols, x)
351
-
352
- y_delta = y - (self.top + max_y - margin)
353
- if y_delta > 0:
354
- self.top += y_delta
355
- elif (y - margin) < self.top:
356
- self.top = y
357
-
358
- self.top = min(self.top, len(self.lines) - max_y)
359
-
360
- x_delta = x - (self.left + max_x)
361
- if x_delta > 0:
362
- self.left += x_delta
363
- elif x < self.left:
364
- self.left = x
365
-
366
- x = min(x, pad_max_x - 1)
367
- self.pad.move(y, x)
368
-
369
- if self.debug_prompt.text != "":
370
- debug_line = "y=%s x=%s, x_delta=%s y_delta=%s top=%s, max_y=%s max_x=%s lines=%s" % \
371
- (y, x, x_delta, y_delta, self.top, max_y, max_x, len(self.lines))
372
- self.debug_prompt.text = debug_line
373
- self.debug_prompt.color = "green_bold"
374
- self.debug_prompt.offset = max_x - len(debug_line) - 1
375
-
376
- if self.debug_prompt.text == "":
377
- self.page_position.color = "highlight"
378
- self.page_position.text = "line %s/%s" % (y, len(self.lines))
379
- self.page_position.offset = max_x - len(self.page_position.text) - 1
380
-
381
- self.show()
382
-
383
- def loop(self):
384
- import curses
385
-
386
- res = None
387
- old_cursor = None
388
- try:
389
- self.screen = curses.initscr()
390
- self.screen.leaveok(True)
391
- self.curses_lines = curses.LINES # pylint: disable=maybe-no-member
392
- curses.start_color()
393
- curses.noecho() # no echo key input
394
- curses.cbreak() # input with no-enter keyed
395
- try:
396
- old_cursor = curses.curs_set(2)
397
- except Exception:
398
- pass
399
- self._init_colors()
400
- self._parse_text()
401
- self._init_pad()
402
- self.pad.move(0, 0)
403
- self.show()
404
- res = self._do_commands()
405
- except Exception as err:
406
- get_logger().exception("%s", err)
407
- finally:
408
- if old_cursor is not None:
409
- curses.curs_set(old_cursor)
410
- curses.nocbreak()
411
- curses.echo()
412
- curses.endwin()
413
- return res
414
-
415
-
416
- def init_colors():
417
- import curses
418
-
419
- curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
420
- curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)
421
- curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK)
422
- curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
423
- curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLACK)
424
- curses.init_pair(6, curses.COLOR_BLUE, curses.COLOR_WHITE)
425
- curses.init_pair(7, curses.COLOR_RED, curses.COLOR_WHITE)
426
- curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_WHITE)
427
- curses.init_pair(9, curses.COLOR_CYAN, curses.COLOR_BLUE)
428
- return {
429
- "green": curses.color_pair(1),
430
- "green_bold": curses.color_pair(1) | curses.A_BOLD,
431
- "cyan": curses.color_pair(2),
432
- "red": curses.color_pair(3),
433
- "magenta": curses.color_pair(4),
434
- "yellow": curses.color_pair(5),
435
- "blue": curses.color_pair(6),
436
- "highlight": curses.color_pair(7),
437
- None: curses.color_pair(8),
438
- "cyan_blue": curses.color_pair(9),
439
- }
440
-
441
-
442
136
  class RulebookQuestionHandler:
443
137
  def __init__(self, dialogs):
444
138
  self._dialogs = dialogs