ntermqt 0.1.7__py3-none-any.whl → 0.1.9__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/parser/api_help_dialog.py +426 -223
- nterm/scripting/api.py +421 -701
- nterm/scripting/models.py +195 -0
- nterm/scripting/platform_data.py +272 -0
- nterm/scripting/platform_utils.py +596 -0
- nterm/scripting/repl.py +527 -131
- nterm/scripting/repl_interactive.py +356 -213
- nterm/scripting/ssh_connection.py +632 -0
- nterm/scripting/test_api_repl.py +290 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/METADATA +89 -29
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/RECORD +14 -9
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/top_level.txt +0 -0
nterm/scripting/api.py
CHANGED
|
@@ -5,25 +5,30 @@ 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
|
|
10
8
|
import fnmatch
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Optional, List, Dict, Any, Generator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import os
|
|
14
13
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
from ..manager.models import SessionStore, SavedSession, SessionFolder
|
|
14
|
+
from ..manager.models import SessionStore
|
|
18
15
|
from ..vault.resolver import CredentialResolver
|
|
19
|
-
from ..vault.store import StoredCredential
|
|
20
|
-
from ..connection.profile import ConnectionProfile, AuthMethod
|
|
21
16
|
|
|
22
|
-
#
|
|
23
|
-
from
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
# Import our refactored modules
|
|
18
|
+
from .models import ActiveSession, CommandResult, DeviceInfo, CredentialInfo
|
|
19
|
+
from .platform_data import INTERFACE_DETAIL_FIELD_MAP
|
|
20
|
+
from .platform_utils import (
|
|
21
|
+
detect_platform,
|
|
22
|
+
detect_platform_from_template,
|
|
23
|
+
extract_platform_from_template_name,
|
|
24
|
+
normalize_fields,
|
|
25
|
+
get_paging_disable_command,
|
|
26
|
+
get_platform_command,
|
|
27
|
+
extract_version_info,
|
|
28
|
+
extract_neighbor_info,
|
|
29
|
+
try_disable_paging,
|
|
26
30
|
)
|
|
31
|
+
from .ssh_connection import connect_ssh, send_command, PagingNotDisabledError
|
|
27
32
|
|
|
28
33
|
# TextFSM parsing - REQUIRED
|
|
29
34
|
try:
|
|
@@ -35,336 +40,6 @@ except ImportError as e:
|
|
|
35
40
|
_TFSM_IMPORT_ERROR = str(e)
|
|
36
41
|
|
|
37
42
|
|
|
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)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
@dataclass
|
|
278
|
-
class DeviceInfo:
|
|
279
|
-
"""Simplified device view for scripting."""
|
|
280
|
-
name: str
|
|
281
|
-
hostname: str
|
|
282
|
-
port: int
|
|
283
|
-
folder: Optional[str] = None
|
|
284
|
-
credential: Optional[str] = None
|
|
285
|
-
last_connected: Optional[str] = None
|
|
286
|
-
connect_count: int = 0
|
|
287
|
-
|
|
288
|
-
@classmethod
|
|
289
|
-
def from_session(cls, session: SavedSession, folder_name: str = None) -> 'DeviceInfo':
|
|
290
|
-
return cls(
|
|
291
|
-
name=session.name,
|
|
292
|
-
hostname=session.hostname,
|
|
293
|
-
port=session.port,
|
|
294
|
-
folder=folder_name,
|
|
295
|
-
credential=session.credential_name,
|
|
296
|
-
last_connected=str(session.last_connected) if session.last_connected else None,
|
|
297
|
-
connect_count=session.connect_count,
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
301
|
-
return asdict(self)
|
|
302
|
-
|
|
303
|
-
def __repr__(self) -> str:
|
|
304
|
-
cred = f", cred={self.credential}" if self.credential else ""
|
|
305
|
-
folder = f", folder={self.folder}" if self.folder else ""
|
|
306
|
-
return f"Device({self.name}, {self.hostname}:{self.port}{cred}{folder})"
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
@dataclass
|
|
326
|
-
class CredentialInfo:
|
|
327
|
-
"""Simplified credential view for scripting (no secrets exposed)."""
|
|
328
|
-
name: str
|
|
329
|
-
username: str
|
|
330
|
-
has_password: bool
|
|
331
|
-
has_key: bool
|
|
332
|
-
match_hosts: List[str]
|
|
333
|
-
match_tags: List[str]
|
|
334
|
-
jump_host: Optional[str] = None
|
|
335
|
-
is_default: bool = False
|
|
336
|
-
|
|
337
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
338
|
-
return asdict(self)
|
|
339
|
-
|
|
340
|
-
def __repr__(self) -> str:
|
|
341
|
-
auth = []
|
|
342
|
-
if self.has_password:
|
|
343
|
-
auth.append("password")
|
|
344
|
-
if self.has_key:
|
|
345
|
-
auth.append("key")
|
|
346
|
-
auth_str = "+".join(auth) if auth else "none"
|
|
347
|
-
default = " [default]" if self.is_default else ""
|
|
348
|
-
return f"Credential({self.name}, user={self.username}, auth={auth_str}{default})"
|
|
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
|
-
|
|
367
|
-
|
|
368
43
|
class NTermAPI:
|
|
369
44
|
"""
|
|
370
45
|
Scripting interface for nterm.
|
|
@@ -384,9 +59,13 @@ class NTermAPI:
|
|
|
384
59
|
api.credentials()
|
|
385
60
|
api.credential("lab-admin")
|
|
386
61
|
|
|
387
|
-
# Connect and execute
|
|
62
|
+
# Connect and execute
|
|
388
63
|
session = api.connect("eng-leaf-1")
|
|
389
64
|
output = api.send(session, "show version")
|
|
65
|
+
|
|
66
|
+
# Context manager (auto-cleanup)
|
|
67
|
+
with api.session("eng-leaf-1") as s:
|
|
68
|
+
result = api.send(s, "show version")
|
|
390
69
|
"""
|
|
391
70
|
|
|
392
71
|
def __init__(
|
|
@@ -658,183 +337,6 @@ class NTermAPI:
|
|
|
658
337
|
# Connection operations
|
|
659
338
|
# -------------------------------------------------------------------------
|
|
660
339
|
|
|
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
340
|
def connect(self, device: str, credential: str = None, debug: bool = False) -> ActiveSession:
|
|
839
341
|
"""
|
|
840
342
|
Connect to a device and detect platform.
|
|
@@ -847,13 +349,6 @@ class NTermAPI:
|
|
|
847
349
|
Returns:
|
|
848
350
|
ActiveSession handle for sending commands
|
|
849
351
|
"""
|
|
850
|
-
debug_log = []
|
|
851
|
-
|
|
852
|
-
def _debug(msg):
|
|
853
|
-
if debug:
|
|
854
|
-
debug_log.append(msg)
|
|
855
|
-
print(f"[DEBUG] {msg}")
|
|
856
|
-
|
|
857
352
|
# Look up device from saved sessions first
|
|
858
353
|
device_info = self.device(device)
|
|
859
354
|
|
|
@@ -868,14 +363,11 @@ class NTermAPI:
|
|
|
868
363
|
device_name = device
|
|
869
364
|
saved_cred = None
|
|
870
365
|
|
|
871
|
-
_debug(f"Target: {hostname}:{port}")
|
|
872
|
-
|
|
873
366
|
# Resolve credentials
|
|
874
367
|
if not self.vault_unlocked:
|
|
875
368
|
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
876
369
|
|
|
877
370
|
cred_name = credential or saved_cred
|
|
878
|
-
_debug(f"Credential: {cred_name or '(auto-resolve)'}")
|
|
879
371
|
|
|
880
372
|
if cred_name:
|
|
881
373
|
try:
|
|
@@ -895,134 +387,10 @@ class NTermAPI:
|
|
|
895
387
|
if not profile:
|
|
896
388
|
raise ValueError(f"No credentials available for {hostname}")
|
|
897
389
|
|
|
898
|
-
#
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
# Prepare connection kwargs
|
|
902
|
-
connect_kwargs = {
|
|
903
|
-
'hostname': hostname,
|
|
904
|
-
'port': port,
|
|
905
|
-
'timeout': 10,
|
|
906
|
-
'allow_agent': False,
|
|
907
|
-
'look_for_keys': False,
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
# Add authentication from profile
|
|
911
|
-
auth_method_used = None
|
|
912
|
-
if profile.auth_methods:
|
|
913
|
-
first_auth = profile.auth_methods[0]
|
|
914
|
-
connect_kwargs['username'] = first_auth.username
|
|
915
|
-
_debug(f"Username: {first_auth.username}")
|
|
916
|
-
|
|
917
|
-
for auth in profile.auth_methods:
|
|
918
|
-
if auth.method == AuthMethod.PASSWORD:
|
|
919
|
-
connect_kwargs['password'] = auth.password
|
|
920
|
-
auth_method_used = "password"
|
|
921
|
-
_debug("Auth method: password")
|
|
922
|
-
break
|
|
923
|
-
elif auth.method == AuthMethod.KEY_FILE:
|
|
924
|
-
connect_kwargs['key_filename'] = auth.key_path
|
|
925
|
-
if auth.key_passphrase:
|
|
926
|
-
connect_kwargs['passphrase'] = auth.key_passphrase
|
|
927
|
-
auth_method_used = f"key_file:{auth.key_path}"
|
|
928
|
-
_debug(f"Auth method: key_file ({auth.key_path})")
|
|
929
|
-
break
|
|
930
|
-
elif auth.method == AuthMethod.KEY_STORED:
|
|
931
|
-
import tempfile
|
|
932
|
-
key_file = tempfile.NamedTemporaryFile(
|
|
933
|
-
mode='w',
|
|
934
|
-
delete=False,
|
|
935
|
-
suffix='.pem'
|
|
936
|
-
)
|
|
937
|
-
key_file.write(auth.key_data)
|
|
938
|
-
key_file.close()
|
|
939
|
-
connect_kwargs['key_filename'] = key_file.name
|
|
940
|
-
if auth.key_passphrase:
|
|
941
|
-
connect_kwargs['passphrase'] = auth.key_passphrase
|
|
942
|
-
auth_method_used = "key_stored"
|
|
943
|
-
_debug(f"Auth method: key_stored (temp: {key_file.name})")
|
|
944
|
-
break
|
|
945
|
-
|
|
946
|
-
# Detect key type if using key auth
|
|
947
|
-
if 'key_filename' in connect_kwargs:
|
|
948
|
-
key_path = connect_kwargs['key_filename']
|
|
949
|
-
key_type = "unknown"
|
|
950
|
-
key_bits = None
|
|
951
|
-
try:
|
|
952
|
-
key = paramiko.RSAKey.from_private_key_file(key_path)
|
|
953
|
-
key_type = "RSA"
|
|
954
|
-
key_bits = key.get_bits()
|
|
955
|
-
except:
|
|
956
|
-
try:
|
|
957
|
-
key = paramiko.Ed25519Key.from_private_key_file(key_path)
|
|
958
|
-
key_type = "Ed25519"
|
|
959
|
-
except:
|
|
960
|
-
try:
|
|
961
|
-
key = paramiko.ECDSAKey.from_private_key_file(key_path)
|
|
962
|
-
key_type = "ECDSA"
|
|
963
|
-
except:
|
|
964
|
-
pass
|
|
965
|
-
_debug(f"Key type: {key_type}" + (f" ({key_bits} bits)" if key_bits else ""))
|
|
966
|
-
|
|
967
|
-
# Connection attempt sequence
|
|
968
|
-
attempts = [
|
|
969
|
-
("modern", None),
|
|
970
|
-
("rsa-sha1", RSA_SHA1_DISABLED_ALGORITHMS),
|
|
971
|
-
]
|
|
972
|
-
|
|
973
|
-
last_error = None
|
|
974
|
-
connected = False
|
|
975
|
-
client = None
|
|
976
|
-
|
|
977
|
-
for attempt_name, disabled_algs in attempts:
|
|
978
|
-
client = paramiko.SSHClient()
|
|
979
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
980
|
-
|
|
981
|
-
_debug(f"Attempt: {attempt_name}")
|
|
982
|
-
|
|
983
|
-
attempt_kwargs = connect_kwargs.copy()
|
|
984
|
-
if disabled_algs:
|
|
985
|
-
attempt_kwargs['disabled_algorithms'] = disabled_algs
|
|
986
|
-
_debug(f" disabled_algorithms: {disabled_algs}")
|
|
987
|
-
|
|
988
|
-
try:
|
|
989
|
-
client.connect(**attempt_kwargs)
|
|
990
|
-
connected = True
|
|
991
|
-
|
|
992
|
-
# Log successful negotiation
|
|
993
|
-
transport = client.get_transport()
|
|
994
|
-
if transport:
|
|
995
|
-
_debug(f" SUCCESS - cipher: {transport.remote_cipher}, mac: {transport.remote_mac}")
|
|
996
|
-
_debug(f" host_key_type: {transport.host_key_type}")
|
|
997
|
-
break
|
|
998
|
-
|
|
999
|
-
except paramiko.AuthenticationException as e:
|
|
1000
|
-
_debug(f" FAILED (auth): {e}")
|
|
1001
|
-
last_error = str(e)
|
|
1002
|
-
client.close()
|
|
1003
|
-
except paramiko.SSHException as e:
|
|
1004
|
-
_debug(f" FAILED (ssh): {e}")
|
|
1005
|
-
last_error = str(e)
|
|
1006
|
-
client.close()
|
|
1007
|
-
except Exception as e:
|
|
1008
|
-
_debug(f" FAILED (other): {e}")
|
|
1009
|
-
last_error = str(e)
|
|
1010
|
-
client.close()
|
|
1011
|
-
|
|
1012
|
-
if not connected:
|
|
1013
|
-
# Build detailed error message
|
|
1014
|
-
error_detail = f"Connection failed: {last_error}"
|
|
1015
|
-
if debug:
|
|
1016
|
-
error_detail += f"\n\nDebug log:\n" + "\n".join(debug_log)
|
|
1017
|
-
raise paramiko.AuthenticationException(error_detail)
|
|
1018
|
-
|
|
1019
|
-
# Open interactive shell
|
|
1020
|
-
shell = client.invoke_shell(width=200, height=50)
|
|
1021
|
-
shell.settimeout(0.5)
|
|
1022
|
-
|
|
1023
|
-
prompt = self._wait_for_prompt(shell)
|
|
1024
|
-
_debug(f"Prompt detected: {prompt}")
|
|
390
|
+
# Establish SSH connection using our refactored module
|
|
391
|
+
client, shell, prompt, debug_log = connect_ssh(hostname, port, profile, debug)
|
|
1025
392
|
|
|
393
|
+
# Create session object
|
|
1026
394
|
session = ActiveSession(
|
|
1027
395
|
device_name=device_name,
|
|
1028
396
|
hostname=hostname,
|
|
@@ -1032,36 +400,97 @@ class NTermAPI:
|
|
|
1032
400
|
prompt=prompt,
|
|
1033
401
|
)
|
|
1034
402
|
|
|
1035
|
-
#
|
|
403
|
+
# Pre-emptively try to disable paging BEFORE platform detection
|
|
404
|
+
# This prevents 'show version' from being truncated by --More--
|
|
405
|
+
# Use the most common command - harmless if it fails on non-Cisco platforms
|
|
1036
406
|
try:
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
_debug(f"Platform detected: {platform}")
|
|
407
|
+
send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
408
|
+
if debug:
|
|
409
|
+
print(f"[DEBUG] Pre-emptive paging disable: terminal length 0")
|
|
1041
410
|
except Exception as e:
|
|
1042
|
-
|
|
411
|
+
if debug:
|
|
412
|
+
print(f"[DEBUG] Pre-emptive paging disable failed (normal on some platforms): {e}")
|
|
1043
413
|
|
|
1044
|
-
#
|
|
414
|
+
# Detect platform using TextFSM template matching (primary) or regex (fallback)
|
|
1045
415
|
try:
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
self._send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
416
|
+
version_output = send_command(shell, "show version", prompt)
|
|
417
|
+
platform = detect_platform(version_output, tfsm_engine=self._tfsm_engine)
|
|
418
|
+
session.platform = platform
|
|
419
|
+
if debug:
|
|
420
|
+
print(f"[DEBUG] Platform detected: {platform}")
|
|
1052
421
|
except Exception as e:
|
|
1053
|
-
|
|
422
|
+
if debug:
|
|
423
|
+
print(f"[DEBUG] Platform detection failed: {e}")
|
|
424
|
+
|
|
425
|
+
# Disable terminal paging
|
|
426
|
+
paging_cmd = get_paging_disable_command(session.platform)
|
|
427
|
+
if paging_cmd:
|
|
428
|
+
try:
|
|
429
|
+
send_command(shell, paging_cmd, prompt, timeout=5)
|
|
430
|
+
session._paging_disabled = True
|
|
431
|
+
except Exception as e:
|
|
432
|
+
if debug:
|
|
433
|
+
print(f"[DEBUG] Failed to disable paging: {e}")
|
|
434
|
+
else:
|
|
435
|
+
# Platform unknown - will try to auto-fix if paging error occurs
|
|
436
|
+
session._paging_disabled = False
|
|
1054
437
|
|
|
1055
438
|
self._active_sessions[device_name] = session
|
|
1056
439
|
return session
|
|
1057
440
|
|
|
441
|
+
@contextmanager
|
|
442
|
+
def session(self, device: str, credential: str = None, debug: bool = False) -> Generator[ActiveSession, None, None]:
|
|
443
|
+
"""
|
|
444
|
+
Context manager for device sessions with automatic cleanup.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
device: Device name (from saved sessions) or hostname
|
|
448
|
+
credential: Optional credential name (auto-resolved if not specified)
|
|
449
|
+
debug: Enable verbose connection debugging
|
|
450
|
+
|
|
451
|
+
Yields:
|
|
452
|
+
ActiveSession handle for sending commands
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
ConnectionError: If connection fails
|
|
456
|
+
|
|
457
|
+
Example:
|
|
458
|
+
# Old way (12 lines):
|
|
459
|
+
session = None
|
|
460
|
+
try:
|
|
461
|
+
session = api.connect(device.name)
|
|
462
|
+
if not session.is_connected():
|
|
463
|
+
continue
|
|
464
|
+
result = api.send(session, "show version")
|
|
465
|
+
finally:
|
|
466
|
+
if session and session.is_connected():
|
|
467
|
+
api.disconnect(session)
|
|
468
|
+
|
|
469
|
+
# New way (3 lines):
|
|
470
|
+
with api.session(device.name) as s:
|
|
471
|
+
result = api.send(s, "show version")
|
|
472
|
+
"""
|
|
473
|
+
sess = None
|
|
474
|
+
try:
|
|
475
|
+
sess = self.connect(device, credential=credential, debug=debug)
|
|
476
|
+
if not sess.is_connected():
|
|
477
|
+
raise ConnectionError(f"Failed to connect to {device}")
|
|
478
|
+
yield sess
|
|
479
|
+
finally:
|
|
480
|
+
if sess and sess.is_connected():
|
|
481
|
+
try:
|
|
482
|
+
self.disconnect(sess)
|
|
483
|
+
except Exception:
|
|
484
|
+
pass # Best effort cleanup
|
|
485
|
+
|
|
1058
486
|
def send(
|
|
1059
487
|
self,
|
|
1060
488
|
session: ActiveSession,
|
|
1061
489
|
command: str,
|
|
1062
|
-
timeout: int =
|
|
490
|
+
timeout: int = 60,
|
|
1063
491
|
parse: bool = True,
|
|
1064
492
|
normalize: bool = True,
|
|
493
|
+
_retry_after_paging_fix: bool = False,
|
|
1065
494
|
) -> CommandResult:
|
|
1066
495
|
"""
|
|
1067
496
|
Send command to a connected session.
|
|
@@ -1072,6 +501,7 @@ class NTermAPI:
|
|
|
1072
501
|
timeout: Command timeout in seconds
|
|
1073
502
|
parse: Whether to attempt TextFSM parsing
|
|
1074
503
|
normalize: Whether to normalize field names (requires parse=True)
|
|
504
|
+
_retry_after_paging_fix: Internal flag to prevent infinite retry loops
|
|
1075
505
|
|
|
1076
506
|
Returns:
|
|
1077
507
|
CommandResult with raw and parsed output
|
|
@@ -1085,12 +515,62 @@ class NTermAPI:
|
|
|
1085
515
|
if result.parsed_data:
|
|
1086
516
|
for row in result.parsed_data:
|
|
1087
517
|
print(row)
|
|
518
|
+
|
|
519
|
+
Note:
|
|
520
|
+
If paging is detected (--More-- prompt), the API will automatically
|
|
521
|
+
try to disable paging and retry the command once.
|
|
1088
522
|
"""
|
|
1089
523
|
if not session.is_connected():
|
|
1090
524
|
raise RuntimeError(f"Session {session.device_name} is not connected")
|
|
1091
525
|
|
|
1092
|
-
# Execute command
|
|
1093
|
-
|
|
526
|
+
# Execute command using our refactored module
|
|
527
|
+
try:
|
|
528
|
+
raw_output = send_command(session.shell, command, session.prompt, timeout)
|
|
529
|
+
except PagingNotDisabledError as e:
|
|
530
|
+
# Auto-recovery: try to disable paging and retry
|
|
531
|
+
if _retry_after_paging_fix:
|
|
532
|
+
# Already tried once, don't loop forever
|
|
533
|
+
raise RuntimeError(
|
|
534
|
+
f"Paging still not disabled after auto-fix attempt. "
|
|
535
|
+
f"Original error: {e}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Try to disable paging with multiple commands
|
|
539
|
+
print(f"[AUTO-FIX] Paging detected, attempting to disable...")
|
|
540
|
+
|
|
541
|
+
# Send Ctrl+C to break out of --More-- prompt
|
|
542
|
+
try:
|
|
543
|
+
session.shell.send('\x03') # Ctrl+C
|
|
544
|
+
import time
|
|
545
|
+
time.sleep(0.5)
|
|
546
|
+
# Clear any pending output
|
|
547
|
+
while session.shell.recv_ready():
|
|
548
|
+
session.shell.recv(65536)
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
|
|
552
|
+
# Try multiple paging disable commands
|
|
553
|
+
success = try_disable_paging(
|
|
554
|
+
session.shell,
|
|
555
|
+
session.prompt,
|
|
556
|
+
send_command,
|
|
557
|
+
debug=True,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if success:
|
|
561
|
+
session._paging_disabled = True
|
|
562
|
+
print(f"[AUTO-FIX] Paging disabled, retrying command...")
|
|
563
|
+
# Retry the original command
|
|
564
|
+
return self.send(
|
|
565
|
+
session, command, timeout, parse, normalize,
|
|
566
|
+
_retry_after_paging_fix=True
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
raise RuntimeError(
|
|
570
|
+
f"Failed to auto-disable paging. "
|
|
571
|
+
f"Tried multiple commands but none succeeded. "
|
|
572
|
+
f"Original error: {e}"
|
|
573
|
+
)
|
|
1094
574
|
|
|
1095
575
|
# Create result object
|
|
1096
576
|
result = CommandResult(
|
|
@@ -1130,7 +610,7 @@ class NTermAPI:
|
|
|
1130
610
|
if normalize and parsed_data:
|
|
1131
611
|
# Determine which field map to use based on command
|
|
1132
612
|
if 'interface' in command.lower():
|
|
1133
|
-
normalized =
|
|
613
|
+
normalized = normalize_fields(
|
|
1134
614
|
parsed_data,
|
|
1135
615
|
session.platform,
|
|
1136
616
|
INTERFACE_DETAIL_FIELD_MAP,
|
|
@@ -1150,47 +630,168 @@ class NTermAPI:
|
|
|
1150
630
|
|
|
1151
631
|
return result
|
|
1152
632
|
|
|
633
|
+
def send_first(
|
|
634
|
+
self,
|
|
635
|
+
session: ActiveSession,
|
|
636
|
+
commands: List[str],
|
|
637
|
+
parse: bool = True,
|
|
638
|
+
timeout: int = 30,
|
|
639
|
+
require_parsed: bool = True,
|
|
640
|
+
) -> Optional[CommandResult]:
|
|
641
|
+
"""
|
|
642
|
+
Try multiple commands until one succeeds (returns parsed data).
|
|
643
|
+
|
|
644
|
+
Useful for CDP/LLDP discovery, platform variations, etc.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
session: Active session
|
|
648
|
+
commands: List of commands to try in order
|
|
649
|
+
parse: Whether to parse output
|
|
650
|
+
timeout: Command timeout
|
|
651
|
+
require_parsed: If True, only consider success if parsed_data is non-empty
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
First successful CommandResult, or None if all failed
|
|
655
|
+
|
|
656
|
+
Example:
|
|
657
|
+
# Try CDP first, fall back to LLDP
|
|
658
|
+
result = api.send_first(session, [
|
|
659
|
+
"show cdp neighbors detail",
|
|
660
|
+
"show lldp neighbors detail",
|
|
661
|
+
])
|
|
662
|
+
|
|
663
|
+
# Platform-agnostic config fetch
|
|
664
|
+
result = api.send_first(session, [
|
|
665
|
+
"show running-config",
|
|
666
|
+
"show configuration",
|
|
667
|
+
], parse=False, require_parsed=False)
|
|
668
|
+
"""
|
|
669
|
+
for cmd in commands:
|
|
670
|
+
if cmd is None:
|
|
671
|
+
continue
|
|
672
|
+
try:
|
|
673
|
+
result = self.send(session, cmd, parse=parse, timeout=timeout)
|
|
674
|
+
|
|
675
|
+
if require_parsed and parse:
|
|
676
|
+
# Need non-empty parsed data
|
|
677
|
+
if result.parsed_data:
|
|
678
|
+
return result
|
|
679
|
+
else:
|
|
680
|
+
# Just need non-empty raw output
|
|
681
|
+
if result.raw_output and result.raw_output.strip():
|
|
682
|
+
return result
|
|
683
|
+
|
|
684
|
+
except Exception:
|
|
685
|
+
continue # Try next command
|
|
686
|
+
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
def send_platform_command(
|
|
690
|
+
self,
|
|
691
|
+
session: ActiveSession,
|
|
692
|
+
command_type: str,
|
|
693
|
+
parse: bool = True,
|
|
694
|
+
timeout: int = 30,
|
|
695
|
+
**kwargs
|
|
696
|
+
) -> Optional[CommandResult]:
|
|
697
|
+
"""
|
|
698
|
+
Send a platform-appropriate command by type.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
session: Active session
|
|
702
|
+
command_type: Command type (e.g., 'config', 'version', 'neighbors')
|
|
703
|
+
parse: Whether to parse output
|
|
704
|
+
timeout: Command timeout
|
|
705
|
+
**kwargs: Format arguments (e.g., name='Gi0/1' for interface_detail)
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
CommandResult or None if command not available
|
|
709
|
+
|
|
710
|
+
Example:
|
|
711
|
+
# Get running config (platform-aware)
|
|
712
|
+
result = api.send_platform_command(session, 'config', parse=False)
|
|
713
|
+
|
|
714
|
+
# Get interface details
|
|
715
|
+
result = api.send_platform_command(session, 'interface_detail', name='Gi0/1')
|
|
716
|
+
"""
|
|
717
|
+
cmd = get_platform_command(session.platform, command_type, **kwargs)
|
|
718
|
+
if not cmd:
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
return self.send(session, cmd, parse=parse, timeout=timeout)
|
|
722
|
+
|
|
1153
723
|
def disconnect(self, session: ActiveSession) -> None:
|
|
1154
724
|
"""
|
|
1155
725
|
Disconnect a session.
|
|
1156
726
|
|
|
1157
727
|
Args:
|
|
1158
|
-
session: ActiveSession to
|
|
728
|
+
session: ActiveSession to disconnect
|
|
1159
729
|
"""
|
|
1160
730
|
if session.device_name in self._active_sessions:
|
|
1161
731
|
del self._active_sessions[session.device_name]
|
|
1162
732
|
|
|
1163
|
-
|
|
1164
|
-
session.shell
|
|
733
|
+
try:
|
|
734
|
+
if session.shell:
|
|
735
|
+
session.shell.close()
|
|
736
|
+
except Exception:
|
|
737
|
+
pass
|
|
1165
738
|
|
|
1166
|
-
|
|
1167
|
-
session.client
|
|
739
|
+
try:
|
|
740
|
+
if session.client:
|
|
741
|
+
session.client.close()
|
|
742
|
+
except Exception:
|
|
743
|
+
pass
|
|
1168
744
|
|
|
1169
|
-
def
|
|
745
|
+
def disconnect_all(self) -> int:
|
|
1170
746
|
"""
|
|
1171
|
-
|
|
747
|
+
Disconnect all active sessions.
|
|
1172
748
|
|
|
1173
749
|
Returns:
|
|
1174
|
-
|
|
750
|
+
Number of sessions disconnected
|
|
751
|
+
|
|
752
|
+
Example:
|
|
753
|
+
# At end of script or in finally block
|
|
754
|
+
count = api.disconnect_all()
|
|
755
|
+
print(f"Disconnected {count} session(s)")
|
|
1175
756
|
"""
|
|
1176
|
-
|
|
757
|
+
count = len(self._active_sessions)
|
|
1177
758
|
|
|
1178
|
-
|
|
759
|
+
for session in list(self._active_sessions.values()):
|
|
760
|
+
try:
|
|
761
|
+
self.disconnect(session)
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
self._active_sessions.clear()
|
|
766
|
+
return count
|
|
767
|
+
|
|
768
|
+
def active_sessions(self) -> List[ActiveSession]:
|
|
1179
769
|
"""
|
|
1180
|
-
|
|
770
|
+
List all active sessions.
|
|
1181
771
|
|
|
1182
772
|
Returns:
|
|
1183
|
-
|
|
773
|
+
List of ActiveSession objects
|
|
1184
774
|
"""
|
|
1185
|
-
|
|
1186
|
-
import os
|
|
775
|
+
return list(self._active_sessions.values())
|
|
1187
776
|
|
|
777
|
+
# -------------------------------------------------------------------------
|
|
778
|
+
# Diagnostics
|
|
779
|
+
# -------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
def db_info(self) -> Dict[str, Any]:
|
|
782
|
+
"""
|
|
783
|
+
Get information about the TextFSM database.
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
Dict with db_path, db_exists, db_size, db_size_mb, etc.
|
|
787
|
+
"""
|
|
1188
788
|
info = {
|
|
1189
|
-
"
|
|
789
|
+
"engine_initialized": self._tfsm_engine is not None,
|
|
1190
790
|
"db_path": None,
|
|
1191
791
|
"db_exists": False,
|
|
792
|
+
"db_size": None,
|
|
793
|
+
"db_size_mb": None,
|
|
1192
794
|
"db_absolute_path": None,
|
|
1193
|
-
"current_working_directory": os.getcwd(),
|
|
1194
795
|
}
|
|
1195
796
|
|
|
1196
797
|
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
@@ -1279,6 +880,90 @@ class NTermAPI:
|
|
|
1279
880
|
|
|
1280
881
|
return debug_info
|
|
1281
882
|
|
|
883
|
+
def detect_platform_from_output(
|
|
884
|
+
self,
|
|
885
|
+
output: str,
|
|
886
|
+
min_score: float = 50.0,
|
|
887
|
+
) -> Dict[str, Any]:
|
|
888
|
+
"""
|
|
889
|
+
Detect platform from command output using TextFSM template matching.
|
|
890
|
+
|
|
891
|
+
This is the same method used internally during connect() for platform
|
|
892
|
+
detection. Useful for testing and debugging.
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
output: Raw command output (typically from 'show version')
|
|
896
|
+
min_score: Minimum match score to accept (default 50.0)
|
|
897
|
+
|
|
898
|
+
Returns:
|
|
899
|
+
Dict with:
|
|
900
|
+
- platform: Detected platform string or None
|
|
901
|
+
- template: Best matching template name
|
|
902
|
+
- score: Match confidence score
|
|
903
|
+
- method: 'textfsm' or 'regex' or 'none'
|
|
904
|
+
- parsed_data: Parsed data from template if available
|
|
905
|
+
|
|
906
|
+
Example:
|
|
907
|
+
>>> result = api.send(session, "show version", parse=False)
|
|
908
|
+
>>> api.detect_platform_from_output(result.raw_output)
|
|
909
|
+
{
|
|
910
|
+
'platform': 'cisco_ios',
|
|
911
|
+
'template': 'cisco_ios_show_version',
|
|
912
|
+
'score': 95.83,
|
|
913
|
+
'method': 'textfsm',
|
|
914
|
+
'parsed_data': [{'VERSION': '15.2(4)M11', ...}]
|
|
915
|
+
}
|
|
916
|
+
"""
|
|
917
|
+
result = {
|
|
918
|
+
"platform": None,
|
|
919
|
+
"template": None,
|
|
920
|
+
"score": 0.0,
|
|
921
|
+
"method": "none",
|
|
922
|
+
"parsed_data": None,
|
|
923
|
+
"all_scores": None,
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if not output:
|
|
927
|
+
result["error"] = "No output provided"
|
|
928
|
+
return result
|
|
929
|
+
|
|
930
|
+
# Try TextFSM template matching first
|
|
931
|
+
if self._tfsm_engine:
|
|
932
|
+
try:
|
|
933
|
+
best_template, parsed_data, best_score, all_scores = self._tfsm_engine.find_best_template(
|
|
934
|
+
device_output=output,
|
|
935
|
+
filter_string="show_version",
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
result["template"] = best_template
|
|
939
|
+
result["score"] = best_score
|
|
940
|
+
result["all_scores"] = all_scores
|
|
941
|
+
result["parsed_data"] = parsed_data
|
|
942
|
+
|
|
943
|
+
if best_template and best_score >= min_score:
|
|
944
|
+
platform = extract_platform_from_template_name(best_template)
|
|
945
|
+
if platform:
|
|
946
|
+
result["platform"] = platform
|
|
947
|
+
result["method"] = "textfsm"
|
|
948
|
+
return result
|
|
949
|
+
|
|
950
|
+
except Exception as e:
|
|
951
|
+
result["textfsm_error"] = str(e)
|
|
952
|
+
|
|
953
|
+
# Fallback to regex
|
|
954
|
+
from .platform_data import PLATFORM_PATTERNS
|
|
955
|
+
import re
|
|
956
|
+
|
|
957
|
+
for platform, patterns in PLATFORM_PATTERNS.items():
|
|
958
|
+
for pattern in patterns:
|
|
959
|
+
if re.search(pattern, output, re.IGNORECASE):
|
|
960
|
+
result["platform"] = platform
|
|
961
|
+
result["method"] = "regex"
|
|
962
|
+
result["regex_pattern"] = pattern
|
|
963
|
+
return result
|
|
964
|
+
|
|
965
|
+
return result
|
|
966
|
+
|
|
1282
967
|
# -------------------------------------------------------------------------
|
|
1283
968
|
# Convenience / REPL helpers
|
|
1284
969
|
# -------------------------------------------------------------------------
|
|
@@ -1310,7 +995,6 @@ class NTermAPI:
|
|
|
1310
995
|
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
1311
996
|
parser_db_path = self._tfsm_engine.db_path
|
|
1312
997
|
if parser_db_path:
|
|
1313
|
-
from pathlib import Path
|
|
1314
998
|
parser_db_exists = Path(parser_db_path).exists()
|
|
1315
999
|
|
|
1316
1000
|
return {
|
|
@@ -1351,8 +1035,22 @@ Connections:
|
|
|
1351
1035
|
session = api.connect("device") Connect to device (auto-detect platform)
|
|
1352
1036
|
result = api.send(session, cmd) Execute command (returns CommandResult)
|
|
1353
1037
|
api.disconnect(session) Close connection
|
|
1038
|
+
api.disconnect_all() Close all connections
|
|
1354
1039
|
api.active_sessions() List active connections
|
|
1355
1040
|
|
|
1041
|
+
Context Manager (recommended):
|
|
1042
|
+
with api.session("device") as s:
|
|
1043
|
+
result = api.send(s, "show version")
|
|
1044
|
+
# Auto-disconnects when done
|
|
1045
|
+
|
|
1046
|
+
Platform-Aware Commands:
|
|
1047
|
+
api.send_platform_command(s, 'config') Get running config
|
|
1048
|
+
api.send_platform_command(s, 'neighbors') Get CDP/LLDP neighbors
|
|
1049
|
+
api.send_platform_command(s, 'interface_detail', name='Gi0/1')
|
|
1050
|
+
|
|
1051
|
+
Try Multiple Commands:
|
|
1052
|
+
api.send_first(s, ["show cdp neighbors", "show lldp neighbors"])
|
|
1053
|
+
|
|
1356
1054
|
Command Results:
|
|
1357
1055
|
result.raw_output Raw text from device
|
|
1358
1056
|
result.parsed_data Parsed data (List[Dict]) if available
|
|
@@ -1362,6 +1060,7 @@ Command Results:
|
|
|
1362
1060
|
|
|
1363
1061
|
Debugging:
|
|
1364
1062
|
api.debug_parse(cmd, output, platform) Debug why parsing failed
|
|
1063
|
+
api.detect_platform_from_output(output) Detect platform from command output
|
|
1365
1064
|
api.db_info() Show TextFSM database path and status
|
|
1366
1065
|
|
|
1367
1066
|
Status:
|
|
@@ -1369,19 +1068,39 @@ Status:
|
|
|
1369
1068
|
api.vault_unlocked Check vault status
|
|
1370
1069
|
api._tfsm_engine TextFSM parser (required)
|
|
1371
1070
|
|
|
1071
|
+
Platform Detection:
|
|
1072
|
+
Platform is detected automatically during connect() using:
|
|
1073
|
+
1. TextFSM template matching (primary - more accurate)
|
|
1074
|
+
2. Regex patterns (fallback)
|
|
1075
|
+
|
|
1076
|
+
The API sends 'terminal length 0' before detection to prevent
|
|
1077
|
+
paging issues. Template names like 'cisco_ios_show_version'
|
|
1078
|
+
tell us the platform directly.
|
|
1079
|
+
|
|
1080
|
+
Auto-Recovery:
|
|
1081
|
+
If paging (--More--) is detected, the API will automatically:
|
|
1082
|
+
1. Send Ctrl+C to break out of the pager
|
|
1083
|
+
2. Try multiple paging disable commands
|
|
1084
|
+
3. Retry the original command
|
|
1085
|
+
|
|
1372
1086
|
Examples:
|
|
1373
|
-
# Connect and execute
|
|
1087
|
+
# Connect and execute (manual)
|
|
1374
1088
|
api.unlock("vault-password")
|
|
1375
1089
|
session = api.connect("spine1")
|
|
1376
1090
|
result = api.send(session, "show interfaces status")
|
|
1091
|
+
api.disconnect(session)
|
|
1377
1092
|
|
|
1378
|
-
#
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1093
|
+
# Connect and execute (context manager - recommended)
|
|
1094
|
+
api.unlock("vault-password")
|
|
1095
|
+
with api.session("spine1") as s:
|
|
1096
|
+
result = api.send(s, "show interfaces status")
|
|
1097
|
+
for intf in result.parsed_data:
|
|
1098
|
+
print(f"{intf['name']}: {intf['status']}")
|
|
1382
1099
|
|
|
1383
|
-
#
|
|
1384
|
-
api.
|
|
1100
|
+
# Platform-aware config backup
|
|
1101
|
+
with api.session("router1") as s:
|
|
1102
|
+
result = api.send_platform_command(s, 'config', parse=False)
|
|
1103
|
+
Path("backup.cfg").write_text(result.raw_output)
|
|
1385
1104
|
|
|
1386
1105
|
Note: TextFSM parser (tfsm_templates.db) is REQUIRED for the API to function.
|
|
1387
1106
|
The API will fail during initialization if the database is not found.
|
|
@@ -1391,6 +1110,7 @@ The API will fail during initialization if the database is not found.
|
|
|
1391
1110
|
# Singleton for convenience in IPython
|
|
1392
1111
|
_default_api: Optional[NTermAPI] = None
|
|
1393
1112
|
|
|
1113
|
+
|
|
1394
1114
|
def get_api() -> NTermAPI:
|
|
1395
1115
|
"""Get or create default API instance."""
|
|
1396
1116
|
global _default_api
|