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.
Files changed (55) hide show
  1. {diffnc-0.0.1 → diffnc-0.0.3}/.github/workflows/publish.yml +2 -2
  2. {diffnc-0.0.1 → diffnc-0.0.3}/.github/workflows/test.yml +2 -2
  3. {diffnc-0.0.1 → diffnc-0.0.3}/PKG-INFO +6 -2
  4. {diffnc-0.0.1 → diffnc-0.0.3}/README.md +5 -1
  5. {diffnc-0.0.1 → diffnc-0.0.3}/pyproject.toml +1 -1
  6. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/detect.py +17 -7
  7. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/diff.py +116 -13
  8. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_diff.py +89 -0
  9. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_reconcile.py +22 -0
  10. {diffnc-0.0.1 → diffnc-0.0.3}/uv.lock +1 -1
  11. {diffnc-0.0.1 → diffnc-0.0.3}/.github/dependabot.yml +0 -0
  12. {diffnc-0.0.1 → diffnc-0.0.3}/.gitignore +0 -0
  13. {diffnc-0.0.1 → diffnc-0.0.3}/LICENSE +0 -0
  14. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/__init__.py +0 -0
  15. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/__main__.py +0 -0
  16. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/cli.py +0 -0
  17. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/errors.py +0 -0
  18. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/ir.py +0 -0
  19. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/py.typed +0 -0
  20. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/reconcile.py +0 -0
  21. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/__init__.py +0 -0
  22. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/_cisco_like.py +0 -0
  23. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/base.py +0 -0
  24. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/eos.py +0 -0
  25. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/ios.py +0 -0
  26. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/iosxe.py +0 -0
  27. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/iosxr.py +0 -0
  28. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/junos.py +0 -0
  29. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/junos_set.py +0 -0
  30. {diffnc-0.0.1 → diffnc-0.0.3}/src/diffnc/vendors/nxos.py +0 -0
  31. {diffnc-0.0.1 → diffnc-0.0.3}/tests/__init__.py +0 -0
  32. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/eos_a.conf +0 -0
  33. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/eos_b.conf +0 -0
  34. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/ios_a.conf +0 -0
  35. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/ios_b.conf +0 -0
  36. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxe_a.conf +0 -0
  37. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxe_b.conf +0 -0
  38. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxr_a.conf +0 -0
  39. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/iosxr_b.conf +0 -0
  40. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_a.conf +0 -0
  41. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_b.conf +0 -0
  42. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_set_a.conf +0 -0
  43. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/junos_set_b.conf +0 -0
  44. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/nxos_a.conf +0 -0
  45. {diffnc-0.0.1 → diffnc-0.0.3}/tests/fixtures/nxos_b.conf +0 -0
  46. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_cli.py +0 -0
  47. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_detect.py +0 -0
  48. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_eos.py +0 -0
  49. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_ios.py +0 -0
  50. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_iosxe.py +0 -0
  51. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_iosxr.py +0 -0
  52. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_junos.py +0 -0
  53. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_junos_set.py +0 -0
  54. {diffnc-0.0.1 → diffnc-0.0.3}/tests/test_nxos.py +0 -0
  55. {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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
18
18
 
19
19
  - name: Install uv
20
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
23
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
24
24
 
25
25
  - name: Install uv
26
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
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.1
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
+ ![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.3"
4
4
  description = "Structural diff library for network device configurations"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -117,20 +117,30 @@ def _has_eos_marker(lines: list[str]) -> bool:
117
117
  return False
118
118
 
119
119
 
120
- def detect_vendor(text: str) -> str:
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
- 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
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
- # 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
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")) == []
@@ -117,7 +117,7 @@ toml = [
117
117
 
118
118
  [[package]]
119
119
  name = "diffnc"
120
- version = "0.0.1"
120
+ version = "0.0.3"
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