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/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,197 +334,17 @@ 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
- 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
- # Create SSH client
896
- client = paramiko.SSHClient()
897
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
898
-
899
- # Apply legacy algorithm support
900
- _apply_global_transport_settings()
901
-
902
- # Prepare connection kwargs
903
- connect_kwargs = {
904
- 'hostname': hostname,
905
- 'port': port,
906
- 'timeout': 10,
907
- 'allow_agent': False,
908
- 'look_for_keys': False,
909
- }
910
-
911
- # Add authentication from profile
912
- # ConnectionProfile has username in first auth method
913
- if profile.auth_methods:
914
- first_auth = profile.auth_methods[0]
915
- connect_kwargs['username'] = first_auth.username
916
-
917
- # Try each auth method in order
918
- for auth in profile.auth_methods:
919
- if auth.method == AuthMethod.PASSWORD:
920
- connect_kwargs['password'] = auth.password
921
- break
922
- elif auth.method == AuthMethod.KEY_FILE:
923
- connect_kwargs['key_filename'] = auth.key_file
924
- break
925
- elif auth.method == AuthMethod.KEY_STORED:
926
- # KEY_STORED has key data as string, need to write to temp file
927
- import tempfile
928
- from io import StringIO
929
-
930
- key_file = tempfile.NamedTemporaryFile(
931
- mode='w',
932
- delete=False,
933
- suffix='.pem'
934
- )
935
- key_file.write(auth.key_data)
936
- key_file.close()
937
-
938
- connect_kwargs['key_filename'] = key_file.name
939
- if auth.key_passphrase:
940
- connect_kwargs['passphrase'] = auth.key_passphrase
941
- break
942
-
943
- try:
944
- # Try connection with modern algorithms
945
- client.connect(**connect_kwargs)
946
- except paramiko.SSHException as e:
947
- # If RSA SHA-1 issue, retry with disabled algorithms
948
- if "rsa-sha2" in str(e).lower() or "server-sig-algs" in str(e).lower():
949
- client.close()
950
- client = paramiko.SSHClient()
951
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
952
-
953
- # Apply RSA SHA-1 fallback
954
- transport = client.get_transport()
955
- if transport:
956
- transport.get_security_options().digests = tuple(
957
- alg for alg in transport.get_security_options().digests
958
- if alg not in RSA_SHA1_DISABLED_ALGORITHMS
959
- )
960
-
961
- client.connect(**connect_kwargs)
962
- else:
963
- raise
964
-
965
- # Open interactive shell
966
- shell = client.invoke_shell(width=200, height=50)
967
- shell.settimeout(0.5)
968
-
969
- # Wait for initial prompt
970
- prompt = self._wait_for_prompt(shell)
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 = self._send_command(shell, "show version", prompt)
985
- platform = self._detect_platform(version_output)
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
- print(f"Warning: Failed to detect platform: {e}")
408
+ if debug:
409
+ print(f"[DEBUG] Platform detection failed: {e}")
989
410
 
990
- # Disable terminal paging for cleaner output
991
- try:
992
- if session.platform and 'cisco' in session.platform:
993
- self._send_command(shell, "terminal length 0", prompt, timeout=5)
994
- elif session.platform == 'juniper_junos':
995
- self._send_command(shell, "set cli screen-length 0", prompt, timeout=5)
996
- elif session.platform == 'arista_eos':
997
- self._send_command(shell, "terminal length 0", prompt, timeout=5)
998
- except Exception as e:
999
- print(f"Warning: Failed to disable paging: {e}")
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 = 30,
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 = 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)
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 = self._normalize_fields(
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 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)")
1118
680
  """
1119
- List currently active session names.
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 device names with active connections
696
+ List of ActiveSession objects
1123
697
  """
1124
- 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
+ # -------------------------------------------------------------------------
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
- # Access parsed data
1327
- if result.parsed_data:
1328
- for interface in result.parsed_data:
1329
- 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']}")
1330
935
 
1331
- # Disconnect
1332
- 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)
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