annet 1.0.3__py3-none-any.whl → 1.1.0__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.

@@ -132,6 +132,9 @@
132
132
  "PC.Whitebox.Ufispace.S": " S",
133
133
  "PC.Whitebox.Ufispace.S.S9100": " S91\\d\\d",
134
134
  "PC.Whitebox.Ufispace.S.S9100.S9110_32X": " S9110-32X",
135
+ "PC.Whitebox.Ufispace.S.S9300": " S93\\d\\d",
136
+ "PC.Whitebox.Ufispace.S.S9300.S9301_32DB": " S9301-32DB",
137
+ "PC.Whitebox.Ufispace.S.S9300.S9321_64EO": " S9321-64EO",
135
138
  "PC.Nebius": "^Nebius",
136
139
  "PC.Nebius.NB-E-BR-DCU-AST2600": "^Nebius NB-E-BR-DCU-AST2600",
137
140
 
annet/annlib/patching.py CHANGED
@@ -186,6 +186,9 @@ class Orderer:
186
186
  block_exit = platform.VENDOR_EXIT[self.vendor]
187
187
 
188
188
  for (order, (raw_rule, rule)) in enumerate(ordering.items()):
189
+ if rule["attrs"]["global"]:
190
+ children.append((raw_rule, rule))
191
+
189
192
  direct_matched = bool(rule["attrs"]["direct_regexp"].match(row))
190
193
  if not rule["attrs"]["order_reverse"] and (direct_matched or rule["attrs"]["reverse_regexp"].match(row)):
191
194
  # если не указано order_reverse - правило считается прямым
@@ -395,7 +398,7 @@ def make_patch(pre, rb, hw, add_comments, orderer=None, _root_pre=None, do_commi
395
398
  for (key, diff) in content["items"].items():
396
399
  # чтобы logic не мог поменять атрибуты
397
400
  rule_pre = content.copy()
398
- attrs = rule_pre["attrs"].copy()
401
+ attrs = copy.deepcopy(rule_pre["attrs"])
399
402
 
400
403
  iterable = attrs["logic"](
401
404
  rule=attrs,
@@ -544,8 +547,7 @@ def _select_match(matches, rules):
544
547
  for (rule, is_cr_allowed) in map(operator.itemgetter(0), matches):
545
548
  if is_cr_allowed:
546
549
  local_children = merge_dicts(local_children, rule["children"]["local"])
547
- # optional break on is_cr_allowed==False?
548
-
550
+ # optional break on is_cr_allowed==False?
549
551
  global_children = merge_dicts(global_children, rule["children"]["global"])
550
552
 
551
553
  global_children = merge_dicts(global_children, rules["global"])
@@ -555,9 +557,10 @@ def _select_match(matches, rules):
555
557
  "global": global_children,
556
558
  }
557
559
 
558
- match = {"attrs": f_rule["attrs"]}
560
+ match = {"attrs": copy.deepcopy(f_rule["attrs"])}
559
561
  match.update(f_other)
560
- return (match, children_rules)
562
+
563
+ return match, children_rules
561
564
 
562
565
 
563
566
  def _rules_local_global(rules):
@@ -16,6 +16,10 @@ def compile_ordering_text(text, vendor):
16
16
  "validator": valid_bool,
17
17
  "default": False,
18
18
  },
19
+ "global": {
20
+ "validator": valid_bool,
21
+ "default": False,
22
+ }
19
23
  }),
20
24
  reverse_prefix=platform.VENDOR_REVERSES[vendor],
21
25
  )
@@ -44,6 +48,7 @@ def _compile_ordering(tree, reverse_prefix):
44
48
  syntax.compile_row_regexp(re.sub(r"^%s\s+" % (reverse_prefix), "", attrs["row"]))
45
49
  ),
46
50
  "order_reverse": attrs["params"]["order_reverse"],
51
+ "global": attrs["params"]["global"],
47
52
  "raw_rule": attrs["raw_rule"],
48
53
  "context": attrs["context"],
49
54
  },
@@ -25,7 +25,7 @@ VENDOR_DIFF = {
25
25
  "routeros": "common.default_diff",
26
26
  "aruba": "aruba.default_diff",
27
27
  "pc": "common.default_diff",
28
- "ribbon": "ribbon.default_diff",
28
+ "ribbon": "common.default_diff",
29
29
  "b4com": "common.default_diff",
30
30
  }
31
31
 
@@ -40,7 +40,7 @@ VENDOR_DIFF_ORDERED = {
40
40
  "routeros": "common.ordered_diff",
41
41
  "aruba": "common.ordered_diff",
42
42
  "pc": "common.ordered_diff",
43
- "ribbon": "ribbon.default_diff",
43
+ "ribbon": "common.ordered_diff",
44
44
  "b4com": "common.ordered_diff",
45
45
  }
46
46
 
annet/annlib/tabparser.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import dataclasses
2
2
  import itertools
3
+ import json
3
4
  import re
5
+ import textwrap
4
6
  from collections import OrderedDict as odict
5
7
  from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Tuple, Union, List
6
8
 
@@ -70,10 +72,10 @@ class CommonFormatter:
70
72
  self._block_end = ""
71
73
  self._statement_end = ""
72
74
 
73
- def split(self, text):
75
+ def split(self, text: str):
74
76
  return list(filter(None, text.split("\n")))
75
77
 
76
- def join(self, config):
78
+ def join(self, config: "PatchTree"):
77
79
  return "\n".join(
78
80
  _filtered_block_marks(
79
81
  self._indent_blocks(self._blocks(config, is_patch=False))
@@ -86,14 +88,14 @@ class CommonFormatter:
86
88
  def diff(self, diff):
87
89
  return list(self.diff_generator(diff))
88
90
 
89
- def patch(self, patch):
91
+ def patch(self, patch: "PatchTree") -> str:
90
92
  return "\n".join(
91
93
  _filtered_block_marks(
92
94
  self._indent_blocks(self._blocks(patch, is_patch=True))
93
95
  )
94
96
  )
95
97
 
96
- def cmd_paths(self, patch):
98
+ def cmd_paths(self, patch: "PatchTree") -> odict:
97
99
  ret = odict()
98
100
  path = []
99
101
  for row, context in self.blocks_and_context(patch, is_patch=True):
@@ -175,7 +177,7 @@ class CommonFormatter:
175
177
  )
176
178
  yield BlockEnd, None
177
179
 
178
- def _blocks(self, tree, is_patch):
180
+ def _blocks(self, tree: "PatchTree", is_patch: bool):
179
181
  for row, _context in self.blocks_and_context(tree, is_patch):
180
182
  yield row
181
183
 
@@ -386,7 +388,32 @@ class AsrFormatter(BlockExitFormatter):
386
388
 
387
389
 
388
390
  class JuniperFormatter(CommonFormatter):
389
- patch_set_prefix = "set "
391
+ patch_set_prefix = "set"
392
+
393
+ @dataclasses.dataclass
394
+ class Comment:
395
+ begin = "/*"
396
+ end = "*/"
397
+
398
+ row: str
399
+ comment: str
400
+
401
+ def __post_init__(self):
402
+ self.row = self.row.strip()
403
+ self.comment = self.comment.strip()
404
+
405
+ @classmethod
406
+ def loads(cls, value: str):
407
+ return cls(
408
+ **json.loads(
409
+ value.removeprefix(cls.begin)
410
+ .removesuffix(cls.end)
411
+ .strip()
412
+ )
413
+ )
414
+
415
+ def dumps(self):
416
+ return json.dumps({"row": self.row, "comment": self.comment})
390
417
 
391
418
  def __init__(self, indent=" "):
392
419
  super().__init__(indent)
@@ -395,20 +422,32 @@ class JuniperFormatter(CommonFormatter):
395
422
  self._statement_end = ";"
396
423
  self._endofline_comment = "; ##"
397
424
 
398
- def split(self, text):
399
- sub_regexs = (
425
+ self._sub_regexs = (
400
426
  (re.compile(self._block_begin + r"\s*" + self._block_end + r"$"), ""), # collapse empty blocks
401
427
  (re.compile(self._block_begin + "(\t# .+)?$"), ""),
402
428
  (re.compile(self._statement_end + r"$"), ""),
403
429
  (re.compile(r"\s*" + self._block_end + "(\t# .+)?$"), ""),
404
430
  (re.compile(self._endofline_comment + r".*$"), ""),
405
431
  )
406
- split = []
407
- for line in text.split("\n"):
408
- for (regex, repl_line) in sub_regexs:
409
- line = regex.sub(repl_line, line)
410
- split.append(line)
411
- return list(filter(None, split))
432
+
433
+ def sub_regexs(self, value: str) -> str:
434
+ for (regex, repl_line) in self._sub_regexs:
435
+ value = regex.sub(repl_line, value)
436
+ return value
437
+
438
+ def split(self, text: str) -> list[str]:
439
+ comment_begin, comment_end = map(re.escape, (self.Comment.begin, self.Comment.end))
440
+ comment_regexp = re.compile(fr"(\s+{comment_begin})((?:(?!{comment_end}).)*)({comment_end})")
441
+
442
+ result = []
443
+ lines = text.split("\n")
444
+ for i, line in enumerate(lines):
445
+ line = self.sub_regexs(line)
446
+ if i + 1 < len(lines) and (m := comment_regexp.match(line)):
447
+ line = f"{m.group(1)} {self.Comment(self.sub_regexs(lines[i + 1]), m.group(2)).dumps()} {m.group(3)}"
448
+ result.append(line)
449
+
450
+ return list(filter(None, result))
412
451
 
413
452
  def join(self, config):
414
453
  return "\n".join(_filtered_block_marks(self._formatted_blocks(self._indented_blocks(config))))
@@ -433,30 +472,45 @@ class JuniperFormatter(CommonFormatter):
433
472
  yield line + self._statement_end
434
473
  yield self._indent * level + self._block_end
435
474
  elif isinstance(line, str):
436
- yield line + self._statement_end
475
+ yield line + ("" if line.endswith(self.Comment.end) else self._statement_end)
437
476
  line = new_line
438
477
  if isinstance(line, str):
439
478
  yield line + self._statement_end
440
479
 
441
- def cmd_paths(self, patch, _prev=""):
480
+ def cmd_paths(self, patch, _prev=tuple()):
442
481
  commands = odict()
443
482
  for item in patch.itms:
444
483
  key, childs, context = item.row, item.child, item.context
484
+
445
485
  if childs:
446
- for k, v in self.cmd_paths(childs, _prev + " " + key).items():
486
+ for k, v in self.cmd_paths(childs, (*_prev, key.strip())).items():
447
487
  commands[k] = v
448
488
  else:
449
- if key.startswith("delete"):
450
- cmd = "delete" + _prev + " " + key.replace("delete", "", 1).strip()
489
+ if "comment" in context:
490
+ value = (
491
+ ""
492
+ if key.startswith("delete")
493
+ else context["comment"]
494
+ )
495
+
496
+ cmd = "\n".join(
497
+ (
498
+ "edit " + " ".join(_prev),
499
+ " ".join(("annotate", context["row"].split(" ")[0], f'"{value}"')),
500
+ "exit"
501
+ )
502
+ )
503
+ elif key.startswith("delete"):
504
+ cmd = " ".join(("delete", *_prev, key.replace("delete", "", 1).strip()))
451
505
  elif key.startswith("activate"):
452
- cmd = "activate" + _prev + " " + key.replace("activate", "", 1).strip()
506
+ cmd = " ".join(("activate", *_prev, key.replace("activate", "", 1).strip()))
453
507
  elif key.startswith("deactivate"):
454
- cmd = "deactivate" + _prev + " " + key.replace("deactivate", "", 1).strip()
508
+ cmd = " ".join(("deactivate", *_prev, key.replace("deactivate", "", 1).strip()))
455
509
  else:
456
- cmd = (self.patch_set_prefix + _prev.strip()).strip() + " " + key
510
+ cmd = " ".join((self.patch_set_prefix, *_prev, key.strip()))
511
+
457
512
  # Expanding [ a b c ] junipers list of arguments
458
- matches = re.search(r"^(.*)\s+\[(.+)\]$", cmd)
459
- if matches:
513
+ if matches := re.search(r"^(.*)\s+\[(.+)\]$", cmd):
460
514
  for c in matches.group(2).split(" "):
461
515
  if c.strip():
462
516
  cmd = " ".join([matches.group(1), c])
@@ -490,7 +544,7 @@ class JuniperList:
490
544
 
491
545
 
492
546
  class NokiaFormatter(JuniperFormatter):
493
- patch_set_prefix = "/configure "
547
+ patch_set_prefix = "/configure"
494
548
 
495
549
  def __init__(self, *args, **kwargs):
496
550
  super().__init__(*args, **kwargs)
@@ -517,18 +571,18 @@ class NokiaFormatter(JuniperFormatter):
517
571
  finish = finish if finish is not None else len(ret)
518
572
  return ret[start:finish]
519
573
 
520
- def cmd_paths(self, patch, _prev=""):
574
+ def cmd_paths(self, patch, _prev=tuple()):
521
575
  commands = odict()
522
576
  for item in patch.itms:
523
577
  key, childs, context = item.row, item.child, item.context
524
578
  if childs:
525
- for k, v in self.cmd_paths(childs, _prev + " " + key).items():
579
+ for k, v in self.cmd_paths(childs, (*_prev, key.strip())).items():
526
580
  commands[k] = v
527
581
  else:
528
582
  if key.startswith("delete"):
529
- cmd = "/configure delete" + _prev + " " + key.replace("delete", "", 1).strip()
583
+ cmd = " ".join((self.patch_set_prefix, "delete", *_prev, key.replace("delete", "", 1).strip()))
530
584
  else:
531
- cmd = self.patch_set_prefix + _prev.strip() + " " + key
585
+ cmd = " ".join((self.patch_set_prefix, *_prev, key.strip()))
532
586
  # Expanding [ a b c ] junipers list of arguments
533
587
  matches = re.search(r"^(.*)\s+\[(.+)\]$", cmd)
534
588
  if matches:
annet/bgp_models.py CHANGED
@@ -3,6 +3,77 @@ from dataclasses import dataclass, field
3
3
  from typing import Literal, Union, Optional
4
4
 
5
5
 
6
+ class VidRange:
7
+ def __init__(self, start: int, stop: int) -> None:
8
+ self.start = start
9
+ self.stop = stop
10
+
11
+ def is_single(self):
12
+ return self.start == self.stop
13
+
14
+ def __iter__(self):
15
+ return iter(range(self.start, self.stop + 1))
16
+
17
+ def __str__(self):
18
+ if self.is_single():
19
+ return str(self.start)
20
+ return f"{self.start}-{self.stop}"
21
+
22
+ def __repr__(self):
23
+ return f"VlanRange({self.start}, {self.stop})"
24
+
25
+ def __eq__(self, other: object) -> bool:
26
+ if type(other) is VidRange:
27
+ return self.start == other.start and self.stop == other.stop
28
+ return NotImplemented
29
+
30
+
31
+ def _parse_vlan_ranges(ranges: str) -> Iterable[VidRange]:
32
+ for range in ranges.split(","):
33
+ start, sep, stop = range.strip().partition("-")
34
+ try:
35
+ if not sep:
36
+ int_start = int(start)
37
+ yield VidRange(int_start, int_start)
38
+ elif not stop or not start:
39
+ raise ValueError(f"Cannot parse range {range!r}. Expected `start-stop`")
40
+ else:
41
+ yield VidRange(int(start), int(stop))
42
+ except ValueError:
43
+ raise ValueError(f"Cannot parse range {range!r}. Expected `vid1-vid2` or `vid`")
44
+
45
+
46
+ class VidCollection:
47
+ @staticmethod
48
+ def parse(ranges: int | str) -> "VidCollection":
49
+ if isinstance(ranges, int):
50
+ return VidCollection([VidRange(ranges, ranges)])
51
+ elif isinstance(ranges, str):
52
+ return VidCollection(list(_parse_vlan_ranges(ranges)))
53
+ elif isinstance(ranges, VidCollection):
54
+ return VidCollection(ranges.ranges)
55
+ else:
56
+ raise TypeError(f"Expected str or int, got {type(ranges)}")
57
+
58
+ def __init__(self, ranges: list[VidRange]) -> None:
59
+ self.ranges = ranges
60
+
61
+ def __str__(self):
62
+ return ",".join(map(str, self.ranges))
63
+
64
+ def __repr__(self):
65
+ return f"VlanCollection({str(self)!r})"
66
+
67
+ def __iter__(self):
68
+ for range in self.ranges:
69
+ yield from range
70
+
71
+ def __eq__(self, other: object) -> bool:
72
+ if type(other) is VidCollection:
73
+ return self.ranges == other.ranges
74
+ return False
75
+
76
+
6
77
  class ASN(int):
7
78
  """
8
79
  Stores ASN number and formats it as в AS1.AS2
@@ -235,6 +306,17 @@ class PeerGroup:
235
306
  mtu: int = 0
236
307
 
237
308
 
309
+ @dataclass
310
+ class L2VpnOptions:
311
+ name: str
312
+ vid: VidCollection
313
+ l2vni: int # VNI, possible values are 1 to 2**24-1
314
+ route_distinguisher: str = "" # like in VrfOptions
315
+ rt_import: list[str] = field(default_factory=list) # like in VrfOptions
316
+ rt_export: list[str] = field(default_factory=list) # like in VrfOptions
317
+ advertise_host_routes: bool = True # advertise IP+MAC routes into L3VNI
318
+
319
+
238
320
  @dataclass
239
321
  class VrfOptions:
240
322
  vrf_name: str
@@ -274,8 +356,8 @@ class GlobalOptions:
274
356
  multipath: int = 0
275
357
  router_id: str = ""
276
358
  vrf: dict[str, VrfOptions] = field(default_factory=dict)
277
-
278
359
  groups: list[PeerGroup] = field(default_factory=list)
360
+ l2vpn: dict[str, L2VpnOptions] = field(default_factory=dict)
279
361
 
280
362
 
281
363
  @dataclass