diffnc 0.0.2__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.2 → diffnc-0.0.3}/.github/workflows/publish.yml +2 -2
  2. {diffnc-0.0.2 → diffnc-0.0.3}/.github/workflows/test.yml +2 -2
  3. {diffnc-0.0.2 → diffnc-0.0.3}/PKG-INFO +1 -1
  4. {diffnc-0.0.2 → diffnc-0.0.3}/pyproject.toml +1 -1
  5. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/detect.py +17 -7
  6. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/diff.py +35 -11
  7. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_diff.py +52 -0
  8. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_reconcile.py +22 -0
  9. {diffnc-0.0.2 → diffnc-0.0.3}/uv.lock +1 -1
  10. {diffnc-0.0.2 → diffnc-0.0.3}/.github/dependabot.yml +0 -0
  11. {diffnc-0.0.2 → diffnc-0.0.3}/.gitignore +0 -0
  12. {diffnc-0.0.2 → diffnc-0.0.3}/LICENSE +0 -0
  13. {diffnc-0.0.2 → diffnc-0.0.3}/README.md +0 -0
  14. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/__init__.py +0 -0
  15. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/__main__.py +0 -0
  16. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/cli.py +0 -0
  17. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/errors.py +0 -0
  18. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/ir.py +0 -0
  19. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/py.typed +0 -0
  20. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/reconcile.py +0 -0
  21. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/__init__.py +0 -0
  22. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/_cisco_like.py +0 -0
  23. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/base.py +0 -0
  24. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/eos.py +0 -0
  25. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/ios.py +0 -0
  26. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/iosxe.py +0 -0
  27. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/iosxr.py +0 -0
  28. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/junos.py +0 -0
  29. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/junos_set.py +0 -0
  30. {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/nxos.py +0 -0
  31. {diffnc-0.0.2 → diffnc-0.0.3}/tests/__init__.py +0 -0
  32. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/eos_a.conf +0 -0
  33. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/eos_b.conf +0 -0
  34. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/ios_a.conf +0 -0
  35. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/ios_b.conf +0 -0
  36. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxe_a.conf +0 -0
  37. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxe_b.conf +0 -0
  38. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxr_a.conf +0 -0
  39. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxr_b.conf +0 -0
  40. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_a.conf +0 -0
  41. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_b.conf +0 -0
  42. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_set_a.conf +0 -0
  43. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_set_b.conf +0 -0
  44. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/nxos_a.conf +0 -0
  45. {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/nxos_b.conf +0 -0
  46. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_cli.py +0 -0
  47. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_detect.py +0 -0
  48. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_eos.py +0 -0
  49. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_ios.py +0 -0
  50. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_iosxe.py +0 -0
  51. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_iosxr.py +0 -0
  52. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_junos.py +0 -0
  53. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_junos_set.py +0 -0
  54. {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_nxos.py +0 -0
  55. {diffnc-0.0.2 → 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.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffnc"
3
- version = "0.0.2"
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,12 +22,13 @@ 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
30
  _SIMILARITY_CUTOFF = 0.6 # difflib.get_close_matches の既定値に合わせる
31
+ _TOKEN_SHARE_CUTOFF = 0.4 # 先頭コマンド語が一致する場合に適用する緩い二次閾値
31
32
 
32
33
 
33
34
  @dataclass(frozen=True)
@@ -120,11 +121,21 @@ def _prepare(
120
121
  text_b = _coerce(b)
121
122
 
122
123
  if vendor is None:
123
- vendor_a = detect_vendor(text_a)
124
- vendor_b = detect_vendor(text_b)
125
- if vendor_a != vendor_b:
126
- raise VendorMismatchError(vendor_a, vendor_b)
127
- 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
128
139
  else:
129
140
  vendor_name = vendor
130
141
 
@@ -317,23 +328,36 @@ def _diff_children_unordered(
317
328
  yield from _emit_one_side(parser, child_b, depth, "+")
318
329
 
319
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
+
320
338
  def _pair_changed_leaves(
321
339
  a_only: list[tuple[int, ConfigNode]],
322
340
  b_only: list[tuple[int, ConfigNode]],
323
341
  ) -> tuple[dict[int, ConfigNode], set[int]]:
324
342
  """Greedily pair A-only / B-only leaves that differ only in value.
325
343
 
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.
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.
330
353
  """
331
354
 
332
355
  candidates: list[tuple[float, int, int]] = []
333
356
  for ai, (_, a_node) in enumerate(a_only):
334
357
  for bi, (_, b_node) in enumerate(b_only):
335
358
  ratio = SequenceMatcher(None, a_node.line, b_node.line).ratio()
336
- if ratio >= _SIMILARITY_CUTOFF:
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):
337
361
  candidates.append((ratio, ai, bi))
338
362
  candidates.sort(key=lambda t: t[0], reverse=True)
339
363
 
@@ -252,6 +252,19 @@ def test_nxos_changed_leaves_paired_adjacently() -> None:
252
252
  )
253
253
 
254
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
+
255
268
  def test_junos_leaf_to_section_still_renders_as_replace() -> None:
256
269
  """A Junos leaf becoming a populated section must not collapse into a context header."""
257
270
 
@@ -262,3 +275,42 @@ def test_junos_leaf_to_section_still_renders_as_replace() -> None:
262
275
  assert "+ ge-0/0/0 {\n" in out
263
276
  assert "+ unit 0;\n" in out
264
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.2"
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
File without changes