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.
Files changed (79) hide show
  1. {ntermqt-0.1.8/ntermqt.egg-info → ntermqt-0.1.9}/PKG-INFO +2 -2
  2. {ntermqt-0.1.8 → ntermqt-0.1.9}/README.md +1 -1
  3. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/api.py +199 -35
  4. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/platform_utils.py +269 -3
  5. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/repl.py +230 -71
  6. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/repl_interactive.py +27 -2
  7. {ntermqt-0.1.8 → ntermqt-0.1.9/ntermqt.egg-info}/PKG-INFO +2 -2
  8. {ntermqt-0.1.8 → ntermqt-0.1.9}/pyproject.toml +1 -1
  9. {ntermqt-0.1.8 → ntermqt-0.1.9}/MANIFEST.in +0 -0
  10. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/__init__.py +0 -0
  11. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/__main__.py +0 -0
  12. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/askpass/__init__.py +0 -0
  13. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/askpass/server.py +0 -0
  14. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/config.py +0 -0
  15. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/connection/__init__.py +0 -0
  16. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/connection/profile.py +0 -0
  17. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/__init__.py +0 -0
  18. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/connect_dialog.py +0 -0
  19. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/editor.py +0 -0
  20. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/io.py +0 -0
  21. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/models.py +0 -0
  22. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/settings.py +0 -0
  23. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/manager/tree.py +0 -0
  24. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/__init__.py +0 -0
  25. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/api_help_dialog.py +0 -0
  26. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/ntc_download_dialog.py +0 -0
  27. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/tfsm_engine.py +0 -0
  28. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/tfsm_fire.py +0 -0
  29. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/parser/tfsm_fire_tester.py +0 -0
  30. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/resources.py +0 -0
  31. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/__init__.py +0 -0
  32. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/cli.py +0 -0
  33. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/models.py +0 -0
  34. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/platform_data.py +0 -0
  35. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/ssh_connection.py +0 -0
  36. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/scripting/test_api_repl.py +0 -0
  37. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/__init__.py +0 -0
  38. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/askpass_ssh.py +0 -0
  39. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/base.py +0 -0
  40. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/interactive_ssh.py +0 -0
  41. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/local_terminal.py +0 -0
  42. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/pty_transport.py +0 -0
  43. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/session/ssh.py +0 -0
  44. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/__init__.py +0 -0
  45. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/bridge.py +0 -0
  46. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/terminal.html +0 -0
  47. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/terminal.js +0 -0
  48. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
  49. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
  50. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
  51. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm.css +0 -0
  52. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/resources/xterm.min.js +0 -0
  53. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/terminal/widget.py +0 -0
  54. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/__init__.py +0 -0
  55. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/engine.py +0 -0
  56. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/stylesheet.py +0 -0
  57. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/clean.yaml +0 -0
  58. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/default.yaml +0 -0
  59. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/dracula.yaml +0 -0
  60. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/enterprise_dark.yaml +0 -0
  61. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
  62. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/enterprise_light.yaml +0 -0
  63. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
  64. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
  65. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/gruvbox_light.yaml +0 -0
  66. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/theme/themes/nord_hybrid.yaml +0 -0
  67. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/__init__.py +0 -0
  68. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/credential_manager.py +0 -0
  69. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/keychain.py +0 -0
  70. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/manager_ui.py +0 -0
  71. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/profile.py +0 -0
  72. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/resolver.py +0 -0
  73. {ntermqt-0.1.8 → ntermqt-0.1.9}/nterm/vault/store.py +0 -0
  74. {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/SOURCES.txt +0 -0
  75. {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/dependency_links.txt +0 -0
  76. {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/entry_points.txt +0 -0
  77. {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/requires.txt +0 -0
  78. {ntermqt-0.1.8 → ntermqt-0.1.9}/ntermqt.egg-info/top_level.txt +0 -0
  79. {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.8
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
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
114
114
 
115
- ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
115
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl_dark3.png)
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
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
69
69
 
70
- ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
70
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl_dark3.png)
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
- # Detect platform
403
+ # Pre-emptively try to disable paging BEFORE platform detection
404
+ # This prevents 'show version' from being truncated by --More--
405
+ # Use the most common command - harmless if it fails on non-Cisco platforms
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
- raw_output = send_command(session.shell, command, session.prompt, timeout)
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 close
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
- if session.shell:
664
- session.shell.close()
733
+ try:
734
+ if session.shell:
735
+ session.shell.close()
736
+ except Exception:
737
+ pass
665
738
 
666
- if session.client:
667
- session.client.close()
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
- # Cleanup at end of script
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 = 0
682
- for session_id in list(self._active_sessions.keys()):
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
- Get list of currently active sessions.
770
+ List all active sessions.
694
771
 
695
772
  Returns:
696
773
  List of ActiveSession objects
697
774
  """
698
- # Clean up stale sessions
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
- # Debug / diagnostic methods
778
+ # Diagnostics
716
779
  # -------------------------------------------------------------------------
717
780
 
718
781
  def db_info(self) -> Dict[str, Any]:
719
782
  """
720
- Get detailed information about TextFSM database.
783
+ Get information about the TextFSM database.
721
784
 
722
785
  Returns:
723
- Dict with database path, existence, and diagnostic info
786
+ Dict with db_path, db_exists, db_size, db_size_mb, etc.
724
787
  """
725
788
  info = {
726
- "engine_available": self._tfsm_engine is not None,
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
- # Get platform-specific or default field map
212
- field_map = VERSION_FIELD_MAP.get(platform, DEFAULT_VERSION_FIELD_MAP) if platform else DEFAULT_VERSION_FIELD_MAP
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.