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.
@@ -0,0 +1,203 @@
1
+ """Shared parser for Cisco-style "indent + ``!`` comments" vendors.
2
+
3
+ IOS, NX-OS, IOS-XE, IOS-XR, and Arista EOS all share the same structural grammar:
4
+
5
+ * Section heads (e.g. ``interface ...``, ``router ospf 1``) introduce nested children at
6
+ deeper indent. Indentation is significant; depth is determined by the stack of seen
7
+ indents, not by a fixed unit.
8
+ * Lines starting with ``!`` or ``#`` are comments and discarded.
9
+ * Some vendors use trailing keywords like ``end`` / ``exit`` / ``commit`` / ``root`` as
10
+ configuration terminators that should be ignored during parse.
11
+ * ``shutdown`` / ``no shutdown`` form a tri-state per parent — the last toggle in input
12
+ order wins and only one of the two appears in the parent's children.
13
+ * ``default <args>`` drops siblings under the current parent whose line equals or
14
+ token-prefix-matches ``<args>``.
15
+
16
+ Vendors differ only in:
17
+
18
+ * the rendered indent width (`indent_unit`)
19
+ * which standalone keywords act as terminators (`terminators`)
20
+ * the vendor name reported by :attr:`name`
21
+
22
+ so this module exposes :class:`CiscoLikeParser` parameterised on those three knobs.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Callable, Iterable, Iterator
28
+ from dataclasses import dataclass, field
29
+ from typing import TYPE_CHECKING
30
+
31
+ from diffnc.errors import ParseError
32
+ from diffnc.ir import ConfigNode, ConfigTree
33
+ from diffnc.vendors.base import VendorParser, render_subtree
34
+
35
+ if TYPE_CHECKING:
36
+ from diffnc.reconcile import ReconcileEvent
37
+
38
+ _DEFAULT_PREFIX = "default "
39
+ _SHUT_STATES = ("shutdown", "no shutdown")
40
+
41
+
42
+ def cisco_default_order_sensitive(path: tuple[str, ...]) -> bool:
43
+ """Path-based order-sensitivity shared by IOS / IOS-XE / IOS-XR / NX-OS / EOS.
44
+
45
+ ACL entries (``ip``/``ipv6``/``mac access-list``) and ``policy-map`` class blocks
46
+ are evaluated top-to-bottom in the order they appear; everything else (interfaces,
47
+ VRFs, ``route-map FOO permit <seq>`` siblings at top level, …) is order-insensitive.
48
+ """
49
+
50
+ if not path:
51
+ return False
52
+ head = path[-1]
53
+ if head.startswith(("ip access-list ", "ipv6 access-list ", "mac access-list ")):
54
+ return True
55
+ return head.startswith("policy-map ")
56
+
57
+
58
+ @dataclass
59
+ class CiscoLikeParser:
60
+ name: str
61
+ indent_unit: int
62
+ terminators: frozenset[str] = field(default_factory=frozenset)
63
+ order_sensitive_predicate: Callable[[tuple[str, ...]], bool] = field(
64
+ default=cisco_default_order_sensitive
65
+ )
66
+
67
+ def parse(self, text: str) -> ConfigTree:
68
+ tree = ConfigTree.empty(vendor=self.name)
69
+
70
+ stack: list[tuple[int, ConfigNode]] = [(-1, tree.root)]
71
+
72
+ for lineno, raw in enumerate(text.splitlines(), start=1):
73
+ stripped = raw.strip()
74
+ if not stripped or stripped.startswith("!") or stripped.startswith("#"):
75
+ continue
76
+ if stripped in self.terminators:
77
+ continue
78
+
79
+ indent = _leading_spaces(raw)
80
+
81
+ while stack and indent <= stack[-1][0]:
82
+ stack.pop()
83
+ if not stack:
84
+ raise ParseError(f"line {lineno}: unexpected indentation in {self.name} config")
85
+
86
+ parent_node = stack[-1][1]
87
+
88
+ if stripped == "default" or stripped.startswith(_DEFAULT_PREFIX):
89
+ path = stripped[len(_DEFAULT_PREFIX) :].strip() if stripped != "default" else ""
90
+ if not path:
91
+ raise ParseError(f"line {lineno}: 'default' requires a path")
92
+ _apply_default(parent_node, path)
93
+ continue
94
+
95
+ if stripped in _SHUT_STATES:
96
+ _apply_shut_toggle(parent_node, stripped)
97
+ continue
98
+
99
+ actual = parent_node.add_child(ConfigNode(line=stripped))
100
+ stack.append((indent, actual))
101
+
102
+ return tree
103
+
104
+ def format(self, tree: ConfigTree) -> list[str]:
105
+ lines: list[str] = []
106
+ for child in tree.root.children:
107
+ lines.extend(render_subtree(self, child, depth=0))
108
+ return lines
109
+
110
+ def render_open(self, node: ConfigNode, depth: int) -> str:
111
+ return self._pad(depth) + node.line
112
+
113
+ def render_close(self, node: ConfigNode, depth: int) -> str | None:
114
+ return None
115
+
116
+ def render_leaf(self, node: ConfigNode, depth: int) -> str:
117
+ return self._pad(depth) + node.line
118
+
119
+ def is_order_sensitive(self, path: tuple[str, ...]) -> bool:
120
+ return self.order_sensitive_predicate(path)
121
+
122
+ def render_reconcile(self, events: Iterable[ReconcileEvent]) -> Iterator[str]:
123
+ from diffnc.reconcile import ReconcileAdd, ReconcileDelete, ReconcileRecreate
124
+
125
+ last_path: tuple[str, ...] | None = None
126
+ for ev in events:
127
+ if isinstance(ev, ReconcileRecreate):
128
+ parents = ev.section_path[:-1]
129
+ section_header = ev.section_path[-1]
130
+ yield from parents
131
+ yield f"no {section_header}"
132
+ yield from parents
133
+ yield section_header
134
+ for child in ev.new_node.children:
135
+ yield from _walk_lines(child)
136
+ last_path = None
137
+ continue
138
+
139
+ if ev.parent_path != last_path:
140
+ yield from ev.parent_path
141
+ last_path = ev.parent_path
142
+
143
+ if isinstance(ev, ReconcileAdd):
144
+ yield from _walk_lines(ev.node)
145
+ elif isinstance(ev, ReconcileDelete):
146
+ yield _negate(ev.node.line)
147
+
148
+ def _pad(self, depth: int) -> str:
149
+ return " " * (self.indent_unit * depth)
150
+
151
+
152
+ def _walk_lines(node: ConfigNode) -> Iterator[str]:
153
+ """Yield ``node.line`` followed by each descendant's line, pre-order."""
154
+
155
+ yield node.line
156
+ for child in node.children:
157
+ yield from _walk_lines(child)
158
+
159
+
160
+ def _negate(line: str) -> str:
161
+ """Return the negation of *line* under Cisco's ``no`` semantics.
162
+
163
+ ``"description foo"`` → ``"no description foo"``; ``"no shutdown"`` → ``"shutdown"``.
164
+ Avoids ``"no no <foo>"`` double negation.
165
+ """
166
+
167
+ if line.startswith("no "):
168
+ return line[3:]
169
+ return f"no {line}"
170
+
171
+
172
+ def _apply_shut_toggle(parent: ConfigNode, new_state: str) -> None:
173
+ """Toggle the shutdown/no-shutdown state under ``parent``.
174
+
175
+ Replaces any existing ``shutdown``/``no shutdown`` child line in place (preserving
176
+ first-occurrence position), or appends a new state node when none exists.
177
+ """
178
+
179
+ for child in parent.children:
180
+ if child.line in _SHUT_STATES:
181
+ child.line = new_state
182
+ return
183
+ parent.children.append(ConfigNode(line=new_state))
184
+
185
+
186
+ def _apply_default(parent: ConfigNode, default_path: str) -> None:
187
+ """Drop ``parent``'s children whose line equals or token-prefix-matches ``default_path``."""
188
+
189
+ boundary = default_path + " "
190
+ surviving: list[ConfigNode] = []
191
+ for child in parent.children:
192
+ if child.line == default_path or child.line.startswith(boundary):
193
+ continue
194
+ surviving.append(child)
195
+ parent.children = surviving
196
+
197
+
198
+ def _leading_spaces(line: str) -> int:
199
+ return len(line) - len(line.lstrip(" \t"))
200
+
201
+
202
+ # Re-export for typing convenience.
203
+ __all__ = ["CiscoLikeParser", "VendorParser", "cisco_default_order_sensitive"]
diffnc/vendors/base.py ADDED
@@ -0,0 +1,88 @@
1
+ """Vendor parser contract.
2
+
3
+ A vendor plugin must expose:
4
+
5
+ * :meth:`parse` — turn raw text into a normalised :class:`~diffnc.ir.ConfigTree`.
6
+ * :meth:`format` — render a tree back to a list of display lines.
7
+ * :meth:`render_open`, :meth:`render_close`, :meth:`render_leaf` — emit a single line at
8
+ the given indent depth. Used by the diff engine to splice context lines around changes
9
+ without re-rendering full subtrees.
10
+ * :meth:`is_order_sensitive` (optional) — declare whether the children at a given
11
+ configuration path are order-sensitive. When ``False`` (the default), the diff engine
12
+ matches children as a multiset so that pure reordering does not produce a diff.
13
+
14
+ Adding a new vendor is a matter of writing a module that implements :class:`VendorParser`
15
+ and registering it via :mod:`diffnc.vendors`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Iterator
21
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
22
+
23
+ from diffnc.ir import ConfigNode, ConfigTree
24
+
25
+ if TYPE_CHECKING:
26
+ from diffnc.reconcile import ReconcileEvent
27
+
28
+
29
+ @runtime_checkable
30
+ class VendorParser(Protocol):
31
+ name: str
32
+ """Vendor identifier, e.g. ``"junos"`` or ``"nxos"``."""
33
+
34
+ def parse(self, text: str) -> ConfigTree: ...
35
+ def format(self, tree: ConfigTree) -> list[str]: ...
36
+ def render_open(self, node: ConfigNode, depth: int) -> str: ...
37
+ def render_close(self, node: ConfigNode, depth: int) -> str | None: ...
38
+ def render_leaf(self, node: ConfigNode, depth: int) -> str: ...
39
+
40
+ def is_order_sensitive(self, path: tuple[str, ...]) -> bool:
41
+ """Return True if the children at ``path`` must be diffed positionally.
42
+
43
+ ``path`` is the tuple of ``node.line`` values from root (exclusive) down to the
44
+ parent whose children are being matched — e.g. ``("firewall", "filter FOO")``.
45
+ Vendors that don't implement this fall back to order-insensitive matching.
46
+ """
47
+ ...
48
+
49
+ def render_reconcile(self, events: Iterable[ReconcileEvent]) -> Iterator[str]:
50
+ """Translate :mod:`diffnc.reconcile` events into config-mode command lines.
51
+
52
+ Optional. Parsers that don't implement this can still be used for diffing; only
53
+ :func:`diffnc.reconcile` will fail (with :class:`NotImplementedError`) when
54
+ called against them.
55
+ """
56
+ ...
57
+
58
+
59
+ def is_order_sensitive_for(parser: VendorParser, path: tuple[str, ...]) -> bool:
60
+ """Resolve :meth:`VendorParser.is_order_sensitive` with a safe default.
61
+
62
+ Older / third-party parsers may not implement the method; treat such parents as
63
+ order-insensitive so the diff engine can fall back to multiset matching.
64
+ """
65
+
66
+ impl = getattr(parser, "is_order_sensitive", None)
67
+ if impl is None:
68
+ return False
69
+ return bool(impl(path))
70
+
71
+
72
+ def render_subtree(
73
+ parser: VendorParser,
74
+ node: ConfigNode,
75
+ depth: int,
76
+ ) -> list[str]:
77
+ """Render *node* and all its descendants as display lines."""
78
+
79
+ if node.is_leaf:
80
+ return [parser.render_leaf(node, depth)]
81
+
82
+ lines = [parser.render_open(node, depth)]
83
+ for child in node.children:
84
+ lines.extend(render_subtree(parser, child, depth + 1))
85
+ close = parser.render_close(node, depth)
86
+ if close is not None:
87
+ lines.append(close)
88
+ return lines
diffnc/vendors/eos.py ADDED
@@ -0,0 +1,19 @@
1
+ """Arista EOS vendor parser.
2
+
3
+ EOS configurations look very Cisco-ish — significant indentation, ``!`` comments,
4
+ ``end`` / ``exit`` terminators — but use a 3-space indent unit and a distinct top-level
5
+ vocabulary (``vrf instance MGMT`` rather than NX-OS's ``vrf context``, ``daemon
6
+ TerminAttr``, ``management api http-commands``, ...). The grammar itself is shared with
7
+ the other Cisco-style vendors via :mod:`diffnc.vendors._cisco_like`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from diffnc.vendors._cisco_like import CiscoLikeParser
13
+ from diffnc.vendors.base import VendorParser
14
+
15
+ PARSER: VendorParser = CiscoLikeParser(
16
+ name="eos",
17
+ indent_unit=3,
18
+ terminators=frozenset({"end", "exit"}),
19
+ )
diffnc/vendors/ios.py ADDED
@@ -0,0 +1,19 @@
1
+ """Cisco IOS vendor parser.
2
+
3
+ IOS configurations use significant indentation under section heads such as
4
+ ``interface GigabitEthernet0/0`` or ``router ospf 1`` with a 1-space indent unit, and
5
+ end with ``end`` / ``exit`` terminator lines that we drop. Everything else (shutdown
6
+ toggling, ``default`` semantics, ``!``/``#`` comment handling) is shared with the other
7
+ Cisco-style vendors and lives in :mod:`diffnc.vendors._cisco_like`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from diffnc.vendors._cisco_like import CiscoLikeParser
13
+ from diffnc.vendors.base import VendorParser
14
+
15
+ PARSER: VendorParser = CiscoLikeParser(
16
+ name="ios",
17
+ indent_unit=1,
18
+ terminators=frozenset({"end", "exit"}),
19
+ )
@@ -0,0 +1,20 @@
1
+ """Cisco IOS-XE vendor parser.
2
+
3
+ IOS-XE shares its CLI grammar with classic IOS — 1-space indentation, ``end`` / ``exit``
4
+ terminators, ``!`` comments — but ships with a richer top-level vocabulary
5
+ (``platform ...``, ``license boot level ...``, 3-tuple interface names like
6
+ ``GigabitEthernet0/0/0``). All of these structural rules are handled by the shared
7
+ :class:`~diffnc.vendors._cisco_like.CiscoLikeParser`; only the auto-detection layer
8
+ needs to know about the IOS-XE-specific markers.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from diffnc.vendors._cisco_like import CiscoLikeParser
14
+ from diffnc.vendors.base import VendorParser
15
+
16
+ PARSER: VendorParser = CiscoLikeParser(
17
+ name="iosxe",
18
+ indent_unit=1,
19
+ terminators=frozenset({"end", "exit"}),
20
+ )
@@ -0,0 +1,19 @@
1
+ """Cisco IOS-XR vendor parser.
2
+
3
+ IOS-XR uses the same indent-based section grammar as IOS (1-space unit) but has its own
4
+ configuration session terminators — ``commit`` (and the less common ``root`` /
5
+ ``abort``) — that we treat the same way IOS treats ``end`` / ``exit``: drop them on
6
+ parse. Interface naming conventions (``Bundle-Ether1``, 4-tuple ``GigabitEthernet0/0/0/0``)
7
+ matter only for auto-detection and are handled in :mod:`diffnc.detect`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from diffnc.vendors._cisco_like import CiscoLikeParser
13
+ from diffnc.vendors.base import VendorParser
14
+
15
+ PARSER: VendorParser = CiscoLikeParser(
16
+ name="iosxr",
17
+ indent_unit=1,
18
+ terminators=frozenset({"end", "exit", "commit", "root"}),
19
+ )
@@ -0,0 +1,175 @@
1
+ """Junos hierarchical-form vendor parser.
2
+
3
+ Handles the curly-brace form produced by ``show configuration``. Documents are modelled as
4
+ a nested tree matching their brace structure, with same-named sibling blocks merged. The
5
+ ``inactive:`` prefix is preserved verbatim on the node line (mirroring how NX-OS keeps
6
+ ``no`` as part of its command line).
7
+
8
+ Block comments (``/* ... */``) and line comments (``#``, ``//`` to end of line) are
9
+ stripped during parsing.
10
+
11
+ The set form (``display set`` output) is handled by a separate vendor, see
12
+ :mod:`diffnc.vendors.junos_set`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Iterable, Iterator
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
21
+
22
+ from diffnc.errors import ParseError
23
+ from diffnc.ir import ConfigNode, ConfigTree
24
+ from diffnc.vendors.base import VendorParser, render_subtree
25
+
26
+ if TYPE_CHECKING:
27
+ from diffnc.reconcile import ReconcileEvent
28
+
29
+ INDENT_UNIT = 4
30
+
31
+ _BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
32
+
33
+
34
+ @dataclass
35
+ class _JunosParser:
36
+ name: str = "junos"
37
+
38
+ def parse(self, text: str) -> ConfigTree:
39
+ cleaned = _BLOCK_COMMENT.sub("", text)
40
+ tree = ConfigTree.empty(vendor=self.name)
41
+ stack: list[ConfigNode] = [tree.root]
42
+
43
+ for lineno, raw in enumerate(cleaned.splitlines(), start=1):
44
+ stripped = _strip_line_comment(raw).strip()
45
+ if not stripped:
46
+ continue
47
+
48
+ if stripped == "}":
49
+ if len(stack) <= 1:
50
+ raise ParseError(f"line {lineno}: unmatched '}}'")
51
+ stack.pop()
52
+ continue
53
+
54
+ if stripped.endswith("{"):
55
+ name = stripped[:-1].strip()
56
+ if not name:
57
+ raise ParseError(f"line {lineno}: anonymous block")
58
+ actual = stack[-1].add_child(ConfigNode(line=name))
59
+ stack.append(actual)
60
+ elif stripped.endswith(";"):
61
+ name = stripped[:-1].strip()
62
+ if name:
63
+ stack[-1].add_child(ConfigNode(line=name))
64
+ else:
65
+ raise ParseError(
66
+ f"line {lineno}: unterminated statement (expected ';' or '{{'): {stripped!r}"
67
+ )
68
+
69
+ if len(stack) != 1:
70
+ raise ParseError("unclosed block at end of Junos hierarchical config")
71
+
72
+ return tree
73
+
74
+ def format(self, tree: ConfigTree) -> list[str]:
75
+ lines: list[str] = []
76
+ for child in tree.root.children:
77
+ lines.extend(render_subtree(self, child, depth=0))
78
+ return lines
79
+
80
+ def render_open(self, node: ConfigNode, depth: int) -> str:
81
+ return f"{_pad(depth)}{node.line} {{"
82
+
83
+ def render_close(self, node: ConfigNode, depth: int) -> str | None:
84
+ return f"{_pad(depth)}}}"
85
+
86
+ def render_leaf(self, node: ConfigNode, depth: int) -> str:
87
+ return f"{_pad(depth)}{node.line};"
88
+
89
+ def is_order_sensitive(self, path: tuple[str, ...]) -> bool:
90
+ """Term order matters inside firewall filters and policy-statements.
91
+
92
+ Junos evaluates ``term`` entries top-to-bottom, so a reorder changes behaviour
93
+ even when the term bodies are identical. Everything else in a Junos config is
94
+ a (named) container where ordering is purely cosmetic.
95
+ """
96
+
97
+ # firewall { filter <name> { term ... } }
98
+ if len(path) == 2 and path[0] == "firewall" and path[1].startswith("filter "):
99
+ return True
100
+ # firewall { family <fam> { filter <name> { term ... } } }
101
+ if (
102
+ len(path) == 3
103
+ and path[0] == "firewall"
104
+ and path[1].startswith("family ")
105
+ and path[2].startswith("filter ")
106
+ ):
107
+ return True
108
+ # policy-options { policy-statement <name> { term ... } }
109
+ return (
110
+ len(path) == 2
111
+ and path[0] == "policy-options"
112
+ and path[1].startswith("policy-statement ")
113
+ )
114
+
115
+ def render_reconcile(self, events: Iterable[ReconcileEvent]) -> Iterator[str]:
116
+ from diffnc.reconcile import ReconcileAdd, ReconcileDelete, ReconcileRecreate
117
+
118
+ for ev in events:
119
+ if isinstance(ev, ReconcileRecreate):
120
+ section = " ".join(ev.section_path)
121
+ yield f"delete {section}"
122
+ prefix = f"set {section} "
123
+ for child in ev.new_node.children:
124
+ yield from _walk_set(prefix, child)
125
+ continue
126
+
127
+ base = " ".join(ev.parent_path)
128
+ base_with_sep = f"{base} " if base else ""
129
+
130
+ if isinstance(ev, ReconcileAdd):
131
+ yield from _walk_set(f"set {base_with_sep}", ev.node)
132
+ elif isinstance(ev, ReconcileDelete):
133
+ yield f"delete {base_with_sep}{ev.node.line}"
134
+
135
+
136
+ def _walk_set(prefix: str, node: ConfigNode) -> Iterator[str]:
137
+ """Yield ``prefix + <leaf-path>`` for every leaf reachable from *node*.
138
+
139
+ Sections become an intermediate ``prefix + node.line + " "`` for their descendants.
140
+ """
141
+
142
+ if node.is_leaf:
143
+ yield prefix + node.line
144
+ return
145
+ next_prefix = f"{prefix}{node.line} "
146
+ for child in node.children:
147
+ yield from _walk_set(next_prefix, child)
148
+
149
+
150
+ def _strip_line_comment(line: str) -> str:
151
+ """Truncate at the first ``#`` or ``//`` that lies outside a double-quoted string."""
152
+
153
+ in_quote = False
154
+ i = 0
155
+ n = len(line)
156
+ while i < n:
157
+ ch = line[i]
158
+ if ch == '"':
159
+ in_quote = not in_quote
160
+ i += 1
161
+ continue
162
+ if not in_quote:
163
+ if ch == "#":
164
+ return line[:i]
165
+ if ch == "/" and i + 1 < n and line[i + 1] == "/":
166
+ return line[:i]
167
+ i += 1
168
+ return line
169
+
170
+
171
+ def _pad(depth: int) -> str:
172
+ return " " * (INDENT_UNIT * depth)
173
+
174
+
175
+ PARSER: VendorParser = _JunosParser()