mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add TextFSM rule tables as dual input to biz-state parsers.
Collectors apply RULE_KEYS (or index stems) into fsm_tables before normalize; parsers prefer FSM or hand-parse. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
24a1a853af
commit
02b8a7073b
25 changed files with 714 additions and 69 deletions
|
|
@ -3,15 +3,21 @@
|
|||
CliTable/ParseCmd is not safe to share across threads. Each call builds a fresh
|
||||
CliTable under a process-wide lock. Template files on disk are read-only and
|
||||
safe for multi-process workers.
|
||||
|
||||
Rule pipeline (biz_state):
|
||||
apply_rule / apply_rules — run TextFSM by template stem (filename without .textfsm)
|
||||
rules_for_command — list stems matching platform + CLI command via index
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypeVar
|
||||
from typing import Any, Callable, Sequence, TypeVar
|
||||
|
||||
from .cli_wrap import apply_cli_wrap
|
||||
|
||||
|
|
@ -103,6 +109,238 @@ def row_get(row: dict[str, Any], *names: str) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _IndexEntry:
|
||||
stem: str
|
||||
rel_path: str
|
||||
platform: str
|
||||
command_pat: str
|
||||
template_dir: Path
|
||||
|
||||
|
||||
def _stem_of_template_path(rel: str) -> str:
|
||||
name = Path(str(rel or "").replace("\\", "/")).name
|
||||
if name.lower().endswith(".textfsm"):
|
||||
name = name[: -len(".textfsm")]
|
||||
return name
|
||||
|
||||
|
||||
def _parse_index_file(template_dir: Path) -> list[_IndexEntry]:
|
||||
index_path = template_dir / "index"
|
||||
if not index_path.is_file():
|
||||
return []
|
||||
out: list[_IndexEntry] = []
|
||||
try:
|
||||
lines = index_path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
except Exception:
|
||||
_log.debug("read index failed dir=%s", template_dir, exc_info=True)
|
||||
return []
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
# Template, Hostname, Platform, Command
|
||||
parts = [p.strip() for p in s.split(",")]
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
if parts[0].lower() == "template":
|
||||
continue
|
||||
rel, _host, platform, cmd_pat = parts[0], parts[1], parts[2], ",".join(parts[3:]).strip()
|
||||
stem = _stem_of_template_path(rel)
|
||||
if not stem or not platform:
|
||||
continue
|
||||
out.append(
|
||||
_IndexEntry(
|
||||
stem=stem,
|
||||
rel_path=rel.replace("\\", "/"),
|
||||
platform=str(platform).strip(),
|
||||
command_pat=cmd_pat,
|
||||
template_dir=template_dir,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _all_index_entries(include_community: bool = True) -> tuple[_IndexEntry, ...]:
|
||||
entries = list(_parse_index_file(CUSTOM_TEMPLATE_DIR))
|
||||
if include_community:
|
||||
try:
|
||||
entries.extend(_parse_index_file(Path(_community_template_dir())))
|
||||
except Exception:
|
||||
_log.debug("community index unavailable", exc_info=True)
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _index_cmd_to_regex(pat: str) -> re.Pattern[str] | None:
|
||||
"""Convert ntc index Command column (sh[[ow]] foo) to an anchored regex."""
|
||||
raw = str(pat or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
out: list[str] = [r"^\s*"]
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
if raw.startswith("[[", i):
|
||||
i += 2
|
||||
continue
|
||||
m = re.match(r"([A-Za-z0-9_.|/\-]+)(?:\[\[([A-Za-z0-9_.|/\-]+)\]\])?", raw[i:])
|
||||
if m:
|
||||
base, opt = m.group(1), m.group(2)
|
||||
if opt:
|
||||
out.append(re.escape(base) + r"(?:" + re.escape(opt) + r")?")
|
||||
else:
|
||||
out.append(re.escape(base))
|
||||
i += m.end()
|
||||
ws = re.match(r"\s+", raw[i:])
|
||||
if ws:
|
||||
out.append(r"\s+")
|
||||
i += ws.end()
|
||||
continue
|
||||
ch = raw[i]
|
||||
if ch in ".$^?*+()[]{}|\\":
|
||||
out.append(re.escape(ch))
|
||||
else:
|
||||
out.append(re.escape(ch))
|
||||
i += 1
|
||||
out.append(r"\s*$")
|
||||
try:
|
||||
return re.compile("".join(out), re.I)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def _command_matches_index(pat: str, command: str) -> bool:
|
||||
cmd = str(command or "").strip()
|
||||
if not cmd:
|
||||
return False
|
||||
rx = _index_cmd_to_regex(pat)
|
||||
if rx and rx.match(cmd):
|
||||
return True
|
||||
# Fallback: loose token containment after expanding [[opt]]
|
||||
flat = re.sub(r"\[\[([^\]]+)\]\]", r"\1", pat)
|
||||
flat = re.sub(r"\s+", " ", flat).strip().lower()
|
||||
norm = re.sub(r"\s+", " ", cmd).strip().lower()
|
||||
return bool(flat) and flat in norm
|
||||
|
||||
|
||||
def rules_for_command(platform: str, command: str) -> list[str]:
|
||||
"""Return TextFSM template stems matching platform + CLI command (index order)."""
|
||||
plat = str(platform or "").strip()
|
||||
cmd = str(command or "").strip()
|
||||
if not plat or not cmd:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for e in _all_index_entries(True):
|
||||
if e.platform != plat:
|
||||
continue
|
||||
if not _command_matches_index(e.command_pat, cmd):
|
||||
continue
|
||||
if e.stem in seen:
|
||||
continue
|
||||
seen.add(e.stem)
|
||||
out.append(e.stem)
|
||||
return out
|
||||
|
||||
|
||||
def _find_entries_for_stem(rule_key: str) -> list[_IndexEntry]:
|
||||
stem = str(rule_key or "").strip()
|
||||
if stem.lower().endswith(".textfsm"):
|
||||
stem = stem[: -len(".textfsm")]
|
||||
if not stem:
|
||||
return []
|
||||
return [e for e in _all_index_entries(True) if e.stem == stem]
|
||||
|
||||
|
||||
def _parse_textfsm_file(template_path: Path, text: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
from textfsm import TextFSM
|
||||
except Exception:
|
||||
_log.debug("textfsm unavailable", exc_info=True)
|
||||
return []
|
||||
try:
|
||||
with template_path.open("r", encoding="utf-8", errors="ignore") as fh:
|
||||
fsm = TextFSM(fh)
|
||||
rows = fsm.ParseTextToDicts(str(text or ""))
|
||||
except Exception:
|
||||
_log.debug("textfsm parse failed path=%s", template_path, exc_info=True)
|
||||
return []
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
# Match ntc-templates parse_output: lowercase dict keys.
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
out.append({str(k).lower(): v for k, v in r.items()})
|
||||
return out
|
||||
|
||||
|
||||
def apply_rule(*, platform: str, rule_key: str, text: str, command: str = "") -> list[dict[str, Any]]:
|
||||
"""Run one TextFSM rule by template stem; empty list if missing/failed."""
|
||||
plat = str(platform or "").strip()
|
||||
key = str(rule_key or "").strip()
|
||||
raw = str(text or "")
|
||||
if not key or not raw.strip():
|
||||
return []
|
||||
wrap_cmd = str(command or "").strip() or key.replace("_", " ")
|
||||
if plat:
|
||||
raw = apply_cli_wrap(raw, platform=plat, command=wrap_cmd)
|
||||
|
||||
entries = _find_entries_for_stem(key)
|
||||
if plat:
|
||||
plat_entries = [e for e in entries if e.platform == plat]
|
||||
if plat_entries:
|
||||
entries = plat_entries
|
||||
with _PARSE_LOCK:
|
||||
for e in entries:
|
||||
path = e.template_dir / e.rel_path
|
||||
if not path.is_file():
|
||||
alt = e.template_dir / Path(e.rel_path).name
|
||||
path = alt if alt.is_file() else path
|
||||
if not path.is_file():
|
||||
continue
|
||||
rows = _parse_textfsm_file(path, raw)
|
||||
if rows:
|
||||
return rows
|
||||
# Fallback: search directories by filename even if not in index
|
||||
fname = f"{key}.textfsm" if not key.endswith(".textfsm") else key
|
||||
try:
|
||||
community = Path(_community_template_dir())
|
||||
except Exception:
|
||||
community = None
|
||||
roots = [CUSTOM_TEMPLATE_DIR] + ([community] if community else [])
|
||||
for base in roots:
|
||||
if not base or not base.is_dir():
|
||||
continue
|
||||
for path in base.rglob(fname):
|
||||
rows = _parse_textfsm_file(path, raw)
|
||||
if rows:
|
||||
return rows
|
||||
return []
|
||||
|
||||
|
||||
def apply_rules(
|
||||
*,
|
||||
platform: str,
|
||||
text: str,
|
||||
rule_keys: Sequence[str],
|
||||
command: str = "",
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Run multiple TextFSM rules → ``{stem: rows}``. Missing/failed keys map to ``[]``."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for key in rule_keys or ():
|
||||
k = str(key or "").strip()
|
||||
if not k:
|
||||
continue
|
||||
if k.lower().endswith(".textfsm"):
|
||||
k = k[: -len(".textfsm")]
|
||||
if k in out:
|
||||
continue
|
||||
out[k] = apply_rule(platform=platform, rule_key=k, text=text, command=command)
|
||||
return out
|
||||
|
||||
|
||||
def parse_cli(
|
||||
*,
|
||||
platform: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue