diffnc 0.0.1__py3-none-any.whl
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/__init__.py +37 -0
- diffnc/__main__.py +8 -0
- diffnc/cli.py +140 -0
- diffnc/detect.py +170 -0
- diffnc/diff.py +353 -0
- diffnc/errors.py +22 -0
- diffnc/ir.py +58 -0
- diffnc/py.typed +0 -0
- diffnc/reconcile.py +132 -0
- diffnc/vendors/__init__.py +58 -0
- diffnc/vendors/_cisco_like.py +203 -0
- diffnc/vendors/base.py +88 -0
- diffnc/vendors/eos.py +19 -0
- diffnc/vendors/ios.py +19 -0
- diffnc/vendors/iosxe.py +20 -0
- diffnc/vendors/iosxr.py +19 -0
- diffnc/vendors/junos.py +175 -0
- diffnc/vendors/junos_set.py +158 -0
- diffnc/vendors/nxos.py +22 -0
- diffnc-0.0.1.dist-info/METADATA +283 -0
- diffnc-0.0.1.dist-info/RECORD +24 -0
- diffnc-0.0.1.dist-info/WHEEL +4 -0
- diffnc-0.0.1.dist-info/entry_points.txt +2 -0
- diffnc-0.0.1.dist-info/licenses/LICENSE +21 -0
diffnc/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Structural diff for network device configurations.
|
|
2
|
+
|
|
3
|
+
Public API mirrors :mod:`difflib`:
|
|
4
|
+
|
|
5
|
+
* :func:`unified_diff` — compact, change-focused diff with section context.
|
|
6
|
+
* :func:`ndiff` — every-line diff with ``- ``/``+ ``/`` `` markers.
|
|
7
|
+
* :func:`reconcile` — config-mode commands that transform *A* into *B*.
|
|
8
|
+
* :func:`detect_vendor` — auto-detect the vendor for a config blob.
|
|
9
|
+
|
|
10
|
+
Errors are exposed via :class:`DiffncError` and its subclasses.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
|
|
17
|
+
from diffnc.detect import detect_vendor
|
|
18
|
+
from diffnc.diff import ndiff, unified_diff
|
|
19
|
+
from diffnc.errors import (
|
|
20
|
+
DiffncError,
|
|
21
|
+
ParseError,
|
|
22
|
+
VendorMismatchError,
|
|
23
|
+
)
|
|
24
|
+
from diffnc.reconcile import reconcile
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DiffncError",
|
|
28
|
+
"ParseError",
|
|
29
|
+
"VendorMismatchError",
|
|
30
|
+
"__version__",
|
|
31
|
+
"detect_vendor",
|
|
32
|
+
"ndiff",
|
|
33
|
+
"reconcile",
|
|
34
|
+
"unified_diff",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = version("diffnc")
|
diffnc/__main__.py
ADDED
diffnc/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Command-line interface — ``diffnc FILE_A FILE_B``.
|
|
2
|
+
|
|
3
|
+
Exit codes follow :manpage:`diff(1)`:
|
|
4
|
+
|
|
5
|
+
* ``0`` — inputs are equivalent (no diff output).
|
|
6
|
+
* ``1`` — inputs differ.
|
|
7
|
+
* ``2`` — an error occurred (parse failure, vendor mismatch, missing file, ...).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from diffnc import __version__
|
|
18
|
+
from diffnc.diff import ndiff, unified_diff
|
|
19
|
+
from diffnc.errors import DiffncError
|
|
20
|
+
from diffnc.reconcile import reconcile
|
|
21
|
+
|
|
22
|
+
_GREEN = "\x1b[32m"
|
|
23
|
+
_RED = "\x1b[31m"
|
|
24
|
+
_YELLOW = "\x1b[33m"
|
|
25
|
+
_RESET = "\x1b[0m"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
parser = _build_argument_parser()
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
text_a = _read_file(args.file_a)
|
|
34
|
+
text_b = _read_file(args.file_b)
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
print(f"diffnc: {exc}", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if args.mode == "ndiff":
|
|
41
|
+
lines: Iterable[str] = ndiff(text_a, text_b, lineterm="\n", vendor=args.vendor)
|
|
42
|
+
elif args.mode == "reconcile":
|
|
43
|
+
lines = (f"{line}\n" for line in reconcile(text_a, text_b, vendor=args.vendor))
|
|
44
|
+
else:
|
|
45
|
+
lines = unified_diff(
|
|
46
|
+
text_a,
|
|
47
|
+
text_b,
|
|
48
|
+
fromfile=str(args.file_a),
|
|
49
|
+
tofile=str(args.file_b),
|
|
50
|
+
lineterm="\n",
|
|
51
|
+
vendor=args.vendor,
|
|
52
|
+
)
|
|
53
|
+
materialised = list(lines)
|
|
54
|
+
except DiffncError as exc:
|
|
55
|
+
print(f"diffnc: {exc}", file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
|
|
58
|
+
if not materialised:
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
use_color = _should_colorize(args.color)
|
|
62
|
+
for line in materialised:
|
|
63
|
+
sys.stdout.write(_colorize(line, use_color))
|
|
64
|
+
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
69
|
+
parser = argparse.ArgumentParser(
|
|
70
|
+
prog="diffnc",
|
|
71
|
+
description="Structural diff for network device configurations.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("file_a", type=Path, help="left-hand configuration file")
|
|
74
|
+
parser.add_argument("file_b", type=Path, help="right-hand configuration file")
|
|
75
|
+
|
|
76
|
+
mode_group = parser.add_mutually_exclusive_group()
|
|
77
|
+
mode_group.add_argument(
|
|
78
|
+
"-u",
|
|
79
|
+
"--unified",
|
|
80
|
+
dest="mode",
|
|
81
|
+
action="store_const",
|
|
82
|
+
const="unified",
|
|
83
|
+
help="emit a compact, structure-aware unified diff (default)",
|
|
84
|
+
)
|
|
85
|
+
mode_group.add_argument(
|
|
86
|
+
"-n",
|
|
87
|
+
"--ndiff",
|
|
88
|
+
dest="mode",
|
|
89
|
+
action="store_const",
|
|
90
|
+
const="ndiff",
|
|
91
|
+
help="emit a verbose diff showing every line",
|
|
92
|
+
)
|
|
93
|
+
mode_group.add_argument(
|
|
94
|
+
"-r",
|
|
95
|
+
"--reconcile",
|
|
96
|
+
dest="mode",
|
|
97
|
+
action="store_const",
|
|
98
|
+
const="reconcile",
|
|
99
|
+
help="emit config-mode commands that transform FILE_A into FILE_B",
|
|
100
|
+
)
|
|
101
|
+
parser.set_defaults(mode="unified")
|
|
102
|
+
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--vendor",
|
|
105
|
+
choices=["junos", "junos_set", "nxos", "ios", "iosxe", "iosxr", "eos"],
|
|
106
|
+
default=None,
|
|
107
|
+
help="skip auto-detection and force a vendor",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--color",
|
|
111
|
+
choices=["auto", "always", "never"],
|
|
112
|
+
default="auto",
|
|
113
|
+
help="colorise +/- lines (default: auto, based on terminal)",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
116
|
+
return parser
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_file(path: Path) -> str:
|
|
120
|
+
return path.read_text(encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _should_colorize(mode: str) -> bool:
|
|
124
|
+
if mode == "always":
|
|
125
|
+
return True
|
|
126
|
+
if mode == "never":
|
|
127
|
+
return False
|
|
128
|
+
return sys.stdout.isatty()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _colorize(line: str, use_color: bool) -> str:
|
|
132
|
+
if not use_color:
|
|
133
|
+
return line
|
|
134
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
135
|
+
return f"{_GREEN}{line}{_RESET}"
|
|
136
|
+
if line.startswith("-") and not line.startswith("---"):
|
|
137
|
+
return f"{_RED}{line}{_RESET}"
|
|
138
|
+
if line.startswith("!"):
|
|
139
|
+
return f"{_YELLOW}{line}{_RESET}"
|
|
140
|
+
return line
|
diffnc/detect.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Vendor auto-detection.
|
|
2
|
+
|
|
3
|
+
The detector inspects a configuration's signal-bearing lines and returns the vendor name.
|
|
4
|
+
Heuristics are intentionally conservative: when in doubt, raise :class:`ParseError` so the
|
|
5
|
+
caller can ask the user to disambiguate via ``--vendor``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from diffnc.errors import ParseError
|
|
13
|
+
|
|
14
|
+
_JUNOS_TOP_KEYWORDS = (
|
|
15
|
+
"interfaces",
|
|
16
|
+
"system",
|
|
17
|
+
"routing-options",
|
|
18
|
+
"routing-instances",
|
|
19
|
+
"protocols",
|
|
20
|
+
"policy-options",
|
|
21
|
+
"firewall",
|
|
22
|
+
"security",
|
|
23
|
+
"groups",
|
|
24
|
+
"chassis",
|
|
25
|
+
"snmp",
|
|
26
|
+
"services",
|
|
27
|
+
"applications",
|
|
28
|
+
"forwarding-options",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_NXOS_INTERFACE_RE = re.compile(r"^interface Ethernet\d+/\d+")
|
|
32
|
+
|
|
33
|
+
_IOS_INTERFACE_PREFIXES = (
|
|
34
|
+
"interface FastEthernet",
|
|
35
|
+
"interface GigabitEthernet",
|
|
36
|
+
"interface TenGigabitEthernet",
|
|
37
|
+
"interface Serial",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Cisco "speed-named" interface prefixes shared by IOS / IOS-XE / IOS-XR. The classic
|
|
41
|
+
# bare ``Ethernet`` prefix is excluded because it belongs to NX-OS and Arista EOS.
|
|
42
|
+
_CISCO_NAMED_IFACES = (
|
|
43
|
+
"FastEthernet",
|
|
44
|
+
"GigabitEthernet",
|
|
45
|
+
"TenGigabitEthernet",
|
|
46
|
+
"TenGigE",
|
|
47
|
+
"TwentyFiveGigE",
|
|
48
|
+
"FortyGigE",
|
|
49
|
+
"HundredGigE",
|
|
50
|
+
)
|
|
51
|
+
_CISCO_NAMED_GROUP = "|".join(_CISCO_NAMED_IFACES)
|
|
52
|
+
|
|
53
|
+
# IOS-XE distinguishes itself with a 3-tuple slot/subslot/port (e.g.
|
|
54
|
+
# ``GigabitEthernet0/0/0``) — distinct from classic IOS's 2-tuple form.
|
|
55
|
+
_IOSXE_INTERFACE_RE = re.compile(rf"^interface (?:{_CISCO_NAMED_GROUP})\d+/\d+/\d+(?:\.\d+)?$")
|
|
56
|
+
|
|
57
|
+
# IOS-XR uses a 4-tuple slot/subslot/instance/port for physical interfaces.
|
|
58
|
+
_IOSXR_INTERFACE_RE = re.compile(rf"^interface (?:{_CISCO_NAMED_GROUP})\d+/\d+/\d+/\d+")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_nxos_marker(lines: list[str]) -> bool:
|
|
62
|
+
for s in lines:
|
|
63
|
+
if s.startswith(("feature ", "vrf context ", "vdc ")):
|
|
64
|
+
return True
|
|
65
|
+
if _NXOS_INTERFACE_RE.match(s):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _has_ios_marker(lines: list[str]) -> bool:
|
|
71
|
+
for s in lines:
|
|
72
|
+
if s.startswith(("service password-encryption", "service timestamps")):
|
|
73
|
+
return True
|
|
74
|
+
if s in ("boot-start-marker", "boot-end-marker"):
|
|
75
|
+
return True
|
|
76
|
+
if s.startswith(_IOS_INTERFACE_PREFIXES):
|
|
77
|
+
return True
|
|
78
|
+
if s.startswith(("line con ", "line vty ")):
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _has_iosxe_marker(lines: list[str]) -> bool:
|
|
84
|
+
for s in lines:
|
|
85
|
+
if s.startswith(("platform ", "license boot level")):
|
|
86
|
+
return True
|
|
87
|
+
if s.startswith(("boot system bootflash:", "boot system flash")):
|
|
88
|
+
return True
|
|
89
|
+
if _IOSXE_INTERFACE_RE.match(s):
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _has_iosxr_marker(lines: list[str]) -> bool:
|
|
95
|
+
for s in lines:
|
|
96
|
+
if s.startswith(("interface Bundle-Ether", "interface MgmtEth")):
|
|
97
|
+
return True
|
|
98
|
+
if s.startswith("RP/0/"):
|
|
99
|
+
return True
|
|
100
|
+
if _IOSXR_INTERFACE_RE.match(s):
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _has_eos_marker(lines: list[str]) -> bool:
|
|
106
|
+
for s in lines:
|
|
107
|
+
if s.startswith(
|
|
108
|
+
(
|
|
109
|
+
"vrf instance ",
|
|
110
|
+
"daemon TerminAttr",
|
|
111
|
+
"management api http-commands",
|
|
112
|
+
"service routing protocols model",
|
|
113
|
+
"transceiver qsfp default-mode",
|
|
114
|
+
)
|
|
115
|
+
):
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_vendor(text: str) -> str:
|
|
121
|
+
"""Return the vendor name for *text*.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ParseError: if no supported vendor can be confidently identified.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
significant: list[str] = []
|
|
128
|
+
for raw in text.splitlines():
|
|
129
|
+
s = raw.strip()
|
|
130
|
+
if not s or s.startswith("!") or s.startswith("#") or s.startswith("/*"):
|
|
131
|
+
continue
|
|
132
|
+
significant.append(s)
|
|
133
|
+
|
|
134
|
+
if not significant:
|
|
135
|
+
raise ParseError("configuration is empty or comment-only")
|
|
136
|
+
|
|
137
|
+
# Junos set form: most lines start with `set ` (or `deactivate `/`activate `/`delete `).
|
|
138
|
+
set_like = sum(
|
|
139
|
+
1 for s in significant if s.startswith(("set ", "deactivate ", "activate ", "delete "))
|
|
140
|
+
)
|
|
141
|
+
if set_like / len(significant) >= 0.8:
|
|
142
|
+
return "junos_set"
|
|
143
|
+
|
|
144
|
+
# Junos hierarchical: presence of braces and at least one Junos top-level keyword.
|
|
145
|
+
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
|
+
)
|
|
151
|
+
if has_brace and has_junos_kw:
|
|
152
|
+
return "junos"
|
|
153
|
+
|
|
154
|
+
# Brace-less indent-based vendors: NX-OS, IOS, IOS-XE, IOS-XR, EOS. Checks are
|
|
155
|
+
# ordered by marker specificity so a mixed config (e.g. EOS with copied IOS-style
|
|
156
|
+
# lines) stays on the more specific vendor.
|
|
157
|
+
if not has_brace:
|
|
158
|
+
if _has_eos_marker(significant):
|
|
159
|
+
return "eos"
|
|
160
|
+
if _has_iosxr_marker(significant):
|
|
161
|
+
return "iosxr"
|
|
162
|
+
if _has_nxos_marker(significant):
|
|
163
|
+
return "nxos"
|
|
164
|
+
if _has_iosxe_marker(significant):
|
|
165
|
+
return "iosxe"
|
|
166
|
+
if _has_ios_marker(significant):
|
|
167
|
+
return "ios"
|
|
168
|
+
return "nxos"
|
|
169
|
+
|
|
170
|
+
raise ParseError("could not detect vendor from configuration text")
|