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.
Files changed (43) hide show
  1. 455848faf89d8974b22a__mypyc.cpython-311-darwin.so +0 -0
  2. xulbux/__init__.cpython-311-darwin.so +0 -0
  3. xulbux/__init__.py +46 -0
  4. xulbux/base/consts.cpython-311-darwin.so +0 -0
  5. xulbux/base/consts.py +172 -0
  6. xulbux/base/decorators.cpython-311-darwin.so +0 -0
  7. xulbux/base/decorators.py +28 -0
  8. xulbux/base/exceptions.cpython-311-darwin.so +0 -0
  9. xulbux/base/exceptions.py +23 -0
  10. xulbux/base/types.cpython-311-darwin.so +0 -0
  11. xulbux/base/types.py +118 -0
  12. xulbux/cli/help.cpython-311-darwin.so +0 -0
  13. xulbux/cli/help.py +77 -0
  14. xulbux/code.cpython-311-darwin.so +0 -0
  15. xulbux/code.py +137 -0
  16. xulbux/color.cpython-311-darwin.so +0 -0
  17. xulbux/color.py +1331 -0
  18. xulbux/console.cpython-311-darwin.so +0 -0
  19. xulbux/console.py +2069 -0
  20. xulbux/data.cpython-311-darwin.so +0 -0
  21. xulbux/data.py +798 -0
  22. xulbux/env_path.cpython-311-darwin.so +0 -0
  23. xulbux/env_path.py +123 -0
  24. xulbux/file.cpython-311-darwin.so +0 -0
  25. xulbux/file.py +74 -0
  26. xulbux/file_sys.cpython-311-darwin.so +0 -0
  27. xulbux/file_sys.py +266 -0
  28. xulbux/format_codes.cpython-311-darwin.so +0 -0
  29. xulbux/format_codes.py +722 -0
  30. xulbux/json.cpython-311-darwin.so +0 -0
  31. xulbux/json.py +200 -0
  32. xulbux/regex.cpython-311-darwin.so +0 -0
  33. xulbux/regex.py +247 -0
  34. xulbux/string.cpython-311-darwin.so +0 -0
  35. xulbux/string.py +161 -0
  36. xulbux/system.cpython-311-darwin.so +0 -0
  37. xulbux/system.py +313 -0
  38. xulbux-1.9.5.dist-info/METADATA +271 -0
  39. xulbux-1.9.5.dist-info/RECORD +43 -0
  40. xulbux-1.9.5.dist-info/WHEEL +6 -0
  41. xulbux-1.9.5.dist-info/entry_points.txt +2 -0
  42. xulbux-1.9.5.dist-info/licenses/LICENSE +21 -0
  43. 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)