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.
- {diffnc-0.0.2 → diffnc-0.0.4}/.github/workflows/publish.yml +2 -2
- {diffnc-0.0.2 → diffnc-0.0.4}/.github/workflows/test.yml +2 -2
- {diffnc-0.0.2 → diffnc-0.0.4}/PKG-INFO +36 -8
- {diffnc-0.0.2 → diffnc-0.0.4}/README.md +35 -7
- {diffnc-0.0.2 → diffnc-0.0.4}/pyproject.toml +1 -1
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/detect.py +38 -27
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/diff.py +162 -59
- diffnc-0.0.4/src/diffnc/ir.py +92 -0
- diffnc-0.0.4/src/diffnc/reconcile.py +279 -0
- {diffnc-0.0.2 → 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.2 → diffnc-0.0.4}/src/diffnc/vendors/iosxr.py +2 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/junos.py +35 -1
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/junos_set.py +50 -7
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_cli.py +3 -1
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_diff.py +105 -4
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_nxos.py +25 -0
- diffnc-0.0.4/tests/test_reconcile.py +448 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/uv.lock +1 -1
- diffnc-0.0.2/src/diffnc/ir.py +0 -58
- diffnc-0.0.2/src/diffnc/reconcile.py +0 -132
- diffnc-0.0.2/src/diffnc/vendors/base.py +0 -88
- diffnc-0.0.2/tests/test_reconcile.py +0 -281
- {diffnc-0.0.2 → diffnc-0.0.4}/.github/dependabot.yml +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/.gitignore +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/LICENSE +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/__init__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/__main__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/cli.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/errors.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/py.typed +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/__init__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/eos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/ios.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/iosxe.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/src/diffnc/vendors/nxos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/__init__.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/eos_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/eos_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/ios_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/ios_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxe_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxe_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxr_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/iosxr_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_set_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/junos_set_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/nxos_a.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/fixtures/nxos_b.conf +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_detect.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_eos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_ios.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_iosxe.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_iosxr.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_junos.py +0 -0
- {diffnc-0.0.2 → diffnc-0.0.4}/tests/test_junos_set.py +0 -0
- {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@
|
|
17
|
+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
18
18
|
|
|
19
19
|
- name: Install uv
|
|
20
|
-
uses: astral-sh/setup-uv@
|
|
20
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
21
21
|
with:
|
|
22
22
|
python-version: "3.12"
|
|
23
23
|
|
|
@@ -20,10 +20,10 @@ jobs:
|
|
|
20
20
|
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
21
21
|
|
|
22
22
|
steps:
|
|
23
|
-
- uses: actions/checkout@
|
|
23
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
24
24
|
|
|
25
25
|
- name: Install uv
|
|
26
|
-
uses: astral-sh/setup-uv@
|
|
26
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
27
27
|
with:
|
|
28
28
|
python-version: ${{ matrix.python-version }}
|
|
29
29
|
enable-cache: true
|
|
@@ -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+")
|
|
@@ -117,20 +119,30 @@ def _has_eos_marker(lines: list[str]) -> bool:
|
|
|
117
119
|
return False
|
|
118
120
|
|
|
119
121
|
|
|
120
|
-
def
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
if
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
|
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;
|
|
383
|
-
#
|
|
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,
|
|
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
|
-
|
|
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,
|