ntermqt 0.1.3__py3-none-any.whl → 0.1.5__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,13 +5,273 @@ 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
8
10
  import fnmatch
9
- from typing import Optional, List, Dict, Any
10
- from dataclasses import dataclass, asdict
11
+ from typing import Optional, List, Dict, Any, Union
12
+ from dataclasses import dataclass, asdict, field
13
+ from datetime import datetime
14
+
15
+ import paramiko
11
16
 
12
17
  from ..manager.models import SessionStore, SavedSession, SessionFolder
13
18
  from ..vault.resolver import CredentialResolver
14
19
  from ..vault.store import StoredCredential
20
+ from ..connection.profile import ConnectionProfile, AuthMethod
21
+
22
+ # Reuse SSHSession's algorithm configuration
23
+ from ..session.ssh import (
24
+ _apply_global_transport_settings,
25
+ RSA_SHA1_DISABLED_ALGORITHMS,
26
+ )
27
+
28
+ # TextFSM parsing - REQUIRED
29
+ try:
30
+ from nterm.parser.tfsm_fire import TextFSMAutoEngine
31
+ TFSM_AVAILABLE = True
32
+ except ImportError as e:
33
+ TFSM_AVAILABLE = False
34
+ TextFSMAutoEngine = None
35
+ _TFSM_IMPORT_ERROR = str(e)
36
+
37
+
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)
15
275
 
16
276
 
17
277
  @dataclass
@@ -45,6 +305,22 @@ class DeviceInfo:
45
305
  folder = f", folder={self.folder}" if self.folder else ""
46
306
  return f"Device({self.name}, {self.hostname}:{self.port}{cred}{folder})"
47
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
+
48
324
 
49
325
  @dataclass
50
326
  class CredentialInfo:
@@ -71,6 +347,23 @@ class CredentialInfo:
71
347
  default = " [default]" if self.is_default else ""
72
348
  return f"Credential({self.name}, user={self.username}, auth={auth_str}{default})"
73
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
+
74
367
 
75
368
  class NTermAPI:
76
369
  """
@@ -100,11 +393,39 @@ class NTermAPI:
100
393
  self,
101
394
  session_store: SessionStore = None,
102
395
  credential_resolver: CredentialResolver = None,
396
+ tfsm_db_path: str = None,
103
397
  ):
104
398
  self._sessions = session_store or SessionStore()
105
399
  self._resolver = credential_resolver or CredentialResolver()
106
400
  self._folder_cache: Dict[int, str] = {}
107
- self._active_sessions: Dict[str, Any] = {} # Future: track open sessions
401
+ self._active_sessions: Dict[str, ActiveSession] = {}
402
+
403
+ # Initialize TextFSM engine - REQUIRED for command parsing
404
+ if not TFSM_AVAILABLE:
405
+ error_msg = (
406
+ "TextFSM parser not available. "
407
+ "Ensure tfsm_fire.py is in nterm/parser/\n"
408
+ )
409
+ if '_TFSM_IMPORT_ERROR' in globals():
410
+ error_msg += f"Import error: {_TFSM_IMPORT_ERROR}"
411
+ raise RuntimeError(error_msg)
412
+
413
+ try:
414
+ # Default to looking for tfsm_templates.db in current directory
415
+ if not tfsm_db_path:
416
+ tfsm_db_path = "./tfsm_templates.db"
417
+ elif tfsm_db_path == "./":
418
+ tfsm_db_path = "./tfsm_templates.db"
419
+
420
+ self._tfsm_engine = TextFSMAutoEngine(db_path=tfsm_db_path)
421
+ if not self._tfsm_engine or not self._tfsm_engine.db_path:
422
+ raise RuntimeError("TextFSM engine initialized but no database path")
423
+ except Exception as e:
424
+ raise RuntimeError(
425
+ f"Failed to initialize TextFSM engine: {e}\n"
426
+ f"Expected database at: {tfsm_db_path or 'default location'}\n"
427
+ "The API requires tfsm_templates.db for command parsing."
428
+ )
108
429
 
109
430
  # -------------------------------------------------------------------------
110
431
  # Device / Session listing
@@ -334,40 +655,577 @@ class NTermAPI:
334
655
  return None
335
656
 
336
657
  # -------------------------------------------------------------------------
337
- # Connection operations (future expansion)
658
+ # Connection operations
338
659
  # -------------------------------------------------------------------------
339
660
 
340
- def connect(self, device: str, credential: str = None):
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:
341
839
  """
342
- Connect to a device.
840
+ Connect to a device and detect platform.
343
841
 
344
842
  Args:
345
843
  device: Device name (from saved sessions) or hostname
346
844
  credential: Optional credential name (auto-resolved if not specified)
347
845
 
348
846
  Returns:
349
- Session handle for sending commands
847
+ ActiveSession handle for sending commands
848
+
849
+ Examples:
850
+ session = api.connect("spine1")
851
+ session = api.connect("192.168.1.1", credential="lab-admin")
350
852
  """
351
- # TODO: Implement actual connection logic
352
- raise NotImplementedError("Connection support coming soon")
853
+ # Look up device from saved sessions first
854
+ device_info = self.device(device)
855
+
856
+ if device_info:
857
+ hostname = device_info.hostname
858
+ port = device_info.port
859
+ device_name = device_info.name
860
+ saved_cred = device_info.credential
861
+ else:
862
+ # Treat as hostname directly
863
+ hostname = device
864
+ port = 22
865
+ device_name = device
866
+ saved_cred = None
867
+
868
+ # Resolve credentials
869
+ if not self.vault_unlocked:
870
+ raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
871
+
872
+ # Get credential - either specified or from saved session or auto-resolve
873
+ cred_name = credential or saved_cred
874
+
875
+ if cred_name:
876
+ # User specified a credential name - use resolver's method
877
+ try:
878
+ profile = self._resolver.create_profile_for_credential(
879
+ credential_name=cred_name,
880
+ hostname=hostname,
881
+ port=port,
882
+ )
883
+ except Exception as e:
884
+ raise ValueError(f"Failed to get credential '{cred_name}': {e}")
885
+ else:
886
+ # Auto-resolve based on hostname patterns
887
+ try:
888
+ profile = self._resolver.resolve_for_device(hostname, port=port)
889
+ except Exception as e:
890
+ raise ValueError(f"Failed to resolve credentials for {hostname}: {e}")
891
+
892
+ if not profile:
893
+ raise ValueError(f"No credentials available for {hostname}")
894
+
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)
971
+
972
+ # Create session object
973
+ session = ActiveSession(
974
+ device_name=device_name,
975
+ hostname=hostname,
976
+ port=port,
977
+ client=client,
978
+ shell=shell,
979
+ prompt=prompt,
980
+ )
981
+
982
+ # Detect platform
983
+ try:
984
+ version_output = self._send_command(shell, "show version", prompt)
985
+ platform = self._detect_platform(version_output)
986
+ session.platform = platform
987
+ except Exception as e:
988
+ print(f"Warning: Failed to detect platform: {e}")
989
+
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}")
1000
+
1001
+ # Track active session
1002
+ self._active_sessions[device_name] = session
353
1003
 
354
- def send(self, session, command: str, timeout: int = 30) -> str:
1004
+ return session
1005
+
1006
+ def send(
1007
+ self,
1008
+ session: ActiveSession,
1009
+ command: str,
1010
+ timeout: int = 30,
1011
+ parse: bool = True,
1012
+ normalize: bool = True,
1013
+ ) -> CommandResult:
355
1014
  """
356
1015
  Send command to a connected session.
357
1016
 
358
1017
  Args:
359
- session: Session handle from connect()
1018
+ session: ActiveSession from connect()
360
1019
  command: Command to execute
361
- timeout: Timeout in seconds
1020
+ timeout: Command timeout in seconds
1021
+ parse: Whether to attempt TextFSM parsing
1022
+ normalize: Whether to normalize field names (requires parse=True)
1023
+
1024
+ Returns:
1025
+ CommandResult with raw and parsed output
1026
+
1027
+ Examples:
1028
+ result = api.send(session, "show version")
1029
+ result = api.send(session, "show interfaces status", parse=True)
1030
+
1031
+ # Access results
1032
+ print(result.raw_output)
1033
+ if result.parsed_data:
1034
+ for row in result.parsed_data:
1035
+ print(row)
1036
+ """
1037
+ if not session.is_connected():
1038
+ raise RuntimeError(f"Session {session.device_name} is not connected")
1039
+
1040
+ # Execute command
1041
+ raw_output = self._send_command(session.shell, command, session.prompt, timeout)
1042
+
1043
+ # Create result object
1044
+ result = CommandResult(
1045
+ command=command,
1046
+ raw_output=raw_output,
1047
+ platform=session.platform,
1048
+ )
1049
+
1050
+ # Attempt parsing if requested and TextFSM available
1051
+ if parse:
1052
+ if not self._tfsm_engine:
1053
+ raise RuntimeError(
1054
+ "TextFSM parser not initialized. Cannot parse command output. "
1055
+ "This should not happen - API should have failed during initialization."
1056
+ )
1057
+
1058
+ if not session.platform:
1059
+ # Can't parse without platform hint
1060
+ result.parse_success = False
1061
+ else:
1062
+ try:
1063
+ # Convert command to filter string (e.g., "show version" -> "show_version")
1064
+ filter_string = command.strip().replace(' ', '_')
1065
+
1066
+ # Call the actual tfsm_fire method
1067
+ best_template, parsed_data, best_score, all_scores = self._tfsm_engine.find_best_template(
1068
+ device_output=raw_output,
1069
+ filter_string=filter_string,
1070
+ )
1071
+
1072
+ if parsed_data and len(parsed_data) > 0:
1073
+ result.parsed_data = parsed_data
1074
+ result.parse_success = True
1075
+ result.parse_template = best_template
1076
+
1077
+ # Normalize field names if requested
1078
+ if normalize and parsed_data:
1079
+ # Determine which field map to use based on command
1080
+ if 'interface' in command.lower():
1081
+ normalized = self._normalize_fields(
1082
+ parsed_data,
1083
+ session.platform,
1084
+ INTERFACE_DETAIL_FIELD_MAP,
1085
+ )
1086
+ result.parsed_data = normalized
1087
+ result.normalized_fields = {
1088
+ 'map_used': 'INTERFACE_DETAIL_FIELD_MAP',
1089
+ 'platform': session.platform,
1090
+ }
1091
+ else:
1092
+ # Parsing returned empty/None
1093
+ result.parse_success = False
1094
+
1095
+ except Exception as e:
1096
+ # Parsing failed, but we still have raw output
1097
+ result.parse_success = False
1098
+
1099
+ return result
1100
+
1101
+ def disconnect(self, session: ActiveSession) -> None:
1102
+ """
1103
+ Disconnect a session.
1104
+
1105
+ Args:
1106
+ session: ActiveSession to close
1107
+ """
1108
+ if session.device_name in self._active_sessions:
1109
+ del self._active_sessions[session.device_name]
1110
+
1111
+ if session.shell:
1112
+ session.shell.close()
1113
+
1114
+ if session.client:
1115
+ session.client.close()
1116
+
1117
+ def active_sessions(self) -> List[str]:
1118
+ """
1119
+ List currently active session names.
1120
+
1121
+ Returns:
1122
+ List of device names with active connections
1123
+ """
1124
+ return list(self._active_sessions.keys())
1125
+
1126
+ def db_info(self) -> Dict[str, Any]:
1127
+ """
1128
+ Get detailed information about TextFSM database.
1129
+
1130
+ Returns:
1131
+ Dict with database path, existence, and diagnostic info
1132
+ """
1133
+ from pathlib import Path
1134
+ import os
1135
+
1136
+ info = {
1137
+ "engine_available": self._tfsm_engine is not None,
1138
+ "db_path": None,
1139
+ "db_exists": False,
1140
+ "db_absolute_path": None,
1141
+ "current_working_directory": os.getcwd(),
1142
+ }
1143
+
1144
+ if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
1145
+ info["db_path"] = self._tfsm_engine.db_path
1146
+
1147
+ if info["db_path"]:
1148
+ db_path = Path(info["db_path"])
1149
+ info["db_exists"] = db_path.exists()
1150
+ info["db_absolute_path"] = str(db_path.absolute())
1151
+ info["db_is_directory"] = db_path.is_dir() if db_path.exists() else None
1152
+
1153
+ if info["db_exists"]:
1154
+ if db_path.is_file():
1155
+ info["db_size"] = db_path.stat().st_size
1156
+ info["db_size_mb"] = round(db_path.stat().st_size / 1024 / 1024, 2)
1157
+ else:
1158
+ info["error"] = f"Path exists but is not a file: {db_path}"
1159
+ else:
1160
+ # Try common locations
1161
+ common_locations = [
1162
+ Path(os.getcwd()) / "tfsm_templates.db",
1163
+ Path.home() / ".nterm" / "tfsm_templates.db",
1164
+ Path(__file__).parent.parent / "tfsm_templates.db",
1165
+ ]
1166
+ info["tried_locations"] = [str(p) for p in common_locations]
1167
+ info["found_at"] = [str(p) for p in common_locations if p.exists()]
1168
+
1169
+ return info
1170
+
1171
+ def debug_parse(
1172
+ self,
1173
+ command: str,
1174
+ output: str,
1175
+ platform: str,
1176
+ ) -> Dict[str, Any]:
1177
+ """
1178
+ Debug TextFSM parsing - useful for troubleshooting why parsing fails.
1179
+
1180
+ Args:
1181
+ command: Command that was executed
1182
+ output: Raw output from device
1183
+ platform: Platform hint (e.g., 'cisco_ios')
362
1184
 
363
1185
  Returns:
364
- Command output
1186
+ Dict with parsing debug info including:
1187
+ - parsed_data: Parsed results if successful
1188
+ - template_used: Template file name if found
1189
+ - error: Error message if parsing failed
1190
+ - attempted_templates: List of templates attempted
365
1191
  """
366
- raise NotImplementedError("Connection support coming soon")
1192
+ if not self._tfsm_engine:
1193
+ return {"error": "TextFSM engine not initialized"}
1194
+
1195
+ debug_info = {
1196
+ "command": command,
1197
+ "platform": platform,
1198
+ "output_length": len(output),
1199
+ "output_preview": output[:200] if output else None,
1200
+ }
367
1201
 
368
- def disconnect(self, session) -> None:
369
- """Disconnect a session."""
370
- raise NotImplementedError("Connection support coming soon")
1202
+ try:
1203
+ # Convert command to filter string
1204
+ filter_string = command.strip().replace(' ', '_')
1205
+
1206
+ best_template, parsed_data, best_score, all_scores = self._tfsm_engine.find_best_template(
1207
+ device_output=output,
1208
+ filter_string=filter_string,
1209
+ )
1210
+
1211
+ debug_info["parsed_data"] = parsed_data
1212
+ debug_info["parse_success"] = parsed_data is not None and len(parsed_data) > 0
1213
+ debug_info["template_used"] = best_template
1214
+ debug_info["best_score"] = best_score
1215
+ debug_info["all_scores"] = all_scores # List of (template_name, score, record_count)
1216
+
1217
+ if parsed_data:
1218
+ debug_info["row_count"] = len(parsed_data)
1219
+ if parsed_data:
1220
+ debug_info["sample_row"] = parsed_data[0]
1221
+ else:
1222
+ debug_info["error"] = "Parsing returned None or empty"
1223
+
1224
+ except Exception as e:
1225
+ debug_info["error"] = str(e)
1226
+ debug_info["parse_success"] = False
1227
+
1228
+ return debug_info
371
1229
 
372
1230
  # -------------------------------------------------------------------------
373
1231
  # Convenience / REPL helpers
@@ -376,14 +1234,16 @@ class NTermAPI:
376
1234
  def __repr__(self) -> str:
377
1235
  device_count = len(self._sessions.list_all_sessions())
378
1236
  vault_status = "unlocked" if self.vault_unlocked else "locked"
379
- return f"<NTermAPI: {device_count} devices, vault {vault_status}>"
1237
+ parser_status = "enabled" if self._tfsm_engine else "disabled"
1238
+ active = len(self._active_sessions)
1239
+ return f"<NTermAPI: {device_count} devices, vault {vault_status}, parser {parser_status}, {active} active>"
380
1240
 
381
1241
  def status(self) -> Dict[str, Any]:
382
1242
  """
383
1243
  Get API status summary.
384
1244
 
385
1245
  Returns:
386
- Dict with device count, folder count, credential count, vault status
1246
+ Dict with device count, folder count, credential count, vault status, parser status
387
1247
  """
388
1248
  sessions = self._sessions.list_all_sessions()
389
1249
  folders = self._sessions.get_tree()["folders"]
@@ -392,6 +1252,15 @@ class NTermAPI:
392
1252
  if self.vault_unlocked:
393
1253
  cred_count = len(self._resolver.list_credentials())
394
1254
 
1255
+ # Check parser DB status
1256
+ parser_db_path = None
1257
+ parser_db_exists = False
1258
+ if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
1259
+ parser_db_path = self._tfsm_engine.db_path
1260
+ if parser_db_path:
1261
+ from pathlib import Path
1262
+ parser_db_exists = Path(parser_db_path).exists()
1263
+
395
1264
  return {
396
1265
  "devices": len(sessions),
397
1266
  "folders": len(folders),
@@ -399,6 +1268,9 @@ class NTermAPI:
399
1268
  "vault_initialized": self.vault_initialized,
400
1269
  "vault_unlocked": self.vault_unlocked,
401
1270
  "active_sessions": len(self._active_sessions),
1271
+ "parser_available": self._tfsm_engine is not None,
1272
+ "parser_db": parser_db_path,
1273
+ "parser_db_exists": parser_db_exists,
402
1274
  }
403
1275
 
404
1276
  def help(self) -> None:
@@ -423,9 +1295,44 @@ Credentials (requires unlocked vault):
423
1295
  api.credential("name") Get specific credential
424
1296
  api.resolve_credential("host") Find matching credential
425
1297
 
1298
+ Connections:
1299
+ session = api.connect("device") Connect to device (auto-detect platform)
1300
+ result = api.send(session, cmd) Execute command (returns CommandResult)
1301
+ api.disconnect(session) Close connection
1302
+ api.active_sessions() List active connections
1303
+
1304
+ Command Results:
1305
+ result.raw_output Raw text from device
1306
+ result.parsed_data Parsed data (List[Dict]) if available
1307
+ result.platform Detected platform
1308
+ result.parse_success Whether parsing succeeded
1309
+ result.to_dict() Export as dictionary
1310
+
1311
+ Debugging:
1312
+ api.debug_parse(cmd, output, platform) Debug why parsing failed
1313
+ api.db_info() Show TextFSM database path and status
1314
+
426
1315
  Status:
427
1316
  api.status() Get summary
428
1317
  api.vault_unlocked Check vault status
1318
+ api._tfsm_engine TextFSM parser (required)
1319
+
1320
+ Examples:
1321
+ # Connect and execute
1322
+ api.unlock("vault-password")
1323
+ session = api.connect("spine1")
1324
+ result = api.send(session, "show interfaces status")
1325
+
1326
+ # Access parsed data
1327
+ if result.parsed_data:
1328
+ for interface in result.parsed_data:
1329
+ print(f"{interface['name']}: {interface['status']}")
1330
+
1331
+ # Disconnect
1332
+ api.disconnect(session)
1333
+
1334
+ Note: TextFSM parser (tfsm_templates.db) is REQUIRED for the API to function.
1335
+ The API will fail during initialization if the database is not found.
429
1336
  """)
430
1337
 
431
1338