ntermqt 0.1.7__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/scripting/api.py +247 -691
- 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 -107
- 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.7.dist-info → ntermqt-0.1.8.dist-info}/METADATA +88 -28
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.8.dist-info}/RECORD +14 -9
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.8.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.8.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.7.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,183 +334,6 @@ class NTermAPI:
|
|
|
658
334
|
# Connection operations
|
|
659
335
|
# -------------------------------------------------------------------------
|
|
660
336
|
|
|
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
337
|
def connect(self, device: str, credential: str = None, debug: bool = False) -> ActiveSession:
|
|
839
338
|
"""
|
|
840
339
|
Connect to a device and detect platform.
|
|
@@ -847,13 +346,6 @@ class NTermAPI:
|
|
|
847
346
|
Returns:
|
|
848
347
|
ActiveSession handle for sending commands
|
|
849
348
|
"""
|
|
850
|
-
debug_log = []
|
|
851
|
-
|
|
852
|
-
def _debug(msg):
|
|
853
|
-
if debug:
|
|
854
|
-
debug_log.append(msg)
|
|
855
|
-
print(f"[DEBUG] {msg}")
|
|
856
|
-
|
|
857
349
|
# Look up device from saved sessions first
|
|
858
350
|
device_info = self.device(device)
|
|
859
351
|
|
|
@@ -868,14 +360,11 @@ class NTermAPI:
|
|
|
868
360
|
device_name = device
|
|
869
361
|
saved_cred = None
|
|
870
362
|
|
|
871
|
-
_debug(f"Target: {hostname}:{port}")
|
|
872
|
-
|
|
873
363
|
# Resolve credentials
|
|
874
364
|
if not self.vault_unlocked:
|
|
875
365
|
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
876
366
|
|
|
877
367
|
cred_name = credential or saved_cred
|
|
878
|
-
_debug(f"Credential: {cred_name or '(auto-resolve)'}")
|
|
879
368
|
|
|
880
369
|
if cred_name:
|
|
881
370
|
try:
|
|
@@ -895,134 +384,10 @@ class NTermAPI:
|
|
|
895
384
|
if not profile:
|
|
896
385
|
raise ValueError(f"No credentials available for {hostname}")
|
|
897
386
|
|
|
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}")
|
|
387
|
+
# Establish SSH connection using our refactored module
|
|
388
|
+
client, shell, prompt, debug_log = connect_ssh(hostname, port, profile, debug)
|
|
1025
389
|
|
|
390
|
+
# Create session object
|
|
1026
391
|
session = ActiveSession(
|
|
1027
392
|
device_name=device_name,
|
|
1028
393
|
hostname=hostname,
|
|
@@ -1034,32 +399,77 @@ class NTermAPI:
|
|
|
1034
399
|
|
|
1035
400
|
# Detect platform
|
|
1036
401
|
try:
|
|
1037
|
-
version_output =
|
|
1038
|
-
platform =
|
|
402
|
+
version_output = send_command(shell, "show version", prompt)
|
|
403
|
+
platform = detect_platform(version_output)
|
|
1039
404
|
session.platform = platform
|
|
1040
|
-
|
|
405
|
+
if debug:
|
|
406
|
+
print(f"[DEBUG] Platform detected: {platform}")
|
|
1041
407
|
except Exception as e:
|
|
1042
|
-
|
|
408
|
+
if debug:
|
|
409
|
+
print(f"[DEBUG] Platform detection failed: {e}")
|
|
1043
410
|
|
|
1044
411
|
# Disable terminal paging
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
except Exception as e:
|
|
1053
|
-
_debug(f"Failed to disable paging: {e}")
|
|
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}")
|
|
1054
419
|
|
|
1055
420
|
self._active_sessions[device_name] = session
|
|
1056
421
|
return session
|
|
1057
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
|
+
|
|
1058
468
|
def send(
|
|
1059
469
|
self,
|
|
1060
470
|
session: ActiveSession,
|
|
1061
471
|
command: str,
|
|
1062
|
-
timeout: int =
|
|
472
|
+
timeout: int = 60,
|
|
1063
473
|
parse: bool = True,
|
|
1064
474
|
normalize: bool = True,
|
|
1065
475
|
) -> CommandResult:
|
|
@@ -1089,8 +499,8 @@ class NTermAPI:
|
|
|
1089
499
|
if not session.is_connected():
|
|
1090
500
|
raise RuntimeError(f"Session {session.device_name} is not connected")
|
|
1091
501
|
|
|
1092
|
-
# Execute command
|
|
1093
|
-
raw_output =
|
|
502
|
+
# Execute command using our refactored module
|
|
503
|
+
raw_output = send_command(session.shell, command, session.prompt, timeout)
|
|
1094
504
|
|
|
1095
505
|
# Create result object
|
|
1096
506
|
result = CommandResult(
|
|
@@ -1130,7 +540,7 @@ class NTermAPI:
|
|
|
1130
540
|
if normalize and parsed_data:
|
|
1131
541
|
# Determine which field map to use based on command
|
|
1132
542
|
if 'interface' in command.lower():
|
|
1133
|
-
normalized =
|
|
543
|
+
normalized = normalize_fields(
|
|
1134
544
|
parsed_data,
|
|
1135
545
|
session.platform,
|
|
1136
546
|
INTERFACE_DETAIL_FIELD_MAP,
|
|
@@ -1150,6 +560,96 @@ class NTermAPI:
|
|
|
1150
560
|
|
|
1151
561
|
return result
|
|
1152
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
|
+
|
|
1153
653
|
def disconnect(self, session: ActiveSession) -> None:
|
|
1154
654
|
"""
|
|
1155
655
|
Disconnect a session.
|
|
@@ -1166,14 +666,54 @@ class NTermAPI:
|
|
|
1166
666
|
if session.client:
|
|
1167
667
|
session.client.close()
|
|
1168
668
|
|
|
1169
|
-
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)")
|
|
680
|
+
"""
|
|
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]:
|
|
1170
692
|
"""
|
|
1171
|
-
|
|
693
|
+
Get list of currently active sessions.
|
|
1172
694
|
|
|
1173
695
|
Returns:
|
|
1174
|
-
List of
|
|
696
|
+
List of ActiveSession objects
|
|
1175
697
|
"""
|
|
1176
|
-
|
|
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
|
+
# -------------------------------------------------------------------------
|
|
1177
717
|
|
|
1178
718
|
def db_info(self) -> Dict[str, Any]:
|
|
1179
719
|
"""
|
|
@@ -1182,9 +722,6 @@ class NTermAPI:
|
|
|
1182
722
|
Returns:
|
|
1183
723
|
Dict with database path, existence, and diagnostic info
|
|
1184
724
|
"""
|
|
1185
|
-
from pathlib import Path
|
|
1186
|
-
import os
|
|
1187
|
-
|
|
1188
725
|
info = {
|
|
1189
726
|
"engine_available": self._tfsm_engine is not None,
|
|
1190
727
|
"db_path": None,
|
|
@@ -1310,7 +847,6 @@ class NTermAPI:
|
|
|
1310
847
|
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
1311
848
|
parser_db_path = self._tfsm_engine.db_path
|
|
1312
849
|
if parser_db_path:
|
|
1313
|
-
from pathlib import Path
|
|
1314
850
|
parser_db_exists = Path(parser_db_path).exists()
|
|
1315
851
|
|
|
1316
852
|
return {
|
|
@@ -1351,8 +887,22 @@ Connections:
|
|
|
1351
887
|
session = api.connect("device") Connect to device (auto-detect platform)
|
|
1352
888
|
result = api.send(session, cmd) Execute command (returns CommandResult)
|
|
1353
889
|
api.disconnect(session) Close connection
|
|
890
|
+
api.disconnect_all() Close all connections
|
|
1354
891
|
api.active_sessions() List active connections
|
|
1355
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
|
+
|
|
1356
906
|
Command Results:
|
|
1357
907
|
result.raw_output Raw text from device
|
|
1358
908
|
result.parsed_data Parsed data (List[Dict]) if available
|
|
@@ -1370,18 +920,23 @@ Status:
|
|
|
1370
920
|
api._tfsm_engine TextFSM parser (required)
|
|
1371
921
|
|
|
1372
922
|
Examples:
|
|
1373
|
-
# Connect and execute
|
|
923
|
+
# Connect and execute (manual)
|
|
1374
924
|
api.unlock("vault-password")
|
|
1375
925
|
session = api.connect("spine1")
|
|
1376
926
|
result = api.send(session, "show interfaces status")
|
|
927
|
+
api.disconnect(session)
|
|
1377
928
|
|
|
1378
|
-
#
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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']}")
|
|
1382
935
|
|
|
1383
|
-
#
|
|
1384
|
-
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)
|
|
1385
940
|
|
|
1386
941
|
Note: TextFSM parser (tfsm_templates.db) is REQUIRED for the API to function.
|
|
1387
942
|
The API will fail during initialization if the database is not found.
|
|
@@ -1391,6 +946,7 @@ The API will fail during initialization if the database is not found.
|
|
|
1391
946
|
# Singleton for convenience in IPython
|
|
1392
947
|
_default_api: Optional[NTermAPI] = None
|
|
1393
948
|
|
|
949
|
+
|
|
1394
950
|
def get_api() -> NTermAPI:
|
|
1395
951
|
"""Get or create default API instance."""
|
|
1396
952
|
global _default_api
|