diffnc 0.0.2__tar.gz → 0.0.4__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 (59) hide show
  1. {diffnc-0.0.2 → diffnc-0.0.4}/.github/workflows/publish.yml +2 -2
  2. {diffnc-0.0.2 → diffnc-0.0.4}/.github/workflows/test.yml +2 -2
  3. {diffnc-0.0.2 → diffnc-0.0.4}/PKG-INFO +36 -8
  4. {diffnc-0.0.2 → diffnc-0.0.4}/README.md +35 -7
  5. {diffnc-0.0.2 → diffnc-0.0.4}/pyproject.toml +1 -1
  6. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/detect.py +38 -27
  7. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/diff.py +162 -59
  8. diffnc-0.0.4/src/diffnc/ir.py +92 -0
  9. diffnc-0.0.4/src/diffnc/reconcile.py +279 -0
  10. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/_cisco_like.py +98 -35
  11. diffnc-0.0.4/src/diffnc/vendors/base.py +208 -0
  12. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/iosxr.py +2 -0
  13. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/junos.py +35 -1
  14. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/junos_set.py +50 -7
  15. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_cli.py +3 -1
  16. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_diff.py +105 -4
  17. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_nxos.py +25 -0
  18. diffnc-0.0.4/tests/test_reconcile.py +448 -0
  19. {diffnc-0.0.2 → diffnc-0.0.4}/uv.lock +1 -1
  20. diffnc-0.0.2/src/diffnc/ir.py +0 -58
  21. diffnc-0.0.2/src/diffnc/reconcile.py +0 -132
  22. diffnc-0.0.2/src/diffnc/vendors/base.py +0 -88
  23. diffnc-0.0.2/tests/test_reconcile.py +0 -281
  24. {diffnc-0.0.2 → diffnc-0.0.4}/.github/dependabot.yml +0 -0
  25. {diffnc-0.0.2 → diffnc-0.0.4}/.gitignore +0 -0
  26. {diffnc-0.0.2 → diffnc-0.0.4}/LICENSE +0 -0
  27. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/__init__.py +0 -0
  28. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/__main__.py +0 -0
  29. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/cli.py +0 -0
  30. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/errors.py +0 -0
  31. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/py.typed +0 -0
  32. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/__init__.py +0 -0
  33. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/eos.py +0 -0
  34. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/ios.py +0 -0
  35. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/iosxe.py +0 -0
  36. {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/nxos.py +0 -0
  37. {diffnc-0.0.2 → diffnc-0.0.4}/tests/__init__.py +0 -0
  38. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/eos_a.conf +0 -0
  39. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/eos_b.conf +0 -0
  40. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/ios_a.conf +0 -0
  41. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/ios_b.conf +0 -0
  42. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxe_a.conf +0 -0
  43. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxe_b.conf +0 -0
  44. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxr_a.conf +0 -0
  45. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxr_b.conf +0 -0
  46. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_a.conf +0 -0
  47. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_b.conf +0 -0
  48. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_set_a.conf +0 -0
  49. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_set_b.conf +0 -0
  50. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/nxos_a.conf +0 -0
  51. {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/nxos_b.conf +0 -0
  52. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_detect.py +0 -0
  53. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_eos.py +0 -0
  54. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_ios.py +0 -0
  55. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_iosxe.py +0 -0
  56. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_iosxr.py +0 -0
  57. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_junos.py +0 -0
  58. {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_junos_set.py +0 -0
  59. {diffnc-0.0.2 → diffnc-0.0.4}/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.4
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
@@ -103,11 +103,11 @@ for line in reconcile(a, b):
103
103
  print(line)
104
104
  ```
105
105
 
106
- Output is config-mode commands only — no `configure terminal` / `end` / `commit` wrappers, no indentation. Pipe through your own session manager.
106
+ Output is config-mode commands only — no `configure terminal` / `end` / `commit` wrappers. Lines are indented to mirror the section hierarchy (using each vendor's own indent unit), so the result reads like the source config; flat vendors such as Junos set form emit no indentation. Pipe through your own session manager.
107
107
 
108
- * **Cisco-like (NX-OS / IOS / IOS-XE / IOS-XR / EOS):** emits section navigation plus `<line>` for adds and `no <line>` for deletes (with `no no foo` collapsed to `foo`, so `no shutdown` `shutdown` toggles correctly).
109
- * **Junos hierarchical:** emits flat `set <path>` and `delete <path>` lines.
110
- * **Junos set:** emits `<line>` verbatim for adds and `delete <path>` (with the `set ` / `activate ` / `deactivate ` prefix stripped) for deletes.
108
+ * **Cisco-like (NX-OS / IOS / IOS-XE / IOS-XR / EOS):** emits section navigation plus `<line>` for adds and `no <line>` for deletes. `X` / `no X` toggles are tracked as state: a transition (e.g. `no shutdown` `shutdown`) emits just the new value, while a *removed* toggle resets to default — `default <cmd>` on vendors that support it (IOS / IOS-XE / NX-OS / EOS) and the inverted `no` on IOS-XR.
109
+ * **Junos hierarchical:** emits flat `set <path>` and `delete <path>` lines. A node whose only change is its inactive state (`inactive: <line>`) emits `activate <path>` / `deactivate <path>` instead of a `delete`/`set` pair.
110
+ * **Junos set:** emits `<line>` verbatim for adds and `delete <path>` (with the `set ` prefix stripped) for deletes. `activate` / `deactivate` are tracked as state: a removed toggle inverts to its counterpart (`deactivate X` → `activate X`) rather than deleting the underlying `set`.
111
111
  * **Order-sensitive sections** (ACL, `policy-map`, Junos `firewall filter` / `policy-statement` terms): on any change, the entire section is deleted and recreated from *B* — partial in-place edits are not attempted.
112
112
 
113
113
  Exceptions:
@@ -151,8 +151,7 @@ Or, in reconcile mode (**experimental**):
151
151
  ```bash
152
152
  $ diffnc before.conf after.conf -r
153
153
  interface Ethernet1/1
154
- no description uplink
155
- description uplink-to-spine
154
+ description uplink-to-spine
156
155
  feature ospf
157
156
  ```
158
157
 
@@ -254,6 +253,33 @@ Example: a reorder of one term plus a content change in another term
254
253
  }
255
254
  ```
256
255
 
256
+ ## Junos inactive/active toggles
257
+
258
+ When a Junos hierarchical node only flips its `inactive:` state (the node itself and its subtree are otherwise identical), the diff does not re-emit the whole subtree as a `-`/`+` pair. Instead it pairs the two states as one node and marks the new state with a `!`:
259
+
260
+ * A deactivated node keeps its literal `inactive:` prefix.
261
+ * A reactivated node has no marker on the B side, so a synthetic `activate:` is shown to make the direction visible.
262
+
263
+ ```diff
264
+ system {
265
+ ! inactive: host-name foo;
266
+ }
267
+ ```
268
+
269
+ If the toggled section also has real changes inside it, the section header is marked `!` and expanded so the inner `-`/`+` (or nested `!`) lines still surface:
270
+
271
+ ```diff
272
+ protocols {
273
+ ! inactive: ospf {
274
+ area 0.0.0.0 {
275
+ ! activate: interface ge-0/0/0.0;
276
+ }
277
+ }
278
+ }
279
+ ```
280
+
281
+ In `reconcile`, the same toggles emit `activate <path>` / `deactivate <path>` (see the reconcile section above).
282
+
257
283
  ### Customizing the behavior for a new vendor
258
284
 
259
285
  The `VendorParser` protocol exposes `is_order_sensitive(path: tuple[str, ...]) -> bool`. `path` is the tuple of `line` values from the root down to "the parent node whose children are being compared." Returning `True` makes the children compared positionally via `SequenceMatcher`; returning `False` (the default) falls back to set-style key comparison. If you're subclassing the Cisco family, the shortest path is to pass `order_sensitive_predicate` to `CiscoLikeParser(...)`.
@@ -280,7 +306,9 @@ Create a new module under `src/diffnc/vendors/`, expose an implementation of the
280
306
  * `render_close(node, depth) -> str | None`
281
307
  * `render_leaf(node, depth) -> str`
282
308
  * `is_order_sensitive(path) -> bool` (optional; treated as always `False` if not implemented. See the "How order is handled" section.)
283
- * `render_reconcile(events) -> Iterator[str]` (optional; required only to support `reconcile`. Receives a sequence of `ReconcileAdd` / `ReconcileDelete` / `ReconcileRecreate` events from `diffnc.reconcile` and yields the corresponding CLI lines.)
309
+ * `render_reconcile(events) -> Iterator[str]` (optional; required only to support `reconcile`. Receives a sequence of `ReconcileAdd` / `ReconcileDelete` / `ReconcileRecreate` / `ReconcileToggle` events from `diffnc.reconcile` and yields the corresponding CLI lines.)
310
+ * `toggle_partners(a, b) -> bool` / `is_toggle_state(line) -> bool` (optional; both default to `False`. Let `reconcile` recognise paired toggle states — e.g. `X` ↔ `no X`, `activate` ↔ `deactivate` — so a transition emits only the new value and a removed toggle is excluded from value-change matching.)
311
+ * `base_identity(line) -> str` / `render_toggle_line(b_line, depth, *, became_active, kind) -> str` (optional; only needed for vendors with an inactive-state prefix such as Junos hierarchical `inactive:`. `base_identity` strips the prefix so a (de)activated node matches its active form as one node in two states; when that match's lines still differ, the diff/reconcile engines treat it as a toggle and call `render_toggle_line` to render the `!`-marked line — `kind` is `"leaf"`, `"section_open"` or `"section_collapsed"` — or emit a `ReconcileToggle`.)
284
312
 
285
313
  ## License
286
314
 
@@ -68,11 +68,11 @@ for line in reconcile(a, b):
68
68
  print(line)
69
69
  ```
70
70
 
71
- Output is config-mode commands only — no `configure terminal` / `end` / `commit` wrappers, no indentation. Pipe through your own session manager.
71
+ Output is config-mode commands only — no `configure terminal` / `end` / `commit` wrappers. Lines are indented to mirror the section hierarchy (using each vendor's own indent unit), so the result reads like the source config; flat vendors such as Junos set form emit no indentation. Pipe through your own session manager.
72
72
 
73
- * **Cisco-like (NX-OS / IOS / IOS-XE / IOS-XR / EOS):** emits section navigation plus `<line>` for adds and `no <line>` for deletes (with `no no foo` collapsed to `foo`, so `no shutdown` `shutdown` toggles correctly).
74
- * **Junos hierarchical:** emits flat `set <path>` and `delete <path>` lines.
75
- * **Junos set:** emits `<line>` verbatim for adds and `delete <path>` (with the `set ` / `activate ` / `deactivate ` prefix stripped) for deletes.
73
+ * **Cisco-like (NX-OS / IOS / IOS-XE / IOS-XR / EOS):** emits section navigation plus `<line>` for adds and `no <line>` for deletes. `X` / `no X` toggles are tracked as state: a transition (e.g. `no shutdown` `shutdown`) emits just the new value, while a *removed* toggle resets to default — `default <cmd>` on vendors that support it (IOS / IOS-XE / NX-OS / EOS) and the inverted `no` on IOS-XR.
74
+ * **Junos hierarchical:** emits flat `set <path>` and `delete <path>` lines. A node whose only change is its inactive state (`inactive: <line>`) emits `activate <path>` / `deactivate <path>` instead of a `delete`/`set` pair.
75
+ * **Junos set:** emits `<line>` verbatim for adds and `delete <path>` (with the `set ` prefix stripped) for deletes. `activate` / `deactivate` are tracked as state: a removed toggle inverts to its counterpart (`deactivate X` → `activate X`) rather than deleting the underlying `set`.
76
76
  * **Order-sensitive sections** (ACL, `policy-map`, Junos `firewall filter` / `policy-statement` terms): on any change, the entire section is deleted and recreated from *B* — partial in-place edits are not attempted.
77
77
 
78
78
  Exceptions:
@@ -116,8 +116,7 @@ Or, in reconcile mode (**experimental**):
116
116
  ```bash
117
117
  $ diffnc before.conf after.conf -r
118
118
  interface Ethernet1/1
119
- no description uplink
120
- description uplink-to-spine
119
+ description uplink-to-spine
121
120
  feature ospf
122
121
  ```
123
122
 
@@ -219,6 +218,33 @@ Example: a reorder of one term plus a content change in another term
219
218
  }
220
219
  ```
221
220
 
221
+ ## Junos inactive/active toggles
222
+
223
+ When a Junos hierarchical node only flips its `inactive:` state (the node itself and its subtree are otherwise identical), the diff does not re-emit the whole subtree as a `-`/`+` pair. Instead it pairs the two states as one node and marks the new state with a `!`:
224
+
225
+ * A deactivated node keeps its literal `inactive:` prefix.
226
+ * A reactivated node has no marker on the B side, so a synthetic `activate:` is shown to make the direction visible.
227
+
228
+ ```diff
229
+ system {
230
+ ! inactive: host-name foo;
231
+ }
232
+ ```
233
+
234
+ If the toggled section also has real changes inside it, the section header is marked `!` and expanded so the inner `-`/`+` (or nested `!`) lines still surface:
235
+
236
+ ```diff
237
+ protocols {
238
+ ! inactive: ospf {
239
+ area 0.0.0.0 {
240
+ ! activate: interface ge-0/0/0.0;
241
+ }
242
+ }
243
+ }
244
+ ```
245
+
246
+ In `reconcile`, the same toggles emit `activate <path>` / `deactivate <path>` (see the reconcile section above).
247
+
222
248
  ### Customizing the behavior for a new vendor
223
249
 
224
250
  The `VendorParser` protocol exposes `is_order_sensitive(path: tuple[str, ...]) -> bool`. `path` is the tuple of `line` values from the root down to "the parent node whose children are being compared." Returning `True` makes the children compared positionally via `SequenceMatcher`; returning `False` (the default) falls back to set-style key comparison. If you're subclassing the Cisco family, the shortest path is to pass `order_sensitive_predicate` to `CiscoLikeParser(...)`.
@@ -245,7 +271,9 @@ Create a new module under `src/diffnc/vendors/`, expose an implementation of the
245
271
  * `render_close(node, depth) -> str | None`
246
272
  * `render_leaf(node, depth) -> str`
247
273
  * `is_order_sensitive(path) -> bool` (optional; treated as always `False` if not implemented. See the "How order is handled" section.)
248
- * `render_reconcile(events) -> Iterator[str]` (optional; required only to support `reconcile`. Receives a sequence of `ReconcileAdd` / `ReconcileDelete` / `ReconcileRecreate` events from `diffnc.reconcile` and yields the corresponding CLI lines.)
274
+ * `render_reconcile(events) -> Iterator[str]` (optional; required only to support `reconcile`. Receives a sequence of `ReconcileAdd` / `ReconcileDelete` / `ReconcileRecreate` / `ReconcileToggle` events from `diffnc.reconcile` and yields the corresponding CLI lines.)
275
+ * `toggle_partners(a, b) -> bool` / `is_toggle_state(line) -> bool` (optional; both default to `False`. Let `reconcile` recognise paired toggle states — e.g. `X` ↔ `no X`, `activate` ↔ `deactivate` — so a transition emits only the new value and a removed toggle is excluded from value-change matching.)
276
+ * `base_identity(line) -> str` / `render_toggle_line(b_line, depth, *, became_active, kind) -> str` (optional; only needed for vendors with an inactive-state prefix such as Junos hierarchical `inactive:`. `base_identity` strips the prefix so a (de)activated node matches its active form as one node in two states; when that match's lines still differ, the diff/reconcile engines treat it as a toggle and call `render_toggle_line` to render the `!`-marked line — `kind` is `"leaf"`, `"section_open"` or `"section_collapsed"` — or emit a `ReconcileToggle`.)
249
277
 
250
278
  ## License
251
279
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffnc"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "Structural diff library for network device configurations"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -11,21 +11,23 @@ import re
11
11
 
12
12
  from diffnc.errors import ParseError
13
13
 
14
- _JUNOS_TOP_KEYWORDS = (
15
- "interfaces",
16
- "system",
17
- "routing-options",
18
- "routing-instances",
19
- "protocols",
20
- "policy-options",
21
- "firewall",
22
- "security",
23
- "groups",
24
- "chassis",
25
- "snmp",
26
- "services",
27
- "applications",
28
- "forwarding-options",
14
+ _JUNOS_TOP_KEYWORDS = frozenset(
15
+ {
16
+ "interfaces",
17
+ "system",
18
+ "routing-options",
19
+ "routing-instances",
20
+ "protocols",
21
+ "policy-options",
22
+ "firewall",
23
+ "security",
24
+ "groups",
25
+ "chassis",
26
+ "snmp",
27
+ "services",
28
+ "applications",
29
+ "forwarding-options",
30
+ }
29
31
  )
30
32
 
31
33
  _NXOS_INTERFACE_RE = re.compile(r"^interface Ethernet\d+/\d+")
@@ -117,20 +119,30 @@ def _has_eos_marker(lines: list[str]) -> bool:
117
119
  return False
118
120
 
119
121
 
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
-
122
+ def _significant_lines(text: str) -> list[str]:
127
123
  significant: list[str] = []
128
124
  for raw in text.splitlines():
129
125
  s = raw.strip()
130
126
  if not s or s.startswith("!") or s.startswith("#") or s.startswith("/*"):
131
127
  continue
132
128
  significant.append(s)
129
+ return significant
130
+
131
+
132
+ def is_empty_config(text: str) -> bool:
133
+ """True if *text* has no significant (non-comment) lines."""
134
+
135
+ return not _significant_lines(text)
133
136
 
137
+
138
+ def detect_vendor(text: str) -> str:
139
+ """Return the vendor name for *text*.
140
+
141
+ Raises:
142
+ ParseError: if no supported vendor can be confidently identified.
143
+ """
144
+
145
+ significant = _significant_lines(text)
134
146
  if not significant:
135
147
  raise ParseError("configuration is empty or comment-only")
136
148
 
@@ -142,12 +154,11 @@ def detect_vendor(text: str) -> str:
142
154
  return "junos_set"
143
155
 
144
156
  # Junos hierarchical: presence of braces and at least one Junos top-level keyword.
157
+ # The keyword check is just "first token is a Junos top-level word" — a line like
158
+ # ``interfaces {`` already has that word as its first token, so no separate brace-form
159
+ # scan (and no per-keyword inner loop) is needed.
145
160
  has_brace = any(s.endswith("{") or s == "}" for s in significant)
146
- has_junos_kw = any(
147
- s.split(" ", 1)[0] in _JUNOS_TOP_KEYWORDS or s.startswith(kw + " {")
148
- for s in significant
149
- for kw in _JUNOS_TOP_KEYWORDS
150
- )
161
+ has_junos_kw = any(s.split(" ", 1)[0] in _JUNOS_TOP_KEYWORDS for s in significant)
151
162
  if has_brace and has_junos_kw:
152
163
  return "junos"
153
164
 
@@ -17,17 +17,26 @@ lines, joined internally) for parity with :mod:`difflib`.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ from collections import deque
20
21
  from collections.abc import Iterable, Iterator, Sequence
21
22
  from dataclasses import dataclass
22
23
  from difflib import SequenceMatcher
23
24
 
24
25
  from diffnc import vendors as _vendors
25
- from diffnc.detect import detect_vendor
26
+ from diffnc.detect import detect_vendor, is_empty_config
26
27
  from diffnc.errors import VendorMismatchError
27
28
  from diffnc.ir import ConfigNode, ConfigTree
28
- from diffnc.vendors.base import VendorParser, is_order_sensitive_for, render_subtree
29
+ from diffnc.vendors.base import (
30
+ VendorParser,
31
+ base_identity_for,
32
+ is_order_sensitive_for,
33
+ leaf_section_render_equivalent,
34
+ render_subtree,
35
+ render_toggle_line_for,
36
+ )
29
37
 
30
38
  _SIMILARITY_CUTOFF = 0.6 # difflib.get_close_matches の既定値に合わせる
39
+ _TOKEN_SHARE_CUTOFF = 0.4 # 先頭コマンド語が一致する場合に適用する緩い二次閾値
31
40
 
32
41
 
33
42
  @dataclass(frozen=True)
@@ -120,11 +129,21 @@ def _prepare(
120
129
  text_b = _coerce(b)
121
130
 
122
131
  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
132
+ empty_a = is_empty_config(text_a)
133
+ empty_b = is_empty_config(text_b)
134
+ if empty_a and empty_b:
135
+ # Nothing to detect from and nothing to diff: both trees parse empty, so no
136
+ # vendor-specific behaviour is ever reached and any registered parser will do.
137
+ vendor_name = "nxos"
138
+ elif empty_a or empty_b:
139
+ # An empty side carries no vendor signal; detect from the other side only.
140
+ vendor_name = detect_vendor(text_b if empty_a else text_a)
141
+ else:
142
+ vendor_a = detect_vendor(text_a)
143
+ vendor_b = detect_vendor(text_b)
144
+ if vendor_a != vendor_b:
145
+ raise VendorMismatchError(vendor_a, vendor_b)
146
+ vendor_name = vendor_a
128
147
  else:
129
148
  vendor_name = vendor
130
149
 
@@ -276,78 +295,151 @@ def _diff_children_unordered(
276
295
 
277
296
  a_children = node_a.children
278
297
  b_children = node_b.children
279
- b_by_line = {child.line: child for child in b_children}
280
- common = {child.line for child in a_children} & set(b_by_line)
281
- b_match_index = {
282
- child.line: idx for idx, child in enumerate(b_children) if child.line in common
283
- }
298
+ # Match on base identity (toggle prefixes stripped) so an (de)activated node pairs with
299
+ # its active form; a base-key match whose lines still differ is an inactive toggle.
300
+ a_keys = [base_identity_for(parser, child.line) for child in a_children]
301
+ b_keys = [base_identity_for(parser, child.line) for child in b_children]
302
+ b_by_key = dict(zip(b_keys, b_children, strict=True))
303
+ common = set(a_keys) & set(b_by_key)
304
+ b_match_index = {key: idx for idx, key in enumerate(b_keys) if key in common}
284
305
 
285
306
  a_only_leaves = [
286
307
  (idx, child)
287
308
  for idx, child in enumerate(a_children)
288
- if child.line not in common and child.is_leaf
309
+ if a_keys[idx] not in common and child.is_leaf
289
310
  ]
290
311
  b_only_leaves = [
291
312
  (idx, child)
292
313
  for idx, child in enumerate(b_children)
293
- if child.line not in common and child.is_leaf
314
+ if b_keys[idx] not in common and child.is_leaf
294
315
  ]
295
316
  a_index_to_b_node, paired_b_positions = _pair_changed_leaves(a_only_leaves, b_only_leaves)
296
317
 
297
318
  b_pointer = 0
298
319
  for idx, child_a in enumerate(a_children):
299
- if child_a.line not in common:
320
+ key_a = a_keys[idx]
321
+ if key_a not in common:
300
322
  yield from _emit_one_side(parser, child_a, depth, "-")
301
323
  partner = a_index_to_b_node.get(idx)
302
324
  if partner is not None:
303
325
  yield from _emit_one_side(parser, partner, depth, "+")
304
326
  continue
305
- b_pos = b_match_index[child_a.line]
327
+ b_pos = b_match_index[key_a]
306
328
  if b_pos >= b_pointer:
307
329
  for k in range(b_pointer, b_pos):
308
330
  child_b = b_children[k]
309
- if child_b.line not in common and k not in paired_b_positions:
331
+ if b_keys[k] not in common and k not in paired_b_positions:
310
332
  yield from _emit_one_side(parser, child_b, depth, "+")
311
333
  b_pointer = b_pos + 1
312
- yield from _equal_pair(parser, child_a, b_by_line[child_a.line], depth, hide_equal, path)
334
+ yield from _equal_pair(parser, child_a, b_by_key[key_a], depth, hide_equal, path)
313
335
 
314
336
  for k in range(b_pointer, len(b_children)):
315
337
  child_b = b_children[k]
316
- if child_b.line not in common and k not in paired_b_positions:
338
+ if b_keys[k] not in common and k not in paired_b_positions:
317
339
  yield from _emit_one_side(parser, child_b, depth, "+")
318
340
 
319
341
 
342
+ def _leading_token(line: str) -> str:
343
+ """Return the first whitespace-delimited token of *line* (its command word)."""
344
+
345
+ parts = line.split(maxsplit=1)
346
+ return parts[0] if parts else ""
347
+
348
+
349
+ def _value_head(line: str) -> str | None:
350
+ """The part of *line* before its trailing token, or ``None`` for a single-token line.
351
+
352
+ ``"ip ospf cost 10"`` → ``"ip ospf cost"``; ``"shutdown"`` → ``None``. Two leaves with the
353
+ same head differ only in their trailing value — i.e. the same setting reassigned.
354
+ """
355
+
356
+ head, sep, _ = line.rpartition(" ")
357
+ return head if sep else None
358
+
359
+
320
360
  def _pair_changed_leaves(
321
361
  a_only: list[tuple[int, ConfigNode]],
322
362
  b_only: list[tuple[int, ConfigNode]],
323
363
  ) -> tuple[dict[int, ConfigNode], set[int]]:
324
364
  """Greedily pair A-only / B-only leaves that differ only in value.
325
365
 
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.
366
+ Matching runs in two passes so it stays near-linear on large configs:
367
+
368
+ * **Phase 1 exact value head.** Leaves that share a :func:`_value_head` (same setting,
369
+ new trailing value, e.g. ``ip route … <next-hop>`` or ``mtu <n>``) are paired directly
370
+ via a hash bucket. This is the overwhelmingly common case and the one that used to blow
371
+ up: thousands of same-prefix leaves no longer need an O(n²) ``SequenceMatcher`` sweep.
372
+ * **Phase 2 — ratio fallback.** Whatever stays unpaired (leaves whose *prefix* changed,
373
+ e.g. ``description uplink`` → ``description uplink-to-spine``, or short settings such as
374
+ ``vlan 1`` → ``vlan 1,100,200,300``) is matched within shared leading-token buckets by
375
+ ``difflib`` ratio, highest first, each side used once. The reused matcher is gated by the
376
+ cheap ``real_quick_ratio`` / ``quick_ratio`` upper bounds (the prefilter
377
+ :func:`difflib.get_close_matches` uses). The residual is small, so this stays cheap.
378
+
379
+ Restricting Phase 2 to a shared leading token (and Phase 1 to a shared head) is what makes
380
+ a value change meaningful; cross-token pairs — rare even under the old all-pairs scan — are
381
+ intentionally not formed.
382
+
383
+ Returns ``(a_child_index -> paired b node, set of paired b child indices)`` so the
384
+ caller can emit the ``+`` next to its ``-`` and suppress it elsewhere.
330
385
  """
331
386
 
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
387
  used_a: set[int] = set()
341
388
  used_b: set[int] = set()
342
389
  a_index_to_b_node: dict[int, ConfigNode] = {}
343
390
  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
391
+
392
+ def _commit(ai: int, bi: int) -> None:
347
393
  used_a.add(ai)
348
394
  used_b.add(bi)
349
395
  a_index_to_b_node[a_only[ai][0]] = b_only[bi][1]
350
396
  paired_b_positions.add(b_only[bi][0])
397
+
398
+ # Phase 1: pair leaves sharing an exact value head (in B order, one-to-one).
399
+ b_by_head: dict[str, deque[int]] = {}
400
+ for bi, (_, b_node) in enumerate(b_only):
401
+ head = _value_head(b_node.line)
402
+ if head is not None:
403
+ b_by_head.setdefault(head, deque()).append(bi)
404
+ for ai, (_, a_node) in enumerate(a_only):
405
+ head = _value_head(a_node.line)
406
+ if head is None:
407
+ continue
408
+ bucket = b_by_head.get(head)
409
+ if bucket:
410
+ _commit(ai, bucket.popleft())
411
+
412
+ # Phase 2: ratio-match the residual within shared leading-token buckets.
413
+ rem_b_by_token: dict[str, list[int]] = {}
414
+ for bi, (_, b_node) in enumerate(b_only):
415
+ if bi not in used_b:
416
+ rem_b_by_token.setdefault(_leading_token(b_node.line), []).append(bi)
417
+ if not rem_b_by_token:
418
+ return a_index_to_b_node, paired_b_positions
419
+
420
+ candidates: list[tuple[float, int, int]] = []
421
+ matcher = SequenceMatcher() # autojunk left at difflib's default, as the old all-pairs scan did
422
+ for ai, (_, a_node) in enumerate(a_only):
423
+ if ai in used_a:
424
+ continue
425
+ bucket = rem_b_by_token.get(_leading_token(a_node.line))
426
+ if not bucket:
427
+ continue
428
+ matcher.set_seq2(a_node.line)
429
+ for bi in bucket:
430
+ matcher.set_seq1(b_only[bi][1].line)
431
+ if (
432
+ matcher.real_quick_ratio() < _TOKEN_SHARE_CUTOFF
433
+ or matcher.quick_ratio() < _TOKEN_SHARE_CUTOFF
434
+ ):
435
+ continue
436
+ ratio = matcher.ratio()
437
+ if ratio >= _TOKEN_SHARE_CUTOFF:
438
+ candidates.append((ratio, ai, bi))
439
+ candidates.sort(key=lambda t: t[0], reverse=True)
440
+ for _, ai, bi in candidates:
441
+ if ai not in used_a and bi not in used_b:
442
+ _commit(ai, bi)
351
443
  return a_index_to_b_node, paired_b_positions
352
444
 
353
445
 
@@ -362,7 +454,21 @@ def _equal_pair(
362
454
  a_is_section = not child_a.is_leaf
363
455
  b_is_section = not child_b.is_leaf
364
456
 
457
+ # Matched by base identity; if the raw lines still differ it is an inactive toggle.
458
+ key = base_identity_for(parser, child_a.line)
459
+ toggled = child_a.line != child_b.line
460
+ became_active = child_b.line == key
461
+
365
462
  if not a_is_section and not b_is_section:
463
+ if toggled:
464
+ # Only the (in)active state changed; show the new state marked ``!``.
465
+ yield _Event(
466
+ "!",
467
+ render_toggle_line_for(
468
+ parser, child_b.line, depth, became_active=became_active, kind="leaf"
469
+ ),
470
+ )
471
+ return
366
472
  # Equal leaf at this level. Suppress in the compact view, show as context in ndiff.
367
473
  if not hide_equal:
368
474
  yield _Event(" ", parser.render_leaf(child_a, depth))
@@ -371,7 +477,7 @@ def _equal_pair(
371
477
  if a_is_section != b_is_section:
372
478
  leaf_node = child_b if a_is_section else child_a
373
479
  section_node = child_a if a_is_section else child_b
374
- if not _leaf_section_render_equivalent(parser, leaf_node, section_node, depth):
480
+ if not leaf_section_render_equivalent(parser, leaf_node, section_node, depth):
375
481
  # A leaf that genuinely became a section (e.g. Junos ``foo;`` → ``foo { ... }``).
376
482
  yield from _emit_one_side(parser, child_a, depth, "-")
377
483
  yield from _emit_one_side(parser, child_b, depth, "+")
@@ -379,8 +485,8 @@ def _equal_pair(
379
485
  # Indent-based vendors: the "leaf" is just an empty section with the same header,
380
486
  # so fall through and diff their children (the empty side yields all ``-``/``+``).
381
487
 
382
- # Both are sections with matching headers. Diff their children; only surface the section
383
- # header if any descendant differs (compact mode) or always (ndiff).
488
+ # Both are sections with matching headers. Diff their children; surface the section header
489
+ # if any descendant differs, if its own state toggled (compact mode), or always (ndiff).
384
490
  inner = list(
385
491
  _diff_children(
386
492
  parser,
@@ -388,40 +494,37 @@ def _equal_pair(
388
494
  child_b,
389
495
  depth + 1,
390
496
  hide_equal=hide_equal,
391
- path=(*path, child_a.line),
497
+ path=(*path, key),
392
498
  )
393
499
  )
394
500
  has_change = any(ev.op != " " for ev in inner)
395
501
 
396
- if not has_change and hide_equal:
502
+ if not has_change and not toggled and hide_equal:
397
503
  return
398
504
 
399
- yield _Event(" ", parser.render_open(child_a, depth))
505
+ if toggled and not has_change and hide_equal:
506
+ # Section state flipped but its subtree is identical: collapse to one marked line.
507
+ yield _Event(
508
+ "!",
509
+ render_toggle_line_for(
510
+ parser, child_b.line, depth, became_active=became_active, kind="section_collapsed"
511
+ ),
512
+ )
513
+ return
514
+
515
+ if toggled:
516
+ header = render_toggle_line_for(
517
+ parser, child_b.line, depth, became_active=became_active, kind="section_open"
518
+ )
519
+ yield _Event("!", header)
520
+ else:
521
+ yield _Event(" ", parser.render_open(child_a, depth))
400
522
  yield from inner
401
523
  close = parser.render_close(child_a, depth)
402
524
  if close is not None:
403
525
  yield _Event(" ", close)
404
526
 
405
527
 
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
-
425
528
  def _emit_one_side(
426
529
  parser: VendorParser,
427
530
  node: ConfigNode,