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.
Files changed (50) hide show
  1. clijson/__init__.py +50 -0
  2. clijson/__main__.py +5 -0
  3. clijson/api.py +455 -0
  4. clijson/cli.py +417 -0
  5. clijson/commands.py +312 -0
  6. clijson/diff.py +177 -0
  7. clijson/engines/__init__.py +1 -0
  8. clijson/engines/config.py +247 -0
  9. clijson/engines/external.py +77 -0
  10. clijson/engines/generic.py +339 -0
  11. clijson/engines/structured.py +143 -0
  12. clijson/exceptions.py +47 -0
  13. clijson/live.py +114 -0
  14. clijson/mcp_server.py +273 -0
  15. clijson/models.py +476 -0
  16. clijson/parsers/__init__.py +1 -0
  17. clijson/parsers/iosxr/__init__.py +1 -0
  18. clijson/parsers/iosxr/bgp_rib.py +269 -0
  19. clijson/parsers/iosxr/config.py +45 -0
  20. clijson/parsers/iosxr/extra.py +547 -0
  21. clijson/parsers/iosxr/interfaces.py +452 -0
  22. clijson/parsers/iosxr/routing.py +872 -0
  23. clijson/parsers/iosxr/services.py +878 -0
  24. clijson/parsers/iosxr/system.py +440 -0
  25. clijson/parsers/junos/__init__.py +1 -0
  26. clijson/parsers/junos/common.py +35 -0
  27. clijson/parsers/junos/config.py +16 -0
  28. clijson/parsers/junos/extra.py +333 -0
  29. clijson/parsers/junos/interfaces.py +447 -0
  30. clijson/parsers/junos/routing.py +939 -0
  31. clijson/parsers/junos/services.py +346 -0
  32. clijson/parsers/junos/system.py +583 -0
  33. clijson/parsers/vrp/__init__.py +1 -0
  34. clijson/parsers/vrp/config.py +23 -0
  35. clijson/parsers/vrp/extra.py +312 -0
  36. clijson/parsers/vrp/interfaces.py +570 -0
  37. clijson/parsers/vrp/routing.py +686 -0
  38. clijson/parsers/vrp/services.py +325 -0
  39. clijson/parsers/vrp/system.py +457 -0
  40. clijson/platforms.py +278 -0
  41. clijson/py.typed +0 -0
  42. clijson/registry.py +204 -0
  43. clijson/result.py +147 -0
  44. clijson/server.py +88 -0
  45. clijson/textutils.py +378 -0
  46. clijson-0.2.0.dist-info/METADATA +390 -0
  47. clijson-0.2.0.dist-info/RECORD +50 -0
  48. clijson-0.2.0.dist-info/WHEEL +4 -0
  49. clijson-0.2.0.dist-info/entry_points.txt +3 -0
  50. 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)