ntermqt 0.1.8__tar.gz → 0.1.9__tar.gz
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.
- {ntermqt-0.1.8/ntermqt.egg-info → ntermqt-0.1.9}/PKG-INFO +2 -2
- {ntermqt-0.1.8 → ntermqt-0.1.9}/README.md +1 -1
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/api.py +199 -35
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/platform_utils.py +269 -3
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/repl.py +230 -71
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/repl_interactive.py +27 -2
- {ntermqt-0.1.8 → ntermqt-0.1.9/ntermqt.egg-info}/PKG-INFO +2 -2
- {ntermqt-0.1.8 → ntermqt-0.1.9}/pyproject.toml +1 -1
- {ntermqt-0.1.8 → ntermqt-0.1.9}/MANIFEST.in +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/__main__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/askpass/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/askpass/server.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/config.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/connection/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/connection/profile.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/connect_dialog.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/editor.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/io.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/models.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/settings.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/tree.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/api_help_dialog.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/ntc_download_dialog.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/tfsm_engine.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/tfsm_fire.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/tfsm_fire_tester.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/resources.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/cli.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/models.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/platform_data.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/ssh_connection.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/test_api_repl.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/askpass_ssh.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/base.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/interactive_ssh.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/local_terminal.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/pty_transport.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/ssh.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/bridge.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/terminal.html +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/terminal.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm.css +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm.min.js +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/widget.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/engine.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/stylesheet.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/clean.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/default.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/dracula.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/enterprise_dark.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/enterprise_light.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/gruvbox_light.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/nord_hybrid.yaml +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/__init__.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/credential_manager.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/keychain.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/manager_ui.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/profile.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/resolver.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/store.py +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/SOURCES.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/dependency_links.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/entry_points.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/requires.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/top_level.txt +0 -0
- {ntermqt-0.1.8 → ntermqt-0.1.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntermqt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
|
|
5
5
|
Author: Scott Peterman
|
|
6
6
|
License: GPL-3.0
|
|
@@ -112,7 +112,7 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
112
112
|
|
|
113
113
|

|
|
114
114
|
|
|
115
|
-

|
|
116
116
|
|
|
117
117
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
118
118
|
|
|
@@ -67,7 +67,7 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
67
67
|
|
|
68
68
|

|
|
69
69
|
|
|
70
|
-

|
|
71
71
|
|
|
72
72
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
73
73
|
|
|
@@ -19,13 +19,16 @@ from .models import ActiveSession, CommandResult, DeviceInfo, CredentialInfo
|
|
|
19
19
|
from .platform_data import INTERFACE_DETAIL_FIELD_MAP
|
|
20
20
|
from .platform_utils import (
|
|
21
21
|
detect_platform,
|
|
22
|
+
detect_platform_from_template,
|
|
23
|
+
extract_platform_from_template_name,
|
|
22
24
|
normalize_fields,
|
|
23
25
|
get_paging_disable_command,
|
|
24
26
|
get_platform_command,
|
|
25
27
|
extract_version_info,
|
|
26
28
|
extract_neighbor_info,
|
|
29
|
+
try_disable_paging,
|
|
27
30
|
)
|
|
28
|
-
from .ssh_connection import connect_ssh, send_command
|
|
31
|
+
from .ssh_connection import connect_ssh, send_command, PagingNotDisabledError
|
|
29
32
|
|
|
30
33
|
# TextFSM parsing - REQUIRED
|
|
31
34
|
try:
|
|
@@ -397,10 +400,21 @@ class NTermAPI:
|
|
|
397
400
|
prompt=prompt,
|
|
398
401
|
)
|
|
399
402
|
|
|
400
|
-
#
|
|
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
|
|
406
|
+
try:
|
|
407
|
+
send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
408
|
+
if debug:
|
|
409
|
+
print(f"[DEBUG] Pre-emptive paging disable: terminal length 0")
|
|
410
|
+
except Exception as e:
|
|
411
|
+
if debug:
|
|
412
|
+
print(f"[DEBUG] Pre-emptive paging disable failed (normal on some platforms): {e}")
|
|
413
|
+
|
|
414
|
+
# Detect platform using TextFSM template matching (primary) or regex (fallback)
|
|
401
415
|
try:
|
|
402
416
|
version_output = send_command(shell, "show version", prompt)
|
|
403
|
-
platform = detect_platform(version_output)
|
|
417
|
+
platform = detect_platform(version_output, tfsm_engine=self._tfsm_engine)
|
|
404
418
|
session.platform = platform
|
|
405
419
|
if debug:
|
|
406
420
|
print(f"[DEBUG] Platform detected: {platform}")
|
|
@@ -413,9 +427,13 @@ class NTermAPI:
|
|
|
413
427
|
if paging_cmd:
|
|
414
428
|
try:
|
|
415
429
|
send_command(shell, paging_cmd, prompt, timeout=5)
|
|
430
|
+
session._paging_disabled = True
|
|
416
431
|
except Exception as e:
|
|
417
432
|
if debug:
|
|
418
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
|
|
419
437
|
|
|
420
438
|
self._active_sessions[device_name] = session
|
|
421
439
|
return session
|
|
@@ -472,6 +490,7 @@ class NTermAPI:
|
|
|
472
490
|
timeout: int = 60,
|
|
473
491
|
parse: bool = True,
|
|
474
492
|
normalize: bool = True,
|
|
493
|
+
_retry_after_paging_fix: bool = False,
|
|
475
494
|
) -> CommandResult:
|
|
476
495
|
"""
|
|
477
496
|
Send command to a connected session.
|
|
@@ -482,6 +501,7 @@ class NTermAPI:
|
|
|
482
501
|
timeout: Command timeout in seconds
|
|
483
502
|
parse: Whether to attempt TextFSM parsing
|
|
484
503
|
normalize: Whether to normalize field names (requires parse=True)
|
|
504
|
+
_retry_after_paging_fix: Internal flag to prevent infinite retry loops
|
|
485
505
|
|
|
486
506
|
Returns:
|
|
487
507
|
CommandResult with raw and parsed output
|
|
@@ -495,12 +515,62 @@ class NTermAPI:
|
|
|
495
515
|
if result.parsed_data:
|
|
496
516
|
for row in result.parsed_data:
|
|
497
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.
|
|
498
522
|
"""
|
|
499
523
|
if not session.is_connected():
|
|
500
524
|
raise RuntimeError(f"Session {session.device_name} is not connected")
|
|
501
525
|
|
|
502
526
|
# Execute command using our refactored module
|
|
503
|
-
|
|
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
|
+
)
|
|
504
574
|
|
|
505
575
|
# Create result object
|
|
506
576
|
result = CommandResult(
|
|
@@ -655,16 +725,22 @@ class NTermAPI:
|
|
|
655
725
|
Disconnect a session.
|
|
656
726
|
|
|
657
727
|
Args:
|
|
658
|
-
session: ActiveSession to
|
|
728
|
+
session: ActiveSession to disconnect
|
|
659
729
|
"""
|
|
660
730
|
if session.device_name in self._active_sessions:
|
|
661
731
|
del self._active_sessions[session.device_name]
|
|
662
732
|
|
|
663
|
-
|
|
664
|
-
session.shell
|
|
733
|
+
try:
|
|
734
|
+
if session.shell:
|
|
735
|
+
session.shell.close()
|
|
736
|
+
except Exception:
|
|
737
|
+
pass
|
|
665
738
|
|
|
666
|
-
|
|
667
|
-
session.client
|
|
739
|
+
try:
|
|
740
|
+
if session.client:
|
|
741
|
+
session.client.close()
|
|
742
|
+
except Exception:
|
|
743
|
+
pass
|
|
668
744
|
|
|
669
745
|
def disconnect_all(self) -> int:
|
|
670
746
|
"""
|
|
@@ -674,60 +750,48 @@ class NTermAPI:
|
|
|
674
750
|
Number of sessions disconnected
|
|
675
751
|
|
|
676
752
|
Example:
|
|
677
|
-
#
|
|
753
|
+
# At end of script or in finally block
|
|
678
754
|
count = api.disconnect_all()
|
|
679
755
|
print(f"Disconnected {count} session(s)")
|
|
680
756
|
"""
|
|
681
|
-
count =
|
|
682
|
-
|
|
757
|
+
count = len(self._active_sessions)
|
|
758
|
+
|
|
759
|
+
for session in list(self._active_sessions.values()):
|
|
683
760
|
try:
|
|
684
|
-
session = self._active_sessions[session_id]
|
|
685
761
|
self.disconnect(session)
|
|
686
|
-
count += 1
|
|
687
762
|
except Exception:
|
|
688
763
|
pass
|
|
764
|
+
|
|
765
|
+
self._active_sessions.clear()
|
|
689
766
|
return count
|
|
690
767
|
|
|
691
768
|
def active_sessions(self) -> List[ActiveSession]:
|
|
692
769
|
"""
|
|
693
|
-
|
|
770
|
+
List all active sessions.
|
|
694
771
|
|
|
695
772
|
Returns:
|
|
696
773
|
List of ActiveSession objects
|
|
697
774
|
"""
|
|
698
|
-
|
|
699
|
-
active = []
|
|
700
|
-
stale = []
|
|
701
|
-
|
|
702
|
-
for session_id, session in self._active_sessions.items():
|
|
703
|
-
if session.is_connected():
|
|
704
|
-
active.append(session)
|
|
705
|
-
else:
|
|
706
|
-
stale.append(session_id)
|
|
707
|
-
|
|
708
|
-
# Remove stale entries
|
|
709
|
-
for session_id in stale:
|
|
710
|
-
del self._active_sessions[session_id]
|
|
711
|
-
|
|
712
|
-
return active
|
|
775
|
+
return list(self._active_sessions.values())
|
|
713
776
|
|
|
714
777
|
# -------------------------------------------------------------------------
|
|
715
|
-
#
|
|
778
|
+
# Diagnostics
|
|
716
779
|
# -------------------------------------------------------------------------
|
|
717
780
|
|
|
718
781
|
def db_info(self) -> Dict[str, Any]:
|
|
719
782
|
"""
|
|
720
|
-
Get
|
|
783
|
+
Get information about the TextFSM database.
|
|
721
784
|
|
|
722
785
|
Returns:
|
|
723
|
-
Dict with
|
|
786
|
+
Dict with db_path, db_exists, db_size, db_size_mb, etc.
|
|
724
787
|
"""
|
|
725
788
|
info = {
|
|
726
|
-
"
|
|
789
|
+
"engine_initialized": self._tfsm_engine is not None,
|
|
727
790
|
"db_path": None,
|
|
728
791
|
"db_exists": False,
|
|
792
|
+
"db_size": None,
|
|
793
|
+
"db_size_mb": None,
|
|
729
794
|
"db_absolute_path": None,
|
|
730
|
-
"current_working_directory": os.getcwd(),
|
|
731
795
|
}
|
|
732
796
|
|
|
733
797
|
if self._tfsm_engine and hasattr(self._tfsm_engine, 'db_path'):
|
|
@@ -816,6 +880,90 @@ class NTermAPI:
|
|
|
816
880
|
|
|
817
881
|
return debug_info
|
|
818
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
|
+
|
|
819
967
|
# -------------------------------------------------------------------------
|
|
820
968
|
# Convenience / REPL helpers
|
|
821
969
|
# -------------------------------------------------------------------------
|
|
@@ -912,6 +1060,7 @@ Command Results:
|
|
|
912
1060
|
|
|
913
1061
|
Debugging:
|
|
914
1062
|
api.debug_parse(cmd, output, platform) Debug why parsing failed
|
|
1063
|
+
api.detect_platform_from_output(output) Detect platform from command output
|
|
915
1064
|
api.db_info() Show TextFSM database path and status
|
|
916
1065
|
|
|
917
1066
|
Status:
|
|
@@ -919,6 +1068,21 @@ Status:
|
|
|
919
1068
|
api.vault_unlocked Check vault status
|
|
920
1069
|
api._tfsm_engine TextFSM parser (required)
|
|
921
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
|
+
|
|
922
1086
|
Examples:
|
|
923
1087
|
# Connect and execute (manual)
|
|
924
1088
|
api.unlock("vault-password")
|
|
@@ -19,16 +19,27 @@ from .platform_data import (
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def detect_platform(version_output: str) -> Optional[str]:
|
|
22
|
+
def detect_platform(version_output: str, tfsm_engine=None) -> Optional[str]:
|
|
23
23
|
"""
|
|
24
24
|
Detect device platform from 'show version' output.
|
|
25
25
|
|
|
26
|
+
Uses TextFSM template matching as primary method (more accurate),
|
|
27
|
+
falls back to regex patterns if no template matches.
|
|
28
|
+
|
|
26
29
|
Args:
|
|
27
30
|
version_output: Raw output from 'show version' command
|
|
31
|
+
tfsm_engine: Optional TextFSMAutoEngine instance for template-based detection
|
|
28
32
|
|
|
29
33
|
Returns:
|
|
30
34
|
Platform string (e.g., 'cisco_ios', 'arista_eos') or None if not detected
|
|
31
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
|
|
32
43
|
for platform, patterns in PLATFORM_PATTERNS.items():
|
|
33
44
|
for pattern in patterns:
|
|
34
45
|
if re.search(pattern, version_output, re.IGNORECASE):
|
|
@@ -36,6 +47,140 @@ def detect_platform(version_output: str) -> Optional[str]:
|
|
|
36
47
|
return None
|
|
37
48
|
|
|
38
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
|
+
|
|
39
184
|
def get_platform_command(
|
|
40
185
|
platform: Optional[str],
|
|
41
186
|
command_type: str,
|
|
@@ -208,8 +353,65 @@ def extract_version_info(
|
|
|
208
353
|
|
|
209
354
|
data = parsed_data[0] # show version typically returns single row
|
|
210
355
|
|
|
211
|
-
#
|
|
212
|
-
|
|
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
|
|
213
415
|
|
|
214
416
|
return extract_fields(data, field_map, defaults={
|
|
215
417
|
'version': 'unknown',
|
|
@@ -317,6 +519,70 @@ def get_paging_disable_command(platform: Optional[str]) -> Optional[str]:
|
|
|
317
519
|
return None
|
|
318
520
|
|
|
319
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
|
+
|
|
320
586
|
def sanitize_filename(name: str) -> str:
|
|
321
587
|
"""
|
|
322
588
|
Make a device name safe for use in filenames.
|