multiSSH3 5.27__tar.gz → 5.33__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.

Potentially problematic release.


This version of multiSSH3 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: multiSSH3
3
- Version: 5.27
3
+ Version: 5.33
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: multiSSH3
3
- Version: 5.27
3
+ Version: 5.33
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -24,6 +24,7 @@ import shutil
24
24
  import getpass
25
25
  import uuid
26
26
  import tempfile
27
+ import math
27
28
 
28
29
  try:
29
30
  # Check if functiools.cache is available
@@ -36,7 +37,7 @@ except AttributeError:
36
37
  # If neither is available, use a dummy decorator
37
38
  def cache_decorator(func):
38
39
  return func
39
- version = '5.27'
40
+ version = '5.33'
40
41
  VERSION = version
41
42
 
42
43
  CONFIG_FILE = '/etc/multiSSH3.config.json'
@@ -165,6 +166,7 @@ class Host:
165
166
  self.stdout = [] # the stdout of the command
166
167
  self.stderr = [] # the stderr of the command
167
168
  self.printedLines = -1 # the number of lines printed on the screen
169
+ self.lineNumToReprintSet = set() # whether to reprint the last line
168
170
  self.lastUpdateTime = time.time() # the last time the output was updated
169
171
  self.files = files # the files to be copied to the host
170
172
  self.ipmi = ipmi # whether to use ipmi to connect to the host
@@ -179,12 +181,17 @@ class Host:
179
181
  self.uuid = uuid
180
182
  self.identity_file = identity_file
181
183
  self.ip = ip if ip else getIP(name)
184
+ self.current_color_pair = [-1, -1, 1]
182
185
 
183
186
  def __iter__(self):
184
187
  return zip(['name', 'command', 'returncode', 'stdout', 'stderr'], [self.name, self.command, self.returncode, self.stdout, self.stderr])
185
188
  def __repr__(self):
186
189
  # return the complete data structure
187
- return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr}, output={self.output}, printedLines={self.printedLines}, files={self.files}, ipmi={self.ipmi}, interface_ip_prefix={self.interface_ip_prefix}, scp={self.scp}, gatherMode={self.gatherMode}, extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={self.uuid}), identity_file={self.identity_file}"
190
+ return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr}, \
191
+ output={self.output}, printedLines={self.printedLines}, lineNumToReprintSet={self.lineNumToReprintSet}, files={self.files}, ipmi={self.ipmi}, \
192
+ interface_ip_prefix={self.interface_ip_prefix}, scp={self.scp}, gatherMode={self.gatherMode}, \
193
+ extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={self.uuid}), \
194
+ identity_file={self.identity_file}, ip={self.ip}, current_color_pair={self.current_color_pair}"
188
195
  def __str__(self):
189
196
  return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
190
197
 
@@ -341,7 +348,30 @@ if True:
341
348
  __keyPressesIn = [[]]
342
349
  _emo = False
343
350
  _etc_hosts = __configs_from_file.get('_etc_hosts', __build_in_default_config['_etc_hosts'])
344
-
351
+ __curses_global_color_pairs = {(-1,-1):1}
352
+ __curses_current_color_pair_index = 2 # Start from 1, as 0 is the default color pair
353
+ __curses_color_table = {}
354
+ __curses_current_color_index = 10
355
+
356
+ # Mapping of ANSI 4-bit colors to curses colors
357
+ ANSI_TO_CURSES_COLOR = {
358
+ 30: curses.COLOR_BLACK,
359
+ 31: curses.COLOR_RED,
360
+ 32: curses.COLOR_GREEN,
361
+ 33: curses.COLOR_YELLOW,
362
+ 34: curses.COLOR_BLUE,
363
+ 35: curses.COLOR_MAGENTA,
364
+ 36: curses.COLOR_CYAN,
365
+ 37: curses.COLOR_WHITE,
366
+ 90: curses.COLOR_BLACK, # Bright Black (usually gray)
367
+ 91: curses.COLOR_RED, # Bright Red
368
+ 92: curses.COLOR_GREEN, # Bright Green
369
+ 93: curses.COLOR_YELLOW, # Bright Yellow
370
+ 94: curses.COLOR_BLUE, # Bright Blue
371
+ 95: curses.COLOR_MAGENTA, # Bright Magenta
372
+ 96: curses.COLOR_CYAN, # Bright Cyan
373
+ 97: curses.COLOR_WHITE # Bright White
374
+ }
345
375
  # ------------ Exportable Help Functions ----------------
346
376
  # check if command sshpass is available
347
377
  _binPaths = {}
@@ -998,9 +1028,6 @@ def __expand_hostnames(hosts) -> dict:
998
1028
  username, host = host.split('@',1)
999
1029
  username = username.strip()
1000
1030
  host = host.strip()
1001
- username, host = host.split('@',1)
1002
- username = username.strip()
1003
- host = host.strip()
1004
1031
  # first we check if the hostname is an range of IP addresses
1005
1032
  # This is done by checking if the hostname follows four fields of
1006
1033
  # "(((\d{1,3}|x|\*|\?)(-(\d{1,3}|x|\*|\?))?)|(\[(\d{1,3}|x|\*|\?)(-(\d{1,3}|x|\*|\?))?\]))"
@@ -1059,10 +1086,10 @@ def __handle_reading_stream(stream,target, host):
1059
1086
  if not keepLastLine:
1060
1087
  target.pop()
1061
1088
  host.output.pop()
1062
- host.printedLines -= 1
1063
1089
  current_line_str = current_line.decode('utf-8',errors='backslashreplace')
1064
1090
  target.append(current_line_str)
1065
1091
  host.output.append(current_line_str)
1092
+ host.lineNumToReprintSet.add(len(host.output)-1)
1066
1093
  host.lastUpdateTime = time.time()
1067
1094
  current_line = bytearray()
1068
1095
  lastLineCommited = True
@@ -1329,12 +1356,13 @@ def run_command(host, sem, timeout=60,passwds=None):
1329
1356
  # remove last line if it is a countdown
1330
1357
  if host.output and timeoutLineAppended and host.output[-1].strip().endswith('] seconds!') and host.output[-1].strip().startswith('Timeout in ['):
1331
1358
  host.output.pop()
1332
- host.printedLines -= 1
1333
1359
  host.output.append(timeoutLine)
1360
+ host.lineNumToReprintSet.add(len(host.output)-1)
1334
1361
  timeoutLineAppended = True
1335
1362
  elif host.output and timeoutLineAppended and host.output[-1].strip().endswith('] seconds!') and host.output[-1].strip().startswith('Timeout in ['):
1336
1363
  host.output.pop()
1337
- host.printedLines -= 1
1364
+ host.output.append('')
1365
+ host.lineNumToReprintSet.add(len(host.output)-1)
1338
1366
  timeoutLineAppended = False
1339
1367
  if _emo:
1340
1368
  host.stderr.append('Ctrl C detected, Emergency Stop!')
@@ -1424,6 +1452,302 @@ def start_run_on_hosts(hosts, timeout=60,password=None,max_connections=4 * os.cp
1424
1452
  return threads
1425
1453
 
1426
1454
  # ------------ Display Block ----------------
1455
+ def __approximate_color_8bit(color):
1456
+ """
1457
+ Approximate an 8-bit color (0-255) to the nearest curses color.
1458
+
1459
+ Args:
1460
+ color: 8-bit color code
1461
+
1462
+ Returns:
1463
+ Curses color code
1464
+ """
1465
+ if color < 8: # Standard and bright colors
1466
+ return ANSI_TO_CURSES_COLOR.get(color % 8 + 30, curses.COLOR_WHITE)
1467
+ elif 8 <= color < 16: # Bright colors
1468
+ return ANSI_TO_CURSES_COLOR.get(color % 8 + 90, curses.COLOR_WHITE)
1469
+ elif 16 <= color <= 231: # Color cube
1470
+ # Convert 216-color cube index to RGB
1471
+ color -= 16
1472
+ r = (color // 36) % 6 * 51
1473
+ g = (color // 6) % 6 * 51
1474
+ b = color % 6 * 51
1475
+ return __approximate_color_24bit(r, g, b) # Map to the closest curses color
1476
+ elif 232 <= color <= 255: # Grayscale
1477
+ gray = (color - 232) * 10 + 8
1478
+ return __approximate_color_24bit(gray, gray, gray)
1479
+ else:
1480
+ return curses.COLOR_WHITE # Fallback to white for unexpected values
1481
+
1482
+ def __approximate_color_24bit(r, g, b):
1483
+ """
1484
+ Approximate a 24-bit RGB color to the nearest curses color.
1485
+ Will initiate a curses color if curses.can_change_color() is True.
1486
+
1487
+ Globals:
1488
+ __curses_color_table: Dictionary of RGB color to curses color code
1489
+ __curses_current_color_index: Current index of the
1490
+
1491
+ Args:
1492
+ r: Red component (0-255)
1493
+ g: Green component (0-255)
1494
+ b: Blue component (0-255)
1495
+
1496
+ Returns:
1497
+ Curses color code
1498
+ """
1499
+ if curses.can_change_color():
1500
+ global __curses_color_table,__curses_current_color_index
1501
+ # Initiate a new color if it does not exist
1502
+ if (r, g, b) not in __curses_color_table:
1503
+ if __curses_current_color_index >= curses.COLORS:
1504
+ eprint("Warning: Maximum number of colors reached. Wrapping around.")
1505
+ __curses_current_color_index = 10
1506
+ curses.init_color(__curses_current_color_index, int(r/255*1000), int(g/255*1000), int(b/255*1000))
1507
+ __curses_color_table[(r, g, b)] = __curses_current_color_index
1508
+ __curses_current_color_index += 1
1509
+ return __curses_color_table[(r, g, b)]
1510
+ # Fallback to 8-bit color approximation
1511
+ colors = {
1512
+ curses.COLOR_BLACK: (0, 0, 0),
1513
+ curses.COLOR_RED: (255, 0, 0),
1514
+ curses.COLOR_GREEN: (0, 255, 0),
1515
+ curses.COLOR_YELLOW: (255, 255, 0),
1516
+ curses.COLOR_BLUE: (0, 0, 255),
1517
+ curses.COLOR_MAGENTA: (255, 0, 255),
1518
+ curses.COLOR_CYAN: (0, 255, 255),
1519
+ curses.COLOR_WHITE: (255, 255, 255),
1520
+ }
1521
+ best_match = curses.COLOR_WHITE
1522
+ min_distance = float("inf")
1523
+ for color, (cr, cg, cb) in colors.items():
1524
+ distance = math.sqrt((r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2)
1525
+ if distance < min_distance:
1526
+ min_distance = distance
1527
+ best_match = color
1528
+ return best_match
1529
+
1530
+ def __get_curses_color_pair(fg, bg):
1531
+ """
1532
+ Use curses color int values to create a curses color pair.
1533
+
1534
+ Globals:
1535
+ __curses_global_color_pairs: Dictionary of color pairs
1536
+ __curses_current_color_pair_index: Current index of the color pair
1537
+
1538
+ Args:
1539
+ fg: Foreground color code
1540
+ bg: Background color code
1541
+
1542
+ Returns:
1543
+ Curses color pair code
1544
+ """
1545
+ global __curses_global_color_pairs, __curses_current_color_pair_index
1546
+ if (fg, bg) not in __curses_global_color_pairs:
1547
+ if __curses_current_color_pair_index >= curses.COLOR_PAIRS:
1548
+ print("Warning: Maximum number of color pairs reached, wrapping around.")
1549
+ __curses_current_color_pair_index = 1
1550
+ curses.init_pair(__curses_current_color_pair_index, fg, bg)
1551
+ __curses_global_color_pairs[(fg, bg)] = __curses_current_color_pair_index
1552
+ __curses_current_color_pair_index += 1
1553
+ return curses.color_pair(__curses_global_color_pairs[(fg, bg)])
1554
+
1555
+ def __parse_ansi_escape_sequence_to_curses_attr(escape_code,color_pair_list = None):
1556
+ """
1557
+ Parse ANSI escape codes to extract foreground and background colors.
1558
+
1559
+ Args:
1560
+ escape_code: ANSI escape sequence for color
1561
+ color_pair_list: List of [foreground, background, color_pair] curses color pair values
1562
+
1563
+ Returns:
1564
+ Curses color pair / attribute code
1565
+ """
1566
+ if not escape_code:
1567
+ return 1
1568
+ if not color_pair_list:
1569
+ color_pair_list = [-1,-1,1]
1570
+ color_match = escape_code.lstrip("\x1b[").rstrip("m").split(";")
1571
+ color_match = [x if x else '0' for x in color_match] # Replace empty strings with '0' (reset)
1572
+ if color_match:
1573
+ processed_index = -1
1574
+ for i, param in enumerate(color_match):
1575
+ if processed_index >= i:
1576
+ # if the index has been processed, skip
1577
+ continue
1578
+ if param.isdigit():
1579
+ if int(param) == 0:
1580
+ color_pair_list[0] = -1
1581
+ color_pair_list[1] = -1
1582
+ color_pair_list[2] = 1
1583
+ elif int(param) == 38:
1584
+ if i + 1 >= len(color_match):
1585
+ # Invalid color code, skip
1586
+ continue
1587
+ if color_match[i + 1] == "5":
1588
+ # 8-bit foreground color
1589
+ if i + 2 >= len(color_match) or not color_match[i + 2].isdigit():
1590
+ # Invalid color code, skip
1591
+ processed_index = i + 1
1592
+ continue
1593
+ color_pair_list[0] = __approximate_color_8bit(int(color_match[i + 2]))
1594
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1595
+ processed_index = i + 2
1596
+ elif color_match[i + 1] == "2":
1597
+ # 24-bit foreground color
1598
+ if i + 4 >= len(color_match) or not all(x.isdigit() for x in color_match[i + 2:i + 5]):
1599
+ # Invalid color code, skip
1600
+ processed_index = i + 1
1601
+ continue
1602
+ color_pair_list[0] = __approximate_color_24bit(int(color_match[i + 2]), int(color_match[i + 3]), int(color_match[i + 4]))
1603
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1604
+ processed_index = i + 4
1605
+ elif int(param) == 48:
1606
+ if i + 1 >= len(color_match):
1607
+ # Invalid color code, skip
1608
+ continue
1609
+ if color_match[i + 1] == "5":
1610
+ # 8-bit background color
1611
+ if i + 2 >= len(color_match) or not color_match[i + 2].isdigit():
1612
+ # Invalid color code, skip
1613
+ processed_index = i + 1
1614
+ continue
1615
+ color_pair_list[1] = __approximate_color_8bit(int(color_match[i + 2]))
1616
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1617
+ processed_index = i + 2
1618
+ elif color_match[i + 1] == "2":
1619
+ # 24-bit background color
1620
+ if i + 4 >= len(color_match) or not all(x.isdigit() for x in color_match[i + 2:i + 5]):
1621
+ # Invalid color code, skip
1622
+ processed_index = i + 1
1623
+ continue
1624
+ color_pair_list[1] = __approximate_color_24bit(int(color_match[i + 2]), int(color_match[i + 3]), int(color_match[i + 4]))
1625
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1626
+ processed_index = i + 4
1627
+ elif 30 <= int(param) <= 37 or 90 <= int(param) <= 97:
1628
+ # 4-bit foreground color
1629
+ color_pair_list[0] = ANSI_TO_CURSES_COLOR.get(int(param), curses.COLOR_WHITE)
1630
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1631
+ elif 40 <= int(param) <= 47 or 100 <= int(param) <= 107:
1632
+ # 4-bit background color
1633
+ color_pair_list[1] = ANSI_TO_CURSES_COLOR.get(int(param)-10, curses.COLOR_BLACK)
1634
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1635
+ elif int(param) == 1:
1636
+ color_pair_list[2] = color_pair_list[2] | curses.A_BOLD
1637
+ elif int(param) == 2:
1638
+ color_pair_list[2] = color_pair_list[2] | curses.A_DIM
1639
+ elif int(param) == 4:
1640
+ color_pair_list[2] = color_pair_list[2] | curses.A_UNDERLINE
1641
+ elif int(param) == 5:
1642
+ color_pair_list[2] = color_pair_list[2] | curses.A_BLINK
1643
+ elif int(param) == 7:
1644
+ color_pair_list[2] = color_pair_list[2] | curses.A_REVERSE
1645
+ elif int(param) == 8:
1646
+ color_pair_list[2] = color_pair_list[2] | curses.A_INVIS
1647
+ elif int(param) == 21:
1648
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_BOLD
1649
+ elif int(param) == 22:
1650
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_DIM
1651
+ elif int(param) == 24:
1652
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_UNDERLINE
1653
+ elif int(param) == 25:
1654
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_BLINK
1655
+ elif int(param) == 27:
1656
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_REVERSE
1657
+ elif int(param) == 28:
1658
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_INVIS
1659
+ elif int(param) == 39:
1660
+ color_pair_list[0] = -1
1661
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1662
+ elif int(param) == 49:
1663
+ color_pair_list[1] = -1
1664
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
1665
+ else:
1666
+ color_pair_list[0] = -1
1667
+ color_pair_list[1] = -1
1668
+ color_pair_list[2] = 1
1669
+ return color_pair_list[2]
1670
+
1671
+ def _curses_add_string_to_window(window, line = '', y = 0, x = 0, number_of_char_to_write = -1, color_pair_list = [-1,-1,1],fill_char=' ',parse_ansi_colors = True,centered = False,lead_str = '', trail_str = '',box_ansi_color = None, keep_top_n_lines = 0):
1672
+ """
1673
+ Add a string to a curses window with / without ANSI color escape sequences translated to curses color pairs.
1674
+
1675
+ Args:
1676
+ window: curses window object
1677
+ line: The line to add
1678
+ y: Line position in the window. Use -1 to scroll the window up 1 line and add the line at the bottom
1679
+ x: Column position in the window
1680
+ number_of_char_to_write: Number of characters to write. -1 for all remaining space in line, 0 for no characters, and a positive integer for a specific number of characters.
1681
+ color_pair_list: List of [foreground, background, color_pair] curses color pair values
1682
+ fill_char: Character to fill the remaining space in the line
1683
+ parse_ansi_colors: Parse ASCII color codes
1684
+ centered: Center the text in the window
1685
+ lead_str: Leading string to add to the line
1686
+ trail_str: Trailing string to add to the line
1687
+ box_ansi_color: ANSI color escape sequence for the box color
1688
+ keep_top_n_lines: Number of lines to keep at the top of the window
1689
+
1690
+ Returns:
1691
+ None
1692
+ """
1693
+ if window.getmaxyx()[0] == 0 or window.getmaxyx()[1] == 0 or x >= window.getmaxyx()[1]:
1694
+ return
1695
+ if x < 0:
1696
+ x = window.getmaxyx()[1] + x
1697
+ if number_of_char_to_write == -1:
1698
+ numChar = window.getmaxyx()[1] - x -1
1699
+ elif number_of_char_to_write == 0:
1700
+ return
1701
+ elif number_of_char_to_write + x > window.getmaxyx()[1]:
1702
+ numChar = window.getmaxyx()[1] - x -1
1703
+ else:
1704
+ numChar = number_of_char_to_write
1705
+ if numChar < 0:
1706
+ return
1707
+ if y < 0 or y >= window.getmaxyx()[0]:
1708
+ if keep_top_n_lines > window.getmaxyx()[0] -1:
1709
+ keep_top_n_lines = window.getmaxyx()[0] -1
1710
+ if keep_top_n_lines < 0:
1711
+ keep_top_n_lines = 0
1712
+ window.move(keep_top_n_lines,0)
1713
+ window.deleteln()
1714
+ y = window.getmaxyx()[0] - 1
1715
+ line = line.replace('\n', ' ').replace('\r', ' ')
1716
+ if parse_ansi_colors:
1717
+ segments = re.split(r"(\x1b\[[\d;]*m)", line) # Split line by ANSI escape codes
1718
+ else:
1719
+ segments = [line]
1720
+ charsWritten = 0
1721
+ boxAttr = __parse_ansi_escape_sequence_to_curses_attr(box_ansi_color)
1722
+ # first add the lead_str
1723
+ window.addnstr(y, x, lead_str, numChar, boxAttr)
1724
+ charsWritten = min(len(lead_str), numChar)
1725
+ # process centering
1726
+ if centered:
1727
+ fill_length = numChar - len(lead_str) - len(trail_str) - sum([len(segment) for segment in segments if not segment.startswith("\x1b[")])
1728
+ window.addnstr(y, x + charsWritten, fill_char * (fill_length // 2), numChar - charsWritten, boxAttr)
1729
+ charsWritten += min(fill_length // 2, numChar - charsWritten)
1730
+ # add the segments
1731
+ for segment in segments:
1732
+ if not segment:
1733
+ continue
1734
+ if parse_ansi_colors and segment.startswith("\x1b["):
1735
+ # Parse ANSI escape sequence
1736
+ newAttr = __parse_ansi_escape_sequence_to_curses_attr(segment,color_pair_list)
1737
+ else:
1738
+ # Add text with current color
1739
+ if charsWritten < numChar:
1740
+ window.addnstr(y, x + charsWritten, segment, numChar - charsWritten, color_pair_list[2])
1741
+ charsWritten += min(len(segment), numChar - charsWritten)
1742
+ # if we have finished printing segments but we still have space, we will fill it with fill_char
1743
+ if charsWritten + len(trail_str) < numChar:
1744
+ fillStr = fill_char * (numChar - charsWritten - len(trail_str))
1745
+ #fillStr = f'{color_pair_list}'
1746
+ window.addnstr(y, x + charsWritten, fillStr + trail_str, numChar - charsWritten, boxAttr)
1747
+ charsWritten += numChar - charsWritten
1748
+ else:
1749
+ window.addnstr(y, x + charsWritten, trail_str, numChar - charsWritten, boxAttr)
1750
+
1427
1751
  def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None):
1428
1752
  '''
1429
1753
  Generate a list for the hosts to be displayed on the screen. This is used to display as much relevant information as possible.
@@ -1458,6 +1782,7 @@ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None):
1458
1782
 
1459
1783
  def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window=DEFAULT_SINGLE_WINDOW, config_reason= 'New Configuration'):
1460
1784
  try:
1785
+ box_ansi_color = None
1461
1786
  org_dim = stdscr.getmaxyx()
1462
1787
  new_configured = True
1463
1788
  # To do this, first we need to know the size of the terminal
@@ -1510,7 +1835,6 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1510
1835
 
1511
1836
  old_stat = ''
1512
1837
  old_bottom_stat = ''
1513
- old_cursor_position = -1
1514
1838
  # we refresh the screen every 0.1 seconds
1515
1839
  last_refresh_time = time.perf_counter()
1516
1840
  stdscr.clear()
@@ -1519,6 +1843,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1519
1843
  stdscr.nodelay(True)
1520
1844
  # we generate a stats window at the top of the screen
1521
1845
  stat_window = curses.newwin(1, max_x, 0, 0)
1846
+ stat_window.leaveok(True)
1522
1847
  # We create a window for each host
1523
1848
  host_windows = []
1524
1849
  for i, host in enumerate(hosts_to_display):
@@ -1529,13 +1854,18 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1529
1854
  #print(f"Creating a window at {y},{x}")
1530
1855
  # We create the window
1531
1856
  host_window = curses.newwin(host_window_height, host_window_width, y, x)
1857
+ host_window.idlok(True)
1858
+ host_window.scrollok(True)
1859
+ host_window.leaveok(True)
1532
1860
  host_windows.append(host_window)
1533
1861
  # If there is space left, we will draw the bottom border
1534
1862
  bottom_border = None
1535
1863
  if y + host_window_height < org_dim[0]:
1536
1864
  bottom_border = curses.newwin(1, max_x, y + host_window_height, 0)
1865
+ bottom_border.leaveok(True)
1537
1866
  #bottom_border.clear()
1538
- bottom_border.addstr(0, 0, '-' * (max_x - 1))
1867
+ #bottom_border.addnstr(0, 0, '-' * (max_x - 1), max_x - 1)
1868
+ _curses_add_string_to_window(window=bottom_border, y=0, line='-' * (max_x - 1),fill_char='-',box_ansi_color=box_ansi_color)
1539
1869
  bottom_border.refresh()
1540
1870
  while host_stats['running'] > 0 or host_stats['waiting'] > 0:
1541
1871
  # Check for keypress
@@ -1626,38 +1956,40 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1626
1956
  return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize detected')
1627
1957
  # We generate the aggregated stats if user did not input anything
1628
1958
  if not __keyPressesIn[lineToDisplay]:
1629
- stats = '┍'+ f" Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} ww: {min_char_len} wh:{min_line_len} "[:max_x - 2].center(max_x - 2, "━")
1959
+ #stats = '┍'+ f" Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} ww: {min_char_len} wh:{min_line_len} "[:max_x - 2].center(max_x - 2, "━")
1960
+ stats = f"Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} ww: {min_char_len} wh:{min_line_len} "
1630
1961
  else:
1631
1962
  # we use the stat bar to display the key presses
1632
1963
  encodedLine = ''.join(__keyPressesIn[lineToDisplay]).encode().decode().strip('\n') + ' '
1633
- # # add the flashing indicator at the curse position
1634
- # if time.perf_counter() % 1 > 0.5:
1635
- # encodedLine = encodedLine[:curserPosition] + '█' + encodedLine[curserPosition:]
1964
+ #stats = '┍'+ f"Send CMD: {encodedLine}"[:max_x - 2].center(max_x - 2, "━")
1965
+ stats = f"Send CMD: {encodedLine}"
1966
+ # format the stats line with chracter at curser position inverted using ansi escape sequence
1967
+ stats = f'{stats[:curserPosition]}\x1b[7m{stats[curserPosition]}\x1b[0m{stats[curserPosition + 1:]}'
1968
+ if stats != old_stat :
1969
+ old_stat = stats
1970
+ # calculate the real curser position in stats as we centered the stats
1971
+ # if 'Send CMD: ' in stats:
1972
+ # curserPositionStats = min(min(curserPosition,len(encodedLine) -1) + stats.find('Send CMD: ')+len('Send CMD: '), max_x -2)
1636
1973
  # else:
1637
- # encodedLine = encodedLine[:curserPosition] + ' ' + encodedLine[curserPosition:]
1638
- stats = '┍'+ f"Send CMD: {encodedLine}"[:max_x - 2].center(max_x - 2, "━")
1974
+ # curserPositionStats = max_x -2
1975
+ #stat_window.clear()
1976
+ #stat_window.addstr(0, 0, stats)
1977
+ # add the line with curser that inverses the color at the curser position
1978
+ # stat_window.addstr(0, 0, stats[:curserPositionStats])
1979
+ # stat_window.addch(0,curserPositionStats, stats[curserPositionStats], curses.A_REVERSE)
1980
+ # stat_window.addnstr(0, curserPositionStats + 1, stats[curserPositionStats + 1:], max_x - 1 - curserPositionStats)
1981
+ # stat_window.refresh()
1982
+ _curses_add_string_to_window(window=stat_window, y=0, line=stats, color_pair_list=[-1, -1, 1],centered=True,fill_char='━',lead_str='┍',box_ansi_color=box_ansi_color)
1639
1983
  if bottom_border:
1640
- bottom_stats = '└'+ f" Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} "[:max_x - 2].center(max_x - 2, "─")
1984
+ #target_length = max_x - 2 + len('\x1b[33m\x1b[0m\x1b[31m\x1b[0m\x1b[32m\x1b[0m')
1985
+ #bottom_stats = '└'+ f" Total: {len(hosts)} Running: \x1b[33m{host_stats['running']}\x1b[0m Failed: \x1b[31m{host_stats['failed']}\x1b[0m Finished: \x1b[32m{host_stats['finished']}\x1b[0m Waiting: {host_stats['waiting']} "[:target_length].center(target_length, "─")
1986
+ bottom_stats = f" Total: {len(hosts)} Running: \x1b[33m{host_stats['running']}\x1b[0m Failed: \x1b[31m{host_stats['failed']}\x1b[0m Finished: \x1b[32m{host_stats['finished']}\x1b[0m Waiting: {host_stats['waiting']} "
1641
1987
  if bottom_stats != old_bottom_stat:
1642
1988
  old_bottom_stat = bottom_stats
1643
1989
  #bottom_border.clear()
1644
- bottom_border.addstr(0, 0, bottom_stats)
1990
+ #bottom_border.addnstr(0, 0, bottom_stats, max_x - 1)
1991
+ _curses_add_string_to_window(window=bottom_border, y=0, line=bottom_stats,fill_char='─',centered=True,lead_str='└',box_ansi_color=box_ansi_color)
1645
1992
  bottom_border.refresh()
1646
- if stats != old_stat or curserPosition != old_cursor_position:
1647
- old_stat = stats
1648
- old_cursor_position = curserPosition
1649
- # calculate the real curser position in stats as we centered the stats
1650
- if 'Send CMD: ' in stats:
1651
- curserPositionStats = min(min(curserPosition,len(encodedLine) -1) + stats.find('Send CMD: ')+len('Send CMD: '), max_x -2)
1652
- else:
1653
- curserPositionStats = max_x -2
1654
- #stat_window.clear()
1655
- #stat_window.addstr(0, 0, stats)
1656
- # add the line with curser that inverses the color at the curser position
1657
- stat_window.addstr(0, 0, stats[:curserPositionStats], curses.color_pair(1))
1658
- stat_window.addstr(0, curserPositionStats, stats[curserPositionStats], curses.color_pair(2))
1659
- stat_window.addstr(0, curserPositionStats + 1, stats[curserPositionStats + 1:], curses.color_pair(1))
1660
- stat_window.refresh()
1661
1993
  # set the maximum refresh rate to 100 Hz
1662
1994
  if time.perf_counter() - last_refresh_time < 0.01:
1663
1995
  time.sleep(max(0,0.01 - time.perf_counter() + last_refresh_time))
@@ -1669,18 +2001,27 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1669
2001
  try:
1670
2002
  #host_window.clear()
1671
2003
  # we will try to center the name of the host with ┼ at the beginning and end and ─ in between
1672
- linePrintOut = f'┼{(host.name+":["+host.command+"]")[:host_window_width - 2].center(host_window_width - 1, "─")}'.replace('\n', ' ').replace('\r', ' ').strip()
1673
- host_window.addstr(0, 0, linePrintOut)
2004
+ #linePrintOut = f'┼{(host.name+":["+host.command+"]")[:host_window_width - 2].center(host_window_width - 1, "─")}'.replace('\n', ' ').replace('\r', ' ').strip()
2005
+ #linePrintOut = f'┼{(host.name+":["+host.command+"]")[:host_window_width - 2].center(host_window_width - 1, "─")}'.replace('\n', ' ').replace('\r', ' ').strip()
2006
+ #host_window.addnstr(0, 0, linePrintOut, host_window_width - 1)
2007
+ linePrintOut = f'{host.name}:[{host.command}]'.replace('\n', ' ').replace('\r', ' ').strip()
2008
+ _curses_add_string_to_window(window=host_window, y=0, line=linePrintOut, color_pair_list=[-1, -1, 1],centered=True,fill_char='─',lead_str='┼',box_ansi_color=box_ansi_color)
2009
+ #_add_line_with_ansi_colors(window=host_window, y=0, x=0, line=linePrintOut, n=host_window_width - 1, color_pair_list = host.current_color_pair)
1674
2010
  # we will display the latest outputs of the host as much as we can
1675
- for i, line in enumerate(host.output[-(host_window_height - 1):]):
2011
+ #for i, line in enumerate(host.output[-(host_window_height - 1):]):
1676
2012
  # print(f"Printng a line at {i + 1} with length of {len('│'+line[:host_window_width - 1])}")
1677
2013
  # time.sleep(10)
1678
- linePrintOut = ('│'+line[:host_window_width - 2].replace('\n', ' ').replace('\r', ' ')).strip().ljust(host_window_width - 1, ' ')
1679
- host_window.addstr(i + 1, 0, linePrintOut)
2014
+ #linePrintOut = ('│'+line[:host_window_width - 2].replace('\n', ' ').replace('\r', ' ')).strip()
2015
+ #host_window.addnstr(i + 1, 0, linePrintOut, host_window_width - 1)
2016
+ #_curses_add_string_to_window(window=host_window, y=i + 1, line=line, color_pair_list=host.current_color_pair,lead_str='│')
1680
2017
  # we draw the rest of the available lines
2018
+ # for i in range(len(host.output), host_window_height - 1):
2019
+ # # print(f"Printng a line at {i + 1} with length of {len('│')}")
2020
+ # host_window.addnstr(i + 1, 0, '│'.ljust(host_window_width - 1, ' '), host_window_width - 1)
2021
+ for i in range(host.printedLines, len(host.output)):
2022
+ _curses_add_string_to_window(window=host_window, y=i + 1, line=host.output[i], color_pair_list=host.current_color_pair,lead_str='│',keep_top_n_lines=1,box_ansi_color=box_ansi_color)
1681
2023
  for i in range(len(host.output), host_window_height - 1):
1682
- # print(f"Printng a line at {i + 1} with length of {len('│')}")
1683
- host_window.addstr(i + 1, 0, '│'.ljust(host_window_width - 1, ' '))
2024
+ _curses_add_string_to_window(window=host_window, y=i + 1,lead_str='│',keep_top_n_lines=1,box_ansi_color=box_ansi_color)
1684
2025
  host.printedLines = len(host.output)
1685
2026
  host_window.refresh()
1686
2027
  except Exception as e:
@@ -1689,6 +2030,24 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1689
2030
  # print(traceback.format_exc().strip())
1690
2031
  if org_dim != stdscr.getmaxyx():
1691
2032
  return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize detected')
2033
+ if host.lineNumToReprintSet:
2034
+ # visible range is from host.printedLines - host_window_height + 1 to host.printedLines
2035
+ visibleLowerBound = host.printedLines - host_window_height + 1
2036
+ for lineNumToReprint in host.lineNumToReprintSet:
2037
+ # if the line is visible, we will reprint it
2038
+ if visibleLowerBound <= lineNumToReprint <= host.printedLines:
2039
+ if visibleLowerBound <= 0:
2040
+ # this means all lines are visible
2041
+ linePos = lineNumToReprint
2042
+ else:
2043
+ # calculate the position of the line to reprint
2044
+ linePos = lineNumToReprint - visibleLowerBound
2045
+ # Note: color can be incorrect if repainting an old line with new colors already initialized,
2046
+ # Thus we will not use any presistent color pair for old lines
2047
+ cpl = host.current_color_pair if lineNumToReprint == host.printedLines else [-1,-1,1]
2048
+ _curses_add_string_to_window(window=host_window, y=linePos + 1, line=host.output[lineNumToReprint], color_pair_list=cpl,lead_str='│',keep_top_n_lines=1,box_ansi_color=box_ansi_color)
2049
+ host.lineNumToReprintSet = set()
2050
+ host_window.refresh()
1692
2051
  new_configured = False
1693
2052
  last_refresh_time = time.perf_counter()
1694
2053
  except Exception as e:
@@ -1710,33 +2069,39 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
1710
2069
  '''
1711
2070
  # We create all the windows we need
1712
2071
  # We initialize the color pair
1713
- curses.start_color()
1714
2072
  curses.curs_set(0)
1715
- curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK)
1716
- curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE)
1717
- curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK)
1718
- curses.init_pair(4, curses.COLOR_GREEN, curses.COLOR_BLACK)
1719
- curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLACK)
1720
- curses.init_pair(6, curses.COLOR_BLUE, curses.COLOR_BLACK)
1721
- curses.init_pair(7, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
1722
- curses.init_pair(8, curses.COLOR_CYAN, curses.COLOR_BLACK)
1723
- curses.init_pair(9, curses.COLOR_WHITE, curses.COLOR_RED)
1724
- curses.init_pair(10, curses.COLOR_WHITE, curses.COLOR_GREEN)
1725
- curses.init_pair(11, curses.COLOR_WHITE, curses.COLOR_YELLOW)
1726
- curses.init_pair(12, curses.COLOR_WHITE, curses.COLOR_BLUE)
1727
- curses.init_pair(13, curses.COLOR_WHITE, curses.COLOR_MAGENTA)
1728
- curses.init_pair(14, curses.COLOR_WHITE, curses.COLOR_CYAN)
1729
- curses.init_pair(15, curses.COLOR_BLACK, curses.COLOR_RED)
1730
- curses.init_pair(16, curses.COLOR_BLACK, curses.COLOR_GREEN)
1731
- curses.init_pair(17, curses.COLOR_BLACK, curses.COLOR_YELLOW)
1732
- curses.init_pair(18, curses.COLOR_BLACK, curses.COLOR_BLUE)
1733
- curses.init_pair(19, curses.COLOR_BLACK, curses.COLOR_MAGENTA)
1734
- curses.init_pair(20, curses.COLOR_BLACK, curses.COLOR_CYAN)
1735
-
2073
+ curses.start_color()
2074
+ curses.use_default_colors()
2075
+ curses.init_pair(1, -1, -1)
1736
2076
  # do not generate display if the output window have a size of zero
1737
2077
  if stdscr.getmaxyx()[0] < 2 or stdscr.getmaxyx()[1] < 2:
1738
2078
  return
1739
-
2079
+ stdscr.idlok(True)
2080
+ stdscr.scrollok(True)
2081
+ stdscr.leaveok(True)
2082
+ # generate some debug information before display initialization
2083
+ try:
2084
+ stdscr.clear()
2085
+ _curses_add_string_to_window(window=stdscr, y=0, line='Initializing display...', n=stdscr.getmaxyx()[1] - 1)
2086
+ # print the size
2087
+ _curses_add_string_to_window(window=stdscr, y=1, line=f"Terminal size: {stdscr.getmaxyx()}", n=stdscr.getmaxyx()[1] - 1)
2088
+ # print the number of hosts
2089
+ _curses_add_string_to_window(window=stdscr, y=2, line=f"Number of hosts: {len(hosts)}", n=stdscr.getmaxyx()[1] - 1)
2090
+ # print the number of threads
2091
+ _curses_add_string_to_window(window=stdscr, y=3, line=f"Number of threads: {len(threads)}", n=stdscr.getmaxyx()[1] - 1)
2092
+ # print the minimum character length
2093
+ _curses_add_string_to_window(window=stdscr, y=4, line=f"Minimum character length: {min_char_len}", n=stdscr.getmaxyx()[1] - 1)
2094
+ # print the minimum line length
2095
+ _curses_add_string_to_window(window=stdscr, y=5, line=f"Minimum line length: {min_line_len}", n=stdscr.getmaxyx()[1] - 1)
2096
+ # print the single window mode
2097
+ _curses_add_string_to_window(window=stdscr, y=6, line=f"Single window mode: {single_window}", n=stdscr.getmaxyx()[1] - 1)
2098
+ # print COLORS and COLOR_PAIRS count
2099
+ _curses_add_string_to_window(window=stdscr, y=7, line=f"len(COLORS): {curses.COLORS} len(COLOR_PAIRS): {curses.COLOR_PAIRS}", n=stdscr.getmaxyx()[1] - 1)
2100
+ # print if can change color
2101
+ _curses_add_string_to_window(window=stdscr, y=8, line=f"Real color capability: {curses.can_change_color()}", n=stdscr.getmaxyx()[1] - 1)
2102
+ stdscr.refresh()
2103
+ except:
2104
+ pass
1740
2105
  params = (-1,0 , min_char_len, min_line_len, single_window,'new config')
1741
2106
  while params:
1742
2107
  params = __generate_display(stdscr, hosts, *params)
@@ -2,7 +2,7 @@ from setuptools import setup
2
2
 
3
3
  setup(
4
4
  name='multiSSH3',
5
- version='5.27',
5
+ version='5.33',
6
6
  description='Run commands on multiple hosts via SSH',
7
7
  long_description=open('README.md').read(),
8
8
  long_description_content_type='text/markdown',
File without changes
File without changes
File without changes