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.
- {diffnc-0.0.2 → diffnc-0.0.3}/.github/workflows/publish.yml +2 -2
- {diffnc-0.0.2 → diffnc-0.0.3}/.github/workflows/test.yml +2 -2
- {diffnc-0.0.2 → diffnc-0.0.3}/PKG-INFO +1 -1
- {diffnc-0.0.2 → diffnc-0.0.3}/pyproject.toml +1 -1
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/detect.py +17 -7
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/diff.py +35 -11
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_diff.py +52 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_reconcile.py +22 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/uv.lock +1 -1
- {diffnc-0.0.2 → diffnc-0.0.3}/.github/dependabot.yml +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/.gitignore +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/LICENSE +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/README.md +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/__init__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/__main__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/cli.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/errors.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/ir.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/py.typed +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/reconcile.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/__init__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/_cisco_like.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/base.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/eos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/ios.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/iosxe.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/iosxr.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/junos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/junos_set.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/src/diffnc/vendors/nxos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/__init__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/eos_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/eos_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/ios_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/ios_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxe_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxe_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxr_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/iosxr_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_set_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/junos_set_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/nxos_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/fixtures/nxos_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_cli.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_detect.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_eos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_ios.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_iosxe.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_iosxr.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_junos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_junos_set.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.3}/tests/test_nxos.py +0 -0
- {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@
|
|
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
|
|
@@ -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,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
|
-
|
|
124
|
-
|
|
125
|
-
if
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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")) == []
|
|
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
|