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 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
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m diffnc`` to dispatch to the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from diffnc.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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")