ntermqt 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nterm/scripting/api.py CHANGED
@@ -5,25 +5,30 @@ Scripting API for nterm - usable from IPython, CLI, or MCP tools.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
- import re
9
- import time
10
8
  import fnmatch
11
- from 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
+ detect_platform_from_template,
23
+ extract_platform_from_template_name,
24
+ normalize_fields,
25
+ get_paging_disable_command,
26
+ get_platform_command,
27
+ extract_version_info,
28
+ extract_neighbor_info,
29
+ try_disable_paging,
26
30
  )
31
+ from .ssh_connection import connect_ssh, send_command, PagingNotDisabledError
27
32
 
28
33
  # TextFSM parsing - REQUIRED
29
34
  try:
@@ -35,336 +40,6 @@ except ImportError as e:
35
40
  _TFSM_IMPORT_ERROR = str(e)
36
41
 
37
42
 
38
- # =============================================================================
39
- # Platform Detection
40
- # =============================================================================
41
-
42
- PLATFORM_PATTERNS = {
43
- 'arista_eos': [
44
- r'Arista',
45
- r'vEOS',
46
- ],
47
- 'cisco_ios': [
48
- r'Cisco IOS Software',
49
- r'IOS \(tm\)',
50
- ],
51
- 'cisco_nxos': [
52
- r'Cisco Nexus',
53
- r'NX-OS',
54
- ],
55
- 'cisco_iosxe': [
56
- r'Cisco IOS XE Software',
57
- ],
58
- 'cisco_iosxr': [
59
- r'Cisco IOS XR Software',
60
- ],
61
- 'juniper_junos': [
62
- r'JUNOS',
63
- r'Juniper Networks',
64
- ],
65
- }
66
-
67
-
68
- # =============================================================================
69
- # Platform-specific command mappings
70
- # =============================================================================
71
-
72
- PLATFORM_COMMANDS = {
73
- 'arista_eos': {
74
- 'interfaces_status': 'show interfaces status',
75
- 'interface_detail': 'show interfaces {name}',
76
- },
77
- 'cisco_ios': {
78
- 'interfaces_status': 'show interfaces status',
79
- 'interface_detail': 'show interfaces {name}',
80
- },
81
- 'cisco_nxos': {
82
- 'interfaces_status': 'show interface status',
83
- 'interface_detail': 'show interface {name}',
84
- },
85
- 'juniper_junos': {
86
- 'interfaces_status': 'show interfaces terse',
87
- 'interface_detail': 'show interfaces {name} extensive',
88
- },
89
- }
90
-
91
- DEFAULT_COMMANDS = {
92
- 'interfaces_status': 'show interfaces status',
93
- 'interface_detail': 'show interfaces {name}',
94
- }
95
-
96
-
97
- # =============================================================================
98
- # Vendor Field Mappings for output normalization
99
- # =============================================================================
100
-
101
- # Maps canonical field names to vendor-specific template field names
102
- INTERFACE_DETAIL_FIELD_MAP = {
103
- 'arista_eos': {
104
- 'interface': ['INTERFACE'],
105
- 'admin_state': ['LINK_STATUS'],
106
- 'oper_state': ['PROTOCOL_STATUS'],
107
- 'hardware': ['HARDWARE_TYPE'],
108
- 'mac_address': ['MAC_ADDRESS'],
109
- 'description': ['DESCRIPTION'],
110
- 'mtu': ['MTU'],
111
- 'bandwidth': ['BANDWIDTH'],
112
- 'in_packets': ['INPUT_PACKETS'],
113
- 'out_packets': ['OUTPUT_PACKETS'],
114
- 'in_errors': ['INPUT_ERRORS'],
115
- 'out_errors': ['OUTPUT_ERRORS'],
116
- 'crc_errors': ['CRC'],
117
- },
118
- 'cisco_ios': {
119
- 'interface': ['INTERFACE'],
120
- 'admin_state': ['LINK_STATUS'],
121
- 'oper_state': ['PROTOCOL_STATUS'],
122
- 'hardware': ['HARDWARE_TYPE'],
123
- 'mac_address': ['MAC_ADDRESS'],
124
- 'description': ['DESCRIPTION'],
125
- 'mtu': ['MTU'],
126
- 'bandwidth': ['BANDWIDTH'],
127
- 'duplex': ['DUPLEX'],
128
- 'speed': ['SPEED'],
129
- 'in_packets': ['INPUT_PACKETS'],
130
- 'out_packets': ['OUTPUT_PACKETS'],
131
- 'in_errors': ['INPUT_ERRORS'],
132
- 'out_errors': ['OUTPUT_ERRORS'],
133
- 'crc_errors': ['CRC'],
134
- },
135
- 'cisco_nxos': {
136
- 'interface': ['INTERFACE'],
137
- 'admin_state': ['ADMIN_STATE', 'LINK_STATUS'],
138
- 'oper_state': ['OPER_STATE', 'PROTOCOL_STATUS'],
139
- 'hardware': ['HARDWARE_TYPE'],
140
- 'mac_address': ['MAC_ADDRESS', 'ADDRESS'],
141
- 'description': ['DESCRIPTION'],
142
- 'mtu': ['MTU'],
143
- 'bandwidth': ['BANDWIDTH', 'BW'],
144
- 'in_packets': ['IN_PKTS', 'INPUT_PACKETS'],
145
- 'out_packets': ['OUT_PKTS', 'OUTPUT_PACKETS'],
146
- 'in_errors': ['IN_ERRORS', 'INPUT_ERRORS'],
147
- 'out_errors': ['OUT_ERRORS', 'OUTPUT_ERRORS'],
148
- 'crc_errors': ['CRC', 'CRC_ERRORS'],
149
- },
150
- 'juniper_junos': {
151
- 'interface': ['INTERFACE'],
152
- 'admin_state': ['ADMIN_STATE'],
153
- 'oper_state': ['LINK_STATUS'],
154
- 'hardware': ['HARDWARE_TYPE'],
155
- 'mac_address': ['MAC_ADDRESS'],
156
- 'description': ['DESCRIPTION'],
157
- 'mtu': ['MTU'],
158
- 'bandwidth': ['BANDWIDTH'],
159
- 'in_packets': ['INPUT_PACKETS'],
160
- 'out_packets': ['OUTPUT_PACKETS'],
161
- 'in_errors': ['INPUT_ERRORS'],
162
- 'out_errors': ['OUTPUT_ERRORS'],
163
- 'crc_errors': ['CRC'],
164
- },
165
- }
166
-
167
- DEFAULT_FIELD_MAP = {
168
- 'interface': ['INTERFACE', 'PORT', 'NAME'],
169
- 'admin_state': ['ADMIN_STATE', 'LINK_STATUS', 'STATUS'],
170
- 'oper_state': ['OPER_STATE', 'PROTOCOL_STATUS', 'LINE_STATUS'],
171
- 'hardware': ['HARDWARE_TYPE', 'HARDWARE', 'MEDIA_TYPE', 'TYPE'],
172
- 'mac_address': ['MAC_ADDRESS', 'ADDRESS', 'MAC'],
173
- 'description': ['DESCRIPTION', 'NAME', 'DESC'],
174
- 'mtu': ['MTU'],
175
- 'bandwidth': ['BANDWIDTH', 'BW', 'SPEED'],
176
- 'duplex': ['DUPLEX'],
177
- 'speed': ['SPEED'],
178
- 'in_packets': ['INPUT_PACKETS', 'IN_PKTS', 'IN_PACKETS'],
179
- 'out_packets': ['OUTPUT_PACKETS', 'OUT_PKTS', 'OUT_PACKETS'],
180
- 'in_errors': ['INPUT_ERRORS', 'IN_ERRORS'],
181
- 'out_errors': ['OUTPUT_ERRORS', 'OUT_ERRORS'],
182
- 'crc_errors': ['CRC', 'CRC_ERRORS'],
183
- }
184
-
185
-
186
- @dataclass
187
- class ActiveSession:
188
- """Represents an active SSH connection to a device."""
189
- device_name: str
190
- hostname: str
191
- port: int
192
- platform: Optional[str] = None
193
- client: Optional[paramiko.SSHClient] = None
194
- shell: Optional[paramiko.Channel] = None
195
- prompt: Optional[str] = None
196
- connected_at: datetime = field(default_factory=datetime.now)
197
-
198
- def is_connected(self) -> bool:
199
- """Check if session is still active."""
200
- return self.client is not None and self.shell is not None and self.shell.active
201
-
202
- def __repr__(self) -> str:
203
- status = "connected" if self.is_connected() else "disconnected"
204
- platform = f", platform={self.platform}" if self.platform else ""
205
- return f"<ActiveSession {self.device_name}@{self.hostname}:{self.port} {status}{platform}>"
206
-
207
- def __str__(self) -> str:
208
- """Detailed string representation."""
209
- lines = [
210
- f"Active Session: {self.device_name}",
211
- f" Host: {self.hostname}:{self.port}",
212
- f" Status: {'connected' if self.is_connected() else 'disconnected'}",
213
- ]
214
- if self.platform:
215
- lines.append(f" Platform: {self.platform}")
216
- if self.prompt:
217
- lines.append(f" Prompt: {self.prompt}")
218
- lines.append(f" Connected at: {self.connected_at.strftime('%Y-%m-%d %H:%M:%S')}")
219
- return '\n'.join(lines)
220
-
221
-
222
- @dataclass
223
- class CommandResult:
224
- """Result of executing a command on a device."""
225
- command: str
226
- raw_output: str
227
- platform: Optional[str] = None
228
- parsed_data: Optional[List[Dict[str, Any]]] = None
229
- parse_success: bool = False
230
- parse_template: Optional[str] = None
231
- normalized_fields: Optional[Dict[str, str]] = None
232
- timestamp: datetime = field(default_factory=datetime.now)
233
-
234
- def to_dict(self) -> Dict[str, Any]:
235
- """Convert to dictionary."""
236
- return {
237
- 'command': self.command,
238
- 'raw_output': self.raw_output,
239
- 'platform': self.platform,
240
- 'parsed_data': self.parsed_data,
241
- 'parse_success': self.parse_success,
242
- 'parse_template': self.parse_template,
243
- 'normalized_fields': self.normalized_fields,
244
- 'timestamp': self.timestamp.isoformat(),
245
- }
246
-
247
- def __repr__(self) -> str:
248
- parsed = f", {len(self.parsed_data)} parsed" if self.parsed_data else ""
249
- platform = f", platform={self.platform}" if self.platform else ""
250
- return f"<CommandResult '{self.command}'{platform}{parsed}>"
251
-
252
- def __str__(self) -> str:
253
- """Detailed string representation."""
254
- lines = [
255
- f"Command: {self.command}",
256
- f"Timestamp: {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}",
257
- ]
258
- if self.platform:
259
- lines.append(f"Platform: {self.platform}")
260
-
261
- lines.append(f"Parse success: {self.parse_success}")
262
- if self.parse_template:
263
- lines.append(f"Template: {self.parse_template}")
264
-
265
- if self.parsed_data:
266
- lines.append(f"Parsed rows: {len(self.parsed_data)}")
267
- if self.normalized_fields:
268
- lines.append(f"Field normalization: {self.normalized_fields['map_used']}")
269
-
270
- lines.append(f"\nRaw output ({len(self.raw_output)} chars):")
271
- lines.append("-" * 60)
272
- lines.append(self.raw_output[:500] + ("..." if len(self.raw_output) > 500 else ""))
273
-
274
- return '\n'.join(lines)
275
-
276
-
277
- @dataclass
278
- class DeviceInfo:
279
- """Simplified device view for scripting."""
280
- name: str
281
- hostname: str
282
- port: int
283
- folder: Optional[str] = None
284
- credential: Optional[str] = None
285
- last_connected: Optional[str] = None
286
- connect_count: int = 0
287
-
288
- @classmethod
289
- def from_session(cls, session: SavedSession, folder_name: str = None) -> 'DeviceInfo':
290
- return cls(
291
- name=session.name,
292
- hostname=session.hostname,
293
- port=session.port,
294
- folder=folder_name,
295
- credential=session.credential_name,
296
- last_connected=str(session.last_connected) if session.last_connected else None,
297
- connect_count=session.connect_count,
298
- )
299
-
300
- def to_dict(self) -> Dict[str, Any]:
301
- return asdict(self)
302
-
303
- def __repr__(self) -> str:
304
- cred = f", cred={self.credential}" if self.credential else ""
305
- folder = f", folder={self.folder}" if self.folder else ""
306
- return f"Device({self.name}, {self.hostname}:{self.port}{cred}{folder})"
307
-
308
- def __str__(self) -> str:
309
- """Detailed string representation."""
310
- lines = [
311
- f"Device: {self.name}",
312
- f" Hostname: {self.hostname}:{self.port}",
313
- ]
314
- if self.folder:
315
- lines.append(f" Folder: {self.folder}")
316
- if self.credential:
317
- lines.append(f" Credential: {self.credential}")
318
- if self.last_connected:
319
- lines.append(f" Last connected: {self.last_connected}")
320
- if self.connect_count > 0:
321
- lines.append(f" Connection count: {self.connect_count}")
322
- return '\n'.join(lines)
323
-
324
-
325
- @dataclass
326
- class CredentialInfo:
327
- """Simplified credential view for scripting (no secrets exposed)."""
328
- name: str
329
- username: str
330
- has_password: bool
331
- has_key: bool
332
- match_hosts: List[str]
333
- match_tags: List[str]
334
- jump_host: Optional[str] = None
335
- is_default: bool = False
336
-
337
- def to_dict(self) -> Dict[str, Any]:
338
- return asdict(self)
339
-
340
- def __repr__(self) -> str:
341
- auth = []
342
- if self.has_password:
343
- auth.append("password")
344
- if self.has_key:
345
- auth.append("key")
346
- auth_str = "+".join(auth) if auth else "none"
347
- default = " [default]" if self.is_default else ""
348
- return f"Credential({self.name}, user={self.username}, auth={auth_str}{default})"
349
-
350
- def __str__(self) -> str:
351
- """Detailed string representation."""
352
- lines = [
353
- f"Credential: {self.name}",
354
- f" Username: {self.username}",
355
- f" Authentication: {'password' if self.has_password else ''}{'+' if self.has_password and self.has_key else ''}{'SSH key' if self.has_key else ''}",
356
- ]
357
- if self.match_hosts:
358
- lines.append(f" Host patterns: {', '.join(self.match_hosts)}")
359
- if self.match_tags:
360
- lines.append(f" Tags: {', '.join(self.match_tags)}")
361
- if self.jump_host:
362
- lines.append(f" Jump host: {self.jump_host}")
363
- if self.is_default:
364
- lines.append(f" [DEFAULT]")
365
- return '\n'.join(lines)
366
-
367
-
368
43
  class NTermAPI:
369
44
  """
370
45
  Scripting interface for nterm.
@@ -384,9 +59,13 @@ class NTermAPI:
384
59
  api.credentials()
385
60
  api.credential("lab-admin")
386
61
 
387
- # Connect and execute (future)
62
+ # Connect and execute
388
63
  session = api.connect("eng-leaf-1")
389
64
  output = api.send(session, "show version")
65
+
66
+ # Context manager (auto-cleanup)
67
+ with api.session("eng-leaf-1") as s:
68
+ result = api.send(s, "show version")
390
69
  """
391
70
 
392
71
  def __init__(
@@ -658,183 +337,6 @@ class NTermAPI:
658
337
  # Connection operations
659
338
  # -------------------------------------------------------------------------
660
339
 
661
- def _detect_platform(self, version_output: str) -> Optional[str]:
662
- """Detect device platform from 'show version' output."""
663
- for platform, patterns in PLATFORM_PATTERNS.items():
664
- for pattern in patterns:
665
- if re.search(pattern, version_output, re.IGNORECASE):
666
- return platform
667
- return None
668
-
669
- def _normalize_fields(
670
- self,
671
- parsed_data: List[Dict[str, Any]],
672
- platform: str,
673
- field_map_dict: Dict[str, Dict[str, List[str]]],
674
- ) -> List[Dict[str, Any]]:
675
- """
676
- Normalize vendor-specific field names to canonical names.
677
-
678
- Args:
679
- parsed_data: Raw parsed data from TextFSM
680
- platform: Detected platform
681
- field_map_dict: Mapping dict (e.g., INTERFACE_DETAIL_FIELD_MAP)
682
-
683
- Returns:
684
- List of dicts with normalized field names
685
- """
686
- field_map = field_map_dict.get(platform, DEFAULT_FIELD_MAP)
687
- normalized = []
688
-
689
- for row in parsed_data:
690
- norm_row = {}
691
- for canonical_name, vendor_names in field_map.items():
692
- for vendor_name in vendor_names:
693
- if vendor_name in row:
694
- norm_row[canonical_name] = row[vendor_name]
695
- break
696
- # Keep any fields that weren't in the mapping
697
- for key, value in row.items():
698
- if key not in [vn for vnames in field_map.values() for vn in vnames]:
699
- norm_row[key] = value
700
- normalized.append(norm_row)
701
-
702
- return normalized
703
-
704
- def _wait_for_prompt(
705
- self,
706
- shell: paramiko.Channel,
707
- timeout: int = 10,
708
- initial_wait: float = 0.5,
709
- ) -> str:
710
- """
711
- Wait for device prompt and return detected prompt pattern.
712
-
713
- Returns:
714
- Detected prompt string
715
- """
716
- time.sleep(initial_wait)
717
-
718
- # Send newline to trigger prompt
719
- shell.send('\n')
720
- time.sleep(0.3)
721
-
722
- output = ""
723
- end_time = time.time() + timeout
724
-
725
- while time.time() < end_time:
726
- if shell.recv_ready():
727
- chunk = shell.recv(4096).decode('utf-8', errors='ignore')
728
- output += chunk
729
- time.sleep(0.1)
730
- else:
731
- break
732
-
733
- # Extract last line as prompt (after last newline)
734
- lines = output.strip().split('\n')
735
- prompt = lines[-1] if lines else ""
736
-
737
- # Common prompt patterns: ends with #, >, $
738
- if prompt and prompt[-1] in '#>$':
739
- return prompt
740
-
741
- return prompt
742
-
743
- def _send_command(
744
- self,
745
- shell: paramiko.Channel,
746
- command: str,
747
- prompt: str,
748
- timeout: int = 30,
749
- ) -> str:
750
- """
751
- Send command and collect output until prompt returns.
752
-
753
- Args:
754
- shell: Active SSH channel
755
- command: Command to execute
756
- prompt: Expected prompt pattern
757
- timeout: Command timeout in seconds
758
-
759
- Returns:
760
- Command output (without echoed command and prompt)
761
- """
762
- # Clear any pending input aggressively
763
- time.sleep(0.1)
764
- while shell.recv_ready():
765
- shell.recv(65536)
766
- time.sleep(0.05)
767
-
768
- # Send command - strip whitespace to avoid issues
769
- command = command.strip()
770
- shell.send(command + '\n')
771
- time.sleep(0.3) # Give device time to echo command
772
-
773
- output = ""
774
- end_time = time.time() + timeout
775
- prompt_seen = False
776
-
777
- # Paging prompts to handle (--More--, -- More --, etc.)
778
- paging_prompts = [
779
- '--More--',
780
- '-- More --',
781
- '<--- More --->',
782
- 'Press any key to continue',
783
- ]
784
-
785
- while time.time() < end_time:
786
- if shell.recv_ready():
787
- chunk = shell.recv(65536).decode('utf-8', errors='ignore')
788
- output += chunk
789
-
790
- # Check for paging prompt
791
- for paging_prompt in paging_prompts:
792
- if paging_prompt in output:
793
- # Send space to continue
794
- shell.send(' ')
795
- time.sleep(0.2)
796
- # Remove paging prompt from output
797
- output = output.replace(paging_prompt, '')
798
- break
799
-
800
- # Check if we've received the final prompt
801
- if prompt in output:
802
- prompt_seen = True
803
- # Give a bit more time for any trailing data
804
- time.sleep(0.1)
805
- if shell.recv_ready():
806
- chunk = shell.recv(65536).decode('utf-8', errors='ignore')
807
- output += chunk
808
- break
809
-
810
- time.sleep(0.1)
811
- else:
812
- if prompt_seen or len(output) > 0:
813
- time.sleep(0.3)
814
- if not shell.recv_ready():
815
- break
816
-
817
- # Clean up output: remove echoed command and prompt
818
- lines = output.split('\n')
819
-
820
- # Remove first line if it contains the echoed command
821
- if lines and command.lower() in lines[0].lower():
822
- lines = lines[1:]
823
-
824
- # Remove last line if it's the prompt
825
- if lines and prompt in lines[-1]:
826
- lines = lines[:-1]
827
-
828
- # Also remove any lines that are just the prompt or empty
829
- cleaned_lines = []
830
- for line in lines:
831
- stripped = line.strip('\r\n ')
832
- # Skip prompt lines and residual paging artifacts
833
- if stripped and stripped != prompt and not any(p in stripped for p in paging_prompts):
834
- cleaned_lines.append(line)
835
-
836
- return '\n'.join(cleaned_lines).strip()
837
-
838
340
  def connect(self, device: str, credential: str = None, debug: bool = False) -> ActiveSession:
839
341
  """
840
342
  Connect to a device and detect platform.
@@ -847,13 +349,6 @@ class NTermAPI:
847
349
  Returns:
848
350
  ActiveSession handle for sending commands
849
351
  """
850
- debug_log = []
851
-
852
- def _debug(msg):
853
- if debug:
854
- debug_log.append(msg)
855
- print(f"[DEBUG] {msg}")
856
-
857
352
  # Look up device from saved sessions first
858
353
  device_info = self.device(device)
859
354
 
@@ -868,14 +363,11 @@ class NTermAPI:
868
363
  device_name = device
869
364
  saved_cred = None
870
365
 
871
- _debug(f"Target: {hostname}:{port}")
872
-
873
366
  # Resolve credentials
874
367
  if not self.vault_unlocked:
875
368
  raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
876
369
 
877
370
  cred_name = credential or saved_cred
878
- _debug(f"Credential: {cred_name or '(auto-resolve)'}")
879
371
 
880
372
  if cred_name:
881
373
  try:
@@ -895,134 +387,10 @@ class NTermAPI:
895
387
  if not profile:
896
388
  raise ValueError(f"No credentials available for {hostname}")
897
389
 
898
- # 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}")
390
+ # Establish SSH connection using our refactored module
391
+ client, shell, prompt, debug_log = connect_ssh(hostname, port, profile, debug)
1025
392
 
393
+ # Create session object
1026
394
  session = ActiveSession(
1027
395
  device_name=device_name,
1028
396
  hostname=hostname,
@@ -1032,36 +400,97 @@ class NTermAPI:
1032
400
  prompt=prompt,
1033
401
  )
1034
402
 
1035
- # Detect platform
403
+ # Pre-emptively try to disable paging BEFORE platform detection
404
+ # This prevents 'show version' from being truncated by --More--
405
+ # Use the most common command - harmless if it fails on non-Cisco platforms
1036
406
  try:
1037
- version_output = self._send_command(shell, "show version", prompt)
1038
- platform = self._detect_platform(version_output)
1039
- session.platform = platform
1040
- _debug(f"Platform detected: {platform}")
407
+ send_command(shell, "terminal length 0", prompt, timeout=5)
408
+ if debug:
409
+ print(f"[DEBUG] Pre-emptive paging disable: terminal length 0")
1041
410
  except Exception as e:
1042
- _debug(f"Platform detection failed: {e}")
411
+ if debug:
412
+ print(f"[DEBUG] Pre-emptive paging disable failed (normal on some platforms): {e}")
1043
413
 
1044
- # Disable terminal paging
414
+ # Detect platform using TextFSM template matching (primary) or regex (fallback)
1045
415
  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)
416
+ version_output = send_command(shell, "show version", prompt)
417
+ platform = detect_platform(version_output, tfsm_engine=self._tfsm_engine)
418
+ session.platform = platform
419
+ if debug:
420
+ print(f"[DEBUG] Platform detected: {platform}")
1052
421
  except Exception as e:
1053
- _debug(f"Failed to disable paging: {e}")
422
+ if debug:
423
+ print(f"[DEBUG] Platform detection failed: {e}")
424
+
425
+ # Disable terminal paging
426
+ paging_cmd = get_paging_disable_command(session.platform)
427
+ if paging_cmd:
428
+ try:
429
+ send_command(shell, paging_cmd, prompt, timeout=5)
430
+ session._paging_disabled = True
431
+ except Exception as e:
432
+ if debug:
433
+ print(f"[DEBUG] Failed to disable paging: {e}")
434
+ else:
435
+ # Platform unknown - will try to auto-fix if paging error occurs
436
+ session._paging_disabled = False
1054
437
 
1055
438
  self._active_sessions[device_name] = session
1056
439
  return session
1057
440
 
441
+ @contextmanager
442
+ def session(self, device: str, credential: str = None, debug: bool = False) -> Generator[ActiveSession, None, None]:
443
+ """
444
+ Context manager for device sessions with automatic cleanup.
445
+
446
+ Args:
447
+ device: Device name (from saved sessions) or hostname
448
+ credential: Optional credential name (auto-resolved if not specified)
449
+ debug: Enable verbose connection debugging
450
+
451
+ Yields:
452
+ ActiveSession handle for sending commands
453
+
454
+ Raises:
455
+ ConnectionError: If connection fails
456
+
457
+ Example:
458
+ # Old way (12 lines):
459
+ session = None
460
+ try:
461
+ session = api.connect(device.name)
462
+ if not session.is_connected():
463
+ continue
464
+ result = api.send(session, "show version")
465
+ finally:
466
+ if session and session.is_connected():
467
+ api.disconnect(session)
468
+
469
+ # New way (3 lines):
470
+ with api.session(device.name) as s:
471
+ result = api.send(s, "show version")
472
+ """
473
+ sess = None
474
+ try:
475
+ sess = self.connect(device, credential=credential, debug=debug)
476
+ if not sess.is_connected():
477
+ raise ConnectionError(f"Failed to connect to {device}")
478
+ yield sess
479
+ finally:
480
+ if sess and sess.is_connected():
481
+ try:
482
+ self.disconnect(sess)
483
+ except Exception:
484
+ pass # Best effort cleanup
485
+
1058
486
  def send(
1059
487
  self,
1060
488
  session: ActiveSession,
1061
489
  command: str,
1062
- timeout: int = 30,
490
+ timeout: int = 60,
1063
491
  parse: bool = True,
1064
492
  normalize: bool = True,
493
+ _retry_after_paging_fix: bool = False,
1065
494
  ) -> CommandResult:
1066
495
  """
1067
496
  Send command to a connected session.
@@ -1072,6 +501,7 @@ class NTermAPI:
1072
501
  timeout: Command timeout in seconds
1073
502
  parse: Whether to attempt TextFSM parsing
1074
503
  normalize: Whether to normalize field names (requires parse=True)
504
+ _retry_after_paging_fix: Internal flag to prevent infinite retry loops
1075
505
 
1076
506
  Returns:
1077
507
  CommandResult with raw and parsed output
@@ -1085,12 +515,62 @@ class NTermAPI:
1085
515
  if result.parsed_data:
1086
516
  for row in result.parsed_data:
1087
517
  print(row)
518
+
519
+ Note:
520
+ If paging is detected (--More-- prompt), the API will automatically
521
+ try to disable paging and retry the command once.
1088
522
  """
1089
523
  if not session.is_connected():
1090
524
  raise RuntimeError(f"Session {session.device_name} is not connected")
1091
525
 
1092
- # Execute command
1093
- raw_output = self._send_command(session.shell, command, session.prompt, timeout)
526
+ # Execute command using our refactored module
527
+ try:
528
+ raw_output = send_command(session.shell, command, session.prompt, timeout)
529
+ except PagingNotDisabledError as e:
530
+ # Auto-recovery: try to disable paging and retry
531
+ if _retry_after_paging_fix:
532
+ # Already tried once, don't loop forever
533
+ raise RuntimeError(
534
+ f"Paging still not disabled after auto-fix attempt. "
535
+ f"Original error: {e}"
536
+ )
537
+
538
+ # Try to disable paging with multiple commands
539
+ print(f"[AUTO-FIX] Paging detected, attempting to disable...")
540
+
541
+ # Send Ctrl+C to break out of --More-- prompt
542
+ try:
543
+ session.shell.send('\x03') # Ctrl+C
544
+ import time
545
+ time.sleep(0.5)
546
+ # Clear any pending output
547
+ while session.shell.recv_ready():
548
+ session.shell.recv(65536)
549
+ except Exception:
550
+ pass
551
+
552
+ # Try multiple paging disable commands
553
+ success = try_disable_paging(
554
+ session.shell,
555
+ session.prompt,
556
+ send_command,
557
+ debug=True,
558
+ )
559
+
560
+ if success:
561
+ session._paging_disabled = True
562
+ print(f"[AUTO-FIX] Paging disabled, retrying command...")
563
+ # Retry the original command
564
+ return self.send(
565
+ session, command, timeout, parse, normalize,
566
+ _retry_after_paging_fix=True
567
+ )
568
+ else:
569
+ raise RuntimeError(
570
+ f"Failed to auto-disable paging. "
571
+ f"Tried multiple commands but none succeeded. "
572
+ f"Original error: {e}"
573
+ )
1094
574
 
1095
575
  # Create result object
1096
576
  result = CommandResult(
@@ -1130,7 +610,7 @@ class NTermAPI:
1130
610
  if normalize and parsed_data:
1131
611
  # Determine which field map to use based on command
1132
612
  if 'interface' in command.lower():
1133
- normalized = self._normalize_fields(
613
+ normalized = normalize_fields(
1134
614
  parsed_data,
1135
615
  session.platform,
1136
616
  INTERFACE_DETAIL_FIELD_MAP,
@@ -1150,47 +630,168 @@ class NTermAPI:
1150
630
 
1151
631
  return result
1152
632
 
633
+ def send_first(
634
+ self,
635
+ session: ActiveSession,
636
+ commands: List[str],
637
+ parse: bool = True,
638
+ timeout: int = 30,
639
+ require_parsed: bool = True,
640
+ ) -> Optional[CommandResult]:
641
+ """
642
+ Try multiple commands until one succeeds (returns parsed data).
643
+
644
+ Useful for CDP/LLDP discovery, platform variations, etc.
645
+
646
+ Args:
647
+ session: Active session
648
+ commands: List of commands to try in order
649
+ parse: Whether to parse output
650
+ timeout: Command timeout
651
+ require_parsed: If True, only consider success if parsed_data is non-empty
652
+
653
+ Returns:
654
+ First successful CommandResult, or None if all failed
655
+
656
+ Example:
657
+ # Try CDP first, fall back to LLDP
658
+ result = api.send_first(session, [
659
+ "show cdp neighbors detail",
660
+ "show lldp neighbors detail",
661
+ ])
662
+
663
+ # Platform-agnostic config fetch
664
+ result = api.send_first(session, [
665
+ "show running-config",
666
+ "show configuration",
667
+ ], parse=False, require_parsed=False)
668
+ """
669
+ for cmd in commands:
670
+ if cmd is None:
671
+ continue
672
+ try:
673
+ result = self.send(session, cmd, parse=parse, timeout=timeout)
674
+
675
+ if require_parsed and parse:
676
+ # Need non-empty parsed data
677
+ if result.parsed_data:
678
+ return result
679
+ else:
680
+ # Just need non-empty raw output
681
+ if result.raw_output and result.raw_output.strip():
682
+ return result
683
+
684
+ except Exception:
685
+ continue # Try next command
686
+
687
+ return None
688
+
689
+ def send_platform_command(
690
+ self,
691
+ session: ActiveSession,
692
+ command_type: str,
693
+ parse: bool = True,
694
+ timeout: int = 30,
695
+ **kwargs
696
+ ) -> Optional[CommandResult]:
697
+ """
698
+ Send a platform-appropriate command by type.
699
+
700
+ Args:
701
+ session: Active session
702
+ command_type: Command type (e.g., 'config', 'version', 'neighbors')
703
+ parse: Whether to parse output
704
+ timeout: Command timeout
705
+ **kwargs: Format arguments (e.g., name='Gi0/1' for interface_detail)
706
+
707
+ Returns:
708
+ CommandResult or None if command not available
709
+
710
+ Example:
711
+ # Get running config (platform-aware)
712
+ result = api.send_platform_command(session, 'config', parse=False)
713
+
714
+ # Get interface details
715
+ result = api.send_platform_command(session, 'interface_detail', name='Gi0/1')
716
+ """
717
+ cmd = get_platform_command(session.platform, command_type, **kwargs)
718
+ if not cmd:
719
+ return None
720
+
721
+ return self.send(session, cmd, parse=parse, timeout=timeout)
722
+
1153
723
  def disconnect(self, session: ActiveSession) -> None:
1154
724
  """
1155
725
  Disconnect a session.
1156
726
 
1157
727
  Args:
1158
- session: ActiveSession to close
728
+ session: ActiveSession to disconnect
1159
729
  """
1160
730
  if session.device_name in self._active_sessions:
1161
731
  del self._active_sessions[session.device_name]
1162
732
 
1163
- if session.shell:
1164
- session.shell.close()
733
+ try:
734
+ if session.shell:
735
+ session.shell.close()
736
+ except Exception:
737
+ pass
1165
738
 
1166
- if session.client:
1167
- session.client.close()
739
+ try:
740
+ if session.client:
741
+ session.client.close()
742
+ except Exception:
743
+ pass
1168
744
 
1169
- def active_sessions(self) -> List[str]:
745
+ def disconnect_all(self) -> int:
1170
746
  """
1171
- List currently active session names.
747
+ Disconnect all active sessions.
1172
748
 
1173
749
  Returns:
1174
- List of device names with active connections
750
+ Number of sessions disconnected
751
+
752
+ Example:
753
+ # At end of script or in finally block
754
+ count = api.disconnect_all()
755
+ print(f"Disconnected {count} session(s)")
1175
756
  """
1176
- return list(self._active_sessions.keys())
757
+ count = len(self._active_sessions)
1177
758
 
1178
- def db_info(self) -> Dict[str, Any]:
759
+ for session in list(self._active_sessions.values()):
760
+ try:
761
+ self.disconnect(session)
762
+ except Exception:
763
+ pass
764
+
765
+ self._active_sessions.clear()
766
+ return count
767
+
768
+ def active_sessions(self) -> List[ActiveSession]:
1179
769
  """
1180
- Get detailed information about TextFSM database.
770
+ List all active sessions.
1181
771
 
1182
772
  Returns:
1183
- Dict with database path, existence, and diagnostic info
773
+ List of ActiveSession objects
1184
774
  """
1185
- from pathlib import Path
1186
- import os
775
+ return list(self._active_sessions.values())
1187
776
 
777
+ # -------------------------------------------------------------------------
778
+ # Diagnostics
779
+ # -------------------------------------------------------------------------
780
+
781
+ def db_info(self) -> Dict[str, Any]:
782
+ """
783
+ Get information about the TextFSM database.
784
+
785
+ Returns:
786
+ Dict with db_path, db_exists, db_size, db_size_mb, etc.
787
+ """
1188
788
  info = {
1189
- "engine_available": self._tfsm_engine is not None,
789
+ "engine_initialized": self._tfsm_engine is not None,
1190
790
  "db_path": None,
1191
791
  "db_exists": False,
792
+ "db_size": None,
793
+ "db_size_mb": None,
1192
794
  "db_absolute_path": None,
1193
- "current_working_directory": os.getcwd(),
1194
795
  }
1195
796
 
1196
797
  if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
@@ -1279,6 +880,90 @@ class NTermAPI:
1279
880
 
1280
881
  return debug_info
1281
882
 
883
+ def detect_platform_from_output(
884
+ self,
885
+ output: str,
886
+ min_score: float = 50.0,
887
+ ) -> Dict[str, Any]:
888
+ """
889
+ Detect platform from command output using TextFSM template matching.
890
+
891
+ This is the same method used internally during connect() for platform
892
+ detection. Useful for testing and debugging.
893
+
894
+ Args:
895
+ output: Raw command output (typically from 'show version')
896
+ min_score: Minimum match score to accept (default 50.0)
897
+
898
+ Returns:
899
+ Dict with:
900
+ - platform: Detected platform string or None
901
+ - template: Best matching template name
902
+ - score: Match confidence score
903
+ - method: 'textfsm' or 'regex' or 'none'
904
+ - parsed_data: Parsed data from template if available
905
+
906
+ Example:
907
+ >>> result = api.send(session, "show version", parse=False)
908
+ >>> api.detect_platform_from_output(result.raw_output)
909
+ {
910
+ 'platform': 'cisco_ios',
911
+ 'template': 'cisco_ios_show_version',
912
+ 'score': 95.83,
913
+ 'method': 'textfsm',
914
+ 'parsed_data': [{'VERSION': '15.2(4)M11', ...}]
915
+ }
916
+ """
917
+ result = {
918
+ "platform": None,
919
+ "template": None,
920
+ "score": 0.0,
921
+ "method": "none",
922
+ "parsed_data": None,
923
+ "all_scores": None,
924
+ }
925
+
926
+ if not output:
927
+ result["error"] = "No output provided"
928
+ return result
929
+
930
+ # Try TextFSM template matching first
931
+ if self._tfsm_engine:
932
+ try:
933
+ best_template, parsed_data, best_score, all_scores = self._tfsm_engine.find_best_template(
934
+ device_output=output,
935
+ filter_string="show_version",
936
+ )
937
+
938
+ result["template"] = best_template
939
+ result["score"] = best_score
940
+ result["all_scores"] = all_scores
941
+ result["parsed_data"] = parsed_data
942
+
943
+ if best_template and best_score >= min_score:
944
+ platform = extract_platform_from_template_name(best_template)
945
+ if platform:
946
+ result["platform"] = platform
947
+ result["method"] = "textfsm"
948
+ return result
949
+
950
+ except Exception as e:
951
+ result["textfsm_error"] = str(e)
952
+
953
+ # Fallback to regex
954
+ from .platform_data import PLATFORM_PATTERNS
955
+ import re
956
+
957
+ for platform, patterns in PLATFORM_PATTERNS.items():
958
+ for pattern in patterns:
959
+ if re.search(pattern, output, re.IGNORECASE):
960
+ result["platform"] = platform
961
+ result["method"] = "regex"
962
+ result["regex_pattern"] = pattern
963
+ return result
964
+
965
+ return result
966
+
1282
967
  # -------------------------------------------------------------------------
1283
968
  # Convenience / REPL helpers
1284
969
  # -------------------------------------------------------------------------
@@ -1310,7 +995,6 @@ class NTermAPI:
1310
995
  if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
1311
996
  parser_db_path = self._tfsm_engine.db_path
1312
997
  if parser_db_path:
1313
- from pathlib import Path
1314
998
  parser_db_exists = Path(parser_db_path).exists()
1315
999
 
1316
1000
  return {
@@ -1351,8 +1035,22 @@ Connections:
1351
1035
  session = api.connect("device") Connect to device (auto-detect platform)
1352
1036
  result = api.send(session, cmd) Execute command (returns CommandResult)
1353
1037
  api.disconnect(session) Close connection
1038
+ api.disconnect_all() Close all connections
1354
1039
  api.active_sessions() List active connections
1355
1040
 
1041
+ Context Manager (recommended):
1042
+ with api.session("device") as s:
1043
+ result = api.send(s, "show version")
1044
+ # Auto-disconnects when done
1045
+
1046
+ Platform-Aware Commands:
1047
+ api.send_platform_command(s, 'config') Get running config
1048
+ api.send_platform_command(s, 'neighbors') Get CDP/LLDP neighbors
1049
+ api.send_platform_command(s, 'interface_detail', name='Gi0/1')
1050
+
1051
+ Try Multiple Commands:
1052
+ api.send_first(s, ["show cdp neighbors", "show lldp neighbors"])
1053
+
1356
1054
  Command Results:
1357
1055
  result.raw_output Raw text from device
1358
1056
  result.parsed_data Parsed data (List[Dict]) if available
@@ -1362,6 +1060,7 @@ Command Results:
1362
1060
 
1363
1061
  Debugging:
1364
1062
  api.debug_parse(cmd, output, platform) Debug why parsing failed
1063
+ api.detect_platform_from_output(output) Detect platform from command output
1365
1064
  api.db_info() Show TextFSM database path and status
1366
1065
 
1367
1066
  Status:
@@ -1369,19 +1068,39 @@ Status:
1369
1068
  api.vault_unlocked Check vault status
1370
1069
  api._tfsm_engine TextFSM parser (required)
1371
1070
 
1071
+ Platform Detection:
1072
+ Platform is detected automatically during connect() using:
1073
+ 1. TextFSM template matching (primary - more accurate)
1074
+ 2. Regex patterns (fallback)
1075
+
1076
+ The API sends 'terminal length 0' before detection to prevent
1077
+ paging issues. Template names like 'cisco_ios_show_version'
1078
+ tell us the platform directly.
1079
+
1080
+ Auto-Recovery:
1081
+ If paging (--More--) is detected, the API will automatically:
1082
+ 1. Send Ctrl+C to break out of the pager
1083
+ 2. Try multiple paging disable commands
1084
+ 3. Retry the original command
1085
+
1372
1086
  Examples:
1373
- # Connect and execute
1087
+ # Connect and execute (manual)
1374
1088
  api.unlock("vault-password")
1375
1089
  session = api.connect("spine1")
1376
1090
  result = api.send(session, "show interfaces status")
1091
+ api.disconnect(session)
1377
1092
 
1378
- # Access parsed data
1379
- if result.parsed_data:
1380
- for interface in result.parsed_data:
1381
- print(f"{interface['name']}: {interface['status']}")
1093
+ # Connect and execute (context manager - recommended)
1094
+ api.unlock("vault-password")
1095
+ with api.session("spine1") as s:
1096
+ result = api.send(s, "show interfaces status")
1097
+ for intf in result.parsed_data:
1098
+ print(f"{intf['name']}: {intf['status']}")
1382
1099
 
1383
- # Disconnect
1384
- api.disconnect(session)
1100
+ # Platform-aware config backup
1101
+ with api.session("router1") as s:
1102
+ result = api.send_platform_command(s, 'config', parse=False)
1103
+ Path("backup.cfg").write_text(result.raw_output)
1385
1104
 
1386
1105
  Note: TextFSM parser (tfsm_templates.db) is REQUIRED for the API to function.
1387
1106
  The API will fail during initialization if the database is not found.
@@ -1391,6 +1110,7 @@ The API will fail during initialization if the database is not found.
1391
1110
  # Singleton for convenience in IPython
1392
1111
  _default_api: Optional[NTermAPI] = None
1393
1112
 
1113
+
1394
1114
  def get_api() -> NTermAPI:
1395
1115
  """Get or create default API instance."""
1396
1116
  global _default_api