clijson 0.2.0__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.
- clijson/__init__.py +50 -0
- clijson/__main__.py +5 -0
- clijson/api.py +455 -0
- clijson/cli.py +417 -0
- clijson/commands.py +312 -0
- clijson/diff.py +177 -0
- clijson/engines/__init__.py +1 -0
- clijson/engines/config.py +247 -0
- clijson/engines/external.py +77 -0
- clijson/engines/generic.py +339 -0
- clijson/engines/structured.py +143 -0
- clijson/exceptions.py +47 -0
- clijson/live.py +114 -0
- clijson/mcp_server.py +273 -0
- clijson/models.py +476 -0
- clijson/parsers/__init__.py +1 -0
- clijson/parsers/iosxr/__init__.py +1 -0
- clijson/parsers/iosxr/bgp_rib.py +269 -0
- clijson/parsers/iosxr/config.py +45 -0
- clijson/parsers/iosxr/extra.py +547 -0
- clijson/parsers/iosxr/interfaces.py +452 -0
- clijson/parsers/iosxr/routing.py +872 -0
- clijson/parsers/iosxr/services.py +878 -0
- clijson/parsers/iosxr/system.py +440 -0
- clijson/parsers/junos/__init__.py +1 -0
- clijson/parsers/junos/common.py +35 -0
- clijson/parsers/junos/config.py +16 -0
- clijson/parsers/junos/extra.py +333 -0
- clijson/parsers/junos/interfaces.py +447 -0
- clijson/parsers/junos/routing.py +939 -0
- clijson/parsers/junos/services.py +346 -0
- clijson/parsers/junos/system.py +583 -0
- clijson/parsers/vrp/__init__.py +1 -0
- clijson/parsers/vrp/config.py +23 -0
- clijson/parsers/vrp/extra.py +312 -0
- clijson/parsers/vrp/interfaces.py +570 -0
- clijson/parsers/vrp/routing.py +686 -0
- clijson/parsers/vrp/services.py +325 -0
- clijson/parsers/vrp/system.py +457 -0
- clijson/platforms.py +278 -0
- clijson/py.typed +0 -0
- clijson/registry.py +204 -0
- clijson/result.py +147 -0
- clijson/server.py +88 -0
- clijson/textutils.py +378 -0
- clijson-0.2.0.dist-info/METADATA +390 -0
- clijson-0.2.0.dist-info/RECORD +50 -0
- clijson-0.2.0.dist-info/WHEEL +4 -0
- clijson-0.2.0.dist-info/entry_points.txt +3 -0
- clijson-0.2.0.dist-info/licenses/LICENSE +21 -0
clijson/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""clijson - turn router show commands into JSON.
|
|
2
|
+
|
|
3
|
+
>>> import clijson
|
|
4
|
+
>>> result = clijson.parse(raw_text, "show ip int brief", platform="iosxr")
|
|
5
|
+
>>> result.data # structured, JSON serialisable
|
|
6
|
+
>>> result.to_json()
|
|
7
|
+
|
|
8
|
+
Supports Cisco IOS XR, Juniper Junos and Huawei VRP with dedicated parsers,
|
|
9
|
+
understands Junos ``| display json/xml`` natively, turns configurations into
|
|
10
|
+
trees and falls back to a heuristic engine that structures *any* output.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError
|
|
14
|
+
from importlib.metadata import version as _dist_version
|
|
15
|
+
|
|
16
|
+
from .api import find_parser, parse, parse_file, parse_session, split_session, supported_commands
|
|
17
|
+
from .diff import Change, diff
|
|
18
|
+
from .exceptions import CliJsonError, ParseError, ParserNotFound, PlatformDetectionError, UnknownPlatformError
|
|
19
|
+
from .platforms import Platform, detect_platform, get_platform, list_platforms
|
|
20
|
+
from .registry import Parser, register
|
|
21
|
+
from .result import ParseResult
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = _dist_version("clijson")
|
|
25
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree without installing
|
|
26
|
+
__version__ = "0.0.0+unknown"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Change",
|
|
30
|
+
"CliJsonError",
|
|
31
|
+
"ParseError",
|
|
32
|
+
"ParseResult",
|
|
33
|
+
"Parser",
|
|
34
|
+
"ParserNotFound",
|
|
35
|
+
"Platform",
|
|
36
|
+
"PlatformDetectionError",
|
|
37
|
+
"UnknownPlatformError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"detect_platform",
|
|
40
|
+
"diff",
|
|
41
|
+
"find_parser",
|
|
42
|
+
"get_platform",
|
|
43
|
+
"list_platforms",
|
|
44
|
+
"parse",
|
|
45
|
+
"parse_file",
|
|
46
|
+
"parse_session",
|
|
47
|
+
"register",
|
|
48
|
+
"split_session",
|
|
49
|
+
"supported_commands",
|
|
50
|
+
]
|
clijson/__main__.py
ADDED
clijson/api.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""High level API: :func:`parse`, :func:`parse_session` and friends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Union
|
|
10
|
+
|
|
11
|
+
from .commands import CommandLine, split_command
|
|
12
|
+
from .engines import external
|
|
13
|
+
from .engines.generic import parse_generic
|
|
14
|
+
from .engines.structured import looks_like_json, looks_like_xml, parse_json, parse_xml
|
|
15
|
+
from .exceptions import ParseError, ParserNotFound, PlatformDetectionError
|
|
16
|
+
from .platforms import Platform, detect_platform, get_platform, match_prompt
|
|
17
|
+
from .registry import REGISTRY, Resolution
|
|
18
|
+
from .result import ParseResult
|
|
19
|
+
from .textutils import XR_TIMESTAMP, clean_output, dedent
|
|
20
|
+
|
|
21
|
+
DEFAULT_ENGINES: tuple[str, ...] = ("native", "ntc", "genie", "generic")
|
|
22
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse(
|
|
26
|
+
output: str | bytes,
|
|
27
|
+
command: str | None = None,
|
|
28
|
+
platform: str | Platform | None = None,
|
|
29
|
+
*,
|
|
30
|
+
normalize: bool = False,
|
|
31
|
+
engines: Sequence[str] | None = None,
|
|
32
|
+
strict: bool = False,
|
|
33
|
+
raise_on_error: bool = False,
|
|
34
|
+
) -> ParseResult:
|
|
35
|
+
"""Parse the *output* of a show/display *command* into JSON-ready data.
|
|
36
|
+
|
|
37
|
+
:param output: raw text captured from the device (prompts, pagers and
|
|
38
|
+
ANSI codes are tolerated and removed)
|
|
39
|
+
:param command: the command that produced the output; abbreviations are
|
|
40
|
+
fine (``sh ip int br``). If omitted, it is read from the
|
|
41
|
+
prompt line echoed at the top of *output* when present.
|
|
42
|
+
:param platform: ``"iosxr"``, ``"junos"``, ``"vrp"`` or any alias. If
|
|
43
|
+
omitted it is auto-detected from the prompt, the command
|
|
44
|
+
verb and output fingerprints.
|
|
45
|
+
:param normalize: also compute a vendor-neutral view in ``result.normalized``
|
|
46
|
+
for commands that map to a common model (interfaces,
|
|
47
|
+
BGP neighbors, routes, LLDP, ...).
|
|
48
|
+
:param engines: engines to try, in order. Default:
|
|
49
|
+
``("native", "ntc", "genie", "generic")``; unavailable
|
|
50
|
+
optional engines are skipped silently.
|
|
51
|
+
:param strict: only accept a dedicated parser; raise :class:`ParserNotFound`
|
|
52
|
+
otherwise.
|
|
53
|
+
:param raise_on_error: re-raise exceptions from dedicated parsers instead
|
|
54
|
+
of falling back to the next engine.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(output, (bytes, bytearray)):
|
|
57
|
+
output = output.decode("utf-8", errors="replace")
|
|
58
|
+
result = _parse(
|
|
59
|
+
output or "",
|
|
60
|
+
command,
|
|
61
|
+
platform,
|
|
62
|
+
normalize=normalize,
|
|
63
|
+
engines=engines,
|
|
64
|
+
strict=strict,
|
|
65
|
+
raise_on_error=raise_on_error,
|
|
66
|
+
)
|
|
67
|
+
result.raw = output
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse(
|
|
72
|
+
output: str,
|
|
73
|
+
command: str | None,
|
|
74
|
+
platform: str | Platform | None,
|
|
75
|
+
*,
|
|
76
|
+
normalize: bool,
|
|
77
|
+
engines: Sequence[str] | None,
|
|
78
|
+
strict: bool,
|
|
79
|
+
raise_on_error: bool,
|
|
80
|
+
) -> ParseResult:
|
|
81
|
+
text = clean_output(output)
|
|
82
|
+
warnings: list[str] = []
|
|
83
|
+
metadata: dict[str, Any] = {}
|
|
84
|
+
|
|
85
|
+
text, echoed_cmd, prompt_platform, host = _strip_prompts(text)
|
|
86
|
+
if host:
|
|
87
|
+
metadata["hostname"] = host
|
|
88
|
+
if command is None and echoed_cmd:
|
|
89
|
+
command = echoed_cmd
|
|
90
|
+
if command is None:
|
|
91
|
+
command = _command_from_echo(text)
|
|
92
|
+
text, ts = _strip_timestamp(text)
|
|
93
|
+
if ts:
|
|
94
|
+
metadata["timestamp"] = ts
|
|
95
|
+
|
|
96
|
+
if command:
|
|
97
|
+
text = _strip_command_echo(text, command)
|
|
98
|
+
device_error = _device_error(text)
|
|
99
|
+
if device_error:
|
|
100
|
+
plat_hint = get_platform(platform) if platform else prompt_platform
|
|
101
|
+
return ParseResult(
|
|
102
|
+
None,
|
|
103
|
+
_name(plat_hint),
|
|
104
|
+
command,
|
|
105
|
+
"device-error",
|
|
106
|
+
None,
|
|
107
|
+
0.0,
|
|
108
|
+
warnings=[f"device returned an error: {device_error}"],
|
|
109
|
+
metadata={**metadata, "device_error": device_error},
|
|
110
|
+
)
|
|
111
|
+
text = dedent(text)
|
|
112
|
+
cmdline: CommandLine | None = split_command(command) if command else None
|
|
113
|
+
if cmdline and cmdline.filtered:
|
|
114
|
+
warnings.append(f"output was filtered by '| {' | '.join(cmdline.pipes)}'; some fields may be missing")
|
|
115
|
+
|
|
116
|
+
plat = _resolve_platform(platform, prompt_platform, text, command, metadata)
|
|
117
|
+
if plat is None and cmdline is not None and cmdline.tokens and not strict:
|
|
118
|
+
plat = _trial_platform(cmdline, text, metadata)
|
|
119
|
+
|
|
120
|
+
if plat is None and strict:
|
|
121
|
+
raise PlatformDetectionError()
|
|
122
|
+
|
|
123
|
+
# Device already produced structured output (| display json / xml)
|
|
124
|
+
fmt = cmdline.output_format if cmdline else None
|
|
125
|
+
if fmt == "json" or (fmt is None and looks_like_json(text)):
|
|
126
|
+
try:
|
|
127
|
+
return ParseResult(
|
|
128
|
+
parse_json(text), _name(plat), command, "json", "json", 1.0, warnings=warnings, metadata=metadata
|
|
129
|
+
)
|
|
130
|
+
except ValueError:
|
|
131
|
+
if fmt == "json":
|
|
132
|
+
warnings.append("output announced as JSON could not be decoded")
|
|
133
|
+
if fmt == "xml" or (fmt is None and looks_like_xml(text)):
|
|
134
|
+
try:
|
|
135
|
+
return ParseResult(
|
|
136
|
+
parse_xml(text), _name(plat), command, "xml", "xml", 1.0, warnings=warnings, metadata=metadata
|
|
137
|
+
)
|
|
138
|
+
except Exception:
|
|
139
|
+
if fmt == "xml":
|
|
140
|
+
warnings.append("output announced as XML could not be decoded")
|
|
141
|
+
|
|
142
|
+
order = tuple(engines) if engines else DEFAULT_ENGINES
|
|
143
|
+
if strict:
|
|
144
|
+
order = ("native",)
|
|
145
|
+
if plat is None:
|
|
146
|
+
warnings.append("platform could not be detected; using the generic engine")
|
|
147
|
+
|
|
148
|
+
resolution: Resolution | None = None
|
|
149
|
+
if plat is not None and cmdline is not None and cmdline.tokens:
|
|
150
|
+
resolution = REGISTRY.resolve(plat, cmdline.tokens)
|
|
151
|
+
|
|
152
|
+
for engine in order:
|
|
153
|
+
if engine == "native":
|
|
154
|
+
if resolution is None:
|
|
155
|
+
continue
|
|
156
|
+
parser = resolution.parser(resolution.params, command or "")
|
|
157
|
+
try:
|
|
158
|
+
data = parser.parse(text)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
if raise_on_error or strict:
|
|
161
|
+
raise ParseError(parser.name, f"{type(exc).__name__}: {exc}") from exc
|
|
162
|
+
warnings.append(f"{parser.name} failed ({type(exc).__name__}: {exc}); falling back")
|
|
163
|
+
continue
|
|
164
|
+
result = ParseResult(
|
|
165
|
+
data=data,
|
|
166
|
+
platform=plat.name if plat else None,
|
|
167
|
+
command=command,
|
|
168
|
+
engine="native",
|
|
169
|
+
parser=parser.name,
|
|
170
|
+
confidence=1.0,
|
|
171
|
+
intent=parser.intent,
|
|
172
|
+
params=dict(resolution.params),
|
|
173
|
+
warnings=warnings,
|
|
174
|
+
metadata=metadata,
|
|
175
|
+
)
|
|
176
|
+
if normalize:
|
|
177
|
+
result.normalized = _normalize(parser, data, result)
|
|
178
|
+
return result
|
|
179
|
+
if engine in external.ENGINES:
|
|
180
|
+
if plat is None or cmdline is None:
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
data = external.ENGINES[engine](plat, cmdline.base, text)
|
|
184
|
+
except external.EngineUnavailable:
|
|
185
|
+
continue
|
|
186
|
+
if data:
|
|
187
|
+
return ParseResult(
|
|
188
|
+
data,
|
|
189
|
+
plat.name,
|
|
190
|
+
command,
|
|
191
|
+
engine,
|
|
192
|
+
f"{engine}:{cmdline.base}",
|
|
193
|
+
0.9,
|
|
194
|
+
warnings=warnings,
|
|
195
|
+
metadata=metadata,
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
if engine == "generic":
|
|
199
|
+
data = parse_generic(text)
|
|
200
|
+
if resolution is None and cmdline is not None and plat is not None:
|
|
201
|
+
hints = REGISTRY.suggest(plat, cmdline.base)
|
|
202
|
+
if hints:
|
|
203
|
+
warnings.append("no dedicated parser; closest supported commands: " + "; ".join(hints))
|
|
204
|
+
confidence = 0.6 if data.get("tables") else 0.4
|
|
205
|
+
return ParseResult(
|
|
206
|
+
data, _name(plat), command, "generic", "generic", confidence, warnings=warnings, metadata=metadata
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if strict and cmdline is not None and plat is not None:
|
|
210
|
+
raise ParserNotFound(plat.name, cmdline.base, REGISTRY.suggest(plat, cmdline.base))
|
|
211
|
+
return ParseResult(
|
|
212
|
+
None,
|
|
213
|
+
_name(plat),
|
|
214
|
+
command,
|
|
215
|
+
"none",
|
|
216
|
+
None,
|
|
217
|
+
0.0,
|
|
218
|
+
warnings=[*warnings, "no engine produced a result"],
|
|
219
|
+
metadata=metadata,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _name(plat: Platform | None) -> str | None:
|
|
224
|
+
return plat.name if plat else None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _resolve_platform(
|
|
228
|
+
platform: str | Platform | None,
|
|
229
|
+
prompt_platform: Platform | None,
|
|
230
|
+
text: str,
|
|
231
|
+
command: str | None,
|
|
232
|
+
metadata: dict[str, Any],
|
|
233
|
+
) -> Platform | None:
|
|
234
|
+
if platform:
|
|
235
|
+
return get_platform(platform)
|
|
236
|
+
if prompt_platform:
|
|
237
|
+
metadata["detected_by"] = "prompt"
|
|
238
|
+
return prompt_platform
|
|
239
|
+
det = detect_platform(text, command)
|
|
240
|
+
if det.platform:
|
|
241
|
+
metadata["detected_by"] = "fingerprint"
|
|
242
|
+
metadata["detection_confidence"] = det.confidence
|
|
243
|
+
return det.platform
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _richness(data: Any) -> int:
|
|
247
|
+
if isinstance(data, dict):
|
|
248
|
+
return sum(_richness(v) for v in data.values())
|
|
249
|
+
if isinstance(data, list):
|
|
250
|
+
return sum(_richness(v) for v in data)
|
|
251
|
+
return 0 if data in (None, "") else 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _trial_platform(cmdline: CommandLine, text: str, metadata: dict[str, Any]) -> Platform | None:
|
|
255
|
+
"""Platform unknown: let every vendor's parser try and keep the one that understands the output."""
|
|
256
|
+
from .platforms import list_platforms
|
|
257
|
+
|
|
258
|
+
best: tuple[int, Platform] | None = None
|
|
259
|
+
for plat in list_platforms():
|
|
260
|
+
res = REGISTRY.resolve(plat, cmdline.tokens)
|
|
261
|
+
if res is None:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
score = _richness(res.parser(res.params, cmdline.raw).parse(text))
|
|
265
|
+
except Exception:
|
|
266
|
+
continue
|
|
267
|
+
if score and (best is None or score > best[0]):
|
|
268
|
+
best = (score, plat)
|
|
269
|
+
if best is None:
|
|
270
|
+
return None
|
|
271
|
+
metadata["detected_by"] = "trial-parse"
|
|
272
|
+
return best[1]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _strip_prompts(text: str) -> tuple[str, str | None, Platform | None, str | None]:
|
|
276
|
+
"""Remove a leading ``prompt#command`` echo and trailing bare prompts."""
|
|
277
|
+
ls = text.split("\n")
|
|
278
|
+
cmd: str | None = None
|
|
279
|
+
plat: Platform | None = None
|
|
280
|
+
host: str | None = None
|
|
281
|
+
# leading junk such as '{master}' or empty lines
|
|
282
|
+
while ls and (
|
|
283
|
+
not ls[0].strip() or re.match(r"^\{(?:master|backup|primary|secondary|linecard)(?::\d+)?\}\s*$", ls[0].strip())
|
|
284
|
+
):
|
|
285
|
+
ls.pop(0)
|
|
286
|
+
if ls:
|
|
287
|
+
hit = match_prompt(ls[0])
|
|
288
|
+
if hit and hit[1].group("cmd").strip():
|
|
289
|
+
plat, m = hit
|
|
290
|
+
cmd = m.group("cmd").strip()
|
|
291
|
+
host = m.groupdict().get("host")
|
|
292
|
+
ls.pop(0)
|
|
293
|
+
# trailing bare prompt(s)
|
|
294
|
+
while ls and (
|
|
295
|
+
not ls[-1].strip() or re.match(r"^\{(?:master|backup|primary|secondary)(?::\d+)?\}\s*$", ls[-1].strip())
|
|
296
|
+
):
|
|
297
|
+
ls.pop()
|
|
298
|
+
while ls:
|
|
299
|
+
hit = match_prompt(ls[-1])
|
|
300
|
+
if hit and not hit[1].group("cmd").strip():
|
|
301
|
+
plat = plat or hit[0]
|
|
302
|
+
host = host or hit[1].groupdict().get("host")
|
|
303
|
+
ls.pop()
|
|
304
|
+
while ls and re.match(r"^\{(?:master|backup|primary|secondary)(?::\d+)?\}\s*$", ls[-1].strip()):
|
|
305
|
+
ls.pop()
|
|
306
|
+
else:
|
|
307
|
+
break
|
|
308
|
+
return "\n".join(ls), cmd, plat, host
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_DEVICE_ERRORS = re.compile(
|
|
312
|
+
r"^\s*(?:\^\s*)?(%\s*(?:Invalid input detected|Incomplete command|Ambiguous command|Bad IP address|Unknown command|No such)[^\n]*"
|
|
313
|
+
r"|syntax error[^\n]*|error: [^\n]*|unknown command\.?[^\n]*"
|
|
314
|
+
r"|Error: (?:Unrecognized command|Wrong parameter|Incomplete command|Too many parameters|Ambiguous command|Unrecognized)[^\n]*)\s*$",
|
|
315
|
+
re.I | re.M,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _device_error(text: str) -> str | None:
|
|
320
|
+
"""Short output consisting of a CLI error message (``% Invalid input``, ``syntax error``, ``Error: ...``)."""
|
|
321
|
+
lines = [ln for ln in text.split("\n") if ln.strip()]
|
|
322
|
+
if not lines or len(lines) > 6:
|
|
323
|
+
return None
|
|
324
|
+
m = _DEVICE_ERRORS.search(text)
|
|
325
|
+
return m.group(1).strip() if m else None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _strip_command_echo(text: str, command: str) -> str:
|
|
329
|
+
"""Drop a first line that merely repeats the command (captures without a prompt)."""
|
|
330
|
+
ls = text.split("\n")
|
|
331
|
+
idx = 0
|
|
332
|
+
while idx < len(ls) and not ls[idx].strip():
|
|
333
|
+
idx += 1
|
|
334
|
+
if idx < len(ls):
|
|
335
|
+
first = " ".join(ls[idx].split()).lower()
|
|
336
|
+
cmd = " ".join(command.split()).lower()
|
|
337
|
+
base = split_command(command).base.lower()
|
|
338
|
+
if (
|
|
339
|
+
first == cmd
|
|
340
|
+
or (first.startswith(base) and first[len(base) :].lstrip().startswith("|"))
|
|
341
|
+
or _ECHO_ANY.match(first)
|
|
342
|
+
):
|
|
343
|
+
return "\n".join(ls[idx + 1 :])
|
|
344
|
+
return text
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
_ECHO_ANY = re.compile(r"^[#>]?\s*(?:sh|sho|show|dis|disp|display)\s+[a-z][\w\-]*(?:\s+\S+)*$", re.I)
|
|
348
|
+
_ECHO_RE = re.compile(r"^\s*((?:show|display)\s+[\w\-]+(?:\s+[^\n]*)?)$", re.I)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _command_from_echo(text: str) -> str | None:
|
|
352
|
+
"""``show bgp summary`` printed alone on the first line (capture without prompt)."""
|
|
353
|
+
for ln in text.split("\n"):
|
|
354
|
+
if ln.strip():
|
|
355
|
+
m = _ECHO_RE.match(ln)
|
|
356
|
+
if m and len(ln) < 160 and ":" not in ln.split("|")[0]:
|
|
357
|
+
return m.group(1).strip()
|
|
358
|
+
return None
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _strip_timestamp(text: str) -> tuple[str, str | None]:
|
|
363
|
+
ls = text.split("\n")
|
|
364
|
+
idx = 0
|
|
365
|
+
while idx < len(ls) and not ls[idx].strip():
|
|
366
|
+
idx += 1
|
|
367
|
+
if idx < len(ls) and XR_TIMESTAMP.match(ls[idx]):
|
|
368
|
+
ts = ls[idx].strip()
|
|
369
|
+
return "\n".join(ls[:idx] + ls[idx + 1 :]).strip("\n"), ts
|
|
370
|
+
return text, None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _normalize(parser: Any, data: Any, result: ParseResult) -> Any:
|
|
374
|
+
fn = getattr(parser, "normalize", None)
|
|
375
|
+
if fn is None:
|
|
376
|
+
return None
|
|
377
|
+
try:
|
|
378
|
+
return fn(data)
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
result.warnings.append(f"normalization failed: {type(exc).__name__}: {exc}")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------------------------------------------------------- #
|
|
385
|
+
# Sessions & files
|
|
386
|
+
# --------------------------------------------------------------------------- #
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass
|
|
390
|
+
class SessionChunk:
|
|
391
|
+
platform: Platform | None
|
|
392
|
+
hostname: str | None
|
|
393
|
+
command: str
|
|
394
|
+
output: str
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def split_session(text: str) -> list[SessionChunk]:
|
|
398
|
+
"""Split a captured terminal session into ``(command, output)`` chunks.
|
|
399
|
+
|
|
400
|
+
Works with logs containing many commands (e.g. a PuTTY/SecureCRT log or a
|
|
401
|
+
``script`` capture) as long as prompts are visible.
|
|
402
|
+
"""
|
|
403
|
+
chunks: list[SessionChunk] = []
|
|
404
|
+
cur: SessionChunk | None = None
|
|
405
|
+
buf: list[str] = []
|
|
406
|
+
for line in clean_output(text).split("\n"):
|
|
407
|
+
hit = match_prompt(line)
|
|
408
|
+
if hit:
|
|
409
|
+
if cur is not None:
|
|
410
|
+
cur.output = "\n".join(buf).strip("\n")
|
|
411
|
+
chunks.append(cur)
|
|
412
|
+
cur, buf = None, []
|
|
413
|
+
cmd = hit[1].group("cmd").strip()
|
|
414
|
+
if cmd:
|
|
415
|
+
cur = SessionChunk(hit[0], hit[1].groupdict().get("host"), cmd, "")
|
|
416
|
+
continue
|
|
417
|
+
if cur is not None:
|
|
418
|
+
buf.append(line)
|
|
419
|
+
if cur is not None:
|
|
420
|
+
cur.output = "\n".join(buf).strip("\n")
|
|
421
|
+
chunks.append(cur)
|
|
422
|
+
return chunks
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def parse_session(text: str, platform: str | Platform | None = None, **kwargs: Any) -> list[ParseResult]:
|
|
426
|
+
"""Parse every command found in a terminal session capture."""
|
|
427
|
+
results = []
|
|
428
|
+
for chunk in split_session(text):
|
|
429
|
+
plat = platform or chunk.platform
|
|
430
|
+
res = parse(chunk.output, chunk.command, plat, **kwargs)
|
|
431
|
+
if chunk.hostname:
|
|
432
|
+
res.metadata.setdefault("hostname", chunk.hostname)
|
|
433
|
+
results.append(res)
|
|
434
|
+
return results
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def parse_file(
|
|
438
|
+
path: PathLike, command: str | None = None, platform: str | Platform | None = None, **kwargs: Any
|
|
439
|
+
) -> ParseResult | list[ParseResult]:
|
|
440
|
+
"""Parse a file. Session logs with several prompts yield a list of results."""
|
|
441
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
442
|
+
text = fh.read()
|
|
443
|
+
if command is None and len(split_session(text)) > 1:
|
|
444
|
+
return parse_session(text, platform, **kwargs)
|
|
445
|
+
return parse(text, command, platform, **kwargs)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def supported_commands(platform: str | Platform | None = None) -> list[dict[str, Any]]:
|
|
449
|
+
"""List the commands with dedicated parsers, optionally for one platform."""
|
|
450
|
+
return REGISTRY.catalog(get_platform(platform).name if platform else None)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def find_parser(platform: str | Platform, command: str) -> Resolution | None:
|
|
454
|
+
"""Return which dedicated parser would handle *command* (``None`` if none)."""
|
|
455
|
+
return REGISTRY.resolve(platform, split_command(command).tokens)
|