xulbux 1.9.5__cp311-cp311-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 455848faf89d8974b22a__mypyc.cpython-311-darwin.so +0 -0
- xulbux/__init__.cpython-311-darwin.so +0 -0
- xulbux/__init__.py +46 -0
- xulbux/base/consts.cpython-311-darwin.so +0 -0
- xulbux/base/consts.py +172 -0
- xulbux/base/decorators.cpython-311-darwin.so +0 -0
- xulbux/base/decorators.py +28 -0
- xulbux/base/exceptions.cpython-311-darwin.so +0 -0
- xulbux/base/exceptions.py +23 -0
- xulbux/base/types.cpython-311-darwin.so +0 -0
- xulbux/base/types.py +118 -0
- xulbux/cli/help.cpython-311-darwin.so +0 -0
- xulbux/cli/help.py +77 -0
- xulbux/code.cpython-311-darwin.so +0 -0
- xulbux/code.py +137 -0
- xulbux/color.cpython-311-darwin.so +0 -0
- xulbux/color.py +1331 -0
- xulbux/console.cpython-311-darwin.so +0 -0
- xulbux/console.py +2069 -0
- xulbux/data.cpython-311-darwin.so +0 -0
- xulbux/data.py +798 -0
- xulbux/env_path.cpython-311-darwin.so +0 -0
- xulbux/env_path.py +123 -0
- xulbux/file.cpython-311-darwin.so +0 -0
- xulbux/file.py +74 -0
- xulbux/file_sys.cpython-311-darwin.so +0 -0
- xulbux/file_sys.py +266 -0
- xulbux/format_codes.cpython-311-darwin.so +0 -0
- xulbux/format_codes.py +722 -0
- xulbux/json.cpython-311-darwin.so +0 -0
- xulbux/json.py +200 -0
- xulbux/regex.cpython-311-darwin.so +0 -0
- xulbux/regex.py +247 -0
- xulbux/string.cpython-311-darwin.so +0 -0
- xulbux/string.py +161 -0
- xulbux/system.cpython-311-darwin.so +0 -0
- xulbux/system.py +313 -0
- xulbux-1.9.5.dist-info/METADATA +271 -0
- xulbux-1.9.5.dist-info/RECORD +43 -0
- xulbux-1.9.5.dist-info/WHEEL +6 -0
- xulbux-1.9.5.dist-info/entry_points.txt +2 -0
- xulbux-1.9.5.dist-info/licenses/LICENSE +21 -0
- xulbux-1.9.5.dist-info/top_level.txt +2 -0
xulbux/console.py
ADDED
|
@@ -0,0 +1,2069 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the `Console`, `ProgressBar`, and `Spinner` classes
|
|
3
|
+
which offer methods for logging and other actions within the console.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .base.types import ProgressUpdater, AllTextChars, ArgParseConfigs, ArgParseConfig, ArgData, Rgba, Hexa
|
|
7
|
+
from .base.decorators import mypyc_attr
|
|
8
|
+
from .base.consts import COLOR, CHARS, ANSI
|
|
9
|
+
|
|
10
|
+
from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes
|
|
11
|
+
from .string import String
|
|
12
|
+
from .color import Color, hexa
|
|
13
|
+
from .regex import LazyRegex
|
|
14
|
+
|
|
15
|
+
from typing import Generator, Callable, Optional, Literal, TypeVar, TextIO, Any, overload, cast
|
|
16
|
+
from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings
|
|
17
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
|
18
|
+
from prompt_toolkit.styles import Style
|
|
19
|
+
from prompt_toolkit.keys import Keys
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from io import StringIO
|
|
22
|
+
import prompt_toolkit as _pt
|
|
23
|
+
import threading as _threading
|
|
24
|
+
import keyboard as _keyboard
|
|
25
|
+
import getpass as _getpass
|
|
26
|
+
import ctypes as _ctypes
|
|
27
|
+
import shutil as _shutil
|
|
28
|
+
import regex as _rx
|
|
29
|
+
import time as _time
|
|
30
|
+
import sys as _sys
|
|
31
|
+
import os as _os
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
_PATTERNS = LazyRegex(
|
|
37
|
+
hr=r"(?i){hr}",
|
|
38
|
+
hr_no_nl=r"(?i)(?<!\n){hr}(?!\n)",
|
|
39
|
+
hr_r_nl=r"(?i)(?<!\n){hr}(?=\n)",
|
|
40
|
+
hr_l_nl=r"(?i)(?<=\n){hr}(?!\n)",
|
|
41
|
+
label=r"(?i){(?:label|l)}",
|
|
42
|
+
bar=r"(?i){(?:bar|b)}",
|
|
43
|
+
current=r"(?i){(?:current|c)(?::(.))?}",
|
|
44
|
+
total=r"(?i){(?:total|t)(?::(.))?}",
|
|
45
|
+
percentage=r"(?i){(?:percentage|percent|p)(?::\.([0-9])+f)?}",
|
|
46
|
+
animation=r"(?i){(?:animation|a)}",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ParsedArgData:
|
|
51
|
+
"""Represents the result of a parsed command-line argument, containing the attributes listed below.\n
|
|
52
|
+
------------------------------------------------------------------------------------------------------------
|
|
53
|
+
- `exists` - whether the argument was found in the command-line arguments or not
|
|
54
|
+
- `is_pos` - whether the argument is a positional `"before"`/`"after"` argument or not
|
|
55
|
+
- `values` - the list of values associated with the argument
|
|
56
|
+
- `flag` - the specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args\n
|
|
57
|
+
------------------------------------------------------------------------------------------------------------
|
|
58
|
+
When the `ParsedArgData` instance is accessed as a boolean it will correspond to the `exists` attribute."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, exists: bool, values: list[str], is_pos: bool, flag: Optional[str] = None):
|
|
61
|
+
self.exists: bool = exists
|
|
62
|
+
"""Whether the argument was found or not."""
|
|
63
|
+
self.is_pos: bool = is_pos
|
|
64
|
+
"""Whether the argument is a positional argument or not."""
|
|
65
|
+
self.values: list[str] = values
|
|
66
|
+
"""The list of values associated with the argument."""
|
|
67
|
+
self.flag: Optional[str] = flag
|
|
68
|
+
"""The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args."""
|
|
69
|
+
|
|
70
|
+
def __bool__(self) -> bool:
|
|
71
|
+
"""Whether the argument was found or not (i.e. the `exists` attribute)."""
|
|
72
|
+
return self.exists
|
|
73
|
+
|
|
74
|
+
def __eq__(self, other: object) -> bool:
|
|
75
|
+
"""Check if two `ParsedArgData` objects are equal by comparing their attributes."""
|
|
76
|
+
if not isinstance(other, ParsedArgData):
|
|
77
|
+
return False
|
|
78
|
+
return (
|
|
79
|
+
self.exists == other.exists \
|
|
80
|
+
and self.is_pos == other.is_pos
|
|
81
|
+
and self.values == other.values
|
|
82
|
+
and self.flag == other.flag
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __ne__(self, other: object) -> bool:
|
|
86
|
+
"""Check if two `ParsedArgData` objects are not equal by comparing their attributes."""
|
|
87
|
+
return not self.__eq__(other)
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"ParsedArgData(\n exists = {self.exists!r},\n is_pos = {self.is_pos!r},\n values = {self.values!r},\n flag = {self.flag!r}\n)"
|
|
91
|
+
|
|
92
|
+
def __str__(self) -> str:
|
|
93
|
+
return self.__repr__()
|
|
94
|
+
|
|
95
|
+
def dict(self) -> ArgData:
|
|
96
|
+
"""Returns the argument result as a dictionary."""
|
|
97
|
+
return ArgData(exists=self.exists, is_pos=self.is_pos, values=self.values, flag=self.flag)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@mypyc_attr(native_class=False)
|
|
101
|
+
class ParsedArgs:
|
|
102
|
+
"""Container for parsed command-line arguments, allowing attribute-style access.\n
|
|
103
|
+
-----------------------------------------------------------------------------------
|
|
104
|
+
- `**parsed_args` -⠀a mapping of argument aliases to their corresponding data
|
|
105
|
+
saved in an `ParsedArgData` object\n
|
|
106
|
+
-----------------------------------------------------------------------------------
|
|
107
|
+
For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
|
|
108
|
+
Each such attribute (e.g. `args.foo`) is an instance of `ParsedArgData`."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, **parsed_args: ParsedArgData):
|
|
111
|
+
for alias_name, parsed_arg_data in parsed_args.items():
|
|
112
|
+
setattr(self, alias_name, parsed_arg_data)
|
|
113
|
+
|
|
114
|
+
def __len__(self):
|
|
115
|
+
"""The number of arguments stored in the `ParsedArgs` object."""
|
|
116
|
+
return len(vars(self))
|
|
117
|
+
|
|
118
|
+
def __contains__(self, key):
|
|
119
|
+
"""Checks if an argument with the given alias exists in the `ParsedArgs` object."""
|
|
120
|
+
return key in vars(self)
|
|
121
|
+
|
|
122
|
+
def __bool__(self) -> bool:
|
|
123
|
+
"""Whether the `ParsedArgs` object contains any arguments."""
|
|
124
|
+
return len(self) > 0
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, name: str) -> ParsedArgData:
|
|
127
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}")
|
|
128
|
+
|
|
129
|
+
def __getitem__(self, key):
|
|
130
|
+
if isinstance(key, int):
|
|
131
|
+
return list(self.__iter__())[key]
|
|
132
|
+
return getattr(self, key)
|
|
133
|
+
|
|
134
|
+
def __iter__(self) -> Generator[tuple[str, ParsedArgData], None, None]:
|
|
135
|
+
for key, val in cast(dict[str, ParsedArgData], vars(self)).items():
|
|
136
|
+
yield (key, val)
|
|
137
|
+
|
|
138
|
+
def __eq__(self, other: object) -> bool:
|
|
139
|
+
"""Check if two `ParsedArgs` objects are equal by comparing their stored arguments."""
|
|
140
|
+
if not isinstance(other, ParsedArgs):
|
|
141
|
+
return False
|
|
142
|
+
return vars(self) == vars(other)
|
|
143
|
+
|
|
144
|
+
def __ne__(self, other: object) -> bool:
|
|
145
|
+
"""Check if two `ParsedArgs` objects are not equal by comparing their stored arguments."""
|
|
146
|
+
return not self.__eq__(other)
|
|
147
|
+
|
|
148
|
+
def __repr__(self) -> str:
|
|
149
|
+
if not self:
|
|
150
|
+
return "ParsedArgs()"
|
|
151
|
+
return "ParsedArgs(\n " + ",\n ".join(
|
|
152
|
+
f"{key} = " + "\n ".join(repr(val).splitlines()) \
|
|
153
|
+
for key, val in self.__iter__()
|
|
154
|
+
) + "\n)"
|
|
155
|
+
|
|
156
|
+
def __str__(self) -> str:
|
|
157
|
+
return self.__repr__()
|
|
158
|
+
|
|
159
|
+
def dict(self) -> dict[str, ArgData]:
|
|
160
|
+
"""Returns the arguments as a dictionary."""
|
|
161
|
+
return {key: val.dict() for key, val in self.__iter__()}
|
|
162
|
+
|
|
163
|
+
def get(self, key: str, default: Any = None) -> ParsedArgData | Any:
|
|
164
|
+
"""Returns the argument result for the given alias, or `default` if not found."""
|
|
165
|
+
return getattr(self, key, default)
|
|
166
|
+
|
|
167
|
+
def keys(self):
|
|
168
|
+
"""Returns the argument aliases as `dict_keys([…])`."""
|
|
169
|
+
return vars(self).keys()
|
|
170
|
+
|
|
171
|
+
def values(self):
|
|
172
|
+
"""Returns the argument results as `dict_values([…])`."""
|
|
173
|
+
return vars(self).values()
|
|
174
|
+
|
|
175
|
+
def items(self) -> Generator[tuple[str, ParsedArgData], None, None]:
|
|
176
|
+
"""Yields tuples of `(alias, ParsedArgData)`."""
|
|
177
|
+
for key, val in self.__iter__():
|
|
178
|
+
yield (key, val)
|
|
179
|
+
|
|
180
|
+
def existing(self) -> Generator[tuple[str, ParsedArgData], None, None]:
|
|
181
|
+
"""Yields tuples of `(alias, ParsedArgData)` for existing arguments only."""
|
|
182
|
+
for key, val in self.__iter__():
|
|
183
|
+
if val.exists:
|
|
184
|
+
yield (key, val)
|
|
185
|
+
|
|
186
|
+
def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]:
|
|
187
|
+
"""Yields tuples of `(alias, ParsedArgData)` for missing arguments only."""
|
|
188
|
+
for key, val in self.__iter__():
|
|
189
|
+
if not val.exists:
|
|
190
|
+
yield (key, val)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@mypyc_attr(native_class=False)
|
|
194
|
+
class _ConsoleMeta(type):
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def w(cls) -> int:
|
|
198
|
+
"""The width of the console in characters."""
|
|
199
|
+
try:
|
|
200
|
+
return _os.get_terminal_size().columns
|
|
201
|
+
except OSError:
|
|
202
|
+
return 80
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def h(cls) -> int:
|
|
206
|
+
"""The height of the console in lines."""
|
|
207
|
+
try:
|
|
208
|
+
return _os.get_terminal_size().lines
|
|
209
|
+
except OSError:
|
|
210
|
+
return 24
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def size(cls) -> tuple[int, int]:
|
|
214
|
+
"""A tuple with the width and height of the console in characters and lines."""
|
|
215
|
+
try:
|
|
216
|
+
size = _os.get_terminal_size()
|
|
217
|
+
return (size.columns, size.lines)
|
|
218
|
+
except OSError:
|
|
219
|
+
return (80, 24)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def user(cls) -> str:
|
|
223
|
+
"""The name of the current user."""
|
|
224
|
+
return _os.getenv("USER") or _os.getenv("USERNAME") or _getpass.getuser()
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def is_tty(cls) -> bool:
|
|
228
|
+
"""Whether the current output is a terminal/console or not."""
|
|
229
|
+
return _sys.stdout.isatty()
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def encoding(cls) -> str:
|
|
233
|
+
"""The encoding used by the console (e.g. `utf-8`, `cp1252`, …)."""
|
|
234
|
+
try:
|
|
235
|
+
encoding = _sys.stdout.encoding
|
|
236
|
+
return encoding if encoding is not None else "utf-8"
|
|
237
|
+
except (AttributeError, Exception):
|
|
238
|
+
return "utf-8"
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def supports_color(cls) -> bool:
|
|
242
|
+
"""Whether the terminal supports ANSI color codes or not."""
|
|
243
|
+
if not cls.is_tty:
|
|
244
|
+
return False
|
|
245
|
+
if _os.name == "nt":
|
|
246
|
+
# CHECK IF VT100 MODE IS ENABLED ON WINDOWS
|
|
247
|
+
try:
|
|
248
|
+
kernel32 = getattr(_ctypes, "windll").kernel32
|
|
249
|
+
h = kernel32.GetStdHandle(-11)
|
|
250
|
+
mode = _ctypes.c_ulong()
|
|
251
|
+
if kernel32.GetConsoleMode(h, _ctypes.byref(mode)):
|
|
252
|
+
return (mode.value & 0x0004) != 0
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
return False
|
|
256
|
+
return _os.getenv("TERM", "").lower() not in {"", "dumb"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Console(metaclass=_ConsoleMeta):
|
|
260
|
+
"""This class provides methods for logging and other actions within the console."""
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def get_args(cls, arg_parse_configs: ArgParseConfigs, flag_value_sep: str = "=") -> ParsedArgs:
|
|
264
|
+
"""Will search for the specified args in the command-line arguments
|
|
265
|
+
and return the results as a special `ParsedArgs` object.\n
|
|
266
|
+
-------------------------------------------------------------------------------------------------
|
|
267
|
+
- `arg_parse_configs` - a dictionary where each key is an alias name for the argument
|
|
268
|
+
and the key's value is the parsing configuration for that argument
|
|
269
|
+
- `flag_value_sep` - the character/s used to separate flags from their values\n
|
|
270
|
+
-------------------------------------------------------------------------------------------------
|
|
271
|
+
The `arg_parse_configs` dictionary can have the following structures for each item:
|
|
272
|
+
1. Simple set of flags (when no default value is needed):
|
|
273
|
+
```python
|
|
274
|
+
"alias_name": {"-f", "--flag"}
|
|
275
|
+
```
|
|
276
|
+
2. Dictionary with the`"flags"` set, plus a specified `"default"` value:
|
|
277
|
+
```python
|
|
278
|
+
"alias_name": {
|
|
279
|
+
"flags": {"-f", "--flag"},
|
|
280
|
+
"default": "some_value",
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
3. Positional value collection using the literals `"before"` or `"after"`:
|
|
284
|
+
```python
|
|
285
|
+
# COLLECT ALL NON-FLAGGED VALUES THAT APPEAR BEFORE THE FIRST FLAG
|
|
286
|
+
"alias_name": "before"
|
|
287
|
+
# COLLECT ALL NON-FLAGGED VALUES THAT APPEAR AFTER THE LAST FLAG'S VALUE
|
|
288
|
+
"alias_name": "after"
|
|
289
|
+
```
|
|
290
|
+
#### Example usage:
|
|
291
|
+
If you call the `get_args()` method in your script like this:
|
|
292
|
+
```python
|
|
293
|
+
parsed_args = Console.get_args({
|
|
294
|
+
"text_before": "before", # POSITIONAL VALUES BEFORE FIRST FLAG
|
|
295
|
+
"arg1": {"-A", "--arg1"}, # NORMAL FLAGS
|
|
296
|
+
"arg2": { # FLAGS WITH SPECIFIED DEFAULT VALUE
|
|
297
|
+
"flags": {"-B", "--arg2"},
|
|
298
|
+
"default": "default value"
|
|
299
|
+
},
|
|
300
|
+
"text_after": "after", # POSITIONAL VALUES AFTER LAST FLAG'S VALUE
|
|
301
|
+
})
|
|
302
|
+
```
|
|
303
|
+
… and execute the script via the command line like this:\n
|
|
304
|
+
`$ python script.py "Hello" "World" --arg1=42 "Goodbye"`\n
|
|
305
|
+
… the `get_args()` method would return a `ParsedArgs` object with the following structure:
|
|
306
|
+
```python
|
|
307
|
+
ParsedArgs(
|
|
308
|
+
# FOUND 2 VALUES BEFORE THE FIRST FLAG
|
|
309
|
+
text_before = ParsedArgData(exists=True, is_pos=True, values=["Hello", "World"], flag=None),
|
|
310
|
+
# FOUND ONE OF THE SPECIFIED FLAGS WITH A VALUE
|
|
311
|
+
arg1 = ParsedArgData(exists=True, is_pos=False, values=["42"], flag="--arg1"),
|
|
312
|
+
# DIDN'T FIND ANY OF THE SPECIFIED FLAGS, USED THE DEFAULT VALUE
|
|
313
|
+
arg2 = ParsedArgData(exists=False, is_pos=False, values=["default value"], flag=None),
|
|
314
|
+
# FOUND 1 VALUE AFTER THE LAST FLAG'S VALUE
|
|
315
|
+
text_after = ParsedArgData(exists=True, is_pos=True, values=["Goodbye"], flag=None),
|
|
316
|
+
)
|
|
317
|
+
```
|
|
318
|
+
-------------------------------------------------------------------------------------------------
|
|
319
|
+
NOTE: Flags can ONLY receive values when the separator is present
|
|
320
|
+
(e.g. `--flag=value` or `--flag = value`)."""
|
|
321
|
+
if not flag_value_sep:
|
|
322
|
+
raise ValueError("The 'flag_value_sep' parameter must be a non-empty string.")
|
|
323
|
+
|
|
324
|
+
return _ConsoleArgsParseHelper(arg_parse_configs, flag_value_sep)()
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def pause_exit(
|
|
328
|
+
cls,
|
|
329
|
+
prompt: object = "",
|
|
330
|
+
pause: bool = True,
|
|
331
|
+
exit: bool = False,
|
|
332
|
+
exit_code: int = 0,
|
|
333
|
+
reset_ansi: bool = False,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Will print the `prompt` and then pause and/or exit the program based on the given options.\n
|
|
336
|
+
--------------------------------------------------------------------------------------------------
|
|
337
|
+
- `prompt` -⠀the message to print before pausing/exiting
|
|
338
|
+
- `pause` -⠀whether to pause and wait for a key press after printing the prompt
|
|
339
|
+
- `exit` -⠀whether to exit the program after printing the prompt (and pausing if `pause` is true)
|
|
340
|
+
- `exit_code` -⠀the exit code to use when exiting the program
|
|
341
|
+
- `reset_ansi` -⠀whether to reset the ANSI formatting after printing the prompt"""
|
|
342
|
+
FormatCodes.print(prompt, end="", flush=True)
|
|
343
|
+
if reset_ansi:
|
|
344
|
+
FormatCodes.print("[_]", end="")
|
|
345
|
+
if pause:
|
|
346
|
+
_keyboard.read_key(suppress=True)
|
|
347
|
+
if exit:
|
|
348
|
+
_sys.exit(exit_code)
|
|
349
|
+
|
|
350
|
+
@classmethod
|
|
351
|
+
def cls(cls) -> None:
|
|
352
|
+
"""Will clear the console in addition to completely resetting the ANSI formats."""
|
|
353
|
+
if _shutil.which("cls"):
|
|
354
|
+
_os.system("cls")
|
|
355
|
+
elif _shutil.which("clear"):
|
|
356
|
+
_os.system("clear")
|
|
357
|
+
print("\033[0m", end="", flush=True)
|
|
358
|
+
|
|
359
|
+
@classmethod
|
|
360
|
+
def log(
|
|
361
|
+
cls,
|
|
362
|
+
title: Optional[str] = None,
|
|
363
|
+
prompt: object = "",
|
|
364
|
+
format_linebreaks: bool = True,
|
|
365
|
+
start: str = "",
|
|
366
|
+
end: str = "\n",
|
|
367
|
+
title_bg_color: Optional[Rgba | Hexa] = None,
|
|
368
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
369
|
+
tab_size: int = 8,
|
|
370
|
+
title_px: int = 1,
|
|
371
|
+
title_mx: int = 2,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Prints a nicely formatted log message.\n
|
|
374
|
+
-------------------------------------------------------------------------------------------
|
|
375
|
+
- `title` -⠀the title of the log message (e.g. `DEBUG`, `WARN`, `FAIL`, etc.)
|
|
376
|
+
- `prompt` -⠀the log message
|
|
377
|
+
- `format_linebreaks` -⠀whether to format (indent after) the line breaks or not
|
|
378
|
+
- `start` -⠀something to print before the log is printed
|
|
379
|
+
- `end` -⠀something to print after the log is printed (e.g. `\\n`)
|
|
380
|
+
- `title_bg_color` -⠀the background color of the `title`
|
|
381
|
+
- `default_color` -⠀the default text color of the `prompt`
|
|
382
|
+
- `tab_size` -⠀the tab size used for the log (default is 8 like console tabs)
|
|
383
|
+
- `title_px` -⠀the horizontal padding (in chars) to the title (if `title_bg_color` is set)
|
|
384
|
+
- `title_mx` -⠀the horizontal margin (in chars) to the title\n
|
|
385
|
+
-------------------------------------------------------------------------------------------
|
|
386
|
+
The log message can be formatted with special formatting codes. For more detailed
|
|
387
|
+
information about formatting codes, see `format_codes` module documentation."""
|
|
388
|
+
has_title_bg: bool = False
|
|
389
|
+
if title_bg_color is not None and (Color.is_valid_rgba(title_bg_color) or Color.is_valid_hexa(title_bg_color)):
|
|
390
|
+
title_bg_color, has_title_bg = Color.to_hexa(cast(Rgba | Hexa, title_bg_color)), True
|
|
391
|
+
if tab_size < 0:
|
|
392
|
+
raise ValueError("The 'tab_size' parameter must be a non-negative integer.")
|
|
393
|
+
if title_px < 0:
|
|
394
|
+
raise ValueError("The 'title_px' parameter must be a non-negative integer.")
|
|
395
|
+
if title_mx < 0:
|
|
396
|
+
raise ValueError("The 'title_mx' parameter must be a non-negative integer.")
|
|
397
|
+
|
|
398
|
+
title = "" if title is None else title.strip().upper()
|
|
399
|
+
title_fg = Color.text_color_for_on_bg(cast(hexa, title_bg_color)) if has_title_bg else "_color"
|
|
400
|
+
|
|
401
|
+
px, mx = (" " * title_px) if has_title_bg else "", " " * title_mx
|
|
402
|
+
tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size))
|
|
403
|
+
|
|
404
|
+
if format_linebreaks:
|
|
405
|
+
clean_prompt, removals = cast(
|
|
406
|
+
tuple[str, tuple[tuple[int, str], ...]],
|
|
407
|
+
FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True),
|
|
408
|
+
)
|
|
409
|
+
prompt_lst: list[str] = [
|
|
410
|
+
item for lst in
|
|
411
|
+
(
|
|
412
|
+
String.split_count(line, cls.w - (title_len + len(tab) + 2 * len(mx))) \
|
|
413
|
+
for line in str(clean_prompt).splitlines()
|
|
414
|
+
)
|
|
415
|
+
for item in ([""] if lst == [] else (lst if isinstance(lst, list) else [lst]))
|
|
416
|
+
]
|
|
417
|
+
prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join(
|
|
418
|
+
cls._add_back_removed_parts(prompt_lst, cast(tuple[tuple[int, str], ...], removals))
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if title == "":
|
|
422
|
+
FormatCodes.print(
|
|
423
|
+
f"{start} {f'[{default_color}]' if default_color else ''}{prompt}[_]",
|
|
424
|
+
default_color=default_color,
|
|
425
|
+
end=end,
|
|
426
|
+
)
|
|
427
|
+
else:
|
|
428
|
+
FormatCodes.print(
|
|
429
|
+
f"{start}{mx}[bold][{title_fg}]{f'[BG:{title_bg_color}]' if title_bg_color else ''}{px}{title}{px}[_]{mx}"
|
|
430
|
+
+ f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]",
|
|
431
|
+
default_color=default_color,
|
|
432
|
+
end=end,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
@classmethod
|
|
436
|
+
def debug(
|
|
437
|
+
cls,
|
|
438
|
+
prompt: object = "Point in program reached.",
|
|
439
|
+
active: bool = True,
|
|
440
|
+
format_linebreaks: bool = True,
|
|
441
|
+
start: str = "",
|
|
442
|
+
end: str = "\n",
|
|
443
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
444
|
+
pause: bool = False,
|
|
445
|
+
exit: bool = False,
|
|
446
|
+
exit_code: int = 0,
|
|
447
|
+
reset_ansi: bool = True,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""A preset for `log()`: `DEBUG` log message with the options to pause
|
|
450
|
+
at the message and exit the program after the message was printed.
|
|
451
|
+
If `active` is false, no debug message will be printed."""
|
|
452
|
+
if active:
|
|
453
|
+
cls.log(
|
|
454
|
+
title="DEBUG",
|
|
455
|
+
prompt=prompt,
|
|
456
|
+
format_linebreaks=format_linebreaks,
|
|
457
|
+
start=start,
|
|
458
|
+
end=end,
|
|
459
|
+
title_bg_color=COLOR.YELLOW,
|
|
460
|
+
default_color=default_color,
|
|
461
|
+
)
|
|
462
|
+
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
|
|
463
|
+
|
|
464
|
+
@classmethod
|
|
465
|
+
def info(
|
|
466
|
+
cls,
|
|
467
|
+
prompt: object = "Program running.",
|
|
468
|
+
format_linebreaks: bool = True,
|
|
469
|
+
start: str = "",
|
|
470
|
+
end: str = "\n",
|
|
471
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
472
|
+
pause: bool = False,
|
|
473
|
+
exit: bool = False,
|
|
474
|
+
exit_code: int = 0,
|
|
475
|
+
reset_ansi: bool = True,
|
|
476
|
+
) -> None:
|
|
477
|
+
"""A preset for `log()`: `INFO` log message with the options to pause
|
|
478
|
+
at the message and exit the program after the message was printed."""
|
|
479
|
+
cls.log(
|
|
480
|
+
title="INFO",
|
|
481
|
+
prompt=prompt,
|
|
482
|
+
format_linebreaks=format_linebreaks,
|
|
483
|
+
start=start,
|
|
484
|
+
end=end,
|
|
485
|
+
title_bg_color=COLOR.BLUE,
|
|
486
|
+
default_color=default_color,
|
|
487
|
+
)
|
|
488
|
+
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
|
|
489
|
+
|
|
490
|
+
@classmethod
|
|
491
|
+
def done(
|
|
492
|
+
cls,
|
|
493
|
+
prompt: object = "Program finished.",
|
|
494
|
+
format_linebreaks: bool = True,
|
|
495
|
+
start: str = "",
|
|
496
|
+
end: str = "\n",
|
|
497
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
498
|
+
pause: bool = False,
|
|
499
|
+
exit: bool = False,
|
|
500
|
+
exit_code: int = 0,
|
|
501
|
+
reset_ansi: bool = True,
|
|
502
|
+
) -> None:
|
|
503
|
+
"""A preset for `log()`: `DONE` log message with the options to pause
|
|
504
|
+
at the message and exit the program after the message was printed."""
|
|
505
|
+
cls.log(
|
|
506
|
+
title="DONE",
|
|
507
|
+
prompt=prompt,
|
|
508
|
+
format_linebreaks=format_linebreaks,
|
|
509
|
+
start=start,
|
|
510
|
+
end=end,
|
|
511
|
+
title_bg_color=COLOR.TEAL,
|
|
512
|
+
default_color=default_color,
|
|
513
|
+
)
|
|
514
|
+
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
|
|
515
|
+
|
|
516
|
+
@classmethod
|
|
517
|
+
def warn(
|
|
518
|
+
cls,
|
|
519
|
+
prompt: object = "Important message.",
|
|
520
|
+
format_linebreaks: bool = True,
|
|
521
|
+
start: str = "",
|
|
522
|
+
end: str = "\n",
|
|
523
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
524
|
+
pause: bool = False,
|
|
525
|
+
exit: bool = False,
|
|
526
|
+
exit_code: int = 1,
|
|
527
|
+
reset_ansi: bool = True,
|
|
528
|
+
) -> None:
|
|
529
|
+
"""A preset for `log()`: `WARN` log message with the options to pause
|
|
530
|
+
at the message and exit the program after the message was printed."""
|
|
531
|
+
cls.log(
|
|
532
|
+
title="WARN",
|
|
533
|
+
prompt=prompt,
|
|
534
|
+
format_linebreaks=format_linebreaks,
|
|
535
|
+
start=start,
|
|
536
|
+
end=end,
|
|
537
|
+
title_bg_color=COLOR.ORANGE,
|
|
538
|
+
default_color=default_color,
|
|
539
|
+
)
|
|
540
|
+
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
|
|
541
|
+
|
|
542
|
+
@classmethod
|
|
543
|
+
def fail(
|
|
544
|
+
cls,
|
|
545
|
+
prompt: object = "Program error.",
|
|
546
|
+
format_linebreaks: bool = True,
|
|
547
|
+
start: str = "",
|
|
548
|
+
end: str = "\n",
|
|
549
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
550
|
+
pause: bool = False,
|
|
551
|
+
exit: bool = True,
|
|
552
|
+
exit_code: int = 1,
|
|
553
|
+
reset_ansi: bool = True,
|
|
554
|
+
) -> None:
|
|
555
|
+
"""A preset for `log()`: `FAIL` log message with the options to pause
|
|
556
|
+
at the message and exit the program after the message was printed."""
|
|
557
|
+
cls.log(
|
|
558
|
+
title="FAIL",
|
|
559
|
+
prompt=prompt,
|
|
560
|
+
format_linebreaks=format_linebreaks,
|
|
561
|
+
start=start,
|
|
562
|
+
end=end,
|
|
563
|
+
title_bg_color=COLOR.RED,
|
|
564
|
+
default_color=default_color,
|
|
565
|
+
)
|
|
566
|
+
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
|
|
567
|
+
|
|
568
|
+
@classmethod
|
|
569
|
+
def exit(
|
|
570
|
+
cls,
|
|
571
|
+
prompt: object = "Program ended.",
|
|
572
|
+
format_linebreaks: bool = True,
|
|
573
|
+
start: str = "",
|
|
574
|
+
end: str = "\n",
|
|
575
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
576
|
+
pause: bool = False,
|
|
577
|
+
exit: bool = True,
|
|
578
|
+
exit_code: int = 0,
|
|
579
|
+
reset_ansi: bool = True,
|
|
580
|
+
) -> None:
|
|
581
|
+
"""A preset for `log()`: `EXIT` log message with the options to pause
|
|
582
|
+
at the message and exit the program after the message was printed."""
|
|
583
|
+
cls.log(
|
|
584
|
+
title="EXIT",
|
|
585
|
+
prompt=prompt,
|
|
586
|
+
format_linebreaks=format_linebreaks,
|
|
587
|
+
start=start,
|
|
588
|
+
end=end,
|
|
589
|
+
title_bg_color=COLOR.MAGENTA,
|
|
590
|
+
default_color=default_color,
|
|
591
|
+
)
|
|
592
|
+
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
|
|
593
|
+
|
|
594
|
+
@classmethod
|
|
595
|
+
def log_box_filled(
|
|
596
|
+
cls,
|
|
597
|
+
*values: object,
|
|
598
|
+
start: str = "",
|
|
599
|
+
end: str = "\n",
|
|
600
|
+
box_bg_color: str | Rgba | Hexa = "br:green",
|
|
601
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
602
|
+
w_padding: int = 2,
|
|
603
|
+
w_full: bool = False,
|
|
604
|
+
indent: int = 0,
|
|
605
|
+
) -> None:
|
|
606
|
+
"""Will print a box with a colored background, containing a formatted log message.\n
|
|
607
|
+
-------------------------------------------------------------------------------------
|
|
608
|
+
- `*values` -⠀the box content (each value is on a new line)
|
|
609
|
+
- `start` -⠀something to print before the log box is printed (e.g. `\\n`)
|
|
610
|
+
- `end` -⠀something to print after the log box is printed (e.g. `\\n`)
|
|
611
|
+
- `box_bg_color` -⠀the background color of the box
|
|
612
|
+
- `default_color` -⠀the default text color of the `*values`
|
|
613
|
+
- `w_padding` -⠀the horizontal padding (in chars) to the box content
|
|
614
|
+
- `w_full` -⠀whether to make the box be the full console width or not
|
|
615
|
+
- `indent` -⠀the indentation of the box (in chars)\n
|
|
616
|
+
-------------------------------------------------------------------------------------
|
|
617
|
+
The box content can be formatted with special formatting codes. For more detailed
|
|
618
|
+
information about formatting codes, see `format_codes` module documentation."""
|
|
619
|
+
if w_padding < 0:
|
|
620
|
+
raise ValueError("The 'w_padding' parameter must be a non-negative integer.")
|
|
621
|
+
if indent < 0:
|
|
622
|
+
raise ValueError("The 'indent' parameter must be a non-negative integer.")
|
|
623
|
+
|
|
624
|
+
if Color.is_valid(box_bg_color):
|
|
625
|
+
box_bg_color = Color.to_hexa(box_bg_color)
|
|
626
|
+
|
|
627
|
+
lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color)
|
|
628
|
+
|
|
629
|
+
spaces_l = " " * indent
|
|
630
|
+
pady = " " * (cls.w if w_full else max_line_len + (2 * w_padding))
|
|
631
|
+
pad_w_full = (cls.w - (max_line_len + (2 * w_padding))) if w_full else 0
|
|
632
|
+
|
|
633
|
+
replacer = _ConsoleLogBoxBgReplacer(box_bg_color)
|
|
634
|
+
lines = [( \
|
|
635
|
+
f"{spaces_l}[bg:{box_bg_color}]{' ' * w_padding}"
|
|
636
|
+
+ _FC_PATTERNS.formatting.sub(replacer, line)
|
|
637
|
+
+ (" " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full))
|
|
638
|
+
+ "[*]"
|
|
639
|
+
) for line, unfmt in zip(lines, unfmt_lines)]
|
|
640
|
+
|
|
641
|
+
FormatCodes.print(
|
|
642
|
+
( \
|
|
643
|
+
f"{start}{spaces_l}[bg:{box_bg_color}]{pady}[*]\n"
|
|
644
|
+
+ "\n".join(lines)
|
|
645
|
+
+ ("\n" if lines else "")
|
|
646
|
+
+ f"{spaces_l}[bg:{box_bg_color}]{pady}[_]"
|
|
647
|
+
),
|
|
648
|
+
default_color=default_color or "#000",
|
|
649
|
+
sep="\n",
|
|
650
|
+
end=end,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
@classmethod
|
|
654
|
+
def log_box_bordered(
|
|
655
|
+
cls,
|
|
656
|
+
*values: object,
|
|
657
|
+
start: str = "",
|
|
658
|
+
end: str = "\n",
|
|
659
|
+
border_type: Literal["standard", "rounded", "strong", "double"] = "rounded",
|
|
660
|
+
border_style: str | Rgba | Hexa = f"dim|{COLOR.GRAY}",
|
|
661
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
662
|
+
w_padding: int = 1,
|
|
663
|
+
w_full: bool = False,
|
|
664
|
+
indent: int = 0,
|
|
665
|
+
_border_chars: Optional[tuple[str, str, str, str, str, str, str, str, str, str, str]] = None,
|
|
666
|
+
) -> None:
|
|
667
|
+
"""Will print a bordered box, containing a formatted log message.\n
|
|
668
|
+
---------------------------------------------------------------------------------------------
|
|
669
|
+
- `*values` -⠀the box content (each value is on a new line)
|
|
670
|
+
- `start` -⠀something to print before the log box is printed (e.g. `\\n`)
|
|
671
|
+
- `end` -⠀something to print after the log box is printed (e.g. `\\n`)
|
|
672
|
+
- `border_type` -⠀one of the predefined border character sets
|
|
673
|
+
- `border_style` -⠀the style of the border (special formatting codes)
|
|
674
|
+
- `default_color` -⠀the default text color of the `*values`
|
|
675
|
+
- `w_padding` -⠀the horizontal padding (in chars) to the box content
|
|
676
|
+
- `w_full` -⠀whether to make the box be the full console width or not
|
|
677
|
+
- `indent` -⠀the indentation of the box (in chars)
|
|
678
|
+
- `_border_chars` -⠀define your own border characters set (overwrites `border_type`)\n
|
|
679
|
+
---------------------------------------------------------------------------------------------
|
|
680
|
+
You can insert horizontal rules to split the box content by using `{hr}` in the `*values`.\n
|
|
681
|
+
---------------------------------------------------------------------------------------------
|
|
682
|
+
The box content can be formatted with special formatting codes. For more detailed
|
|
683
|
+
information about formatting codes, see `format_codes` module documentation.\n
|
|
684
|
+
---------------------------------------------------------------------------------------------
|
|
685
|
+
The `border_type` can be one of the following:
|
|
686
|
+
- `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│', '├', '─', '┤')`
|
|
687
|
+
- `"rounded" = ('╭', '─', '╮', '│', '╯', '─', '╰', '│', '├', '─', '┤')`
|
|
688
|
+
- `"strong" = ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃', '┣', '━', '┫')`
|
|
689
|
+
- `"double" = ('╔', '═', '╗', '║', '╝', '═', '╚', '║', '╠', '═', '╣')`\n
|
|
690
|
+
The order of the characters is always:
|
|
691
|
+
1. top-left corner
|
|
692
|
+
2. top border
|
|
693
|
+
3. top-right corner
|
|
694
|
+
4. right border
|
|
695
|
+
5. bottom-right corner
|
|
696
|
+
6. bottom border
|
|
697
|
+
7. bottom-left corner
|
|
698
|
+
8. left border
|
|
699
|
+
9. left horizontal rule connector
|
|
700
|
+
10. horizontal rule
|
|
701
|
+
11. right horizontal rule connector"""
|
|
702
|
+
if w_padding < 0:
|
|
703
|
+
raise ValueError("The 'w_padding' parameter must be a non-negative integer.")
|
|
704
|
+
if indent < 0:
|
|
705
|
+
raise ValueError("The 'indent' parameter must be a non-negative integer.")
|
|
706
|
+
if _border_chars is not None:
|
|
707
|
+
if len(_border_chars) != 11:
|
|
708
|
+
raise ValueError(f"The '_border_chars' parameter must contain exactly 11 characters, got {len(_border_chars)}")
|
|
709
|
+
if not all(len(char) == 1 for char in _border_chars):
|
|
710
|
+
raise ValueError("The '_border_chars' parameter must only contain single-character strings.")
|
|
711
|
+
|
|
712
|
+
if border_style is not None and Color.is_valid(border_style):
|
|
713
|
+
border_style = Color.to_hexa(border_style)
|
|
714
|
+
|
|
715
|
+
borders = {
|
|
716
|
+
"standard": ("┌", "─", "┐", "│", "┘", "─", "└", "│", "├", "─", "┤"),
|
|
717
|
+
"rounded": ("╭", "─", "╮", "│", "╯", "─", "╰", "│", "├", "─", "┤"),
|
|
718
|
+
"strong": ("┏", "━", "┓", "┃", "┛", "━", "┗", "┃", "┣", "━", "┫"),
|
|
719
|
+
"double": ("╔", "═", "╗", "║", "╝", "═", "╚", "║", "╠", "═", "╣"),
|
|
720
|
+
}
|
|
721
|
+
border_chars = borders.get(border_type, borders["standard"]) if _border_chars is None else _border_chars
|
|
722
|
+
|
|
723
|
+
lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color, has_rules=True)
|
|
724
|
+
|
|
725
|
+
spaces_l = " " * indent
|
|
726
|
+
pad_w_full = (cls.w - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0
|
|
727
|
+
|
|
728
|
+
border_l = f"[{border_style}]{border_chars[7]}[*]"
|
|
729
|
+
border_r = f"[{border_style}]{border_chars[3]}[_]"
|
|
730
|
+
border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (cls.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]"
|
|
731
|
+
border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (cls.w - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]"
|
|
732
|
+
|
|
733
|
+
h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{border_chars[9] * (cls.w - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]"
|
|
734
|
+
|
|
735
|
+
lines = [( \
|
|
736
|
+
h_rule if _PATTERNS.hr.match(line) else f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]"
|
|
737
|
+
+ " " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full)
|
|
738
|
+
+ border_r
|
|
739
|
+
) for line, unfmt in zip(lines, unfmt_lines)]
|
|
740
|
+
|
|
741
|
+
FormatCodes.print(
|
|
742
|
+
( \
|
|
743
|
+
f"{start}{border_t}[_]\n"
|
|
744
|
+
+ "\n".join(lines)
|
|
745
|
+
+ ("\n" if lines else "")
|
|
746
|
+
+ f"{border_b}[_]"
|
|
747
|
+
),
|
|
748
|
+
default_color=default_color,
|
|
749
|
+
sep="\n",
|
|
750
|
+
end=end,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
@classmethod
|
|
754
|
+
def confirm(
|
|
755
|
+
cls,
|
|
756
|
+
prompt: object = "Do you want to continue?",
|
|
757
|
+
start: str = "",
|
|
758
|
+
end: str = "",
|
|
759
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
760
|
+
default_is_yes: bool = True,
|
|
761
|
+
) -> bool:
|
|
762
|
+
"""Ask a yes/no question.\n
|
|
763
|
+
------------------------------------------------------------------------------------
|
|
764
|
+
- `prompt` -⠀the input prompt
|
|
765
|
+
- `start` -⠀something to print before the input
|
|
766
|
+
- `end` -⠀something to print after the input (e.g. `\\n`)
|
|
767
|
+
- `default_color` -⠀the default text color of the `prompt`
|
|
768
|
+
- `default_is_yes` -⠀the default answer if the user just presses enter
|
|
769
|
+
------------------------------------------------------------------------------------
|
|
770
|
+
The prompt can be formatted with special formatting codes. For more detailed
|
|
771
|
+
information about formatting codes, see the `format_codes` module documentation."""
|
|
772
|
+
confirmed = cls.input(
|
|
773
|
+
FormatCodes.to_ansi(
|
|
774
|
+
f"{start}{str(prompt)} [_|dim](({'Y' if default_is_yes else 'y'}/{'n' if default_is_yes else 'N'}): )",
|
|
775
|
+
default_color=default_color,
|
|
776
|
+
)
|
|
777
|
+
).strip().lower() in ({"", "y", "yes"} if default_is_yes else {"y", "yes"})
|
|
778
|
+
|
|
779
|
+
if end:
|
|
780
|
+
FormatCodes.print(end, end="")
|
|
781
|
+
return confirmed
|
|
782
|
+
|
|
783
|
+
@classmethod
|
|
784
|
+
def multiline_input(
|
|
785
|
+
cls,
|
|
786
|
+
prompt: object = "",
|
|
787
|
+
start: str = "",
|
|
788
|
+
end: str = "\n",
|
|
789
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
790
|
+
show_keybindings: bool = True,
|
|
791
|
+
input_prefix: str = " ⮡ ",
|
|
792
|
+
reset_ansi: bool = True,
|
|
793
|
+
) -> str:
|
|
794
|
+
"""An input where users can write (and paste) text over multiple lines.\n
|
|
795
|
+
---------------------------------------------------------------------------------------
|
|
796
|
+
- `prompt` -⠀the input prompt
|
|
797
|
+
- `start` -⠀something to print before the input
|
|
798
|
+
- `end` -⠀something to print after the input (e.g. `\\n`)
|
|
799
|
+
- `default_color` -⠀the default text color of the `prompt`
|
|
800
|
+
- `show_keybindings` -⠀whether to show the special keybindings or not
|
|
801
|
+
- `input_prefix` -⠀the prefix of the input line
|
|
802
|
+
- `reset_ansi` -⠀whether to reset the ANSI codes after the input or not
|
|
803
|
+
---------------------------------------------------------------------------------------
|
|
804
|
+
The input prompt can be formatted with special formatting codes. For more detailed
|
|
805
|
+
information about formatting codes, see the `format_codes` module documentation."""
|
|
806
|
+
kb = KeyBindings()
|
|
807
|
+
kb.add("c-d", eager=True)(cls._multiline_input_submit)
|
|
808
|
+
|
|
809
|
+
FormatCodes.print(start + str(prompt), default_color=default_color)
|
|
810
|
+
if show_keybindings:
|
|
811
|
+
FormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]")
|
|
812
|
+
input_string = _pt.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb)
|
|
813
|
+
FormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end)
|
|
814
|
+
|
|
815
|
+
return input_string
|
|
816
|
+
|
|
817
|
+
@overload
|
|
818
|
+
@classmethod
|
|
819
|
+
def input(
|
|
820
|
+
cls,
|
|
821
|
+
prompt: object = "",
|
|
822
|
+
start: str = "",
|
|
823
|
+
end: str = "",
|
|
824
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
825
|
+
placeholder: Optional[str] = None,
|
|
826
|
+
mask_char: Optional[str] = None,
|
|
827
|
+
min_len: Optional[int] = None,
|
|
828
|
+
max_len: Optional[int] = None,
|
|
829
|
+
allowed_chars: str | AllTextChars = CHARS.ALL,
|
|
830
|
+
allow_paste: bool = True,
|
|
831
|
+
validator: Optional[Callable[[str], Optional[str]]] = None,
|
|
832
|
+
default_val: Optional[str] = None,
|
|
833
|
+
output_type: type[str] = str,
|
|
834
|
+
) -> str:
|
|
835
|
+
...
|
|
836
|
+
|
|
837
|
+
@overload
|
|
838
|
+
@classmethod
|
|
839
|
+
def input(
|
|
840
|
+
cls,
|
|
841
|
+
prompt: object = "",
|
|
842
|
+
start: str = "",
|
|
843
|
+
end: str = "",
|
|
844
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
845
|
+
placeholder: Optional[str] = None,
|
|
846
|
+
mask_char: Optional[str] = None,
|
|
847
|
+
min_len: Optional[int] = None,
|
|
848
|
+
max_len: Optional[int] = None,
|
|
849
|
+
allowed_chars: str | AllTextChars = CHARS.ALL,
|
|
850
|
+
allow_paste: bool = True,
|
|
851
|
+
validator: Optional[Callable[[str], Optional[str]]] = None,
|
|
852
|
+
default_val: Optional[T] = None,
|
|
853
|
+
output_type: type[T] = ...,
|
|
854
|
+
) -> T:
|
|
855
|
+
...
|
|
856
|
+
|
|
857
|
+
@classmethod
|
|
858
|
+
def input(
|
|
859
|
+
cls,
|
|
860
|
+
prompt: object = "",
|
|
861
|
+
start: str = "",
|
|
862
|
+
end: str = "",
|
|
863
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
864
|
+
placeholder: Optional[str] = None,
|
|
865
|
+
mask_char: Optional[str] = None,
|
|
866
|
+
min_len: Optional[int] = None,
|
|
867
|
+
max_len: Optional[int] = None,
|
|
868
|
+
allowed_chars: str | AllTextChars = CHARS.ALL,
|
|
869
|
+
allow_paste: bool = True,
|
|
870
|
+
validator: Optional[Callable[[str], Optional[str]]] = None,
|
|
871
|
+
default_val: Any = None,
|
|
872
|
+
output_type: type[Any] = str,
|
|
873
|
+
) -> Any:
|
|
874
|
+
"""Acts like a standard Python `input()` a bunch of cool extra features.\n
|
|
875
|
+
------------------------------------------------------------------------------------
|
|
876
|
+
- `prompt` -⠀the input prompt
|
|
877
|
+
- `start` -⠀something to print before the input
|
|
878
|
+
- `end` -⠀something to print after the input (e.g. `\\n`)
|
|
879
|
+
- `default_color` -⠀the default text color of the `prompt`
|
|
880
|
+
- `placeholder` -⠀a placeholder text that is shown when the input is empty
|
|
881
|
+
- `mask_char` -⠀if set, the input will be masked with this character
|
|
882
|
+
- `min_len` -⠀the minimum length of the input (required to submit)
|
|
883
|
+
- `max_len` -⠀the maximum length of the input (can't write further if reached)
|
|
884
|
+
- `allowed_chars` -⠀a string of characters that are allowed to be inputted
|
|
885
|
+
(default allows all characters)
|
|
886
|
+
- `allow_paste` -⠀whether to allow pasting text into the input or not
|
|
887
|
+
- `validator` -⠀a function that takes the input string and returns a string error
|
|
888
|
+
message if invalid, or nothing if valid
|
|
889
|
+
- `default_val` -⠀the default value to return if the input is empty
|
|
890
|
+
- `output_type` -⠀the type (class) to convert the input to before returning it\n
|
|
891
|
+
------------------------------------------------------------------------------------
|
|
892
|
+
The input prompt can be formatted with special formatting codes. For more detailed
|
|
893
|
+
information about formatting codes, see the `format_codes` module documentation."""
|
|
894
|
+
if mask_char is not None and len(mask_char) != 1:
|
|
895
|
+
raise ValueError(f"The 'mask_char' parameter must be a single character, got {mask_char!r}")
|
|
896
|
+
if min_len is not None and min_len < 0:
|
|
897
|
+
raise ValueError("The 'min_len' parameter must be a non-negative integer.")
|
|
898
|
+
if max_len is not None and max_len < 0:
|
|
899
|
+
raise ValueError("The 'max_len' parameter must be a non-negative integer.")
|
|
900
|
+
|
|
901
|
+
helper = _ConsoleInputHelper(
|
|
902
|
+
mask_char=mask_char,
|
|
903
|
+
min_len=min_len,
|
|
904
|
+
max_len=max_len,
|
|
905
|
+
allowed_chars=allowed_chars,
|
|
906
|
+
allow_paste=allow_paste,
|
|
907
|
+
validator=validator,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
kb = KeyBindings()
|
|
911
|
+
kb.add(Keys.Delete)(helper.handle_delete)
|
|
912
|
+
kb.add(Keys.Backspace)(helper.handle_backspace)
|
|
913
|
+
kb.add(Keys.ControlA)(helper.handle_control_a)
|
|
914
|
+
kb.add(Keys.BracketedPaste)(helper.handle_paste)
|
|
915
|
+
kb.add(Keys.Any)(helper.handle_any)
|
|
916
|
+
|
|
917
|
+
custom_style = Style.from_dict({"bottom-toolbar": "noreverse"})
|
|
918
|
+
session: _pt.PromptSession = _pt.PromptSession(
|
|
919
|
+
message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)),
|
|
920
|
+
validator=_ConsoleInputValidator(
|
|
921
|
+
get_text=helper.get_text,
|
|
922
|
+
mask_char=mask_char,
|
|
923
|
+
min_len=min_len,
|
|
924
|
+
validator=validator,
|
|
925
|
+
),
|
|
926
|
+
validate_while_typing=True,
|
|
927
|
+
key_bindings=kb,
|
|
928
|
+
bottom_toolbar=helper.bottom_toolbar,
|
|
929
|
+
placeholder=_pt.formatted_text.ANSI(FormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]"))
|
|
930
|
+
if placeholder else "",
|
|
931
|
+
style=custom_style,
|
|
932
|
+
)
|
|
933
|
+
FormatCodes.print(start, end="")
|
|
934
|
+
session.prompt()
|
|
935
|
+
FormatCodes.print(end, end="")
|
|
936
|
+
|
|
937
|
+
result_text = helper.get_text()
|
|
938
|
+
if result_text in {"", None}:
|
|
939
|
+
if default_val is not None:
|
|
940
|
+
return default_val
|
|
941
|
+
result_text = ""
|
|
942
|
+
|
|
943
|
+
if output_type == str:
|
|
944
|
+
return result_text
|
|
945
|
+
else:
|
|
946
|
+
try:
|
|
947
|
+
return output_type(result_text) # type: ignore[call-arg]
|
|
948
|
+
except (ValueError, TypeError):
|
|
949
|
+
if default_val is not None:
|
|
950
|
+
return default_val
|
|
951
|
+
raise
|
|
952
|
+
|
|
953
|
+
@classmethod
|
|
954
|
+
def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[int, str], ...]) -> list[str]:
|
|
955
|
+
"""Adds back the removed parts into the split string parts at their original positions."""
|
|
956
|
+
cumulative_pos = [0]
|
|
957
|
+
for length in (len(s) for s in split_string):
|
|
958
|
+
cumulative_pos.append(cumulative_pos[-1] + length)
|
|
959
|
+
|
|
960
|
+
result, offset_adjusts = split_string.copy(), [0] * len(split_string)
|
|
961
|
+
last_idx, total_length = len(split_string) - 1, cumulative_pos[-1]
|
|
962
|
+
|
|
963
|
+
for pos, removal in removals:
|
|
964
|
+
if pos >= total_length:
|
|
965
|
+
result[last_idx] = result[last_idx] + removal
|
|
966
|
+
continue
|
|
967
|
+
|
|
968
|
+
i = cls._find_string_part(pos, cumulative_pos)
|
|
969
|
+
adjusted_pos = (pos - cumulative_pos[i]) + offset_adjusts[i]
|
|
970
|
+
parts = [result[i][:adjusted_pos], removal, result[i][adjusted_pos:]]
|
|
971
|
+
result[i] = "".join(parts)
|
|
972
|
+
offset_adjusts[i] += len(removal)
|
|
973
|
+
|
|
974
|
+
return result
|
|
975
|
+
|
|
976
|
+
@staticmethod
|
|
977
|
+
def _find_string_part(pos: int, cumulative_pos: list[int]) -> int:
|
|
978
|
+
"""Finds the index of the string part that contains the given position."""
|
|
979
|
+
left, right = 0, len(cumulative_pos) - 1
|
|
980
|
+
while left < right:
|
|
981
|
+
mid = (left + right) // 2
|
|
982
|
+
if cumulative_pos[mid] <= pos < cumulative_pos[mid + 1]:
|
|
983
|
+
return mid
|
|
984
|
+
elif pos < cumulative_pos[mid]:
|
|
985
|
+
right = mid
|
|
986
|
+
else:
|
|
987
|
+
left = mid + 1
|
|
988
|
+
return left
|
|
989
|
+
|
|
990
|
+
@staticmethod
|
|
991
|
+
def _prepare_log_box(
|
|
992
|
+
values: list[object] | tuple[object, ...],
|
|
993
|
+
default_color: Optional[Rgba | Hexa] = None,
|
|
994
|
+
has_rules: bool = False,
|
|
995
|
+
) -> tuple[list[str], list[str], int]:
|
|
996
|
+
"""Prepares the log box content and returns it along with the max line length."""
|
|
997
|
+
if has_rules:
|
|
998
|
+
lines = []
|
|
999
|
+
for val in values:
|
|
1000
|
+
val_str, result_parts, current_pos = str(val), [], 0
|
|
1001
|
+
for match in _PATTERNS.hr.finditer(val_str):
|
|
1002
|
+
start, end = match.span()
|
|
1003
|
+
should_split_before = start > 0 and val_str[start - 1] != "\n"
|
|
1004
|
+
should_split_after = end < len(val_str) and val_str[end] != "\n"
|
|
1005
|
+
|
|
1006
|
+
if should_split_before:
|
|
1007
|
+
if start > current_pos:
|
|
1008
|
+
result_parts.append(val_str[current_pos:start])
|
|
1009
|
+
if should_split_after:
|
|
1010
|
+
result_parts.append(match.group())
|
|
1011
|
+
current_pos = end
|
|
1012
|
+
else:
|
|
1013
|
+
current_pos = start
|
|
1014
|
+
else:
|
|
1015
|
+
if should_split_after:
|
|
1016
|
+
result_parts.append(val_str[current_pos:end])
|
|
1017
|
+
current_pos = end
|
|
1018
|
+
|
|
1019
|
+
if current_pos < len(val_str):
|
|
1020
|
+
result_parts.append(val_str[current_pos:])
|
|
1021
|
+
|
|
1022
|
+
if not result_parts:
|
|
1023
|
+
result_parts.append(val_str)
|
|
1024
|
+
|
|
1025
|
+
for part in result_parts:
|
|
1026
|
+
lines.extend(part.splitlines())
|
|
1027
|
+
else:
|
|
1028
|
+
lines = [line for val in values for line in str(val).splitlines()]
|
|
1029
|
+
|
|
1030
|
+
unfmt_lines = [cast(str, FormatCodes.remove(line, default_color)) for line in lines]
|
|
1031
|
+
max_line_len = max(len(line) for line in unfmt_lines) if unfmt_lines else 0
|
|
1032
|
+
return lines, unfmt_lines, max_line_len
|
|
1033
|
+
|
|
1034
|
+
@staticmethod
|
|
1035
|
+
def _multiline_input_submit(event: KeyPressEvent) -> None:
|
|
1036
|
+
event.app.exit(result=event.app.current_buffer.document.text)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
class _ConsoleArgsParseHelper:
|
|
1040
|
+
"""Internal, callable helper class to parse command-line arguments."""
|
|
1041
|
+
|
|
1042
|
+
def __init__(self, arg_parse_configs: ArgParseConfigs, flag_value_sep: str):
|
|
1043
|
+
self.arg_parse_configs = arg_parse_configs
|
|
1044
|
+
self.flag_value_sep = flag_value_sep
|
|
1045
|
+
|
|
1046
|
+
self.parsed_args: dict[str, ParsedArgData] = {}
|
|
1047
|
+
self.positional_configs: dict[str, str] = {}
|
|
1048
|
+
self.arg_lookup: dict[str, str] = {}
|
|
1049
|
+
|
|
1050
|
+
self.args = _sys.argv[1:]
|
|
1051
|
+
self.args_len = len(self.args)
|
|
1052
|
+
self.pos_before_configured = False
|
|
1053
|
+
self.pos_after_configured = False
|
|
1054
|
+
self.first_flag_pos: Optional[int] = None
|
|
1055
|
+
self.last_flag_pos: Optional[int] = None
|
|
1056
|
+
|
|
1057
|
+
def __call__(self) -> ParsedArgs:
|
|
1058
|
+
self.parse_arg_configs()
|
|
1059
|
+
self.find_flag_positions()
|
|
1060
|
+
self.process_flagged_args()
|
|
1061
|
+
self.process_positional_args()
|
|
1062
|
+
|
|
1063
|
+
return ParsedArgs(**self.parsed_args)
|
|
1064
|
+
|
|
1065
|
+
def parse_arg_configs(self) -> None:
|
|
1066
|
+
"""Parse the `arg_parse_configs` configuration and build lookup structures."""
|
|
1067
|
+
for alias, config in self.arg_parse_configs.items():
|
|
1068
|
+
if not alias.isidentifier():
|
|
1069
|
+
raise ValueError(f"Invalid argument alias '{alias}'.\n"
|
|
1070
|
+
"Aliases must be valid Python identifiers.")
|
|
1071
|
+
|
|
1072
|
+
# PARSE ARG CONFIG & BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGS
|
|
1073
|
+
if (flags := self._parse_arg_config(alias, config)) is not None:
|
|
1074
|
+
for flag in flags:
|
|
1075
|
+
if flag in self.arg_lookup:
|
|
1076
|
+
raise ValueError(
|
|
1077
|
+
f"Duplicate flag '{flag}' found. It's assigned to both '{self.arg_lookup[flag]}' and '{alias}'."
|
|
1078
|
+
)
|
|
1079
|
+
self.arg_lookup[flag] = alias
|
|
1080
|
+
|
|
1081
|
+
def _parse_arg_config(self, alias: str, config: ArgParseConfig) -> Optional[set[str]]:
|
|
1082
|
+
"""Parse an individual argument configuration."""
|
|
1083
|
+
# POSITIONAL ARGUMENT CONFIGURATION
|
|
1084
|
+
if isinstance(config, str):
|
|
1085
|
+
if config == "before":
|
|
1086
|
+
if self.pos_before_configured:
|
|
1087
|
+
raise ValueError("Only one alias can use the value 'before' for positional argument collection.")
|
|
1088
|
+
self.pos_before_configured = True
|
|
1089
|
+
elif config == "after":
|
|
1090
|
+
if self.pos_after_configured:
|
|
1091
|
+
raise ValueError("Only one alias can use the value 'after' for positional argument collection.")
|
|
1092
|
+
self.pos_after_configured = True
|
|
1093
|
+
else:
|
|
1094
|
+
raise ValueError(
|
|
1095
|
+
f"Invalid positional argument type '{config}' under alias '{alias}'.\n"
|
|
1096
|
+
"Must be either 'before' or 'after'."
|
|
1097
|
+
)
|
|
1098
|
+
self.positional_configs[alias] = config
|
|
1099
|
+
self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=True)
|
|
1100
|
+
return None # NO FLAGS TO RETURN FOR POSITIONAL ARGS
|
|
1101
|
+
|
|
1102
|
+
# NORMAL SET OF FLAGS
|
|
1103
|
+
elif isinstance(config, set):
|
|
1104
|
+
if not config:
|
|
1105
|
+
raise ValueError(
|
|
1106
|
+
f"The flag set under alias '{alias}' is empty.\n"
|
|
1107
|
+
"The set must contain at least one flag to search for."
|
|
1108
|
+
)
|
|
1109
|
+
self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=False)
|
|
1110
|
+
return config
|
|
1111
|
+
|
|
1112
|
+
# SET OF FLAGS WITH SPECIFIED DEFAULT VALUE
|
|
1113
|
+
elif isinstance(config, dict):
|
|
1114
|
+
if not config.get("flags"):
|
|
1115
|
+
raise ValueError(
|
|
1116
|
+
f"No flags provided under alias '{alias}'.\n"
|
|
1117
|
+
"The 'flags'-key set must contain at least one flag to search for."
|
|
1118
|
+
)
|
|
1119
|
+
self.parsed_args[alias] = ParsedArgData(
|
|
1120
|
+
exists=False,
|
|
1121
|
+
values=[default] if (default := config.get("default")) is not None else [],
|
|
1122
|
+
is_pos=False,
|
|
1123
|
+
)
|
|
1124
|
+
return config["flags"]
|
|
1125
|
+
|
|
1126
|
+
else:
|
|
1127
|
+
raise TypeError(
|
|
1128
|
+
f"Invalid configuration type under alias '{alias}'.\n"
|
|
1129
|
+
"Must be a set, dict, literal 'before' or literal 'after'."
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
def find_flag_positions(self) -> None:
|
|
1133
|
+
"""Find positions of first and last flags for positional argument collection."""
|
|
1134
|
+
i = 0
|
|
1135
|
+
while i < self.args_len:
|
|
1136
|
+
arg = self.args[i]
|
|
1137
|
+
|
|
1138
|
+
# CHECK FOR FLAG WITH INLINE SEPARATOR ('--flag=value')
|
|
1139
|
+
if self.flag_value_sep in arg:
|
|
1140
|
+
if arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup:
|
|
1141
|
+
if self.first_flag_pos is None:
|
|
1142
|
+
self.first_flag_pos = i
|
|
1143
|
+
self.last_flag_pos = i
|
|
1144
|
+
i += 1
|
|
1145
|
+
continue
|
|
1146
|
+
|
|
1147
|
+
# CHECK FOR STANDALONE FLAG
|
|
1148
|
+
if arg in self.arg_lookup:
|
|
1149
|
+
if self.first_flag_pos is None:
|
|
1150
|
+
self.first_flag_pos = i
|
|
1151
|
+
self.last_flag_pos = i
|
|
1152
|
+
|
|
1153
|
+
# CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value')
|
|
1154
|
+
if i + 1 < self.args_len and self.args[i + 1] == self.flag_value_sep:
|
|
1155
|
+
if i + 2 < self.args_len:
|
|
1156
|
+
i += 3 # SKIP FLAG, SEPARATOR, AND VALUE
|
|
1157
|
+
continue
|
|
1158
|
+
else:
|
|
1159
|
+
i += 2 # SKIP FLAG AND SEPARATOR
|
|
1160
|
+
continue
|
|
1161
|
+
|
|
1162
|
+
i += 1
|
|
1163
|
+
|
|
1164
|
+
def process_positional_args(self) -> None:
|
|
1165
|
+
"""Collect positional `"before"`/`"after"` arguments."""
|
|
1166
|
+
for alias, pos_type in self.positional_configs.items():
|
|
1167
|
+
if pos_type == "before":
|
|
1168
|
+
self._collect_before_arg(alias)
|
|
1169
|
+
elif pos_type == "after":
|
|
1170
|
+
self._collect_after_arg(alias)
|
|
1171
|
+
else:
|
|
1172
|
+
raise ValueError(
|
|
1173
|
+
f"Invalid positional argument type '{pos_type}' for alias '{alias}'.\n"
|
|
1174
|
+
"Must be either 'before' or 'after'."
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
def _collect_before_arg(self, alias: str) -> None:
|
|
1178
|
+
"""Collect positional `"before"` arguments."""
|
|
1179
|
+
before_args: list[str] = []
|
|
1180
|
+
end_pos: int = self.first_flag_pos if self.first_flag_pos is not None else self.args_len
|
|
1181
|
+
|
|
1182
|
+
for i in range(end_pos):
|
|
1183
|
+
if self._is_positional_arg(arg := self.args[i], allow_separator=False):
|
|
1184
|
+
before_args.append(arg)
|
|
1185
|
+
|
|
1186
|
+
if before_args:
|
|
1187
|
+
self.parsed_args[alias].values = before_args
|
|
1188
|
+
self.parsed_args[alias].exists = len(before_args) > 0
|
|
1189
|
+
|
|
1190
|
+
def _collect_after_arg(self, alias: str) -> None:
|
|
1191
|
+
"""Collect positional `"after"` arguments."""
|
|
1192
|
+
after_args: list[str] = []
|
|
1193
|
+
start_pos: int = (self.last_flag_pos + 1) if self.last_flag_pos is not None else 0
|
|
1194
|
+
|
|
1195
|
+
# SKIP THE VALUE AFTER THE LAST FLAG IF IT HAS A SEPARATOR
|
|
1196
|
+
if self.last_flag_pos is not None:
|
|
1197
|
+
# CHECK IF LAST FLAG HAS INLINE VALUE ('--flag=value')
|
|
1198
|
+
if self.flag_value_sep in self.args[self.last_flag_pos]:
|
|
1199
|
+
start_pos = self.last_flag_pos + 1 # VALUE IS INLINE, START AFTER THIS POSITION
|
|
1200
|
+
# CHECK IF NEXT TOKEN IS SEPARATOR ('--flag', '=', 'value')
|
|
1201
|
+
elif start_pos < self.args_len and self.args[start_pos].strip() == self.flag_value_sep:
|
|
1202
|
+
if start_pos + 1 < self.args_len:
|
|
1203
|
+
start_pos += 2 # SKIP SEPARATOR AND VALUE
|
|
1204
|
+
else:
|
|
1205
|
+
start_pos += 1 # SKIP SEPARATOR ONLY
|
|
1206
|
+
# NO SEPARATOR = FLAG HAS NO VALUE = START COLLECTING FROM NEXT POSITION
|
|
1207
|
+
|
|
1208
|
+
for i in range(start_pos, self.args_len):
|
|
1209
|
+
# DON'T INCLUDE FLAGS OR SEPARATORS
|
|
1210
|
+
if (arg := self.args[i]) == self.flag_value_sep:
|
|
1211
|
+
continue
|
|
1212
|
+
elif self._is_positional_arg(arg):
|
|
1213
|
+
after_args.append(arg)
|
|
1214
|
+
|
|
1215
|
+
if after_args:
|
|
1216
|
+
self.parsed_args[alias].values = after_args
|
|
1217
|
+
self.parsed_args[alias].exists = len(after_args) > 0
|
|
1218
|
+
|
|
1219
|
+
def _is_positional_arg(self, arg: str, allow_separator: bool = True) -> bool:
|
|
1220
|
+
"""Check if an argument is positional (not a flag or separator)."""
|
|
1221
|
+
if self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup:
|
|
1222
|
+
return True
|
|
1223
|
+
if arg not in self.arg_lookup and (allow_separator or arg != self.flag_value_sep):
|
|
1224
|
+
return True
|
|
1225
|
+
return False
|
|
1226
|
+
|
|
1227
|
+
def process_flagged_args(self) -> None:
|
|
1228
|
+
"""Process flagged arguments."""
|
|
1229
|
+
i = 0
|
|
1230
|
+
|
|
1231
|
+
while i < self.args_len:
|
|
1232
|
+
arg = self.args[i]
|
|
1233
|
+
|
|
1234
|
+
# CASE 1: FLAG WITH INLINE SEPARATOR ('--flag=value')
|
|
1235
|
+
if self.flag_value_sep in arg:
|
|
1236
|
+
parts = arg.split(self.flag_value_sep, 1)
|
|
1237
|
+
|
|
1238
|
+
if (potential_flag := (parts := arg.split(self.flag_value_sep, 1))[0].strip()) in self.arg_lookup:
|
|
1239
|
+
alias = self.arg_lookup[potential_flag]
|
|
1240
|
+
self.parsed_args[alias].exists = True
|
|
1241
|
+
self.parsed_args[alias].flag = potential_flag
|
|
1242
|
+
|
|
1243
|
+
if len(parts) > 1 and (val := parts[1].strip()):
|
|
1244
|
+
self.parsed_args[alias].values = [val]
|
|
1245
|
+
|
|
1246
|
+
i += 1
|
|
1247
|
+
continue
|
|
1248
|
+
|
|
1249
|
+
# CASE 2: STANDALONE FLAG
|
|
1250
|
+
if arg in self.arg_lookup:
|
|
1251
|
+
alias = self.arg_lookup[arg]
|
|
1252
|
+
self.parsed_args[alias].exists = True
|
|
1253
|
+
self.parsed_args[alias].flag = arg
|
|
1254
|
+
|
|
1255
|
+
# CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value')
|
|
1256
|
+
if i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep:
|
|
1257
|
+
if i + 2 < self.args_len:
|
|
1258
|
+
if (val := self.args[i + 2]) not in self.arg_lookup and val != self.flag_value_sep:
|
|
1259
|
+
self.parsed_args[alias].values = [val]
|
|
1260
|
+
i += 3
|
|
1261
|
+
continue
|
|
1262
|
+
i += 2
|
|
1263
|
+
continue
|
|
1264
|
+
# NO SEPARATOR = JUST A FLAG WITHOUT VALUE
|
|
1265
|
+
|
|
1266
|
+
i += 1
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
class _ConsoleLogBoxBgReplacer:
|
|
1270
|
+
"""Internal, callable class to replace matched text with background-colored text for log boxes."""
|
|
1271
|
+
|
|
1272
|
+
def __init__(self, box_bg_color: str | Rgba | Hexa) -> None:
|
|
1273
|
+
self.box_bg_color = box_bg_color
|
|
1274
|
+
|
|
1275
|
+
def __call__(self, m: _rx.Match[str]) -> str:
|
|
1276
|
+
return f"{cast(str, m.group(0))}[bg:{self.box_bg_color}]"
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
class _ConsoleInputHelper:
|
|
1280
|
+
"""Helper class to manage input processing and events."""
|
|
1281
|
+
|
|
1282
|
+
def __init__(
|
|
1283
|
+
self,
|
|
1284
|
+
mask_char: Optional[str],
|
|
1285
|
+
min_len: Optional[int],
|
|
1286
|
+
max_len: Optional[int],
|
|
1287
|
+
allowed_chars: str | AllTextChars,
|
|
1288
|
+
allow_paste: bool,
|
|
1289
|
+
validator: Optional[Callable[[str], Optional[str]]],
|
|
1290
|
+
) -> None:
|
|
1291
|
+
self.mask_char = mask_char
|
|
1292
|
+
self.min_len = min_len
|
|
1293
|
+
self.max_len = max_len
|
|
1294
|
+
self.allowed_chars = allowed_chars
|
|
1295
|
+
self.allow_paste = allow_paste
|
|
1296
|
+
self.validator = validator
|
|
1297
|
+
|
|
1298
|
+
self.result_text: str = ""
|
|
1299
|
+
self.filtered_chars: set[str] = set()
|
|
1300
|
+
self.tried_pasting: bool = False
|
|
1301
|
+
|
|
1302
|
+
def get_text(self) -> str:
|
|
1303
|
+
"""Returns the current result text."""
|
|
1304
|
+
return self.result_text
|
|
1305
|
+
|
|
1306
|
+
def bottom_toolbar(self) -> _pt.formatted_text.ANSI:
|
|
1307
|
+
"""Generates the bottom toolbar text based on the current input state."""
|
|
1308
|
+
try:
|
|
1309
|
+
if self.mask_char:
|
|
1310
|
+
text_to_check = self.result_text
|
|
1311
|
+
else:
|
|
1312
|
+
app = _pt.application.get_app()
|
|
1313
|
+
text_to_check = app.current_buffer.text
|
|
1314
|
+
|
|
1315
|
+
toolbar_msgs: list[str] = []
|
|
1316
|
+
if self.max_len and len(text_to_check) > self.max_len:
|
|
1317
|
+
toolbar_msgs.append("[b|#FFF|bg:red]( Text too long! )")
|
|
1318
|
+
if self.validator and text_to_check and (validation_error_msg := self.validator(text_to_check)) not in {"", None}:
|
|
1319
|
+
toolbar_msgs.append(f"[b|#000|bg:br:red] {validation_error_msg} [_bg]")
|
|
1320
|
+
if self.filtered_chars:
|
|
1321
|
+
plural = "" if len(char_list := "".join(sorted(self.filtered_chars))) == 1 else "s"
|
|
1322
|
+
toolbar_msgs.append(f"[b|#000|bg:yellow]( Char{plural} '{char_list}' not allowed )")
|
|
1323
|
+
self.filtered_chars.clear()
|
|
1324
|
+
if self.min_len and len(text_to_check) < self.min_len:
|
|
1325
|
+
toolbar_msgs.append(f"[b|#000|bg:yellow]( Need {self.min_len - len(text_to_check)} more chars )")
|
|
1326
|
+
if self.tried_pasting:
|
|
1327
|
+
toolbar_msgs.append("[b|#000|bg:br:yellow]( Pasting disabled )")
|
|
1328
|
+
self.tried_pasting = False
|
|
1329
|
+
if self.max_len and len(text_to_check) == self.max_len:
|
|
1330
|
+
toolbar_msgs.append("[b|#000|bg:br:yellow]( Maximum length reached )")
|
|
1331
|
+
|
|
1332
|
+
return _pt.formatted_text.ANSI(FormatCodes.to_ansi(" ".join(toolbar_msgs)))
|
|
1333
|
+
|
|
1334
|
+
except Exception:
|
|
1335
|
+
return _pt.formatted_text.ANSI("")
|
|
1336
|
+
|
|
1337
|
+
def process_insert_text(self, text: str) -> tuple[str, set[str]]:
|
|
1338
|
+
"""Processes the inserted text according to the allowed characters and max length."""
|
|
1339
|
+
removed_chars: set[str] = set()
|
|
1340
|
+
|
|
1341
|
+
if not text:
|
|
1342
|
+
return "", removed_chars
|
|
1343
|
+
|
|
1344
|
+
processed_text = "".join(c for c in text if ord(c) >= 32)
|
|
1345
|
+
if self.allowed_chars is not CHARS.ALL:
|
|
1346
|
+
filtered_text = ""
|
|
1347
|
+
for char in processed_text:
|
|
1348
|
+
if char in cast(str, self.allowed_chars):
|
|
1349
|
+
filtered_text += char
|
|
1350
|
+
else:
|
|
1351
|
+
removed_chars.add(char)
|
|
1352
|
+
processed_text = filtered_text
|
|
1353
|
+
|
|
1354
|
+
if self.max_len:
|
|
1355
|
+
if (remaining_space := self.max_len - len(self.result_text)) > 0:
|
|
1356
|
+
if len(processed_text) > remaining_space:
|
|
1357
|
+
processed_text = processed_text[:remaining_space]
|
|
1358
|
+
else:
|
|
1359
|
+
processed_text = ""
|
|
1360
|
+
|
|
1361
|
+
return processed_text, removed_chars
|
|
1362
|
+
|
|
1363
|
+
def insert_text_event(self, event: KeyPressEvent) -> None:
|
|
1364
|
+
"""Handles text insertion events (typing/pasting)."""
|
|
1365
|
+
try:
|
|
1366
|
+
if not (insert_text := event.data):
|
|
1367
|
+
return
|
|
1368
|
+
|
|
1369
|
+
buffer = event.app.current_buffer
|
|
1370
|
+
cursor_pos = buffer.cursor_position
|
|
1371
|
+
insert_text, filtered_chars = self.process_insert_text(insert_text)
|
|
1372
|
+
self.filtered_chars.update(filtered_chars)
|
|
1373
|
+
|
|
1374
|
+
if insert_text:
|
|
1375
|
+
self.result_text = self.result_text[:cursor_pos] + insert_text + self.result_text[cursor_pos:]
|
|
1376
|
+
if self.mask_char:
|
|
1377
|
+
buffer.insert_text(self.mask_char[0] * len(insert_text))
|
|
1378
|
+
else:
|
|
1379
|
+
buffer.insert_text(insert_text)
|
|
1380
|
+
|
|
1381
|
+
except Exception:
|
|
1382
|
+
pass
|
|
1383
|
+
|
|
1384
|
+
def remove_text_event(self, event: KeyPressEvent, is_backspace: bool = False) -> None:
|
|
1385
|
+
"""Handles text removal events (backspace/delete)."""
|
|
1386
|
+
try:
|
|
1387
|
+
buffer = event.app.current_buffer
|
|
1388
|
+
cursor_pos = buffer.cursor_position
|
|
1389
|
+
has_selection = buffer.selection_state is not None
|
|
1390
|
+
|
|
1391
|
+
if has_selection:
|
|
1392
|
+
start, end = buffer.document.selection_range()
|
|
1393
|
+
self.result_text = self.result_text[:start] + self.result_text[end:]
|
|
1394
|
+
buffer.cursor_position = start
|
|
1395
|
+
buffer.delete(end - start)
|
|
1396
|
+
else:
|
|
1397
|
+
if is_backspace:
|
|
1398
|
+
if cursor_pos > 0:
|
|
1399
|
+
self.result_text = self.result_text[:cursor_pos - 1] + self.result_text[cursor_pos:]
|
|
1400
|
+
buffer.delete_before_cursor(1)
|
|
1401
|
+
else:
|
|
1402
|
+
if cursor_pos < len(self.result_text):
|
|
1403
|
+
self.result_text = self.result_text[:cursor_pos] + self.result_text[cursor_pos + 1:]
|
|
1404
|
+
buffer.delete(1)
|
|
1405
|
+
|
|
1406
|
+
except Exception:
|
|
1407
|
+
pass
|
|
1408
|
+
|
|
1409
|
+
def handle_delete(self, event: KeyPressEvent) -> None:
|
|
1410
|
+
self.remove_text_event(event)
|
|
1411
|
+
|
|
1412
|
+
def handle_backspace(self, event: KeyPressEvent) -> None:
|
|
1413
|
+
self.remove_text_event(event, is_backspace=True)
|
|
1414
|
+
|
|
1415
|
+
@staticmethod
|
|
1416
|
+
def handle_control_a(event: KeyPressEvent) -> None:
|
|
1417
|
+
buffer = event.app.current_buffer
|
|
1418
|
+
buffer.cursor_position = 0
|
|
1419
|
+
buffer.start_selection()
|
|
1420
|
+
buffer.cursor_position = len(buffer.text)
|
|
1421
|
+
|
|
1422
|
+
def handle_paste(self, event: KeyPressEvent) -> None:
|
|
1423
|
+
if self.allow_paste:
|
|
1424
|
+
self.insert_text_event(event)
|
|
1425
|
+
else:
|
|
1426
|
+
self.tried_pasting = True
|
|
1427
|
+
|
|
1428
|
+
def handle_any(self, event: KeyPressEvent) -> None:
|
|
1429
|
+
self.insert_text_event(event)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
class _ConsoleInputValidator(Validator):
|
|
1433
|
+
|
|
1434
|
+
def __init__(
|
|
1435
|
+
self,
|
|
1436
|
+
get_text: Callable[[], str],
|
|
1437
|
+
mask_char: Optional[str],
|
|
1438
|
+
min_len: Optional[int],
|
|
1439
|
+
validator: Optional[Callable[[str], Optional[str]]],
|
|
1440
|
+
):
|
|
1441
|
+
self.get_text = get_text
|
|
1442
|
+
self.mask_char = mask_char
|
|
1443
|
+
self.min_len = min_len
|
|
1444
|
+
self.validator = validator
|
|
1445
|
+
|
|
1446
|
+
def validate(self, document) -> None:
|
|
1447
|
+
text_to_validate = self.get_text() if self.mask_char else document.text
|
|
1448
|
+
if self.min_len and len(text_to_validate) < self.min_len:
|
|
1449
|
+
raise ValidationError(message="", cursor_position=len(document.text))
|
|
1450
|
+
if self.validator and self.validator(text_to_validate) not in {"", None}:
|
|
1451
|
+
raise ValidationError(message="", cursor_position=len(document.text))
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
class ProgressBar:
|
|
1455
|
+
"""A console progress bar with smooth transitions and customizable appearance.\n
|
|
1456
|
+
--------------------------------------------------------------------------------------------------
|
|
1457
|
+
- `min_width` -⠀the min width of the progress bar in chars
|
|
1458
|
+
- `max_width` -⠀the max width of the progress bar in chars
|
|
1459
|
+
- `bar_format` -⠀the format strings used to render the progress bar, containing placeholders:
|
|
1460
|
+
* `{label}` `{l}`
|
|
1461
|
+
* `{bar}` `{b}`
|
|
1462
|
+
* `{current}` `{c}` (optional `:<char>` format specifier for thousands separator, e.g. `{c:,}`)
|
|
1463
|
+
* `{total}` `{t}` (optional `:<char>` format specifier for thousands separator, e.g. `{t:,}`)
|
|
1464
|
+
* `{percentage}` `{percent}` `{p}` (optional `:.<num>f` format specifier to round
|
|
1465
|
+
to specified number of decimal places, e.g. `{p:.1f}`)
|
|
1466
|
+
- `limited_bar_format` -⠀a simplified format string used when the console width is too small
|
|
1467
|
+
for the normal `bar_format`
|
|
1468
|
+
- `chars` -⠀a tuple of characters ordered from full to empty progress<br>
|
|
1469
|
+
The first character represents completely filled sections, intermediate
|
|
1470
|
+
characters create smooth transitions, and the last character represents
|
|
1471
|
+
empty sections. Default is a set of Unicode block characters.
|
|
1472
|
+
--------------------------------------------------------------------------------------------------
|
|
1473
|
+
The bar format (also limited) can additionally be formatted with special formatting codes. For
|
|
1474
|
+
more detailed information about formatting codes, see the `format_codes` module documentation."""
|
|
1475
|
+
|
|
1476
|
+
def __init__(
|
|
1477
|
+
self,
|
|
1478
|
+
min_width: int = 10,
|
|
1479
|
+
max_width: int = 50,
|
|
1480
|
+
bar_format: list[str] | tuple[str, ...] = ["{l}", "▕{b}▏", "[b]({c:,})/{t:,}", "[dim](([i]({p}%)))"],
|
|
1481
|
+
limited_bar_format: list[str] | tuple[str, ...] = ["▕{b}▏"],
|
|
1482
|
+
sep: str = " ",
|
|
1483
|
+
chars: tuple[str, ...] = ("█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", " "),
|
|
1484
|
+
):
|
|
1485
|
+
self.active: bool = False
|
|
1486
|
+
"""Whether the progress bar is currently active (intercepting stdout) or not."""
|
|
1487
|
+
self.min_width: int
|
|
1488
|
+
"""The min width of the progress bar in chars."""
|
|
1489
|
+
self.max_width: int
|
|
1490
|
+
"""The max width of the progress bar in chars."""
|
|
1491
|
+
self.bar_format: list[str] | tuple[str, ...]
|
|
1492
|
+
"""The format strings used to render the progress bar (joined by `sep`)."""
|
|
1493
|
+
self.limited_bar_format: list[str] | tuple[str, ...]
|
|
1494
|
+
"""The simplified format strings used when the console width is too small."""
|
|
1495
|
+
self.sep: str
|
|
1496
|
+
"""The separator string used to join multiple bar-format strings."""
|
|
1497
|
+
self.chars: tuple[str, ...]
|
|
1498
|
+
"""A tuple of characters ordered from full to empty progress."""
|
|
1499
|
+
|
|
1500
|
+
self.set_width(min_width, max_width)
|
|
1501
|
+
self.set_bar_format(bar_format, limited_bar_format, sep)
|
|
1502
|
+
self.set_chars(chars)
|
|
1503
|
+
|
|
1504
|
+
self._buffer: list[str] = []
|
|
1505
|
+
self._original_stdout: Optional[TextIO] = None
|
|
1506
|
+
self._current_progress_str: str = ""
|
|
1507
|
+
self._last_line_len: int = 0
|
|
1508
|
+
self._last_update_time: float = 0.0
|
|
1509
|
+
self._min_update_interval: float = 0.02 # 20ms = MAX 50 UPDATES/SECOND
|
|
1510
|
+
|
|
1511
|
+
def set_width(self, min_width: Optional[int] = None, max_width: Optional[int] = None) -> None:
|
|
1512
|
+
"""Set the width of the progress bar.\n
|
|
1513
|
+
--------------------------------------------------------------
|
|
1514
|
+
- `min_width` -⠀the min width of the progress bar in chars
|
|
1515
|
+
- `max_width` -⠀the max width of the progress bar in chars"""
|
|
1516
|
+
if min_width is not None:
|
|
1517
|
+
if min_width < 1:
|
|
1518
|
+
raise ValueError(f"The 'min_width' parameter must be a positive integer, got {min_width!r}")
|
|
1519
|
+
|
|
1520
|
+
self.min_width = max(1, min_width)
|
|
1521
|
+
|
|
1522
|
+
if max_width is not None:
|
|
1523
|
+
if max_width < 1:
|
|
1524
|
+
raise ValueError(f"The 'max_width' parameter must be a positive integer, got {max_width!r}")
|
|
1525
|
+
|
|
1526
|
+
self.max_width = max(self.min_width, max_width)
|
|
1527
|
+
|
|
1528
|
+
def set_bar_format(
|
|
1529
|
+
self,
|
|
1530
|
+
bar_format: Optional[list[str] | tuple[str, ...]] = None,
|
|
1531
|
+
limited_bar_format: Optional[list[str] | tuple[str, ...]] = None,
|
|
1532
|
+
sep: Optional[str] = None,
|
|
1533
|
+
) -> None:
|
|
1534
|
+
"""Set the format string used to render the progress bar.\n
|
|
1535
|
+
--------------------------------------------------------------------------------------------------
|
|
1536
|
+
- `bar_format` -⠀the format strings used to render the progress bar, containing placeholders:
|
|
1537
|
+
* `{label}` `{l}`
|
|
1538
|
+
* `{bar}` `{b}`
|
|
1539
|
+
* `{current}` `{c}` (optional `:<char>` format specifier for thousands separator, e.g. `{c:,}`)
|
|
1540
|
+
* `{total}` `{t}` (optional `:<char>` format specifier for thousands separator, e.g. `{t:,}`)
|
|
1541
|
+
* `{percentage}` `{percent}` `{p}` (optional `:.<num>f` format specifier to round
|
|
1542
|
+
to specified number of decimal places, e.g. `{p:.1f}`)
|
|
1543
|
+
- `limited_bar_format` -⠀a simplified format strings used when the console width is too small
|
|
1544
|
+
- `sep` -⠀the separator string used to join multiple format strings
|
|
1545
|
+
--------------------------------------------------------------------------------------------------
|
|
1546
|
+
The bar format (also limited) can additionally be formatted with special formatting codes. For
|
|
1547
|
+
more detailed information about formatting codes, see the `format_codes` module documentation."""
|
|
1548
|
+
if bar_format is not None:
|
|
1549
|
+
if not any(_PATTERNS.bar.search(s) for s in bar_format):
|
|
1550
|
+
raise ValueError("The 'bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.")
|
|
1551
|
+
|
|
1552
|
+
self.bar_format = bar_format
|
|
1553
|
+
|
|
1554
|
+
if limited_bar_format is not None:
|
|
1555
|
+
if not any(_PATTERNS.bar.search(s) for s in limited_bar_format):
|
|
1556
|
+
raise ValueError("The 'limited_bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.")
|
|
1557
|
+
|
|
1558
|
+
self.limited_bar_format = limited_bar_format
|
|
1559
|
+
|
|
1560
|
+
if sep is not None:
|
|
1561
|
+
self.sep = sep
|
|
1562
|
+
|
|
1563
|
+
def set_chars(self, chars: tuple[str, ...]) -> None:
|
|
1564
|
+
"""Set the characters used to render the progress bar.\n
|
|
1565
|
+
--------------------------------------------------------------------------
|
|
1566
|
+
- `chars` -⠀a tuple of characters ordered from full to empty progress<br>
|
|
1567
|
+
The first character represents completely filled sections, intermediate
|
|
1568
|
+
characters create smooth transitions, and the last character represents
|
|
1569
|
+
empty sections. If None, uses default Unicode block characters."""
|
|
1570
|
+
if len(chars) < 2:
|
|
1571
|
+
raise ValueError("The 'chars' parameter must contain at least two characters (full and empty).")
|
|
1572
|
+
elif not all(isinstance(c, str) and len(c) == 1 for c in chars):
|
|
1573
|
+
raise ValueError("All elements of 'chars' must be single-character strings.")
|
|
1574
|
+
|
|
1575
|
+
self.chars = chars
|
|
1576
|
+
|
|
1577
|
+
def show_progress(self, current: int, total: int, label: Optional[str] = None) -> None:
|
|
1578
|
+
"""Show or update the progress bar.\n
|
|
1579
|
+
-------------------------------------------------------------------------------------------
|
|
1580
|
+
- `current` -⠀the current progress value (below `0` or greater than `total` hides the bar)
|
|
1581
|
+
- `total` -⠀the total value representing 100% progress (must be greater than `0`)
|
|
1582
|
+
- `label` -⠀an optional label which is inserted at the `{label}` or `{l}` placeholder"""
|
|
1583
|
+
# THROTTLE UPDATES (UNLESS IT'S THE FIRST/FINAL UPDATE)
|
|
1584
|
+
current_time = _time.time()
|
|
1585
|
+
if (
|
|
1586
|
+
not (self._last_update_time == 0.0 or current >= total or current < 0) \
|
|
1587
|
+
and (current_time - self._last_update_time) < self._min_update_interval
|
|
1588
|
+
):
|
|
1589
|
+
return
|
|
1590
|
+
self._last_update_time = current_time
|
|
1591
|
+
|
|
1592
|
+
if current < 0:
|
|
1593
|
+
raise ValueError("The 'current' parameter must be a non-negative integer.")
|
|
1594
|
+
if total <= 0:
|
|
1595
|
+
raise ValueError("The 'total' parameter must be a positive integer.")
|
|
1596
|
+
|
|
1597
|
+
try:
|
|
1598
|
+
if not self.active:
|
|
1599
|
+
self._start_intercepting()
|
|
1600
|
+
self._flush_buffer()
|
|
1601
|
+
self._draw_progress_bar(current, total, label or "")
|
|
1602
|
+
if current < 0 or current > total:
|
|
1603
|
+
self.hide_progress()
|
|
1604
|
+
except Exception:
|
|
1605
|
+
self._emergency_cleanup()
|
|
1606
|
+
raise
|
|
1607
|
+
|
|
1608
|
+
def hide_progress(self) -> None:
|
|
1609
|
+
"""Hide the progress bar and restore normal console output."""
|
|
1610
|
+
if self.active:
|
|
1611
|
+
self._clear_progress_line()
|
|
1612
|
+
self._stop_intercepting()
|
|
1613
|
+
|
|
1614
|
+
@contextmanager
|
|
1615
|
+
def progress_context(self, total: int, label: Optional[str] = None) -> Generator[ProgressUpdater, None, None]:
|
|
1616
|
+
"""Context manager for automatic cleanup. Returns a function to update progress.\n
|
|
1617
|
+
----------------------------------------------------------------------------------------------------
|
|
1618
|
+
- `total` -⠀the total value representing 100% progress (must be greater than `0`)
|
|
1619
|
+
- `label` -⠀an optional label which is inserted at the `{label}` or `{l}` placeholder
|
|
1620
|
+
----------------------------------------------------------------------------------------------------
|
|
1621
|
+
The returned callable accepts keyword arguments. At least one of these parameters must be provided:
|
|
1622
|
+
- `current` -⠀update the current progress value
|
|
1623
|
+
- `label` -⠀update the progress label\n
|
|
1624
|
+
|
|
1625
|
+
#### Example usage:
|
|
1626
|
+
```python
|
|
1627
|
+
with ProgressBar().progress_context(500, "Loading...") as update_progress:
|
|
1628
|
+
update_progress(0) # Show empty bar at start
|
|
1629
|
+
|
|
1630
|
+
for i in range(400):
|
|
1631
|
+
# Do some work...
|
|
1632
|
+
update_progress(i) # Update progress
|
|
1633
|
+
|
|
1634
|
+
update_progress(label="Finalizing...") # Update label
|
|
1635
|
+
|
|
1636
|
+
for i in range(400, 500):
|
|
1637
|
+
# Do some work...
|
|
1638
|
+
update_progress(i, f"Finalizing ({i})") # Update both
|
|
1639
|
+
```"""
|
|
1640
|
+
if total <= 0:
|
|
1641
|
+
raise ValueError("The 'total' parameter must be a positive integer.")
|
|
1642
|
+
|
|
1643
|
+
try:
|
|
1644
|
+
yield _ProgressContextHelper(self, total, label)
|
|
1645
|
+
except Exception:
|
|
1646
|
+
self._emergency_cleanup()
|
|
1647
|
+
raise
|
|
1648
|
+
finally:
|
|
1649
|
+
self.hide_progress()
|
|
1650
|
+
|
|
1651
|
+
def _draw_progress_bar(self, current: int, total: int, label: Optional[str] = None) -> None:
|
|
1652
|
+
if total <= 0 or not self._original_stdout:
|
|
1653
|
+
return
|
|
1654
|
+
|
|
1655
|
+
percentage = min(100, (current / total) * 100)
|
|
1656
|
+
|
|
1657
|
+
formatted, bar_width = self._get_formatted_info_and_bar_width(self.bar_format, current, total, percentage, label)
|
|
1658
|
+
if bar_width < self.min_width:
|
|
1659
|
+
formatted, bar_width = self._get_formatted_info_and_bar_width(
|
|
1660
|
+
self.limited_bar_format, current, total, percentage, label
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
bar = f"{self._create_bar(current, total, max(1, bar_width))}[*]"
|
|
1664
|
+
progress_text = _PATTERNS.bar.sub(FormatCodes.to_ansi(bar), formatted)
|
|
1665
|
+
|
|
1666
|
+
self._current_progress_str = progress_text
|
|
1667
|
+
self._last_line_len = len(progress_text)
|
|
1668
|
+
self._original_stdout.write(f"\r{progress_text}")
|
|
1669
|
+
self._original_stdout.flush()
|
|
1670
|
+
|
|
1671
|
+
def _get_formatted_info_and_bar_width(
|
|
1672
|
+
self,
|
|
1673
|
+
bar_format: list[str] | tuple[str, ...],
|
|
1674
|
+
current: int,
|
|
1675
|
+
total: int,
|
|
1676
|
+
percentage: float,
|
|
1677
|
+
label: Optional[str] = None,
|
|
1678
|
+
) -> tuple[str, int]:
|
|
1679
|
+
fmt_parts = []
|
|
1680
|
+
|
|
1681
|
+
for s in bar_format:
|
|
1682
|
+
fmt_part = _PATTERNS.label.sub(label or "", s)
|
|
1683
|
+
fmt_part = _PATTERNS.current.sub(_ProgressBarCurrentReplacer(current), fmt_part)
|
|
1684
|
+
fmt_part = _PATTERNS.total.sub(_ProgressBarTotalReplacer(total), fmt_part)
|
|
1685
|
+
fmt_part = _PATTERNS.percentage.sub(_ProgressBarPercentageReplacer(percentage), fmt_part)
|
|
1686
|
+
if fmt_part:
|
|
1687
|
+
fmt_parts.append(fmt_part)
|
|
1688
|
+
|
|
1689
|
+
fmt_str = self.sep.join(fmt_parts)
|
|
1690
|
+
fmt_str = FormatCodes.to_ansi(fmt_str)
|
|
1691
|
+
|
|
1692
|
+
bar_space = Console.w - len(FormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str)))
|
|
1693
|
+
bar_width = min(bar_space, self.max_width) if bar_space > 0 else 0
|
|
1694
|
+
|
|
1695
|
+
return fmt_str, bar_width
|
|
1696
|
+
|
|
1697
|
+
def _create_bar(self, current: int, total: int, bar_width: int) -> str:
|
|
1698
|
+
progress = current / total if total > 0 else 0
|
|
1699
|
+
bar = []
|
|
1700
|
+
|
|
1701
|
+
for i in range(bar_width):
|
|
1702
|
+
pos_progress = (i + 1) / bar_width
|
|
1703
|
+
if progress >= pos_progress:
|
|
1704
|
+
bar.append(self.chars[0])
|
|
1705
|
+
elif progress >= pos_progress - (1 / bar_width):
|
|
1706
|
+
remainder = (progress - (pos_progress - (1 / bar_width))) * bar_width
|
|
1707
|
+
char_idx = len(self.chars) - 1 - min(int(remainder * len(self.chars)), len(self.chars) - 1)
|
|
1708
|
+
bar.append(self.chars[char_idx])
|
|
1709
|
+
else:
|
|
1710
|
+
bar.append(self.chars[-1])
|
|
1711
|
+
return "".join(bar)
|
|
1712
|
+
|
|
1713
|
+
def _start_intercepting(self) -> None:
|
|
1714
|
+
self.active = True
|
|
1715
|
+
self._original_stdout = _sys.stdout
|
|
1716
|
+
_sys.stdout = _InterceptedOutput(self)
|
|
1717
|
+
|
|
1718
|
+
def _stop_intercepting(self) -> None:
|
|
1719
|
+
if self._original_stdout:
|
|
1720
|
+
_sys.stdout = self._original_stdout
|
|
1721
|
+
self._original_stdout = None
|
|
1722
|
+
self.active = False
|
|
1723
|
+
self._buffer.clear()
|
|
1724
|
+
self._last_line_len = 0
|
|
1725
|
+
self._last_update_time = 0.0
|
|
1726
|
+
self._current_progress_str = ""
|
|
1727
|
+
|
|
1728
|
+
def _emergency_cleanup(self) -> None:
|
|
1729
|
+
"""Emergency cleanup to restore stdout in case of exceptions."""
|
|
1730
|
+
try:
|
|
1731
|
+
self._stop_intercepting()
|
|
1732
|
+
except Exception:
|
|
1733
|
+
pass
|
|
1734
|
+
|
|
1735
|
+
def _clear_progress_line(self) -> None:
|
|
1736
|
+
if self._last_line_len > 0 and self._original_stdout:
|
|
1737
|
+
self._original_stdout.write(f"{ANSI.CHAR}[2K\r")
|
|
1738
|
+
self._original_stdout.flush()
|
|
1739
|
+
|
|
1740
|
+
def _flush_buffer(self) -> None:
|
|
1741
|
+
if self._buffer and self._original_stdout:
|
|
1742
|
+
self._clear_progress_line()
|
|
1743
|
+
for content in self._buffer:
|
|
1744
|
+
self._original_stdout.write(content)
|
|
1745
|
+
self._original_stdout.flush()
|
|
1746
|
+
self._buffer.clear()
|
|
1747
|
+
|
|
1748
|
+
def _redraw_display(self) -> None:
|
|
1749
|
+
if self._current_progress_str and self._original_stdout:
|
|
1750
|
+
self._original_stdout.write(f"{ANSI.CHAR}[2K\r{self._current_progress_str}")
|
|
1751
|
+
self._original_stdout.flush()
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
class _ProgressContextHelper:
|
|
1755
|
+
"""Internal, callable helper class to update the progress bar's current value and/or label.\n
|
|
1756
|
+
----------------------------------------------------------------------------------------------
|
|
1757
|
+
- `current` -⠀the current progress value
|
|
1758
|
+
- `label` -⠀the progress label
|
|
1759
|
+
- `type_checking` -⠀whether to check the parameters' types:
|
|
1760
|
+
Is false per default to save performance, but can be set to true for debugging purposes."""
|
|
1761
|
+
|
|
1762
|
+
def __init__(self, progress_bar: ProgressBar, total: int, label: Optional[str]):
|
|
1763
|
+
self.progress_bar = progress_bar
|
|
1764
|
+
self.total = total
|
|
1765
|
+
self.current_label = label
|
|
1766
|
+
self.current_progress = 0
|
|
1767
|
+
|
|
1768
|
+
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
|
1769
|
+
current, label = None, None
|
|
1770
|
+
|
|
1771
|
+
if (num_args := len(args)) == 1:
|
|
1772
|
+
current = args[0]
|
|
1773
|
+
elif num_args == 2:
|
|
1774
|
+
current, label = args[0], args[1]
|
|
1775
|
+
else:
|
|
1776
|
+
raise TypeError(f"update_progress() takes 1 or 2 positional arguments, got {len(args)}")
|
|
1777
|
+
|
|
1778
|
+
if current is not None and "current" in kwargs:
|
|
1779
|
+
current = kwargs["current"]
|
|
1780
|
+
if label is None and "label" in kwargs:
|
|
1781
|
+
label = kwargs["label"]
|
|
1782
|
+
|
|
1783
|
+
if current is None and label is None:
|
|
1784
|
+
raise TypeError("Either the keyword argument 'current' or 'label' must be provided.")
|
|
1785
|
+
|
|
1786
|
+
if current is not None:
|
|
1787
|
+
self.current_progress = current
|
|
1788
|
+
if label is not None:
|
|
1789
|
+
self.current_label = label
|
|
1790
|
+
|
|
1791
|
+
self.progress_bar.show_progress(current=self.current_progress, total=self.total, label=self.current_label)
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
class _ProgressBarCurrentReplacer:
|
|
1795
|
+
"""Internal, callable class to replace `{current}` placeholder with formatted number."""
|
|
1796
|
+
|
|
1797
|
+
def __init__(self, current: int) -> None:
|
|
1798
|
+
self.current = current
|
|
1799
|
+
|
|
1800
|
+
def __call__(self, match: _rx.Match[str]) -> str:
|
|
1801
|
+
if (sep := match.group(1)):
|
|
1802
|
+
return f"{self.current:,}".replace(",", sep)
|
|
1803
|
+
return str(self.current)
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
class _ProgressBarTotalReplacer:
|
|
1807
|
+
"""Internal, callable class to replace `{total}` placeholder with formatted number."""
|
|
1808
|
+
|
|
1809
|
+
def __init__(self, total: int) -> None:
|
|
1810
|
+
self.total = total
|
|
1811
|
+
|
|
1812
|
+
def __call__(self, match: _rx.Match[str]) -> str:
|
|
1813
|
+
if (sep := match.group(1)):
|
|
1814
|
+
return f"{self.total:,}".replace(",", sep)
|
|
1815
|
+
return str(self.total)
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
class _ProgressBarPercentageReplacer:
|
|
1819
|
+
"""Internal, callable class to replace `{percentage}` placeholder with formatted float."""
|
|
1820
|
+
|
|
1821
|
+
def __init__(self, percentage: float) -> None:
|
|
1822
|
+
self.percentage = percentage
|
|
1823
|
+
|
|
1824
|
+
def __call__(self, match: _rx.Match[str]) -> str:
|
|
1825
|
+
return f"{self.percentage:.{match.group(1) if match.group(1) else '1'}f}"
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
class Spinner:
|
|
1829
|
+
"""A console spinner for indeterminate processes with customizable appearance.
|
|
1830
|
+
This class intercepts stdout to allow printing while the animation is active.\n
|
|
1831
|
+
---------------------------------------------------------------------------------------------
|
|
1832
|
+
- `label` -⠀the current label text
|
|
1833
|
+
- `spinner_format` -⠀the format string used to render the spinner, containing placeholders:
|
|
1834
|
+
* `{label}` `{l}`
|
|
1835
|
+
* `{animation}` `{a}`
|
|
1836
|
+
- `frames` -⠀a tuple of strings representing the animation frames
|
|
1837
|
+
- `interval` -⠀the time in seconds between each animation frame
|
|
1838
|
+
---------------------------------------------------------------------------------------------
|
|
1839
|
+
The `spinner_format` can additionally be formatted with special formatting codes. For more
|
|
1840
|
+
detailed information about formatting codes, see the `format_codes` module documentation."""
|
|
1841
|
+
|
|
1842
|
+
def __init__(
|
|
1843
|
+
self,
|
|
1844
|
+
label: Optional[str] = None,
|
|
1845
|
+
spinner_format: list[str] | tuple[str, ...] = ["{l}", "[b]({a}) "],
|
|
1846
|
+
sep: str = " ",
|
|
1847
|
+
frames: tuple[str, ...] = ("· ", "·· ", "···", " ··", " ·", " ·", " ··", "···", "·· ", "· "),
|
|
1848
|
+
interval: float = 0.2,
|
|
1849
|
+
):
|
|
1850
|
+
self.spinner_format: list[str] | tuple[str, ...]
|
|
1851
|
+
"""The format strings used to render the spinner (joined by `sep`)."""
|
|
1852
|
+
self.sep: str
|
|
1853
|
+
"""The separator string used to join multiple spinner-format strings."""
|
|
1854
|
+
self.frames: tuple[str, ...]
|
|
1855
|
+
"""A tuple of strings representing the animation frames."""
|
|
1856
|
+
self.interval: float
|
|
1857
|
+
"""The time in seconds between each animation frame."""
|
|
1858
|
+
self.label: Optional[str]
|
|
1859
|
+
"""The current label text."""
|
|
1860
|
+
self.active: bool = False
|
|
1861
|
+
"""Whether the spinner is currently active (intercepting stdout) or not."""
|
|
1862
|
+
|
|
1863
|
+
self.update_label(label)
|
|
1864
|
+
self.set_format(spinner_format, sep)
|
|
1865
|
+
self.set_frames(frames)
|
|
1866
|
+
self.set_interval(interval)
|
|
1867
|
+
|
|
1868
|
+
self._buffer: list[str] = []
|
|
1869
|
+
self._original_stdout: Optional[TextIO] = None
|
|
1870
|
+
self._current_animation_str: str = ""
|
|
1871
|
+
self._last_line_len: int = 0
|
|
1872
|
+
self._frame_index: int = 0
|
|
1873
|
+
self._stop_event: Optional[_threading.Event] = None
|
|
1874
|
+
self._animation_thread: Optional[_threading.Thread] = None
|
|
1875
|
+
|
|
1876
|
+
def set_format(self, spinner_format: list[str] | tuple[str, ...], sep: Optional[str] = None) -> None:
|
|
1877
|
+
"""Set the format string used to render the spinner.\n
|
|
1878
|
+
---------------------------------------------------------------------------------------------
|
|
1879
|
+
- `spinner_format` -⠀the format strings used to render the spinner, containing placeholders:
|
|
1880
|
+
* `{label}` `{l}`
|
|
1881
|
+
* `{animation}` `{a}`
|
|
1882
|
+
- `sep` -⠀the separator string used to join multiple format strings"""
|
|
1883
|
+
if not any(_PATTERNS.animation.search(fmt) for fmt in spinner_format):
|
|
1884
|
+
raise ValueError(
|
|
1885
|
+
"At least one format string in 'spinner_format' must contain the '{animation}' or '{a}' placeholder."
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
self.spinner_format = spinner_format
|
|
1889
|
+
self.sep = sep or self.sep
|
|
1890
|
+
|
|
1891
|
+
def set_frames(self, frames: tuple[str, ...]) -> None:
|
|
1892
|
+
"""Set the frames used for the spinner animation.\n
|
|
1893
|
+
---------------------------------------------------------------------
|
|
1894
|
+
- `frames` -⠀a tuple of strings representing the animation frames"""
|
|
1895
|
+
if len(frames) < 2:
|
|
1896
|
+
raise ValueError("The 'frames' parameter must contain at least two frames.")
|
|
1897
|
+
|
|
1898
|
+
self.frames = frames
|
|
1899
|
+
|
|
1900
|
+
def set_interval(self, interval: int | float) -> None:
|
|
1901
|
+
"""Set the time interval between each animation frame.\n
|
|
1902
|
+
-------------------------------------------------------------------
|
|
1903
|
+
- `interval` -⠀the time in seconds between each animation frame"""
|
|
1904
|
+
if interval <= 0:
|
|
1905
|
+
raise ValueError("The 'interval' parameter must be a positive number.")
|
|
1906
|
+
|
|
1907
|
+
self.interval = interval
|
|
1908
|
+
|
|
1909
|
+
def start(self, label: Optional[str] = None) -> None:
|
|
1910
|
+
"""Start the spinner animation and intercept stdout.\n
|
|
1911
|
+
----------------------------------------------------------
|
|
1912
|
+
- `label` -⠀the label to display alongside the spinner"""
|
|
1913
|
+
if self.active:
|
|
1914
|
+
return
|
|
1915
|
+
|
|
1916
|
+
self.label = label or self.label
|
|
1917
|
+
self._start_intercepting()
|
|
1918
|
+
self._stop_event = _threading.Event()
|
|
1919
|
+
self._animation_thread = _threading.Thread(target=self._animation_loop, daemon=True)
|
|
1920
|
+
self._animation_thread.start()
|
|
1921
|
+
|
|
1922
|
+
def stop(self) -> None:
|
|
1923
|
+
"""Stop and hide the spinner and restore normal console output."""
|
|
1924
|
+
if self.active:
|
|
1925
|
+
if self._stop_event:
|
|
1926
|
+
self._stop_event.set()
|
|
1927
|
+
if self._animation_thread:
|
|
1928
|
+
self._animation_thread.join()
|
|
1929
|
+
|
|
1930
|
+
self._stop_event = None
|
|
1931
|
+
self._animation_thread = None
|
|
1932
|
+
self._frame_index = 0
|
|
1933
|
+
|
|
1934
|
+
self._clear_spinner_line()
|
|
1935
|
+
self._stop_intercepting()
|
|
1936
|
+
|
|
1937
|
+
def update_label(self, label: Optional[str]) -> None:
|
|
1938
|
+
"""Update the spinner's label text.\n
|
|
1939
|
+
--------------------------------------
|
|
1940
|
+
- `new_label` -⠀the new label text"""
|
|
1941
|
+
self.label = label
|
|
1942
|
+
|
|
1943
|
+
@contextmanager
|
|
1944
|
+
def context(self, label: Optional[str] = None) -> Generator[Callable[[str], None], None, None]:
|
|
1945
|
+
"""Context manager for automatic cleanup. Returns a function to update the label.\n
|
|
1946
|
+
----------------------------------------------------------------------------------------------
|
|
1947
|
+
- `label` -⠀the label to display alongside the spinner
|
|
1948
|
+
-----------------------------------------------------------------------------------------------
|
|
1949
|
+
The returned callable accepts a single parameter:
|
|
1950
|
+
- `new_label` -⠀the new label text\n
|
|
1951
|
+
|
|
1952
|
+
#### Example usage:
|
|
1953
|
+
```python
|
|
1954
|
+
with Spinner().context("Starting...") as update_label:
|
|
1955
|
+
time.sleep(2)
|
|
1956
|
+
update_label("Processing...")
|
|
1957
|
+
time.sleep(3)
|
|
1958
|
+
update_label("Finishing...")
|
|
1959
|
+
time.sleep(2)
|
|
1960
|
+
```"""
|
|
1961
|
+
try:
|
|
1962
|
+
self.start(label)
|
|
1963
|
+
yield self.update_label
|
|
1964
|
+
except Exception:
|
|
1965
|
+
self._emergency_cleanup()
|
|
1966
|
+
raise
|
|
1967
|
+
finally:
|
|
1968
|
+
self.stop()
|
|
1969
|
+
|
|
1970
|
+
def _animation_loop(self) -> None:
|
|
1971
|
+
"""The internal thread target that runs the animation loop."""
|
|
1972
|
+
self._frame_index = 0
|
|
1973
|
+
while self._stop_event and not self._stop_event.is_set():
|
|
1974
|
+
try:
|
|
1975
|
+
if not self.active or not self._original_stdout:
|
|
1976
|
+
break
|
|
1977
|
+
|
|
1978
|
+
self._flush_buffer()
|
|
1979
|
+
|
|
1980
|
+
frame = FormatCodes.to_ansi(f"{self.frames[self._frame_index % len(self.frames)]}[*]")
|
|
1981
|
+
formatted = FormatCodes.to_ansi(self.sep.join(
|
|
1982
|
+
s for s in ( \
|
|
1983
|
+
_PATTERNS.animation.sub(frame, _PATTERNS.label.sub(self.label or "", s))
|
|
1984
|
+
for s in self.spinner_format
|
|
1985
|
+
) if s
|
|
1986
|
+
))
|
|
1987
|
+
|
|
1988
|
+
self._current_animation_str = formatted
|
|
1989
|
+
self._last_line_len = len(formatted)
|
|
1990
|
+
self._redraw_display()
|
|
1991
|
+
self._frame_index += 1
|
|
1992
|
+
|
|
1993
|
+
except Exception:
|
|
1994
|
+
self._emergency_cleanup()
|
|
1995
|
+
break
|
|
1996
|
+
|
|
1997
|
+
if self._stop_event:
|
|
1998
|
+
self._stop_event.wait(self.interval)
|
|
1999
|
+
|
|
2000
|
+
def _start_intercepting(self) -> None:
|
|
2001
|
+
self.active = True
|
|
2002
|
+
self._original_stdout = _sys.stdout
|
|
2003
|
+
_sys.stdout = _InterceptedOutput(self)
|
|
2004
|
+
|
|
2005
|
+
def _stop_intercepting(self) -> None:
|
|
2006
|
+
if self._original_stdout:
|
|
2007
|
+
_sys.stdout = self._original_stdout
|
|
2008
|
+
self._original_stdout = None
|
|
2009
|
+
self.active = False
|
|
2010
|
+
self._buffer.clear()
|
|
2011
|
+
self._last_line_len = 0
|
|
2012
|
+
self._current_animation_str = ""
|
|
2013
|
+
|
|
2014
|
+
def _emergency_cleanup(self) -> None:
|
|
2015
|
+
"""Emergency cleanup to restore stdout in case of exceptions."""
|
|
2016
|
+
try:
|
|
2017
|
+
self._stop_intercepting()
|
|
2018
|
+
except Exception:
|
|
2019
|
+
pass
|
|
2020
|
+
|
|
2021
|
+
def _clear_spinner_line(self) -> None:
|
|
2022
|
+
if self._last_line_len > 0 and self._original_stdout:
|
|
2023
|
+
self._original_stdout.write(f"{ANSI.CHAR}[2K\r")
|
|
2024
|
+
self._original_stdout.flush()
|
|
2025
|
+
|
|
2026
|
+
def _flush_buffer(self) -> None:
|
|
2027
|
+
if self._buffer and self._original_stdout:
|
|
2028
|
+
self._clear_spinner_line()
|
|
2029
|
+
for content in self._buffer:
|
|
2030
|
+
self._original_stdout.write(content)
|
|
2031
|
+
self._original_stdout.flush()
|
|
2032
|
+
self._buffer.clear()
|
|
2033
|
+
|
|
2034
|
+
def _redraw_display(self) -> None:
|
|
2035
|
+
if self._current_animation_str and self._original_stdout:
|
|
2036
|
+
self._original_stdout.write(f"{ANSI.CHAR}[2K\r{self._current_animation_str}")
|
|
2037
|
+
self._original_stdout.flush()
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
@mypyc_attr(native_class=False)
|
|
2041
|
+
class _InterceptedOutput:
|
|
2042
|
+
"""Custom StringIO that captures output and stores it in the progress bar buffer."""
|
|
2043
|
+
|
|
2044
|
+
def __init__(self, progress_bar: ProgressBar | Spinner):
|
|
2045
|
+
self.progress_bar = progress_bar
|
|
2046
|
+
self.string_io = StringIO()
|
|
2047
|
+
|
|
2048
|
+
def write(self, content: str) -> int:
|
|
2049
|
+
self.string_io.write(content)
|
|
2050
|
+
try:
|
|
2051
|
+
if content and content != "\r":
|
|
2052
|
+
self.progress_bar._buffer.append(content)
|
|
2053
|
+
return len(content)
|
|
2054
|
+
except Exception:
|
|
2055
|
+
self.progress_bar._emergency_cleanup()
|
|
2056
|
+
raise
|
|
2057
|
+
|
|
2058
|
+
def flush(self) -> None:
|
|
2059
|
+
self.string_io.flush()
|
|
2060
|
+
try:
|
|
2061
|
+
if self.progress_bar.active and self.progress_bar._buffer:
|
|
2062
|
+
self.progress_bar._flush_buffer()
|
|
2063
|
+
self.progress_bar._redraw_display()
|
|
2064
|
+
except Exception:
|
|
2065
|
+
self.progress_bar._emergency_cleanup()
|
|
2066
|
+
raise
|
|
2067
|
+
|
|
2068
|
+
def __getattr__(self, name: str) -> Any:
|
|
2069
|
+
return getattr(self.string_io, name)
|