diffnc 0.0.1__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.1/.github/dependabot.yml +6 -0
- diffnc-0.0.1/.github/workflows/publish.yml +49 -0
- diffnc-0.0.1/.github/workflows/test.yml +44 -0
- diffnc-0.0.1/.gitignore +10 -0
- diffnc-0.0.1/LICENSE +21 -0
- diffnc-0.0.1/PKG-INFO +283 -0
- diffnc-0.0.1/README.md +248 -0
- diffnc-0.0.1/pyproject.toml +63 -0
- diffnc-0.0.1/src/diffnc/__init__.py +37 -0
- diffnc-0.0.1/src/diffnc/__main__.py +8 -0
- diffnc-0.0.1/src/diffnc/cli.py +140 -0
- diffnc-0.0.1/src/diffnc/detect.py +170 -0
- diffnc-0.0.1/src/diffnc/diff.py +353 -0
- diffnc-0.0.1/src/diffnc/errors.py +22 -0
- diffnc-0.0.1/src/diffnc/ir.py +58 -0
- diffnc-0.0.1/src/diffnc/py.typed +0 -0
- diffnc-0.0.1/src/diffnc/reconcile.py +132 -0
- diffnc-0.0.1/src/diffnc/vendors/__init__.py +58 -0
- diffnc-0.0.1/src/diffnc/vendors/_cisco_like.py +203 -0
- diffnc-0.0.1/src/diffnc/vendors/base.py +88 -0
- diffnc-0.0.1/src/diffnc/vendors/eos.py +19 -0
- diffnc-0.0.1/src/diffnc/vendors/ios.py +19 -0
- diffnc-0.0.1/src/diffnc/vendors/iosxe.py +20 -0
- diffnc-0.0.1/src/diffnc/vendors/iosxr.py +19 -0
- diffnc-0.0.1/src/diffnc/vendors/junos.py +175 -0
- diffnc-0.0.1/src/diffnc/vendors/junos_set.py +158 -0
- diffnc-0.0.1/src/diffnc/vendors/nxos.py +22 -0
- diffnc-0.0.1/tests/__init__.py +0 -0
- diffnc-0.0.1/tests/fixtures/eos_a.conf +37 -0
- diffnc-0.0.1/tests/fixtures/eos_b.conf +42 -0
- diffnc-0.0.1/tests/fixtures/ios_a.conf +28 -0
- diffnc-0.0.1/tests/fixtures/ios_b.conf +34 -0
- diffnc-0.0.1/tests/fixtures/iosxe_a.conf +36 -0
- diffnc-0.0.1/tests/fixtures/iosxe_b.conf +43 -0
- diffnc-0.0.1/tests/fixtures/iosxr_a.conf +31 -0
- diffnc-0.0.1/tests/fixtures/iosxr_b.conf +37 -0
- diffnc-0.0.1/tests/fixtures/junos_a.conf +12 -0
- diffnc-0.0.1/tests/fixtures/junos_b.conf +15 -0
- diffnc-0.0.1/tests/fixtures/junos_set_a.conf +3 -0
- diffnc-0.0.1/tests/fixtures/junos_set_b.conf +4 -0
- diffnc-0.0.1/tests/fixtures/nxos_a.conf +12 -0
- diffnc-0.0.1/tests/fixtures/nxos_b.conf +13 -0
- diffnc-0.0.1/tests/test_cli.py +154 -0
- diffnc-0.0.1/tests/test_detect.py +128 -0
- diffnc-0.0.1/tests/test_diff.py +227 -0
- diffnc-0.0.1/tests/test_eos.py +107 -0
- diffnc-0.0.1/tests/test_ios.py +96 -0
- diffnc-0.0.1/tests/test_iosxe.py +98 -0
- diffnc-0.0.1/tests/test_iosxr.py +110 -0
- diffnc-0.0.1/tests/test_junos.py +102 -0
- diffnc-0.0.1/tests/test_junos_set.py +201 -0
- diffnc-0.0.1/tests/test_nxos.py +142 -0
- diffnc-0.0.1/tests/test_order_sensitivity.py +284 -0
- diffnc-0.0.1/tests/test_reconcile.py +281 -0
- diffnc-0.0.1/uv.lock +309 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Build distributions
|
|
25
|
+
run: uv build
|
|
26
|
+
|
|
27
|
+
- name: Upload build artifacts
|
|
28
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
needs: build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment:
|
|
37
|
+
name: pypi
|
|
38
|
+
url: https://pypi.org/p/diffnc
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write
|
|
41
|
+
steps:
|
|
42
|
+
- name: Download build artifacts
|
|
43
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
|
|
48
|
+
- name: Publish to PyPI
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags-ignore:
|
|
6
|
+
- "v*"
|
|
7
|
+
branches:
|
|
8
|
+
- "*"
|
|
9
|
+
pull_request:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
27
|
+
with:
|
|
28
|
+
python-version: ${{ matrix.python-version }}
|
|
29
|
+
enable-cache: true
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: uv sync
|
|
33
|
+
|
|
34
|
+
- name: Format check with ruff
|
|
35
|
+
run: uv run ruff format --check .
|
|
36
|
+
|
|
37
|
+
- name: Lint with ruff
|
|
38
|
+
run: uv run ruff check .
|
|
39
|
+
|
|
40
|
+
- name: Type check with ty
|
|
41
|
+
run: uv run ty check
|
|
42
|
+
|
|
43
|
+
- name: Run pytest
|
|
44
|
+
run: uv run pytest
|
diffnc-0.0.1/.gitignore
ADDED
diffnc-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 minefuto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
diffnc-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diffnc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Structural diff library for network device configurations
|
|
5
|
+
Author-email: minefuto <46558834+minefuto@users.noreply.github.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 minefuto
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Classifier: Programming Language :: Python :: 3
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
33
|
+
Requires-Python: >=3.11
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# diffnc(DIFF for Network device Configurations)
|
|
37
|
+
|
|
38
|
+
A Python library and CLI that diffs network device configurations **with structural awareness**, exposed through a `difflib`-like API.
|
|
39
|
+
|
|
40
|
+
* Duplicate same-name blocks (e.g. `interface eth1` appearing more than once) are merged at parse time
|
|
41
|
+
* **Only sections where order carries meaning emit order diffs** (Junos `firewall filter` / `policy-statement` terms, Cisco `access-list` / `policy-map`, etc.). Everywhere else, reordering alone produces no diff
|
|
42
|
+
* Vendor is auto-detected. Diffing across vendors raises an error
|
|
43
|
+
* Supported vendors: **Cisco NX-OS**, **Cisco IOS**, **Cisco IOS-XE**, **Cisco IOS-XR**, **Arista EOS**, **Junos** (hierarchical), **Junos set** (`display set` format)
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install diffnc
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For development:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv sync
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Library usage
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from diffnc import unified_diff, ndiff
|
|
61
|
+
|
|
62
|
+
with open("router-before.conf") as f:
|
|
63
|
+
a = f.read()
|
|
64
|
+
with open("router-after.conf") as f:
|
|
65
|
+
b = f.read()
|
|
66
|
+
|
|
67
|
+
# Structural unified diff (shows changed lines and their parent sections only)
|
|
68
|
+
for line in unified_diff(a, b, fromfile="before", tofile="after"):
|
|
69
|
+
print(line, end="")
|
|
70
|
+
|
|
71
|
+
# Full ndiff
|
|
72
|
+
for line in ndiff(a, b):
|
|
73
|
+
print(line, end="")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To force a specific vendor:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
unified_diff(a, b, vendor="junos_set")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To only run detection:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from diffnc import detect_vendor
|
|
86
|
+
detect_vendor(open("config.conf").read()) # -> "nxos"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `reconcile` (experimental)
|
|
90
|
+
|
|
91
|
+
> **Experimental.** The output shape and exact command sequences may change in future releases. Always review the generated commands before applying them to a live device.
|
|
92
|
+
|
|
93
|
+
`reconcile(a, b)` returns the bare config-mode command lines that, when entered on a device currently running config *A*, bring it to the state described by config *B*.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from diffnc import reconcile
|
|
97
|
+
|
|
98
|
+
for line in reconcile(a, b):
|
|
99
|
+
print(line)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Output is config-mode commands only — no `configure terminal` / `end` / `commit` wrappers, no indentation. Pipe through your own session manager.
|
|
103
|
+
|
|
104
|
+
* **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).
|
|
105
|
+
* **Junos hierarchical:** emits flat `set <path>` and `delete <path>` lines.
|
|
106
|
+
* **Junos set:** emits `<line>` verbatim for adds and `delete <path>` (with the `set ` / `activate ` / `deactivate ` prefix stripped) for deletes.
|
|
107
|
+
* **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.
|
|
108
|
+
|
|
109
|
+
Exceptions:
|
|
110
|
+
|
|
111
|
+
| Exception | When it is raised |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `VendorMismatchError` | The two configs are detected as different vendors (e.g. Junos set vs. Junos hierarchical is also rejected here) |
|
|
114
|
+
| `ParseError` | Vendor detection failed, syntax error, etc. |
|
|
115
|
+
|
|
116
|
+
## CLI
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
diffnc [OPTIONS] FILE_A FILE_B
|
|
120
|
+
|
|
121
|
+
-u, --unified Structural unified diff (default)
|
|
122
|
+
-n, --ndiff Full ndiff output
|
|
123
|
+
-r, --reconcile Emit config-mode commands that transform FILE_A into FILE_B (experimental)
|
|
124
|
+
--vendor {junos,junos_set,nxos,ios,iosxe,iosxr,eos}
|
|
125
|
+
Skip auto-detection and use the given vendor
|
|
126
|
+
--color {auto,always,never}
|
|
127
|
+
Colorize +/- lines (auto = tty detection)
|
|
128
|
+
--version
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Exit codes follow `diff(1)`: `0` = no differences, `1` = differences found, `2` = error.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
$ diffnc before.conf after.conf
|
|
137
|
+
--- before.conf
|
|
138
|
+
+++ after.conf
|
|
139
|
+
+feature ospf
|
|
140
|
+
interface Ethernet1/1
|
|
141
|
+
- description uplink
|
|
142
|
+
+ description uplink-to-spine
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Or, in reconcile mode (**experimental**):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
$ diffnc before.conf after.conf -r
|
|
149
|
+
interface Ethernet1/1
|
|
150
|
+
no description uplink
|
|
151
|
+
description uplink-to-spine
|
|
152
|
+
feature ospf
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Example: normalizing duplicate blocks
|
|
156
|
+
|
|
157
|
+
Input A:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
interface eth1
|
|
161
|
+
no shut
|
|
162
|
+
ip address 1.1.1.1/24
|
|
163
|
+
stp
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Input B (the same `interface eth1` appears twice):
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
interface eth1
|
|
170
|
+
shut
|
|
171
|
+
ip address 1.1.1.1/24
|
|
172
|
+
|
|
173
|
+
interface eth1
|
|
174
|
+
stp
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`ndiff` output:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
interface eth1
|
|
181
|
+
- no shut
|
|
182
|
+
+ shut
|
|
183
|
+
ip address 1.1.1.1/24
|
|
184
|
+
stp
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## How order is handled
|
|
188
|
+
|
|
189
|
+
Network device configurations mix "sections whose semantics don't depend on order" with "sections where order determines behavior." diffnc diffs **order-insensitively by default** and only does **position-based comparison for parent paths where order carries meaning**.
|
|
190
|
+
|
|
191
|
+
### Order-insensitive (reorder ≠ diff)
|
|
192
|
+
|
|
193
|
+
Most containers fall into this bucket. Examples: `system`, `interfaces`, `routing-options`, `vrf context`, top-level `interface ...`, `route-map FOO permit <seq>`, and so on. Reshuffling the children alone produces an empty diff.
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
# A
|
|
197
|
+
system {
|
|
198
|
+
host-name foo;
|
|
199
|
+
domain-name example.com;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# B
|
|
203
|
+
system {
|
|
204
|
+
domain-name example.com;
|
|
205
|
+
host-name foo;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
$ diffnc a.conf b.conf # → no diff, exit 0
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Order-sensitive (reorder = diff)
|
|
212
|
+
|
|
213
|
+
The paths below are evaluated in declaration order by the device, so swapping term/ACE/class order produces diff output.
|
|
214
|
+
|
|
215
|
+
| Vendor | Parent path | Children |
|
|
216
|
+
| --- | --- | --- |
|
|
217
|
+
| Junos | `firewall.filter <name>` | `term <name>` |
|
|
218
|
+
| Junos | `firewall.family <fam>.filter <name>` | `term <name>` |
|
|
219
|
+
| Junos | `policy-options.policy-statement <name>` | `term <name>` |
|
|
220
|
+
| Cisco-like (IOS / IOS-XE / IOS-XR / NX-OS / EOS) | `ip access-list <name>`, `ipv6 access-list <name>`, `mac access-list <name>` | ACE lines |
|
|
221
|
+
| Cisco-like (same as above) | `policy-map <name>` | `class <name>` blocks |
|
|
222
|
+
|
|
223
|
+
Pure reorders (children whose rendered subtree is byte-identical on both sides, just in a different position) are surfaced with a `!` marker, once per moved subtree. Children whose contents also changed continue to use `-` / `+` pairs.
|
|
224
|
+
|
|
225
|
+
Example: swapping two byte-identical terms inside a Junos firewall filter
|
|
226
|
+
|
|
227
|
+
```diff
|
|
228
|
+
firewall {
|
|
229
|
+
filter F {
|
|
230
|
+
! term B {
|
|
231
|
+
! then discard;
|
|
232
|
+
! }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Example: a reorder of one term plus a content change in another term
|
|
238
|
+
|
|
239
|
+
```diff
|
|
240
|
+
firewall {
|
|
241
|
+
filter F {
|
|
242
|
+
! term A {
|
|
243
|
+
! then accept;
|
|
244
|
+
! }
|
|
245
|
+
term B {
|
|
246
|
+
- then discard;
|
|
247
|
+
+ then reject;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Customizing the behavior for a new vendor
|
|
254
|
+
|
|
255
|
+
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(...)`.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
uv sync --extra dev
|
|
261
|
+
uv run pytest # tests
|
|
262
|
+
uv run ruff check . # lint
|
|
263
|
+
uv run ruff format . # format
|
|
264
|
+
uv run ty check # type check
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Adding a new vendor
|
|
268
|
+
|
|
269
|
+
Create a new module under `src/diffnc/vendors/`, expose an implementation of the `VendorParser` protocol (`src/diffnc/vendors/base.py`) as `PARSER`, call `register(_yourvendor.PARSER)` from `src/diffnc/vendors/__init__.py`, and add the corresponding case to the detection logic in `src/diffnc/detect.py`.
|
|
270
|
+
|
|
271
|
+
`VendorParser` requires the following methods:
|
|
272
|
+
|
|
273
|
+
* `parse(text) -> ConfigTree`
|
|
274
|
+
* `format(tree) -> list[str]`
|
|
275
|
+
* `render_open(node, depth) -> str`
|
|
276
|
+
* `render_close(node, depth) -> str | None`
|
|
277
|
+
* `render_leaf(node, depth) -> str`
|
|
278
|
+
* `is_order_sensitive(path) -> bool` (optional; treated as always `False` if not implemented. See the "How order is handled" section.)
|
|
279
|
+
* `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.)
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
MIT
|
diffnc-0.0.1/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# diffnc(DIFF for Network device Configurations)
|
|
2
|
+
|
|
3
|
+
A Python library and CLI that diffs network device configurations **with structural awareness**, exposed through a `difflib`-like API.
|
|
4
|
+
|
|
5
|
+
* Duplicate same-name blocks (e.g. `interface eth1` appearing more than once) are merged at parse time
|
|
6
|
+
* **Only sections where order carries meaning emit order diffs** (Junos `firewall filter` / `policy-statement` terms, Cisco `access-list` / `policy-map`, etc.). Everywhere else, reordering alone produces no diff
|
|
7
|
+
* Vendor is auto-detected. Diffing across vendors raises an error
|
|
8
|
+
* Supported vendors: **Cisco NX-OS**, **Cisco IOS**, **Cisco IOS-XE**, **Cisco IOS-XR**, **Arista EOS**, **Junos** (hierarchical), **Junos set** (`display set` format)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install diffnc
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For development:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv sync
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Library usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from diffnc import unified_diff, ndiff
|
|
26
|
+
|
|
27
|
+
with open("router-before.conf") as f:
|
|
28
|
+
a = f.read()
|
|
29
|
+
with open("router-after.conf") as f:
|
|
30
|
+
b = f.read()
|
|
31
|
+
|
|
32
|
+
# Structural unified diff (shows changed lines and their parent sections only)
|
|
33
|
+
for line in unified_diff(a, b, fromfile="before", tofile="after"):
|
|
34
|
+
print(line, end="")
|
|
35
|
+
|
|
36
|
+
# Full ndiff
|
|
37
|
+
for line in ndiff(a, b):
|
|
38
|
+
print(line, end="")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To force a specific vendor:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
unified_diff(a, b, vendor="junos_set")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To only run detection:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from diffnc import detect_vendor
|
|
51
|
+
detect_vendor(open("config.conf").read()) # -> "nxos"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `reconcile` (experimental)
|
|
55
|
+
|
|
56
|
+
> **Experimental.** The output shape and exact command sequences may change in future releases. Always review the generated commands before applying them to a live device.
|
|
57
|
+
|
|
58
|
+
`reconcile(a, b)` returns the bare config-mode command lines that, when entered on a device currently running config *A*, bring it to the state described by config *B*.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from diffnc import reconcile
|
|
62
|
+
|
|
63
|
+
for line in reconcile(a, b):
|
|
64
|
+
print(line)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Output is config-mode commands only — no `configure terminal` / `end` / `commit` wrappers, no indentation. Pipe through your own session manager.
|
|
68
|
+
|
|
69
|
+
* **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).
|
|
70
|
+
* **Junos hierarchical:** emits flat `set <path>` and `delete <path>` lines.
|
|
71
|
+
* **Junos set:** emits `<line>` verbatim for adds and `delete <path>` (with the `set ` / `activate ` / `deactivate ` prefix stripped) for deletes.
|
|
72
|
+
* **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.
|
|
73
|
+
|
|
74
|
+
Exceptions:
|
|
75
|
+
|
|
76
|
+
| Exception | When it is raised |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `VendorMismatchError` | The two configs are detected as different vendors (e.g. Junos set vs. Junos hierarchical is also rejected here) |
|
|
79
|
+
| `ParseError` | Vendor detection failed, syntax error, etc. |
|
|
80
|
+
|
|
81
|
+
## CLI
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
diffnc [OPTIONS] FILE_A FILE_B
|
|
85
|
+
|
|
86
|
+
-u, --unified Structural unified diff (default)
|
|
87
|
+
-n, --ndiff Full ndiff output
|
|
88
|
+
-r, --reconcile Emit config-mode commands that transform FILE_A into FILE_B (experimental)
|
|
89
|
+
--vendor {junos,junos_set,nxos,ios,iosxe,iosxr,eos}
|
|
90
|
+
Skip auto-detection and use the given vendor
|
|
91
|
+
--color {auto,always,never}
|
|
92
|
+
Colorize +/- lines (auto = tty detection)
|
|
93
|
+
--version
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Exit codes follow `diff(1)`: `0` = no differences, `1` = differences found, `2` = error.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
$ diffnc before.conf after.conf
|
|
102
|
+
--- before.conf
|
|
103
|
+
+++ after.conf
|
|
104
|
+
+feature ospf
|
|
105
|
+
interface Ethernet1/1
|
|
106
|
+
- description uplink
|
|
107
|
+
+ description uplink-to-spine
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or, in reconcile mode (**experimental**):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
$ diffnc before.conf after.conf -r
|
|
114
|
+
interface Ethernet1/1
|
|
115
|
+
no description uplink
|
|
116
|
+
description uplink-to-spine
|
|
117
|
+
feature ospf
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Example: normalizing duplicate blocks
|
|
121
|
+
|
|
122
|
+
Input A:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
interface eth1
|
|
126
|
+
no shut
|
|
127
|
+
ip address 1.1.1.1/24
|
|
128
|
+
stp
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Input B (the same `interface eth1` appears twice):
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
interface eth1
|
|
135
|
+
shut
|
|
136
|
+
ip address 1.1.1.1/24
|
|
137
|
+
|
|
138
|
+
interface eth1
|
|
139
|
+
stp
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`ndiff` output:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
interface eth1
|
|
146
|
+
- no shut
|
|
147
|
+
+ shut
|
|
148
|
+
ip address 1.1.1.1/24
|
|
149
|
+
stp
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## How order is handled
|
|
153
|
+
|
|
154
|
+
Network device configurations mix "sections whose semantics don't depend on order" with "sections where order determines behavior." diffnc diffs **order-insensitively by default** and only does **position-based comparison for parent paths where order carries meaning**.
|
|
155
|
+
|
|
156
|
+
### Order-insensitive (reorder ≠ diff)
|
|
157
|
+
|
|
158
|
+
Most containers fall into this bucket. Examples: `system`, `interfaces`, `routing-options`, `vrf context`, top-level `interface ...`, `route-map FOO permit <seq>`, and so on. Reshuffling the children alone produces an empty diff.
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
# A
|
|
162
|
+
system {
|
|
163
|
+
host-name foo;
|
|
164
|
+
domain-name example.com;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# B
|
|
168
|
+
system {
|
|
169
|
+
domain-name example.com;
|
|
170
|
+
host-name foo;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
$ diffnc a.conf b.conf # → no diff, exit 0
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Order-sensitive (reorder = diff)
|
|
177
|
+
|
|
178
|
+
The paths below are evaluated in declaration order by the device, so swapping term/ACE/class order produces diff output.
|
|
179
|
+
|
|
180
|
+
| Vendor | Parent path | Children |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| Junos | `firewall.filter <name>` | `term <name>` |
|
|
183
|
+
| Junos | `firewall.family <fam>.filter <name>` | `term <name>` |
|
|
184
|
+
| Junos | `policy-options.policy-statement <name>` | `term <name>` |
|
|
185
|
+
| Cisco-like (IOS / IOS-XE / IOS-XR / NX-OS / EOS) | `ip access-list <name>`, `ipv6 access-list <name>`, `mac access-list <name>` | ACE lines |
|
|
186
|
+
| Cisco-like (same as above) | `policy-map <name>` | `class <name>` blocks |
|
|
187
|
+
|
|
188
|
+
Pure reorders (children whose rendered subtree is byte-identical on both sides, just in a different position) are surfaced with a `!` marker, once per moved subtree. Children whose contents also changed continue to use `-` / `+` pairs.
|
|
189
|
+
|
|
190
|
+
Example: swapping two byte-identical terms inside a Junos firewall filter
|
|
191
|
+
|
|
192
|
+
```diff
|
|
193
|
+
firewall {
|
|
194
|
+
filter F {
|
|
195
|
+
! term B {
|
|
196
|
+
! then discard;
|
|
197
|
+
! }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Example: a reorder of one term plus a content change in another term
|
|
203
|
+
|
|
204
|
+
```diff
|
|
205
|
+
firewall {
|
|
206
|
+
filter F {
|
|
207
|
+
! term A {
|
|
208
|
+
! then accept;
|
|
209
|
+
! }
|
|
210
|
+
term B {
|
|
211
|
+
- then discard;
|
|
212
|
+
+ then reject;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Customizing the behavior for a new vendor
|
|
219
|
+
|
|
220
|
+
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(...)`.
|
|
221
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
uv sync --extra dev
|
|
226
|
+
uv run pytest # tests
|
|
227
|
+
uv run ruff check . # lint
|
|
228
|
+
uv run ruff format . # format
|
|
229
|
+
uv run ty check # type check
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Adding a new vendor
|
|
233
|
+
|
|
234
|
+
Create a new module under `src/diffnc/vendors/`, expose an implementation of the `VendorParser` protocol (`src/diffnc/vendors/base.py`) as `PARSER`, call `register(_yourvendor.PARSER)` from `src/diffnc/vendors/__init__.py`, and add the corresponding case to the detection logic in `src/diffnc/detect.py`.
|
|
235
|
+
|
|
236
|
+
`VendorParser` requires the following methods:
|
|
237
|
+
|
|
238
|
+
* `parse(text) -> ConfigTree`
|
|
239
|
+
* `format(tree) -> list[str]`
|
|
240
|
+
* `render_open(node, depth) -> str`
|
|
241
|
+
* `render_close(node, depth) -> str | None`
|
|
242
|
+
* `render_leaf(node, depth) -> str`
|
|
243
|
+
* `is_order_sensitive(path) -> bool` (optional; treated as always `False` if not implemented. See the "How order is handled" section.)
|
|
244
|
+
* `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.)
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|