diffnc 0.0.1__tar.gz → 0.0.3__tar.gz
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-0.0.1 → diffnc-0.0.3}/.github/workflows/publish.yml +2 -2
- {diffnc-0.0.1 → diffnc-0.0.3}/.github/workflows/test.yml +2 -2
- {diffnc-0.0.1 → diffnc-0.0.3}/PKG-INFO +6 -2
- {diffnc-0.0.1 → diffnc-0.0.3}/README.md +5 -1
- {diffnc-0.0.1 → diffnc-0.0.3}/pyproject.toml +1 -1
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/detect.py +17 -7
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/diff.py +116 -13
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_diff.py +89 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_reconcile.py +22 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/uv.lock +1 -1
- {diffnc-0.0.1 → diffnc-0.0.3}/.github/dependabot.yml +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/.gitignore +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/LICENSE +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/__init__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/__main__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/cli.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/errors.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/ir.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/py.typed +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/reconcile.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/__init__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/_cisco_like.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/base.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/eos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/ios.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/iosxe.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/iosxr.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/junos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/junos_set.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/nxos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/__init__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/eos_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/eos_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/ios_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/ios_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxe_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxe_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxr_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxr_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_set_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_set_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/nxos_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/nxos_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_cli.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_detect.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_eos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_ios.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_iosxe.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_iosxr.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_junos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_junos_set.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_nxos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_order_sensitivity.py +0 -0
|
@@ -14,10 +14,10 @@ jobs:
|
|
|
14
14
|
contents: read
|
|
15
15
|
steps:
|
|
16
16
|
- name: Checkout
|
|
17
|
-
uses: actions/checkout@
|
|
17
|
+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
18
18
|
|
|
19
19
|
- name: Install uv
|
|
20
|
-
uses: astral-sh/setup-uv@
|
|
20
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
21
21
|
with:
|
|
22
22
|
python-version: "3.12"
|
|
23
23
|
|
|
@@ -20,10 +20,10 @@ jobs:
|
|
|
20
20
|
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
21
21
|
|
|
22
22
|
steps:
|
|
23
|
-
- uses: actions/checkout@
|
|
23
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
24
24
|
|
|
25
25
|
- name: Install uv
|
|
26
|
-
uses: astral-sh/setup-uv@
|
|
26
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
27
27
|
with:
|
|
28
28
|
python-version: ${{ matrix.python-version }}
|
|
29
29
|
enable-cache: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffnc
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Structural diff library for network device configurations
|
|
5
5
|
Author-email: minefuto <46558834+minefuto@users.noreply.github.com>
|
|
6
6
|
License: MIT License
|
|
@@ -35,6 +35,10 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
|
|
36
36
|
# diffnc(DIFF for Network device Configurations)
|
|
37
37
|
|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+
|
|
38
42
|
A Python library and CLI that diffs network device configurations **with structural awareness**, exposed through a `difflib`-like API.
|
|
39
43
|
|
|
40
44
|
* Duplicate same-name blocks (e.g. `interface eth1` appearing more than once) are merged at parse time
|
|
@@ -257,7 +261,7 @@ The `VendorParser` protocol exposes `is_order_sensitive(path: tuple[str, ...]) -
|
|
|
257
261
|
## Development
|
|
258
262
|
|
|
259
263
|
```bash
|
|
260
|
-
uv sync
|
|
264
|
+
uv sync
|
|
261
265
|
uv run pytest # tests
|
|
262
266
|
uv run ruff check . # lint
|
|
263
267
|
uv run ruff format . # format
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# diffnc(DIFF for Network device Configurations)
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
3
7
|
A Python library and CLI that diffs network device configurations **with structural awareness**, exposed through a `difflib`-like API.
|
|
4
8
|
|
|
5
9
|
* Duplicate same-name blocks (e.g. `interface eth1` appearing more than once) are merged at parse time
|
|
@@ -222,7 +226,7 @@ The `VendorParser` protocol exposes `is_order_sensitive(path: tuple[str, ...]) -
|
|
|
222
226
|
## Development
|
|
223
227
|
|
|
224
228
|
```bash
|
|
225
|
-
uv sync
|
|
229
|
+
uv sync
|
|
226
230
|
uv run pytest # tests
|
|
227
231
|
uv run ruff check . # lint
|
|
228
232
|
uv run ruff format . # format
|
|
@@ -117,20 +117,30 @@ def _has_eos_marker(lines: list[str]) -> bool:
|
|
|
117
117
|
return False
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def
|
|
121
|
-
"""Return the vendor name for *text*.
|
|
122
|
-
|
|
123
|
-
Raises:
|
|
124
|
-
ParseError: if no supported vendor can be confidently identified.
|
|
125
|
-
"""
|
|
126
|
-
|
|
120
|
+
def _significant_lines(text: str) -> list[str]:
|
|
127
121
|
significant: list[str] = []
|
|
128
122
|
for raw in text.splitlines():
|
|
129
123
|
s = raw.strip()
|
|
130
124
|
if not s or s.startswith("!") or s.startswith("#") or s.startswith("/*"):
|
|
131
125
|
continue
|
|
132
126
|
significant.append(s)
|
|
127
|
+
return significant
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_empty_config(text: str) -> bool:
|
|
131
|
+
"""True if *text* has no significant (non-comment) lines."""
|
|
132
|
+
|
|
133
|
+
return not _significant_lines(text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def detect_vendor(text: str) -> str:
|
|
137
|
+
"""Return the vendor name for *text*.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ParseError: if no supported vendor can be confidently identified.
|
|
141
|
+
"""
|
|
133
142
|
|
|
143
|
+
significant = _significant_lines(text)
|
|
134
144
|
if not significant:
|
|
135
145
|
raise ParseError("configuration is empty or comment-only")
|
|
136
146
|
|
|
@@ -22,11 +22,14 @@ from dataclasses import dataclass
|
|
|
22
22
|
from difflib import SequenceMatcher
|
|
23
23
|
|
|
24
24
|
from diffnc import vendors as _vendors
|
|
25
|
-
from diffnc.detect import detect_vendor
|
|
25
|
+
from diffnc.detect import detect_vendor, is_empty_config
|
|
26
26
|
from diffnc.errors import VendorMismatchError
|
|
27
27
|
from diffnc.ir import ConfigNode, ConfigTree
|
|
28
28
|
from diffnc.vendors.base import VendorParser, is_order_sensitive_for, render_subtree
|
|
29
29
|
|
|
30
|
+
_SIMILARITY_CUTOFF = 0.6 # difflib.get_close_matches の既定値に合わせる
|
|
31
|
+
_TOKEN_SHARE_CUTOFF = 0.4 # 先頭コマンド語が一致する場合に適用する緩い二次閾値
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
@dataclass(frozen=True)
|
|
32
35
|
class _Event:
|
|
@@ -118,11 +121,21 @@ def _prepare(
|
|
|
118
121
|
text_b = _coerce(b)
|
|
119
122
|
|
|
120
123
|
if vendor is None:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
empty_a = is_empty_config(text_a)
|
|
125
|
+
empty_b = is_empty_config(text_b)
|
|
126
|
+
if empty_a and empty_b:
|
|
127
|
+
# Nothing to detect from and nothing to diff: both trees parse empty, so no
|
|
128
|
+
# vendor-specific behaviour is ever reached and any registered parser will do.
|
|
129
|
+
vendor_name = "nxos"
|
|
130
|
+
elif empty_a or empty_b:
|
|
131
|
+
# An empty side carries no vendor signal; detect from the other side only.
|
|
132
|
+
vendor_name = detect_vendor(text_b if empty_a else text_a)
|
|
133
|
+
else:
|
|
134
|
+
vendor_a = detect_vendor(text_a)
|
|
135
|
+
vendor_b = detect_vendor(text_b)
|
|
136
|
+
if vendor_a != vendor_b:
|
|
137
|
+
raise VendorMismatchError(vendor_a, vendor_b)
|
|
138
|
+
vendor_name = vendor_a
|
|
126
139
|
else:
|
|
127
140
|
vendor_name = vendor
|
|
128
141
|
|
|
@@ -266,6 +279,10 @@ def _diff_children_unordered(
|
|
|
266
279
|
splices in ``b``-only children at their natural position — i.e. before the next
|
|
267
280
|
matched anchor — so ``-`` / ``+`` lines surface near each other instead of clumping
|
|
268
281
|
at the start and end.
|
|
282
|
+
|
|
283
|
+
Additionally, A-only and B-only *leaves* that look like the same setting with a
|
|
284
|
+
changed value (high :class:`difflib.SequenceMatcher` ratio) are paired so the ``+``
|
|
285
|
+
is emitted immediately after its ``-``.
|
|
269
286
|
"""
|
|
270
287
|
|
|
271
288
|
a_children = node_a.children
|
|
@@ -276,26 +293,88 @@ def _diff_children_unordered(
|
|
|
276
293
|
child.line: idx for idx, child in enumerate(b_children) if child.line in common
|
|
277
294
|
}
|
|
278
295
|
|
|
296
|
+
a_only_leaves = [
|
|
297
|
+
(idx, child)
|
|
298
|
+
for idx, child in enumerate(a_children)
|
|
299
|
+
if child.line not in common and child.is_leaf
|
|
300
|
+
]
|
|
301
|
+
b_only_leaves = [
|
|
302
|
+
(idx, child)
|
|
303
|
+
for idx, child in enumerate(b_children)
|
|
304
|
+
if child.line not in common and child.is_leaf
|
|
305
|
+
]
|
|
306
|
+
a_index_to_b_node, paired_b_positions = _pair_changed_leaves(a_only_leaves, b_only_leaves)
|
|
307
|
+
|
|
279
308
|
b_pointer = 0
|
|
280
|
-
for child_a in a_children:
|
|
309
|
+
for idx, child_a in enumerate(a_children):
|
|
281
310
|
if child_a.line not in common:
|
|
282
311
|
yield from _emit_one_side(parser, child_a, depth, "-")
|
|
312
|
+
partner = a_index_to_b_node.get(idx)
|
|
313
|
+
if partner is not None:
|
|
314
|
+
yield from _emit_one_side(parser, partner, depth, "+")
|
|
283
315
|
continue
|
|
284
316
|
b_pos = b_match_index[child_a.line]
|
|
285
317
|
if b_pos >= b_pointer:
|
|
286
318
|
for k in range(b_pointer, b_pos):
|
|
287
319
|
child_b = b_children[k]
|
|
288
|
-
if child_b.line not in common:
|
|
320
|
+
if child_b.line not in common and k not in paired_b_positions:
|
|
289
321
|
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
290
322
|
b_pointer = b_pos + 1
|
|
291
323
|
yield from _equal_pair(parser, child_a, b_by_line[child_a.line], depth, hide_equal, path)
|
|
292
324
|
|
|
293
325
|
for k in range(b_pointer, len(b_children)):
|
|
294
326
|
child_b = b_children[k]
|
|
295
|
-
if child_b.line not in common:
|
|
327
|
+
if child_b.line not in common and k not in paired_b_positions:
|
|
296
328
|
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
297
329
|
|
|
298
330
|
|
|
331
|
+
def _leading_token(line: str) -> str:
|
|
332
|
+
"""Return the first whitespace-delimited token of *line* (its command word)."""
|
|
333
|
+
|
|
334
|
+
parts = line.split(maxsplit=1)
|
|
335
|
+
return parts[0] if parts else ""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _pair_changed_leaves(
|
|
339
|
+
a_only: list[tuple[int, ConfigNode]],
|
|
340
|
+
b_only: list[tuple[int, ConfigNode]],
|
|
341
|
+
) -> tuple[dict[int, ConfigNode], set[int]]:
|
|
342
|
+
"""Greedily pair A-only / B-only leaves that differ only in value.
|
|
343
|
+
|
|
344
|
+
A pair is a candidate when either the raw-line ``difflib`` ratio clears
|
|
345
|
+
:data:`_SIMILARITY_CUTOFF`, or the two leaves share the same leading command token
|
|
346
|
+
and the ratio clears the looser :data:`_TOKEN_SHARE_CUTOFF`. The latter rescues short
|
|
347
|
+
settings with a large value change (``vlan 1`` → ``vlan 1,100,200,300``) whose char
|
|
348
|
+
ratio dips below the primary cutoff. Pairs are chosen highest-ratio first and each side
|
|
349
|
+
is used at most once, so genuine high-ratio matches win before the looser fallbacks.
|
|
350
|
+
|
|
351
|
+
Returns ``(a_child_index -> paired b node, set of paired b child indices)`` so the
|
|
352
|
+
caller can emit the ``+`` next to its ``-`` and suppress it elsewhere.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
candidates: list[tuple[float, int, int]] = []
|
|
356
|
+
for ai, (_, a_node) in enumerate(a_only):
|
|
357
|
+
for bi, (_, b_node) in enumerate(b_only):
|
|
358
|
+
ratio = SequenceMatcher(None, a_node.line, b_node.line).ratio()
|
|
359
|
+
shares_token = _leading_token(a_node.line) == _leading_token(b_node.line)
|
|
360
|
+
if ratio >= _SIMILARITY_CUTOFF or (shares_token and ratio >= _TOKEN_SHARE_CUTOFF):
|
|
361
|
+
candidates.append((ratio, ai, bi))
|
|
362
|
+
candidates.sort(key=lambda t: t[0], reverse=True)
|
|
363
|
+
|
|
364
|
+
used_a: set[int] = set()
|
|
365
|
+
used_b: set[int] = set()
|
|
366
|
+
a_index_to_b_node: dict[int, ConfigNode] = {}
|
|
367
|
+
paired_b_positions: set[int] = set()
|
|
368
|
+
for _, ai, bi in candidates:
|
|
369
|
+
if ai in used_a or bi in used_b:
|
|
370
|
+
continue
|
|
371
|
+
used_a.add(ai)
|
|
372
|
+
used_b.add(bi)
|
|
373
|
+
a_index_to_b_node[a_only[ai][0]] = b_only[bi][1]
|
|
374
|
+
paired_b_positions.add(b_only[bi][0])
|
|
375
|
+
return a_index_to_b_node, paired_b_positions
|
|
376
|
+
|
|
377
|
+
|
|
299
378
|
def _equal_pair(
|
|
300
379
|
parser: VendorParser,
|
|
301
380
|
child_a: ConfigNode,
|
|
@@ -314,10 +393,15 @@ def _equal_pair(
|
|
|
314
393
|
return
|
|
315
394
|
|
|
316
395
|
if a_is_section != b_is_section:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
396
|
+
leaf_node = child_b if a_is_section else child_a
|
|
397
|
+
section_node = child_a if a_is_section else child_b
|
|
398
|
+
if not _leaf_section_render_equivalent(parser, leaf_node, section_node, depth):
|
|
399
|
+
# A leaf that genuinely became a section (e.g. Junos ``foo;`` → ``foo { ... }``).
|
|
400
|
+
yield from _emit_one_side(parser, child_a, depth, "-")
|
|
401
|
+
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
402
|
+
return
|
|
403
|
+
# Indent-based vendors: the "leaf" is just an empty section with the same header,
|
|
404
|
+
# so fall through and diff their children (the empty side yields all ``-``/``+``).
|
|
321
405
|
|
|
322
406
|
# Both are sections with matching headers. Diff their children; only surface the section
|
|
323
407
|
# header if any descendant differs (compact mode) or always (ndiff).
|
|
@@ -343,6 +427,25 @@ def _equal_pair(
|
|
|
343
427
|
yield _Event(" ", close)
|
|
344
428
|
|
|
345
429
|
|
|
430
|
+
def _leaf_section_render_equivalent(
|
|
431
|
+
parser: VendorParser,
|
|
432
|
+
leaf_node: ConfigNode,
|
|
433
|
+
section_node: ConfigNode,
|
|
434
|
+
depth: int,
|
|
435
|
+
) -> bool:
|
|
436
|
+
"""Whether promoting *leaf_node* to an empty section is render-transparent.
|
|
437
|
+
|
|
438
|
+
True for indent-based vendors (Cisco/NX-OS), where ``render_leaf == render_open`` and
|
|
439
|
+
there is no closing line, so an empty ``interface eth1`` and a populated one share the
|
|
440
|
+
same header. False for brace/terminator vendors (Junos hierarchical), where a leaf
|
|
441
|
+
(``foo;``) is structurally distinct from a section (``foo { ... }``).
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
return parser.render_close(section_node, depth) is None and parser.render_leaf(
|
|
445
|
+
leaf_node, depth
|
|
446
|
+
) == parser.render_open(section_node, depth)
|
|
447
|
+
|
|
448
|
+
|
|
346
449
|
def _emit_one_side(
|
|
347
450
|
parser: VendorParser,
|
|
348
451
|
node: ConfigNode,
|
|
@@ -225,3 +225,92 @@ def test_nxos_default_changes_effective_diff() -> None:
|
|
|
225
225
|
)
|
|
226
226
|
b = "interface Ethernet1/1\n description new-uplink\n"
|
|
227
227
|
assert list(unified_diff(a, b)) == []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_nxos_emptied_section_header_shown_as_context() -> None:
|
|
231
|
+
"""An interface that loses all its children stays a context header, not a -/+ replace."""
|
|
232
|
+
|
|
233
|
+
a = "interface Ethernet1/1\n no shutdown\n"
|
|
234
|
+
b = "interface Ethernet1/1\n"
|
|
235
|
+
out = "".join(unified_diff(a, b, lineterm="\n"))
|
|
236
|
+
assert out == " interface Ethernet1/1\n- no shutdown\n"
|
|
237
|
+
assert "+interface Ethernet1/1" not in out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_nxos_changed_leaves_paired_adjacently() -> None:
|
|
241
|
+
"""Value changes of the same setting surface as adjacent -/+ pairs, not clumped."""
|
|
242
|
+
|
|
243
|
+
a = "interface Ethernet1/2\n ip address 192.168.1.1/24\n ip ospf cost 50\n"
|
|
244
|
+
b = "interface Ethernet1/2\n ip address 192.168.1.2/24\n ip ospf cost 100\n"
|
|
245
|
+
out = "".join(unified_diff(a, b, lineterm="\n"))
|
|
246
|
+
assert out == (
|
|
247
|
+
" interface Ethernet1/2\n"
|
|
248
|
+
"- ip address 192.168.1.1/24\n"
|
|
249
|
+
"+ ip address 192.168.1.2/24\n"
|
|
250
|
+
"- ip ospf cost 50\n"
|
|
251
|
+
"+ ip ospf cost 100\n"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_short_leaf_value_change_paired_by_leading_token() -> None:
|
|
256
|
+
"""A short setting with a big value change pairs via its shared command word.
|
|
257
|
+
|
|
258
|
+
``vlan 1`` vs ``vlan 1,100,200,300`` scores below ``_SIMILARITY_CUTOFF`` on raw chars,
|
|
259
|
+
but the shared ``vlan`` token rescues the pairing so the ``+`` sits next to its ``-``.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
a = "vlan 1\nlicense smart transport smart\nlicense smart source-interface mgmt0\n"
|
|
263
|
+
b = "vlan 1,100,200,300\nlicense smart source-interface mgmt0\n"
|
|
264
|
+
out = "".join(unified_diff(a, b, lineterm="\n"))
|
|
265
|
+
assert out == ("-vlan 1\n+vlan 1,100,200,300\n-license smart transport smart\n")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_junos_leaf_to_section_still_renders_as_replace() -> None:
|
|
269
|
+
"""A Junos leaf becoming a populated section must not collapse into a context header."""
|
|
270
|
+
|
|
271
|
+
a = "interfaces {\n ge-0/0/0;\n}\n"
|
|
272
|
+
b = "interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}\n"
|
|
273
|
+
out = "".join(unified_diff(a, b, lineterm="\n"))
|
|
274
|
+
assert "- ge-0/0/0;\n" in out
|
|
275
|
+
assert "+ ge-0/0/0 {\n" in out
|
|
276
|
+
assert "+ unit 0;\n" in out
|
|
277
|
+
assert "+ }\n" in out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Empty-side inputs
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_ndiff_against_empty_b_marks_all_as_deleted() -> None:
|
|
286
|
+
a = "set interface ge-0/0/0 unit 0 family inet dhcp"
|
|
287
|
+
assert list(ndiff(a, "")) == ["- set interface ge-0/0/0 unit 0 family inet dhcp"]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_unified_against_empty_a_marks_all_as_added() -> None:
|
|
291
|
+
b = "set interface ge-0/0/0 unit 0 family inet dhcp\nset system host-name r1\n"
|
|
292
|
+
assert list(unified_diff("", b)) == [
|
|
293
|
+
"+set interface ge-0/0/0 unit 0 family inet dhcp",
|
|
294
|
+
"+set system host-name r1",
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_comment_only_side_treated_as_empty() -> None:
|
|
299
|
+
a = "interface Ethernet1/1\n no shutdown\n"
|
|
300
|
+
b = "! header comment only\n"
|
|
301
|
+
out = list(unified_diff(a, b))
|
|
302
|
+
assert out == ["-interface Ethernet1/1", "- no shutdown"]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_empty_side_emits_whole_subtree() -> None:
|
|
306
|
+
a = "interface Ethernet1/1\n description uplink\n no shutdown\n"
|
|
307
|
+
assert list(ndiff(a, "")) == [
|
|
308
|
+
"- interface Ethernet1/1",
|
|
309
|
+
"- description uplink",
|
|
310
|
+
"- no shutdown",
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_both_sides_empty_produce_no_output() -> None:
|
|
315
|
+
assert list(unified_diff("", "")) == []
|
|
316
|
+
assert list(ndiff("", "! comment\n")) == []
|
|
@@ -279,3 +279,25 @@ def test_iterable_input_is_accepted() -> None:
|
|
|
279
279
|
b_lines = ["interface eth1", " description new"]
|
|
280
280
|
out = list(reconcile(a_lines, b_lines, vendor="nxos"))
|
|
281
281
|
assert out == ["interface eth1", "no description old", "description new"]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Empty-side inputs
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_reconcile_to_empty_deletes_everything() -> None:
|
|
290
|
+
a = "set interface ge-0/0/0 unit 0 family inet dhcp\n"
|
|
291
|
+
assert list(reconcile(a, "")) == ["delete interface ge-0/0/0 unit 0 family inet dhcp"]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_reconcile_from_empty_adds_everything() -> None:
|
|
295
|
+
b = "set interface ge-0/0/0 unit 0 family inet dhcp\nset system host-name r1\n"
|
|
296
|
+
assert list(reconcile("", b)) == [
|
|
297
|
+
"set interface ge-0/0/0 unit 0 family inet dhcp",
|
|
298
|
+
"set system host-name r1",
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_reconcile_both_empty_produces_no_output() -> None:
|
|
303
|
+
assert list(reconcile("", "! comment only\n")) == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|