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.
Files changed (55) hide show
  1. {diffnc-0.0.1 → diffnc-0.0.2}/PKG-INFO +6 -2
  2. {diffnc-0.0.1 → diffnc-0.0.2}/README.md +5 -1
  3. {diffnc-0.0.1 → diffnc-0.0.2}/pyproject.toml +1 -1
  4. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/diff.py +86 -7
  5. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_diff.py +37 -0
  6. {diffnc-0.0.1 → diffnc-0.0.2}/uv.lock +1 -1
  7. {diffnc-0.0.1 → diffnc-0.0.2}/.github/dependabot.yml +0 -0
  8. {diffnc-0.0.1 → diffnc-0.0.2}/.github/workflows/publish.yml +0 -0
  9. {diffnc-0.0.1 → diffnc-0.0.2}/.github/workflows/test.yml +0 -0
  10. {diffnc-0.0.1 → diffnc-0.0.2}/.gitignore +0 -0
  11. {diffnc-0.0.1 → diffnc-0.0.2}/LICENSE +0 -0
  12. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/__init__.py +0 -0
  13. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/__main__.py +0 -0
  14. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/cli.py +0 -0
  15. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/detect.py +0 -0
  16. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/errors.py +0 -0
  17. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/ir.py +0 -0
  18. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/py.typed +0 -0
  19. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/reconcile.py +0 -0
  20. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/__init__.py +0 -0
  21. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/_cisco_like.py +0 -0
  22. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/base.py +0 -0
  23. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/eos.py +0 -0
  24. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/ios.py +0 -0
  25. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/iosxe.py +0 -0
  26. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/iosxr.py +0 -0
  27. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/junos.py +0 -0
  28. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/junos_set.py +0 -0
  29. {diffnc-0.0.1 → diffnc-0.0.2}/src/diffnc/vendors/nxos.py +0 -0
  30. {diffnc-0.0.1 → diffnc-0.0.2}/tests/__init__.py +0 -0
  31. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/eos_a.conf +0 -0
  32. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/eos_b.conf +0 -0
  33. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/ios_a.conf +0 -0
  34. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/ios_b.conf +0 -0
  35. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxe_a.conf +0 -0
  36. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxe_b.conf +0 -0
  37. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxr_a.conf +0 -0
  38. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/iosxr_b.conf +0 -0
  39. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_a.conf +0 -0
  40. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_b.conf +0 -0
  41. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_set_a.conf +0 -0
  42. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/junos_set_b.conf +0 -0
  43. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/nxos_a.conf +0 -0
  44. {diffnc-0.0.1 → diffnc-0.0.2}/tests/fixtures/nxos_b.conf +0 -0
  45. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_cli.py +0 -0
  46. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_detect.py +0 -0
  47. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_eos.py +0 -0
  48. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_ios.py +0 -0
  49. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_iosxe.py +0 -0
  50. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_iosxr.py +0 -0
  51. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_junos.py +0 -0
  52. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_junos_set.py +0 -0
  53. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_nxos.py +0 -0
  54. {diffnc-0.0.1 → diffnc-0.0.2}/tests/test_order_sensitivity.py +0 -0
  55. {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.1
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
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/diffnc)
39
+ ![PyPI - Version](https://img.shields.io/pypi/v/diffnc)
40
+ ![GitHub License](https://img.shields.io/github/license/minefuto/diffnc)
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 --extra dev
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
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/diffnc)
4
+ ![PyPI - Version](https://img.shields.io/pypi/v/diffnc)
5
+ ![GitHub License](https://img.shields.io/github/license/minefuto/diffnc)
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 --extra dev
229
+ uv sync
226
230
  uv run pytest # tests
227
231
  uv run ruff check . # lint
228
232
  uv run ruff format . # format
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffnc"
3
- version = "0.0.1"
3
+ version = "0.0.2"
4
4
  description = "Structural diff library for network device configurations"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
- # 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
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
@@ -117,7 +117,7 @@ toml = [
117
117
 
118
118
  [[package]]
119
119
  name = "diffnc"
120
- version = "0.0.1"
120
+ version = "0.0.2"
121
121
  source = { editable = "." }
122
122
 
123
123
  [package.dev-dependencies]
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