diffnc 0.0.3__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 (58) hide show
  1. {diffnc-0.0.3 → diffnc-0.0.4}/PKG-INFO +36 -8
  2. {diffnc-0.0.3 → diffnc-0.0.4}/README.md +35 -7
  3. {diffnc-0.0.3 → diffnc-0.0.4}/pyproject.toml +1 -1
  4. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/detect.py +21 -20
  5. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/diff.py +135 -56
  6. diffnc-0.0.4/src/diffnc/ir.py +92 -0
  7. diffnc-0.0.4/src/diffnc/reconcile.py +279 -0
  8. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/_cisco_like.py +98 -35
  9. diffnc-0.0.4/src/diffnc/vendors/base.py +208 -0
  10. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/iosxr.py +2 -0
  11. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/junos.py +35 -1
  12. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/junos_set.py +50 -7
  13. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_cli.py +3 -1
  14. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_diff.py +53 -4
  15. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_nxos.py +25 -0
  16. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_reconcile.py +177 -32
  17. {diffnc-0.0.3 → diffnc-0.0.4}/uv.lock +1 -1
  18. diffnc-0.0.3/src/diffnc/ir.py +0 -58
  19. diffnc-0.0.3/src/diffnc/reconcile.py +0 -132
  20. diffnc-0.0.3/src/diffnc/vendors/base.py +0 -88
  21. {diffnc-0.0.3 → diffnc-0.0.4}/.github/dependabot.yml +0 -0
  22. {diffnc-0.0.3 → diffnc-0.0.4}/.github/workflows/publish.yml +0 -0
  23. {diffnc-0.0.3 → diffnc-0.0.4}/.github/workflows/test.yml +0 -0
  24. {diffnc-0.0.3 → diffnc-0.0.4}/.gitignore +0 -0
  25. {diffnc-0.0.3 → diffnc-0.0.4}/LICENSE +0 -0
  26. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/__init__.py +0 -0
  27. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/__main__.py +0 -0
  28. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/cli.py +0 -0
  29. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/errors.py +0 -0
  30. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/py.typed +0 -0
  31. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/__init__.py +0 -0
  32. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/eos.py +0 -0
  33. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/ios.py +0 -0
  34. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/iosxe.py +0 -0
  35. {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/nxos.py +0 -0
  36. {diffnc-0.0.3 → diffnc-0.0.4}/tests/__init__.py +0 -0
  37. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/eos_a.conf +0 -0
  38. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/eos_b.conf +0 -0
  39. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/ios_a.conf +0 -0
  40. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/ios_b.conf +0 -0
  41. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxe_a.conf +0 -0
  42. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxe_b.conf +0 -0
  43. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxr_a.conf +0 -0
  44. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxr_b.conf +0 -0
  45. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_a.conf +0 -0
  46. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_b.conf +0 -0
  47. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_set_a.conf +0 -0
  48. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_set_b.conf +0 -0
  49. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/nxos_a.conf +0 -0
  50. {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/nxos_b.conf +0 -0
  51. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_detect.py +0 -0
  52. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_eos.py +0 -0
  53. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_ios.py +0 -0
  54. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_iosxe.py +0 -0
  55. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_iosxr.py +0 -0
  56. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_junos.py +0 -0
  57. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_junos_set.py +0 -0
  58. {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_order_sensitivity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffnc
3
- Version: 0.0.3
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.3"
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+")
@@ -152,12 +154,11 @@ def detect_vendor(text: str) -> str:
152
154
  return "junos_set"
153
155
 
154
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.
155
160
  has_brace = any(s.endswith("{") or s == "}" for s in significant)
156
- has_junos_kw = any(
157
- s.split(" ", 1)[0] in _JUNOS_TOP_KEYWORDS or s.startswith(kw + " {")
158
- for s in significant
159
- for kw in _JUNOS_TOP_KEYWORDS
160
- )
161
+ has_junos_kw = any(s.split(" ", 1)[0] in _JUNOS_TOP_KEYWORDS for s in significant)
161
162
  if has_brace and has_junos_kw:
162
163
  return "junos"
163
164
 
@@ -17,6 +17,7 @@ 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
@@ -25,7 +26,14 @@ from diffnc import vendors as _vendors
25
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 の既定値に合わせる
31
39
  _TOKEN_SHARE_CUTOFF = 0.4 # 先頭コマンド語が一致する場合に適用する緩い二次閾値
@@ -287,44 +295,47 @@ def _diff_children_unordered(
287
295
 
288
296
  a_children = node_a.children
289
297
  b_children = node_b.children
290
- b_by_line = {child.line: child for child in b_children}
291
- common = {child.line for child in a_children} & set(b_by_line)
292
- b_match_index = {
293
- child.line: idx for idx, child in enumerate(b_children) if child.line in common
294
- }
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}
295
305
 
296
306
  a_only_leaves = [
297
307
  (idx, child)
298
308
  for idx, child in enumerate(a_children)
299
- if child.line not in common and child.is_leaf
309
+ if a_keys[idx] not in common and child.is_leaf
300
310
  ]
301
311
  b_only_leaves = [
302
312
  (idx, child)
303
313
  for idx, child in enumerate(b_children)
304
- if child.line not in common and child.is_leaf
314
+ if b_keys[idx] not in common and child.is_leaf
305
315
  ]
306
316
  a_index_to_b_node, paired_b_positions = _pair_changed_leaves(a_only_leaves, b_only_leaves)
307
317
 
308
318
  b_pointer = 0
309
319
  for idx, child_a in enumerate(a_children):
310
- if child_a.line not in common:
320
+ key_a = a_keys[idx]
321
+ if key_a not in common:
311
322
  yield from _emit_one_side(parser, child_a, depth, "-")
312
323
  partner = a_index_to_b_node.get(idx)
313
324
  if partner is not None:
314
325
  yield from _emit_one_side(parser, partner, depth, "+")
315
326
  continue
316
- b_pos = b_match_index[child_a.line]
327
+ b_pos = b_match_index[key_a]
317
328
  if b_pos >= b_pointer:
318
329
  for k in range(b_pointer, b_pos):
319
330
  child_b = b_children[k]
320
- 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:
321
332
  yield from _emit_one_side(parser, child_b, depth, "+")
322
333
  b_pointer = b_pos + 1
323
- 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)
324
335
 
325
336
  for k in range(b_pointer, len(b_children)):
326
337
  child_b = b_children[k]
327
- 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:
328
339
  yield from _emit_one_side(parser, child_b, depth, "+")
329
340
 
330
341
 
@@ -335,43 +346,100 @@ def _leading_token(line: str) -> str:
335
346
  return parts[0] if parts else ""
336
347
 
337
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
+
338
360
  def _pair_changed_leaves(
339
361
  a_only: list[tuple[int, ConfigNode]],
340
362
  b_only: list[tuple[int, ConfigNode]],
341
363
  ) -> tuple[dict[int, ConfigNode], set[int]]:
342
364
  """Greedily pair A-only / B-only leaves that differ only in value.
343
365
 
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.
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.
350
382
 
351
383
  Returns ``(a_child_index -> paired b node, set of paired b child indices)`` so the
352
384
  caller can emit the ``+`` next to its ``-`` and suppress it elsewhere.
353
385
  """
354
386
 
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
387
  used_a: set[int] = set()
365
388
  used_b: set[int] = set()
366
389
  a_index_to_b_node: dict[int, ConfigNode] = {}
367
390
  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
391
+
392
+ def _commit(ai: int, bi: int) -> None:
371
393
  used_a.add(ai)
372
394
  used_b.add(bi)
373
395
  a_index_to_b_node[a_only[ai][0]] = b_only[bi][1]
374
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)
375
443
  return a_index_to_b_node, paired_b_positions
376
444
 
377
445
 
@@ -386,7 +454,21 @@ def _equal_pair(
386
454
  a_is_section = not child_a.is_leaf
387
455
  b_is_section = not child_b.is_leaf
388
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
+
389
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
390
472
  # Equal leaf at this level. Suppress in the compact view, show as context in ndiff.
391
473
  if not hide_equal:
392
474
  yield _Event(" ", parser.render_leaf(child_a, depth))
@@ -395,7 +477,7 @@ def _equal_pair(
395
477
  if a_is_section != b_is_section:
396
478
  leaf_node = child_b if a_is_section else child_a
397
479
  section_node = child_a if a_is_section else child_b
398
- 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):
399
481
  # A leaf that genuinely became a section (e.g. Junos ``foo;`` → ``foo { ... }``).
400
482
  yield from _emit_one_side(parser, child_a, depth, "-")
401
483
  yield from _emit_one_side(parser, child_b, depth, "+")
@@ -403,8 +485,8 @@ def _equal_pair(
403
485
  # Indent-based vendors: the "leaf" is just an empty section with the same header,
404
486
  # so fall through and diff their children (the empty side yields all ``-``/``+``).
405
487
 
406
- # Both are sections with matching headers. Diff their children; only surface the section
407
- # 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).
408
490
  inner = list(
409
491
  _diff_children(
410
492
  parser,
@@ -412,40 +494,37 @@ def _equal_pair(
412
494
  child_b,
413
495
  depth + 1,
414
496
  hide_equal=hide_equal,
415
- path=(*path, child_a.line),
497
+ path=(*path, key),
416
498
  )
417
499
  )
418
500
  has_change = any(ev.op != " " for ev in inner)
419
501
 
420
- if not has_change and hide_equal:
502
+ if not has_change and not toggled and hide_equal:
421
503
  return
422
504
 
423
- 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))
424
522
  yield from inner
425
523
  close = parser.render_close(child_a, depth)
426
524
  if close is not None:
427
525
  yield _Event(" ", close)
428
526
 
429
527
 
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
-
449
528
  def _emit_one_side(
450
529
  parser: VendorParser,
451
530
  node: ConfigNode,
@@ -0,0 +1,92 @@
1
+ """Internal representation of a network configuration.
2
+
3
+ A configuration is modelled as a tree of :class:`ConfigNode`. Each node carries one logical
4
+ command line plus its (ordered) children. The whole document is wrapped in :class:`ConfigTree`
5
+ which records the originating vendor so that the diff engine can later format output back
6
+ in the input's flavour.
7
+
8
+ The constructors in this module also implement the normalisation rules described in the plan:
9
+
10
+ * Same-name non-leaf siblings get merged (their children concatenate).
11
+ * Duplicate leaf siblings collapse to one occurrence.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+
19
+ @dataclass
20
+ class ConfigNode:
21
+ """A single configuration statement, optionally with nested children."""
22
+
23
+ line: str
24
+ children: list[ConfigNode] = field(default_factory=list)
25
+ # line -> child node, kept in sync with ``children`` so same-name lookups stay O(1).
26
+ # Excluded from equality/repr: a node's identity is its line plus its children.
27
+ _index: dict[str, ConfigNode] = field(default_factory=dict, compare=False, repr=False)
28
+
29
+ def __post_init__(self) -> None:
30
+ # Children supplied at construction (e.g. in tests) bypass ``add_child``; index them
31
+ # so lookups and merges behave identically to a tree built incrementally.
32
+ if self.children:
33
+ self._index = {child.line: child for child in self.children}
34
+
35
+ @property
36
+ def is_leaf(self) -> bool:
37
+ return not self.children
38
+
39
+ def add_child(self, child: ConfigNode) -> ConfigNode:
40
+ """Append *child* (or fold it into an existing same-named sibling) and return the
41
+ node that now represents it in the tree.
42
+
43
+ Indent-based vendors (e.g. NX-OS) can't tell at insertion time whether a line will
44
+ end up being a leaf or a section, so the merge rule is intentionally simple: any
45
+ sibling with the same ``line`` absorbs the new node's children. This collapses
46
+ repeated blocks (``interface eth1`` appearing twice) into one. The ``_index`` map
47
+ makes the same-name check O(1) instead of scanning every sibling.
48
+ """
49
+
50
+ existing = self._index.get(child.line)
51
+ if existing is not None:
52
+ for grandchild in child.children:
53
+ existing.add_child(grandchild)
54
+ return existing
55
+ self.children.append(child)
56
+ self._index[child.line] = child
57
+ return child
58
+
59
+ def child_by_line(self, line: str) -> ConfigNode | None:
60
+ """Return the child whose ``line`` equals *line*, or ``None`` — O(1) via the index."""
61
+
62
+ return self._index.get(line)
63
+
64
+ def relabel_child(self, child: ConfigNode, new_line: str) -> None:
65
+ """Rename an existing *child* to *new_line* in place, keeping the index in sync.
66
+
67
+ Used by toggle collapsing (``X`` ↔ ``no X``, ``activate`` ↔ ``deactivate``) where the
68
+ last occurrence wins but the child's position must be preserved.
69
+ """
70
+
71
+ self._index.pop(child.line, None)
72
+ child.line = new_line
73
+ self._index[new_line] = child
74
+
75
+ def retain_children(self, surviving: list[ConfigNode]) -> None:
76
+ """Replace the child list (e.g. after a ``default`` / ``delete`` purge) and rebuild
77
+ the index from it."""
78
+
79
+ self.children = surviving
80
+ self._index = {child.line: child for child in surviving}
81
+
82
+
83
+ @dataclass
84
+ class ConfigTree:
85
+ """A parsed configuration document."""
86
+
87
+ root: ConfigNode
88
+ vendor: str
89
+
90
+ @classmethod
91
+ def empty(cls, vendor: str) -> ConfigTree:
92
+ return cls(root=ConfigNode(line=""), vendor=vendor)