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/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 typing import Optional, List, Dict, Any, Union
12
- from dataclasses import dataclass, asdict, field
13
- from datetime import datetime
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 paramiko
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
- # Reuse SSHSession's algorithm configuration
23
- from ..session.ssh import (
24
- _apply_global_transport_settings,
25
- RSA_SHA1_DISABLED_ALGORITHMS,
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 (future)
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
- # Apply legacy algorithm support
899
- _apply_global_transport_settings()
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 = self._send_command(shell, "show version", prompt)
1038
- platform = self._detect_platform(version_output)
402
+ version_output = send_command(shell, "show version", prompt)
403
+ platform = detect_platform(version_output)
1039
404
  session.platform = platform
1040
- _debug(f"Platform detected: {platform}")
405
+ if debug:
406
+ print(f"[DEBUG] Platform detected: {platform}")
1041
407
  except Exception as e:
1042
- _debug(f"Platform detection failed: {e}")
408
+ if debug:
409
+ print(f"[DEBUG] Platform detection failed: {e}")
1043
410
 
1044
411
  # Disable terminal paging
1045
- try:
1046
- if session.platform and 'cisco' in session.platform:
1047
- self._send_command(shell, "terminal length 0", prompt, timeout=5)
1048
- elif session.platform == 'juniper_junos':
1049
- self._send_command(shell, "set cli screen-length 0", prompt, timeout=5)
1050
- elif session.platform == 'arista_eos':
1051
- self._send_command(shell, "terminal length 0", prompt, timeout=5)
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 = 30,
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 = self._send_command(session.shell, command, session.prompt, timeout)
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 = self._normalize_fields(
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 active_sessions(self) -> List[str]:
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
- List currently active session names.
693
+ Get list of currently active sessions.
1172
694
 
1173
695
  Returns:
1174
- List of device names with active connections
696
+ List of ActiveSession objects
1175
697
  """
1176
- return list(self._active_sessions.keys())
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
- # Access parsed data
1379
- if result.parsed_data:
1380
- for interface in result.parsed_data:
1381
- print(f"{interface['name']}: {interface['status']}")
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
- # Disconnect
1384
- api.disconnect(session)
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