diffnc 0.0.1__tar.gz → 0.0.2__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.2}/PKG-INFO +6 -2
- {diffnc-0.0.1 → diffnc-0.0.2}/README.md +5 -1
- {diffnc-0.0.1 → diffnc-0.0.2}/pyproject.toml +1 -1
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/diff.py +86 -7
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_diff.py +37 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/uv.lock +1 -1
- {diffnc-0.0.1 → diffnc-0.0.2}/.github/dependabot.yml +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/.github/workflows/publish.yml +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/.github/workflows/test.yml +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/.gitignore +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/LICENSE +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/__init__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/__main__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/cli.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/detect.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/errors.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/ir.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/py.typed +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/reconcile.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/__init__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/_cisco_like.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/base.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/eos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/ios.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/iosxe.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/iosxr.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/junos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/junos_set.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/nxos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/__init__.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/eos_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/eos_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/ios_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/ios_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxe_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxe_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxr_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxr_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_set_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_set_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/nxos_a.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/nxos_b.conf +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_cli.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_detect.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_eos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_ios.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_iosxe.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_iosxr.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_junos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_junos_set.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_nxos.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_order_sensitivity.py +0 -0
- {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_reconcile.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffnc
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
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
|
|
@@ -27,6 +27,8 @@ 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
|
+
|
|
30
32
|
|
|
31
33
|
@dataclass(frozen=True)
|
|
32
34
|
class _Event:
|
|
@@ -266,6 +268,10 @@ def _diff_children_unordered(
|
|
|
266
268
|
splices in ``b``-only children at their natural position — i.e. before the next
|
|
267
269
|
matched anchor — so ``-`` / ``+`` lines surface near each other instead of clumping
|
|
268
270
|
at the start and end.
|
|
271
|
+
|
|
272
|
+
Additionally, A-only and B-only *leaves* that look like the same setting with a
|
|
273
|
+
changed value (high :class:`difflib.SequenceMatcher` ratio) are paired so the ``+``
|
|
274
|
+
is emitted immediately after its ``-``.
|
|
269
275
|
"""
|
|
270
276
|
|
|
271
277
|
a_children = node_a.children
|
|
@@ -276,26 +282,75 @@ def _diff_children_unordered(
|
|
|
276
282
|
child.line: idx for idx, child in enumerate(b_children) if child.line in common
|
|
277
283
|
}
|
|
278
284
|
|
|
285
|
+
a_only_leaves = [
|
|
286
|
+
(idx, child)
|
|
287
|
+
for idx, child in enumerate(a_children)
|
|
288
|
+
if child.line not in common and child.is_leaf
|
|
289
|
+
]
|
|
290
|
+
b_only_leaves = [
|
|
291
|
+
(idx, child)
|
|
292
|
+
for idx, child in enumerate(b_children)
|
|
293
|
+
if child.line not in common and child.is_leaf
|
|
294
|
+
]
|
|
295
|
+
a_index_to_b_node, paired_b_positions = _pair_changed_leaves(a_only_leaves, b_only_leaves)
|
|
296
|
+
|
|
279
297
|
b_pointer = 0
|
|
280
|
-
for child_a in a_children:
|
|
298
|
+
for idx, child_a in enumerate(a_children):
|
|
281
299
|
if child_a.line not in common:
|
|
282
300
|
yield from _emit_one_side(parser, child_a, depth, "-")
|
|
301
|
+
partner = a_index_to_b_node.get(idx)
|
|
302
|
+
if partner is not None:
|
|
303
|
+
yield from _emit_one_side(parser, partner, depth, "+")
|
|
283
304
|
continue
|
|
284
305
|
b_pos = b_match_index[child_a.line]
|
|
285
306
|
if b_pos >= b_pointer:
|
|
286
307
|
for k in range(b_pointer, b_pos):
|
|
287
308
|
child_b = b_children[k]
|
|
288
|
-
if child_b.line not in common:
|
|
309
|
+
if child_b.line not in common and k not in paired_b_positions:
|
|
289
310
|
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
290
311
|
b_pointer = b_pos + 1
|
|
291
312
|
yield from _equal_pair(parser, child_a, b_by_line[child_a.line], depth, hide_equal, path)
|
|
292
313
|
|
|
293
314
|
for k in range(b_pointer, len(b_children)):
|
|
294
315
|
child_b = b_children[k]
|
|
295
|
-
if child_b.line not in common:
|
|
316
|
+
if child_b.line not in common and k not in paired_b_positions:
|
|
296
317
|
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
297
318
|
|
|
298
319
|
|
|
320
|
+
def _pair_changed_leaves(
|
|
321
|
+
a_only: list[tuple[int, ConfigNode]],
|
|
322
|
+
b_only: list[tuple[int, ConfigNode]],
|
|
323
|
+
) -> tuple[dict[int, ConfigNode], set[int]]:
|
|
324
|
+
"""Greedily pair A-only / B-only leaves that differ only in value.
|
|
325
|
+
|
|
326
|
+
Pairs are chosen highest-similarity first (``difflib`` ratio over the raw lines),
|
|
327
|
+
each side used at most once, and only above :data:`_SIMILARITY_CUTOFF`. Returns
|
|
328
|
+
``(a_child_index -> paired b node, set of paired b child indices)`` so the caller can
|
|
329
|
+
emit the ``+`` next to its ``-`` and suppress it elsewhere.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
candidates: list[tuple[float, int, int]] = []
|
|
333
|
+
for ai, (_, a_node) in enumerate(a_only):
|
|
334
|
+
for bi, (_, b_node) in enumerate(b_only):
|
|
335
|
+
ratio = SequenceMatcher(None, a_node.line, b_node.line).ratio()
|
|
336
|
+
if ratio >= _SIMILARITY_CUTOFF:
|
|
337
|
+
candidates.append((ratio, ai, bi))
|
|
338
|
+
candidates.sort(key=lambda t: t[0], reverse=True)
|
|
339
|
+
|
|
340
|
+
used_a: set[int] = set()
|
|
341
|
+
used_b: set[int] = set()
|
|
342
|
+
a_index_to_b_node: dict[int, ConfigNode] = {}
|
|
343
|
+
paired_b_positions: set[int] = set()
|
|
344
|
+
for _, ai, bi in candidates:
|
|
345
|
+
if ai in used_a or bi in used_b:
|
|
346
|
+
continue
|
|
347
|
+
used_a.add(ai)
|
|
348
|
+
used_b.add(bi)
|
|
349
|
+
a_index_to_b_node[a_only[ai][0]] = b_only[bi][1]
|
|
350
|
+
paired_b_positions.add(b_only[bi][0])
|
|
351
|
+
return a_index_to_b_node, paired_b_positions
|
|
352
|
+
|
|
353
|
+
|
|
299
354
|
def _equal_pair(
|
|
300
355
|
parser: VendorParser,
|
|
301
356
|
child_a: ConfigNode,
|
|
@@ -314,10 +369,15 @@ def _equal_pair(
|
|
|
314
369
|
return
|
|
315
370
|
|
|
316
371
|
if a_is_section != b_is_section:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
372
|
+
leaf_node = child_b if a_is_section else child_a
|
|
373
|
+
section_node = child_a if a_is_section else child_b
|
|
374
|
+
if not _leaf_section_render_equivalent(parser, leaf_node, section_node, depth):
|
|
375
|
+
# A leaf that genuinely became a section (e.g. Junos ``foo;`` → ``foo { ... }``).
|
|
376
|
+
yield from _emit_one_side(parser, child_a, depth, "-")
|
|
377
|
+
yield from _emit_one_side(parser, child_b, depth, "+")
|
|
378
|
+
return
|
|
379
|
+
# Indent-based vendors: the "leaf" is just an empty section with the same header,
|
|
380
|
+
# so fall through and diff their children (the empty side yields all ``-``/``+``).
|
|
321
381
|
|
|
322
382
|
# Both are sections with matching headers. Diff their children; only surface the section
|
|
323
383
|
# header if any descendant differs (compact mode) or always (ndiff).
|
|
@@ -343,6 +403,25 @@ def _equal_pair(
|
|
|
343
403
|
yield _Event(" ", close)
|
|
344
404
|
|
|
345
405
|
|
|
406
|
+
def _leaf_section_render_equivalent(
|
|
407
|
+
parser: VendorParser,
|
|
408
|
+
leaf_node: ConfigNode,
|
|
409
|
+
section_node: ConfigNode,
|
|
410
|
+
depth: int,
|
|
411
|
+
) -> bool:
|
|
412
|
+
"""Whether promoting *leaf_node* to an empty section is render-transparent.
|
|
413
|
+
|
|
414
|
+
True for indent-based vendors (Cisco/NX-OS), where ``render_leaf == render_open`` and
|
|
415
|
+
there is no closing line, so an empty ``interface eth1`` and a populated one share the
|
|
416
|
+
same header. False for brace/terminator vendors (Junos hierarchical), where a leaf
|
|
417
|
+
(``foo;``) is structurally distinct from a section (``foo { ... }``).
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
return parser.render_close(section_node, depth) is None and parser.render_leaf(
|
|
421
|
+
leaf_node, depth
|
|
422
|
+
) == parser.render_open(section_node, depth)
|
|
423
|
+
|
|
424
|
+
|
|
346
425
|
def _emit_one_side(
|
|
347
426
|
parser: VendorParser,
|
|
348
427
|
node: ConfigNode,
|
|
@@ -225,3 +225,40 @@ 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_junos_leaf_to_section_still_renders_as_replace() -> None:
|
|
256
|
+
"""A Junos leaf becoming a populated section must not collapse into a context header."""
|
|
257
|
+
|
|
258
|
+
a = "interfaces {\n ge-0/0/0;\n}\n"
|
|
259
|
+
b = "interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}\n"
|
|
260
|
+
out = "".join(unified_diff(a, b, lineterm="\n"))
|
|
261
|
+
assert "- ge-0/0/0;\n" in out
|
|
262
|
+
assert "+ ge-0/0/0 {\n" in out
|
|
263
|
+
assert "+ unit 0;\n" in out
|
|
264
|
+
assert "+ }\n" in out
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|