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.
- {diffnc-0.0.3 → diffnc-0.0.4}/PKG-INFO +36 -8
- {diffnc-0.0.3 → diffnc-0.0.4}/README.md +35 -7
- {diffnc-0.0.3 → diffnc-0.0.4}/pyproject.toml +1 -1
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/detect.py +21 -20
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/diff.py +135 -56
- diffnc-0.0.4/src/diffnc/ir.py +92 -0
- diffnc-0.0.4/src/diffnc/reconcile.py +279 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/_cisco_like.py +98 -35
- diffnc-0.0.4/src/diffnc/vendors/base.py +208 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/iosxr.py +2 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/junos.py +35 -1
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/junos_set.py +50 -7
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_cli.py +3 -1
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_diff.py +53 -4
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_nxos.py +25 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_reconcile.py +177 -32
- {diffnc-0.0.3 → diffnc-0.0.4}/uv.lock +1 -1
- diffnc-0.0.3/src/diffnc/ir.py +0 -58
- diffnc-0.0.3/src/diffnc/reconcile.py +0 -132
- diffnc-0.0.3/src/diffnc/vendors/base.py +0 -88
- {diffnc-0.0.3 → diffnc-0.0.4}/.github/dependabot.yml +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/.github/workflows/publish.yml +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/.github/workflows/test.yml +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/.gitignore +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/LICENSE +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/__init__.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/__main__.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/cli.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/errors.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/py.typed +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/__init__.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/eos.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/ios.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/iosxe.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/src/diffnc/vendors/nxos.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/__init__.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/eos_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/eos_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/ios_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/ios_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxe_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxe_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxr_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/iosxr_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_set_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/junos_set_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/nxos_a.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/fixtures/nxos_b.conf +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_detect.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_eos.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_ios.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_iosxe.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_iosxr.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_junos.py +0 -0
- {diffnc-0.0.3 → diffnc-0.0.4}/tests/test_junos_set.py +0 -0
- {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
|
+
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
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
|
|
@@ -11,21 +11,23 @@ import re
|
|
|
11
11
|
|
|
12
12
|
from diffnc.errors import ParseError
|
|
13
13
|
|
|
14
|
-
_JUNOS_TOP_KEYWORDS = (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
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;
|
|
407
|
-
#
|
|
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,
|
|
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
|
-
|
|
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)
|