multiSSH3 5.45__tar.gz → 5.47__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.2
2
2
  Name: multiSSH3
3
- Version: 5.45
3
+ Version: 5.47
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.2
2
2
  Name: multiSSH3
3
- Version: 5.45
3
+ Version: 5.47
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
@@ -6,4 +6,9 @@ multiSSH3.egg-info/SOURCES.txt
6
6
  multiSSH3.egg-info/dependency_links.txt
7
7
  multiSSH3.egg-info/entry_points.txt
8
8
  multiSSH3.egg-info/requires.txt
9
- multiSSH3.egg-info/top_level.txt
9
+ multiSSH3.egg-info/top_level.txt
10
+ test/test.py
11
+ test/testCurses.py
12
+ test/testCursesOld.py
13
+ test/testPerfCompact.py
14
+ test/testPerfExpand.py
@@ -46,8 +46,9 @@ except AttributeError:
46
46
  # If neither is available, use a dummy decorator
47
47
  def cache_decorator(func):
48
48
  return func
49
- version = '5.45'
49
+ version = '5.47'
50
50
  VERSION = version
51
+ COMMIT_DATE = '2025-01-30'
51
52
 
52
53
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
53
54
  '~/multiSSH3.config.json',
@@ -1718,13 +1719,16 @@ def _curses_add_string_to_window(window, line = '', y = 0, x = 0, number_of_char
1718
1719
  charsWritten = 0
1719
1720
  boxAttr = __parse_ansi_escape_sequence_to_curses_attr(box_ansi_color)
1720
1721
  # first add the lead_str
1721
- window.addnstr(y, x, lead_str, numChar, boxAttr)
1722
- charsWritten = min(len(lead_str), numChar)
1722
+ if len(lead_str) > 0:
1723
+ window.addnstr(y, x, lead_str, numChar, boxAttr)
1724
+ charsWritten = min(len(lead_str), numChar)
1723
1725
  # process centering
1724
1726
  if centered:
1725
1727
  fill_length = numChar - len(lead_str) - len(trail_str) - sum([len(segment) for segment in segments if not segment.startswith("\x1b[")])
1726
- window.addnstr(y, x + charsWritten, fill_char * (fill_length // 2 // len(fill_char)), numChar - charsWritten, boxAttr)
1727
- charsWritten += min(len(fill_char * (fill_length // 2)), numChar - charsWritten)
1728
+ leading_fill_length = fill_length // 2
1729
+ if leading_fill_length > 0:
1730
+ window.addnstr(y, x + charsWritten, fill_char * (leading_fill_length // len(fill_char) + 1), leading_fill_length, boxAttr)
1731
+ charsWritten += leading_fill_length
1728
1732
  # add the segments
1729
1733
  for segment in segments:
1730
1734
  if not segment:
@@ -1734,17 +1738,17 @@ def _curses_add_string_to_window(window, line = '', y = 0, x = 0, number_of_char
1734
1738
  newAttr = __parse_ansi_escape_sequence_to_curses_attr(segment,color_pair_list)
1735
1739
  else:
1736
1740
  # Add text with current color
1737
- if charsWritten < numChar:
1741
+ if charsWritten < numChar and len(segment) > 0:
1738
1742
  window.addnstr(y, x + charsWritten, segment, numChar - charsWritten, color_pair_list[2])
1739
1743
  charsWritten += min(len(segment), numChar - charsWritten)
1740
1744
  # if we have finished printing segments but we still have space, we will fill it with fill_char
1741
- if charsWritten + len(trail_str) < numChar:
1742
- fillStr = fill_char * ((numChar - charsWritten - len(trail_str))//len(fill_char))
1743
- #fillStr = f'{color_pair_list}'
1744
- window.addnstr(y, x + charsWritten, fillStr + trail_str, numChar - charsWritten, boxAttr)
1745
- charsWritten += numChar - charsWritten
1746
- else:
1745
+ trail_fill_length = numChar - charsWritten - len(trail_str)
1746
+ if trail_fill_length > 0:
1747
+ window.addnstr(y, x + charsWritten,fill_char * (trail_fill_length // len(fill_char) + 1), trail_fill_length, boxAttr)
1748
+ charsWritten += trail_fill_length
1749
+ if len(trail_str) > 0 and charsWritten < numChar:
1747
1750
  window.addnstr(y, x + charsWritten, trail_str, numChar - charsWritten, boxAttr)
1751
+ charsWritten += min(len(trail_str), numChar - charsWritten)
1748
1752
 
1749
1753
  def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None):
1750
1754
  '''
@@ -1875,7 +1879,11 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1875
1879
  # with open('keylog.txt','a') as f:
1876
1880
  # f.write(str(key)+'\n')
1877
1881
  if key == 410: # 410 is the key code for resize
1878
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize requested')
1882
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize requested')
1883
+ # if the user pressed ctrl + d and the last line is empty, we will exit by adding 'exit\n' to the last line
1884
+ elif key == 4 and not __keyPressesIn[-1]:
1885
+ __keyPressesIn[-1].extend('exit\n')
1886
+ __keyPressesIn.append([])
1879
1887
  elif key == 95 and not __keyPressesIn[-1]: # 95 is the key code for _
1880
1888
  # if last line is empty, we will reconfigure the wh to be smaller
1881
1889
  if min_line_len != 1:
@@ -2767,7 +2775,7 @@ def main():
2767
2775
  parser.add_argument('--store_config_file',type = str,nargs='?',help=f'Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
2768
2776
  parser.add_argument('--debug', action='store_true', help='Print debug information')
2769
2777
  parser.add_argument('-ci','--copy_id', action='store_true', help='Copy the ssh id to the hosts')
2770
- parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
2778
+ parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
2771
2779
 
2772
2780
  # parser.add_argument('-u', '--user', metavar='user', type=str, nargs=1,
2773
2781
  # help='the user to use to connect to the hosts')
@@ -0,0 +1,316 @@
1
+ import re
2
+ import string
3
+
4
+ def validate_expand_hostname(hostname):
5
+ # Assuming this function is already implemented.
6
+ return [hostname]
7
+
8
+
9
+ def old_expand_hostname(text,validate=True):
10
+ '''
11
+ Expand the hostname range in the text.
12
+ Will search the string for a range ( [] encloused and non enclosed number ranges).
13
+ Will expand the range, validate them using validate_expand_hostname and return a list of expanded hostnames
14
+
15
+ Args:
16
+ text (str): The text to be expanded
17
+ validate (bool, optional): Whether to validate the hostname. Defaults to True.
18
+
19
+ Returns:
20
+ set: A set of expanded hostnames
21
+ '''
22
+ expandinghosts = [text]
23
+ expandedhosts = set()
24
+ # all valid alphanumeric characters
25
+ alphanumeric = string.digits + string.ascii_letters
26
+ while len(expandinghosts) > 0:
27
+ hostname = expandinghosts.pop()
28
+ match = re.search(r'\[(.*?-.*?)\]', hostname)
29
+ if not match:
30
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
31
+ continue
32
+ try:
33
+ range_start, range_end = match.group(1).split('-')
34
+ except ValueError:
35
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
36
+ continue
37
+ range_start = range_start.strip()
38
+ range_end = range_end.strip()
39
+ if not range_end:
40
+ if range_start.isdigit():
41
+ range_end = '9'
42
+ elif range_start.isalpha() and range_start.islower():
43
+ range_end = 'z'
44
+ elif range_start.isalpha() and range_start.isupper():
45
+ range_end = 'Z'
46
+ else:
47
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
48
+ continue
49
+ if not range_start:
50
+ if range_end.isdigit():
51
+ range_start = '0'
52
+ elif range_end.isalpha() and range_end.islower():
53
+ range_start = 'a'
54
+ elif range_end.isalpha() and range_end.isupper():
55
+ range_start = 'A'
56
+ else:
57
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
58
+ continue
59
+ if range_start.isdigit() and range_end.isdigit():
60
+ padding_length = min(len(range_start), len(range_end))
61
+ format_str = "{:0" + str(padding_length) + "d}"
62
+ for i in range(int(range_start), int(range_end) + 1):
63
+ formatted_i = format_str.format(i)
64
+ if '[' in hostname:
65
+ expandinghosts.append(hostname.replace(match.group(0), formatted_i, 1))
66
+ else:
67
+ expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), formatted_i, 1)) if validate else [hostname])
68
+ else:
69
+ if all(c in string.hexdigits for c in range_start + range_end):
70
+ for i in range(int(range_start, 16), int(range_end, 16)+1):
71
+ if '[' in hostname:
72
+ expandinghosts.append(hostname.replace(match.group(0), format(i, 'x'), 1))
73
+ else:
74
+ expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), format(i, 'x'), 1)) if validate else [hostname])
75
+ else:
76
+ try:
77
+ start_index = alphanumeric.index(range_start)
78
+ end_index = alphanumeric.index(range_end)
79
+ for i in range(start_index, end_index + 1):
80
+ if '[' in hostname:
81
+ expandinghosts.append(hostname.replace(match.group(0), alphanumeric[i], 1))
82
+ else:
83
+ expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), alphanumeric[i], 1)) if validate else [hostname])
84
+ except ValueError:
85
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
86
+ return expandedhosts
87
+
88
+ def expand_hostname(text, validate=True):
89
+ '''
90
+ Expand the hostname range in the text.
91
+ Will search the string for a range ( [] enclosed and non-enclosed number ranges).
92
+ Will expand the range, validate them using validate_expand_hostname and return a list of expanded hostnames
93
+
94
+ Args:
95
+ text (str): The text to be expanded
96
+ validate (bool, optional): Whether to validate the hostname. Defaults to True.
97
+
98
+ Returns:
99
+ set: A set of expanded hostnames
100
+ '''
101
+ expandinghosts = [text]
102
+ expandedhosts = set()
103
+ # all valid alphanumeric characters
104
+ alphanumeric = string.digits + string.ascii_letters
105
+ while len(expandinghosts) > 0:
106
+ hostname = expandinghosts.pop()
107
+ match = re.search(r'\[(.*?)]', hostname)
108
+ if not match:
109
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
110
+ continue
111
+ group = match.group(1)
112
+ parts = group.split(',')
113
+ for part in parts:
114
+ part = part.strip()
115
+ if '-' in part:
116
+ try:
117
+ range_start,_, range_end = part.partition('-')
118
+ except ValueError:
119
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
120
+ continue
121
+ range_start = range_start.strip()
122
+ range_end = range_end.strip()
123
+ if range_start.isdigit() and range_end.isdigit():
124
+ padding_length = min(len(range_start), len(range_end))
125
+ format_str = "{:0" + str(padding_length) + "d}"
126
+ for i in range(int(range_start), int(range_end) + 1):
127
+ formatted_i = format_str.format(i)
128
+ expandinghosts.append(hostname.replace(match.group(0), formatted_i, 1))
129
+ elif all(c in string.hexdigits for c in range_start + range_end):
130
+ for i in range(int(range_start, 16), int(range_end, 16) + 1):
131
+ expandinghosts.append(hostname.replace(match.group(0), format(i, 'x'), 1))
132
+ else:
133
+ try:
134
+ start_index = alphanumeric.index(range_start)
135
+ end_index = alphanumeric.index(range_end)
136
+ for i in range(start_index, end_index + 1):
137
+ expandinghosts.append(hostname.replace(match.group(0), alphanumeric[i], 1))
138
+ except ValueError:
139
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
140
+ else:
141
+ expandinghosts.append(hostname.replace(match.group(0), part, 1))
142
+ return expandedhosts
143
+
144
+ # Example usage
145
+ # text = "test[2-1c]"
146
+ # print(expand_hostname(text))
147
+ # print(old_expand_hostname(text))
148
+
149
+ # Test cases for expand_hostname and old_expand_hostname functions
150
+
151
+ def run_tests():
152
+ test_cases = [
153
+ # Simple numeric range
154
+ ("test[1-3]", {"test1", "test2", "test3"}),
155
+
156
+ # Simple letter range
157
+ ("host[a-c]", {"hosta", "hostb", "hostc"}),
158
+
159
+ # Simple hexadecimal range
160
+ ("hex[0-3]", {"hex0", "hex1", "hex2", "hex3"}),
161
+
162
+ # Numeric range with padding
163
+ ("server[001-003]", {"server001", "server002", "server003"}),
164
+
165
+ # Letter and numeric mixed ranges
166
+ ("mixed[a-b,1-2]", {"mixeda", "mixedb", "mixed1", "mixed2"}),
167
+
168
+ # Multiple ranges with different characters
169
+ ("complex[a-c,1-2,x]", {"complexa", "complexb", "complexc", "complex1", "complex2", "complexx"}),
170
+
171
+ # Hexadecimal range with uppercase letters
172
+ ("hexrange[A-C]", {"hexrangea", "hexrangeb", "hexrangec"}),
173
+
174
+ # Multiple numeric ranges
175
+ ("num[1-2,5-6]", {"num1", "num2", "num5", "num6"}),
176
+
177
+ # Overlapping numeric range
178
+ ("overlap[3-6,5-8]", {"overlap3", "overlap4", "overlap5", "overlap6", "overlap7", "overlap8"}),
179
+
180
+ # Invalid input without range brackets
181
+ ("invalid_host", {"invalid_host"}),
182
+
183
+ # No content inside brackets
184
+ ("empty[]", {"empty"}),
185
+
186
+ # Invalid range format with non-numeric or non-alphanumeric character
187
+ ("invalid[@-%]", {"invalid[@-%]"}),
188
+
189
+ # No valid starting character for range
190
+ #("start[0-9A-Z]", {"start0", "start1", "start2", "start3", "start4", "start5", "start6", "start7", "start8", "start9"}),
191
+
192
+ # Empty input
193
+ ("", {""}),
194
+
195
+ # Simple hostname without any ranges
196
+ ("hostname", {"hostname"}),
197
+ ("hostname[x-z]", {"hostnamex","hostnamey","hostnamez"}),
198
+
199
+ # Mixed valid and invalid ranges
200
+ #("mix[1-3, x-]", {"mix1", "mix2", "mix3", "mix[ x-]"}),
201
+
202
+ # Single digit to single letter range
203
+ ("combo[5-a]", {'combo8', 'combo6', 'combo9', 'comboa', 'combo5', 'combo7'}),
204
+ ]
205
+
206
+ for idx, (input_str, expected) in enumerate(test_cases):
207
+ result = expand_hostname(input_str, validate=False)
208
+ #old_result = old_expand_hostname(input_str, validate=False)
209
+ assert result == expected, f"Test {idx + 1} failed in expand_hostname(). Expected: {expected}, Got: {result}"
210
+ #assert old_result == expected, f"Test {idx + 1} failed in old_expand_hostname(). Expected: {expected}, Got: {old_result}"
211
+ print(f"Test {idx + 1} passed.")
212
+
213
+ import re
214
+
215
+ def tokenize_hostname(hostname):
216
+ """
217
+ Tokenize the hostname into a list of tokens.
218
+ Tokens will be seperated by symbols or numbers.
219
+
220
+ Args:
221
+ hostname (str): The hostname to tokenize.
222
+
223
+ Returns:
224
+ list: A list of tokens (hashed).
225
+ """
226
+ # Split the hostname into tokens
227
+ tokens = re.findall(r"([a-zA-Z]+|\d+)", hostname)
228
+ # Hash the tokens
229
+ return [hash(token) for token in tokens]
230
+
231
+
232
+
233
+ def compact_hostnames(hostnames):
234
+ patterns = defaultdict(list)
235
+ for hostname in hostnames:
236
+ parts = parse_hostname(hostname)
237
+ pattern = get_pattern(parts)
238
+ patterns[pattern].append(parts)
239
+
240
+ # Function to compact a list of numbers
241
+ def compact_numbers(numbers):
242
+ sorted_nums = sorted(set(numbers))
243
+ if len(sorted_nums) == 1:
244
+ return str(sorted_nums[0])
245
+ ranges = []
246
+ start = prev = sorted_nums[0]
247
+ for number in sorted_nums[1:]:
248
+ if number != prev + 1:
249
+ if start == prev:
250
+ ranges.append(str(start))
251
+ else:
252
+ ranges.append(f"{start}-{prev}")
253
+ start = number
254
+ prev = number
255
+ if start == prev:
256
+ ranges.append(str(start))
257
+ else:
258
+ ranges.append(f"{start}-{prev}")
259
+ return "[" + ",".join(ranges) + "]"
260
+
261
+ results = []
262
+ for pattern, parts_list in patterns.items():
263
+ segment_lists = OrderedDict()
264
+ for parts in parts_list:
265
+ for i, part in enumerate(parts):
266
+ if part.isdigit():
267
+ if i not in segment_lists:
268
+ segment_lists[i] = []
269
+ segment_lists[i].append(int(part))
270
+
271
+ # Construct the resulting compacted hostname
272
+ result_parts = []
273
+ last_index = 0
274
+ for index, num_list in segment_lists.items():
275
+ # Add the preceding static text
276
+ result_parts.append("".join(pattern[last_index:index]))
277
+ last_index = index + 1
278
+ # Add the compacted number range
279
+ result_parts.append(compact_numbers(num_list))
280
+ # Add any trailing static text
281
+ result_parts.append("".join(pattern[last_index:]))
282
+ results.append("".join(result_parts))
283
+
284
+ return ",".join(results)
285
+
286
+ # Test this updated function with your test cases.
287
+
288
+
289
+ # Run the tests
290
+ run_tests()
291
+
292
+ # servera,serverb,serverc=server[a-c]
293
+ # server15,server16,server17=server[15-17]
294
+ # server-1,server-2,server-3=server-[1-3]
295
+ # server-1-2,server-1-1,server-2-1,server-2-2=server-[1-2]-[1-2]
296
+ # server-1-2,server-1-1,server-2-2=server-1-[1-2],server-2-2
297
+ # test1-a,test2-a=test[1-2]-a
298
+
299
+ # Test cases
300
+ test_cases = [
301
+ (['server15', 'server16', 'server17'], 'server[15-17]'),
302
+ (['server-1', 'server-2', 'server-3'], 'server-[1-3]'),
303
+ (['server-1-2', 'server-1-1', 'server-2-1', 'server-2-2'], 'server-[1-2]-[1-2]'),
304
+ (['server-1-2', 'server-1-1', 'server-2-2'], 'server-1-[1-2],server-2-2'),
305
+ (['test1-a', 'test2-a'], 'test[1-2]-a'),
306
+ (['sub-s1', 'sub-s2'], 'sub-s[1-2]'),
307
+ ]
308
+
309
+ for hostnames, expected in test_cases:
310
+ result = compact_hostnames(hostnames)
311
+ print(f"Hostnames: {hostnames}")
312
+ print(f"Compacted: {result}")
313
+ print(f"Expected: {expected}")
314
+ print(f"Pass: {result == expected}\n")
315
+
316
+
@@ -0,0 +1,455 @@
1
+ import curses
2
+ import re
3
+ import math
4
+ import sys
5
+
6
+ # Global dictionary to store color pairs
7
+ __curses_global_color_pairs = {(-1,-1):1}
8
+ __curses_current_color_pair_index = 2 # Start from 1, as 0 is the default color pair
9
+ __curses_color_table = {}
10
+ __curses_current_color_index = 10
11
+ # Mapping of ANSI 4-bit colors to curses colors
12
+ ANSI_TO_CURSES_COLOR = {
13
+ 30: curses.COLOR_BLACK,
14
+ 31: curses.COLOR_RED,
15
+ 32: curses.COLOR_GREEN,
16
+ 33: curses.COLOR_YELLOW,
17
+ 34: curses.COLOR_BLUE,
18
+ 35: curses.COLOR_MAGENTA,
19
+ 36: curses.COLOR_CYAN,
20
+ 37: curses.COLOR_WHITE,
21
+ 90: curses.COLOR_BLACK, # Bright Black (usually gray)
22
+ 91: curses.COLOR_RED, # Bright Red
23
+ 92: curses.COLOR_GREEN, # Bright Green
24
+ 93: curses.COLOR_YELLOW, # Bright Yellow
25
+ 94: curses.COLOR_BLUE, # Bright Blue
26
+ 95: curses.COLOR_MAGENTA, # Bright Magenta
27
+ 96: curses.COLOR_CYAN, # Bright Cyan
28
+ 97: curses.COLOR_WHITE # Bright White
29
+ }
30
+
31
+ def __approximate_color_8bit(color):
32
+ """
33
+ Approximate an 8-bit color (0-255) to the nearest curses color.
34
+
35
+ Args:
36
+ color: 8-bit color code
37
+
38
+ Returns:
39
+ Curses color code
40
+ """
41
+ if color < 8: # Standard and bright colors
42
+ return ANSI_TO_CURSES_COLOR.get(color % 8 + 30, curses.COLOR_WHITE)
43
+ elif 8 <= color < 16: # Bright colors
44
+ return ANSI_TO_CURSES_COLOR.get(color % 8 + 90, curses.COLOR_WHITE)
45
+ elif 16 <= color <= 231: # Color cube
46
+ # Convert 216-color cube index to RGB
47
+ color -= 16
48
+ r = (color // 36) % 6 * 51
49
+ g = (color // 6) % 6 * 51
50
+ b = color % 6 * 51
51
+ return __approximate_color_24bit(r, g, b) # Map to the closest curses color
52
+ elif 232 <= color <= 255: # Grayscale
53
+ gray = (color - 232) * 10 + 8
54
+ return __approximate_color_24bit(gray, gray, gray)
55
+ else:
56
+ return curses.COLOR_WHITE # Fallback to white for unexpected values
57
+
58
+ def __approximate_color_24bit(r, g, b):
59
+ """
60
+ Approximate a 24-bit RGB color to the nearest curses color.
61
+ Will initiate a curses color if curses.can_change_color() is True.
62
+
63
+ Globals:
64
+ __curses_color_table: Dictionary of RGB color to curses color code
65
+ __curses_current_color_index: Current index of the
66
+
67
+ Args:
68
+ r: Red component (0-255)
69
+ g: Green component (0-255)
70
+ b: Blue component (0-255)
71
+
72
+ Returns:
73
+ Curses color code
74
+ """
75
+ if curses.can_change_color():
76
+ global __curses_color_table,__curses_current_color_index
77
+ # Initiate a new color if it does not exist
78
+ if (r, g, b) not in __curses_color_table:
79
+ if __curses_current_color_index >= curses.COLORS:
80
+ eprint("Warning: Maximum number of colors reached. Wrapping around.")
81
+ __curses_current_color_index = 10
82
+ curses.init_color(__curses_current_color_index, int(r/255*1000), int(g/255*1000), int(b/255*1000))
83
+ __curses_color_table[(r, g, b)] = __curses_current_color_index
84
+ __curses_current_color_index += 1
85
+ return __curses_color_table[(r, g, b)]
86
+ # Fallback to 8-bit color approximation
87
+ colors = {
88
+ curses.COLOR_BLACK: (0, 0, 0),
89
+ curses.COLOR_RED: (255, 0, 0),
90
+ curses.COLOR_GREEN: (0, 255, 0),
91
+ curses.COLOR_YELLOW: (255, 255, 0),
92
+ curses.COLOR_BLUE: (0, 0, 255),
93
+ curses.COLOR_MAGENTA: (255, 0, 255),
94
+ curses.COLOR_CYAN: (0, 255, 255),
95
+ curses.COLOR_WHITE: (255, 255, 255),
96
+ }
97
+ best_match = curses.COLOR_WHITE
98
+ min_distance = float("inf")
99
+ for color, (cr, cg, cb) in colors.items():
100
+ distance = math.sqrt((r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2)
101
+ if distance < min_distance:
102
+ min_distance = distance
103
+ best_match = color
104
+ return best_match
105
+
106
+ def __get_curses_color_pair(fg, bg):
107
+ """
108
+ Use curses color int values to create a curses color pair.
109
+
110
+ Globals:
111
+ __curses_global_color_pairs: Dictionary of color pairs
112
+ __curses_current_color_pair_index: Current index of the color pair
113
+
114
+ Args:
115
+ fg: Foreground color code
116
+ bg: Background color code
117
+
118
+ Returns:
119
+ Curses color pair code
120
+ """
121
+ global __curses_global_color_pairs, __curses_current_color_pair_index
122
+ if (fg, bg) not in __curses_global_color_pairs:
123
+ if __curses_current_color_pair_index >= curses.COLOR_PAIRS:
124
+ print("Warning: Maximum number of color pairs reached, wrapping around.")
125
+ __curses_current_color_pair_index = 1
126
+ curses.init_pair(__curses_current_color_pair_index, fg, bg)
127
+ __curses_global_color_pairs[(fg, bg)] = __curses_current_color_pair_index
128
+ __curses_current_color_pair_index += 1
129
+ return curses.color_pair(__curses_global_color_pairs[(fg, bg)])
130
+
131
+ def __parse_ansi_escape_sequence_to_curses_attr(escape_code,color_pair_list = None):
132
+ """
133
+ Parse ANSI escape codes to extract foreground and background colors.
134
+
135
+ Args:
136
+ escape_code: ANSI escape sequence for color
137
+ color_pair_list: List of [foreground, background, color_pair] curses color pair values
138
+
139
+ Returns:
140
+ Curses color pair / attribute code
141
+ """
142
+ if not escape_code:
143
+ return 1
144
+ if not color_pair_list:
145
+ color_pair_list = [-1,-1,1]
146
+ color_match = escape_code.lstrip("\x1b[").rstrip("m").split(";")
147
+ color_match = [x if x else '0' for x in color_match] # Replace empty strings with '0' (reset)
148
+ if color_match:
149
+ processed_index = -1
150
+ for i, param in enumerate(color_match):
151
+ if processed_index >= i:
152
+ # if the index has been processed, skip
153
+ continue
154
+ if param.isdigit():
155
+ if int(param) == 0:
156
+ color_pair_list[0] = -1
157
+ color_pair_list[1] = -1
158
+ color_pair_list[2] = 1
159
+ elif int(param) == 38:
160
+ if i + 1 >= len(color_match):
161
+ # Invalid color code, skip
162
+ continue
163
+ if color_match[i + 1] == "5":
164
+ # 8-bit foreground color
165
+ if i + 2 >= len(color_match) or not color_match[i + 2].isdigit():
166
+ # Invalid color code, skip
167
+ processed_index = i + 1
168
+ continue
169
+ color_pair_list[0] = __approximate_color_8bit(int(color_match[i + 2]))
170
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
171
+ processed_index = i + 2
172
+ elif color_match[i + 1] == "2":
173
+ # 24-bit foreground color
174
+ if i + 4 >= len(color_match) or not all(x.isdigit() for x in color_match[i + 2:i + 5]):
175
+ # Invalid color code, skip
176
+ processed_index = i + 1
177
+ continue
178
+ color_pair_list[0] = __approximate_color_24bit(int(color_match[i + 2]), int(color_match[i + 3]), int(color_match[i + 4]))
179
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
180
+ processed_index = i + 4
181
+ elif int(param) == 48:
182
+ if i + 1 >= len(color_match):
183
+ # Invalid color code, skip
184
+ continue
185
+ if color_match[i + 1] == "5":
186
+ # 8-bit background color
187
+ if i + 2 >= len(color_match) or not color_match[i + 2].isdigit():
188
+ # Invalid color code, skip
189
+ processed_index = i + 1
190
+ continue
191
+ color_pair_list[1] = __approximate_color_8bit(int(color_match[i + 2]))
192
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
193
+ processed_index = i + 2
194
+ elif color_match[i + 1] == "2":
195
+ # 24-bit background color
196
+ if i + 4 >= len(color_match) or not all(x.isdigit() for x in color_match[i + 2:i + 5]):
197
+ # Invalid color code, skip
198
+ processed_index = i + 1
199
+ continue
200
+ color_pair_list[1] = __approximate_color_24bit(int(color_match[i + 2]), int(color_match[i + 3]), int(color_match[i + 4]))
201
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
202
+ processed_index = i + 4
203
+ elif 30 <= int(param) <= 37 or 90 <= int(param) <= 97:
204
+ # 4-bit foreground color
205
+ color_pair_list[0] = ANSI_TO_CURSES_COLOR.get(int(param), curses.COLOR_WHITE)
206
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
207
+ elif 40 <= int(param) <= 47 or 100 <= int(param) <= 107:
208
+ # 4-bit background color
209
+ color_pair_list[1] = ANSI_TO_CURSES_COLOR.get(int(param)-10, curses.COLOR_BLACK)
210
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
211
+ elif int(param) == 1:
212
+ color_pair_list[2] = color_pair_list[2] | curses.A_BOLD
213
+ elif int(param) == 2:
214
+ color_pair_list[2] = color_pair_list[2] | curses.A_DIM
215
+ elif int(param) == 4:
216
+ color_pair_list[2] = color_pair_list[2] | curses.A_UNDERLINE
217
+ elif int(param) == 5:
218
+ color_pair_list[2] = color_pair_list[2] | curses.A_BLINK
219
+ elif int(param) == 7:
220
+ color_pair_list[2] = color_pair_list[2] | curses.A_REVERSE
221
+ elif int(param) == 8:
222
+ color_pair_list[2] = color_pair_list[2] | curses.A_INVIS
223
+ elif int(param) == 21:
224
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_BOLD
225
+ elif int(param) == 22:
226
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_DIM
227
+ elif int(param) == 24:
228
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_UNDERLINE
229
+ elif int(param) == 25:
230
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_BLINK
231
+ elif int(param) == 27:
232
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_REVERSE
233
+ elif int(param) == 28:
234
+ color_pair_list[2] = color_pair_list[2] & ~curses.A_INVIS
235
+ elif int(param) == 39:
236
+ color_pair_list[0] = -1
237
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
238
+ elif int(param) == 49:
239
+ color_pair_list[1] = -1
240
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
241
+ else:
242
+ color_pair_list[0] = -1
243
+ color_pair_list[1] = -1
244
+ color_pair_list[2] = 1
245
+ return color_pair_list[2]
246
+
247
+ 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):
248
+ """
249
+ Add a string to a curses window with / without ANSI color escape sequences translated to curses color pairs.
250
+
251
+ Args:
252
+ window: curses window object
253
+ line: The line to add
254
+ y: Line position in the window. Use -1 to scroll the window up 1 line and add the line at the bottom
255
+ x: Column position in the window
256
+ 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.
257
+ color_pair_list: List of [foreground, background, color_pair] curses color pair values
258
+ fill_char: Character to fill the remaining space in the line
259
+ parse_ansi_colors: Parse ASCII color codes
260
+ centered: Center the text in the window
261
+ lead_str: Leading string to add to the line
262
+ trail_str: Trailing string to add to the line
263
+ box_ansi_color: ANSI color escape sequence for the box color
264
+ keep_top_n_lines: Number of lines to keep at the top of the window
265
+
266
+ Returns:
267
+ None
268
+ """
269
+ if window.getmaxyx()[0] == 0 or window.getmaxyx()[1] == 0 or x >= window.getmaxyx()[1]:
270
+ return
271
+ if x < 0:
272
+ x = window.getmaxyx()[1] + x
273
+ if number_of_char_to_write == -1:
274
+ numChar = window.getmaxyx()[1] - x -1
275
+ elif number_of_char_to_write == 0:
276
+ return
277
+ elif number_of_char_to_write + x > window.getmaxyx()[1]:
278
+ numChar = window.getmaxyx()[1] - x -1
279
+ else:
280
+ numChar = number_of_char_to_write
281
+ if numChar < 0:
282
+ return
283
+ if y < 0 or y >= window.getmaxyx()[0]:
284
+ if keep_top_n_lines > window.getmaxyx()[0] -1:
285
+ keep_top_n_lines = window.getmaxyx()[0] -1
286
+ if keep_top_n_lines < 0:
287
+ keep_top_n_lines = 0
288
+ window.move(keep_top_n_lines,0)
289
+ window.deleteln()
290
+ y = window.getmaxyx()[0] - 1
291
+ line = line.replace('\n', ' ').replace('\r', ' ')
292
+ if parse_ansi_colors:
293
+ segments = re.split(r"(\x1b\[[\d;]*m)", line) # Split line by ANSI escape codes
294
+ else:
295
+ segments = [line]
296
+ charsWritten = 0
297
+ boxAttr = __parse_ansi_escape_sequence_to_curses_attr(box_ansi_color)
298
+ # first add the lead_str
299
+ window.addnstr(y, x, lead_str, numChar, boxAttr)
300
+ charsWritten = min(len(lead_str), numChar)
301
+ # process centering
302
+ if centered:
303
+ fill_length = numChar - len(lead_str) - len(trail_str) - sum([len(segment) for segment in segments if not segment.startswith("\x1b[")])
304
+ window.addnstr(y, x + charsWritten, fill_char * (fill_length // 2 // len(fill_char)), numChar - charsWritten, boxAttr)
305
+ charsWritten += min(len(fill_char * (fill_length // 2)), numChar - charsWritten)
306
+ # add the segments
307
+ for segment in segments:
308
+ if not segment:
309
+ continue
310
+ if parse_ansi_colors and segment.startswith("\x1b["):
311
+ # Parse ANSI escape sequence
312
+ newAttr = __parse_ansi_escape_sequence_to_curses_attr(segment,color_pair_list)
313
+ else:
314
+ # Add text with current color
315
+ if charsWritten < numChar:
316
+ window.addnstr(y, x + charsWritten, segment, numChar - charsWritten, color_pair_list[2])
317
+ charsWritten += min(len(segment), numChar - charsWritten)
318
+ # if we have finished printing segments but we still have space, we will fill it with fill_char
319
+ if charsWritten + len(trail_str) < numChar:
320
+ fillStr = fill_char * ((numChar - charsWritten - len(trail_str))//len(fill_char))
321
+ #fillStr = f'{color_pair_list}'
322
+ window.addnstr(y, x + charsWritten, fillStr + trail_str, numChar - charsWritten, boxAttr)
323
+ charsWritten += numChar - charsWritten
324
+ else:
325
+ window.addnstr(y, x + charsWritten, trail_str, numChar - charsWritten, boxAttr)
326
+
327
+ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None):
328
+ '''
329
+ Generate a list for the hosts to be displayed on the screen. This is used to display as much relevant information as possible.
330
+
331
+ Args:
332
+ hosts (list): A list of Host objects
333
+ max_num_hosts (int): The maximum number of hosts to be displayed
334
+ hosts_to_display (list, optional): The hosts that are currently displayed. Defaults to None.
335
+
336
+ Returns:
337
+ list: A list of Host objects to be displayed
338
+ '''
339
+ # We will sort the hosts by running -> failed -> finished -> waiting
340
+ # running: returncode is None and output is not empty (output will be appened immediately after the command is run)
341
+ # failed: returncode is not None and returncode is not 0
342
+ # finished: returncode is not None and returncode is 0
343
+ # waiting: returncode is None and output is empty
344
+ running_hosts = [host for host in hosts if host.returncode is None and host.output]
345
+ failed_hosts = [host for host in hosts if host.returncode is not None and host.returncode != 0]
346
+ finished_hosts = [host for host in hosts if host.returncode is not None and host.returncode == 0]
347
+ waiting_hosts = [host for host in hosts if host.returncode is None and not host.output]
348
+ new_hosts_to_display = (running_hosts + failed_hosts + finished_hosts + waiting_hosts)[:max_num_hosts]
349
+ if not hosts_to_display:
350
+ return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}
351
+ # we will compare the new_hosts_to_display with the old one, if some hosts are not in their original position, we will change its printedLines to 0
352
+ for i, host in enumerate(new_hosts_to_display):
353
+ if host not in hosts_to_display:
354
+ host.printedLines = 0
355
+ elif i != hosts_to_display.index(host):
356
+ host.printedLines = 0
357
+ return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}
358
+
359
+ TEST_LINE = (
360
+ "Standard \x1b[31m Red\x1b[0m, ",
361
+ "Bold \x1b[91m \x1b[1mRed\x1b[0m, ",
362
+ "Standard \x1b[32m Green\x1b[0m, ",
363
+ "Bright \x1b[92m Green\x1b[0m, ",
364
+ "Standard \x1b[33m Yellow\x1b[0m, ",
365
+ "Bright \x1b[93m Yellow\x1b[0m, ",
366
+ "Standard \x1b[34m Blue\x1b[0m, ",
367
+ "Bright \x1b[94m Blue\x1b[0m, ",
368
+ "Standard \x1b[35m Magenta\x1b[0m, ",
369
+ "Bright \x1b[95m Magenta\x1b[0m, ",
370
+ "Standard \x1b[36m Cyan\x1b[0m, ",
371
+ "Bright \x1b[96m Cyan\x1b[0m, ",
372
+ "Standard \x1b[37m White\x1b[0m, ",
373
+ "Bright \x1b[97m White\x1b[0m, ",
374
+ "8-bit \x1b[38;5;196m Red\x1b[0m, ",
375
+ "8-bit \x1b[38;5;82m Green\x1b[0m, ",
376
+ "8-bit \x1b[38;5;27m Blue\x1b[0m, ",
377
+ "8-bit \x1b[38;5;226m Yellow\x1b[0m, ",
378
+ "8-bit \x1b[38;5;201m Magenta\x1b[0m, ",
379
+ "8-bit \x1b[38;5;51m Cyan\x1b[0m, ",
380
+ "8-bit \x1b[38;5;15m White\x1b[0m, ",
381
+ "8-bit \x1b[38;5;0m Black\x1b[0m, ",
382
+ "8-bit \x1b[38;5;4m Blue\x1b[0m, ",
383
+ "24-bit \x1b[38;2;128;128;128m Gray\x1b[0m.",
384
+ "24-bit \x1b[38;2;255;165;0m Orange\x1b[0m.",
385
+ "24-bit \x1b[38;2;255;0;255m Pink\x1b[0m.",
386
+ "24-bit \x1b[38;2;0;255;255m Cyan\x1b[0m.",
387
+ "24-bit \x1b[38;2;255;255;255m White\x1b[0m.",
388
+ "24-bit \x1b[38;2;0;0;0m Black\x1b[0m.",
389
+ "24-bit \x1b[38;2;128;128;128m Gray\x1b[0m.",
390
+ "24-bit \x1b[38;2;255;0;0m Red\x1b[0m.",
391
+ "24-bit \x1b[38;2;0;255;0m Green\x1b[0m.",
392
+ "24-bit \x1b[38;2;0;0;255m Blue\x1b[0m.",
393
+ "24-bit \x1b[38;2;255;255;0m Yellow\x1b[0m.",
394
+ "24-bit \x1b[38;2;255;0;255m Magenta\x1b[0m.",
395
+ "24-bit \x1b[38;2;0;255;255m Cyan colored dot.",
396
+ "Following string without reset color.",
397
+ "Following string with \x1b[31m Red color.",
398
+ "Following string with \x1b[31m Red color and \x1b[32m Green color.",
399
+ "Reset color. \x1b[0m Following string with default color.",
400
+ "Bold \x1b[1m Following string with bold text.\x1b[0m",
401
+ "Dim \x1b[2m Following string with dim text.\x1b[0m",
402
+ "Underline \x1b[4m Following string with underline text.\x1b[0m",
403
+ "Blink \x1b[5m Following string with blink text.\x1b[0m",
404
+ "Reverse \x1b[7m Following string with reverse text.\x1b[0m",
405
+ "Invisible \x1b[8m Following string with invisible text.\x1b[0m",
406
+ "Reset color. \x1b[0m Following string with default color.\x1b[0m",
407
+ "Bold \x1b[1m Following string with \x1b[22m not bold text.\x1b[0m",
408
+ "RedGreen \x1b[31;32m Green\x1b[0m, ",
409
+ )
410
+ # Example usage in a curses application
411
+ def main(stdscr):
412
+ curses.start_color()
413
+ curses.use_default_colors()
414
+ curses.init_pair(1, -1, -1)
415
+ parsed_attr = __parse_ansi_escape_sequence_to_curses_attr('\x1b[31m')
416
+ stdscr.addnstr(0, 0, f'{parsed_attr}',stdscr.getmaxyx()[1], parsed_attr)
417
+ # get curses.A_BOLD
418
+ stdscr.addnstr(1, 0, f'{curses.A_BOLD}',stdscr.getmaxyx()[1], curses.A_BOLD)
419
+ stdscr.addnstr(2, 0, f'{parsed_attr | curses.A_BOLD}',stdscr.getmaxyx()[1], parsed_attr | curses.A_BOLD)
420
+ stdscr.addnstr(3, 0, f'{curses.A_DIM}',stdscr.getmaxyx()[1], parsed_attr | curses.A_DIM)
421
+ stdscr.addnstr(4, 0, f'{parsed_attr | curses.A_BOLD | curses.A_DIM}',stdscr.getmaxyx()[1], parsed_attr | curses.A_BOLD| curses.A_DIM)
422
+ stdscr.refresh()
423
+ stdscr.getch()
424
+ stdscr.clear()
425
+ #stdscr.idlok(True)
426
+ #stdscr.scrollok(True)
427
+ screen_size = stdscr.getmaxyx()
428
+
429
+ # Example line with ANSI color escape codes (including 8-bit and 24-bit colors)
430
+ color_pair_list = [-1,-1,1]
431
+ for i, line in enumerate(TEST_LINE):
432
+ _curses_add_string_to_window(window=stdscr, line=line, y = i,color_pair_list=color_pair_list,fill_char='-',centered=True,lead_str='|',trail_str='?|',box_ansi_color='\x1b[43;31;5m',keep_top_n_lines=2)
433
+
434
+ #stdscr.addnstr(i+ 1, 0, 'test',10, 4)
435
+ stdscr.refresh()
436
+ stdscr.getch()
437
+ #stdscr.scroll(1)
438
+ stdscr.move(0,0)
439
+ stdscr.deleteln()
440
+ stdscr.refresh()
441
+ stdscr.getch()
442
+
443
+ if __name__ == "__main__":
444
+ for i, color in enumerate(TEST_LINE):
445
+ print(f'{i}: {color}')
446
+ curses.wrapper(main)
447
+
448
+ #print(__curses_color_table)
449
+ #print(__curses_global_color_pairs)
450
+ for color in __curses_color_table:
451
+ print(f'{color}: {__curses_color_table[color]}, {curses.color_content(__curses_color_table[color])}')
452
+ for color_pair in __curses_global_color_pairs:
453
+ print(f'{color_pair}: {__curses_global_color_pairs[color_pair]}')
454
+ # for i in range(curses.COLORS):
455
+ # print(f'{i}: {curses.color_content(i)}')
@@ -0,0 +1,289 @@
1
+ import curses
2
+ import re
3
+ import math
4
+
5
+ # Global dictionary to store color pairs
6
+ __curses_global_color_pairs = {(-1,-1):1}
7
+ __curses_current_color_pair_index = 2 # Start from 1, as 0 is the default color pair
8
+ __curses_color_table = {}
9
+ __curses_current_color_index = 10
10
+ # Mapping of ANSI 4-bit colors to curses colors
11
+ ANSI_TO_CURSES_COLOR = {
12
+ 30: curses.COLOR_BLACK,
13
+ 31: curses.COLOR_RED,
14
+ 32: curses.COLOR_GREEN,
15
+ 33: curses.COLOR_YELLOW,
16
+ 34: curses.COLOR_BLUE,
17
+ 35: curses.COLOR_MAGENTA,
18
+ 36: curses.COLOR_CYAN,
19
+ 37: curses.COLOR_WHITE,
20
+ 90: curses.COLOR_BLACK, # Bright Black (usually gray)
21
+ 91: curses.COLOR_RED, # Bright Red
22
+ 92: curses.COLOR_GREEN, # Bright Green
23
+ 93: curses.COLOR_YELLOW, # Bright Yellow
24
+ 94: curses.COLOR_BLUE, # Bright Blue
25
+ 95: curses.COLOR_MAGENTA, # Bright Magenta
26
+ 96: curses.COLOR_CYAN, # Bright Cyan
27
+ 97: curses.COLOR_WHITE # Bright White
28
+ }
29
+
30
+ def __approximate_color_8bit(color):
31
+ """
32
+ Approximate an 8-bit color (0-255) to the nearest curses color.
33
+
34
+ Args:
35
+ color: 8-bit color code
36
+
37
+ Returns:
38
+ Curses color code
39
+ """
40
+ if color < 8: # Standard and bright colors
41
+ return ANSI_TO_CURSES_COLOR.get(color % 8 + 30, curses.COLOR_WHITE)
42
+ elif 8 <= color < 16: # Bright colors
43
+ return ANSI_TO_CURSES_COLOR.get(color % 8 + 90, curses.COLOR_WHITE)
44
+ elif 16 <= color <= 231: # Color cube
45
+ # Convert 216-color cube index to RGB
46
+ color -= 16
47
+ r = (color // 36) % 6 * 51
48
+ g = (color // 6) % 6 * 51
49
+ b = color % 6 * 51
50
+ return __approximate_color_24bit(r, g, b) # Map to the closest curses color
51
+ elif 232 <= color <= 255: # Grayscale
52
+ gray = (color - 232) * 10 + 8
53
+ return __approximate_color_24bit(gray, gray, gray)
54
+ else:
55
+ return curses.COLOR_WHITE # Fallback to white for unexpected values
56
+
57
+ def __approximate_color_24bit(r, g, b):
58
+ """
59
+ Approximate a 24-bit RGB color to the nearest curses color.
60
+ Will initiate a curses color if curses.can_change_color() is True.
61
+
62
+ Globals:
63
+ __curses_color_table: Dictionary of RGB color to curses color code
64
+ __curses_current_color_index: Current index of the
65
+
66
+ Args:
67
+ r: Red component (0-255)
68
+ g: Green component (0-255)
69
+ b: Blue component (0-255)
70
+
71
+ Returns:
72
+ Curses color code
73
+ """
74
+ if curses.can_change_color():
75
+ global __curses_color_table,__curses_current_color_index
76
+ # Initiate a new color if it does not exist
77
+ if (r, g, b) not in __curses_color_table:
78
+ if __curses_current_color_index >= curses.COLORS:
79
+ eprint("Warning: Maximum number of colors reached. Wrapping around.")
80
+ __curses_current_color_index = 10
81
+ curses.init_color(__curses_current_color_index, int(r/255*1000), int(g/255*1000), int(b/255*1000))
82
+ __curses_color_table[(r, g, b)] = __curses_current_color_index
83
+ __curses_current_color_index += 1
84
+ return __curses_color_table[(r, g, b)]
85
+ # Fallback to 8-bit color approximation
86
+ colors = {
87
+ curses.COLOR_BLACK: (0, 0, 0),
88
+ curses.COLOR_RED: (255, 0, 0),
89
+ curses.COLOR_GREEN: (0, 255, 0),
90
+ curses.COLOR_YELLOW: (255, 255, 0),
91
+ curses.COLOR_BLUE: (0, 0, 255),
92
+ curses.COLOR_MAGENTA: (255, 0, 255),
93
+ curses.COLOR_CYAN: (0, 255, 255),
94
+ curses.COLOR_WHITE: (255, 255, 255),
95
+ }
96
+ best_match = curses.COLOR_WHITE
97
+ min_distance = float("inf")
98
+ for color, (cr, cg, cb) in colors.items():
99
+ distance = math.sqrt((r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2)
100
+ if distance < min_distance:
101
+ min_distance = distance
102
+ best_match = color
103
+ return best_match
104
+
105
+ def __parse_ansi_escape_sequence_to_curses_color(escape_code):
106
+ """
107
+ Parse ANSI escape codes to extract foreground and background colors.
108
+
109
+ Args:
110
+ escape_code: ANSI escape sequence for color
111
+
112
+ Returns:
113
+ Tuple of (foreground, background) curses color pairs.
114
+ If the escape code is a reset code, return (-1, -1).
115
+ None values indicate that the color should not be changed.
116
+ """
117
+ if not escape_code:
118
+ return None, None
119
+ color_match = re.match(r"\x1b\[(\d+)(?:;(\d+))?(?:;(\d+))?(?:;(\d+);(\d+);(\d+))?m", escape_code)
120
+ if color_match:
121
+ params = color_match.groups()
122
+ if params[0] == "0" and not any(params[1:]): # Reset code
123
+ return -1, -1
124
+ if params[0] == "38" and params[1] == "5": # 8-bit foreground
125
+ return __approximate_color_8bit(int(params[2])), None
126
+ elif params[0] == "38" and params[1] == "2": # 24-bit foreground
127
+ return __approximate_color_24bit(int(params[3]), int(params[4]), int(params[5])), None
128
+ elif params[0] == "48" and params[1] == "5": # 8-bit background
129
+ return None , __approximate_color_8bit(int(params[2]))
130
+ elif params[0] == "48" and params[1] == "2": # 24-bit background
131
+ return None, __approximate_color_24bit(int(params[3]), int(params[4]), int(params[5]))
132
+ else:
133
+ fg = None
134
+ bg = None
135
+ if params[0] and params[0].isdigit(): # 4-bit color
136
+ fg = ANSI_TO_CURSES_COLOR.get(int(params[0]), curses.COLOR_WHITE)
137
+ if params[1] and params[1].isdigit():
138
+ bg = ANSI_TO_CURSES_COLOR.get(int(params[1]), curses.COLOR_BLACK)
139
+ return fg, bg
140
+ return None, None
141
+
142
+ def __get_curses_color_pair(fg, bg):
143
+ """
144
+ Use curses color int values to create a curses color pair.
145
+
146
+ Globals:
147
+ __curses_global_color_pairs: Dictionary of color pairs
148
+ __curses_current_color_pair_index: Current index of the color pair
149
+
150
+ Args:
151
+ fg: Foreground color code
152
+ bg: Background color code
153
+
154
+ Returns:
155
+ Curses color pair code
156
+ """
157
+ global __curses_global_color_pairs, __curses_current_color_pair_index
158
+ if (fg, bg) not in __curses_global_color_pairs:
159
+ if __curses_current_color_pair_index >= curses.COLOR_PAIRS:
160
+ print("Warning: Maximum number of color pairs reached, wrapping around.")
161
+ __curses_current_color_pair_index = 1
162
+ curses.init_pair(__curses_current_color_pair_index, fg, bg)
163
+ __curses_global_color_pairs[(fg, bg)] = __curses_current_color_pair_index
164
+ __curses_current_color_pair_index += 1
165
+ return __curses_global_color_pairs[(fg, bg)]
166
+
167
+ def _add_line_with_ascii_colors(window, y, x, line, n, color_pair_list = [-1,-1,1]):
168
+ """
169
+ Add a line to a curses window with ANSI escape sequences translated to curses color pairs.
170
+
171
+ Args:
172
+ window: curses window object
173
+ y: Line position in the window
174
+ x: Column position in the window
175
+ line: The string containing ANSI escape sequences for color
176
+ n: Maximum number of characters to write
177
+ host: The host object
178
+
179
+ Returns:
180
+ None
181
+ """
182
+ segments = re.split(r"(\x1b\[[\d;]*m)", line) # Split line by ANSI escape codes
183
+ current_x = x
184
+ for segment in segments:
185
+ if segment.startswith("\x1b["):
186
+ # Parse ANSI escape sequence
187
+ newFrontColor, newBackColor = __parse_ansi_escape_sequence_to_curses_color(segment)
188
+ if newFrontColor is not None:
189
+ color_pair_list[0] = newFrontColor
190
+ if newBackColor is not None:
191
+ color_pair_list[1] = newBackColor
192
+ color_pair_list[2] = __get_curses_color_pair(color_pair_list[0], color_pair_list[1])
193
+ pair = str(color_pair_list[2])
194
+ window.addstr(y, current_x, pair)
195
+ current_x += len(pair)
196
+ else:
197
+ # Add text with current color
198
+ if current_x < x + n:
199
+ #eprint(f"\ny: {y}, x: {current_x}, segment: {segment}, n: {n}, color_pair: {color_pair_list[2]}\n")
200
+ window.addnstr(y, current_x, segment, n - (current_x - x), curses.color_pair(color_pair_list[2]))
201
+ current_x += len(segment)
202
+
203
+
204
+ TEST_LINE = (
205
+ "Standard \x1b[31mRed\x1b[0m, ",
206
+ "Bright \x1b[91mRed\x1b[0m, ",
207
+ "Standard \x1b[32mGreen\x1b[0m, ",
208
+ "Bright \x1b[92mGreen\x1b[0m, ",
209
+ "Standard \x1b[33mYellow\x1b[0m, ",
210
+ "Bright \x1b[93mYellow\x1b[0m, ",
211
+ "Standard \x1b[34mBlue\x1b[0m, ",
212
+ "Bright \x1b[94mBlue\x1b[0m, ",
213
+ "Standard \x1b[35mMagenta\x1b[0m, ",
214
+ "Bright \x1b[95mMagenta\x1b[0m, ",
215
+ "Standard \x1b[36mCyan\x1b[0m, ",
216
+ "Bright \x1b[96mCyan\x1b[0m, ",
217
+ "Standard \x1b[37mWhite\x1b[0m, ",
218
+ "Bright \x1b[97mWhite\x1b[0m, ",
219
+ "8-bit \x1b[38;5;196mRed\x1b[0m, ",
220
+ "8-bit \x1b[38;5;82mGreen\x1b[0m, ",
221
+ "8-bit \x1b[38;5;27mBlue\x1b[0m, ",
222
+ "8-bit \x1b[38;5;226mYellow\x1b[0m, ",
223
+ "8-bit \x1b[38;5;201mMagenta\x1b[0m, ",
224
+ "8-bit \x1b[38;5;51mCyan\x1b[0m, ",
225
+ "8-bit \x1b[38;5;15mWhite\x1b[0m, ",
226
+ "8-bit \x1b[38;5;0mBlack\x1b[0m, ",
227
+ "8-bit \x1b[38;5;4mBlue\x1b[0m, ",
228
+ "24-bit \x1b[38;2;128;128;128mGray\x1b[0m.",
229
+ "24-bit \x1b[38;2;255;165;0mOrange\x1b[0m.",
230
+ "24-bit \x1b[38;2;255;0;255mPink\x1b[0m.",
231
+ "24-bit \x1b[38;2;0;255;255mCyan\x1b[0m.",
232
+ "24-bit \x1b[38;2;255;255;255mWhite\x1b[0m.",
233
+ "24-bit \x1b[38;2;0;0;0mBlack\x1b[0m.",
234
+ "24-bit \x1b[38;2;128;128;128mGray\x1b[0m.",
235
+ "24-bit \x1b[38;2;255;0;0mRed\x1b[0m.",
236
+ "24-bit \x1b[38;2;0;255;0mGreen\x1b[0m.",
237
+ "24-bit \x1b[38;2;0;0;255mBlue\x1b[0m.",
238
+ "24-bit \x1b[38;2;255;255;0mYellow\x1b[0m.",
239
+ "24-bit \x1b[38;2;255;0;255mMagenta\x1b[0m.",
240
+ "24-bit \x1b[38;2;0;255;255mCyan colored dot.",
241
+ "Following string without reset color.",
242
+ "Following string with \x1b[31mRed color.",
243
+ "Following string with \x1b[31mRed color and \x1b[32mGreen color.",
244
+ "Reset color. \x1b[0mFollowing string with default color."
245
+ )
246
+ # Example usage in a curses application
247
+ def main(stdscr):
248
+ curses.start_color()
249
+ curses.use_default_colors()
250
+ curses.init_pair(1, -1, -1)
251
+ frontColor, backColor = __parse_ansi_escape_sequence_to_curses_color('\x1b[31;33m')
252
+ stdscr.addnstr(0, 0, f'frontColor,backColor:{frontColor},{backColor}',stdscr.getmaxyx()[1], 1)
253
+ stdscr.addnstr(1, 0, f'{curses.color_content(frontColor)}, {curses.color_content(backColor)}',stdscr.getmaxyx()[1], 1)
254
+ stdscr.refresh()
255
+ color_pair = __get_curses_color_pair(frontColor, backColor)
256
+ stdscr.addnstr(2, 0, f'color_pair {color_pair}',stdscr.getmaxyx()[1], color_pair)
257
+ stdscr.addnstr(3, 0, f'{curses.color_pair(color_pair)}',stdscr.getmaxyx()[1], color_pair)
258
+ stdscr.refresh()
259
+ stdscr.getch()
260
+ stdscr.clear()
261
+ #stdscr.idlok(True)
262
+ #stdscr.scrollok(True)
263
+ screen_size = stdscr.getmaxyx()
264
+
265
+ # Example line with ANSI color escape codes (including 8-bit and 24-bit colors)
266
+ color_pair_list = [-1,-1,1]
267
+ for i, line in enumerate(TEST_LINE):
268
+ _add_line_with_ascii_colors(stdscr, i, 0, line, screen_size[1], color_pair_list)
269
+
270
+ stdscr.refresh()
271
+ stdscr.getch()
272
+ #stdscr.scroll(1)
273
+ stdscr.move(0,0)
274
+ stdscr.deleteln()
275
+ stdscr.refresh()
276
+ stdscr.getch()
277
+
278
+
279
+ if __name__ == "__main__":
280
+ for i, color in enumerate(TEST_LINE):
281
+ print(f'{i}: {color}')
282
+ curses.wrapper(main)
283
+
284
+ #print(__curses_color_table)
285
+ #print(__curses_global_color_pairs)
286
+ for color in __curses_color_table:
287
+ print(f'{color}: {__curses_color_table[color]}, {curses.color_content(__curses_color_table[color])}')
288
+ for color_pair in __curses_global_color_pairs:
289
+ print(f'{color_pair}: {__curses_global_color_pairs[color_pair]}')
@@ -0,0 +1,31 @@
1
+ import multiSSH3
2
+ import time
3
+
4
+ print(multiSSH3.compact_hostnames(frozenset([f'PC{i}-{j:03d}' for i in range(8,11) for j in range(1, 3) ])))
5
+
6
+ # bigZeroPaddedHosts = frozenset([f'PC{i:02d}-{j:02d}' for i in range(1, 200) for j in range(1, 3102)] + ['3-3PC','nebulamaster'])
7
+ # print(f'len of bigZeroPaddedHosts: {len(bigZeroPaddedHosts)}')
8
+ # startTime = time.perf_counter()
9
+ # print(compact_hostnames(bigZeroPaddedHosts))
10
+ # print(f'Time: {time.perf_counter() - startTime}')
11
+
12
+ # hugeHosts = frozenset([f'PC{i}-{j:03d}-{k:05d}' for i in range(3, 40) for j in range(1, 50) for k in range(1, 100)] + ['3-3-3PC','nebulamaster'])
13
+ # print(f'len of hugeHosts: {len(hugeHosts)}')
14
+ # startTime = time.perf_counter()
15
+ # print(compact_hostnames(hugeHosts))
16
+ # print(f'Time: {time.perf_counter() - startTime}')
17
+
18
+ # # %%
19
+ # hugeHosts = frozenset([f'PC{i:01d}-{j:03d}-{k:03d}' for i in range(1, 100) for j in range(1, 50) for k in range(1, 100) if j != 8 if i != 35] + ['3-3-3PC','nebulamaster'])
20
+ # startTime = time.perf_counter()
21
+ # print(f'len of skipping hugerHosts: {len(hugeHosts)}')
22
+ # print(compact_hostnames(hugeHosts))
23
+ # print(f'Time: {time.perf_counter() - startTime}')
24
+
25
+
26
+ ipRangeHosts = frozenset([f'10.{i}.{j}.{k}' for i in range(6, 13) for j in range(100, 255) for k in range(1, 255)] +[f'192.168.{j}.{k}' for k in range(100, 200) for j in range(1, 255) ]+ ['localhost'])
27
+ print(f'len of ipRangeHosts: {len(ipRangeHosts)}')
28
+ startTime = time.perf_counter()
29
+ print(multiSSH3.compact_hostnames(ipRangeHosts))
30
+ print(f'Time: {time.perf_counter() - startTime}')
31
+
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ import multiSSH3
3
+ import time
4
+
5
+ ipRangeHosts = frozenset(['admin[1-10]@10.251.*.1-253','localhost'])
6
+ print(f'len of ipRangeHosts: {len(ipRangeHosts)}')
7
+ startTime = time.perf_counter()
8
+ results = multiSSH3.expand_hostnames(ipRangeHosts)
9
+ print(f'Time: {time.perf_counter() - startTime}')
10
+ print(len(results))
11
+ for i, result in enumerate(results.items()):
12
+ print(result)
13
+ if i > 10:
14
+ break
File without changes
File without changes
File without changes