ntermqt 0.1.8__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 +199 -35
- nterm/scripting/platform_utils.py +269 -3
- nterm/scripting/repl.py +230 -71
- nterm/scripting/repl_interactive.py +27 -2
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.9.dist-info}/METADATA +2 -2
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.9.dist-info}/RECORD +9 -9
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.9.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.9.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.9.dist-info}/top_level.txt +0 -0
nterm/scripting/api.py
CHANGED
|
@@ -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.
|
nterm/scripting/repl.py
CHANGED
|
@@ -102,6 +102,8 @@ class NTermREPL:
|
|
|
102
102
|
:folders List folders
|
|
103
103
|
:connect <device> Connect to device [--cred name] [--debug]
|
|
104
104
|
:disconnect Disconnect current session
|
|
105
|
+
:disconnect_all Disconnect all sessions
|
|
106
|
+
:switch <device> Switch to another active session
|
|
105
107
|
:sessions List all active sessions
|
|
106
108
|
:policy [mode] Get/set policy (read_only|ops)
|
|
107
109
|
:mode [raw|parsed] Get/set output mode
|
|
@@ -120,7 +122,7 @@ class NTermREPL:
|
|
|
120
122
|
:neighbors Fetch CDP/LLDP neighbors (tries both)
|
|
121
123
|
:bgp Fetch BGP summary
|
|
122
124
|
:routes Fetch routing table
|
|
123
|
-
:intf <
|
|
125
|
+
:intf <n> Fetch specific interface details
|
|
124
126
|
|
|
125
127
|
Raw Commands:
|
|
126
128
|
(anything else) Runs as CLI on the connected session
|
|
@@ -232,9 +234,26 @@ class NTermREPL:
|
|
|
232
234
|
if not self.state.api.vault_unlocked:
|
|
233
235
|
return self._err("Vault is locked. Run :unlock first.")
|
|
234
236
|
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
# Check if already connected to this device
|
|
238
|
+
existing_sessions = self.state.api.active_sessions()
|
|
239
|
+
for sess in existing_sessions:
|
|
240
|
+
if sess.device_name == device:
|
|
241
|
+
# Already connected - just switch to it
|
|
242
|
+
self.state.session = sess
|
|
243
|
+
self.state.connected_device = sess.device_name
|
|
244
|
+
return self._ok({
|
|
245
|
+
"type": "switch",
|
|
246
|
+
"device": sess.device_name,
|
|
247
|
+
"hostname": sess.hostname,
|
|
248
|
+
"port": sess.port,
|
|
249
|
+
"platform": sess.platform,
|
|
250
|
+
"prompt": sess.prompt,
|
|
251
|
+
"message": "Already connected - switched to existing session",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
# NOTE: We no longer disconnect the existing session!
|
|
255
|
+
# Old sessions stay active in the background.
|
|
256
|
+
# User can switch back with :switch or disconnect with :disconnect
|
|
238
257
|
|
|
239
258
|
try:
|
|
240
259
|
sess = self.state.api.connect(device, credential=cred, debug=debug)
|
|
@@ -255,8 +274,60 @@ class NTermREPL:
|
|
|
255
274
|
if cmd == ":disconnect":
|
|
256
275
|
if not self.state.session:
|
|
257
276
|
return self._ok({"type": "disconnect", "message": "No active session"})
|
|
277
|
+
|
|
278
|
+
device_name = self.state.connected_device
|
|
258
279
|
self._safe_disconnect()
|
|
259
|
-
|
|
280
|
+
|
|
281
|
+
# Try to switch to another active session if available
|
|
282
|
+
remaining = self.state.api.active_sessions()
|
|
283
|
+
if remaining:
|
|
284
|
+
self.state.session = remaining[0]
|
|
285
|
+
self.state.connected_device = remaining[0].device_name
|
|
286
|
+
return self._ok({
|
|
287
|
+
"type": "disconnect",
|
|
288
|
+
"disconnected": device_name,
|
|
289
|
+
"switched_to": self.state.connected_device,
|
|
290
|
+
"message": f"Disconnected {device_name}, switched to {self.state.connected_device}",
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
return self._ok({"type": "disconnect", "disconnected": device_name})
|
|
294
|
+
|
|
295
|
+
if cmd == ":disconnect_all":
|
|
296
|
+
count = self.state.api.disconnect_all()
|
|
297
|
+
self.state.session = None
|
|
298
|
+
self.state.connected_device = None
|
|
299
|
+
return self._ok({"type": "disconnect_all", "count": count})
|
|
300
|
+
|
|
301
|
+
if cmd == ":switch":
|
|
302
|
+
if len(parts) < 2:
|
|
303
|
+
# Show available sessions
|
|
304
|
+
sessions = self.state.api.active_sessions()
|
|
305
|
+
if not sessions:
|
|
306
|
+
return self._err("No active sessions. Use :connect <device> first.")
|
|
307
|
+
|
|
308
|
+
session_names = [s.device_name for s in sessions]
|
|
309
|
+
return self._err(f"Usage: :switch <device>\nActive sessions: {', '.join(session_names)}")
|
|
310
|
+
|
|
311
|
+
target_device = parts[1]
|
|
312
|
+
|
|
313
|
+
# Find the session
|
|
314
|
+
sessions = self.state.api.active_sessions()
|
|
315
|
+
for sess in sessions:
|
|
316
|
+
if sess.device_name == target_device:
|
|
317
|
+
self.state.session = sess
|
|
318
|
+
self.state.connected_device = sess.device_name
|
|
319
|
+
return self._ok({
|
|
320
|
+
"type": "switch",
|
|
321
|
+
"device": sess.device_name,
|
|
322
|
+
"hostname": sess.hostname,
|
|
323
|
+
"port": sess.port,
|
|
324
|
+
"platform": sess.platform,
|
|
325
|
+
"prompt": sess.prompt,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
# Not found
|
|
329
|
+
session_names = [s.device_name for s in sessions]
|
|
330
|
+
return self._err(f"Session '{target_device}' not found.\nActive sessions: {', '.join(session_names)}")
|
|
260
331
|
|
|
261
332
|
if cmd == ":sessions":
|
|
262
333
|
sessions = self.state.api.active_sessions()
|
|
@@ -295,7 +366,7 @@ class NTermREPL:
|
|
|
295
366
|
})
|
|
296
367
|
mode = parts[1].lower()
|
|
297
368
|
if mode not in ["raw", "parsed"]:
|
|
298
|
-
return self._err("Mode must be
|
|
369
|
+
return self._err("Mode must be raw or parsed")
|
|
299
370
|
self.state.output_mode = mode
|
|
300
371
|
return self._ok({"type": "mode", "mode": mode})
|
|
301
372
|
|
|
@@ -304,113 +375,87 @@ class NTermREPL:
|
|
|
304
375
|
return self._ok({"type": "format", "format": self.state.output_format})
|
|
305
376
|
fmt = parts[1].lower()
|
|
306
377
|
if fmt not in ["text", "rich", "json"]:
|
|
307
|
-
return self._err("Format must be
|
|
378
|
+
return self._err("Format must be text, rich, or json")
|
|
308
379
|
self.state.output_format = fmt
|
|
309
380
|
return self._ok({"type": "format", "format": fmt})
|
|
310
381
|
|
|
311
382
|
if cmd == ":set_hint":
|
|
312
383
|
if len(parts) < 2:
|
|
313
384
|
return self._err("Usage: :set_hint <platform> (e.g., cisco_ios, arista_eos)")
|
|
314
|
-
|
|
315
|
-
self.state.platform_hint
|
|
316
|
-
return self._ok({"type": "set_hint", "platform_hint": platform})
|
|
385
|
+
self.state.platform_hint = parts[1]
|
|
386
|
+
return self._ok({"type": "set_hint", "platform_hint": self.state.platform_hint})
|
|
317
387
|
|
|
318
388
|
if cmd == ":clear_hint":
|
|
319
389
|
self.state.platform_hint = None
|
|
320
390
|
return self._ok({"type": "clear_hint"})
|
|
321
391
|
|
|
322
392
|
if cmd == ":debug":
|
|
323
|
-
if len(parts)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
elif mode in ["off", "false", "0"]:
|
|
328
|
-
self.state.debug_mode = False
|
|
329
|
-
else:
|
|
330
|
-
return self._err("Debug mode must be on or off")
|
|
331
|
-
else:
|
|
332
|
-
self.state.debug_mode = not self.state.debug_mode
|
|
393
|
+
if len(parts) < 2:
|
|
394
|
+
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
395
|
+
val = parts[1].lower()
|
|
396
|
+
self.state.debug_mode = val in ["on", "true", "1", "yes"]
|
|
333
397
|
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
334
398
|
|
|
335
399
|
if cmd == ":dbinfo":
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return self._ok({"type": "dbinfo", "db_info": db_info})
|
|
339
|
-
except Exception as e:
|
|
340
|
-
return self._err(f"Failed to get DB info: {e}")
|
|
400
|
+
info = self.state.api.db_info()
|
|
401
|
+
return self._ok({"type": "dbinfo", "db_info": info})
|
|
341
402
|
|
|
342
|
-
# ===== Quick Commands
|
|
403
|
+
# ===== Quick Commands =====
|
|
343
404
|
if cmd == ":config":
|
|
344
|
-
return self.
|
|
405
|
+
return self._quick_config()
|
|
345
406
|
|
|
346
407
|
if cmd == ":version":
|
|
347
408
|
return self._quick_version()
|
|
348
409
|
|
|
349
410
|
if cmd == ":interfaces":
|
|
350
|
-
return self.
|
|
411
|
+
return self._quick_interfaces()
|
|
351
412
|
|
|
352
413
|
if cmd == ":neighbors":
|
|
353
414
|
return self._quick_neighbors()
|
|
354
415
|
|
|
355
416
|
if cmd == ":bgp":
|
|
356
|
-
return self.
|
|
417
|
+
return self._quick_bgp()
|
|
357
418
|
|
|
358
419
|
if cmd == ":routes":
|
|
359
|
-
return self.
|
|
420
|
+
return self._quick_routes()
|
|
360
421
|
|
|
361
422
|
if cmd == ":intf":
|
|
362
423
|
if len(parts) < 2:
|
|
363
|
-
return self._err("Usage: :intf <
|
|
364
|
-
|
|
365
|
-
return self._quick_command('interface_detail', parse=True, name=intf_name)
|
|
424
|
+
return self._err("Usage: :intf <interface> (e.g., :intf Gi0/1)")
|
|
425
|
+
return self._quick_interface_detail(parts[1])
|
|
366
426
|
|
|
367
427
|
return self._err(f"Unknown REPL command: {cmd}")
|
|
368
428
|
|
|
369
429
|
# -----------------------
|
|
370
|
-
# Quick
|
|
430
|
+
# Quick commands
|
|
371
431
|
# -----------------------
|
|
372
432
|
|
|
373
|
-
def
|
|
374
|
-
|
|
375
|
-
command_type: str,
|
|
376
|
-
parse: bool = True,
|
|
377
|
-
timeout: int = 30,
|
|
378
|
-
**kwargs
|
|
379
|
-
) -> Dict[str, Any]:
|
|
380
|
-
"""Execute a platform-aware command."""
|
|
433
|
+
def _quick_config(self) -> Dict[str, Any]:
|
|
434
|
+
"""Fetch running configuration."""
|
|
381
435
|
if not self.state.session:
|
|
382
436
|
return self._err("Not connected. Use :connect <device>")
|
|
383
437
|
|
|
384
|
-
platform = self.state.platform_hint or self.state.session.platform
|
|
385
|
-
cmd = get_platform_command(platform, command_type, **kwargs)
|
|
386
|
-
|
|
387
|
-
if not cmd:
|
|
388
|
-
return self._err(f"Command '{command_type}' not available for platform '{platform}'")
|
|
389
|
-
|
|
390
438
|
try:
|
|
391
439
|
started = time.time()
|
|
392
|
-
result = self.state.api.
|
|
440
|
+
result = self.state.api.send_platform_command(
|
|
393
441
|
self.state.session,
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
normalize=True,
|
|
442
|
+
'config',
|
|
443
|
+
parse=False, # Config is typically not parsed
|
|
444
|
+
timeout=60,
|
|
398
445
|
)
|
|
399
446
|
elapsed = time.time() - started
|
|
400
447
|
|
|
448
|
+
if not result:
|
|
449
|
+
return self._err("Config command not available for this platform")
|
|
450
|
+
|
|
401
451
|
payload = result.to_dict()
|
|
402
452
|
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
403
|
-
payload["command_type"] =
|
|
404
|
-
|
|
405
|
-
# Truncate if needed
|
|
406
|
-
raw = payload.get("raw_output", "")
|
|
407
|
-
if len(raw) > self.state.policy.max_output_chars:
|
|
408
|
-
payload["raw_output"] = raw[:self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
453
|
+
payload["command_type"] = "config"
|
|
409
454
|
|
|
410
|
-
return self._ok({"type": "
|
|
455
|
+
return self._ok({"type": "config", "result": payload})
|
|
411
456
|
|
|
412
457
|
except Exception as e:
|
|
413
|
-
return self._err(f"
|
|
458
|
+
return self._err(f"Config fetch failed: {e}")
|
|
414
459
|
|
|
415
460
|
def _quick_version(self) -> Dict[str, Any]:
|
|
416
461
|
"""Fetch and extract version info."""
|
|
@@ -446,6 +491,33 @@ class NTermREPL:
|
|
|
446
491
|
except Exception as e:
|
|
447
492
|
return self._err(f"Version command failed: {e}")
|
|
448
493
|
|
|
494
|
+
def _quick_interfaces(self) -> Dict[str, Any]:
|
|
495
|
+
"""Fetch interface status."""
|
|
496
|
+
if not self.state.session:
|
|
497
|
+
return self._err("Not connected. Use :connect <device>")
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
started = time.time()
|
|
501
|
+
result = self.state.api.send_platform_command(
|
|
502
|
+
self.state.session,
|
|
503
|
+
'interfaces_status',
|
|
504
|
+
parse=True,
|
|
505
|
+
timeout=30,
|
|
506
|
+
)
|
|
507
|
+
elapsed = time.time() - started
|
|
508
|
+
|
|
509
|
+
if not result:
|
|
510
|
+
return self._err("Interfaces command not available for this platform")
|
|
511
|
+
|
|
512
|
+
payload = result.to_dict()
|
|
513
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
514
|
+
payload["command_type"] = "interfaces"
|
|
515
|
+
|
|
516
|
+
return self._ok({"type": "interfaces", "result": payload})
|
|
517
|
+
|
|
518
|
+
except Exception as e:
|
|
519
|
+
return self._err(f"Interfaces command failed: {e}")
|
|
520
|
+
|
|
449
521
|
def _quick_neighbors(self) -> Dict[str, Any]:
|
|
450
522
|
"""Fetch CDP/LLDP neighbors with fallback."""
|
|
451
523
|
if not self.state.session:
|
|
@@ -513,6 +585,89 @@ class NTermREPL:
|
|
|
513
585
|
except Exception as e:
|
|
514
586
|
return self._err(f"Neighbor discovery failed: {e}")
|
|
515
587
|
|
|
588
|
+
def _quick_bgp(self) -> Dict[str, Any]:
|
|
589
|
+
"""Fetch BGP summary."""
|
|
590
|
+
if not self.state.session:
|
|
591
|
+
return self._err("Not connected. Use :connect <device>")
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
started = time.time()
|
|
595
|
+
result = self.state.api.send_platform_command(
|
|
596
|
+
self.state.session,
|
|
597
|
+
'bgp_summary',
|
|
598
|
+
parse=True,
|
|
599
|
+
timeout=30,
|
|
600
|
+
)
|
|
601
|
+
elapsed = time.time() - started
|
|
602
|
+
|
|
603
|
+
if not result:
|
|
604
|
+
return self._err("BGP command not available for this platform")
|
|
605
|
+
|
|
606
|
+
payload = result.to_dict()
|
|
607
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
608
|
+
payload["command_type"] = "bgp"
|
|
609
|
+
|
|
610
|
+
return self._ok({"type": "bgp", "result": payload})
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
return self._err(f"BGP command failed: {e}")
|
|
614
|
+
|
|
615
|
+
def _quick_routes(self) -> Dict[str, Any]:
|
|
616
|
+
"""Fetch routing table."""
|
|
617
|
+
if not self.state.session:
|
|
618
|
+
return self._err("Not connected. Use :connect <device>")
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
started = time.time()
|
|
622
|
+
result = self.state.api.send_platform_command(
|
|
623
|
+
self.state.session,
|
|
624
|
+
'routing_table',
|
|
625
|
+
parse=True,
|
|
626
|
+
timeout=30,
|
|
627
|
+
)
|
|
628
|
+
elapsed = time.time() - started
|
|
629
|
+
|
|
630
|
+
if not result:
|
|
631
|
+
return self._err("Routing command not available for this platform")
|
|
632
|
+
|
|
633
|
+
payload = result.to_dict()
|
|
634
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
635
|
+
payload["command_type"] = "routes"
|
|
636
|
+
|
|
637
|
+
return self._ok({"type": "routes", "result": payload})
|
|
638
|
+
|
|
639
|
+
except Exception as e:
|
|
640
|
+
return self._err(f"Routing command failed: {e}")
|
|
641
|
+
|
|
642
|
+
def _quick_interface_detail(self, interface: str) -> Dict[str, Any]:
|
|
643
|
+
"""Fetch specific interface details."""
|
|
644
|
+
if not self.state.session:
|
|
645
|
+
return self._err("Not connected. Use :connect <device>")
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
started = time.time()
|
|
649
|
+
result = self.state.api.send_platform_command(
|
|
650
|
+
self.state.session,
|
|
651
|
+
'interface_detail',
|
|
652
|
+
name=interface,
|
|
653
|
+
parse=True,
|
|
654
|
+
timeout=30,
|
|
655
|
+
)
|
|
656
|
+
elapsed = time.time() - started
|
|
657
|
+
|
|
658
|
+
if not result:
|
|
659
|
+
return self._err(f"Interface detail command not available for this platform")
|
|
660
|
+
|
|
661
|
+
payload = result.to_dict()
|
|
662
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
663
|
+
payload["command_type"] = "interface_detail"
|
|
664
|
+
payload["interface"] = interface
|
|
665
|
+
|
|
666
|
+
return self._ok({"type": "interface_detail", "result": payload})
|
|
667
|
+
|
|
668
|
+
except Exception as e:
|
|
669
|
+
return self._err(f"Interface detail command failed: {e}")
|
|
670
|
+
|
|
516
671
|
# -----------------------
|
|
517
672
|
# CLI send path
|
|
518
673
|
# -----------------------
|
|
@@ -568,6 +723,7 @@ class NTermREPL:
|
|
|
568
723
|
# -----------------------
|
|
569
724
|
|
|
570
725
|
def _safe_disconnect(self) -> None:
|
|
726
|
+
"""Disconnect current session only (not all sessions)."""
|
|
571
727
|
if self.state.session:
|
|
572
728
|
try:
|
|
573
729
|
self.state.api.disconnect(self.state.session)
|
|
@@ -601,6 +757,8 @@ Inventory:
|
|
|
601
757
|
Sessions:
|
|
602
758
|
:connect <device> Connect to device [--cred name] [--debug]
|
|
603
759
|
:disconnect Disconnect current session
|
|
760
|
+
:disconnect_all Disconnect all sessions
|
|
761
|
+
:switch <device> Switch to another active session
|
|
604
762
|
:sessions List all active sessions
|
|
605
763
|
|
|
606
764
|
Quick Commands (platform-aware, auto-selects correct syntax):
|
|
@@ -610,7 +768,7 @@ Quick Commands (platform-aware, auto-selects correct syntax):
|
|
|
610
768
|
:neighbors Fetch CDP/LLDP neighbors (tries both)
|
|
611
769
|
:bgp Fetch BGP summary
|
|
612
770
|
:routes Fetch routing table
|
|
613
|
-
:intf <
|
|
771
|
+
:intf <n> Fetch specific interface details
|
|
614
772
|
|
|
615
773
|
Settings:
|
|
616
774
|
:policy [mode] Get/set policy mode (read_only|ops)
|
|
@@ -628,15 +786,16 @@ Info:
|
|
|
628
786
|
Raw Commands:
|
|
629
787
|
(anything else) Sends as CLI command to connected device
|
|
630
788
|
|
|
631
|
-
|
|
632
|
-
:
|
|
633
|
-
|
|
634
|
-
:connect
|
|
635
|
-
|
|
636
|
-
:
|
|
637
|
-
:
|
|
638
|
-
show ip route
|
|
639
|
-
:disconnect
|
|
789
|
+
Multi-Session Example:
|
|
790
|
+
:connect spine-1 # Connect to first device
|
|
791
|
+
show version # Run command on spine-1
|
|
792
|
+
:connect spine-2 # Connect to second (spine-1 stays active!)
|
|
793
|
+
show version # Run command on spine-2
|
|
794
|
+
:sessions # See both sessions
|
|
795
|
+
:switch spine-1 # Switch back to spine-1
|
|
796
|
+
show ip route # Run command on spine-1
|
|
797
|
+
:disconnect # Disconnect spine-1, auto-switch to spine-2
|
|
798
|
+
:disconnect_all # Disconnect all remaining sessions
|
|
640
799
|
:exit
|
|
641
800
|
"""
|
|
642
801
|
|
|
@@ -200,9 +200,34 @@ def _display_result(repl: NTermREPL, result: Dict) -> None:
|
|
|
200
200
|
print(f" Prompt: {data.get('prompt', '')}")
|
|
201
201
|
return
|
|
202
202
|
|
|
203
|
+
if cmd_type == "switch":
|
|
204
|
+
msg = data.get('message', '')
|
|
205
|
+
if msg:
|
|
206
|
+
print(f"✓ {msg}")
|
|
207
|
+
else:
|
|
208
|
+
print(f"✓ Switched to {data['device']} ({data['hostname']}:{data['port']})")
|
|
209
|
+
print(f" Platform: {data.get('platform', 'unknown')}")
|
|
210
|
+
print(f" Prompt: {data.get('prompt', '')}")
|
|
211
|
+
return
|
|
212
|
+
|
|
203
213
|
if cmd_type == "disconnect":
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
disconnected = data.get("disconnected")
|
|
215
|
+
switched_to = data.get("switched_to")
|
|
216
|
+
msg = data.get("message")
|
|
217
|
+
|
|
218
|
+
if msg:
|
|
219
|
+
print(f"✓ {msg}")
|
|
220
|
+
elif disconnected:
|
|
221
|
+
print(f"✓ Disconnected from {disconnected}")
|
|
222
|
+
if switched_to:
|
|
223
|
+
print(f" Switched to: {switched_to}")
|
|
224
|
+
else:
|
|
225
|
+
print("✓ Disconnected")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if cmd_type == "disconnect_all":
|
|
229
|
+
count = data.get("count", 0)
|
|
230
|
+
print(f"✓ Disconnected {count} session(s)")
|
|
206
231
|
return
|
|
207
232
|
|
|
208
233
|
if cmd_type == "sessions":
|
|
@@ -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
|
|
|
@@ -20,13 +20,13 @@ nterm/parser/tfsm_engine.py,sha256=6p4wrNa9tQRuCmWgsR4E3rZTprpLmii5PNjoGpCQBCw,7
|
|
|
20
20
|
nterm/parser/tfsm_fire.py,sha256=AHbN6p4HlgcYDjLWb67CF9YfMSTk-3aetMswmEZyRVc,9222
|
|
21
21
|
nterm/parser/tfsm_fire_tester.py,sha256=h2CAqTS6ZNHMUr4di2DBRHAWbBGiUTliOvm5jVG4ltI,79146
|
|
22
22
|
nterm/scripting/__init__.py,sha256=vxbODaXR0IPneja3BuDHmjsHzQg03tFWtHO4Rc6vCTk,1099
|
|
23
|
-
nterm/scripting/api.py,sha256=
|
|
23
|
+
nterm/scripting/api.py,sha256=nWuANeYyT8mI1OsP9g3Z2lZohrrCpAZVss6t9Ba5Avg,39011
|
|
24
24
|
nterm/scripting/cli.py,sha256=W2DK4ZnuutaArye_to7CBchg0ogClURxVbGsMdnj1y0,9187
|
|
25
25
|
nterm/scripting/models.py,sha256=zX90xtFYz0fqIPc0G8mRaoiZ1aRLm0koiHIvklUBflg,6858
|
|
26
26
|
nterm/scripting/platform_data.py,sha256=uCWBDS1HqmOdYNPojNlKBWLYhLiME9eoE0LWL-w6dkQ,10200
|
|
27
|
-
nterm/scripting/platform_utils.py,sha256=
|
|
28
|
-
nterm/scripting/repl.py,sha256=
|
|
29
|
-
nterm/scripting/repl_interactive.py,sha256=
|
|
27
|
+
nterm/scripting/platform_utils.py,sha256=_vb1tMstlD9pQ4blD2keAkVbs9lkbFlTBEavI5oMd3Q,18478
|
|
28
|
+
nterm/scripting/repl.py,sha256=URcgs-c-bEYsw2JNCewEonNy1oLsGpzsBcq5xpYWseM,29152
|
|
29
|
+
nterm/scripting/repl_interactive.py,sha256=yQ-XjyELBCE5t4Gk3hBnO8eZCbCXquddLBqcQIKPhY8,17870
|
|
30
30
|
nterm/scripting/ssh_connection.py,sha256=p9EGPE3hgbceDVh7UdGz44cSi79Vl7g9Q4fKd-2T314,19270
|
|
31
31
|
nterm/scripting/test_api_repl.py,sha256=deeA_epPnBxgDGYMKcyXEWQOOpIXQNjDZL01FmOpKvs,8762
|
|
32
32
|
nterm/session/__init__.py,sha256=FkgHF1WPz78JBOWHSC7LLynG2NqoR6aanNTRlEzsO6I,1612
|
|
@@ -66,8 +66,8 @@ nterm/vault/manager_ui.py,sha256=qle-W40j6L_pOR0AaOCeyU8myizFTRkISNrloCn0H_Y,345
|
|
|
66
66
|
nterm/vault/profile.py,sha256=qM9TJf68RKdjtxo-sJehO7wS4iTi2G26BKbmlmHLA5M,6246
|
|
67
67
|
nterm/vault/resolver.py,sha256=GWB2YR9H1MH98RGQBKvitIsjWT_-wSMLuddZNz4wbns,7800
|
|
68
68
|
nterm/vault/store.py,sha256=_0Lfe0WKjm3uSAtxgn9qAPlpBOLCuq9SVgzqsE_qaGQ,21199
|
|
69
|
-
ntermqt-0.1.
|
|
70
|
-
ntermqt-0.1.
|
|
71
|
-
ntermqt-0.1.
|
|
72
|
-
ntermqt-0.1.
|
|
73
|
-
ntermqt-0.1.
|
|
69
|
+
ntermqt-0.1.9.dist-info/METADATA,sha256=ICYH7EjefVJLe_Hp-prpK8GPQE2TTqEaoX7-x2M0cOo,16040
|
|
70
|
+
ntermqt-0.1.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
71
|
+
ntermqt-0.1.9.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
|
|
72
|
+
ntermqt-0.1.9.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
|
|
73
|
+
ntermqt-0.1.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|