diffnc 0.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.
- diffnc/__init__.py +37 -0
- diffnc/__main__.py +8 -0
- diffnc/cli.py +140 -0
- diffnc/detect.py +170 -0
- diffnc/diff.py +353 -0
- diffnc/errors.py +22 -0
- diffnc/ir.py +58 -0
- diffnc/py.typed +0 -0
- diffnc/reconcile.py +132 -0
- diffnc/vendors/__init__.py +58 -0
- diffnc/vendors/_cisco_like.py +203 -0
- diffnc/vendors/base.py +88 -0
- diffnc/vendors/eos.py +19 -0
- diffnc/vendors/ios.py +19 -0
- diffnc/vendors/iosxe.py +20 -0
- diffnc/vendors/iosxr.py +19 -0
- diffnc/vendors/junos.py +175 -0
- diffnc/vendors/junos_set.py +158 -0
- diffnc/vendors/nxos.py +22 -0
- diffnc-0.0.1.dist-info/METADATA +283 -0
- diffnc-0.0.1.dist-info/RECORD +24 -0
- diffnc-0.0.1.dist-info/WHEEL +4 -0
- diffnc-0.0.1.dist-info/entry_points.txt +2 -0
- diffnc-0.0.1.dist-info/licenses/LICENSE +21 -0
diffnc/diff.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Structural diff engine.
|
|
2
|
+
|
|
3
|
+
The algorithm walks two :class:`~diffnc.ir.ConfigTree`'s in parallel, computing per-level
|
|
4
|
+
opcodes with :class:`difflib.SequenceMatcher` keyed on each node's ``line``. For matched
|
|
5
|
+
sections we recurse so that only their *changed descendants* surface; the matched section
|
|
6
|
+
header itself is emitted as a context line so the user can see the surrounding scope.
|
|
7
|
+
|
|
8
|
+
Two flavours are exposed, both modelled on :mod:`difflib`:
|
|
9
|
+
|
|
10
|
+
* :func:`unified_diff` — compact: only changed lines plus the section path leading to them
|
|
11
|
+
(and ``--- a`` / ``+++ b`` headers if file names are supplied).
|
|
12
|
+
* :func:`ndiff` — verbose: every line is shown, prefixed with ``- ``, ``+ `` or `` ``.
|
|
13
|
+
|
|
14
|
+
Both functions accept either ``str`` (raw config text) or ``list[str]`` (already-split
|
|
15
|
+
lines, joined internally) for parity with :mod:`difflib`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from difflib import SequenceMatcher
|
|
23
|
+
|
|
24
|
+
from diffnc import vendors as _vendors
|
|
25
|
+
from diffnc.detect import detect_vendor
|
|
26
|
+
from diffnc.errors import VendorMismatchError
|
|
27
|
+
from diffnc.ir import ConfigNode, ConfigTree
|
|
28
|
+
from diffnc.vendors.base import VendorParser, is_order_sensitive_for, render_subtree
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _Event:
|
|
33
|
+
op: str # one of ' ', '-', '+', '!'
|
|
34
|
+
text: str # already-indented line, no marker prefix
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unified_diff(
|
|
38
|
+
a: str | Iterable[str],
|
|
39
|
+
b: str | Iterable[str],
|
|
40
|
+
fromfile: str = "",
|
|
41
|
+
tofile: str = "",
|
|
42
|
+
lineterm: str = "",
|
|
43
|
+
*,
|
|
44
|
+
vendor: str | None = None,
|
|
45
|
+
) -> Iterator[str]:
|
|
46
|
+
"""Yield a structural unified diff of *a* vs *b*.
|
|
47
|
+
|
|
48
|
+
Equal leaves are omitted; only changed lines are shown, along with the path of
|
|
49
|
+
enclosing sections (as `` `` context lines) so the diff stays readable.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
a, b: configuration text. ``str`` is taken verbatim; an iterable of strings is
|
|
53
|
+
joined with newlines before parsing.
|
|
54
|
+
fromfile, tofile: optional file names rendered as ``--- a`` / ``+++ b`` headers.
|
|
55
|
+
lineterm: line terminator appended to each yielded line. Defaults to the empty
|
|
56
|
+
string; callers that need newline-terminated lines should pass ``"\\n"``.
|
|
57
|
+
vendor: skip auto-detection and force a specific parser. Ignored when unset.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
VendorMismatchError, ParseError: see :mod:`diffnc.errors`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
events = _structural_events(a, b, vendor=vendor)
|
|
64
|
+
if not events:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if fromfile or tofile:
|
|
68
|
+
yield f"--- {fromfile}{lineterm}"
|
|
69
|
+
yield f"+++ {tofile}{lineterm}"
|
|
70
|
+
for ev in events:
|
|
71
|
+
yield f"{ev.op}{ev.text}{lineterm}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ndiff(
|
|
75
|
+
a: str | Iterable[str],
|
|
76
|
+
b: str | Iterable[str],
|
|
77
|
+
lineterm: str = "",
|
|
78
|
+
*,
|
|
79
|
+
vendor: str | None = None,
|
|
80
|
+
) -> Iterator[str]:
|
|
81
|
+
"""Yield a verbose, every-line diff using ``- ``/``+ ``/``! ``/`` `` markers.
|
|
82
|
+
|
|
83
|
+
``lineterm`` defaults to the empty string; pass ``"\\n"`` for newline-terminated
|
|
84
|
+
output.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
parser, tree_a, tree_b = _prepare(a, b, vendor=vendor)
|
|
88
|
+
events = list(
|
|
89
|
+
_diff_children(parser, tree_a.root, tree_b.root, depth=0, hide_equal=False, path=())
|
|
90
|
+
)
|
|
91
|
+
for ev in events:
|
|
92
|
+
marker = f"{ev.op} " if ev.op != " " else " "
|
|
93
|
+
yield f"{marker}{ev.text}{lineterm}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# internals
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _structural_events(
|
|
102
|
+
a: str | Iterable[str],
|
|
103
|
+
b: str | Iterable[str],
|
|
104
|
+
*,
|
|
105
|
+
vendor: str | None,
|
|
106
|
+
) -> list[_Event]:
|
|
107
|
+
parser, tree_a, tree_b = _prepare(a, b, vendor=vendor)
|
|
108
|
+
return list(_diff_children(parser, tree_a.root, tree_b.root, depth=0, hide_equal=True, path=()))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _prepare(
|
|
112
|
+
a: str | Iterable[str],
|
|
113
|
+
b: str | Iterable[str],
|
|
114
|
+
*,
|
|
115
|
+
vendor: str | None,
|
|
116
|
+
) -> tuple[VendorParser, ConfigTree, ConfigTree]:
|
|
117
|
+
text_a = _coerce(a)
|
|
118
|
+
text_b = _coerce(b)
|
|
119
|
+
|
|
120
|
+
if vendor is None:
|
|
121
|
+
vendor_a = detect_vendor(text_a)
|
|
122
|
+
vendor_b = detect_vendor(text_b)
|
|
123
|
+
if vendor_a != vendor_b:
|
|
124
|
+
raise VendorMismatchError(vendor_a, vendor_b)
|
|
125
|
+
vendor_name = vendor_a
|
|
126
|
+
else:
|
|
127
|
+
vendor_name = vendor
|
|
128
|
+
|
|
129
|
+
parser = _vendors.get(vendor_name)
|
|
130
|
+
tree_a = parser.parse(text_a)
|
|
131
|
+
tree_b = parser.parse(text_b)
|
|
132
|
+
return parser, tree_a, tree_b
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _coerce(value: str | Iterable[str]) -> str:
|
|
136
|
+
if isinstance(value, str):
|
|
137
|
+
return value
|
|
138
|
+
return "\n".join(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _diff_children(
|
|
142
|
+
parser: VendorParser,
|
|
143
|
+
node_a: ConfigNode,
|
|
144
|
+
node_b: ConfigNode,
|
|
145
|
+
depth: int,
|
|
146
|
+
*,
|
|
147
|
+
hide_equal: bool,
|
|
148
|
+
path: tuple[str, ...],
|
|
149
|
+
) -> Iterator[_Event]:
|
|
150
|
+
if is_order_sensitive_for(parser, path):
|
|
151
|
+
yield from _diff_children_ordered(
|
|
152
|
+
parser, node_a, node_b, depth, hide_equal=hide_equal, path=path
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
yield from _diff_children_unordered(
|
|
156
|
+
parser, node_a, node_b, depth, hide_equal=hide_equal, path=path
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _diff_children_ordered(
|
|
161
|
+
parser: VendorParser,
|
|
162
|
+
node_a: ConfigNode,
|
|
163
|
+
node_b: ConfigNode,
|
|
164
|
+
depth: int,
|
|
165
|
+
*,
|
|
166
|
+
hide_equal: bool,
|
|
167
|
+
path: tuple[str, ...],
|
|
168
|
+
) -> Iterator[_Event]:
|
|
169
|
+
"""Positional matching via :class:`difflib.SequenceMatcher`.
|
|
170
|
+
|
|
171
|
+
Used for parents whose children's evaluation order is semantically meaningful — e.g.
|
|
172
|
+
Junos ``firewall filter`` terms, Cisco ``ip access-list`` ACEs, ``policy-map`` class
|
|
173
|
+
blocks. Pure reorders (children whose rendered subtree is byte-identical on both
|
|
174
|
+
sides) surface once with the ``!`` marker; genuine changes still show as ``-``/``+``
|
|
175
|
+
pairs.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
a_children = node_a.children
|
|
179
|
+
b_children = node_b.children
|
|
180
|
+
a_keys = [c.line for c in a_children]
|
|
181
|
+
b_keys = [c.line for c in b_children]
|
|
182
|
+
matcher = SequenceMatcher(a=a_keys, b=b_keys, autojunk=False)
|
|
183
|
+
opcodes = matcher.get_opcodes()
|
|
184
|
+
|
|
185
|
+
reordered_a, reordered_b = _collect_reorder_pairs(opcodes, a_children, b_children, parser)
|
|
186
|
+
|
|
187
|
+
for tag, i1, i2, j1, j2 in opcodes:
|
|
188
|
+
if tag == "equal":
|
|
189
|
+
for k in range(i2 - i1):
|
|
190
|
+
child_a = a_children[i1 + k]
|
|
191
|
+
child_b = b_children[j1 + k]
|
|
192
|
+
yield from _equal_pair(parser, child_a, child_b, depth, hide_equal, path)
|
|
193
|
+
elif tag == "delete":
|
|
194
|
+
for k in range(i1, i2):
|
|
195
|
+
op = "!" if k in reordered_a else "-"
|
|
196
|
+
yield from _emit_one_side(parser, a_children[k], depth, op)
|
|
197
|
+
elif tag == "insert":
|
|
198
|
+
for k in range(j1, j2):
|
|
199
|
+
if k in reordered_b:
|
|
200
|
+
continue
|
|
201
|
+
yield from _emit_one_side(parser, b_children[k], depth, "+")
|
|
202
|
+
elif tag == "replace":
|
|
203
|
+
a_indices = list(range(i1, i2))
|
|
204
|
+
b_indices = [k for k in range(j1, j2) if k not in reordered_b]
|
|
205
|
+
for step in range(max(len(a_indices), len(b_indices))):
|
|
206
|
+
if step < len(a_indices):
|
|
207
|
+
ka = a_indices[step]
|
|
208
|
+
op = "!" if ka in reordered_a else "-"
|
|
209
|
+
yield from _emit_one_side(parser, a_children[ka], depth, op)
|
|
210
|
+
if step < len(b_indices):
|
|
211
|
+
kb = b_indices[step]
|
|
212
|
+
yield from _emit_one_side(parser, b_children[kb], depth, "+")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _collect_reorder_pairs(
|
|
216
|
+
opcodes: Sequence[tuple[str, int, int, int, int]],
|
|
217
|
+
a_children: list[ConfigNode],
|
|
218
|
+
b_children: list[ConfigNode],
|
|
219
|
+
parser: VendorParser,
|
|
220
|
+
) -> tuple[set[int], set[int]]:
|
|
221
|
+
"""Find delete/insert pairs whose subtrees render identically — i.e. pure reorders.
|
|
222
|
+
|
|
223
|
+
Returns ``(reordered_a_indices, reordered_b_indices)``. The A-side index is where
|
|
224
|
+
we will emit a single ``!`` line; the B-side index marks the matching insert that
|
|
225
|
+
should be suppressed.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
a_indices: list[int] = []
|
|
229
|
+
b_indices: list[int] = []
|
|
230
|
+
for tag, i1, i2, j1, j2 in opcodes:
|
|
231
|
+
if tag in ("delete", "replace"):
|
|
232
|
+
a_indices.extend(range(i1, i2))
|
|
233
|
+
if tag in ("insert", "replace"):
|
|
234
|
+
b_indices.extend(range(j1, j2))
|
|
235
|
+
|
|
236
|
+
b_by_render: dict[tuple[str, ...], list[int]] = {}
|
|
237
|
+
for idx in b_indices:
|
|
238
|
+
key = tuple(render_subtree(parser, b_children[idx], 0))
|
|
239
|
+
b_by_render.setdefault(key, []).append(idx)
|
|
240
|
+
|
|
241
|
+
reordered_a: set[int] = set()
|
|
242
|
+
reordered_b: set[int] = set()
|
|
243
|
+
for idx in a_indices:
|
|
244
|
+
key = tuple(render_subtree(parser, a_children[idx], 0))
|
|
245
|
+
bucket = b_by_render.get(key)
|
|
246
|
+
if bucket:
|
|
247
|
+
reordered_a.add(idx)
|
|
248
|
+
reordered_b.add(bucket.pop(0))
|
|
249
|
+
return reordered_a, reordered_b
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _diff_children_unordered(
|
|
253
|
+
parser: VendorParser,
|
|
254
|
+
node_a: ConfigNode,
|
|
255
|
+
node_b: ConfigNode,
|
|
256
|
+
depth: int,
|
|
257
|
+
*,
|
|
258
|
+
hide_equal: bool,
|
|
259
|
+
path: tuple[str, ...],
|
|
260
|
+
) -> Iterator[_Event]:
|
|
261
|
+
"""Set-like matching keyed on ``line``, interleaved by position.
|
|
262
|
+
|
|
263
|
+
Within a parent, the IR guarantees each child has a unique ``line`` (same-named
|
|
264
|
+
siblings are merged on parse), so we can pair children by key. Matching stays
|
|
265
|
+
order-insensitive (pure reorders produce no diff), but the display walks ``a`` and
|
|
266
|
+
splices in ``b``-only children at their natural position — i.e. before the next
|
|
267
|
+
matched anchor — so ``-`` / ``+`` lines surface near each other instead of clumping
|
|
268
|
+
at the start and end.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
a_children = node_a.children
|
|
272
|
+
b_children = node_b.children
|
|
273
|
+
b_by_line = {child.line: child for child in b_children}
|
|
274
|
+
common = {child.line for child in a_children} & set(b_by_line)
|
|
275
|
+
b_match_index = {
|
|
276
|
+
child.line: idx for idx, child in enumerate(b_children) if child.line in common
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
b_pointer = 0
|
|
280
|
+
for child_a in a_children:
|
|
281
|
+
if child_a.line not in common:
|
|
282
|
+
yield from _emit_one_side(parser, child_a, depth, "-")
|
|
283
|
+
continue
|
|
284
|
+
b_pos = b_match_index[child_a.line]
|
|
285
|
+
if b_pos >= b_pointer:
|
|
286
|
+
for k in range(b_pointer, b_pos):
|
|
287
|
+
child_b = b_children[k]
|
|
288
|
+
if child_b.line not in common:
|
|
289
|
+
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
290
|
+
b_pointer = b_pos + 1
|
|
291
|
+
yield from _equal_pair(parser, child_a, b_by_line[child_a.line], depth, hide_equal, path)
|
|
292
|
+
|
|
293
|
+
for k in range(b_pointer, len(b_children)):
|
|
294
|
+
child_b = b_children[k]
|
|
295
|
+
if child_b.line not in common:
|
|
296
|
+
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _equal_pair(
|
|
300
|
+
parser: VendorParser,
|
|
301
|
+
child_a: ConfigNode,
|
|
302
|
+
child_b: ConfigNode,
|
|
303
|
+
depth: int,
|
|
304
|
+
hide_equal: bool,
|
|
305
|
+
path: tuple[str, ...],
|
|
306
|
+
) -> Iterator[_Event]:
|
|
307
|
+
a_is_section = not child_a.is_leaf
|
|
308
|
+
b_is_section = not child_b.is_leaf
|
|
309
|
+
|
|
310
|
+
if not a_is_section and not b_is_section:
|
|
311
|
+
# Equal leaf at this level. Suppress in the compact view, show as context in ndiff.
|
|
312
|
+
if not hide_equal:
|
|
313
|
+
yield _Event(" ", parser.render_leaf(child_a, depth))
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
if a_is_section != b_is_section:
|
|
317
|
+
# One side became a section while the other stayed a leaf — emit as replace.
|
|
318
|
+
yield from _emit_one_side(parser, child_a, depth, "-")
|
|
319
|
+
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
# Both are sections with matching headers. Diff their children; only surface the section
|
|
323
|
+
# header if any descendant differs (compact mode) or always (ndiff).
|
|
324
|
+
inner = list(
|
|
325
|
+
_diff_children(
|
|
326
|
+
parser,
|
|
327
|
+
child_a,
|
|
328
|
+
child_b,
|
|
329
|
+
depth + 1,
|
|
330
|
+
hide_equal=hide_equal,
|
|
331
|
+
path=(*path, child_a.line),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
has_change = any(ev.op != " " for ev in inner)
|
|
335
|
+
|
|
336
|
+
if not has_change and hide_equal:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
yield _Event(" ", parser.render_open(child_a, depth))
|
|
340
|
+
yield from inner
|
|
341
|
+
close = parser.render_close(child_a, depth)
|
|
342
|
+
if close is not None:
|
|
343
|
+
yield _Event(" ", close)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _emit_one_side(
|
|
347
|
+
parser: VendorParser,
|
|
348
|
+
node: ConfigNode,
|
|
349
|
+
depth: int,
|
|
350
|
+
op: str,
|
|
351
|
+
) -> Iterator[_Event]:
|
|
352
|
+
for line in render_subtree(parser, node, depth):
|
|
353
|
+
yield _Event(op, line)
|
diffnc/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exception types used across diffnc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DiffncError(Exception):
|
|
7
|
+
"""Base class for all diffnc errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParseError(DiffncError):
|
|
11
|
+
"""Raised when a configuration cannot be parsed or its vendor cannot be detected."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VendorMismatchError(DiffncError):
|
|
15
|
+
"""Raised when two configurations belong to different vendors."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, vendor_a: str, vendor_b: str) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"cannot diff configurations from different vendors: {vendor_a!r} vs {vendor_b!r}"
|
|
20
|
+
)
|
|
21
|
+
self.vendor_a = vendor_a
|
|
22
|
+
self.vendor_b = vendor_b
|
diffnc/ir.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Internal representation of a network configuration.
|
|
2
|
+
|
|
3
|
+
A configuration is modelled as a tree of :class:`ConfigNode`. Each node carries one logical
|
|
4
|
+
command line plus its (ordered) children. The whole document is wrapped in :class:`ConfigTree`
|
|
5
|
+
which records the originating vendor so that the diff engine can later format output back
|
|
6
|
+
in the input's flavour.
|
|
7
|
+
|
|
8
|
+
The constructors in this module also implement the normalisation rules described in the plan:
|
|
9
|
+
|
|
10
|
+
* Same-name non-leaf siblings get merged (their children concatenate).
|
|
11
|
+
* Duplicate leaf siblings collapse to one occurrence.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ConfigNode:
|
|
21
|
+
"""A single configuration statement, optionally with nested children."""
|
|
22
|
+
|
|
23
|
+
line: str
|
|
24
|
+
children: list[ConfigNode] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_leaf(self) -> bool:
|
|
28
|
+
return not self.children
|
|
29
|
+
|
|
30
|
+
def add_child(self, child: ConfigNode) -> ConfigNode:
|
|
31
|
+
"""Append *child* (or fold it into an existing same-named sibling) and return the
|
|
32
|
+
node that now represents it in the tree.
|
|
33
|
+
|
|
34
|
+
Indent-based vendors (e.g. NX-OS) can't tell at insertion time whether a line will
|
|
35
|
+
end up being a leaf or a section, so the merge rule is intentionally simple: any
|
|
36
|
+
sibling with the same ``line`` absorbs the new node's children. This collapses
|
|
37
|
+
repeated blocks (``interface eth1`` appearing twice) into one.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
for sibling in self.children:
|
|
41
|
+
if sibling.line == child.line:
|
|
42
|
+
for grandchild in child.children:
|
|
43
|
+
sibling.add_child(grandchild)
|
|
44
|
+
return sibling
|
|
45
|
+
self.children.append(child)
|
|
46
|
+
return child
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ConfigTree:
|
|
51
|
+
"""A parsed configuration document."""
|
|
52
|
+
|
|
53
|
+
root: ConfigNode
|
|
54
|
+
vendor: str
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def empty(cls, vendor: str) -> ConfigTree:
|
|
58
|
+
return cls(root=ConfigNode(line=""), vendor=vendor)
|
diffnc/py.typed
ADDED
|
File without changes
|
diffnc/reconcile.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Generate config-mode commands that transform config *A* into config *B*.
|
|
2
|
+
|
|
3
|
+
Walks two :class:`~diffnc.ir.ConfigTree`'s in parallel and emits a stream of
|
|
4
|
+
:class:`ReconcileEvent`'s — one per added subtree, deleted subtree, or order-sensitive
|
|
5
|
+
section that needs to be recreated wholesale. Each vendor's :meth:`render_reconcile` then
|
|
6
|
+
translates those events into its own CLI syntax (``no`` / ``set`` / ``delete`` ...).
|
|
7
|
+
|
|
8
|
+
Output is intentionally bare config-mode commands: no ``configure terminal`` / ``end`` /
|
|
9
|
+
``commit`` wrappers, no indentation. The caller is expected to pipe the result into a
|
|
10
|
+
session that's already in config mode (e.g. ``... | ssh device 'configure terminal; ...'``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterable, Iterator
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from diffnc.diff import _prepare
|
|
19
|
+
from diffnc.ir import ConfigNode
|
|
20
|
+
from diffnc.vendors.base import VendorParser, is_order_sensitive_for, render_subtree
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ReconcileAdd:
|
|
25
|
+
"""A subtree present in *B* but not in *A* at ``parent_path``."""
|
|
26
|
+
|
|
27
|
+
parent_path: tuple[str, ...]
|
|
28
|
+
node: ConfigNode
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ReconcileDelete:
|
|
33
|
+
"""A subtree present in *A* but not in *B* at ``parent_path``."""
|
|
34
|
+
|
|
35
|
+
parent_path: tuple[str, ...]
|
|
36
|
+
node: ConfigNode
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ReconcileRecreate:
|
|
41
|
+
"""An order-sensitive section whose children differ — wipe and recreate.
|
|
42
|
+
|
|
43
|
+
``section_path`` is the full path *including* the section to recreate (its last
|
|
44
|
+
element is the section header). ``new_node`` is the B-side parent node, whose
|
|
45
|
+
children become the new contents of the section.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
section_path: tuple[str, ...]
|
|
49
|
+
new_node: ConfigNode
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ReconcileEvent = ReconcileAdd | ReconcileDelete | ReconcileRecreate
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reconcile(
|
|
56
|
+
a: str | Iterable[str],
|
|
57
|
+
b: str | Iterable[str],
|
|
58
|
+
*,
|
|
59
|
+
vendor: str | None = None,
|
|
60
|
+
) -> Iterator[str]:
|
|
61
|
+
"""Yield config-mode commands that, when entered on a device running config *A*,
|
|
62
|
+
bring it to the state described by config *B*.
|
|
63
|
+
|
|
64
|
+
Output lines do not include a trailing newline; callers that need newline-terminated
|
|
65
|
+
output should append one themselves (mirrors :func:`diffnc.unified_diff`).
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
VendorMismatchError, ParseError: see :mod:`diffnc.errors`.
|
|
69
|
+
NotImplementedError: if the resolved vendor parser does not implement
|
|
70
|
+
:meth:`~diffnc.vendors.base.VendorParser.render_reconcile`.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
parser, tree_a, tree_b = _prepare(a, b, vendor=vendor)
|
|
74
|
+
events = list(_collect_events(parser, tree_a.root, tree_b.root, parent_path=()))
|
|
75
|
+
if not events:
|
|
76
|
+
return
|
|
77
|
+
render = getattr(parser, "render_reconcile", None)
|
|
78
|
+
if render is None:
|
|
79
|
+
raise NotImplementedError(f"vendor {parser.name!r} does not support command generation")
|
|
80
|
+
yield from render(events)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# internals
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_events(
|
|
89
|
+
parser: VendorParser,
|
|
90
|
+
node_a: ConfigNode,
|
|
91
|
+
node_b: ConfigNode,
|
|
92
|
+
parent_path: tuple[str, ...],
|
|
93
|
+
) -> Iterator[ReconcileEvent]:
|
|
94
|
+
"""Diff two parent nodes' children and yield reconcile events.
|
|
95
|
+
|
|
96
|
+
Mirrors the order-insensitive matching used by :func:`diffnc.diff._diff_children`
|
|
97
|
+
but emits semantic events instead of display lines, and short-circuits any
|
|
98
|
+
order-sensitive subsection into a single :class:`ReconcileRecreate`.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
b_by_line = {c.line: c for c in node_b.children}
|
|
102
|
+
a_by_line = {c.line: c for c in node_a.children}
|
|
103
|
+
|
|
104
|
+
for child_a in node_a.children:
|
|
105
|
+
child_b = b_by_line.get(child_a.line)
|
|
106
|
+
if child_b is None:
|
|
107
|
+
yield ReconcileDelete(parent_path, child_a)
|
|
108
|
+
continue
|
|
109
|
+
if child_a.is_leaf and child_b.is_leaf:
|
|
110
|
+
continue
|
|
111
|
+
if child_a.is_leaf != child_b.is_leaf:
|
|
112
|
+
yield ReconcileDelete(parent_path, child_a)
|
|
113
|
+
yield ReconcileAdd(parent_path, child_b)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
next_path = (*parent_path, child_a.line)
|
|
117
|
+
if is_order_sensitive_for(parser, next_path):
|
|
118
|
+
if not _subtrees_equal(parser, child_a, child_b):
|
|
119
|
+
yield ReconcileRecreate(next_path, child_b)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
yield from _collect_events(parser, child_a, child_b, next_path)
|
|
123
|
+
|
|
124
|
+
for child_b in node_b.children:
|
|
125
|
+
if child_b.line not in a_by_line:
|
|
126
|
+
yield ReconcileAdd(parent_path, child_b)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _subtrees_equal(parser: VendorParser, a: ConfigNode, b: ConfigNode) -> bool:
|
|
130
|
+
"""True iff *a* and *b* render to identical line sequences."""
|
|
131
|
+
|
|
132
|
+
return render_subtree(parser, a, 0) == render_subtree(parser, b, 0)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Vendor plugin registry.
|
|
2
|
+
|
|
3
|
+
Importing this package automatically registers the built-in vendors (Junos, Junos set,
|
|
4
|
+
NXOS, IOS, IOS-XE, IOS-XR, EOS). Third-party code can register additional vendors via
|
|
5
|
+
:func:`register`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from diffnc.vendors.base import VendorParser
|
|
11
|
+
|
|
12
|
+
_REGISTRY: dict[str, VendorParser] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(parser: VendorParser) -> None:
|
|
16
|
+
"""Register *parser* under its :attr:`~VendorParser.name`."""
|
|
17
|
+
|
|
18
|
+
_REGISTRY[parser.name] = parser
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get(name: str) -> VendorParser:
|
|
22
|
+
"""Look up the parser registered for *name*.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
KeyError: if no vendor with that name has been registered.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return _REGISTRY[name]
|
|
30
|
+
except KeyError as exc:
|
|
31
|
+
raise KeyError(f"unknown vendor: {name!r}") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def names() -> list[str]:
|
|
35
|
+
"""Return all registered vendor names, sorted alphabetically."""
|
|
36
|
+
|
|
37
|
+
return sorted(_REGISTRY)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Register built-in vendors. Imports are placed after the registry is defined to avoid
|
|
41
|
+
# circular-import surprises when sub-modules call back into this package at import time.
|
|
42
|
+
from diffnc.vendors import eos as _eos # noqa: E402
|
|
43
|
+
from diffnc.vendors import ios as _ios # noqa: E402
|
|
44
|
+
from diffnc.vendors import iosxe as _iosxe # noqa: E402
|
|
45
|
+
from diffnc.vendors import iosxr as _iosxr # noqa: E402
|
|
46
|
+
from diffnc.vendors import junos as _junos # noqa: E402
|
|
47
|
+
from diffnc.vendors import junos_set as _junos_set # noqa: E402
|
|
48
|
+
from diffnc.vendors import nxos as _nxos # noqa: E402
|
|
49
|
+
|
|
50
|
+
register(_junos.PARSER)
|
|
51
|
+
register(_junos_set.PARSER)
|
|
52
|
+
register(_nxos.PARSER)
|
|
53
|
+
register(_ios.PARSER)
|
|
54
|
+
register(_iosxe.PARSER)
|
|
55
|
+
register(_iosxr.PARSER)
|
|
56
|
+
register(_eos.PARSER)
|
|
57
|
+
|
|
58
|
+
__all__ = ["VendorParser", "get", "names", "register"]
|