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
nterm/scripting/api.py
CHANGED
|
@@ -5,13 +5,273 @@ Scripting API for nterm - usable from IPython, CLI, or MCP tools.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
8
10
|
import fnmatch
|
|
9
|
-
from typing import Optional, List, Dict, Any
|
|
10
|
-
from dataclasses import dataclass, asdict
|
|
11
|
+
from typing import Optional, List, Dict, Any, Union
|
|
12
|
+
from dataclasses import dataclass, asdict, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
import paramiko
|
|
11
16
|
|
|
12
17
|
from ..manager.models import SessionStore, SavedSession, SessionFolder
|
|
13
18
|
from ..vault.resolver import CredentialResolver
|
|
14
19
|
from ..vault.store import StoredCredential
|
|
20
|
+
from ..connection.profile import ConnectionProfile, AuthMethod
|
|
21
|
+
|
|
22
|
+
# Reuse SSHSession's algorithm configuration
|
|
23
|
+
from ..session.ssh import (
|
|
24
|
+
_apply_global_transport_settings,
|
|
25
|
+
RSA_SHA1_DISABLED_ALGORITHMS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# TextFSM parsing - REQUIRED
|
|
29
|
+
try:
|
|
30
|
+
from nterm.parser.tfsm_fire import TextFSMAutoEngine
|
|
31
|
+
TFSM_AVAILABLE = True
|
|
32
|
+
except ImportError as e:
|
|
33
|
+
TFSM_AVAILABLE = False
|
|
34
|
+
TextFSMAutoEngine = None
|
|
35
|
+
_TFSM_IMPORT_ERROR = str(e)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Platform Detection
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
PLATFORM_PATTERNS = {
|
|
43
|
+
'arista_eos': [
|
|
44
|
+
r'Arista',
|
|
45
|
+
r'vEOS',
|
|
46
|
+
],
|
|
47
|
+
'cisco_ios': [
|
|
48
|
+
r'Cisco IOS Software',
|
|
49
|
+
r'IOS \(tm\)',
|
|
50
|
+
],
|
|
51
|
+
'cisco_nxos': [
|
|
52
|
+
r'Cisco Nexus',
|
|
53
|
+
r'NX-OS',
|
|
54
|
+
],
|
|
55
|
+
'cisco_iosxe': [
|
|
56
|
+
r'Cisco IOS XE Software',
|
|
57
|
+
],
|
|
58
|
+
'cisco_iosxr': [
|
|
59
|
+
r'Cisco IOS XR Software',
|
|
60
|
+
],
|
|
61
|
+
'juniper_junos': [
|
|
62
|
+
r'JUNOS',
|
|
63
|
+
r'Juniper Networks',
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# =============================================================================
|
|
69
|
+
# Platform-specific command mappings
|
|
70
|
+
# =============================================================================
|
|
71
|
+
|
|
72
|
+
PLATFORM_COMMANDS = {
|
|
73
|
+
'arista_eos': {
|
|
74
|
+
'interfaces_status': 'show interfaces status',
|
|
75
|
+
'interface_detail': 'show interfaces {name}',
|
|
76
|
+
},
|
|
77
|
+
'cisco_ios': {
|
|
78
|
+
'interfaces_status': 'show interfaces status',
|
|
79
|
+
'interface_detail': 'show interfaces {name}',
|
|
80
|
+
},
|
|
81
|
+
'cisco_nxos': {
|
|
82
|
+
'interfaces_status': 'show interface status',
|
|
83
|
+
'interface_detail': 'show interface {name}',
|
|
84
|
+
},
|
|
85
|
+
'juniper_junos': {
|
|
86
|
+
'interfaces_status': 'show interfaces terse',
|
|
87
|
+
'interface_detail': 'show interfaces {name} extensive',
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
DEFAULT_COMMANDS = {
|
|
92
|
+
'interfaces_status': 'show interfaces status',
|
|
93
|
+
'interface_detail': 'show interfaces {name}',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Vendor Field Mappings for output normalization
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
# Maps canonical field names to vendor-specific template field names
|
|
102
|
+
INTERFACE_DETAIL_FIELD_MAP = {
|
|
103
|
+
'arista_eos': {
|
|
104
|
+
'interface': ['INTERFACE'],
|
|
105
|
+
'admin_state': ['LINK_STATUS'],
|
|
106
|
+
'oper_state': ['PROTOCOL_STATUS'],
|
|
107
|
+
'hardware': ['HARDWARE_TYPE'],
|
|
108
|
+
'mac_address': ['MAC_ADDRESS'],
|
|
109
|
+
'description': ['DESCRIPTION'],
|
|
110
|
+
'mtu': ['MTU'],
|
|
111
|
+
'bandwidth': ['BANDWIDTH'],
|
|
112
|
+
'in_packets': ['INPUT_PACKETS'],
|
|
113
|
+
'out_packets': ['OUTPUT_PACKETS'],
|
|
114
|
+
'in_errors': ['INPUT_ERRORS'],
|
|
115
|
+
'out_errors': ['OUTPUT_ERRORS'],
|
|
116
|
+
'crc_errors': ['CRC'],
|
|
117
|
+
},
|
|
118
|
+
'cisco_ios': {
|
|
119
|
+
'interface': ['INTERFACE'],
|
|
120
|
+
'admin_state': ['LINK_STATUS'],
|
|
121
|
+
'oper_state': ['PROTOCOL_STATUS'],
|
|
122
|
+
'hardware': ['HARDWARE_TYPE'],
|
|
123
|
+
'mac_address': ['MAC_ADDRESS'],
|
|
124
|
+
'description': ['DESCRIPTION'],
|
|
125
|
+
'mtu': ['MTU'],
|
|
126
|
+
'bandwidth': ['BANDWIDTH'],
|
|
127
|
+
'duplex': ['DUPLEX'],
|
|
128
|
+
'speed': ['SPEED'],
|
|
129
|
+
'in_packets': ['INPUT_PACKETS'],
|
|
130
|
+
'out_packets': ['OUTPUT_PACKETS'],
|
|
131
|
+
'in_errors': ['INPUT_ERRORS'],
|
|
132
|
+
'out_errors': ['OUTPUT_ERRORS'],
|
|
133
|
+
'crc_errors': ['CRC'],
|
|
134
|
+
},
|
|
135
|
+
'cisco_nxos': {
|
|
136
|
+
'interface': ['INTERFACE'],
|
|
137
|
+
'admin_state': ['ADMIN_STATE', 'LINK_STATUS'],
|
|
138
|
+
'oper_state': ['OPER_STATE', 'PROTOCOL_STATUS'],
|
|
139
|
+
'hardware': ['HARDWARE_TYPE'],
|
|
140
|
+
'mac_address': ['MAC_ADDRESS', 'ADDRESS'],
|
|
141
|
+
'description': ['DESCRIPTION'],
|
|
142
|
+
'mtu': ['MTU'],
|
|
143
|
+
'bandwidth': ['BANDWIDTH', 'BW'],
|
|
144
|
+
'in_packets': ['IN_PKTS', 'INPUT_PACKETS'],
|
|
145
|
+
'out_packets': ['OUT_PKTS', 'OUTPUT_PACKETS'],
|
|
146
|
+
'in_errors': ['IN_ERRORS', 'INPUT_ERRORS'],
|
|
147
|
+
'out_errors': ['OUT_ERRORS', 'OUTPUT_ERRORS'],
|
|
148
|
+
'crc_errors': ['CRC', 'CRC_ERRORS'],
|
|
149
|
+
},
|
|
150
|
+
'juniper_junos': {
|
|
151
|
+
'interface': ['INTERFACE'],
|
|
152
|
+
'admin_state': ['ADMIN_STATE'],
|
|
153
|
+
'oper_state': ['LINK_STATUS'],
|
|
154
|
+
'hardware': ['HARDWARE_TYPE'],
|
|
155
|
+
'mac_address': ['MAC_ADDRESS'],
|
|
156
|
+
'description': ['DESCRIPTION'],
|
|
157
|
+
'mtu': ['MTU'],
|
|
158
|
+
'bandwidth': ['BANDWIDTH'],
|
|
159
|
+
'in_packets': ['INPUT_PACKETS'],
|
|
160
|
+
'out_packets': ['OUTPUT_PACKETS'],
|
|
161
|
+
'in_errors': ['INPUT_ERRORS'],
|
|
162
|
+
'out_errors': ['OUTPUT_ERRORS'],
|
|
163
|
+
'crc_errors': ['CRC'],
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
DEFAULT_FIELD_MAP = {
|
|
168
|
+
'interface': ['INTERFACE', 'PORT', 'NAME'],
|
|
169
|
+
'admin_state': ['ADMIN_STATE', 'LINK_STATUS', 'STATUS'],
|
|
170
|
+
'oper_state': ['OPER_STATE', 'PROTOCOL_STATUS', 'LINE_STATUS'],
|
|
171
|
+
'hardware': ['HARDWARE_TYPE', 'HARDWARE', 'MEDIA_TYPE', 'TYPE'],
|
|
172
|
+
'mac_address': ['MAC_ADDRESS', 'ADDRESS', 'MAC'],
|
|
173
|
+
'description': ['DESCRIPTION', 'NAME', 'DESC'],
|
|
174
|
+
'mtu': ['MTU'],
|
|
175
|
+
'bandwidth': ['BANDWIDTH', 'BW', 'SPEED'],
|
|
176
|
+
'duplex': ['DUPLEX'],
|
|
177
|
+
'speed': ['SPEED'],
|
|
178
|
+
'in_packets': ['INPUT_PACKETS', 'IN_PKTS', 'IN_PACKETS'],
|
|
179
|
+
'out_packets': ['OUTPUT_PACKETS', 'OUT_PKTS', 'OUT_PACKETS'],
|
|
180
|
+
'in_errors': ['INPUT_ERRORS', 'IN_ERRORS'],
|
|
181
|
+
'out_errors': ['OUTPUT_ERRORS', 'OUT_ERRORS'],
|
|
182
|
+
'crc_errors': ['CRC', 'CRC_ERRORS'],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class ActiveSession:
|
|
188
|
+
"""Represents an active SSH connection to a device."""
|
|
189
|
+
device_name: str
|
|
190
|
+
hostname: str
|
|
191
|
+
port: int
|
|
192
|
+
platform: Optional[str] = None
|
|
193
|
+
client: Optional[paramiko.SSHClient] = None
|
|
194
|
+
shell: Optional[paramiko.Channel] = None
|
|
195
|
+
prompt: Optional[str] = None
|
|
196
|
+
connected_at: datetime = field(default_factory=datetime.now)
|
|
197
|
+
|
|
198
|
+
def is_connected(self) -> bool:
|
|
199
|
+
"""Check if session is still active."""
|
|
200
|
+
return self.client is not None and self.shell is not None and self.shell.active
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
status = "connected" if self.is_connected() else "disconnected"
|
|
204
|
+
platform = f", platform={self.platform}" if self.platform else ""
|
|
205
|
+
return f"<ActiveSession {self.device_name}@{self.hostname}:{self.port} {status}{platform}>"
|
|
206
|
+
|
|
207
|
+
def __str__(self) -> str:
|
|
208
|
+
"""Detailed string representation."""
|
|
209
|
+
lines = [
|
|
210
|
+
f"Active Session: {self.device_name}",
|
|
211
|
+
f" Host: {self.hostname}:{self.port}",
|
|
212
|
+
f" Status: {'connected' if self.is_connected() else 'disconnected'}",
|
|
213
|
+
]
|
|
214
|
+
if self.platform:
|
|
215
|
+
lines.append(f" Platform: {self.platform}")
|
|
216
|
+
if self.prompt:
|
|
217
|
+
lines.append(f" Prompt: {self.prompt}")
|
|
218
|
+
lines.append(f" Connected at: {self.connected_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
219
|
+
return '\n'.join(lines)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class CommandResult:
|
|
224
|
+
"""Result of executing a command on a device."""
|
|
225
|
+
command: str
|
|
226
|
+
raw_output: str
|
|
227
|
+
platform: Optional[str] = None
|
|
228
|
+
parsed_data: Optional[List[Dict[str, Any]]] = None
|
|
229
|
+
parse_success: bool = False
|
|
230
|
+
parse_template: Optional[str] = None
|
|
231
|
+
normalized_fields: Optional[Dict[str, str]] = None
|
|
232
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
233
|
+
|
|
234
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
235
|
+
"""Convert to dictionary."""
|
|
236
|
+
return {
|
|
237
|
+
'command': self.command,
|
|
238
|
+
'raw_output': self.raw_output,
|
|
239
|
+
'platform': self.platform,
|
|
240
|
+
'parsed_data': self.parsed_data,
|
|
241
|
+
'parse_success': self.parse_success,
|
|
242
|
+
'parse_template': self.parse_template,
|
|
243
|
+
'normalized_fields': self.normalized_fields,
|
|
244
|
+
'timestamp': self.timestamp.isoformat(),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def __repr__(self) -> str:
|
|
248
|
+
parsed = f", {len(self.parsed_data)} parsed" if self.parsed_data else ""
|
|
249
|
+
platform = f", platform={self.platform}" if self.platform else ""
|
|
250
|
+
return f"<CommandResult '{self.command}'{platform}{parsed}>"
|
|
251
|
+
|
|
252
|
+
def __str__(self) -> str:
|
|
253
|
+
"""Detailed string representation."""
|
|
254
|
+
lines = [
|
|
255
|
+
f"Command: {self.command}",
|
|
256
|
+
f"Timestamp: {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
257
|
+
]
|
|
258
|
+
if self.platform:
|
|
259
|
+
lines.append(f"Platform: {self.platform}")
|
|
260
|
+
|
|
261
|
+
lines.append(f"Parse success: {self.parse_success}")
|
|
262
|
+
if self.parse_template:
|
|
263
|
+
lines.append(f"Template: {self.parse_template}")
|
|
264
|
+
|
|
265
|
+
if self.parsed_data:
|
|
266
|
+
lines.append(f"Parsed rows: {len(self.parsed_data)}")
|
|
267
|
+
if self.normalized_fields:
|
|
268
|
+
lines.append(f"Field normalization: {self.normalized_fields['map_used']}")
|
|
269
|
+
|
|
270
|
+
lines.append(f"\nRaw output ({len(self.raw_output)} chars):")
|
|
271
|
+
lines.append("-" * 60)
|
|
272
|
+
lines.append(self.raw_output[:500] + ("..." if len(self.raw_output) > 500 else ""))
|
|
273
|
+
|
|
274
|
+
return '\n'.join(lines)
|
|
15
275
|
|
|
16
276
|
|
|
17
277
|
@dataclass
|
|
@@ -45,6 +305,22 @@ class DeviceInfo:
|
|
|
45
305
|
folder = f", folder={self.folder}" if self.folder else ""
|
|
46
306
|
return f"Device({self.name}, {self.hostname}:{self.port}{cred}{folder})"
|
|
47
307
|
|
|
308
|
+
def __str__(self) -> str:
|
|
309
|
+
"""Detailed string representation."""
|
|
310
|
+
lines = [
|
|
311
|
+
f"Device: {self.name}",
|
|
312
|
+
f" Hostname: {self.hostname}:{self.port}",
|
|
313
|
+
]
|
|
314
|
+
if self.folder:
|
|
315
|
+
lines.append(f" Folder: {self.folder}")
|
|
316
|
+
if self.credential:
|
|
317
|
+
lines.append(f" Credential: {self.credential}")
|
|
318
|
+
if self.last_connected:
|
|
319
|
+
lines.append(f" Last connected: {self.last_connected}")
|
|
320
|
+
if self.connect_count > 0:
|
|
321
|
+
lines.append(f" Connection count: {self.connect_count}")
|
|
322
|
+
return '\n'.join(lines)
|
|
323
|
+
|
|
48
324
|
|
|
49
325
|
@dataclass
|
|
50
326
|
class CredentialInfo:
|
|
@@ -71,6 +347,23 @@ class CredentialInfo:
|
|
|
71
347
|
default = " [default]" if self.is_default else ""
|
|
72
348
|
return f"Credential({self.name}, user={self.username}, auth={auth_str}{default})"
|
|
73
349
|
|
|
350
|
+
def __str__(self) -> str:
|
|
351
|
+
"""Detailed string representation."""
|
|
352
|
+
lines = [
|
|
353
|
+
f"Credential: {self.name}",
|
|
354
|
+
f" Username: {self.username}",
|
|
355
|
+
f" Authentication: {'password' if self.has_password else ''}{'+' if self.has_password and self.has_key else ''}{'SSH key' if self.has_key else ''}",
|
|
356
|
+
]
|
|
357
|
+
if self.match_hosts:
|
|
358
|
+
lines.append(f" Host patterns: {', '.join(self.match_hosts)}")
|
|
359
|
+
if self.match_tags:
|
|
360
|
+
lines.append(f" Tags: {', '.join(self.match_tags)}")
|
|
361
|
+
if self.jump_host:
|
|
362
|
+
lines.append(f" Jump host: {self.jump_host}")
|
|
363
|
+
if self.is_default:
|
|
364
|
+
lines.append(f" [DEFAULT]")
|
|
365
|
+
return '\n'.join(lines)
|
|
366
|
+
|
|
74
367
|
|
|
75
368
|
class NTermAPI:
|
|
76
369
|
"""
|
|
@@ -100,11 +393,39 @@ class NTermAPI:
|
|
|
100
393
|
self,
|
|
101
394
|
session_store: SessionStore = None,
|
|
102
395
|
credential_resolver: CredentialResolver = None,
|
|
396
|
+
tfsm_db_path: str = None,
|
|
103
397
|
):
|
|
104
398
|
self._sessions = session_store or SessionStore()
|
|
105
399
|
self._resolver = credential_resolver or CredentialResolver()
|
|
106
400
|
self._folder_cache: Dict[int, str] = {}
|
|
107
|
-
self._active_sessions: Dict[str,
|
|
401
|
+
self._active_sessions: Dict[str, ActiveSession] = {}
|
|
402
|
+
|
|
403
|
+
# Initialize TextFSM engine - REQUIRED for command parsing
|
|
404
|
+
if not TFSM_AVAILABLE:
|
|
405
|
+
error_msg = (
|
|
406
|
+
"TextFSM parser not available. "
|
|
407
|
+
"Ensure tfsm_fire.py is in nterm/parser/\n"
|
|
408
|
+
)
|
|
409
|
+
if '_TFSM_IMPORT_ERROR' in globals():
|
|
410
|
+
error_msg += f"Import error: {_TFSM_IMPORT_ERROR}"
|
|
411
|
+
raise RuntimeError(error_msg)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
# Default to looking for tfsm_templates.db in current directory
|
|
415
|
+
if not tfsm_db_path:
|
|
416
|
+
tfsm_db_path = "./tfsm_templates.db"
|
|
417
|
+
elif tfsm_db_path == "./":
|
|
418
|
+
tfsm_db_path = "./tfsm_templates.db"
|
|
419
|
+
|
|
420
|
+
self._tfsm_engine = TextFSMAutoEngine(db_path=tfsm_db_path)
|
|
421
|
+
if not self._tfsm_engine or not self._tfsm_engine.db_path:
|
|
422
|
+
raise RuntimeError("TextFSM engine initialized but no database path")
|
|
423
|
+
except Exception as e:
|
|
424
|
+
raise RuntimeError(
|
|
425
|
+
f"Failed to initialize TextFSM engine: {e}\n"
|
|
426
|
+
f"Expected database at: {tfsm_db_path or 'default location'}\n"
|
|
427
|
+
"The API requires tfsm_templates.db for command parsing."
|
|
428
|
+
)
|
|
108
429
|
|
|
109
430
|
# -------------------------------------------------------------------------
|
|
110
431
|
# Device / Session listing
|
|
@@ -334,40 +655,577 @@ class NTermAPI:
|
|
|
334
655
|
return None
|
|
335
656
|
|
|
336
657
|
# -------------------------------------------------------------------------
|
|
337
|
-
# Connection operations
|
|
658
|
+
# Connection operations
|
|
338
659
|
# -------------------------------------------------------------------------
|
|
339
660
|
|
|
340
|
-
def
|
|
661
|
+
def _detect_platform(self, version_output: str) -> Optional[str]:
|
|
662
|
+
"""Detect device platform from 'show version' output."""
|
|
663
|
+
for platform, patterns in PLATFORM_PATTERNS.items():
|
|
664
|
+
for pattern in patterns:
|
|
665
|
+
if re.search(pattern, version_output, re.IGNORECASE):
|
|
666
|
+
return platform
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
def _normalize_fields(
|
|
670
|
+
self,
|
|
671
|
+
parsed_data: List[Dict[str, Any]],
|
|
672
|
+
platform: str,
|
|
673
|
+
field_map_dict: Dict[str, Dict[str, List[str]]],
|
|
674
|
+
) -> List[Dict[str, Any]]:
|
|
675
|
+
"""
|
|
676
|
+
Normalize vendor-specific field names to canonical names.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
parsed_data: Raw parsed data from TextFSM
|
|
680
|
+
platform: Detected platform
|
|
681
|
+
field_map_dict: Mapping dict (e.g., INTERFACE_DETAIL_FIELD_MAP)
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
List of dicts with normalized field names
|
|
685
|
+
"""
|
|
686
|
+
field_map = field_map_dict.get(platform, DEFAULT_FIELD_MAP)
|
|
687
|
+
normalized = []
|
|
688
|
+
|
|
689
|
+
for row in parsed_data:
|
|
690
|
+
norm_row = {}
|
|
691
|
+
for canonical_name, vendor_names in field_map.items():
|
|
692
|
+
for vendor_name in vendor_names:
|
|
693
|
+
if vendor_name in row:
|
|
694
|
+
norm_row[canonical_name] = row[vendor_name]
|
|
695
|
+
break
|
|
696
|
+
# Keep any fields that weren't in the mapping
|
|
697
|
+
for key, value in row.items():
|
|
698
|
+
if key not in [vn for vnames in field_map.values() for vn in vnames]:
|
|
699
|
+
norm_row[key] = value
|
|
700
|
+
normalized.append(norm_row)
|
|
701
|
+
|
|
702
|
+
return normalized
|
|
703
|
+
|
|
704
|
+
def _wait_for_prompt(
|
|
705
|
+
self,
|
|
706
|
+
shell: paramiko.Channel,
|
|
707
|
+
timeout: int = 10,
|
|
708
|
+
initial_wait: float = 0.5,
|
|
709
|
+
) -> str:
|
|
710
|
+
"""
|
|
711
|
+
Wait for device prompt and return detected prompt pattern.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
Detected prompt string
|
|
715
|
+
"""
|
|
716
|
+
time.sleep(initial_wait)
|
|
717
|
+
|
|
718
|
+
# Send newline to trigger prompt
|
|
719
|
+
shell.send('\n')
|
|
720
|
+
time.sleep(0.3)
|
|
721
|
+
|
|
722
|
+
output = ""
|
|
723
|
+
end_time = time.time() + timeout
|
|
724
|
+
|
|
725
|
+
while time.time() < end_time:
|
|
726
|
+
if shell.recv_ready():
|
|
727
|
+
chunk = shell.recv(4096).decode('utf-8', errors='ignore')
|
|
728
|
+
output += chunk
|
|
729
|
+
time.sleep(0.1)
|
|
730
|
+
else:
|
|
731
|
+
break
|
|
732
|
+
|
|
733
|
+
# Extract last line as prompt (after last newline)
|
|
734
|
+
lines = output.strip().split('\n')
|
|
735
|
+
prompt = lines[-1] if lines else ""
|
|
736
|
+
|
|
737
|
+
# Common prompt patterns: ends with #, >, $
|
|
738
|
+
if prompt and prompt[-1] in '#>$':
|
|
739
|
+
return prompt
|
|
740
|
+
|
|
741
|
+
return prompt
|
|
742
|
+
|
|
743
|
+
def _send_command(
|
|
744
|
+
self,
|
|
745
|
+
shell: paramiko.Channel,
|
|
746
|
+
command: str,
|
|
747
|
+
prompt: str,
|
|
748
|
+
timeout: int = 30,
|
|
749
|
+
) -> str:
|
|
750
|
+
"""
|
|
751
|
+
Send command and collect output until prompt returns.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
shell: Active SSH channel
|
|
755
|
+
command: Command to execute
|
|
756
|
+
prompt: Expected prompt pattern
|
|
757
|
+
timeout: Command timeout in seconds
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
Command output (without echoed command and prompt)
|
|
761
|
+
"""
|
|
762
|
+
# Clear any pending input aggressively
|
|
763
|
+
time.sleep(0.1)
|
|
764
|
+
while shell.recv_ready():
|
|
765
|
+
shell.recv(65536)
|
|
766
|
+
time.sleep(0.05)
|
|
767
|
+
|
|
768
|
+
# Send command - strip whitespace to avoid issues
|
|
769
|
+
command = command.strip()
|
|
770
|
+
shell.send(command + '\n')
|
|
771
|
+
time.sleep(0.3) # Give device time to echo command
|
|
772
|
+
|
|
773
|
+
output = ""
|
|
774
|
+
end_time = time.time() + timeout
|
|
775
|
+
prompt_seen = False
|
|
776
|
+
|
|
777
|
+
# Paging prompts to handle (--More--, -- More --, etc.)
|
|
778
|
+
paging_prompts = [
|
|
779
|
+
'--More--',
|
|
780
|
+
'-- More --',
|
|
781
|
+
'<--- More --->',
|
|
782
|
+
'Press any key to continue',
|
|
783
|
+
]
|
|
784
|
+
|
|
785
|
+
while time.time() < end_time:
|
|
786
|
+
if shell.recv_ready():
|
|
787
|
+
chunk = shell.recv(65536).decode('utf-8', errors='ignore')
|
|
788
|
+
output += chunk
|
|
789
|
+
|
|
790
|
+
# Check for paging prompt
|
|
791
|
+
for paging_prompt in paging_prompts:
|
|
792
|
+
if paging_prompt in output:
|
|
793
|
+
# Send space to continue
|
|
794
|
+
shell.send(' ')
|
|
795
|
+
time.sleep(0.2)
|
|
796
|
+
# Remove paging prompt from output
|
|
797
|
+
output = output.replace(paging_prompt, '')
|
|
798
|
+
break
|
|
799
|
+
|
|
800
|
+
# Check if we've received the final prompt
|
|
801
|
+
if prompt in output:
|
|
802
|
+
prompt_seen = True
|
|
803
|
+
# Give a bit more time for any trailing data
|
|
804
|
+
time.sleep(0.1)
|
|
805
|
+
if shell.recv_ready():
|
|
806
|
+
chunk = shell.recv(65536).decode('utf-8', errors='ignore')
|
|
807
|
+
output += chunk
|
|
808
|
+
break
|
|
809
|
+
|
|
810
|
+
time.sleep(0.1)
|
|
811
|
+
else:
|
|
812
|
+
if prompt_seen or len(output) > 0:
|
|
813
|
+
time.sleep(0.3)
|
|
814
|
+
if not shell.recv_ready():
|
|
815
|
+
break
|
|
816
|
+
|
|
817
|
+
# Clean up output: remove echoed command and prompt
|
|
818
|
+
lines = output.split('\n')
|
|
819
|
+
|
|
820
|
+
# Remove first line if it contains the echoed command
|
|
821
|
+
if lines and command.lower() in lines[0].lower():
|
|
822
|
+
lines = lines[1:]
|
|
823
|
+
|
|
824
|
+
# Remove last line if it's the prompt
|
|
825
|
+
if lines and prompt in lines[-1]:
|
|
826
|
+
lines = lines[:-1]
|
|
827
|
+
|
|
828
|
+
# Also remove any lines that are just the prompt or empty
|
|
829
|
+
cleaned_lines = []
|
|
830
|
+
for line in lines:
|
|
831
|
+
stripped = line.strip('\r\n ')
|
|
832
|
+
# Skip prompt lines and residual paging artifacts
|
|
833
|
+
if stripped and stripped != prompt and not any(p in stripped for p in paging_prompts):
|
|
834
|
+
cleaned_lines.append(line)
|
|
835
|
+
|
|
836
|
+
return '\n'.join(cleaned_lines).strip()
|
|
837
|
+
|
|
838
|
+
def connect(self, device: str, credential: str = None) -> ActiveSession:
|
|
341
839
|
"""
|
|
342
|
-
Connect to a device.
|
|
840
|
+
Connect to a device and detect platform.
|
|
343
841
|
|
|
344
842
|
Args:
|
|
345
843
|
device: Device name (from saved sessions) or hostname
|
|
346
844
|
credential: Optional credential name (auto-resolved if not specified)
|
|
347
845
|
|
|
348
846
|
Returns:
|
|
349
|
-
|
|
847
|
+
ActiveSession handle for sending commands
|
|
848
|
+
|
|
849
|
+
Examples:
|
|
850
|
+
session = api.connect("spine1")
|
|
851
|
+
session = api.connect("192.168.1.1", credential="lab-admin")
|
|
350
852
|
"""
|
|
351
|
-
#
|
|
352
|
-
|
|
853
|
+
# Look up device from saved sessions first
|
|
854
|
+
device_info = self.device(device)
|
|
855
|
+
|
|
856
|
+
if device_info:
|
|
857
|
+
hostname = device_info.hostname
|
|
858
|
+
port = device_info.port
|
|
859
|
+
device_name = device_info.name
|
|
860
|
+
saved_cred = device_info.credential
|
|
861
|
+
else:
|
|
862
|
+
# Treat as hostname directly
|
|
863
|
+
hostname = device
|
|
864
|
+
port = 22
|
|
865
|
+
device_name = device
|
|
866
|
+
saved_cred = None
|
|
867
|
+
|
|
868
|
+
# Resolve credentials
|
|
869
|
+
if not self.vault_unlocked:
|
|
870
|
+
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
871
|
+
|
|
872
|
+
# Get credential - either specified or from saved session or auto-resolve
|
|
873
|
+
cred_name = credential or saved_cred
|
|
874
|
+
|
|
875
|
+
if cred_name:
|
|
876
|
+
# User specified a credential name - use resolver's method
|
|
877
|
+
try:
|
|
878
|
+
profile = self._resolver.create_profile_for_credential(
|
|
879
|
+
credential_name=cred_name,
|
|
880
|
+
hostname=hostname,
|
|
881
|
+
port=port,
|
|
882
|
+
)
|
|
883
|
+
except Exception as e:
|
|
884
|
+
raise ValueError(f"Failed to get credential '{cred_name}': {e}")
|
|
885
|
+
else:
|
|
886
|
+
# Auto-resolve based on hostname patterns
|
|
887
|
+
try:
|
|
888
|
+
profile = self._resolver.resolve_for_device(hostname, port=port)
|
|
889
|
+
except Exception as e:
|
|
890
|
+
raise ValueError(f"Failed to resolve credentials for {hostname}: {e}")
|
|
891
|
+
|
|
892
|
+
if not profile:
|
|
893
|
+
raise ValueError(f"No credentials available for {hostname}")
|
|
894
|
+
|
|
895
|
+
# Create SSH client
|
|
896
|
+
client = paramiko.SSHClient()
|
|
897
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
898
|
+
|
|
899
|
+
# Apply legacy algorithm support
|
|
900
|
+
_apply_global_transport_settings()
|
|
901
|
+
|
|
902
|
+
# Prepare connection kwargs
|
|
903
|
+
connect_kwargs = {
|
|
904
|
+
'hostname': hostname,
|
|
905
|
+
'port': port,
|
|
906
|
+
'timeout': 10,
|
|
907
|
+
'allow_agent': False,
|
|
908
|
+
'look_for_keys': False,
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
# Add authentication from profile
|
|
912
|
+
# ConnectionProfile has username in first auth method
|
|
913
|
+
if profile.auth_methods:
|
|
914
|
+
first_auth = profile.auth_methods[0]
|
|
915
|
+
connect_kwargs['username'] = first_auth.username
|
|
916
|
+
|
|
917
|
+
# Try each auth method in order
|
|
918
|
+
for auth in profile.auth_methods:
|
|
919
|
+
if auth.method == AuthMethod.PASSWORD:
|
|
920
|
+
connect_kwargs['password'] = auth.password
|
|
921
|
+
break
|
|
922
|
+
elif auth.method == AuthMethod.KEY_FILE:
|
|
923
|
+
connect_kwargs['key_filename'] = auth.key_file
|
|
924
|
+
break
|
|
925
|
+
elif auth.method == AuthMethod.KEY_STORED:
|
|
926
|
+
# KEY_STORED has key data as string, need to write to temp file
|
|
927
|
+
import tempfile
|
|
928
|
+
from io import StringIO
|
|
929
|
+
|
|
930
|
+
key_file = tempfile.NamedTemporaryFile(
|
|
931
|
+
mode='w',
|
|
932
|
+
delete=False,
|
|
933
|
+
suffix='.pem'
|
|
934
|
+
)
|
|
935
|
+
key_file.write(auth.key_data)
|
|
936
|
+
key_file.close()
|
|
937
|
+
|
|
938
|
+
connect_kwargs['key_filename'] = key_file.name
|
|
939
|
+
if auth.key_passphrase:
|
|
940
|
+
connect_kwargs['passphrase'] = auth.key_passphrase
|
|
941
|
+
break
|
|
942
|
+
|
|
943
|
+
try:
|
|
944
|
+
# Try connection with modern algorithms
|
|
945
|
+
client.connect(**connect_kwargs)
|
|
946
|
+
except paramiko.SSHException as e:
|
|
947
|
+
# If RSA SHA-1 issue, retry with disabled algorithms
|
|
948
|
+
if "rsa-sha2" in str(e).lower() or "server-sig-algs" in str(e).lower():
|
|
949
|
+
client.close()
|
|
950
|
+
client = paramiko.SSHClient()
|
|
951
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
952
|
+
|
|
953
|
+
# Apply RSA SHA-1 fallback
|
|
954
|
+
transport = client.get_transport()
|
|
955
|
+
if transport:
|
|
956
|
+
transport.get_security_options().digests = tuple(
|
|
957
|
+
alg for alg in transport.get_security_options().digests
|
|
958
|
+
if alg not in RSA_SHA1_DISABLED_ALGORITHMS
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
client.connect(**connect_kwargs)
|
|
962
|
+
else:
|
|
963
|
+
raise
|
|
964
|
+
|
|
965
|
+
# Open interactive shell
|
|
966
|
+
shell = client.invoke_shell(width=200, height=50)
|
|
967
|
+
shell.settimeout(0.5)
|
|
968
|
+
|
|
969
|
+
# Wait for initial prompt
|
|
970
|
+
prompt = self._wait_for_prompt(shell)
|
|
971
|
+
|
|
972
|
+
# Create session object
|
|
973
|
+
session = ActiveSession(
|
|
974
|
+
device_name=device_name,
|
|
975
|
+
hostname=hostname,
|
|
976
|
+
port=port,
|
|
977
|
+
client=client,
|
|
978
|
+
shell=shell,
|
|
979
|
+
prompt=prompt,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# Detect platform
|
|
983
|
+
try:
|
|
984
|
+
version_output = self._send_command(shell, "show version", prompt)
|
|
985
|
+
platform = self._detect_platform(version_output)
|
|
986
|
+
session.platform = platform
|
|
987
|
+
except Exception as e:
|
|
988
|
+
print(f"Warning: Failed to detect platform: {e}")
|
|
989
|
+
|
|
990
|
+
# Disable terminal paging for cleaner output
|
|
991
|
+
try:
|
|
992
|
+
if session.platform and 'cisco' in session.platform:
|
|
993
|
+
self._send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
994
|
+
elif session.platform == 'juniper_junos':
|
|
995
|
+
self._send_command(shell, "set cli screen-length 0", prompt, timeout=5)
|
|
996
|
+
elif session.platform == 'arista_eos':
|
|
997
|
+
self._send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
998
|
+
except Exception as e:
|
|
999
|
+
print(f"Warning: Failed to disable paging: {e}")
|
|
1000
|
+
|
|
1001
|
+
# Track active session
|
|
1002
|
+
self._active_sessions[device_name] = session
|
|
353
1003
|
|
|
354
|
-
|
|
1004
|
+
return session
|
|
1005
|
+
|
|
1006
|
+
def send(
|
|
1007
|
+
self,
|
|
1008
|
+
session: ActiveSession,
|
|
1009
|
+
command: str,
|
|
1010
|
+
timeout: int = 30,
|
|
1011
|
+
parse: bool = True,
|
|
1012
|
+
normalize: bool = True,
|
|
1013
|
+
) -> CommandResult:
|
|
355
1014
|
"""
|
|
356
1015
|
Send command to a connected session.
|
|
357
1016
|
|
|
358
1017
|
Args:
|
|
359
|
-
session:
|
|
1018
|
+
session: ActiveSession from connect()
|
|
360
1019
|
command: Command to execute
|
|
361
|
-
timeout:
|
|
1020
|
+
timeout: Command timeout in seconds
|
|
1021
|
+
parse: Whether to attempt TextFSM parsing
|
|
1022
|
+
normalize: Whether to normalize field names (requires parse=True)
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
CommandResult with raw and parsed output
|
|
1026
|
+
|
|
1027
|
+
Examples:
|
|
1028
|
+
result = api.send(session, "show version")
|
|
1029
|
+
result = api.send(session, "show interfaces status", parse=True)
|
|
1030
|
+
|
|
1031
|
+
# Access results
|
|
1032
|
+
print(result.raw_output)
|
|
1033
|
+
if result.parsed_data:
|
|
1034
|
+
for row in result.parsed_data:
|
|
1035
|
+
print(row)
|
|
1036
|
+
"""
|
|
1037
|
+
if not session.is_connected():
|
|
1038
|
+
raise RuntimeError(f"Session {session.device_name} is not connected")
|
|
1039
|
+
|
|
1040
|
+
# Execute command
|
|
1041
|
+
raw_output = self._send_command(session.shell, command, session.prompt, timeout)
|
|
1042
|
+
|
|
1043
|
+
# Create result object
|
|
1044
|
+
result = CommandResult(
|
|
1045
|
+
command=command,
|
|
1046
|
+
raw_output=raw_output,
|
|
1047
|
+
platform=session.platform,
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
# Attempt parsing if requested and TextFSM available
|
|
1051
|
+
if parse:
|
|
1052
|
+
if not self._tfsm_engine:
|
|
1053
|
+
raise RuntimeError(
|
|
1054
|
+
"TextFSM parser not initialized. Cannot parse command output. "
|
|
1055
|
+
"This should not happen - API should have failed during initialization."
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
if not session.platform:
|
|
1059
|
+
# Can't parse without platform hint
|
|
1060
|
+
result.parse_success = False
|
|
1061
|
+
else:
|
|
1062
|
+
try:
|
|
1063
|
+
# Convert command to filter string (e.g., "show version" -> "show_version")
|
|
1064
|
+
filter_string = command.strip().replace(' ', '_')
|
|
1065
|
+
|
|
1066
|
+
# Call the actual tfsm_fire method
|
|
1067
|
+
best_template, parsed_data, best_score, all_scores = self._tfsm_engine.find_best_template(
|
|
1068
|
+
device_output=raw_output,
|
|
1069
|
+
filter_string=filter_string,
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
if parsed_data and len(parsed_data) > 0:
|
|
1073
|
+
result.parsed_data = parsed_data
|
|
1074
|
+
result.parse_success = True
|
|
1075
|
+
result.parse_template = best_template
|
|
1076
|
+
|
|
1077
|
+
# Normalize field names if requested
|
|
1078
|
+
if normalize and parsed_data:
|
|
1079
|
+
# Determine which field map to use based on command
|
|
1080
|
+
if 'interface' in command.lower():
|
|
1081
|
+
normalized = self._normalize_fields(
|
|
1082
|
+
parsed_data,
|
|
1083
|
+
session.platform,
|
|
1084
|
+
INTERFACE_DETAIL_FIELD_MAP,
|
|
1085
|
+
)
|
|
1086
|
+
result.parsed_data = normalized
|
|
1087
|
+
result.normalized_fields = {
|
|
1088
|
+
'map_used': 'INTERFACE_DETAIL_FIELD_MAP',
|
|
1089
|
+
'platform': session.platform,
|
|
1090
|
+
}
|
|
1091
|
+
else:
|
|
1092
|
+
# Parsing returned empty/None
|
|
1093
|
+
result.parse_success = False
|
|
1094
|
+
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
# Parsing failed, but we still have raw output
|
|
1097
|
+
result.parse_success = False
|
|
1098
|
+
|
|
1099
|
+
return result
|
|
1100
|
+
|
|
1101
|
+
def disconnect(self, session: ActiveSession) -> None:
|
|
1102
|
+
"""
|
|
1103
|
+
Disconnect a session.
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
session: ActiveSession to close
|
|
1107
|
+
"""
|
|
1108
|
+
if session.device_name in self._active_sessions:
|
|
1109
|
+
del self._active_sessions[session.device_name]
|
|
1110
|
+
|
|
1111
|
+
if session.shell:
|
|
1112
|
+
session.shell.close()
|
|
1113
|
+
|
|
1114
|
+
if session.client:
|
|
1115
|
+
session.client.close()
|
|
1116
|
+
|
|
1117
|
+
def active_sessions(self) -> List[str]:
|
|
1118
|
+
"""
|
|
1119
|
+
List currently active session names.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
List of device names with active connections
|
|
1123
|
+
"""
|
|
1124
|
+
return list(self._active_sessions.keys())
|
|
1125
|
+
|
|
1126
|
+
def db_info(self) -> Dict[str, Any]:
|
|
1127
|
+
"""
|
|
1128
|
+
Get detailed information about TextFSM database.
|
|
1129
|
+
|
|
1130
|
+
Returns:
|
|
1131
|
+
Dict with database path, existence, and diagnostic info
|
|
1132
|
+
"""
|
|
1133
|
+
from pathlib import Path
|
|
1134
|
+
import os
|
|
1135
|
+
|
|
1136
|
+
info = {
|
|
1137
|
+
"engine_available": self._tfsm_engine is not None,
|
|
1138
|
+
"db_path": None,
|
|
1139
|
+
"db_exists": False,
|
|
1140
|
+
"db_absolute_path": None,
|
|
1141
|
+
"current_working_directory": os.getcwd(),
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
1145
|
+
info["db_path"] = self._tfsm_engine.db_path
|
|
1146
|
+
|
|
1147
|
+
if info["db_path"]:
|
|
1148
|
+
db_path = Path(info["db_path"])
|
|
1149
|
+
info["db_exists"] = db_path.exists()
|
|
1150
|
+
info["db_absolute_path"] = str(db_path.absolute())
|
|
1151
|
+
info["db_is_directory"] = db_path.is_dir() if db_path.exists() else None
|
|
1152
|
+
|
|
1153
|
+
if info["db_exists"]:
|
|
1154
|
+
if db_path.is_file():
|
|
1155
|
+
info["db_size"] = db_path.stat().st_size
|
|
1156
|
+
info["db_size_mb"] = round(db_path.stat().st_size / 1024 / 1024, 2)
|
|
1157
|
+
else:
|
|
1158
|
+
info["error"] = f"Path exists but is not a file: {db_path}"
|
|
1159
|
+
else:
|
|
1160
|
+
# Try common locations
|
|
1161
|
+
common_locations = [
|
|
1162
|
+
Path(os.getcwd()) / "tfsm_templates.db",
|
|
1163
|
+
Path.home() / ".nterm" / "tfsm_templates.db",
|
|
1164
|
+
Path(__file__).parent.parent / "tfsm_templates.db",
|
|
1165
|
+
]
|
|
1166
|
+
info["tried_locations"] = [str(p) for p in common_locations]
|
|
1167
|
+
info["found_at"] = [str(p) for p in common_locations if p.exists()]
|
|
1168
|
+
|
|
1169
|
+
return info
|
|
1170
|
+
|
|
1171
|
+
def debug_parse(
|
|
1172
|
+
self,
|
|
1173
|
+
command: str,
|
|
1174
|
+
output: str,
|
|
1175
|
+
platform: str,
|
|
1176
|
+
) -> Dict[str, Any]:
|
|
1177
|
+
"""
|
|
1178
|
+
Debug TextFSM parsing - useful for troubleshooting why parsing fails.
|
|
1179
|
+
|
|
1180
|
+
Args:
|
|
1181
|
+
command: Command that was executed
|
|
1182
|
+
output: Raw output from device
|
|
1183
|
+
platform: Platform hint (e.g., 'cisco_ios')
|
|
362
1184
|
|
|
363
1185
|
Returns:
|
|
364
|
-
|
|
1186
|
+
Dict with parsing debug info including:
|
|
1187
|
+
- parsed_data: Parsed results if successful
|
|
1188
|
+
- template_used: Template file name if found
|
|
1189
|
+
- error: Error message if parsing failed
|
|
1190
|
+
- attempted_templates: List of templates attempted
|
|
365
1191
|
"""
|
|
366
|
-
|
|
1192
|
+
if not self._tfsm_engine:
|
|
1193
|
+
return {"error": "TextFSM engine not initialized"}
|
|
1194
|
+
|
|
1195
|
+
debug_info = {
|
|
1196
|
+
"command": command,
|
|
1197
|
+
"platform": platform,
|
|
1198
|
+
"output_length": len(output),
|
|
1199
|
+
"output_preview": output[:200] if output else None,
|
|
1200
|
+
}
|
|
367
1201
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1202
|
+
try:
|
|
1203
|
+
# Convert command to filter string
|
|
1204
|
+
filter_string = command.strip().replace(' ', '_')
|
|
1205
|
+
|
|
1206
|
+
best_template, parsed_data, best_score, all_scores = self._tfsm_engine.find_best_template(
|
|
1207
|
+
device_output=output,
|
|
1208
|
+
filter_string=filter_string,
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
debug_info["parsed_data"] = parsed_data
|
|
1212
|
+
debug_info["parse_success"] = parsed_data is not None and len(parsed_data) > 0
|
|
1213
|
+
debug_info["template_used"] = best_template
|
|
1214
|
+
debug_info["best_score"] = best_score
|
|
1215
|
+
debug_info["all_scores"] = all_scores # List of (template_name, score, record_count)
|
|
1216
|
+
|
|
1217
|
+
if parsed_data:
|
|
1218
|
+
debug_info["row_count"] = len(parsed_data)
|
|
1219
|
+
if parsed_data:
|
|
1220
|
+
debug_info["sample_row"] = parsed_data[0]
|
|
1221
|
+
else:
|
|
1222
|
+
debug_info["error"] = "Parsing returned None or empty"
|
|
1223
|
+
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
debug_info["error"] = str(e)
|
|
1226
|
+
debug_info["parse_success"] = False
|
|
1227
|
+
|
|
1228
|
+
return debug_info
|
|
371
1229
|
|
|
372
1230
|
# -------------------------------------------------------------------------
|
|
373
1231
|
# Convenience / REPL helpers
|
|
@@ -376,14 +1234,16 @@ class NTermAPI:
|
|
|
376
1234
|
def __repr__(self) -> str:
|
|
377
1235
|
device_count = len(self._sessions.list_all_sessions())
|
|
378
1236
|
vault_status = "unlocked" if self.vault_unlocked else "locked"
|
|
379
|
-
|
|
1237
|
+
parser_status = "enabled" if self._tfsm_engine else "disabled"
|
|
1238
|
+
active = len(self._active_sessions)
|
|
1239
|
+
return f"<NTermAPI: {device_count} devices, vault {vault_status}, parser {parser_status}, {active} active>"
|
|
380
1240
|
|
|
381
1241
|
def status(self) -> Dict[str, Any]:
|
|
382
1242
|
"""
|
|
383
1243
|
Get API status summary.
|
|
384
1244
|
|
|
385
1245
|
Returns:
|
|
386
|
-
Dict with device count, folder count, credential count, vault status
|
|
1246
|
+
Dict with device count, folder count, credential count, vault status, parser status
|
|
387
1247
|
"""
|
|
388
1248
|
sessions = self._sessions.list_all_sessions()
|
|
389
1249
|
folders = self._sessions.get_tree()["folders"]
|
|
@@ -392,6 +1252,15 @@ class NTermAPI:
|
|
|
392
1252
|
if self.vault_unlocked:
|
|
393
1253
|
cred_count = len(self._resolver.list_credentials())
|
|
394
1254
|
|
|
1255
|
+
# Check parser DB status
|
|
1256
|
+
parser_db_path = None
|
|
1257
|
+
parser_db_exists = False
|
|
1258
|
+
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
1259
|
+
parser_db_path = self._tfsm_engine.db_path
|
|
1260
|
+
if parser_db_path:
|
|
1261
|
+
from pathlib import Path
|
|
1262
|
+
parser_db_exists = Path(parser_db_path).exists()
|
|
1263
|
+
|
|
395
1264
|
return {
|
|
396
1265
|
"devices": len(sessions),
|
|
397
1266
|
"folders": len(folders),
|
|
@@ -399,6 +1268,9 @@ class NTermAPI:
|
|
|
399
1268
|
"vault_initialized": self.vault_initialized,
|
|
400
1269
|
"vault_unlocked": self.vault_unlocked,
|
|
401
1270
|
"active_sessions": len(self._active_sessions),
|
|
1271
|
+
"parser_available": self._tfsm_engine is not None,
|
|
1272
|
+
"parser_db": parser_db_path,
|
|
1273
|
+
"parser_db_exists": parser_db_exists,
|
|
402
1274
|
}
|
|
403
1275
|
|
|
404
1276
|
def help(self) -> None:
|
|
@@ -423,9 +1295,44 @@ Credentials (requires unlocked vault):
|
|
|
423
1295
|
api.credential("name") Get specific credential
|
|
424
1296
|
api.resolve_credential("host") Find matching credential
|
|
425
1297
|
|
|
1298
|
+
Connections:
|
|
1299
|
+
session = api.connect("device") Connect to device (auto-detect platform)
|
|
1300
|
+
result = api.send(session, cmd) Execute command (returns CommandResult)
|
|
1301
|
+
api.disconnect(session) Close connection
|
|
1302
|
+
api.active_sessions() List active connections
|
|
1303
|
+
|
|
1304
|
+
Command Results:
|
|
1305
|
+
result.raw_output Raw text from device
|
|
1306
|
+
result.parsed_data Parsed data (List[Dict]) if available
|
|
1307
|
+
result.platform Detected platform
|
|
1308
|
+
result.parse_success Whether parsing succeeded
|
|
1309
|
+
result.to_dict() Export as dictionary
|
|
1310
|
+
|
|
1311
|
+
Debugging:
|
|
1312
|
+
api.debug_parse(cmd, output, platform) Debug why parsing failed
|
|
1313
|
+
api.db_info() Show TextFSM database path and status
|
|
1314
|
+
|
|
426
1315
|
Status:
|
|
427
1316
|
api.status() Get summary
|
|
428
1317
|
api.vault_unlocked Check vault status
|
|
1318
|
+
api._tfsm_engine TextFSM parser (required)
|
|
1319
|
+
|
|
1320
|
+
Examples:
|
|
1321
|
+
# Connect and execute
|
|
1322
|
+
api.unlock("vault-password")
|
|
1323
|
+
session = api.connect("spine1")
|
|
1324
|
+
result = api.send(session, "show interfaces status")
|
|
1325
|
+
|
|
1326
|
+
# Access parsed data
|
|
1327
|
+
if result.parsed_data:
|
|
1328
|
+
for interface in result.parsed_data:
|
|
1329
|
+
print(f"{interface['name']}: {interface['status']}")
|
|
1330
|
+
|
|
1331
|
+
# Disconnect
|
|
1332
|
+
api.disconnect(session)
|
|
1333
|
+
|
|
1334
|
+
Note: TextFSM parser (tfsm_templates.db) is REQUIRED for the API to function.
|
|
1335
|
+
The API will fail during initialization if the database is not found.
|
|
429
1336
|
""")
|
|
430
1337
|
|
|
431
1338
|
|