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 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
- # 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.
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 <name> Fetch specific interface details
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
- # Disconnect existing session if any
236
- if self.state.session:
237
- self._safe_disconnect()
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
- return self._ok({"type": "disconnect"})
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 'raw' or 'parsed'")
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 'text', 'rich', or 'json'")
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
- platform = parts[1].lower()
315
- self.state.platform_hint = platform
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) >= 2:
324
- mode = parts[1].lower()
325
- if mode in ["on", "true", "1"]:
326
- self.state.debug_mode = True
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
- try:
337
- db_info = self.state.api.db_info()
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 (Platform-Aware) =====
403
+ # ===== Quick Commands =====
343
404
  if cmd == ":config":
344
- return self._quick_command('config', parse=False, timeout=120)
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._quick_command('interfaces_status', parse=True)
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._quick_command('bgp_summary', parse=True)
417
+ return self._quick_bgp()
357
418
 
358
419
  if cmd == ":routes":
359
- return self._quick_command('routing_table', parse=True, timeout=60)
420
+ return self._quick_routes()
360
421
 
361
422
  if cmd == ":intf":
362
423
  if len(parts) < 2:
363
- return self._err("Usage: :intf <interface_name> (e.g., :intf Gi0/1)")
364
- intf_name = parts[1]
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 Command Helpers
430
+ # Quick commands
371
431
  # -----------------------
372
432
 
373
- def _quick_command(
374
- self,
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.send(
440
+ result = self.state.api.send_platform_command(
393
441
  self.state.session,
394
- cmd,
395
- timeout=timeout,
396
- parse=parse,
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"] = 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": "result", "result": payload})
455
+ return self._ok({"type": "config", "result": payload})
411
456
 
412
457
  except Exception as e:
413
- return self._err(f"Command failed: {e}")
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 <name> Fetch specific interface details
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
- Examples:
632
- :unlock
633
- :devices *leaf*
634
- :connect usa-leaf-1
635
- :version
636
- :interfaces
637
- :neighbors
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
- msg = data.get("message", "Disconnected")
205
- print(f"✓ {msg}")
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.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
 
@@ -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=2Q6p07pUVOzye15NHoHUNE0PY4anla4jUsnu87yym0s,32740
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=7SrvZvrcoQwOFAIjzXVvbFH04bFW6nOXar5JU3YEbr8,9560
28
- nterm/scripting/repl.py,sha256=BUISW1FX7EDh-1AV5UFpn2DB20CIYq_SdiCHea_6Ku0,22738
29
- nterm/scripting/repl_interactive.py,sha256=qcHpubo9GBcz-EpUrR_nHbfSIqwIx-gDXdNJP7rZVbU,17045
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.8.dist-info/METADATA,sha256=m9M46bSjV7s_trY8RfJMPodSxNLkAk1EQTY4RqqR2IY,16034
70
- ntermqt-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
71
- ntermqt-0.1.8.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
72
- ntermqt-0.1.8.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
73
- ntermqt-0.1.8.dist-info/RECORD,,
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,,