ntermqt 0.1.6__py3-none-any.whl → 0.1.8__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/parser/tfsm_fire_tester.py +561 -731
- nterm/scripting/api.py +249 -641
- nterm/scripting/models.py +195 -0
- nterm/scripting/platform_data.py +272 -0
- nterm/scripting/platform_utils.py +330 -0
- nterm/scripting/repl.py +344 -103
- nterm/scripting/repl_interactive.py +331 -213
- nterm/scripting/ssh_connection.py +632 -0
- nterm/scripting/test_api_repl.py +290 -0
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/METADATA +88 -28
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/RECORD +15 -10
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/top_level.txt +0 -0
nterm/scripting/api.py
CHANGED
|
@@ -5,25 +5,27 @@ 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
|
+
normalize_fields,
|
|
23
|
+
get_paging_disable_command,
|
|
24
|
+
get_platform_command,
|
|
25
|
+
extract_version_info,
|
|
26
|
+
extract_neighbor_info,
|
|
26
27
|
)
|
|
28
|
+
from .ssh_connection import connect_ssh, send_command
|
|
27
29
|
|
|
28
30
|
# TextFSM parsing - REQUIRED
|
|
29
31
|
try:
|
|
@@ -35,336 +37,6 @@ except ImportError as e:
|
|
|
35
37
|
_TFSM_IMPORT_ERROR = str(e)
|
|
36
38
|
|
|
37
39
|
|
|
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
40
|
class NTermAPI:
|
|
369
41
|
"""
|
|
370
42
|
Scripting interface for nterm.
|
|
@@ -384,9 +56,13 @@ class NTermAPI:
|
|
|
384
56
|
api.credentials()
|
|
385
57
|
api.credential("lab-admin")
|
|
386
58
|
|
|
387
|
-
# Connect and execute
|
|
59
|
+
# Connect and execute
|
|
388
60
|
session = api.connect("eng-leaf-1")
|
|
389
61
|
output = api.send(session, "show version")
|
|
62
|
+
|
|
63
|
+
# Context manager (auto-cleanup)
|
|
64
|
+
with api.session("eng-leaf-1") as s:
|
|
65
|
+
result = api.send(s, "show version")
|
|
390
66
|
"""
|
|
391
67
|
|
|
392
68
|
def __init__(
|
|
@@ -658,197 +334,17 @@ class NTermAPI:
|
|
|
658
334
|
# Connection operations
|
|
659
335
|
# -------------------------------------------------------------------------
|
|
660
336
|
|
|
661
|
-
def
|
|
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:
|
|
337
|
+
def connect(self, device: str, credential: str = None, debug: bool = False) -> ActiveSession:
|
|
839
338
|
"""
|
|
840
339
|
Connect to a device and detect platform.
|
|
841
340
|
|
|
842
341
|
Args:
|
|
843
342
|
device: Device name (from saved sessions) or hostname
|
|
844
343
|
credential: Optional credential name (auto-resolved if not specified)
|
|
344
|
+
debug: Enable verbose connection debugging
|
|
845
345
|
|
|
846
346
|
Returns:
|
|
847
347
|
ActiveSession handle for sending commands
|
|
848
|
-
|
|
849
|
-
Examples:
|
|
850
|
-
session = api.connect("spine1")
|
|
851
|
-
session = api.connect("192.168.1.1", credential="lab-admin")
|
|
852
348
|
"""
|
|
853
349
|
# Look up device from saved sessions first
|
|
854
350
|
device_info = self.device(device)
|
|
@@ -859,7 +355,6 @@ class NTermAPI:
|
|
|
859
355
|
device_name = device_info.name
|
|
860
356
|
saved_cred = device_info.credential
|
|
861
357
|
else:
|
|
862
|
-
# Treat as hostname directly
|
|
863
358
|
hostname = device
|
|
864
359
|
port = 22
|
|
865
360
|
device_name = device
|
|
@@ -869,11 +364,9 @@ class NTermAPI:
|
|
|
869
364
|
if not self.vault_unlocked:
|
|
870
365
|
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
871
366
|
|
|
872
|
-
# Get credential - either specified or from saved session or auto-resolve
|
|
873
367
|
cred_name = credential or saved_cred
|
|
874
368
|
|
|
875
369
|
if cred_name:
|
|
876
|
-
# User specified a credential name - use resolver's method
|
|
877
370
|
try:
|
|
878
371
|
profile = self._resolver.create_profile_for_credential(
|
|
879
372
|
credential_name=cred_name,
|
|
@@ -883,7 +376,6 @@ class NTermAPI:
|
|
|
883
376
|
except Exception as e:
|
|
884
377
|
raise ValueError(f"Failed to get credential '{cred_name}': {e}")
|
|
885
378
|
else:
|
|
886
|
-
# Auto-resolve based on hostname patterns
|
|
887
379
|
try:
|
|
888
380
|
profile = self._resolver.resolve_for_device(hostname, port=port)
|
|
889
381
|
except Exception as e:
|
|
@@ -892,82 +384,8 @@ class NTermAPI:
|
|
|
892
384
|
if not profile:
|
|
893
385
|
raise ValueError(f"No credentials available for {hostname}")
|
|
894
386
|
|
|
895
|
-
#
|
|
896
|
-
client =
|
|
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)
|
|
387
|
+
# Establish SSH connection using our refactored module
|
|
388
|
+
client, shell, prompt, debug_log = connect_ssh(hostname, port, profile, debug)
|
|
971
389
|
|
|
972
390
|
# Create session object
|
|
973
391
|
session = ActiveSession(
|
|
@@ -981,33 +399,77 @@ class NTermAPI:
|
|
|
981
399
|
|
|
982
400
|
# Detect platform
|
|
983
401
|
try:
|
|
984
|
-
version_output =
|
|
985
|
-
platform =
|
|
402
|
+
version_output = send_command(shell, "show version", prompt)
|
|
403
|
+
platform = detect_platform(version_output)
|
|
986
404
|
session.platform = platform
|
|
405
|
+
if debug:
|
|
406
|
+
print(f"[DEBUG] Platform detected: {platform}")
|
|
987
407
|
except Exception as e:
|
|
988
|
-
|
|
408
|
+
if debug:
|
|
409
|
+
print(f"[DEBUG] Platform detection failed: {e}")
|
|
989
410
|
|
|
990
|
-
# Disable terminal paging
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
except Exception as e:
|
|
999
|
-
print(f"Warning: Failed to disable paging: {e}")
|
|
411
|
+
# Disable terminal paging
|
|
412
|
+
paging_cmd = get_paging_disable_command(session.platform)
|
|
413
|
+
if paging_cmd:
|
|
414
|
+
try:
|
|
415
|
+
send_command(shell, paging_cmd, prompt, timeout=5)
|
|
416
|
+
except Exception as e:
|
|
417
|
+
if debug:
|
|
418
|
+
print(f"[DEBUG] Failed to disable paging: {e}")
|
|
1000
419
|
|
|
1001
|
-
# Track active session
|
|
1002
420
|
self._active_sessions[device_name] = session
|
|
1003
|
-
|
|
1004
421
|
return session
|
|
1005
422
|
|
|
423
|
+
@contextmanager
|
|
424
|
+
def session(self, device: str, credential: str = None, debug: bool = False) -> Generator[ActiveSession, None, None]:
|
|
425
|
+
"""
|
|
426
|
+
Context manager for device sessions with automatic cleanup.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
device: Device name (from saved sessions) or hostname
|
|
430
|
+
credential: Optional credential name (auto-resolved if not specified)
|
|
431
|
+
debug: Enable verbose connection debugging
|
|
432
|
+
|
|
433
|
+
Yields:
|
|
434
|
+
ActiveSession handle for sending commands
|
|
435
|
+
|
|
436
|
+
Raises:
|
|
437
|
+
ConnectionError: If connection fails
|
|
438
|
+
|
|
439
|
+
Example:
|
|
440
|
+
# Old way (12 lines):
|
|
441
|
+
session = None
|
|
442
|
+
try:
|
|
443
|
+
session = api.connect(device.name)
|
|
444
|
+
if not session.is_connected():
|
|
445
|
+
continue
|
|
446
|
+
result = api.send(session, "show version")
|
|
447
|
+
finally:
|
|
448
|
+
if session and session.is_connected():
|
|
449
|
+
api.disconnect(session)
|
|
450
|
+
|
|
451
|
+
# New way (3 lines):
|
|
452
|
+
with api.session(device.name) as s:
|
|
453
|
+
result = api.send(s, "show version")
|
|
454
|
+
"""
|
|
455
|
+
sess = None
|
|
456
|
+
try:
|
|
457
|
+
sess = self.connect(device, credential=credential, debug=debug)
|
|
458
|
+
if not sess.is_connected():
|
|
459
|
+
raise ConnectionError(f"Failed to connect to {device}")
|
|
460
|
+
yield sess
|
|
461
|
+
finally:
|
|
462
|
+
if sess and sess.is_connected():
|
|
463
|
+
try:
|
|
464
|
+
self.disconnect(sess)
|
|
465
|
+
except Exception:
|
|
466
|
+
pass # Best effort cleanup
|
|
467
|
+
|
|
1006
468
|
def send(
|
|
1007
469
|
self,
|
|
1008
470
|
session: ActiveSession,
|
|
1009
471
|
command: str,
|
|
1010
|
-
timeout: int =
|
|
472
|
+
timeout: int = 60,
|
|
1011
473
|
parse: bool = True,
|
|
1012
474
|
normalize: bool = True,
|
|
1013
475
|
) -> CommandResult:
|
|
@@ -1037,8 +499,8 @@ class NTermAPI:
|
|
|
1037
499
|
if not session.is_connected():
|
|
1038
500
|
raise RuntimeError(f"Session {session.device_name} is not connected")
|
|
1039
501
|
|
|
1040
|
-
# Execute command
|
|
1041
|
-
raw_output =
|
|
502
|
+
# Execute command using our refactored module
|
|
503
|
+
raw_output = send_command(session.shell, command, session.prompt, timeout)
|
|
1042
504
|
|
|
1043
505
|
# Create result object
|
|
1044
506
|
result = CommandResult(
|
|
@@ -1078,7 +540,7 @@ class NTermAPI:
|
|
|
1078
540
|
if normalize and parsed_data:
|
|
1079
541
|
# Determine which field map to use based on command
|
|
1080
542
|
if 'interface' in command.lower():
|
|
1081
|
-
normalized =
|
|
543
|
+
normalized = normalize_fields(
|
|
1082
544
|
parsed_data,
|
|
1083
545
|
session.platform,
|
|
1084
546
|
INTERFACE_DETAIL_FIELD_MAP,
|
|
@@ -1098,6 +560,96 @@ class NTermAPI:
|
|
|
1098
560
|
|
|
1099
561
|
return result
|
|
1100
562
|
|
|
563
|
+
def send_first(
|
|
564
|
+
self,
|
|
565
|
+
session: ActiveSession,
|
|
566
|
+
commands: List[str],
|
|
567
|
+
parse: bool = True,
|
|
568
|
+
timeout: int = 30,
|
|
569
|
+
require_parsed: bool = True,
|
|
570
|
+
) -> Optional[CommandResult]:
|
|
571
|
+
"""
|
|
572
|
+
Try multiple commands until one succeeds (returns parsed data).
|
|
573
|
+
|
|
574
|
+
Useful for CDP/LLDP discovery, platform variations, etc.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
session: Active session
|
|
578
|
+
commands: List of commands to try in order
|
|
579
|
+
parse: Whether to parse output
|
|
580
|
+
timeout: Command timeout
|
|
581
|
+
require_parsed: If True, only consider success if parsed_data is non-empty
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
First successful CommandResult, or None if all failed
|
|
585
|
+
|
|
586
|
+
Example:
|
|
587
|
+
# Try CDP first, fall back to LLDP
|
|
588
|
+
result = api.send_first(session, [
|
|
589
|
+
"show cdp neighbors detail",
|
|
590
|
+
"show lldp neighbors detail",
|
|
591
|
+
])
|
|
592
|
+
|
|
593
|
+
# Platform-agnostic config fetch
|
|
594
|
+
result = api.send_first(session, [
|
|
595
|
+
"show running-config",
|
|
596
|
+
"show configuration",
|
|
597
|
+
], parse=False, require_parsed=False)
|
|
598
|
+
"""
|
|
599
|
+
for cmd in commands:
|
|
600
|
+
if cmd is None:
|
|
601
|
+
continue
|
|
602
|
+
try:
|
|
603
|
+
result = self.send(session, cmd, parse=parse, timeout=timeout)
|
|
604
|
+
|
|
605
|
+
if require_parsed and parse:
|
|
606
|
+
# Need non-empty parsed data
|
|
607
|
+
if result.parsed_data:
|
|
608
|
+
return result
|
|
609
|
+
else:
|
|
610
|
+
# Just need non-empty raw output
|
|
611
|
+
if result.raw_output and result.raw_output.strip():
|
|
612
|
+
return result
|
|
613
|
+
|
|
614
|
+
except Exception:
|
|
615
|
+
continue # Try next command
|
|
616
|
+
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
def send_platform_command(
|
|
620
|
+
self,
|
|
621
|
+
session: ActiveSession,
|
|
622
|
+
command_type: str,
|
|
623
|
+
parse: bool = True,
|
|
624
|
+
timeout: int = 30,
|
|
625
|
+
**kwargs
|
|
626
|
+
) -> Optional[CommandResult]:
|
|
627
|
+
"""
|
|
628
|
+
Send a platform-appropriate command by type.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
session: Active session
|
|
632
|
+
command_type: Command type (e.g., 'config', 'version', 'neighbors')
|
|
633
|
+
parse: Whether to parse output
|
|
634
|
+
timeout: Command timeout
|
|
635
|
+
**kwargs: Format arguments (e.g., name='Gi0/1' for interface_detail)
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
CommandResult or None if command not available
|
|
639
|
+
|
|
640
|
+
Example:
|
|
641
|
+
# Get running config (platform-aware)
|
|
642
|
+
result = api.send_platform_command(session, 'config', parse=False)
|
|
643
|
+
|
|
644
|
+
# Get interface details
|
|
645
|
+
result = api.send_platform_command(session, 'interface_detail', name='Gi0/1')
|
|
646
|
+
"""
|
|
647
|
+
cmd = get_platform_command(session.platform, command_type, **kwargs)
|
|
648
|
+
if not cmd:
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
return self.send(session, cmd, parse=parse, timeout=timeout)
|
|
652
|
+
|
|
1101
653
|
def disconnect(self, session: ActiveSession) -> None:
|
|
1102
654
|
"""
|
|
1103
655
|
Disconnect a session.
|
|
@@ -1114,14 +666,54 @@ class NTermAPI:
|
|
|
1114
666
|
if session.client:
|
|
1115
667
|
session.client.close()
|
|
1116
668
|
|
|
1117
|
-
def
|
|
669
|
+
def disconnect_all(self) -> int:
|
|
670
|
+
"""
|
|
671
|
+
Disconnect all active sessions.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Number of sessions disconnected
|
|
675
|
+
|
|
676
|
+
Example:
|
|
677
|
+
# Cleanup at end of script
|
|
678
|
+
count = api.disconnect_all()
|
|
679
|
+
print(f"Disconnected {count} session(s)")
|
|
1118
680
|
"""
|
|
1119
|
-
|
|
681
|
+
count = 0
|
|
682
|
+
for session_id in list(self._active_sessions.keys()):
|
|
683
|
+
try:
|
|
684
|
+
session = self._active_sessions[session_id]
|
|
685
|
+
self.disconnect(session)
|
|
686
|
+
count += 1
|
|
687
|
+
except Exception:
|
|
688
|
+
pass
|
|
689
|
+
return count
|
|
690
|
+
|
|
691
|
+
def active_sessions(self) -> List[ActiveSession]:
|
|
692
|
+
"""
|
|
693
|
+
Get list of currently active sessions.
|
|
1120
694
|
|
|
1121
695
|
Returns:
|
|
1122
|
-
List of
|
|
696
|
+
List of ActiveSession objects
|
|
1123
697
|
"""
|
|
1124
|
-
|
|
698
|
+
# Clean up stale sessions
|
|
699
|
+
active = []
|
|
700
|
+
stale = []
|
|
701
|
+
|
|
702
|
+
for session_id, session in self._active_sessions.items():
|
|
703
|
+
if session.is_connected():
|
|
704
|
+
active.append(session)
|
|
705
|
+
else:
|
|
706
|
+
stale.append(session_id)
|
|
707
|
+
|
|
708
|
+
# Remove stale entries
|
|
709
|
+
for session_id in stale:
|
|
710
|
+
del self._active_sessions[session_id]
|
|
711
|
+
|
|
712
|
+
return active
|
|
713
|
+
|
|
714
|
+
# -------------------------------------------------------------------------
|
|
715
|
+
# Debug / diagnostic methods
|
|
716
|
+
# -------------------------------------------------------------------------
|
|
1125
717
|
|
|
1126
718
|
def db_info(self) -> Dict[str, Any]:
|
|
1127
719
|
"""
|
|
@@ -1130,9 +722,6 @@ class NTermAPI:
|
|
|
1130
722
|
Returns:
|
|
1131
723
|
Dict with database path, existence, and diagnostic info
|
|
1132
724
|
"""
|
|
1133
|
-
from pathlib import Path
|
|
1134
|
-
import os
|
|
1135
|
-
|
|
1136
725
|
info = {
|
|
1137
726
|
"engine_available": self._tfsm_engine is not None,
|
|
1138
727
|
"db_path": None,
|
|
@@ -1258,7 +847,6 @@ class NTermAPI:
|
|
|
1258
847
|
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
1259
848
|
parser_db_path = self._tfsm_engine.db_path
|
|
1260
849
|
if parser_db_path:
|
|
1261
|
-
from pathlib import Path
|
|
1262
850
|
parser_db_exists = Path(parser_db_path).exists()
|
|
1263
851
|
|
|
1264
852
|
return {
|
|
@@ -1299,8 +887,22 @@ Connections:
|
|
|
1299
887
|
session = api.connect("device") Connect to device (auto-detect platform)
|
|
1300
888
|
result = api.send(session, cmd) Execute command (returns CommandResult)
|
|
1301
889
|
api.disconnect(session) Close connection
|
|
890
|
+
api.disconnect_all() Close all connections
|
|
1302
891
|
api.active_sessions() List active connections
|
|
1303
892
|
|
|
893
|
+
Context Manager (recommended):
|
|
894
|
+
with api.session("device") as s:
|
|
895
|
+
result = api.send(s, "show version")
|
|
896
|
+
# Auto-disconnects when done
|
|
897
|
+
|
|
898
|
+
Platform-Aware Commands:
|
|
899
|
+
api.send_platform_command(s, 'config') Get running config
|
|
900
|
+
api.send_platform_command(s, 'neighbors') Get CDP/LLDP neighbors
|
|
901
|
+
api.send_platform_command(s, 'interface_detail', name='Gi0/1')
|
|
902
|
+
|
|
903
|
+
Try Multiple Commands:
|
|
904
|
+
api.send_first(s, ["show cdp neighbors", "show lldp neighbors"])
|
|
905
|
+
|
|
1304
906
|
Command Results:
|
|
1305
907
|
result.raw_output Raw text from device
|
|
1306
908
|
result.parsed_data Parsed data (List[Dict]) if available
|
|
@@ -1318,18 +920,23 @@ Status:
|
|
|
1318
920
|
api._tfsm_engine TextFSM parser (required)
|
|
1319
921
|
|
|
1320
922
|
Examples:
|
|
1321
|
-
# Connect and execute
|
|
923
|
+
# Connect and execute (manual)
|
|
1322
924
|
api.unlock("vault-password")
|
|
1323
925
|
session = api.connect("spine1")
|
|
1324
926
|
result = api.send(session, "show interfaces status")
|
|
927
|
+
api.disconnect(session)
|
|
1325
928
|
|
|
1326
|
-
#
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
929
|
+
# Connect and execute (context manager - recommended)
|
|
930
|
+
api.unlock("vault-password")
|
|
931
|
+
with api.session("spine1") as s:
|
|
932
|
+
result = api.send(s, "show interfaces status")
|
|
933
|
+
for intf in result.parsed_data:
|
|
934
|
+
print(f"{intf['name']}: {intf['status']}")
|
|
1330
935
|
|
|
1331
|
-
#
|
|
1332
|
-
api.
|
|
936
|
+
# Platform-aware config backup
|
|
937
|
+
with api.session("router1") as s:
|
|
938
|
+
result = api.send_platform_command(s, 'config', parse=False)
|
|
939
|
+
Path("backup.cfg").write_text(result.raw_output)
|
|
1333
940
|
|
|
1334
941
|
Note: TextFSM parser (tfsm_templates.db) is REQUIRED for the API to function.
|
|
1335
942
|
The API will fail during initialization if the database is not found.
|
|
@@ -1339,6 +946,7 @@ The API will fail during initialization if the database is not found.
|
|
|
1339
946
|
# Singleton for convenience in IPython
|
|
1340
947
|
_default_api: Optional[NTermAPI] = None
|
|
1341
948
|
|
|
949
|
+
|
|
1342
950
|
def get_api() -> NTermAPI:
|
|
1343
951
|
"""Get or create default API instance."""
|
|
1344
952
|
global _default_api
|