annet 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.

Files changed (113) hide show
  1. annet/__init__.py +61 -0
  2. annet/annet.py +25 -0
  3. annet/annlib/__init__.py +7 -0
  4. annet/annlib/command.py +49 -0
  5. annet/annlib/diff.py +158 -0
  6. annet/annlib/errors.py +8 -0
  7. annet/annlib/filter_acl.py +196 -0
  8. annet/annlib/jsontools.py +89 -0
  9. annet/annlib/lib.py +495 -0
  10. annet/annlib/netdev/__init__.py +0 -0
  11. annet/annlib/netdev/db.py +62 -0
  12. annet/annlib/netdev/devdb/__init__.py +28 -0
  13. annet/annlib/netdev/devdb/data/devdb.json +137 -0
  14. annet/annlib/netdev/views/__init__.py +0 -0
  15. annet/annlib/netdev/views/dump.py +121 -0
  16. annet/annlib/netdev/views/hardware.py +112 -0
  17. annet/annlib/output.py +246 -0
  18. annet/annlib/patching.py +533 -0
  19. annet/annlib/rbparser/__init__.py +0 -0
  20. annet/annlib/rbparser/acl.py +120 -0
  21. annet/annlib/rbparser/deploying.py +55 -0
  22. annet/annlib/rbparser/ordering.py +52 -0
  23. annet/annlib/rbparser/platform.py +51 -0
  24. annet/annlib/rbparser/syntax.py +115 -0
  25. annet/annlib/rulebook/__init__.py +0 -0
  26. annet/annlib/rulebook/common.py +350 -0
  27. annet/annlib/tabparser.py +648 -0
  28. annet/annlib/types.py +35 -0
  29. annet/api/__init__.py +807 -0
  30. annet/argparse.py +415 -0
  31. annet/cli.py +192 -0
  32. annet/cli_args.py +493 -0
  33. annet/configs/context.yml +18 -0
  34. annet/configs/logging.yaml +39 -0
  35. annet/connectors.py +64 -0
  36. annet/deploy.py +441 -0
  37. annet/diff.py +85 -0
  38. annet/executor.py +551 -0
  39. annet/filtering.py +40 -0
  40. annet/gen.py +828 -0
  41. annet/generators/__init__.py +987 -0
  42. annet/generators/common/__init__.py +0 -0
  43. annet/generators/common/initial.py +33 -0
  44. annet/hardware.py +45 -0
  45. annet/implicit.py +139 -0
  46. annet/lib.py +128 -0
  47. annet/output.py +170 -0
  48. annet/parallel.py +448 -0
  49. annet/patching.py +25 -0
  50. annet/reference.py +148 -0
  51. annet/rulebook/__init__.py +114 -0
  52. annet/rulebook/arista/__init__.py +0 -0
  53. annet/rulebook/arista/iface.py +16 -0
  54. annet/rulebook/aruba/__init__.py +16 -0
  55. annet/rulebook/aruba/ap_env.py +146 -0
  56. annet/rulebook/aruba/misc.py +8 -0
  57. annet/rulebook/cisco/__init__.py +0 -0
  58. annet/rulebook/cisco/iface.py +68 -0
  59. annet/rulebook/cisco/misc.py +57 -0
  60. annet/rulebook/cisco/vlandb.py +90 -0
  61. annet/rulebook/common.py +19 -0
  62. annet/rulebook/deploying.py +87 -0
  63. annet/rulebook/huawei/__init__.py +0 -0
  64. annet/rulebook/huawei/aaa.py +75 -0
  65. annet/rulebook/huawei/bgp.py +97 -0
  66. annet/rulebook/huawei/iface.py +33 -0
  67. annet/rulebook/huawei/misc.py +337 -0
  68. annet/rulebook/huawei/vlandb.py +115 -0
  69. annet/rulebook/juniper/__init__.py +107 -0
  70. annet/rulebook/nexus/__init__.py +0 -0
  71. annet/rulebook/nexus/iface.py +92 -0
  72. annet/rulebook/patching.py +143 -0
  73. annet/rulebook/ribbon/__init__.py +12 -0
  74. annet/rulebook/texts/arista.deploy +20 -0
  75. annet/rulebook/texts/arista.order +125 -0
  76. annet/rulebook/texts/arista.rul +59 -0
  77. annet/rulebook/texts/aruba.deploy +20 -0
  78. annet/rulebook/texts/aruba.order +83 -0
  79. annet/rulebook/texts/aruba.rul +87 -0
  80. annet/rulebook/texts/cisco.deploy +27 -0
  81. annet/rulebook/texts/cisco.order +82 -0
  82. annet/rulebook/texts/cisco.rul +105 -0
  83. annet/rulebook/texts/huawei.deploy +188 -0
  84. annet/rulebook/texts/huawei.order +388 -0
  85. annet/rulebook/texts/huawei.rul +471 -0
  86. annet/rulebook/texts/juniper.rul +120 -0
  87. annet/rulebook/texts/nexus.deploy +24 -0
  88. annet/rulebook/texts/nexus.order +85 -0
  89. annet/rulebook/texts/nexus.rul +83 -0
  90. annet/rulebook/texts/nokia.rul +31 -0
  91. annet/rulebook/texts/pc.order +5 -0
  92. annet/rulebook/texts/pc.rul +9 -0
  93. annet/rulebook/texts/ribbon.deploy +22 -0
  94. annet/rulebook/texts/ribbon.rul +77 -0
  95. annet/rulebook/texts/routeros.order +38 -0
  96. annet/rulebook/texts/routeros.rul +45 -0
  97. annet/storage.py +121 -0
  98. annet/tabparser.py +36 -0
  99. annet/text_term_format.py +95 -0
  100. annet/tracing.py +170 -0
  101. annet/types.py +223 -0
  102. annet-0.1.dist-info/AUTHORS +21 -0
  103. annet-0.1.dist-info/LICENSE +21 -0
  104. annet-0.1.dist-info/METADATA +24 -0
  105. annet-0.1.dist-info/RECORD +113 -0
  106. annet-0.1.dist-info/WHEEL +5 -0
  107. annet-0.1.dist-info/entry_points.txt +6 -0
  108. annet-0.1.dist-info/top_level.txt +3 -0
  109. annet_generators/__init__.py +0 -0
  110. annet_generators/example/__init__.py +12 -0
  111. annet_generators/example/lldp.py +52 -0
  112. annet_nbexport/__init__.py +220 -0
  113. annet_nbexport/main.py +46 -0
File without changes
@@ -0,0 +1,33 @@
1
+ from annet.generators import PartialGenerator
2
+
3
+
4
+ class InitialConfig(PartialGenerator):
5
+
6
+ """
7
+ Конфиги у свежих (еще ни разу не настраиваемых устройств)
8
+ на самом деле НЕ пустые. В данном генераторе отображен
9
+ такой набор команд, по крайней мере тех, которые могут
10
+ изменяться в ходе первичной конфигурации.
11
+
12
+ Acl для данного генератора не нужен, он будет генерировать
13
+ конфиг целиком.
14
+ """
15
+ def __init__(self, storage=None):
16
+ self._do_run = not storage
17
+ super().__init__(storage=None)
18
+
19
+ def run_huawei(self, device):
20
+ if not self._do_run:
21
+ return
22
+ if device.hw.CE:
23
+ yield """
24
+ telnet server disable
25
+ telnet ipv6 server disable
26
+ diffserv domain default
27
+ aaa
28
+ authentication-scheme default
29
+ authorization-scheme default
30
+ accounting-scheme default
31
+ domain default
32
+ domain default_admin
33
+ """
annet/hardware.py ADDED
@@ -0,0 +1,45 @@
1
+ import abc
2
+ from typing import Any
3
+
4
+ from annet.annlib.netdev.views.hardware import HardwareView, hw_to_vendor
5
+
6
+ from annet.connectors import Connector
7
+
8
+
9
+ try:
10
+ from annet.annlib.netdev.views.hardware import vendor_to_hw
11
+ except ImportError:
12
+ from netdev.views.hardware import vendor_to_hw
13
+
14
+
15
+ class _HardwareConnector(Connector["HarwareProvider"]):
16
+ name = "Hardware"
17
+ ep_name = "hardware"
18
+
19
+
20
+ hardware_connector = _HardwareConnector()
21
+
22
+
23
+ class HarwareProvider(abc.ABC):
24
+ @abc.abstractmethod
25
+ def make_hw(self, hw_model: str, sw_version: str) -> Any:
26
+ pass
27
+
28
+ @abc.abstractmethod
29
+ def vendor_to_hw(self, vendor: str) -> Any:
30
+ pass
31
+
32
+ @abc.abstractmethod
33
+ def hw_to_vendor(self, hw: Any) -> str:
34
+ pass
35
+
36
+
37
+ class AnnetHardwareProvider(HarwareProvider):
38
+ def make_hw(self, hw_model: str, sw_version: str) -> HardwareView:
39
+ return HardwareView(hw_model, sw_version)
40
+
41
+ def vendor_to_hw(self, vendor: str) -> HardwareView:
42
+ return vendor_to_hw(vendor)
43
+
44
+ def hw_to_vendor(self, hw: HardwareView) -> str:
45
+ return hw_to_vendor(hw)
annet/implicit.py ADDED
@@ -0,0 +1,139 @@
1
+ from collections import OrderedDict as odict
2
+
3
+ from annet.annlib.rbparser import syntax
4
+
5
+
6
+ def config(config_tree, rules):
7
+ implicit_config_tree = odict()
8
+ for (row, rule) in rules.items():
9
+ matched_lines = [line for line in config_tree.keys() if rule["regexp"].match(line)]
10
+ if rule["type"] != "ignore":
11
+ if not any(matched_lines) and row not in config_tree:
12
+ implicit_config_tree[row] = odict()
13
+ for line in matched_lines:
14
+ implicit_config_tree[line] = config(config_tree[line], rule["children"])
15
+ return implicit_config_tree
16
+
17
+
18
+ def compile_rules(device):
19
+ return compile_tree(_implicit_tree(device))
20
+
21
+
22
+ def compile_tree(tree):
23
+ rules = odict()
24
+ for (_, attrs) in tree.items():
25
+ rule = {
26
+ "type": attrs["type"],
27
+ "children": compile_tree(attrs["children"]) if attrs.get("children") else odict(),
28
+ "regexp": syntax.compile_row_regexp(attrs["row"]),
29
+ }
30
+ rules[attrs["row"]] = rule
31
+ return rules
32
+
33
+
34
+ def _implicit_tree(device):
35
+ text = ""
36
+ if device.hw.Huawei:
37
+ if device.hw.Huawei.CE:
38
+ text = """
39
+ stp mode mstp
40
+ stp enable
41
+ undo ntp server disable
42
+ undo telnet server disable
43
+ undo dhcp enable
44
+ !user-interface con *
45
+ user privilege level 3
46
+ !user-interface vty ~
47
+ protocol inbound all
48
+ netconf
49
+ """
50
+ elif device.hw.Huawei.NE:
51
+ text = """
52
+ !bgp *
53
+ !ipv4-family unicast
54
+ undo synchronization
55
+ !ipv6-family unicast
56
+ undo synchronization
57
+ !user-interface con *
58
+ user privilege level 3
59
+ !user-interface vty ~
60
+ protocol inbound all
61
+ aaa
62
+ undo user-password complexity-check
63
+ netconf
64
+ """
65
+ else:
66
+ text = """
67
+ stp mode mstp
68
+ !interface X?GigabitEthernet*
69
+ bpdu enable
70
+ netconf
71
+ """
72
+ elif device.hw.Arista:
73
+ # эта часть конфигурации будет не видна в конфиге, если она включена с таким набором полей:
74
+ text = r"""
75
+ ip load-sharing trident fields ipv6 destination-port source-ip ingress-interface destination-ip source-port flow-label
76
+ ip load-sharing trident fields ip source-ip source-port destination-ip destination-port ingress-interface
77
+ """
78
+ elif device.hw.Nexus:
79
+ text = r"""
80
+ # часть конфигурации скрытая если включена
81
+ snmp-server enable traps link linkDown
82
+ snmp-server enable traps link linkUp
83
+ """
84
+ if device.hw.Nexus.N3x.N3432 \
85
+ or (device.hw.Nexus.N9x.N9500 and "spine1" in device.tags) \
86
+ or device.hw.Nexus.N9x.N9316 or device.hw.Cisco.Nexus.N9x.N9364:
87
+ text += r"""
88
+ # SVI
89
+ !interface Vlan*
90
+ !shutdown
91
+ !interface mgmt[0-9]*
92
+ no shutdown
93
+ mtu 1500
94
+ !interface Ethernet1*
95
+ no shutdown
96
+ # Лупбеки
97
+ !interface */Loopback[0-9.]+/
98
+ no shutdown
99
+ # Агрегаты
100
+ !interface */port-channel[0-9.]+/
101
+ no shutdown
102
+ # BGP
103
+ !router bgp *
104
+ !neighbor *
105
+ no shutdown
106
+ """
107
+
108
+ elif device.hw.Nexus.N3x:
109
+ # У нексуса сложные взаимоотношения с
110
+ # shutdown командой вездe
111
+ # в данный момент поведение проверенно для Cisco Nexus 3132Q 6.0(2)U6(7)
112
+ text += r"""
113
+ # SVI
114
+ !interface Vlan*
115
+ !shutdown
116
+ !interface mgmt[0-9]*
117
+ no shutdown
118
+ # Физические НЕ сплитованные интерфейсы и subif'ы
119
+ !interface */Ethernet1\/[0-9.]*/
120
+ no shutdown
121
+ # Физические НЕ сплитованные интерфейсы и subif'ы
122
+ !interface */Ethernet1\/[0-9]+\/[0-9.]+/
123
+ # только explicit
124
+ # Лупбеки
125
+ !interface */Loopback[0-9.]+/
126
+ no shutdown
127
+ # Агрегаты
128
+ !interface */port-channel[0-9.]+/
129
+ no shutdown
130
+ # BGP
131
+ !router bgp *
132
+ !neighbor *
133
+ no shutdown
134
+ """
135
+ return parse_text(text)
136
+
137
+
138
+ def parse_text(text):
139
+ return syntax.parse_text(text, {})
annet/lib.py ADDED
@@ -0,0 +1,128 @@
1
+ import asyncio
2
+ import os
3
+ import shutil
4
+ import sys
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Awaitable, Optional
8
+
9
+ import yaml
10
+ from annet.annlib.lib import ( # pylint: disable=unused-import
11
+ ContextOrderedDict,
12
+ HuaweiNumBlock,
13
+ LMSMatcher,
14
+ add_annotation,
15
+ catch_ctrl_c,
16
+ cisco_collapse_vlandb,
17
+ cisco_expand_vlandb,
18
+ find_exc_in_stack,
19
+ find_modules,
20
+ first,
21
+ flatten,
22
+ huawei_collapse_vlandb,
23
+ huawei_expand_vlandb,
24
+ huawei_iface_ranges,
25
+ is_relative,
26
+ jinja_render,
27
+ jun_activate,
28
+ jun_is_inactive,
29
+ juniper_fmt_prefix_lists_acl,
30
+ juniper_port_split,
31
+ make_ip4_mask,
32
+ mako_render,
33
+ merge_dicts,
34
+ percentile,
35
+ uniq,
36
+ )
37
+ from contextlog import get_logger
38
+
39
+
40
+ _TEMPLATE_CONTEXT_PATH: Optional[str] = None
41
+ _DEFAULT_CONTEXT_PATH: Optional[str] = None
42
+
43
+
44
+ def get_template_context_path() -> str:
45
+ if _TEMPLATE_CONTEXT_PATH is None:
46
+ set_template_context_path(str(Path(sys.modules["annet"].__file__).parent / "configs/context.yml"))
47
+ return _TEMPLATE_CONTEXT_PATH
48
+
49
+
50
+ def set_template_context_path(path: str) -> None:
51
+ global _TEMPLATE_CONTEXT_PATH # pylint: disable=global-statement
52
+ _TEMPLATE_CONTEXT_PATH = path
53
+
54
+
55
+ def get_default_context_path() -> str:
56
+ if _DEFAULT_CONTEXT_PATH is None:
57
+ set_default_context_path("~/.annet/context.yml")
58
+ return _DEFAULT_CONTEXT_PATH
59
+
60
+
61
+ def set_default_context_path(path: str) -> None:
62
+ global _DEFAULT_CONTEXT_PATH # pylint: disable=global-statement
63
+ _DEFAULT_CONTEXT_PATH = path
64
+
65
+
66
+ @lru_cache(maxsize=1)
67
+ def _get_template_context():
68
+ with open(get_template_context_path()) as f:
69
+ return yaml.safe_load(f)
70
+
71
+
72
+ def get_context_path(touch: Optional[bool] = False) -> str:
73
+ path = Path(os.getenv("ANN_CONTEXT_CONFIG_PATH", get_default_context_path())).expanduser().absolute()
74
+ if not path.exists():
75
+ src = get_template_context_path()
76
+ if not touch:
77
+ return str(src)
78
+ try:
79
+ # populate path with default configuration
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ shutil.copy(src, path)
82
+ except shutil.SameFileError:
83
+ pass
84
+ return str(path)
85
+
86
+
87
+ def get_context() -> dict:
88
+ with open(get_context_path()) as f:
89
+ raw = yaml.safe_load(f)
90
+ _fill_in_default_generator_modules(raw)
91
+ context_name = os.getenv("ANN_SELECTED_CONTEXT", raw["selected_context"])
92
+ res = {k: raw[k][v] for k, v in raw["context"][context_name].items()}
93
+ if "ANN_GENERATORS_CONTEXT" in os.environ: # an undocumented hack to maintain backwards compatibility; TODO: remove
94
+ res["generators"] = raw["generators"][os.getenv("ANN_GENERATORS_CONTEXT")]
95
+ return res
96
+
97
+
98
+ @lru_cache(maxsize=1)
99
+ def _warn_no_generators_in_context():
100
+ get_logger().warning(
101
+ "Older version of the context configuration found. Getting generators references from the template context"
102
+ )
103
+
104
+
105
+ def _fill_in_default_generator_modules(raw: dict) -> bool:
106
+ """Backwards compatibility hack to add existing generators refs to context"""
107
+ if "generators" not in raw:
108
+ _warn_no_generators_in_context()
109
+ raw["generators"] = _get_template_context()["generators"]
110
+ for dst_context in raw["context"].values():
111
+ dst_context["generators"] = "default"
112
+ return True
113
+ return False
114
+
115
+
116
+ def repair_context_file() -> None:
117
+ path = get_context_path()
118
+ with open(path) as f:
119
+ data = yaml.safe_load(f)
120
+ if _fill_in_default_generator_modules(data):
121
+ with open(path, "w") as f:
122
+ yaml.dump(data, f, sort_keys=False)
123
+
124
+
125
+ def do_async(coro: Awaitable):
126
+ loop = asyncio.get_event_loop()
127
+ res = loop.run_until_complete(coro)
128
+ return res
annet/output.py ADDED
@@ -0,0 +1,170 @@
1
+ import abc
2
+ import os
3
+ import posixpath
4
+ import sys
5
+ from typing import List, Optional, Tuple, Type
6
+
7
+ import colorama
8
+ from annet.annlib.output import ( # pylint: disable=unused-import
9
+ LABEL_NEW_PREFIX,
10
+ OutputWriter,
11
+ TextArgs,
12
+ capture_output,
13
+ dir_or_file_output,
14
+ format_file_diff,
15
+ print_as_json,
16
+ print_as_yaml,
17
+ print_err_label,
18
+ print_label,
19
+ )
20
+ from contextlog import get_logger
21
+
22
+ from annet.cli_args import FileOutOptions, QueryOptions
23
+ from annet.connectors import Connector
24
+ from annet.storage import Device, storage_connector
25
+
26
+
27
+ class _DriverConnector(Connector["OutputDriver"]):
28
+ name = "OutputDriver"
29
+ ep_name = "output"
30
+
31
+ def _get_default(self) -> Type["OutputDriver"]:
32
+ return OutputDriverBasic
33
+
34
+
35
+ output_driver_connector = _DriverConnector()
36
+
37
+
38
+ class OutputDriver(abc.ABC):
39
+ @abc.abstractmethod
40
+ def write_output(self, arg_out: FileOutOptions, items, query_result_count=1) -> None:
41
+ pass
42
+
43
+ @abc.abstractmethod
44
+ def format_fails(self, fail, args: Optional[QueryOptions] = None) -> Tuple[str, str]:
45
+ pass
46
+
47
+ @abc.abstractmethod
48
+ def cfg_file_names(self, device: Device) -> List[str]:
49
+ pass
50
+
51
+ @abc.abstractmethod
52
+ def entire_config_dest_path(self, device: Device, config_path: str) -> str:
53
+ pass
54
+
55
+
56
+ class OutputDriverBasic(OutputDriver):
57
+ def write_output(self, arg_out: FileOutOptions, items, query_result_count=1):
58
+ """
59
+ пишет результаты генерации в файл или директорию :dest
60
+ :dest - это директория в случаях:
61
+ - заканчивается на "/"
62
+ - существует директория с таким именем
63
+ - более одного устройства в результате запроса
64
+ - есть entire-генераторы (определяется по типу первого результата, если устройство одно)
65
+ """
66
+ logger = get_logger()
67
+
68
+ items_iter = iter(items)
69
+ try:
70
+ first_result = next(items_iter)
71
+ except StopIteration:
72
+ # нет результатов, ничего не пишем и не создаём
73
+ return
74
+
75
+ def _reassemble_items():
76
+ yield first_result
77
+ yield from items_iter
78
+
79
+ dest = arg_out.dest
80
+ dir_mode = dir_or_file_output(dest, query_result_count, suggest_dir=(os.sep in first_result[0]))
81
+ _reassembled_items = list(_reassemble_items())
82
+
83
+ for output_no, (label, output, is_fail) in enumerate(_reassembled_items):
84
+ writer = output if isinstance(output, OutputWriter) else OutputWriter(output)
85
+ label = os.path.normpath(label)
86
+ label_color = colorama.Back.RED if is_fail else colorama.Back.GREEN
87
+ if dest is None:
88
+ if hasattr(arg_out, "format") and arg_out.format == "json":
89
+ if output_no > 0:
90
+ sys.stdout.write(",")
91
+ elif output_no == 0:
92
+ sys.stdout.write("{")
93
+ sys.stdout.write('"%s": ' % label)
94
+ writer.write(sys.stdout)
95
+ if output_no == len(_reassembled_items) - 1:
96
+ sys.stdout.write("}")
97
+ else:
98
+ if not arg_out.no_label:
99
+ print_label(label, back_color=label_color)
100
+ writer.write(sys.stdout)
101
+ elif dir_mode:
102
+ if label.startswith(LABEL_NEW_PREFIX):
103
+ label = label[len(LABEL_NEW_PREFIX):]
104
+ if label.startswith(os.sep):
105
+ # just in case.
106
+ label = label.lstrip(os.sep)
107
+ if os.sep not in label:
108
+ # vendor config
109
+ label = os.path.basename(label)
110
+ else:
111
+ # entire generated file
112
+ parts = label.split(os.sep)
113
+ hostname = parts[0]
114
+ label = os.sep.join(parts[1:])
115
+ if not arg_out.expand_path:
116
+ label = os.path.basename(label)
117
+ if query_result_count > 1:
118
+ label = os.path.join(hostname, label)
119
+ file_dest = os.path.join(dest, "errors") if is_fail else dest
120
+ out_file = os.path.normpath(os.path.join(file_dest, label))
121
+ logger.info("writing '%s'", out_file)
122
+ dirname = os.path.dirname(out_file)
123
+ if not os.path.exists(dirname):
124
+ os.makedirs(dirname)
125
+ with open(out_file, "w") as file:
126
+ writer.write(file)
127
+ else:
128
+ logger.info("writing '%s'", dest)
129
+ with open(dest, "w") as file:
130
+ writer.write(file)
131
+
132
+ def format_fails(self, fail, args: Optional[QueryOptions] = None):
133
+ ret = []
134
+ fqdns = {}
135
+ if args:
136
+ with storage_connector.get().storage()(args) as storage:
137
+ fqdns = storage.resolve_fdnds_by_query(args.query)
138
+ for (assignment, exc) in fail.items():
139
+ label = assignment
140
+ if assignment in fqdns:
141
+ label = fqdns[assignment]
142
+ elif isinstance(assignment, tuple):
143
+ label = assignment[0]
144
+ else:
145
+ ValueError("Failed to parse failed assignment %r" % assignment)
146
+ ret.append((label, getattr(exc, "formatted_output", f"{repr(exc)} (formatted_output is absent)"), True))
147
+ return ret
148
+
149
+ def cfg_file_names(self, device: Device) -> List[str]:
150
+ return [f"{device.hostname}.cfg"]
151
+
152
+ def entire_config_dest_path(self, device, config_path: str) -> str:
153
+ """Формирует путь к конфигу в директории destname.
154
+
155
+ Например, для устройства с hostname `my-device`:
156
+ ```
157
+ >>> device.entire_config_dest_path("/etc/frr/frr.conf")
158
+ 'my-device.cfg/etc/frr/frr.conf'
159
+ >>>
160
+ ```
161
+ """
162
+ # NOTE: с полученным `config_path` работаем через `posixpath`, а не через `os.path`, потому что
163
+ # entire-путь POSIX-специфичный; но в конце формируем путь через `os.path` для текущей платформы
164
+ if not posixpath.abspath(config_path):
165
+ raise RuntimeError(f"Want absolute config path, but relative received: {config_path}")
166
+ cfg_files = self.cfg_file_names(device)
167
+ # NOTE: получаем путь без "/" в начале, например, "etc/frr/frr.conf"
168
+ relative_config_path = posixpath.relpath(config_path, "/")
169
+ dest_config_path_parts = [cfg_files[0]] + relative_config_path.split(posixpath.sep)
170
+ return os.path.join(*dest_config_path_parts)