multiCMD 1.32__tar.gz → 1.34__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.32
3
+ Version: 1.34
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.32
3
+ Version: 1.34
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -5,6 +5,7 @@
5
5
  # "argparse",
6
6
  # ]
7
7
  # ///
8
+ #%% imports
8
9
  import time
9
10
  import threading
10
11
  import io
@@ -18,11 +19,20 @@ import re
18
19
  import itertools
19
20
  import signal
20
21
 
21
- version = '1.32'
22
+ #%% global vars
23
+ version = '1.34'
22
24
  __version__ = version
23
-
24
- __running_threads = []
25
+ COMMIT_DATE = '2025-08-25'
26
+ __running_threads = set()
25
27
  __variables = {}
28
+
29
+ # immutable helpers compiled once at import time
30
+ _BRACKET_RX = re.compile(r'\[([^\]]+)\]')
31
+ _ALPHANUM = string.digits + string.ascii_letters
32
+ _ALPHA_IDX = {c: i for i, c in enumerate(_ALPHANUM)}
33
+
34
+
35
+ #%% objects
26
36
  class Task:
27
37
  def __init__(self, command):
28
38
  self.command = command
@@ -41,7 +51,7 @@ class Task:
41
51
  if self.thread is not None:
42
52
  return self.thread.is_alive()
43
53
  return False
44
-
54
+
45
55
  class AsyncExecutor:
46
56
  def __init__(self, max_threads=1,semaphore=...,timeout=0,quiet=True,dry_run=False,parse=False):
47
57
  '''
@@ -227,11 +237,7 @@ class AsyncExecutor:
227
237
  '''
228
238
  return [task.returncode for task in self.tasks]
229
239
 
230
- # immutable helpers compiled once at import time
231
- _BRACKET_RX = re.compile(r'\[([^\]]+)\]')
232
- _ALPHANUM = string.digits + string.ascii_letters
233
- _ALPHA_IDX = {c: i for i, c in enumerate(_ALPHANUM)}
234
-
240
+ #%% helper functions
235
241
  def _expand_piece(piece, vars_):
236
242
  """Turn one comma-separated element from inside [...] into a list of strings."""
237
243
  piece = piece.strip()
@@ -287,69 +293,6 @@ def _expand_ranges_fast(inStr):
287
293
  # cartesian product of all segments
288
294
  return [''.join(parts) for parts in itertools.product(*segments)]
289
295
 
290
- def _expand_ranges(inStr):
291
- '''
292
- Expand ranges in a string
293
-
294
- @params:
295
- inStr: The string to expand
296
-
297
- @returns:
298
- list[str]: The expanded string
299
- '''
300
- global __variables
301
- expandingStr = [inStr]
302
- expandedList = []
303
- # all valid alphanumeric characters
304
- alphanumeric = string.digits + string.ascii_letters
305
- while len(expandingStr) > 0:
306
- currentStr = expandingStr.pop()
307
- match = re.search(r'\[(.*?)]', currentStr)
308
- if not match:
309
- expandedList.append(currentStr)
310
- continue
311
- group = match.group(1)
312
- parts = group.split(',')
313
- for part in parts:
314
- part = part.strip()
315
- if ':' in part:
316
- variableName, _, part = part.partition(':')
317
- __variables[variableName] = part
318
- expandingStr.append(currentStr.replace(match.group(0), '', 1))
319
- elif '-' in part:
320
- try:
321
- range_start,_, range_end = part.partition('-')
322
- except ValueError:
323
- expandedList.append(currentStr)
324
- continue
325
- range_start = range_start.strip()
326
- if range_start in __variables:
327
- range_start = __variables[range_start]
328
- range_end = range_end.strip()
329
- if range_end in __variables:
330
- range_end = __variables[range_end]
331
- if range_start.isdigit() and range_end.isdigit():
332
- padding_length = min(len(range_start), len(range_end))
333
- format_str = "{:0" + str(padding_length) + "d}"
334
- for i in range(int(range_start), int(range_end) + 1):
335
- formatted_i = format_str.format(i)
336
- expandingStr.append(currentStr.replace(match.group(0), formatted_i, 1))
337
- elif all(c in string.hexdigits for c in range_start + range_end):
338
- for i in range(int(range_start, 16), int(range_end, 16) + 1):
339
- expandingStr.append(currentStr.replace(match.group(0), format(i, 'x'), 1))
340
- else:
341
- try:
342
- start_index = alphanumeric.index(range_start)
343
- end_index = alphanumeric.index(range_end)
344
- for i in range(start_index, end_index + 1):
345
- expandingStr.append(currentStr.replace(match.group(0), alphanumeric[i], 1))
346
- except ValueError:
347
- expandedList.append(currentStr)
348
- else:
349
- expandingStr.append(currentStr.replace(match.group(0), part, 1))
350
- expandedList.reverse()
351
- return expandedList
352
-
353
296
  def __handle_stream(stream,target,pre='',post='',quiet=False):
354
297
  '''
355
298
  Handle a stream
@@ -519,6 +462,43 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
519
462
  else:
520
463
  return task.stdout
521
464
 
465
+ def __format_command(command,expand = False):
466
+ '''
467
+ Format a command
468
+
469
+ @params:
470
+ command: The command to format
471
+ expand: Whether to expand ranges
472
+
473
+ @returns:
474
+ list[list[str]]: The formatted commands sequence
475
+ '''
476
+ if isinstance(command,str):
477
+ if expand:
478
+ commands = _expand_ranges_fast(command)
479
+ else:
480
+ commands = [command]
481
+ return [command.split() for command in commands]
482
+ # elif it is a iterable
483
+ elif hasattr(command,'__iter__'):
484
+ sanitized_command = []
485
+ for field in command:
486
+ if isinstance(field,str):
487
+ sanitized_command.append(field)
488
+ else:
489
+ sanitized_command.append(repr(field))
490
+ if not expand:
491
+ return [sanitized_command]
492
+ sanitized_expanded_command = [_expand_ranges_fast(field) for field in sanitized_command]
493
+ # now the command had been expanded to list of list of fields with each field expanded to all possible options
494
+ # we need to generate all possible combinations of the fields
495
+ # we will use the cartesian product to do this
496
+ commands = list(itertools.product(*sanitized_expanded_command))
497
+ return [list(command) for command in commands]
498
+ else:
499
+ return __format_command(str(command),expand=expand)
500
+
501
+ #%% core funcitons
522
502
  def ping(hosts,timeout=1,max_threads=0,quiet=True,dry_run=False,with_stdErr=False,
523
503
  return_code_only=False,return_object=False,wait_for_return=True,return_true_false=True):
524
504
  '''
@@ -560,7 +540,6 @@ def ping(hosts,timeout=1,max_threads=0,quiet=True,dry_run=False,with_stdErr=Fals
560
540
  else:
561
541
  return results
562
542
 
563
-
564
543
  def run_command(command, timeout=0,max_threads=1,quiet=False,dry_run=False,with_stdErr=False,
565
544
  return_code_only=False,return_object=False,wait_for_return=True, sem = None):
566
545
  '''
@@ -585,42 +564,6 @@ def run_command(command, timeout=0,max_threads=1,quiet=False,dry_run=False,with_
585
564
  dry_run=dry_run, with_stdErr=with_stdErr, return_code_only=return_code_only,
586
565
  return_object=return_object,parse=False,wait_for_return=wait_for_return,sem=sem)[0]
587
566
 
588
- def __format_command(command,expand = False):
589
- '''
590
- Format a command
591
-
592
- @params:
593
- command: The command to format
594
- expand: Whether to expand ranges
595
-
596
- @returns:
597
- list[list[str]]: The formatted commands sequence
598
- '''
599
- if isinstance(command,str):
600
- if expand:
601
- commands = _expand_ranges_fast(command)
602
- else:
603
- commands = [command]
604
- return [command.split() for command in commands]
605
- # elif it is a iterable
606
- elif hasattr(command,'__iter__'):
607
- sanitized_command = []
608
- for field in command:
609
- if isinstance(field,str):
610
- sanitized_command.append(field)
611
- else:
612
- sanitized_command.append(repr(field))
613
- if not expand:
614
- return [sanitized_command]
615
- sanitized_expanded_command = [_expand_ranges_fast(field) for field in sanitized_command]
616
- # now the command had been expanded to list of list of fields with each field expanded to all possible options
617
- # we need to generate all possible combinations of the fields
618
- # we will use the cartesian product to do this
619
- commands = list(itertools.product(*sanitized_expanded_command))
620
- return [list(command) for command in commands]
621
- else:
622
- return __format_command(str(command),expand=expand)
623
-
624
567
  def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,with_stdErr=False,
625
568
  return_code_only=False,return_object=False, parse = False, wait_for_return = True, sem : threading.Semaphore = None):
626
569
  '''
@@ -662,7 +605,7 @@ def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,wit
662
605
  for thread in threads:
663
606
  thread.join()
664
607
  else:
665
- __running_threads.extend(threads)
608
+ __running_threads.update(threads)
666
609
  else:
667
610
  # just process the commands sequentially
668
611
  sem = threading.Semaphore(1)
@@ -689,9 +632,27 @@ def join_threads(threads=__running_threads,timeout=None):
689
632
  @returns:
690
633
  None
691
634
  '''
635
+ global __running_threads
692
636
  for thread in threads:
693
637
  thread.join(timeout=timeout)
638
+ if threads is __running_threads:
639
+ __running_threads = {t for t in threads if t.is_alive()}
694
640
 
641
+ def main():
642
+ parser = argparse.ArgumentParser(description='Run multiple commands in parallel')
643
+ parser.add_argument('commands', metavar='command', type=str, nargs='+',help='commands to run')
644
+ parser.add_argument('-p','--parse', action='store_true',help='Parse ranged input and expand them into multiple commands')
645
+ parser.add_argument('-t','--timeout', metavar='timeout', type=int, default=60,help='timeout for each command')
646
+ parser.add_argument('-m','--max_threads', metavar='max_threads', type=int, default=1,help='maximum number of threads to use')
647
+ parser.add_argument('-q','--quiet', action='store_true',help='quiet mode')
648
+ parser.add_argument('-V','--version', action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} by pan@zopyr.us')
649
+ args = parser.parse_args()
650
+ run_commands(args.commands, args.timeout, args.max_threads, args.quiet,parse = args.parse, with_stdErr=True)
651
+
652
+ if __name__ == '__main__':
653
+ main()
654
+
655
+ #%% misc functions
695
656
  def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
696
657
  """
697
658
  Read an input from the user with a timeout and a countdown.
@@ -718,6 +679,148 @@ def input_with_timeout_and_countdown(timeout, prompt='Please enter your selectio
718
679
  # If there is no input, return None
719
680
  return None
720
681
 
682
+ def pretty_format_table(data, delimiter = '\t',header = None):
683
+ version = 1.11
684
+ _ = version
685
+ if not data:
686
+ return ''
687
+ if isinstance(data, str):
688
+ data = data.strip('\n').split('\n')
689
+ data = [line.split(delimiter) for line in data]
690
+ elif isinstance(data, dict):
691
+ # flatten the 2D dict to a list of lists
692
+ if isinstance(next(iter(data.values())), dict):
693
+ tempData = [['key'] + list(next(iter(data.values())).keys())]
694
+ tempData.extend( [[key] + list(value.values()) for key, value in data.items()])
695
+ data = tempData
696
+ else:
697
+ # it is a dict of lists
698
+ data = [[key] + list(value) for key, value in data.items()]
699
+ elif not isinstance(data, list):
700
+ data = list(data)
701
+ # format the list into 2d list of list of strings
702
+ if isinstance(data[0], dict):
703
+ tempData = [data[0].keys()]
704
+ tempData.extend([list(item.values()) for item in data])
705
+ data = tempData
706
+ data = [[str(item) for item in row] for row in data]
707
+ num_cols = len(data[0])
708
+ col_widths = [0] * num_cols
709
+ # Calculate the maximum width of each column
710
+ for c in range(num_cols):
711
+ #col_widths[c] = max(len(row[c]) for row in data)
712
+ # handle ansii escape sequences
713
+ col_widths[c] = max(len(re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]','',row[c])) for row in data)
714
+ if header:
715
+ header_widths = [len(re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', col)) for col in header]
716
+ col_widths = [max(col_widths[i], header_widths[i]) for i in range(num_cols)]
717
+ # Build the row format string
718
+ row_format = ' | '.join('{{:<{}}}'.format(width) for width in col_widths)
719
+ # Print the header
720
+ if not header:
721
+ header = data[0]
722
+ outTable = []
723
+ outTable.append(row_format.format(*header))
724
+ outTable.append('-+-'.join('-' * width for width in col_widths))
725
+ for row in data[1:]:
726
+ # if the row is empty, print an divider
727
+ if not any(row):
728
+ outTable.append('-+-'.join('-' * width for width in col_widths))
729
+ else:
730
+ outTable.append(row_format.format(*row))
731
+ else:
732
+ # pad / truncate header to appropriate length
733
+ if isinstance(header,str):
734
+ header = header.split(delimiter)
735
+ if len(header) < num_cols:
736
+ header += ['']*(num_cols-len(header))
737
+ elif len(header) > num_cols:
738
+ header = header[:num_cols]
739
+ outTable = []
740
+ outTable.append(row_format.format(*header))
741
+ outTable.append('-+-'.join('-' * width for width in col_widths))
742
+ for row in data:
743
+ # if the row is empty, print an divider
744
+ if not any(row):
745
+ outTable.append('-+-'.join('-' * width for width in col_widths))
746
+ else:
747
+ outTable.append(row_format.format(*row))
748
+ return '\n'.join(outTable) + '\n'
749
+
750
+ def parseTable(data,sort=False):
751
+ if isinstance(data, str):
752
+ data = data.strip('\n').split('\n')
753
+ header_line = data[0]
754
+ # Use regex to find column names and their positions
755
+ pattern = r'(\S(?:.*?\S)?)(?=\s{2,}|\s*$)'
756
+ matches = list(re.finditer(pattern, header_line))
757
+ data_list = [[]]
758
+ columns = []
759
+ for i, match in enumerate(matches):
760
+ col_name = match.group(1)
761
+ data_list[0].append(col_name)
762
+ start = match.start()
763
+ if i + 1 < len(matches):
764
+ end = matches[i+1].start()
765
+ else:
766
+ end = None # Last column goes till the end
767
+ columns.append((col_name, start, end))
768
+ for line in data[1:]:
769
+ if not line.strip():
770
+ continue # Skip empty lines
771
+ row = []
772
+ for col_name, start, end in columns:
773
+ if end is not None:
774
+ value = line[start:end].strip()
775
+ else:
776
+ value = line[start:].strip()
777
+ row.append(value)
778
+ data_list.append(row)
779
+ # sort data_list[1:] by the first column
780
+ if sort:
781
+ data_list[1:] = sorted(data_list[1:], key=lambda x: x[0])
782
+ return data_list
783
+
784
+ def slugify(value, allow_unicode=False):
785
+ import unicodedata
786
+ """
787
+ Taken from https://github.com/django/django/blob/master/django/utils/text.py
788
+ Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
789
+ dashes to single dashes. Remove characters that aren't alphanumerics,
790
+ underscores, or hyphens. Convert to lowercase. Also strip leading and
791
+ trailing whitespace, dashes, and underscores.
792
+ """
793
+ value = str(value)
794
+ if allow_unicode:
795
+ value = unicodedata.normalize('NFKC', value)
796
+ else:
797
+ value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
798
+ value = re.sub(r'[^\w\s-]', '', value.lower())
799
+ return re.sub(r'[-\s]+', '-', value).strip('-_')
800
+
801
+ def get_terminal_size():
802
+ '''
803
+ Get the terminal size
804
+
805
+ @params:
806
+ None
807
+
808
+ @returns:
809
+ (int,int): the number of columns and rows of the terminal
810
+ '''
811
+ try:
812
+ import os
813
+ _tsize = os.get_terminal_size()
814
+ except:
815
+ try:
816
+ import fcntl, termios, struct
817
+ packed = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
818
+ _tsize = struct.unpack('HHHH', packed)[:2]
819
+ except:
820
+ import shutil
821
+ _tsize = shutil.get_terminal_size(fallback=(120, 30))
822
+ return _tsize
823
+
721
824
  def _genrate_progress_bar(iteration, total, prefix='', suffix='',columns=120):
722
825
  '''
723
826
  Generate a progress bar string
@@ -777,29 +880,6 @@ def _genrate_progress_bar(iteration, total, prefix='', suffix='',columns=120):
777
880
  lineOut += suffix
778
881
  return lineOut
779
882
 
780
- def get_terminal_size():
781
- '''
782
- Get the terminal size
783
-
784
- @params:
785
- None
786
-
787
- @returns:
788
- (int,int): the number of columns and rows of the terminal
789
- '''
790
- try:
791
- import os
792
- _tsize = os.get_terminal_size()
793
- except:
794
- try:
795
- import fcntl, termios, struct
796
- packed = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
797
- _tsize = struct.unpack('HHHH', packed)[:2]
798
- except:
799
- import shutil
800
- _tsize = shutil.get_terminal_size(fallback=(120, 30))
801
- return _tsize
802
-
803
883
  def print_progress_bar(iteration, total, prefix='', suffix=''):
804
884
  '''
805
885
  Call in a loop to create terminal progress bar
@@ -823,17 +903,3 @@ def print_progress_bar(iteration, total, prefix='', suffix=''):
823
903
  except:
824
904
  if iteration % 5 == 0:
825
905
  print(_genrate_progress_bar(iteration, total, prefix, suffix))
826
-
827
- def main():
828
- parser = argparse.ArgumentParser(description='Run multiple commands in parallel')
829
- parser.add_argument('commands', metavar='command', type=str, nargs='+',help='commands to run')
830
- parser.add_argument('-p','--parse', action='store_true',help='Parse ranged input and expand them into multiple commands')
831
- parser.add_argument('-t','--timeout', metavar='timeout', type=int, default=60,help='timeout for each command')
832
- parser.add_argument('-m','--max_threads', metavar='max_threads', type=int, default=1,help='maximum number of threads to use')
833
- parser.add_argument('-q','--quiet', action='store_true',help='quiet mode')
834
- parser.add_argument('-V','--version', action='version', version=f'%(prog)s {version} by pan@zopyr.us')
835
- args = parser.parse_args()
836
- run_commands(args.commands, args.timeout, args.max_threads, args.quiet,parse = args.parse, with_stdErr=True)
837
-
838
- if __name__ == '__main__':
839
- main()
File without changes
File without changes