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.
@@ -0,0 +1,596 @@
1
+ """
2
+ nterm/scripting/platform_utils.py
3
+
4
+ Platform detection and field normalization utilities.
5
+ """
6
+
7
+ import re
8
+ from typing import Optional, List, Dict, Any, Union
9
+
10
+ from .platform_data import (
11
+ PLATFORM_PATTERNS,
12
+ PLATFORM_COMMANDS,
13
+ DEFAULT_COMMANDS,
14
+ INTERFACE_DETAIL_FIELD_MAP,
15
+ DEFAULT_FIELD_MAP,
16
+ VERSION_FIELD_MAP,
17
+ DEFAULT_VERSION_FIELD_MAP,
18
+ NEIGHBOR_FIELD_MAP,
19
+ )
20
+
21
+
22
+ def detect_platform(version_output: str, tfsm_engine=None) -> Optional[str]:
23
+ """
24
+ Detect device platform from 'show version' output.
25
+
26
+ Uses TextFSM template matching as primary method (more accurate),
27
+ falls back to regex patterns if no template matches.
28
+
29
+ Args:
30
+ version_output: Raw output from 'show version' command
31
+ tfsm_engine: Optional TextFSMAutoEngine instance for template-based detection
32
+
33
+ Returns:
34
+ Platform string (e.g., 'cisco_ios', 'arista_eos') or None if not detected
35
+ """
36
+ # Primary method: Use TextFSM template matching
37
+ if tfsm_engine is not None:
38
+ platform = detect_platform_from_template(version_output, tfsm_engine)
39
+ if platform:
40
+ return platform
41
+
42
+ # Fallback: Regex pattern matching
43
+ for platform, patterns in PLATFORM_PATTERNS.items():
44
+ for pattern in patterns:
45
+ if re.search(pattern, version_output, re.IGNORECASE):
46
+ return platform
47
+ return None
48
+
49
+
50
+ def detect_platform_from_template(
51
+ version_output: str,
52
+ tfsm_engine,
53
+ min_score: float = 50.0,
54
+ ) -> Optional[str]:
55
+ """
56
+ Detect platform by finding the best matching TextFSM template.
57
+
58
+ This is more accurate than regex because TextFSM templates are designed
59
+ to parse specific platform outputs structurally.
60
+
61
+ Args:
62
+ version_output: Raw output from 'show version' or similar command
63
+ tfsm_engine: TextFSMAutoEngine instance
64
+ min_score: Minimum match score to accept (default 50.0)
65
+
66
+ Returns:
67
+ Platform string extracted from template name, or None
68
+
69
+ Example:
70
+ Template 'cisco_ios_show_version' → returns 'cisco_ios'
71
+ Template 'arista_eos_show_version' → returns 'arista_eos'
72
+ """
73
+ if not tfsm_engine or not version_output:
74
+ return None
75
+
76
+ try:
77
+ # Try with show_version filter first
78
+ best_template, parsed_data, best_score, all_scores = tfsm_engine.find_best_template(
79
+ device_output=version_output,
80
+ filter_string="show_version",
81
+ )
82
+
83
+ if best_template and best_score >= min_score:
84
+ platform = extract_platform_from_template_name(best_template)
85
+ if platform:
86
+ return platform
87
+
88
+ # If show_version didn't match well, try without filter
89
+ # This catches show system info, show version detail, etc.
90
+ if not best_template or best_score < min_score:
91
+ best_template, parsed_data, best_score, all_scores = tfsm_engine.find_best_template(
92
+ device_output=version_output,
93
+ filter_string="", # No filter - let it find best match
94
+ )
95
+
96
+ if best_template and best_score >= min_score:
97
+ platform = extract_platform_from_template_name(best_template)
98
+ if platform:
99
+ return platform
100
+
101
+ except Exception:
102
+ pass # Fall through to return None
103
+
104
+ return None
105
+
106
+
107
+ def extract_platform_from_template_name(template_name: str) -> Optional[str]:
108
+ """
109
+ Extract platform identifier from a TextFSM template name.
110
+
111
+ Template names follow the pattern: {platform}_{command}
112
+ Examples:
113
+ 'cisco_ios_show_version' → 'cisco_ios'
114
+ 'arista_eos_show_interfaces' → 'arista_eos'
115
+ 'juniper_junos_show_version' → 'juniper_junos'
116
+ 'cisco_nxos_show_version' → 'cisco_nxos'
117
+
118
+ Args:
119
+ template_name: Full template name from TextFSM database
120
+
121
+ Returns:
122
+ Platform string or None if pattern not recognized
123
+ """
124
+ if not template_name:
125
+ return None
126
+
127
+ # Known platform prefixes (ordered by specificity - longer matches first)
128
+ known_platforms = [
129
+ 'cisco_iosxr',
130
+ 'cisco_iosxe',
131
+ 'cisco_nxos',
132
+ 'cisco_ios',
133
+ 'cisco_asa',
134
+ 'cisco_wlc',
135
+ 'arista_eos',
136
+ 'juniper_junos',
137
+ 'juniper_screenos',
138
+ 'hp_procurve',
139
+ 'hp_comware',
140
+ 'huawei_vrp',
141
+ 'linux',
142
+ 'paloalto_panos',
143
+ 'fortinet_fortios',
144
+ 'dell_force10',
145
+ 'dell_os10',
146
+ 'extreme_exos',
147
+ 'extreme_nos',
148
+ 'brocade_fastiron',
149
+ 'brocade_netiron',
150
+ 'ubiquiti_edgeswitch',
151
+ 'mikrotik_routeros',
152
+ 'vyos',
153
+ 'alcatel_aos',
154
+ 'alcatel_sros',
155
+ 'checkpoint_gaia',
156
+ 'enterasys',
157
+ 'ruckus_fastiron',
158
+ 'yamaha',
159
+ 'zyxel_os',
160
+ ]
161
+
162
+ template_lower = template_name.lower()
163
+
164
+ for platform in known_platforms:
165
+ if template_lower.startswith(platform + '_'):
166
+ return platform
167
+
168
+ # Fallback: try to extract platform from template name pattern
169
+ # Assumes format: platform_command or platform_subplatform_command
170
+ parts = template_name.split('_')
171
+ if len(parts) >= 2:
172
+ # Try first two parts (handles cisco_ios, arista_eos, etc.)
173
+ potential_platform = f"{parts[0]}_{parts[1]}"
174
+ if potential_platform.lower() in [p.lower() for p in known_platforms]:
175
+ return potential_platform.lower()
176
+
177
+ # Try just first part for single-word platforms (linux, vyos)
178
+ if parts[0].lower() in [p.lower() for p in known_platforms]:
179
+ return parts[0].lower()
180
+
181
+ return None
182
+
183
+
184
+ def get_platform_command(
185
+ platform: Optional[str],
186
+ command_type: str,
187
+ **kwargs
188
+ ) -> Optional[str]:
189
+ """
190
+ Get platform-specific command for a given operation.
191
+
192
+ Args:
193
+ platform: Platform string (e.g., 'cisco_ios') or None
194
+ command_type: Command type (e.g., 'config', 'version', 'interfaces')
195
+ **kwargs: Format arguments for commands with placeholders (e.g., name='Gi0/1')
196
+
197
+ Returns:
198
+ Command string or None if command not available for platform
199
+
200
+ Example:
201
+ >>> get_platform_command('cisco_ios', 'config')
202
+ 'show running-config'
203
+ >>> get_platform_command('juniper_junos', 'config')
204
+ 'show configuration'
205
+ >>> get_platform_command('cisco_ios', 'interface_detail', name='Gi0/1')
206
+ 'show interfaces Gi0/1'
207
+ """
208
+ # Get platform-specific commands or fall back to defaults
209
+ platform_cmds = PLATFORM_COMMANDS.get(platform, {}) if platform else {}
210
+
211
+ # Look up command type
212
+ cmd = platform_cmds.get(command_type)
213
+ if cmd is None:
214
+ cmd = DEFAULT_COMMANDS.get(command_type)
215
+
216
+ if cmd is None:
217
+ return None
218
+
219
+ # Apply format arguments if provided
220
+ if kwargs:
221
+ try:
222
+ cmd = cmd.format(**kwargs)
223
+ except KeyError:
224
+ pass # Return unformatted if missing keys
225
+
226
+ return cmd
227
+
228
+
229
+ def get_command_alternatives(command_type: str) -> List[str]:
230
+ """
231
+ Get all platform variants of a command for try-until-success patterns.
232
+
233
+ Args:
234
+ command_type: Command type (e.g., 'neighbors')
235
+
236
+ Returns:
237
+ List of unique command strings across all platforms
238
+
239
+ Example:
240
+ >>> get_command_alternatives('neighbors')
241
+ ['show cdp neighbors detail', 'show lldp neighbors detail', 'show lldp neighbors']
242
+ """
243
+ commands = set()
244
+
245
+ # Gather from all platforms
246
+ for platform_cmds in PLATFORM_COMMANDS.values():
247
+ cmd = platform_cmds.get(command_type)
248
+ if cmd:
249
+ commands.add(cmd)
250
+
251
+ # Add default
252
+ default = DEFAULT_COMMANDS.get(command_type)
253
+ if default:
254
+ commands.add(default)
255
+
256
+ return list(commands)
257
+
258
+
259
+ def extract_field(
260
+ data: Dict[str, Any],
261
+ field_names: List[str],
262
+ default: Any = None,
263
+ first_element: bool = True,
264
+ ) -> Any:
265
+ """
266
+ Extract a field value trying multiple possible field names.
267
+
268
+ Args:
269
+ data: Dictionary to extract from (e.g., parsed command output row)
270
+ field_names: List of possible field names in priority order
271
+ default: Value to return if no field found
272
+ first_element: If value is a list, return first element
273
+
274
+ Returns:
275
+ Field value or default
276
+
277
+ Example:
278
+ >>> data = {'VERSION': '15.2(4)M', 'HARDWARE': ['ISR4451']}
279
+ >>> extract_field(data, ['version', 'VERSION', 'SOFTWARE'])
280
+ '15.2(4)M'
281
+ >>> extract_field(data, ['HARDWARE', 'MODEL'])
282
+ 'ISR4451' # first_element=True extracts from list
283
+ """
284
+ for name in field_names:
285
+ if name in data and data[name]:
286
+ value = data[name]
287
+ # Handle list values
288
+ if first_element and isinstance(value, list):
289
+ return value[0] if value else default
290
+ return value
291
+ return default
292
+
293
+
294
+ def extract_fields(
295
+ data: Dict[str, Any],
296
+ field_map: Dict[str, List[str]],
297
+ defaults: Optional[Dict[str, Any]] = None,
298
+ ) -> Dict[str, Any]:
299
+ """
300
+ Extract multiple fields using a field mapping.
301
+
302
+ Args:
303
+ data: Dictionary to extract from
304
+ field_map: Mapping of canonical names to possible field names
305
+ defaults: Default values for each canonical field
306
+
307
+ Returns:
308
+ Dictionary with canonical field names and extracted values
309
+
310
+ Example:
311
+ >>> data = {'VERSION': '15.2', 'HARDWARE': 'ISR4451'}
312
+ >>> field_map = {'version': ['VERSION', 'SOFTWARE'], 'hardware': ['HARDWARE', 'MODEL']}
313
+ >>> extract_fields(data, field_map)
314
+ {'version': '15.2', 'hardware': 'ISR4451'}
315
+ """
316
+ defaults = defaults or {}
317
+ result = {}
318
+
319
+ for canonical_name, possible_names in field_map.items():
320
+ default = defaults.get(canonical_name, 'unknown')
321
+ result[canonical_name] = extract_field(data, possible_names, default)
322
+
323
+ return result
324
+
325
+
326
+ def extract_version_info(
327
+ parsed_data: List[Dict[str, Any]],
328
+ platform: Optional[str] = None,
329
+ ) -> Dict[str, Any]:
330
+ """
331
+ Extract version information from parsed 'show version' output.
332
+
333
+ Args:
334
+ parsed_data: Parsed output from show version command
335
+ platform: Platform string for platform-specific field mapping
336
+
337
+ Returns:
338
+ Dictionary with version, hardware, serial, uptime, hostname
339
+
340
+ Example:
341
+ >>> result = api.send(session, "show version")
342
+ >>> extract_version_info(result.parsed_data, session.platform)
343
+ {'version': '15.2(4)M', 'hardware': 'ISR4451', 'serial': 'FDO2134', ...}
344
+ """
345
+ if not parsed_data:
346
+ return {
347
+ 'version': 'unknown',
348
+ 'hardware': 'unknown',
349
+ 'serial': 'unknown',
350
+ 'uptime': 'unknown',
351
+ 'hostname': 'unknown',
352
+ }
353
+
354
+ data = parsed_data[0] # show version typically returns single row
355
+
356
+ # Platform-specific field mappings
357
+ # These override the generic mappings in platform_data.py when needed
358
+ PLATFORM_VERSION_FIELDS = {
359
+ 'arista_eos': {
360
+ 'version': ['IMAGE', 'VERSION', 'SOFTWARE_IMAGE', 'EOS_VERSION'],
361
+ 'hardware': ['MODEL', 'HARDWARE', 'PLATFORM'],
362
+ 'serial': ['SERIAL_NUMBER', 'SERIAL', 'SN'],
363
+ 'uptime': ['UPTIME'],
364
+ 'hostname': ['HOSTNAME'],
365
+ },
366
+ 'cisco_ios': {
367
+ 'version': ['VERSION', 'SOFTWARE_VERSION', 'ROMMON'],
368
+ 'hardware': ['HARDWARE', 'MODEL', 'PLATFORM'],
369
+ 'serial': ['SERIAL', 'SERIAL_NUMBER'],
370
+ 'uptime': ['UPTIME'],
371
+ 'hostname': ['HOSTNAME'],
372
+ },
373
+ 'cisco_nxos': {
374
+ 'version': ['OS', 'VERSION', 'NXOS_VERSION', 'KICKSTART_VERSION'],
375
+ 'hardware': ['PLATFORM', 'HARDWARE', 'CHASSIS'],
376
+ 'serial': ['SERIAL', 'SERIAL_NUMBER'],
377
+ 'uptime': ['UPTIME'],
378
+ 'hostname': ['HOSTNAME', 'DEVICE_NAME'],
379
+ },
380
+ 'cisco_iosxe': {
381
+ 'version': ['VERSION', 'ROMMON'],
382
+ 'hardware': ['HARDWARE', 'MODEL', 'PLATFORM'],
383
+ 'serial': ['SERIAL', 'SERIAL_NUMBER'],
384
+ 'uptime': ['UPTIME'],
385
+ 'hostname': ['HOSTNAME'],
386
+ },
387
+ 'juniper_junos': {
388
+ 'version': ['JUNOS_VERSION', 'VERSION'],
389
+ 'hardware': ['MODEL', 'HARDWARE'],
390
+ 'serial': ['SERIAL_NUMBER', 'SERIAL'],
391
+ 'uptime': ['UPTIME', 'RE_UPTIME'],
392
+ 'hostname': ['HOSTNAME'],
393
+ },
394
+ }
395
+
396
+ # Default field mapping (tries common field names)
397
+ DEFAULT_FIELDS = {
398
+ 'version': ['VERSION', 'IMAGE', 'SOFTWARE_VERSION', 'SOFTWARE_IMAGE', 'OS', 'ROMMON'],
399
+ 'hardware': ['HARDWARE', 'MODEL', 'PLATFORM', 'CHASSIS'],
400
+ 'serial': ['SERIAL', 'SERIAL_NUMBER', 'SN'],
401
+ 'uptime': ['UPTIME'],
402
+ 'hostname': ['HOSTNAME', 'DEVICE_NAME'],
403
+ }
404
+
405
+ # Get platform-specific mapping or fall back to defaults
406
+ if platform and platform in PLATFORM_VERSION_FIELDS:
407
+ field_map = PLATFORM_VERSION_FIELDS[platform]
408
+ else:
409
+ # Try to load from platform_data if available
410
+ try:
411
+ loaded_map = VERSION_FIELD_MAP.get(platform, DEFAULT_VERSION_FIELD_MAP) if platform else DEFAULT_VERSION_FIELD_MAP
412
+ field_map = loaded_map if loaded_map else DEFAULT_FIELDS
413
+ except (NameError, TypeError):
414
+ field_map = DEFAULT_FIELDS
415
+
416
+ return extract_fields(data, field_map, defaults={
417
+ 'version': 'unknown',
418
+ 'hardware': 'unknown',
419
+ 'serial': 'unknown',
420
+ 'uptime': 'unknown',
421
+ 'hostname': 'unknown',
422
+ })
423
+
424
+
425
+ def extract_neighbor_info(
426
+ parsed_data: List[Dict[str, Any]],
427
+ ) -> List[Dict[str, Any]]:
428
+ """
429
+ Extract neighbor information from parsed CDP/LLDP output.
430
+
431
+ Args:
432
+ parsed_data: Parsed output from show cdp/lldp neighbors command
433
+
434
+ Returns:
435
+ List of neighbor dictionaries with normalized field names
436
+ """
437
+ if not parsed_data:
438
+ return []
439
+
440
+ neighbors = []
441
+ for row in parsed_data:
442
+ neighbor = extract_fields(row, NEIGHBOR_FIELD_MAP, defaults={
443
+ 'local_interface': 'unknown',
444
+ 'neighbor_device': 'unknown',
445
+ 'neighbor_interface': 'unknown',
446
+ 'platform': 'unknown',
447
+ 'ip_address': '',
448
+ })
449
+ neighbors.append(neighbor)
450
+
451
+ return neighbors
452
+
453
+
454
+ def normalize_fields(
455
+ parsed_data: List[Dict[str, Any]],
456
+ platform: str,
457
+ field_map_dict: Dict[str, Dict[str, List[str]]],
458
+ ) -> List[Dict[str, Any]]:
459
+ """
460
+ Normalize vendor-specific field names to canonical names.
461
+
462
+ Args:
463
+ parsed_data: Raw parsed data from TextFSM
464
+ platform: Detected platform (e.g., 'cisco_ios')
465
+ field_map_dict: Mapping dict (e.g., INTERFACE_DETAIL_FIELD_MAP)
466
+
467
+ Returns:
468
+ List of dicts with normalized field names
469
+
470
+ Example:
471
+ >>> data = [{'LINK_STATUS': 'up', 'PROTOCOL_STATUS': 'up'}]
472
+ >>> normalize_fields(data, 'cisco_ios', INTERFACE_DETAIL_FIELD_MAP)
473
+ [{'admin_state': 'up', 'oper_state': 'up'}]
474
+ """
475
+ field_map = field_map_dict.get(platform, DEFAULT_FIELD_MAP)
476
+ normalized = []
477
+
478
+ for row in parsed_data:
479
+ norm_row = {}
480
+
481
+ # Map vendor-specific fields to canonical names
482
+ for canonical_name, vendor_names in field_map.items():
483
+ for vendor_name in vendor_names:
484
+ if vendor_name in row:
485
+ norm_row[canonical_name] = row[vendor_name]
486
+ break
487
+
488
+ # Keep any fields that weren't in the mapping
489
+ mapped_vendor_fields = {vn for vnames in field_map.values() for vn in vnames}
490
+ for key, value in row.items():
491
+ if key not in mapped_vendor_fields:
492
+ norm_row[key] = value
493
+
494
+ normalized.append(norm_row)
495
+
496
+ return normalized
497
+
498
+
499
+ def get_paging_disable_command(platform: Optional[str]) -> Optional[str]:
500
+ """
501
+ Get the appropriate command to disable terminal paging for a platform.
502
+
503
+ Args:
504
+ platform: Detected platform string
505
+
506
+ Returns:
507
+ Command string or None if platform doesn't require paging disable
508
+ """
509
+ if not platform:
510
+ return None
511
+
512
+ if 'cisco' in platform:
513
+ return "terminal length 0"
514
+ elif platform == 'juniper_junos':
515
+ return "set cli screen-length 0"
516
+ elif platform == 'arista_eos':
517
+ return "terminal length 0"
518
+
519
+ return None
520
+
521
+
522
+ def get_paging_disable_commands_to_try() -> List[str]:
523
+ """
524
+ Get list of paging disable commands to try when platform is unknown.
525
+
526
+ Returns a prioritized list of commands that cover most network platforms.
527
+ Commands are ordered by likelihood of success across vendors.
528
+
529
+ Returns:
530
+ List of paging disable commands to try in order
531
+ """
532
+ return [
533
+ # Cisco IOS/IOS-XE/NX-OS, Arista EOS (most common)
534
+ "terminal length 0",
535
+ # Cisco alternative
536
+ "terminal pager 0",
537
+ # Juniper JUNOS
538
+ "set cli screen-length 0",
539
+ # Some Cisco platforms
540
+ "screen-length 0",
541
+ # HP/Aruba
542
+ "no page",
543
+ # Huawei
544
+ "screen-length 0 temporary",
545
+ # Extreme
546
+ "disable clipaging",
547
+ # Dell/Force10
548
+ "terminal length 0",
549
+ ]
550
+
551
+
552
+ def try_disable_paging(shell, prompt, send_command_func, debug: bool = False) -> bool:
553
+ """
554
+ Try multiple commands to disable terminal paging.
555
+
556
+ Used for auto-recovery when PagingNotDisabledError is raised.
557
+
558
+ Args:
559
+ shell: Active SSH channel
560
+ prompt: Device prompt
561
+ send_command_func: Function to send commands (ssh_connection.send_command)
562
+ debug: Print debug info
563
+
564
+ Returns:
565
+ True if any command succeeded (didn't raise exception), False if all failed
566
+ """
567
+ commands = get_paging_disable_commands_to_try()
568
+
569
+ for cmd in commands:
570
+ try:
571
+ if debug:
572
+ print(f"[PAGING] Trying: {cmd}")
573
+ # Short timeout - these commands should return quickly
574
+ send_command_func(shell, cmd, prompt, timeout=5)
575
+ if debug:
576
+ print(f"[PAGING] Success: {cmd}")
577
+ return True
578
+ except Exception as e:
579
+ if debug:
580
+ print(f"[PAGING] Failed: {cmd} - {e}")
581
+ continue
582
+
583
+ return False
584
+
585
+
586
+ def sanitize_filename(name: str) -> str:
587
+ """
588
+ Make a device name safe for use in filenames.
589
+
590
+ Args:
591
+ name: Device name or identifier
592
+
593
+ Returns:
594
+ Sanitized string safe for filesystem use
595
+ """
596
+ return "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in name)