ntermqt 0.1.4__py3-none-any.whl → 0.1.5__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.
- nterm/__main__.py +48 -0
- nterm/parser/__init__.py +0 -0
- nterm/parser/api_help_dialog.py +607 -0
- nterm/parser/ntc_download_dialog.py +372 -0
- nterm/parser/tfsm_engine.py +246 -0
- nterm/parser/tfsm_fire.py +237 -0
- nterm/parser/tfsm_fire_tester.py +2329 -0
- nterm/scripting/api.py +926 -19
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.5.dist-info}/METADATA +4 -5
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.5.dist-info}/RECORD +13 -8
- nterm/examples/basic_terminal.py +0 -415
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.5.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.5.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import hashlib
|
|
6
|
+
import traceback
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
|
|
11
|
+
from PyQt6.QtWidgets import (
|
|
12
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
13
|
+
QTextEdit, QLineEdit, QPushButton, QLabel, QSplitter, QTableWidget,
|
|
14
|
+
QTableWidgetItem, QTabWidget, QGroupBox, QSpinBox, QCheckBox,
|
|
15
|
+
QFileDialog, QMessageBox, QComboBox, QDialog, QDialogButtonBox,
|
|
16
|
+
QFormLayout, QHeaderView, QAbstractItemView, QMenu, QInputDialog,
|
|
17
|
+
QStatusBar, QToolBar, QFrame
|
|
18
|
+
)
|
|
19
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
|
|
20
|
+
from PyQt6.QtGui import QFont, QAction, QIcon, QColor, QPalette, QShortcut, QKeySequence
|
|
21
|
+
|
|
22
|
+
import textfsm
|
|
23
|
+
import io
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Try to import requests for NTC GitHub download
|
|
28
|
+
REQUESTS_AVAILABLE = False
|
|
29
|
+
try:
|
|
30
|
+
import requests
|
|
31
|
+
|
|
32
|
+
REQUESTS_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
# Try to import the engine, but don't fail if not available (manual mode still works)
|
|
37
|
+
TFSM_ENGINE_AVAILABLE = False
|
|
38
|
+
try:
|
|
39
|
+
from nterm.parser.tfsm_fire import TextFSMAutoEngine
|
|
40
|
+
|
|
41
|
+
TFSM_ENGINE_AVAILABLE = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
try:
|
|
44
|
+
from .tfsm_fire import TextFSMAutoEngine
|
|
45
|
+
|
|
46
|
+
TFSM_ENGINE_AVAILABLE = True
|
|
47
|
+
except ImportError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
GITHUB_API_URL = "https://api.github.com/repos/networktocode/ntc-templates/contents/ntc_templates/templates"
|
|
51
|
+
GITHUB_RAW_BASE = "https://raw.githubusercontent.com/networktocode/ntc-templates/master/ntc_templates/templates"
|
|
52
|
+
|
|
53
|
+
VENDOR_PREFIXES = [
|
|
54
|
+
'cisco', 'arista', 'juniper', 'hp', 'dell', 'paloalto', 'fortinet',
|
|
55
|
+
'brocade', 'extreme', 'huawei', 'mikrotik', 'ubiquiti', 'vmware',
|
|
56
|
+
'checkpoint', 'alcatel', 'avaya', 'ruckus', 'f5', 'a10', 'linux',
|
|
57
|
+
'yamaha', 'zyxel', 'enterasys', 'adtran', 'ciena', 'nokia', 'watchguard'
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def extract_platform(filename: str) -> str:
|
|
61
|
+
"""Extract platform name from template filename."""
|
|
62
|
+
name = filename.replace('.textfsm', '')
|
|
63
|
+
parts = name.split('_')
|
|
64
|
+
if len(parts) >= 2 and parts[0] in VENDOR_PREFIXES:
|
|
65
|
+
return f"{parts[0]}_{parts[1]}"
|
|
66
|
+
if parts[0] in VENDOR_PREFIXES:
|
|
67
|
+
return parts[0]
|
|
68
|
+
return parts[0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_package_db_path() -> Path:
|
|
72
|
+
"""Database is in same directory as this module."""
|
|
73
|
+
return Path(__file__).parent / "tfsm_templates.db"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NTCDownloadWorker(QThread):
|
|
77
|
+
"""Worker thread for downloading NTC templates"""
|
|
78
|
+
progress = pyqtSignal(int, int, str) # current, total, status
|
|
79
|
+
finished = pyqtSignal(dict) # stats dict
|
|
80
|
+
error = pyqtSignal(str)
|
|
81
|
+
|
|
82
|
+
def __init__(self, platforms: list, db_path: str, replace: bool = False):
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.platforms = platforms
|
|
85
|
+
self.db_path = db_path or str(get_package_db_path())
|
|
86
|
+
self.replace = replace
|
|
87
|
+
self.templates_to_download = []
|
|
88
|
+
|
|
89
|
+
def run(self):
|
|
90
|
+
try:
|
|
91
|
+
# Fetch template list
|
|
92
|
+
response = requests.get(GITHUB_API_URL, timeout=30)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
|
|
95
|
+
files = response.json()
|
|
96
|
+
all_templates = [f for f in files if f['name'].endswith('.textfsm')]
|
|
97
|
+
|
|
98
|
+
# Group by platform
|
|
99
|
+
platforms_map = defaultdict(list)
|
|
100
|
+
for t in all_templates:
|
|
101
|
+
platform = extract_platform(t['name'])
|
|
102
|
+
platforms_map[platform].append(t)
|
|
103
|
+
|
|
104
|
+
# Filter to selected platforms
|
|
105
|
+
for platform in self.platforms:
|
|
106
|
+
if platform in platforms_map:
|
|
107
|
+
self.templates_to_download.extend(platforms_map[platform])
|
|
108
|
+
|
|
109
|
+
if not self.templates_to_download:
|
|
110
|
+
self.finished.emit({'imported': 0, 'updated': 0, 'skipped': 0, 'errors': 0})
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Connect to database
|
|
114
|
+
conn = sqlite3.connect(self.db_path)
|
|
115
|
+
conn.execute("""
|
|
116
|
+
CREATE TABLE IF NOT EXISTS templates (
|
|
117
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
cli_command TEXT UNIQUE,
|
|
119
|
+
cli_content TEXT,
|
|
120
|
+
textfsm_content TEXT,
|
|
121
|
+
textfsm_hash TEXT,
|
|
122
|
+
source TEXT,
|
|
123
|
+
created TEXT
|
|
124
|
+
)
|
|
125
|
+
""")
|
|
126
|
+
cursor = conn.cursor()
|
|
127
|
+
|
|
128
|
+
stats = {'imported': 0, 'updated': 0, 'skipped': 0, 'errors': 0}
|
|
129
|
+
total = len(self.templates_to_download)
|
|
130
|
+
|
|
131
|
+
for i, template in enumerate(self.templates_to_download, 1):
|
|
132
|
+
name = template['name']
|
|
133
|
+
cli_command = name.replace('.textfsm', '')
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
# Download content
|
|
137
|
+
url = f"{GITHUB_RAW_BASE}/{name}"
|
|
138
|
+
resp = requests.get(url, timeout=30)
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
content = resp.text
|
|
141
|
+
|
|
142
|
+
textfsm_hash = hashlib.md5(content.encode()).hexdigest()
|
|
143
|
+
created = datetime.now().isoformat()
|
|
144
|
+
|
|
145
|
+
# Check if exists
|
|
146
|
+
cursor.execute("SELECT textfsm_hash FROM templates WHERE cli_command = ?", (cli_command,))
|
|
147
|
+
existing = cursor.fetchone()
|
|
148
|
+
|
|
149
|
+
if existing:
|
|
150
|
+
if self.replace and existing[0] != textfsm_hash:
|
|
151
|
+
cursor.execute("""
|
|
152
|
+
UPDATE templates
|
|
153
|
+
SET textfsm_content = ?, textfsm_hash = ?, source = ?, created = ?
|
|
154
|
+
WHERE cli_command = ?
|
|
155
|
+
""", (content, textfsm_hash, "ntc-templates", created, cli_command))
|
|
156
|
+
stats['updated'] += 1
|
|
157
|
+
status = "U"
|
|
158
|
+
else:
|
|
159
|
+
stats['skipped'] += 1
|
|
160
|
+
status = "."
|
|
161
|
+
else:
|
|
162
|
+
cursor.execute("""
|
|
163
|
+
INSERT INTO templates (cli_command, cli_content, textfsm_content, textfsm_hash, source, created)
|
|
164
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
165
|
+
""", (cli_command, "", content, textfsm_hash, "ntc-templates", created))
|
|
166
|
+
stats['imported'] += 1
|
|
167
|
+
status = "+"
|
|
168
|
+
|
|
169
|
+
self.progress.emit(i, total, f"{status} {cli_command}")
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
stats['errors'] += 1
|
|
173
|
+
self.progress.emit(i, total, f"E {cli_command}: {str(e)[:30]}")
|
|
174
|
+
|
|
175
|
+
conn.commit()
|
|
176
|
+
conn.close()
|
|
177
|
+
self.finished.emit(stats)
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
traceback.print_exc()
|
|
181
|
+
self.error.emit(str(e))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class NTCDownloadDialog(QDialog):
|
|
186
|
+
"""Dialog for selecting and downloading NTC templates from GitHub"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, parent=None, db_path: str = "tfsm_templates.db"):
|
|
189
|
+
super().__init__(parent)
|
|
190
|
+
self.db_path = db_path
|
|
191
|
+
self.platforms = {}
|
|
192
|
+
self.setWindowTitle("Download NTC Templates from GitHub")
|
|
193
|
+
self.setMinimumSize(600, 500)
|
|
194
|
+
self.setup_ui()
|
|
195
|
+
|
|
196
|
+
def setup_ui(self):
|
|
197
|
+
layout = QVBoxLayout(self)
|
|
198
|
+
|
|
199
|
+
# Info label
|
|
200
|
+
info = QLabel("Download TextFSM templates directly from networktocode/ntc-templates GitHub repository.")
|
|
201
|
+
info.setWordWrap(True)
|
|
202
|
+
layout.addWidget(info)
|
|
203
|
+
|
|
204
|
+
# Fetch button
|
|
205
|
+
fetch_layout = QHBoxLayout()
|
|
206
|
+
self.fetch_btn = QPushButton("Fetch Available Platforms")
|
|
207
|
+
self.fetch_btn.clicked.connect(self.fetch_platforms)
|
|
208
|
+
fetch_layout.addWidget(self.fetch_btn)
|
|
209
|
+
fetch_layout.addStretch()
|
|
210
|
+
layout.addLayout(fetch_layout)
|
|
211
|
+
|
|
212
|
+
# Platform list
|
|
213
|
+
list_group = QGroupBox("Available Platforms")
|
|
214
|
+
list_layout = QVBoxLayout(list_group)
|
|
215
|
+
|
|
216
|
+
# Select all / none buttons
|
|
217
|
+
select_layout = QHBoxLayout()
|
|
218
|
+
select_all_btn = QPushButton("Select All")
|
|
219
|
+
select_all_btn.setProperty("secondary", True)
|
|
220
|
+
select_all_btn.clicked.connect(self.select_all)
|
|
221
|
+
select_layout.addWidget(select_all_btn)
|
|
222
|
+
|
|
223
|
+
select_none_btn = QPushButton("Select None")
|
|
224
|
+
select_none_btn.setProperty("secondary", True)
|
|
225
|
+
select_none_btn.clicked.connect(self.select_none)
|
|
226
|
+
select_layout.addWidget(select_none_btn)
|
|
227
|
+
|
|
228
|
+
select_layout.addStretch()
|
|
229
|
+
|
|
230
|
+
self.status_label = QLabel("")
|
|
231
|
+
select_layout.addWidget(self.status_label)
|
|
232
|
+
list_layout.addLayout(select_layout)
|
|
233
|
+
|
|
234
|
+
# Platform table with checkboxes
|
|
235
|
+
self.platform_table = QTableWidget()
|
|
236
|
+
self.platform_table.setColumnCount(3)
|
|
237
|
+
self.platform_table.setHorizontalHeaderLabels(["Select", "Platform", "Templates"])
|
|
238
|
+
self.platform_table.horizontalHeader().setStretchLastSection(True)
|
|
239
|
+
self.platform_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
240
|
+
self.platform_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
241
|
+
list_layout.addWidget(self.platform_table)
|
|
242
|
+
|
|
243
|
+
layout.addWidget(list_group)
|
|
244
|
+
|
|
245
|
+
# Options
|
|
246
|
+
options_layout = QHBoxLayout()
|
|
247
|
+
self.replace_check = QCheckBox("Replace existing templates if content changed")
|
|
248
|
+
options_layout.addWidget(self.replace_check)
|
|
249
|
+
options_layout.addStretch()
|
|
250
|
+
layout.addLayout(options_layout)
|
|
251
|
+
|
|
252
|
+
# Progress
|
|
253
|
+
self.progress_label = QLabel("")
|
|
254
|
+
layout.addWidget(self.progress_label)
|
|
255
|
+
|
|
256
|
+
# Buttons
|
|
257
|
+
btn_layout = QHBoxLayout()
|
|
258
|
+
btn_layout.addStretch()
|
|
259
|
+
|
|
260
|
+
self.download_btn = QPushButton("Download Selected")
|
|
261
|
+
self.download_btn.clicked.connect(self.start_download)
|
|
262
|
+
self.download_btn.setEnabled(False)
|
|
263
|
+
btn_layout.addWidget(self.download_btn)
|
|
264
|
+
|
|
265
|
+
close_btn = QPushButton("Close")
|
|
266
|
+
close_btn.setProperty("secondary", True)
|
|
267
|
+
close_btn.clicked.connect(self.accept)
|
|
268
|
+
btn_layout.addWidget(close_btn)
|
|
269
|
+
|
|
270
|
+
layout.addLayout(btn_layout)
|
|
271
|
+
|
|
272
|
+
def fetch_platforms(self):
|
|
273
|
+
"""Fetch available platforms from GitHub"""
|
|
274
|
+
if not REQUESTS_AVAILABLE:
|
|
275
|
+
QMessageBox.critical(self, "Error", "requests library not available.\nInstall with: pip install requests")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
self.fetch_btn.setEnabled(False)
|
|
279
|
+
self.status_label.setText("Fetching from GitHub...")
|
|
280
|
+
QApplication.processEvents()
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
response = requests.get(GITHUB_API_URL, timeout=30)
|
|
284
|
+
response.raise_for_status()
|
|
285
|
+
|
|
286
|
+
files = response.json()
|
|
287
|
+
templates = [f for f in files if f['name'].endswith('.textfsm')]
|
|
288
|
+
|
|
289
|
+
# Group by platform
|
|
290
|
+
self.platforms = defaultdict(list)
|
|
291
|
+
for t in templates:
|
|
292
|
+
platform = extract_platform(t['name'])
|
|
293
|
+
self.platforms[platform].append(t)
|
|
294
|
+
|
|
295
|
+
# Populate table
|
|
296
|
+
self.platform_table.setRowCount(len(self.platforms))
|
|
297
|
+
for row, (platform, tmpl_list) in enumerate(sorted(self.platforms.items(), key=lambda x: -len(x[1]))):
|
|
298
|
+
# Checkbox
|
|
299
|
+
checkbox = QCheckBox()
|
|
300
|
+
self.platform_table.setCellWidget(row, 0, checkbox)
|
|
301
|
+
|
|
302
|
+
# Platform name
|
|
303
|
+
self.platform_table.setItem(row, 1, QTableWidgetItem(platform))
|
|
304
|
+
|
|
305
|
+
# Template count
|
|
306
|
+
self.platform_table.setItem(row, 2, QTableWidgetItem(str(len(tmpl_list))))
|
|
307
|
+
|
|
308
|
+
self.status_label.setText(f"Found {len(templates)} templates across {len(self.platforms)} platforms")
|
|
309
|
+
self.download_btn.setEnabled(True)
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
traceback.print_exc()
|
|
313
|
+
QMessageBox.critical(self, "Error", f"Failed to fetch platforms:\n{str(e)}")
|
|
314
|
+
self.status_label.setText("Fetch failed")
|
|
315
|
+
|
|
316
|
+
self.fetch_btn.setEnabled(True)
|
|
317
|
+
|
|
318
|
+
def select_all(self):
|
|
319
|
+
for row in range(self.platform_table.rowCount()):
|
|
320
|
+
checkbox = self.platform_table.cellWidget(row, 0)
|
|
321
|
+
if checkbox:
|
|
322
|
+
checkbox.setChecked(True)
|
|
323
|
+
|
|
324
|
+
def select_none(self):
|
|
325
|
+
for row in range(self.platform_table.rowCount()):
|
|
326
|
+
checkbox = self.platform_table.cellWidget(row, 0)
|
|
327
|
+
if checkbox:
|
|
328
|
+
checkbox.setChecked(False)
|
|
329
|
+
|
|
330
|
+
def get_selected_platforms(self) -> list:
|
|
331
|
+
selected = []
|
|
332
|
+
for row in range(self.platform_table.rowCount()):
|
|
333
|
+
checkbox = self.platform_table.cellWidget(row, 0)
|
|
334
|
+
if checkbox and checkbox.isChecked():
|
|
335
|
+
item = self.platform_table.item(row, 1)
|
|
336
|
+
if item:
|
|
337
|
+
selected.append(item.text())
|
|
338
|
+
return selected
|
|
339
|
+
|
|
340
|
+
def start_download(self):
|
|
341
|
+
selected = self.get_selected_platforms()
|
|
342
|
+
if not selected:
|
|
343
|
+
QMessageBox.warning(self, "Warning", "Please select at least one platform")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
self.download_btn.setEnabled(False)
|
|
347
|
+
self.fetch_btn.setEnabled(False)
|
|
348
|
+
self.progress_label.setText("Starting download...")
|
|
349
|
+
|
|
350
|
+
self.worker = NTCDownloadWorker(selected, self.db_path, self.replace_check.isChecked())
|
|
351
|
+
self.worker.progress.connect(self.update_progress)
|
|
352
|
+
self.worker.finished.connect(self.download_finished)
|
|
353
|
+
self.worker.error.connect(self.download_error)
|
|
354
|
+
self.worker.start()
|
|
355
|
+
|
|
356
|
+
def update_progress(self, current: int, total: int, status: str):
|
|
357
|
+
self.progress_label.setText(f"[{current}/{total}] {status}")
|
|
358
|
+
|
|
359
|
+
def download_finished(self, stats: dict):
|
|
360
|
+
self.download_btn.setEnabled(True)
|
|
361
|
+
self.fetch_btn.setEnabled(True)
|
|
362
|
+
|
|
363
|
+
msg = f"Download Complete!\n\nImported: {stats['imported']}\nUpdated: {stats['updated']}\nSkipped: {stats['skipped']}\nErrors: {stats['errors']}"
|
|
364
|
+
self.progress_label.setText(
|
|
365
|
+
f"Done: {stats['imported']} imported, {stats['updated']} updated, {stats['skipped']} skipped")
|
|
366
|
+
QMessageBox.information(self, "Download Complete", msg)
|
|
367
|
+
|
|
368
|
+
def download_error(self, error: str):
|
|
369
|
+
self.download_btn.setEnabled(True)
|
|
370
|
+
self.fetch_btn.setEnabled(True)
|
|
371
|
+
self.progress_label.setText("Download failed")
|
|
372
|
+
QMessageBox.critical(self, "Error", f"Download failed:\n{error}")
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TextFSM Validation Engine - Wrapper around tfsm_fire.TextFSMAutoEngine.
|
|
3
|
+
|
|
4
|
+
Validates collected output against TextFSM templates.
|
|
5
|
+
Only output with score > 0 is considered valid.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
engine = ValidationEngine(db_path="path/to/tfsm_templates.db")
|
|
9
|
+
result = engine.validate(output, filter_string="cisco_ios_show_version")
|
|
10
|
+
|
|
11
|
+
if result.is_valid:
|
|
12
|
+
print(f"Template: {result.template}")
|
|
13
|
+
print(f"Score: {result.score}")
|
|
14
|
+
print(f"Records: {len(result.parsed_data)}")
|
|
15
|
+
else:
|
|
16
|
+
print("Invalid output - no matching template")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
# Import the actual engine from core
|
|
24
|
+
from tfsm_fire import TextFSMAutoEngine
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ValidationResult:
|
|
29
|
+
"""Result of output validation."""
|
|
30
|
+
is_valid: bool
|
|
31
|
+
template: Optional[str] = None
|
|
32
|
+
parsed_data: Optional[List[Dict]] = None
|
|
33
|
+
score: float = 0.0
|
|
34
|
+
error: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def record_count(self) -> int:
|
|
38
|
+
"""Number of records parsed."""
|
|
39
|
+
return len(self.parsed_data) if self.parsed_data else 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ValidationEngine:
|
|
43
|
+
"""
|
|
44
|
+
TextFSM-based output validation engine.
|
|
45
|
+
|
|
46
|
+
Wraps tfsm_fire.TextFSMAutoEngine for use in the collection pipeline.
|
|
47
|
+
Output must achieve a score > 0 to be considered valid (no cruft).
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
db_path: Path to TextFSM templates database
|
|
51
|
+
min_score: Minimum score for valid output (default: 0.01)
|
|
52
|
+
verbose: Enable verbose logging
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
db_path: Optional[str] = None,
|
|
58
|
+
min_score: float = 0.01,
|
|
59
|
+
verbose: bool = False,
|
|
60
|
+
):
|
|
61
|
+
"""
|
|
62
|
+
Initialize validation engine.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
db_path: Path to tfsm_templates.db. If None, uses default location.
|
|
66
|
+
min_score: Minimum score to consider output valid.
|
|
67
|
+
verbose: Enable verbose output.
|
|
68
|
+
"""
|
|
69
|
+
# Default database location - check multiple places
|
|
70
|
+
if db_path is None:
|
|
71
|
+
possible_paths = [
|
|
72
|
+
Path(__file__).parent.parent / "core" / "tfsm_templates.db",
|
|
73
|
+
Path.home() / ".vcollector" / "tfsm_templates.db",
|
|
74
|
+
]
|
|
75
|
+
for p in possible_paths:
|
|
76
|
+
if p.exists():
|
|
77
|
+
db_path = str(p)
|
|
78
|
+
break
|
|
79
|
+
else:
|
|
80
|
+
raise FileNotFoundError(
|
|
81
|
+
f"TextFSM template database not found. Searched:\n"
|
|
82
|
+
f" - {possible_paths[0]}\n"
|
|
83
|
+
f" - {possible_paths[1]}\n"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.db_path = db_path
|
|
87
|
+
self.min_score = min_score
|
|
88
|
+
self.verbose = verbose
|
|
89
|
+
|
|
90
|
+
# Verify database exists
|
|
91
|
+
if not Path(db_path).exists():
|
|
92
|
+
raise FileNotFoundError(f"TextFSM template database not found: {db_path}")
|
|
93
|
+
|
|
94
|
+
# Initialize the actual engine
|
|
95
|
+
self._engine = TextFSMAutoEngine(db_path, verbose=verbose)
|
|
96
|
+
|
|
97
|
+
def _clean_output(self, raw_output: str) -> str:
|
|
98
|
+
"""
|
|
99
|
+
Clean raw CLI output for TextFSM parsing.
|
|
100
|
+
|
|
101
|
+
Removes:
|
|
102
|
+
- Preamble lines (terminal length, pagination messages)
|
|
103
|
+
- Command echo (hostname#show command)
|
|
104
|
+
- Trailing prompts
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
raw_output: Raw output from SSH session
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Cleaned output suitable for TextFSM parsing
|
|
111
|
+
"""
|
|
112
|
+
import re
|
|
113
|
+
|
|
114
|
+
lines = raw_output.split('\n')
|
|
115
|
+
cleaned_lines = []
|
|
116
|
+
found_output_start = False
|
|
117
|
+
|
|
118
|
+
# Common preamble patterns to skip
|
|
119
|
+
preamble_patterns = [
|
|
120
|
+
r'^terminal\s+(length|width)',
|
|
121
|
+
r'^pagination\s+disabled',
|
|
122
|
+
r'^screen-length\s+disable',
|
|
123
|
+
r'^\s*$', # Empty lines at start
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# Command echo pattern: hostname#command or hostname>command
|
|
127
|
+
# Also matches: hostname(config)#, hostname(config-if)#, etc.
|
|
128
|
+
command_echo_pattern = r'^[\w\-\.]+[\#\>\$\)].*?(show|display|get)\s+'
|
|
129
|
+
|
|
130
|
+
# Trailing prompt pattern
|
|
131
|
+
trailing_prompt_pattern = r'^[\w\-\.]+[\#\>\$\)]\s*$'
|
|
132
|
+
|
|
133
|
+
for i, line in enumerate(lines):
|
|
134
|
+
line_stripped = line.strip()
|
|
135
|
+
|
|
136
|
+
# Skip empty lines at the start
|
|
137
|
+
if not found_output_start and not line_stripped:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Skip preamble lines
|
|
141
|
+
if not found_output_start:
|
|
142
|
+
is_preamble = False
|
|
143
|
+
for pattern in preamble_patterns:
|
|
144
|
+
if re.match(pattern, line_stripped, re.IGNORECASE):
|
|
145
|
+
is_preamble = True
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
if is_preamble:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Check for command echo line
|
|
152
|
+
if re.match(command_echo_pattern, line_stripped, re.IGNORECASE):
|
|
153
|
+
found_output_start = True
|
|
154
|
+
continue # Skip the command echo itself
|
|
155
|
+
|
|
156
|
+
# If we get here without matching preamble or command echo,
|
|
157
|
+
# this is probably the start of actual output
|
|
158
|
+
found_output_start = True
|
|
159
|
+
|
|
160
|
+
# Skip trailing prompts
|
|
161
|
+
if re.match(trailing_prompt_pattern, line_stripped):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
cleaned_lines.append(line)
|
|
165
|
+
|
|
166
|
+
# Remove trailing empty lines
|
|
167
|
+
while cleaned_lines and not cleaned_lines[-1].strip():
|
|
168
|
+
cleaned_lines.pop()
|
|
169
|
+
|
|
170
|
+
return '\n'.join(cleaned_lines)
|
|
171
|
+
|
|
172
|
+
def validate(
|
|
173
|
+
self,
|
|
174
|
+
device_output: str,
|
|
175
|
+
filter_string: Optional[str] = None,
|
|
176
|
+
) -> ValidationResult:
|
|
177
|
+
"""
|
|
178
|
+
Validate device output against TextFSM templates.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
device_output: Raw CLI output from device.
|
|
182
|
+
filter_string: Template filter (e.g., "cisco_ios_show_version").
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
ValidationResult with validation status and parsed data.
|
|
186
|
+
"""
|
|
187
|
+
if not device_output or not device_output.strip():
|
|
188
|
+
return ValidationResult(
|
|
189
|
+
is_valid=False,
|
|
190
|
+
error="Empty output"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
# Clean the output before validation
|
|
195
|
+
cleaned_output = self._clean_output(device_output)
|
|
196
|
+
|
|
197
|
+
if self.verbose:
|
|
198
|
+
print(f"[VALIDATION] Cleaned output ({len(cleaned_output)} chars):")
|
|
199
|
+
print(cleaned_output[:500] + "..." if len(cleaned_output) > 500 else cleaned_output)
|
|
200
|
+
|
|
201
|
+
# Use tfsm_fire engine to find best template
|
|
202
|
+
template, parsed_data, score = self._engine.find_best_template(
|
|
203
|
+
cleaned_output, filter_string
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
is_valid = score >= self.min_score and parsed_data is not None
|
|
207
|
+
|
|
208
|
+
return ValidationResult(
|
|
209
|
+
is_valid=is_valid,
|
|
210
|
+
template=template,
|
|
211
|
+
parsed_data=parsed_data,
|
|
212
|
+
score=score,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
return ValidationResult(
|
|
217
|
+
is_valid=False,
|
|
218
|
+
error=str(e)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def list_templates(self, filter_string: Optional[str] = None) -> List[str]:
|
|
222
|
+
"""List available templates matching filter."""
|
|
223
|
+
with self._engine.connection_manager.get_connection() as conn:
|
|
224
|
+
templates = self._engine.get_filtered_templates(conn, filter_string)
|
|
225
|
+
return [t['cli_command'] for t in templates]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Convenience function for simple validation
|
|
229
|
+
def validate_output(
|
|
230
|
+
output: str,
|
|
231
|
+
filter_string: str,
|
|
232
|
+
db_path: Optional[str] = None,
|
|
233
|
+
) -> ValidationResult:
|
|
234
|
+
"""
|
|
235
|
+
Validate device output against TextFSM templates.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
output: Raw CLI output from device.
|
|
239
|
+
filter_string: Template filter (e.g., "cisco_ios_show_version").
|
|
240
|
+
db_path: Path to template database (optional).
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
ValidationResult with validation status.
|
|
244
|
+
"""
|
|
245
|
+
engine = ValidationEngine(db_path=db_path)
|
|
246
|
+
return engine.validate(output, filter_string)
|