A Python library and CLI that diffs network device configurations with structural awareness, exposed through a difflib-like API.
- Duplicate same-name blocks (e.g.
interface eth1appearing more than once) are merged at parse time - Only sections where order carries meaning emit order diffs (Junos
firewall filter/policy-statementterms, Ciscoaccess-list/policy-map, etc.). Everywhere else, reordering alone produces no diff - Vendor is auto-detected. Diffing across vendors raises an error
- Supported vendors: Cisco NX-OS, Cisco IOS, Cisco IOS-XE, Cisco IOS-XR, Arista EOS, Junos (hierarchical), Junos set (
display setformat)
pip install diffncFor development:
uv syncfrom diffnc import unified_diff, ndiff
with open("router-before.conf") as f:
a = f.read()
with open("router-after.conf") as f:
b = f.read()
# Structural unified diff (shows changed lines and their parent sections only)
for line in unified_diff(a, b, fromfile="before", tofile="after"):
print(line, end="")
# Full ndiff
for line in ndiff(a, b):
print(line, end="")To force a specific vendor:
unified_diff(a, b, vendor="junos_set")To only run detection:
from diffnc import detect_vendor
detect_vendor(open("config.conf").read()) # -> "nxos"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.
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.
from diffnc import reconcile
for line in reconcile(a, b):
print(line)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.
- Cisco-like (NX-OS / IOS / IOS-XE / IOS-XR / EOS): emits section navigation plus
<line>for adds andno <line>for deletes.X/no Xtoggles 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 invertednoon IOS-XR. - Junos hierarchical: emits flat
set <path>anddelete <path>lines. A node whose only change is its inactive state (inactive: <line>) emitsactivate <path>/deactivate <path>instead of adelete/setpair. - Junos set: emits
<line>verbatim for adds anddelete <path>(with thesetprefix stripped) for deletes.activate/deactivateare tracked as state: a removed toggle inverts to its counterpart (deactivate X→activate X) rather than deleting the underlyingset. - Order-sensitive sections (ACL,
policy-map, NX-OSconfigure maintenance profile, Junosfirewall filter/policy-statementterms): on any change, the entire section is deleted and recreated from B — partial in-place edits are not attempted.
Exceptions:
| Exception | When it is raised |
|---|---|
VendorMismatchError |
The two configs are detected as different vendors (e.g. Junos set vs. Junos hierarchical is also rejected here) |
ParseError |
Vendor detection failed, syntax error, etc. |
diffnc [OPTIONS] FILE_A FILE_B
-u, --unified Structural unified diff (default)
-n, --ndiff Full ndiff output
-r, --reconcile Emit config-mode commands that transform FILE_A into FILE_B (experimental)
--vendor {junos,junos_set,nxos,ios,iosxe,iosxr,eos}
Skip auto-detection and use the given vendor
--color {auto,always,never}
Colorize +/- lines (auto = tty detection)
--version
Exit codes follow diff(1): 0 = no differences, 1 = differences found, 2 = error.
Example:
$ diffnc before.conf after.conf
--- before.conf
+++ after.conf
+feature ospf
interface Ethernet1/1
- description uplink
+ description uplink-to-spineOr, in reconcile mode (experimental):
$ diffnc before.conf after.conf -r
interface Ethernet1/1
description uplink-to-spine
feature ospfInput A:
interface eth1
no shut
ip address 1.1.1.1/24
stp
Input B (the same interface eth1 appears twice):
interface eth1
shut
ip address 1.1.1.1/24
interface eth1
stp
ndiff output:
interface eth1
- no shut
+ shut
ip address 1.1.1.1/24
stp
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.
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.
# A
system {
host-name foo;
domain-name example.com;
}
# B
system {
domain-name example.com;
host-name foo;
}
$ diffnc a.conf b.conf # → no diff, exit 0
The paths below are evaluated in declaration order by the device, so swapping term/ACE/class order produces diff output.
| Vendor | Parent path | Children |
|---|---|---|
| Junos | firewall.filter <name> |
term <name> |
| Junos | firewall.family <fam>.filter <name> |
term <name> |
| Junos | policy-options.policy-statement <name> |
term <name> |
| Cisco-like (IOS / IOS-XE / IOS-XR / NX-OS / EOS) | ip access-list <name>, ipv6 access-list <name>, mac access-list <name> |
ACE lines |
| Cisco-like (same as above) | policy-map <name> |
class <name> blocks |
| Cisco NX-OS | configure maintenance profile <normal-mode|maintenance-mode> |
GIR profile command lines |
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.
Example: swapping two byte-identical terms inside a Junos firewall filter
firewall {
filter F {
! term B {
! then discard;
! }
}
}Example: a reorder of one term plus a content change in another term
firewall {
filter F {
! term A {
! then accept;
! }
term B {
- then discard;
+ then reject;
}
}
}Within one parent section, a leaf that exists only in A and a leaf that exists only in B are displayed as an adjacent -/+ pair when they look like the same setting with a changed value, instead of clumping all deletes before all inserts:
interface Ethernet1/2
- ip address 192.168.1.1/24
+ ip address 192.168.1.2/24
- ip ospf cost 50
+ ip ospf cost 100Candidates are paired in two passes:
- Exact head match. Leaves that are identical up to their trailing token (
ip ospf cost 50→ip ospf cost 100) are paired directly. - Similarity fallback. Leftover leaves that share their leading command word are paired by
difflibsimilarity ratio (cutoff 0.4), best match first, each side used at most once. This catches changes that are not in the trailing token, e.g.description uplink→description uplink-to-spineorvlan 1→vlan 1,100,200,300. Leaves with different leading words are never paired.
Pairing only affects where lines appear in the output. The set of -/+ lines is decided purely by full-line matching; a leaf that finds no partner is still emitted, just not adjacent to a counterpart.
The similarity fallback is guarded so it cannot go quadratic on large residuals. This matters most for Junos set form, where every line starts with set and would otherwise fall into a single all-pairs comparison bucket:
- A leading-word bucket whose candidate-pair count (
A-side × B-side) exceeds a fixed cap is recursively re-split on successive tokens (forsetform this follows the configuration path) until each sub-bucket is small enough. Inside an over-cap bucket, two leaves then only pair if they also share the token prefix down to the split point. - The whole similarity phase stops after a fixed total comparison budget as a last resort.
Both limits degrade only the -/+ adjacency described above — the diff content itself never changes, and buckets below the cap behave exactly as if no limit existed. This keeps 50k-line display set diffs in well under a second.
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 !:
- A deactivated node keeps its literal
inactive:prefix. - A reactivated node has no marker on the B side, so a synthetic
activate:is shown to make the direction visible.
system {
! inactive: host-name foo;
}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:
protocols {
! inactive: ospf {
area 0.0.0.0 {
! activate: interface ge-0/0/0.0;
}
}
}In reconcile, the same toggles emit activate <path> / deactivate <path> (see the reconcile section above).
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(...).
uv sync
uv run pytest # tests
uv run ruff check . # lint
uv run ruff format . # format
uv run ty check # type checkCreate 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.
VendorParser requires the following methods:
parse(text) -> ConfigTreeformat(tree) -> list[str]render_open(node, depth) -> strrender_close(node, depth) -> str | Nonerender_leaf(node, depth) -> stris_order_sensitive(path) -> bool(optional; treated as alwaysFalseif not implemented. See the "How order is handled" section.)render_reconcile(events) -> Iterator[str](optional; required only to supportreconcile. Receives a sequence ofReconcileAdd/ReconcileDelete/ReconcileRecreate/ReconcileToggleevents fromdiffnc.reconcileand yields the corresponding CLI lines.)toggle_partners(a, b) -> bool/is_toggle_state(line) -> bool(optional; both default toFalse. Letreconcilerecognise 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.)group_sort_key(line) -> str | None(optional; defaults toNone, which disables clustering. Return a key — typically the line with its leading operation keyword stripped — to make order-insensitive children sharing that key display adjacently. Junos set form uses it so a path'sset/delete/activate/deactivateoperations cluster together.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 hierarchicalinactive:.base_identitystrips 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 callrender_toggle_lineto render the!-marked line —kindis"leaf","section_open"or"section_collapsed"— or emit aReconcileToggle.)
MIT