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.
@@ -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)