pyallel 0.13.0__tar.gz → 0.15.0__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.1
2
2
  Name: pyallel
3
- Version: 0.13.0
3
+ Version: 0.15.0
4
4
  Summary: Run and handle the output of multiple executables in pyallel (as in parallel)
5
5
  Home-page: https://github.com/Danthewaann/pyallel
6
6
  License: MIT
@@ -40,7 +40,7 @@ pip install pyallel
40
40
  Once installed, you can run `pyallel` to see usage information, like so:
41
41
 
42
42
  ```
43
- usage: pyallel [-h] [-t] [-n] [-V] [-v] [commands ...]
43
+ usage: pyallel [-h] [-t] [-n] [-V] [-v] [--colour {yes,no,auto}] [commands ...]
44
44
 
45
45
  Run and handle the output of multiple executables in pyallel (as in parallel)
46
46
 
@@ -58,6 +58,8 @@ options:
58
58
  run in non-interactive mode
59
59
  -V, --verbose run in verbose mode
60
60
  -v, --version print version and exit
61
+ --colour {yes,no,auto}
62
+ colour terminal output, defaults to "auto"
61
63
  ```
62
64
 
63
65
  Currently you can provide a variable number of `commands` to run to `pyallel`, like so:
@@ -94,6 +96,7 @@ pyallel "MYPY_FORCE_COLOR=1 mypy ." \
94
96
  - [x] Maybe make tail mode followed by an optional dump of all the command output once it
95
97
  finishes the default behaviour?
96
98
  - [x] Add graceful Ctrl-C interrupt handling to streamed modes
99
+ - [x] Add a `--colour` flag to configure pyallel print colours
97
100
  - [ ] Setup build system to convert `pyallel` into a single file executable for ease of
98
101
  use and distribution
99
102
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
@@ -19,7 +19,7 @@ pip install pyallel
19
19
  Once installed, you can run `pyallel` to see usage information, like so:
20
20
 
21
21
  ```
22
- usage: pyallel [-h] [-t] [-n] [-V] [-v] [commands ...]
22
+ usage: pyallel [-h] [-t] [-n] [-V] [-v] [--colour {yes,no,auto}] [commands ...]
23
23
 
24
24
  Run and handle the output of multiple executables in pyallel (as in parallel)
25
25
 
@@ -37,6 +37,8 @@ options:
37
37
  run in non-interactive mode
38
38
  -V, --verbose run in verbose mode
39
39
  -v, --version print version and exit
40
+ --colour {yes,no,auto}
41
+ colour terminal output, defaults to "auto"
40
42
  ```
41
43
 
42
44
  Currently you can provide a variable number of `commands` to run to `pyallel`, like so:
@@ -73,6 +75,7 @@ pyallel "MYPY_FORCE_COLOR=1 mypy ." \
73
75
  - [x] Maybe make tail mode followed by an optional dump of all the command output once it
74
76
  finishes the default behaviour?
75
77
  - [x] Add graceful Ctrl-C interrupt handling to streamed modes
78
+ - [x] Add a `--colour` flag to configure pyallel print colours
76
79
  - [ ] Setup build system to convert `pyallel` into a single file executable for ease of
77
80
  use and distribution
78
81
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyallel"
3
- version = "0.13.0"
3
+ version = "0.15.0"
4
4
  description = "Run and handle the output of multiple executables in pyallel (as in parallel)"
5
5
  authors = ["Daniel Black <danielcrblack@gmail.com>"]
6
6
  license = "MIT"
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields
4
+ from typing import Literal
5
+ from pyallel import constants
6
+
7
+
8
+ @dataclass
9
+ class Colours:
10
+ white_bold: str = "\033[1m"
11
+ green_bold: str = "\033[1;32m"
12
+ blue_bold: str = "\033[1;34m"
13
+ red_bold: str = "\033[1;31m"
14
+ yellow_bold: str = "\033[1;33m"
15
+ clear_line: str = "\033[2K"
16
+ clear_screen: str = "\033[2J"
17
+ save_cursor: str = "\033[s"
18
+ restore_cursor: str = "\033[u"
19
+ up_line: str = "\033[1F"
20
+ reset_colour: str = "\033[0m"
21
+ dim_on: str = "\033[2m"
22
+ dim_off: str = "\033[22m"
23
+
24
+ @classmethod
25
+ def from_colour(cls, colour: Literal["yes", "no", "auto"]) -> Colours:
26
+ colours = cls()
27
+
28
+ if colour == "no" or colour == "auto" and not constants.IN_TTY:
29
+ for field in fields(colours):
30
+ setattr(colours, field.name, "")
31
+
32
+ return colours
@@ -0,0 +1,21 @@
1
+ import os
2
+ import sys
3
+
4
+ IN_TTY = sys.stdout.isatty()
5
+
6
+
7
+ if IN_TTY:
8
+
9
+ def LINES() -> int:
10
+ return os.get_terminal_size().lines
11
+
12
+ else:
13
+
14
+ def LINES() -> int:
15
+ return sys.maxsize
16
+
17
+
18
+ ICONS = ("/", "-", "\\", "|")
19
+ # Unicode character bytes to render different symbols in the terminal
20
+ TICK = "\u2713"
21
+ X = "\u2717"
@@ -3,21 +3,26 @@ from __future__ import annotations
3
3
  import sys
4
4
  import traceback
5
5
  import importlib.metadata
6
+ from pyallel.colours import Colours
6
7
  from pyallel.errors import InvalidExecutableErrors
7
8
 
8
9
  from pyallel.parser import Arguments, create_parser
9
10
  from pyallel.process import ProcessGroup
10
- from pyallel import constants
11
11
 
12
12
 
13
13
  def main_loop(
14
14
  *commands: str,
15
+ colours: Colours,
15
16
  interactive: bool = False,
16
17
  timer: bool = False,
17
18
  verbose: bool = False,
18
19
  ) -> int:
19
20
  process_group = ProcessGroup.from_commands(
20
- *commands, interactive=interactive, timer=timer, verbose=verbose
21
+ *commands,
22
+ colours=colours,
23
+ interactive=interactive,
24
+ timer=timer,
25
+ verbose=verbose,
21
26
  )
22
27
 
23
28
  return process_group.stream()
@@ -36,10 +41,13 @@ def run(*args: str) -> int:
36
41
  parser.print_help()
37
42
  return 2
38
43
 
44
+ colours = Colours.from_colour(parsed_args.colour)
45
+
39
46
  message = None
40
47
  try:
41
48
  exit_code = main_loop(
42
49
  *parsed_args.commands,
50
+ colours=colours,
43
51
  interactive=parsed_args.interactive,
44
52
  timer=parsed_args.timer,
45
53
  verbose=parsed_args.verbose,
@@ -51,15 +59,13 @@ def run(*args: str) -> int:
51
59
  exit_code = 1
52
60
  message = traceback.format_exc()
53
61
 
54
- if exit_code == 2:
55
- print(f"{constants.YELLOW_BOLD}Interrupt!{constants.RESET_COLOUR}")
56
- elif exit_code == 1:
62
+ if exit_code == 1:
57
63
  if not message:
58
- print(f"{constants.RED_BOLD}A command failed!{constants.RESET_COLOUR}")
64
+ print(f"{colours.red_bold}A command failed!{colours.reset_colour}")
59
65
  else:
60
- print(f"{constants.RED_BOLD}Error: {message}{constants.RESET_COLOUR}")
61
- else:
62
- print(f"{constants.GREEN_BOLD}Success!{constants.RESET_COLOUR}")
66
+ print(f"{colours.red_bold}Error: {message}{colours.reset_colour}")
67
+ elif exit_code == 0:
68
+ print(f"{colours.green_bold}Success!{colours.reset_colour}")
63
69
 
64
70
  return exit_code
65
71
 
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from argparse import ArgumentParser, RawTextHelpFormatter
4
+ from typing import Literal
4
5
 
5
6
 
6
7
  class Arguments:
8
+ colour: Literal["yes", "no", "auto"]
7
9
  commands: list[str]
8
10
  interactive: bool
9
11
  timer: bool
@@ -67,5 +69,11 @@ def create_parser() -> ArgumentParser:
67
69
  action="store_true",
68
70
  default=False,
69
71
  )
72
+ parser.add_argument(
73
+ "--colour",
74
+ help="colour terminal output, defaults to \"%(default)s\"",
75
+ choices=("yes", "no", "auto"),
76
+ default="auto",
77
+ )
70
78
 
71
79
  return parser
@@ -9,23 +9,14 @@ import shlex
9
9
  import shutil
10
10
  import os
11
11
  from uuid import UUID, uuid4
12
- from typing import BinaryIO
12
+ from typing import Any, BinaryIO
13
13
  from pyallel import constants
14
14
 
15
15
  from dataclasses import dataclass, field
16
+ from pyallel.colours import Colours
16
17
  from pyallel.errors import InvalidExecutableErrors, InvalidExecutableError
17
18
 
18
19
 
19
- def prefix(output: str, keepend: bool = True) -> str:
20
- prefixed_output = "\n".join(
21
- f"{constants.DIM_ON}=>{constants.DIM_OFF} {line}{constants.RESET_COLOUR}"
22
- for line in output.splitlines()
23
- )
24
- if keepend and output and output[-1] == "\n":
25
- prefixed_output += "\n"
26
- return prefixed_output
27
-
28
-
29
20
  def format_time_taken(time_taken: float) -> str:
30
21
  time_taken = round(time_taken, 1)
31
22
  seconds = time_taken % (24 * 3600)
@@ -33,47 +24,6 @@ def format_time_taken(time_taken: float) -> str:
33
24
  return f"{seconds}s"
34
25
 
35
26
 
36
- def get_command_status(
37
- process: Process,
38
- icon: str | None = None,
39
- passed: bool | None = None,
40
- verbose: bool = False,
41
- timer: bool = False,
42
- ) -> str:
43
- if passed is True:
44
- colour = constants.GREEN_BOLD
45
- msg = "done"
46
- icon = icon or constants.TICK
47
- elif passed is False:
48
- colour = constants.RED_BOLD
49
- msg = "failed"
50
- icon = icon or constants.X
51
- else:
52
- colour = constants.WHITE_BOLD
53
- msg = "running"
54
- icon = icon or ""
55
- if not icon:
56
- msg += "..."
57
-
58
- output = f"[{constants.BLUE_BOLD}{process.name}"
59
-
60
- if verbose:
61
- output += f" {' '.join(process.args)}"
62
-
63
- output += f"{constants.RESET_COLOUR}]{colour} {msg} {icon}{constants.RESET_COLOUR}"
64
-
65
- if timer:
66
- end = process.end
67
- if not process.end:
68
- end = time.perf_counter()
69
- elapsed = end - process.start
70
- output += (
71
- f" ({constants.DIM_ON}{format_time_taken(elapsed)}{constants.DIM_OFF})"
72
- )
73
-
74
- return output
75
-
76
-
77
27
  @dataclass
78
28
  class ProcessGroup:
79
29
  processes: list[Process]
@@ -83,8 +33,11 @@ class ProcessGroup:
83
33
  output: dict[UUID, list[str]] = field(default_factory=lambda: defaultdict(list))
84
34
  process_lines: list[int] = field(default_factory=list)
85
35
  completed_processes: set[UUID] = field(default_factory=set)
36
+ exit_code: int = 0
37
+ interrupt_count: int = 0
86
38
  passed: bool = True
87
39
  icon: int = 0
40
+ colours: Colours = field(default_factory=Colours)
88
41
 
89
42
  def stream(self) -> int:
90
43
  for process in self.processes:
@@ -93,110 +46,177 @@ class ProcessGroup:
93
46
  if not self.interactive:
94
47
  return self.stream_non_interactive()
95
48
 
96
- interrupted = False
97
-
98
49
  while True:
99
- try:
100
- output = self.complete_output()
101
- self.icon += 1
102
- if self.icon == len(constants.ICONS):
103
- self.icon = 0
50
+ output = self.complete_output()
51
+ self.icon += 1
52
+ if self.icon == len(constants.ICONS):
53
+ self.icon = 0
104
54
 
105
- # Clear the screen and print the output
106
- print(f"\033[H\033[0J{output}", end="")
55
+ # Clear the screen and print the output
56
+ print(f"\033[H\033[0J{output}", end="")
107
57
 
108
- # Clear the screen again
109
- print("\033[H\033[0J", end="")
58
+ # Clear the screen again
59
+ print("\033[H\033[0J", end="")
110
60
 
111
- if len(self.completed_processes) == len(self.processes):
112
- break
61
+ if len(self.completed_processes) == len(self.processes):
62
+ break
113
63
 
114
- time.sleep(0.1)
115
- except KeyboardInterrupt:
116
- interrupted = True
117
- for process in self.processes:
118
- process.interrupt()
119
- process.wait()
64
+ time.sleep(0.1)
120
65
 
121
66
  output = self.complete_output(all=True)
122
67
  # Clear the screen one final time before printing the output
123
68
  print(f"\033[3J{output}")
124
69
 
125
- return 2 if interrupted else (1 if not self.passed else 0)
70
+ if not self.exit_code and not self.passed:
71
+ self.exit_code = 1
72
+
73
+ return self.exit_code
126
74
 
127
75
  def stream_non_interactive(self) -> int:
128
76
  running_process = None
129
77
  interrupted = False
130
78
 
131
- print(f"{constants.WHITE_BOLD}Running commands...{constants.RESET_COLOUR}\n")
79
+ print(
80
+ f"{self.colours.white_bold}Running commands...{self.colours.reset_colour}\n"
81
+ )
132
82
 
133
83
  while True:
134
- try:
135
- output = ""
136
- for process in self.processes:
137
- if (
138
- running_process is None
139
- and process.id not in self.completed_processes
84
+ output = ""
85
+ for process in self.processes:
86
+ if (
87
+ running_process is None
88
+ and process.id not in self.completed_processes
89
+ ):
90
+ output += self._get_command_status(process, verbose=self.verbose)
91
+ output += "\n"
92
+ running_process = process
93
+ elif running_process is not process:
94
+ # Need to do this to properly keep track of how long all the other
95
+ # commands are taking
96
+ process.poll()
97
+ continue
98
+
99
+ process_output = process.readline().decode()
100
+
101
+ if not self.output[process.id] and process_output:
102
+ process_output = self._prefix(process_output)
103
+ self.output[process.id].append(process_output)
104
+ output += process_output
105
+ elif process_output:
106
+ if self.output[process.id][-1][-1] != "\n":
107
+ self.output[process.id][-1] += process_output
108
+ else:
109
+ process_output = self._prefix(process_output)
110
+ self.output[process.id].append(process_output)
111
+ output += process_output
112
+
113
+ if process.poll() is not None:
114
+ if process.return_code() != 0:
115
+ self.passed = False
116
+ process_output = process.read().decode()
117
+ if process_output:
118
+ output += self._prefix(process_output)
119
+
120
+ output += self._get_command_status(
121
+ process,
122
+ passed=process.return_code() == 0,
123
+ verbose=self.verbose,
124
+ timer=self.timer,
125
+ )
126
+ output += "\n\n"
127
+ self.completed_processes.add(process.id)
128
+ running_process = None
129
+
130
+ if self.interrupt_count == 0:
131
+ pass
132
+ elif not interrupted and self.interrupt_count == 1:
133
+ if (output and output[-1] != "\n") or (
134
+ self.output[process.id]
135
+ and self.output[process.id][-1][-1] != "\n"
140
136
  ):
141
- output += get_command_status(process, verbose=self.verbose)
142
137
  output += "\n"
143
- running_process = process
144
- elif running_process is not process:
145
- # Need to do this to properly keep track of how long all the other
146
- # commands are taking
147
- process.poll()
148
- continue
138
+ output += f"\n{self.colours.yellow_bold}Interrupt!{self.colours.reset_colour}\n\n"
139
+ interrupted = True
149
140
 
150
- process_output = process.readline().decode()
141
+ if output:
142
+ print(output, end="")
151
143
 
152
- if not self.output[process.id] and process_output:
153
- process_output = prefix(process_output)
154
- self.output[process.id].append(process_output)
155
- output += process_output
156
- elif process_output:
157
- if self.output[process.id][-1][-1] != "\n":
158
- self.output[process.id][-1] += process_output
159
- else:
160
- process_output = prefix(process_output)
161
- self.output[process.id].append(process_output)
162
- output += process_output
163
-
164
- if process.poll() is not None:
165
- if process.return_code() != 0:
166
- self.passed = False
167
- process_output = process.read().decode()
168
- if process_output:
169
- output += prefix(process_output)
170
-
171
- output += get_command_status(
172
- process,
173
- passed=process.return_code() == 0,
174
- verbose=self.verbose,
175
- timer=self.timer,
176
- )
177
- output += "\n\n"
178
- self.completed_processes.add(process.id)
179
- running_process = None
180
-
181
- if output:
182
- print(output, end="")
183
-
184
- if len(self.completed_processes) == len(self.processes):
185
- break
186
-
187
- time.sleep(0.01)
188
- except KeyboardInterrupt:
189
- interrupted = True
190
- for process in self.processes:
191
- process.interrupt()
192
- process.wait()
193
-
194
- return 2 if interrupted else (1 if not self.passed else 0)
144
+ if len(self.completed_processes) == len(self.processes):
145
+ break
146
+
147
+ time.sleep(0.01)
148
+
149
+ if self.interrupt_count == 2:
150
+ print(f"{self.colours.red_bold}Abort!{self.colours.reset_colour}")
151
+
152
+ if not self.exit_code and not self.passed:
153
+ self.exit_code = 1
154
+
155
+ return self.exit_code
156
+
157
+ def _prefix(self, output: str, keepend: bool = True) -> str:
158
+ prefixed_output = "\n".join(
159
+ f"{self.colours.dim_on}=>{self.colours.dim_off} {line}{self.colours.reset_colour}"
160
+ for line in output.splitlines()
161
+ )
162
+ if keepend and output and output[-1] == "\n":
163
+ prefixed_output += "\n"
164
+ return prefixed_output
165
+
166
+ def _get_command_status(
167
+ self,
168
+ process: Process,
169
+ icon: str | None = None,
170
+ passed: bool | None = None,
171
+ verbose: bool = False,
172
+ timer: bool = False,
173
+ ) -> str:
174
+ if passed is True:
175
+ colour = self.colours.green_bold
176
+ msg = "done"
177
+ icon = icon or constants.TICK
178
+ elif passed is False:
179
+ colour = self.colours.red_bold
180
+ msg = "failed"
181
+ icon = icon or constants.X
182
+ else:
183
+ colour = self.colours.white_bold
184
+ msg = "running"
185
+ icon = icon or ""
186
+ if not icon:
187
+ msg += "..."
188
+
189
+ output = f"[{self.colours.blue_bold}{process.name}"
190
+
191
+ if verbose:
192
+ output += f" {' '.join(process.args)}"
193
+
194
+ output += f"{self.colours.reset_colour}]{colour} {msg} {icon}{self.colours.reset_colour}"
195
+
196
+ if timer:
197
+ end = process.end
198
+ if not process.end:
199
+ end = time.perf_counter()
200
+ elapsed = end - process.start
201
+ output += f" ({self.colours.dim_on}{format_time_taken(elapsed)}{self.colours.dim_off})"
202
+
203
+ return output
204
+
205
+ def _handle_signal(self, signum: int, _frame: Any) -> None:
206
+ for process in self.processes:
207
+ if self.interrupt_count == 0:
208
+ process.interrupt()
209
+ else:
210
+ process.kill()
211
+
212
+ self.exit_code = 128 + signum
213
+ self.interrupt_count += 1
195
214
 
196
215
  @classmethod
197
216
  def from_commands(
198
217
  cls,
199
218
  *commands: str,
219
+ colours: Colours,
200
220
  interactive: bool = False,
201
221
  timer: bool = False,
202
222
  verbose: bool = False,
@@ -213,13 +233,19 @@ class ProcessGroup:
213
233
  if errors:
214
234
  raise InvalidExecutableErrors(*errors)
215
235
 
216
- return cls(
236
+ process_group = cls(
217
237
  processes=processes,
218
238
  interactive=interactive,
219
239
  timer=timer,
220
240
  verbose=verbose,
241
+ colours=colours,
221
242
  )
222
243
 
244
+ signal.signal(signal.SIGINT, process_group._handle_signal)
245
+ signal.signal(signal.SIGTERM, process_group._handle_signal)
246
+
247
+ return process_group
248
+
223
249
  def complete_output(self, tail: int = 20, all: bool = False) -> str:
224
250
  num_processes = len(self.processes)
225
251
  lines = constants.LINES() - (2 * num_processes)
@@ -243,7 +269,7 @@ class ProcessGroup:
243
269
  self.completed_processes.add(process.id)
244
270
  if process.return_code() != 0:
245
271
  self.passed = False
246
- output += get_command_status(
272
+ output += self._get_command_status(
247
273
  process,
248
274
  passed=process.return_code() == 0,
249
275
  verbose=self.verbose,
@@ -251,7 +277,7 @@ class ProcessGroup:
251
277
  )
252
278
  output += "\n"
253
279
  else:
254
- output += get_command_status(
280
+ output += self._get_command_status(
255
281
  process,
256
282
  icon=constants.ICONS[self.icon],
257
283
  verbose=self.verbose,
@@ -270,12 +296,22 @@ class ProcessGroup:
270
296
  process_output.splitlines()[-self.process_lines[i - 1] :]
271
297
  )
272
298
  process_output += "\n"
273
- output += prefix(process_output)
299
+ output += self._prefix(process_output)
274
300
  if output and output[-1] != "\n":
275
301
  output += "\n"
276
302
  if i != num_processes:
277
303
  output += "\n"
278
304
 
305
+ if self.interrupt_count == 0:
306
+ return output
307
+
308
+ if self.interrupt_count == 1:
309
+ output += (
310
+ f"\n{self.colours.yellow_bold}Interrupt!{self.colours.reset_colour}"
311
+ )
312
+ elif self.interrupt_count == 2:
313
+ output += f"\n{self.colours.red_bold}Abort!{self.colours.reset_colour}"
314
+
279
315
  return output
280
316
 
281
317
 
@@ -341,6 +377,10 @@ class Process:
341
377
  if self._process:
342
378
  self._process.send_signal(signal.SIGINT)
343
379
 
380
+ def kill(self) -> None:
381
+ if self._process:
382
+ self._process.send_signal(signal.SIGKILL)
383
+
344
384
  def wait(self) -> int:
345
385
  if self._process:
346
386
  return self._process.wait()
@@ -1,55 +0,0 @@
1
- import os
2
- import sys
3
-
4
- IN_TTY = sys.stdout.isatty()
5
-
6
-
7
- if IN_TTY:
8
- WHITE_BOLD = "\033[1m"
9
- GREEN_BOLD = "\033[1;32m"
10
- BLUE_BOLD = "\033[1;34m"
11
- RED_BOLD = "\033[1;31m"
12
- YELLOW_BOLD = "\033[1;33m"
13
- CLEAR_LINE = "\033[2K"
14
- CLEAR_SCREEN = "\033[2J"
15
- SAVE_CURSOR = "\033[s"
16
- RESTORE_CURSOR = "\033[u"
17
- UP_LINE = "\033[1F"
18
- RESET_COLOUR = "\033[0m"
19
- CARRIAGE_RETURN = "\r"
20
- DIM_ON = "\033[2m"
21
- DIM_OFF = "\033[22m"
22
-
23
- def COLUMNS() -> int:
24
- return os.get_terminal_size().columns
25
-
26
- def LINES() -> int:
27
- return os.get_terminal_size().lines
28
-
29
- else:
30
- WHITE_BOLD = ""
31
- GREEN_BOLD = ""
32
- BLUE_BOLD = ""
33
- RED_BOLD = ""
34
- YELLOW_BOLD = ""
35
- CLEAR_LINE = ""
36
- CLEAR_SCREEN = ""
37
- SAVE_CURSOR = ""
38
- RESTORE_CURSOR = ""
39
- UP_LINE = ""
40
- RESET_COLOUR = ""
41
- CARRIAGE_RETURN = ""
42
- DIM_ON = ""
43
- DIM_OFF = ""
44
-
45
- def COLUMNS() -> int:
46
- return sys.maxsize
47
-
48
- def LINES() -> int:
49
- return sys.maxsize
50
-
51
-
52
- ICONS = ("/", "-", "\\", "|")
53
- # Unicode character bytes to render different symbols in the terminal
54
- TICK = "\u2713"
55
- X = "\u2717"
File without changes
File without changes
File without changes