xulbux 1.8.1__py3-none-any.whl → 1.8.3__py3-none-any.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.

Potentially problematic release.


This version of xulbux might be problematic. Click here for more details.

xulbux/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.8.1"
1
+ __version__ = "1.8.3"
2
2
 
3
3
  __author__ = "XulbuX"
4
4
  __email__ = "xulbux.real@gmail.com"
@@ -7,11 +7,26 @@ __copyright__ = "Copyright (c) 2024 XulbuX"
7
7
  __url__ = "https://github.com/XulbuX/PythonLibraryXulbuX"
8
8
  __description__ = "A Python library which includes lots of helpful classes, types, and functions aiming to make common programming tasks simpler."
9
9
 
10
- __all__ = ["Code", "Color", "Console", "Data", "EnvPath", "File", "FormatCodes", "Json", "Path", "Regex", "String", "System"]
10
+ __all__ = [
11
+ "Code",
12
+ "Color",
13
+ "Console",
14
+ "Data",
15
+ "EnvPath",
16
+ "File",
17
+ "FormatCodes",
18
+ "Json",
19
+ "Path",
20
+ "ProgressBar",
21
+ "Regex",
22
+ "String",
23
+ "System",
24
+ ]
11
25
 
12
26
  from .code import Code
13
27
  from .color import Color
14
28
  from .console import Console
29
+ from .console import ProgressBar
15
30
  from .data import Data
16
31
  from .env_path import EnvPath
17
32
  from .file import File
xulbux/cli/help.py CHANGED
@@ -3,22 +3,50 @@ from ..base.consts import COLOR
3
3
  from ..format_codes import FormatCodes
4
4
  from ..console import Console
5
5
 
6
+ from urllib.error import HTTPError
7
+ from typing import Optional
8
+ import urllib.request as _request
9
+ import json as _json
6
10
 
11
+
12
+ def get_latest_version() -> Optional[str]:
13
+ with _request.urlopen(URL) as response:
14
+ if response.status == 200:
15
+ data = _json.load(response)
16
+ return data["info"]["version"]
17
+ else:
18
+ raise HTTPError(URL, response.status, "Failed to fetch latest version info", response.headers, None)
19
+
20
+
21
+ def is_latest_version() -> Optional[bool]:
22
+ try:
23
+ if (latest := get_latest_version()) in ("", None):
24
+ return None
25
+ latest_v_parts = tuple(int(part) for part in latest.lower().lstrip("v").split('.'))
26
+ installed_v_parts = tuple(int(part) for part in __version__.lower().lstrip("v").split('.'))
27
+ return latest_v_parts <= installed_v_parts
28
+ except Exception:
29
+ return None
30
+
31
+
32
+ URL = "https://pypi.org/pypi/xulbux/json"
33
+ IS_LATEST_VERSION = is_latest_version()
7
34
  CLR = {
8
35
  "class": COLOR.TANGERINE,
36
+ "code_border": COLOR.GRAY,
9
37
  "const": COLOR.RED,
10
38
  "func": COLOR.CYAN,
11
39
  "import": COLOR.NEON_GREEN,
12
40
  "lib": COLOR.ORANGE,
41
+ "notice": COLOR.YELLOW,
13
42
  "punctuators": COLOR.DARK_GRAY,
14
- "code_border": COLOR.GRAY,
15
43
  }
16
44
  HELP = FormatCodes.to_ansi(
17
45
  rf""" [_|b|#7075FF] __ __
18
46
  [b|#7075FF] _ __ __ __/ / / /_ __ ___ __
19
47
  [b|#7075FF] | |/ // / / / / / __ \/ / / | |/ /
20
48
  [b|#7075FF] > , </ /_/ / /_/ /_/ / /_/ /> , <
21
- [b|#7075FF]/_/|_|\____/\__/\____/\____//_/|_| [*|BG:{COLOR.GRAY}|#000] v[b]{__version__} [*]
49
+ [b|#7075FF]/_/|_|\____/\__/\____/\____//_/|_| [*|BG:{COLOR.GRAY}|#000] v[b]{__version__} [*|dim|{CLR['notice']}]({'' if IS_LATEST_VERSION else ' (newer available)'})[*]
22
50
 
23
51
  [i|{COLOR.CORAL}]A TON OF COOL FUNCTIONS, YOU NEED![*]
24
52
 
xulbux/color.py CHANGED
@@ -92,9 +92,13 @@ class rgba:
92
92
 
93
93
  def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, _validate: bool = True):
94
94
  self.r: int
95
+ """The red channel (`0`–`255`)"""
95
96
  self.g: int
97
+ """The green channel (`0`–`255`)"""
96
98
  self.b: int
99
+ """The blue channel (`0`–`255`)"""
97
100
  self.a: Optional[float]
101
+ """The alpha channel (`0.0`–`1.0`) or `None` if not set"""
98
102
  if not _validate:
99
103
  self.r, self.g, self.b, self.a = r, g, b, a
100
104
  return
@@ -291,9 +295,13 @@ class hsla:
291
295
 
292
296
  def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, _validate: bool = True):
293
297
  self.h: int
298
+ """The hue channel (`0`–`360`)"""
294
299
  self.s: int
300
+ """The saturation channel (`0`–`100`)"""
295
301
  self.l: int
302
+ """The lightness channel (`0`–`100`)"""
296
303
  self.a: Optional[float]
304
+ """The alpha channel (`0.0`–`1.0`) or `None` if not set"""
297
305
  if not _validate:
298
306
  self.h, self.s, self.l, self.a = h, s, l, a
299
307
  return
@@ -496,9 +504,13 @@ class hexa:
496
504
  _a: Optional[float] = None,
497
505
  ):
498
506
  self.r: int
507
+ """The red channel (`0`–`255`)"""
499
508
  self.g: int
509
+ """The green channel (`0`–`255`)"""
500
510
  self.b: int
511
+ """The blue channel (`0`–`255`)"""
501
512
  self.a: Optional[float]
513
+ """The alpha channel (`0.0`–`1.0`) or `None` if not set"""
502
514
  if all(x is not None for x in (_r, _g, _b)):
503
515
  self.r, self.g, self.b, self.a = cast(int, _r), cast(int, _g), cast(int, _b), _a
504
516
  return
xulbux/console.py CHANGED
@@ -5,15 +5,16 @@ You can also use special formatting codes directly inside the log message to cha
5
5
  For more detailed information about formatting codes, see the the `format_codes` module documentation.
6
6
  """
7
7
 
8
- from .base.consts import COLOR, CHARS
9
- from .format_codes import FormatCodes, _COMPILED
8
+ from .base.consts import COLOR, CHARS, ANSI
9
+ from .format_codes import FormatCodes, _COMPILED as _FC_COMPILED
10
10
  from .string import String
11
11
  from .color import Color, Rgba, Hexa
12
12
 
13
- from typing import Callable, Optional, Literal, Mapping, Any, cast
13
+ from typing import Generator, TypedDict, Callable, Optional, Literal, Mapping, Pattern, TypeVar, TextIO, Any, overload, cast, Protocol
14
14
  from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings
15
15
  from prompt_toolkit.validation import ValidationError, Validator
16
16
  from prompt_toolkit.styles import Style
17
+ from contextlib import contextmanager
17
18
  from prompt_toolkit.keys import Keys
18
19
  import prompt_toolkit as _pt
19
20
  import keyboard as _keyboard
@@ -21,6 +22,21 @@ import getpass as _getpass
21
22
  import shutil as _shutil
22
23
  import sys as _sys
23
24
  import os as _os
25
+ import re as _re
26
+ import io as _io
27
+
28
+
29
+ _COMPILED: dict[str, Pattern] = { # PRECOMPILE REGULAR EXPRESSIONS
30
+ "hr": _re.compile(r"(?i)\{hr\}"),
31
+ "hr_no_nl": _re.compile(r"(?i)(?<!\n){hr}(?!\n)"),
32
+ "hr_r_nl": _re.compile(r"(?i)(?<!\n){hr}(?=\n)"),
33
+ "hr_l_nl": _re.compile(r"(?i)(?<=\n){hr}(?!\n)"),
34
+ "label": _re.compile(r"(?i)\{(?:label|l)\}"),
35
+ "bar": _re.compile(r"(?i)\{(?:bar|b)\}"),
36
+ "current": _re.compile(r"(?i)\{(?:current|c)\}"),
37
+ "total": _re.compile(r"(?i)\{(?:total|t)\}"),
38
+ "percentage": _re.compile(r"(?i)\{(?:percentage|percent|p)\}"),
39
+ }
24
40
 
25
41
 
26
42
  class _ConsoleWidth:
@@ -57,6 +73,11 @@ class _ConsoleUser:
57
73
  return _os.getenv("USER") or _os.getenv("USERNAME") or _getpass.getuser()
58
74
 
59
75
 
76
+ class _ArgConfigWithDefault(TypedDict):
77
+ flags: list[str] | tuple[str, ...]
78
+ default: Any
79
+
80
+
60
81
  class ArgResult:
61
82
  """Represents the result of a parsed command-line argument and contains the following attributes:
62
83
  - `exists` -⠀if the argument was found or not
@@ -66,7 +87,9 @@ class ArgResult:
66
87
 
67
88
  def __init__(self, exists: bool, value: Any | list[Any]):
68
89
  self.exists: bool = exists
90
+ """Whether the argument was found or not."""
69
91
  self.value: Any = value
92
+ """The value given with the found argument."""
70
93
 
71
94
  def __bool__(self):
72
95
  return self.exists
@@ -132,8 +155,10 @@ class Console:
132
155
 
133
156
  @staticmethod
134
157
  def get_args(
135
- find_args: Mapping[str, list[str] | tuple[str, ...] | dict[str, list[str] | tuple[str, ...] | Any]
136
- | Literal["before", "after"]],
158
+ find_args: Mapping[
159
+ str,
160
+ list[str] | tuple[str, ...] | _ArgConfigWithDefault | Literal["before", "after"],
161
+ ],
137
162
  allow_spaces: bool = False
138
163
  ) -> Args:
139
164
  """Will search for the specified arguments in the command line
@@ -227,8 +252,11 @@ class Console:
227
252
  elif isinstance(config, dict):
228
253
  if "flags" not in config:
229
254
  raise ValueError(f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'flags' key.")
230
- flags = config["flags"]
231
- default_value = config.get("default")
255
+ if "default" not in config:
256
+ raise ValueError(
257
+ f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'default' key. Use a simple list/tuple if no default value is needed."
258
+ )
259
+ flags, default_value = config["flags"], config["default"]
232
260
  if not isinstance(flags, (list, tuple)):
233
261
  raise ValueError(f"Invalid 'flags' for alias '{alias}'. Must be a list or tuple.")
234
262
  results[alias] = {"exists": False, "value": default_value}
@@ -255,14 +283,14 @@ class Console:
255
283
  if first_flag_pos is None:
256
284
  first_flag_pos = i
257
285
  # CHECK IF THIS FLAG HAS A VALUE FOLLOWING IT
258
- flag_has_value = (i + 1 < args_len and not args[i + 1].startswith("-") and args[i + 1] not in arg_lookup)
286
+ flag_has_value = (i + 1 < args_len and args[i + 1] not in arg_lookup)
259
287
  if flag_has_value:
260
288
  if not allow_spaces:
261
289
  last_flag_with_value_pos = i + 1
262
290
  else:
263
291
  # FIND THE END OF THE MULTI-WORD VALUE
264
292
  j = i + 1
265
- while j < args_len and not args[j].startswith("-") and args[j] not in arg_lookup:
293
+ while j < args_len and args[j] not in arg_lookup:
266
294
  j += 1
267
295
  last_flag_with_value_pos = j - 1
268
296
 
@@ -272,7 +300,7 @@ class Console:
272
300
  before_args = []
273
301
  end_pos = first_flag_pos if first_flag_pos is not None else args_len
274
302
  for i in range(end_pos):
275
- if not args[i].startswith("-"):
303
+ if args[i] not in arg_lookup:
276
304
  before_args.append(String.to_type(args[i]))
277
305
  if before_args:
278
306
  results[alias]["value"] = before_args
@@ -286,7 +314,7 @@ class Console:
286
314
  if alias:
287
315
  results[alias]["exists"] = True
288
316
  value_found_after_flag = False
289
- if i + 1 < args_len and not args[i + 1].startswith("-"):
317
+ if i + 1 < args_len and args[i + 1] not in arg_lookup:
290
318
  if not allow_spaces:
291
319
  results[alias]["value"] = String.to_type(args[i + 1])
292
320
  i += 1
@@ -294,7 +322,7 @@ class Console:
294
322
  else:
295
323
  value_parts = []
296
324
  j = i + 1
297
- while j < args_len and not args[j].startswith("-"):
325
+ while j < args_len and args[j] not in arg_lookup:
298
326
  value_parts.append(args[j])
299
327
  j += 1
300
328
  if value_parts:
@@ -302,7 +330,7 @@ class Console:
302
330
  i = j - 1
303
331
  value_found_after_flag = True
304
332
  if not value_found_after_flag:
305
- results[alias]["value"] = True
333
+ results[alias]["value"] = None
306
334
  i += 1
307
335
 
308
336
  # COLLECT "after" POSITIONAL ARGUMENTS
@@ -321,7 +349,7 @@ class Console:
321
349
  start_pos = last_flag_pos + 1
322
350
 
323
351
  for i in range(start_pos, args_len):
324
- if not args[i].startswith("-") and args[i] not in arg_lookup:
352
+ if args[i] not in arg_lookup:
325
353
  after_args.append(String.to_type(args[i]))
326
354
 
327
355
  if after_args:
@@ -332,9 +360,9 @@ class Console:
332
360
 
333
361
  @staticmethod
334
362
  def pause_exit(
363
+ prompt: object = "",
335
364
  pause: bool = True,
336
365
  exit: bool = False,
337
- prompt: object = "",
338
366
  exit_code: int = 0,
339
367
  reset_ansi: bool = False,
340
368
  ) -> None:
@@ -459,13 +487,15 @@ class Console:
459
487
  default_color: Optional[Rgba | Hexa] = None,
460
488
  pause: bool = False,
461
489
  exit: bool = False,
490
+ exit_code: int = 0,
491
+ reset_ansi: bool = True,
462
492
  ) -> None:
463
493
  """A preset for `log()`: `DEBUG` log message with the options to pause
464
494
  at the message and exit the program after the message was printed.
465
495
  If `active` is false, no debug message will be printed."""
466
496
  if active:
467
497
  Console.log("DEBUG", prompt, format_linebreaks, start, end, COLOR.YELLOW, default_color)
468
- Console.pause_exit(pause, exit)
498
+ Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
469
499
 
470
500
  @staticmethod
471
501
  def info(
@@ -476,11 +506,13 @@ class Console:
476
506
  default_color: Optional[Rgba | Hexa] = None,
477
507
  pause: bool = False,
478
508
  exit: bool = False,
509
+ exit_code: int = 0,
510
+ reset_ansi: bool = True,
479
511
  ) -> None:
480
512
  """A preset for `log()`: `INFO` log message with the options to pause
481
513
  at the message and exit the program after the message was printed."""
482
514
  Console.log("INFO", prompt, format_linebreaks, start, end, COLOR.BLUE, default_color)
483
- Console.pause_exit(pause, exit)
515
+ Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
484
516
 
485
517
  @staticmethod
486
518
  def done(
@@ -491,11 +523,13 @@ class Console:
491
523
  default_color: Optional[Rgba | Hexa] = None,
492
524
  pause: bool = False,
493
525
  exit: bool = False,
526
+ exit_code: int = 0,
527
+ reset_ansi: bool = True,
494
528
  ) -> None:
495
529
  """A preset for `log()`: `DONE` log message with the options to pause
496
530
  at the message and exit the program after the message was printed."""
497
531
  Console.log("DONE", prompt, format_linebreaks, start, end, COLOR.TEAL, default_color)
498
- Console.pause_exit(pause, exit)
532
+ Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
499
533
 
500
534
  @staticmethod
501
535
  def warn(
@@ -506,11 +540,13 @@ class Console:
506
540
  default_color: Optional[Rgba | Hexa] = None,
507
541
  pause: bool = False,
508
542
  exit: bool = False,
543
+ exit_code: int = 1,
544
+ reset_ansi: bool = True,
509
545
  ) -> None:
510
546
  """A preset for `log()`: `WARN` log message with the options to pause
511
547
  at the message and exit the program after the message was printed."""
512
548
  Console.log("WARN", prompt, format_linebreaks, start, end, COLOR.ORANGE, default_color)
513
- Console.pause_exit(pause, exit)
549
+ Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
514
550
 
515
551
  @staticmethod
516
552
  def fail(
@@ -521,12 +557,13 @@ class Console:
521
557
  default_color: Optional[Rgba | Hexa] = None,
522
558
  pause: bool = False,
523
559
  exit: bool = True,
560
+ exit_code: int = 1,
524
561
  reset_ansi: bool = True,
525
562
  ) -> None:
526
563
  """A preset for `log()`: `FAIL` log message with the options to pause
527
564
  at the message and exit the program after the message was printed."""
528
565
  Console.log("FAIL", prompt, format_linebreaks, start, end, COLOR.RED, default_color)
529
- Console.pause_exit(pause, exit, reset_ansi=reset_ansi)
566
+ Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
530
567
 
531
568
  @staticmethod
532
569
  def exit(
@@ -537,12 +574,13 @@ class Console:
537
574
  default_color: Optional[Rgba | Hexa] = None,
538
575
  pause: bool = False,
539
576
  exit: bool = True,
577
+ exit_code: int = 0,
540
578
  reset_ansi: bool = True,
541
579
  ) -> None:
542
580
  """A preset for `log()`: `EXIT` log message with the options to pause
543
581
  at the message and exit the program after the message was printed."""
544
582
  Console.log("EXIT", prompt, format_linebreaks, start, end, COLOR.MAGENTA, default_color)
545
- Console.pause_exit(pause, exit, reset_ansi=reset_ansi)
583
+ Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
546
584
 
547
585
  @staticmethod
548
586
  def log_box_filled(
@@ -574,7 +612,7 @@ class Console:
574
612
  spaces_l = " " * indent
575
613
  lines = [
576
614
  f"{spaces_l}[bg:{box_bg_color}]{' ' * w_padding}"
577
- + _COMPILED["formatting"].sub(lambda m: f"{m.group(0)}[bg:{box_bg_color}]", line) +
615
+ + _FC_COMPILED["formatting"].sub(lambda m: f"{m.group(0)}[bg:{box_bg_color}]", line) +
578
616
  (" " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full)) + "[*]" for line, unfmt in zip(lines, unfmt_lines)
579
617
  ]
580
618
  pady = " " * (Console.w if w_full else max_line_len + (2 * w_padding))
@@ -597,7 +635,7 @@ class Console:
597
635
  w_padding: int = 1,
598
636
  w_full: bool = False,
599
637
  indent: int = 0,
600
- _border_chars: Optional[tuple[str, str, str, str, str, str, str, str]] = None,
638
+ _border_chars: Optional[tuple[str, str, str, str, str, str, str, str, str, str, str]] = None,
601
639
  ) -> None:
602
640
  """Will print a bordered box, containing a formatted log message:
603
641
  - `*values` -⠀the box content (each value is on a new line)
@@ -610,15 +648,17 @@ class Console:
610
648
  - `w_full` -⠀whether to make the box be the full console width or not
611
649
  - `indent` -⠀the indentation of the box (in chars)
612
650
  - `_border_chars` -⠀define your own border characters set (overwrites `border_type`)\n
613
- ---------------------------------------------------------------------------------------
651
+ ---------------------------------------------------------------------------------------------
652
+ You can insert horizontal rules to split the box content by using `{hr}` in the `*values`.\n
653
+ ---------------------------------------------------------------------------------------------
614
654
  The box content can be formatted with special formatting codes. For more detailed
615
655
  information about formatting codes, see `format_codes` module documentation.\n
616
- ---------------------------------------------------------------------------------------
656
+ ---------------------------------------------------------------------------------------------
617
657
  The `border_type` can be one of the following:
618
- - `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│')`
619
- - `"rounded" = ('╭', '─', '╮', '│', '╯', '─', '╰', '│')`
620
- - `"strong" = ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃')`
621
- - `"double" = ('╔', '═', '╗', '║', '╝', '═', '╚', '║')`\n
658
+ - `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│', '├', '─', '┤')`
659
+ - `"rounded" = ('╭', '─', '╮', '│', '╯', '─', '╰', '│', '├', '─', '┤')`
660
+ - `"strong" = ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃', '┣', '━', '┫')`
661
+ - `"double" = ('╔', '═', '╗', '║', '╝', '═', '╚', '║', '╠', '═', '╣')`\n
622
662
  The order of the characters is always:
623
663
  1. top-left corner
624
664
  2. top border
@@ -627,27 +667,31 @@ class Console:
627
667
  5. bottom-right corner
628
668
  6. bottom border
629
669
  7. bottom-left corner
630
- 8. left border"""
670
+ 8. left border
671
+ 9. left horizontal rule connector
672
+ 10. horizontal rule
673
+ 11. right horizontal rule connector"""
631
674
  borders = {
632
- "standard": ('┌', '─', '┐', '│', '┘', '─', '└', '│'),
633
- "rounded": ('╭', '─', '╮', '│', '╯', '─', '╰', '│'),
634
- "strong": ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃'),
635
- "double": ('╔', '═', '╗', '║', '╝', '═', '╚', '║'),
675
+ "standard": ('┌', '─', '┐', '│', '┘', '─', '└', '│', '├', '─', '┤'),
676
+ "rounded": ('╭', '─', '╮', '│', '╯', '─', '╰', '│', '├', '─', '┤'),
677
+ "strong": ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃', '┣', '━', '┫'),
678
+ "double": ('╔', '═', '╗', '║', '╝', '═', '╚', '║', '╠', '═', '╣'),
636
679
  }
637
680
  border_chars = borders.get(border_type, borders["standard"]) if _border_chars is None else _border_chars
638
- lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color)
681
+ lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color, has_rules=True)
639
682
  pad_w_full = (Console.w - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0
640
683
  if border_style is not None and Color.is_valid(border_style):
641
684
  border_style = Color.to_hexa(border_style)
642
685
  spaces_l = " " * indent
643
686
  border_l = f"[{border_style}]{border_chars[7]}[*]"
644
687
  border_r = f"[{border_style}]{border_chars[3]}[_]"
688
+ border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]"
689
+ border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (Console.w - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]"
690
+ h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{border_chars[9] * (Console.w - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]"
645
691
  lines = [
646
- f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]" + " " *
692
+ h_rule if _COMPILED["hr"].match(line) else f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]" + " " *
647
693
  ((w_padding + max_line_len - len(unfmt)) + pad_w_full) + border_r for line, unfmt in zip(lines, unfmt_lines)
648
694
  ]
649
- border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]"
650
- border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]"
651
695
  FormatCodes.print(
652
696
  f"{start}{border_t}[_]\n" + "\n".join(lines) + f"\n{border_b}[_]",
653
697
  default_color=default_color,
@@ -659,9 +703,42 @@ class Console:
659
703
  def __prepare_log_box(
660
704
  values: tuple[object, ...],
661
705
  default_color: Optional[Rgba | Hexa] = None,
706
+ has_rules: bool = False,
662
707
  ) -> tuple[list[str], list[tuple[str, tuple[tuple[int, str], ...]]], int]:
663
708
  """Prepares the log box content and returns it along with the max line length."""
664
- lines = [line for val in values for line in str(val).splitlines()]
709
+ if has_rules:
710
+ lines = []
711
+ for val in values:
712
+ val_str, result_parts, current_pos = str(val), [], 0
713
+ for match in _COMPILED["hr"].finditer(val_str):
714
+ start, end = match.span()
715
+ should_split_before = start > 0 and val_str[start - 1] != '\n'
716
+ should_split_after = end < len(val_str) and val_str[end] != '\n'
717
+
718
+ if should_split_before:
719
+ if start > current_pos:
720
+ result_parts.append(val_str[current_pos:start])
721
+ if should_split_after:
722
+ result_parts.append(match.group())
723
+ current_pos = end
724
+ else:
725
+ current_pos = start
726
+ else:
727
+ if should_split_after:
728
+ result_parts.append(val_str[current_pos:end])
729
+ current_pos = end
730
+
731
+ if current_pos < len(val_str):
732
+ result_parts.append(val_str[current_pos:])
733
+
734
+ if not result_parts:
735
+ result_parts.append(val_str)
736
+
737
+ for part in result_parts:
738
+ lines.extend(part.splitlines())
739
+ else:
740
+ lines = [line for val in values for line in str(val).splitlines()]
741
+
665
742
  unfmt_lines = [FormatCodes.remove_formatting(line, default_color) for line in lines]
666
743
  max_line_len = max(len(line) for line in unfmt_lines)
667
744
  return lines, cast(list[tuple[str, tuple[tuple[int, str], ...]]], unfmt_lines), max_line_len
@@ -729,6 +806,8 @@ class Console:
729
806
  FormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end)
730
807
  return input_string
731
808
 
809
+ T = TypeVar("T")
810
+
732
811
  @staticmethod
733
812
  def input(
734
813
  prompt: object = "",
@@ -739,10 +818,12 @@ class Console:
739
818
  mask_char: Optional[str] = None,
740
819
  min_len: Optional[int] = None,
741
820
  max_len: Optional[int] = None,
742
- allowed_chars: str = CHARS.ALL, #type: ignore[assignment]
821
+ allowed_chars: str = CHARS.ALL, # type: ignore[assignment]
743
822
  allow_paste: bool = True,
744
823
  validator: Optional[Callable[[str], Optional[str]]] = None,
745
- ) -> str:
824
+ default_val: Optional[T] = None,
825
+ output_type: type[T] = str, # type: ignore[assignment]
826
+ ) -> T:
746
827
  """Acts like a standard Python `input()` a bunch of cool extra features.\n
747
828
  ------------------------------------------------------------------------------------
748
829
  - `prompt` -⠀the input prompt
@@ -758,12 +839,15 @@ class Console:
758
839
  - `allow_paste` -⠀whether to allow pasting text into the input or not
759
840
  - `validator` -⠀a function that takes the input string and returns a string error
760
841
  message if invalid, or nothing if valid
842
+ - `default_val` -⠀the default value to return if the input is empty
843
+ - `output_type` -⠀the type (class) to convert the input to before returning it\n
761
844
  ------------------------------------------------------------------------------------
762
845
  The input prompt can be formatted with special formatting codes. For more detailed
763
846
  information about formatting codes, see the `format_codes` module documentation."""
764
847
  result_text = ""
765
848
  tried_pasting = False
766
849
  filtered_chars = set()
850
+ has_default = default_val is not None
767
851
 
768
852
  class InputValidator(Validator):
769
853
 
@@ -804,7 +888,8 @@ class Console:
804
888
 
805
889
  def process_insert_text(text: str) -> tuple[str, set[str]]:
806
890
  removed_chars = set()
807
- if not text: return "", removed_chars
891
+ if not text:
892
+ return "", removed_chars
808
893
  processed_text = "".join(c for c in text if ord(c) >= 32)
809
894
  if allowed_chars != CHARS.ALL:
810
895
  filtered_text = ""
@@ -825,8 +910,8 @@ class Console:
825
910
  def insert_text_event(event: KeyPressEvent) -> None:
826
911
  nonlocal result_text, filtered_chars
827
912
  try:
828
- insert_text = event.data
829
- if not insert_text: return
913
+ if not (insert_text := event.data):
914
+ return
830
915
  buffer = event.app.current_buffer
831
916
  cursor_pos = buffer.cursor_position
832
917
  insert_text, filtered_chars = process_insert_text(insert_text)
@@ -905,4 +990,349 @@ class Console:
905
990
  FormatCodes.print(start, end="")
906
991
  session.prompt()
907
992
  FormatCodes.print(end, end="")
908
- return result_text
993
+
994
+ if result_text in ("", None):
995
+ if has_default:
996
+ return default_val
997
+ result_text = ""
998
+
999
+ if output_type == str:
1000
+ return result_text # type: ignore[return-value]
1001
+ else:
1002
+ try:
1003
+ return output_type(result_text) # type: ignore[call-arg]
1004
+ except (ValueError, TypeError):
1005
+ if has_default:
1006
+ return default_val
1007
+ raise
1008
+
1009
+
1010
+ class _ProgressUpdater(Protocol):
1011
+ """Protocol for progress update function with proper type hints."""
1012
+
1013
+ @overload
1014
+ def __call__(self, current: int) -> None:
1015
+ """Update the current progress value."""
1016
+ ...
1017
+
1018
+ @overload
1019
+ def __call__(self, current: int, label: str) -> None:
1020
+ """Update both current progress value and label."""
1021
+ ...
1022
+
1023
+ @overload
1024
+ def __call__(self, *, label: str) -> None:
1025
+ """Update the progress label only (keyword-only)."""
1026
+ ...
1027
+
1028
+
1029
+ class ProgressBar:
1030
+ """A console progress bar with smooth transitions and customizable appearance.\n
1031
+ -------------------------------------------------------------------------------------------------
1032
+ - `min_width` -⠀the min width of the progress bar in chars
1033
+ - `max_width` -⠀the max width of the progress bar in chars
1034
+ - `bar_format` -⠀the format string used to render the progress bar, containing placeholders:
1035
+ * `{label}` `{l}`
1036
+ * `{bar}` `{b}`
1037
+ * `{current}` `{c}`
1038
+ * `{total}` `{t}`
1039
+ * `{percentage}` `{percent}` `{p}`
1040
+ - `limited_bar_format` -⠀a simplified format string used when the console width is too small
1041
+ - `chars` -⠀a tuple of characters ordered from full to empty progress<br>
1042
+ The first character represents completely filled sections, intermediate
1043
+ characters create smooth transitions, and the last character represents
1044
+ empty sections. Default is a set of Unicode block characters.
1045
+ --------------------------------------------------------------------------------------------------
1046
+ The bar format (also limited) can additionally be formatted with special formatting codes. For
1047
+ more detailed information about formatting codes, see the `format_codes` module documentation."""
1048
+
1049
+ def __init__(
1050
+ self,
1051
+ min_width: int = 10,
1052
+ max_width: int = 50,
1053
+ bar_format: str = "{l} |{b}| [b]({c})/{t} [dim](([i]({p}%)))",
1054
+ limited_bar_format: str = "|{b}|",
1055
+ chars: tuple[str, ...] = ("█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", " "),
1056
+ ):
1057
+ self.active: bool = False
1058
+ """Whether the progress bar is currently active (intercepting stdout) or not."""
1059
+ self.min_width: int
1060
+ """The min width of the progress bar in chars."""
1061
+ self.max_width: int
1062
+ """The max width of the progress bar in chars."""
1063
+ self.bar_format: str
1064
+ """The format string used to render the progress bar."""
1065
+ self.limited_bar_format: str
1066
+ """The simplified format string used when the console width is too small."""
1067
+ self.chars: tuple[str, ...]
1068
+ """A tuple of characters ordered from full to empty progress."""
1069
+
1070
+ self.set_width(min_width, max_width)
1071
+ self.set_bar_format(bar_format, limited_bar_format)
1072
+ self.set_chars(chars)
1073
+
1074
+ self._buffer: list[str] = []
1075
+ self._original_stdout: Optional[TextIO] = None
1076
+ self._current_progress_str: str = ""
1077
+ self._last_line_len: int = 0
1078
+
1079
+ def set_width(self, min_width: Optional[int] = None, max_width: Optional[int] = None) -> None:
1080
+ """Set the width of the progress bar.\n
1081
+ --------------------------------------------------------------
1082
+ - `min_width` -⠀the min width of the progress bar in chars
1083
+ - `max_width` -⠀the max width of the progress bar in chars"""
1084
+ if min_width is not None:
1085
+ if min_width < 1:
1086
+ raise ValueError("Minimum width must be at least 1.")
1087
+ self.min_width = max(1, min_width)
1088
+ if max_width is not None:
1089
+ if max_width < 1:
1090
+ raise ValueError("Maximum width must be at least 1.")
1091
+ self.max_width = max(self.min_width, max_width)
1092
+
1093
+ def set_bar_format(self, bar_format: Optional[str] = None, limited_bar_format: Optional[str] = None) -> None:
1094
+ """Set the format string used to render the progress bar.\n
1095
+ --------------------------------------------------------------------------------------------------
1096
+ - `bar_format` -⠀the format string used to render the progress bar, containing placeholders:
1097
+ * `{label}` `{l}`
1098
+ * `{bar}` `{b}`
1099
+ * `{current}` `{c}`
1100
+ * `{total}` `{t}`
1101
+ * `{percentage}` `{percent}` `{p}`
1102
+ - `limited_bar_format` -⠀a simplified format string used when the console width is too small
1103
+ --------------------------------------------------------------------------------------------------
1104
+ The bar format (also limited) can additionally be formatted with special formatting codes. For
1105
+ more detailed information about formatting codes, see the `format_codes` module documentation."""
1106
+ if bar_format is not None:
1107
+ if not _COMPILED["bar"].search(bar_format):
1108
+ raise ValueError("'bar_format' must contain the '{bar}' or '{b}' placeholder.")
1109
+ self.bar_format = bar_format
1110
+ if limited_bar_format is not None:
1111
+ if not _COMPILED["bar"].search(limited_bar_format):
1112
+ raise ValueError("'limited_bar_format' must contain the '{bar}' or '{b}' placeholder.")
1113
+ self.limited_bar_format = limited_bar_format
1114
+
1115
+ def set_chars(self, chars: tuple[str, ...]) -> None:
1116
+ """Set the characters used to render the progress bar.\n
1117
+ --------------------------------------------------------------------------
1118
+ - `chars` -⠀a tuple of characters ordered from full to empty progress<br>
1119
+ The first character represents completely filled sections, intermediate
1120
+ characters create smooth transitions, and the last character represents
1121
+ empty sections. If None, uses default Unicode block characters."""
1122
+ if len(chars) < 2:
1123
+ raise ValueError("'chars' must contain at least two characters (full and empty).")
1124
+ if not all(len(c) == 1 for c in chars if isinstance(c, str)):
1125
+ raise ValueError("All 'chars' items must be single-character strings.")
1126
+ self.chars = chars
1127
+
1128
+ def show_progress(self, current: int, total: int, label: Optional[str] = None) -> None:
1129
+ """Show or update the progress bar.\n
1130
+ -------------------------------------------------------------------------------------------
1131
+ - `current` -⠀the current progress value (below `0` or greater than `total` hides the bar)
1132
+ - `total` -⠀the total value representing 100% progress (must be greater than `0`)
1133
+ - `label` -⠀an optional label which is inserted at the `{label}` or `{l}` placeholder"""
1134
+ if total <= 0:
1135
+ raise ValueError("Total must be greater than 0.")
1136
+
1137
+ try:
1138
+ if not self.active:
1139
+ self._start_intercepting()
1140
+ self._flush_buffer()
1141
+ self._draw_progress_bar(current, total, label or "")
1142
+ if current < 0 or current > total:
1143
+ self.hide_progress()
1144
+ except Exception:
1145
+ self._emergency_cleanup()
1146
+ raise
1147
+
1148
+ def hide_progress(self) -> None:
1149
+ """Hide the progress bar and restore normal console output."""
1150
+ if self.active:
1151
+ self._clear_progress_line()
1152
+ self._stop_intercepting()
1153
+
1154
+ @contextmanager
1155
+ def progress_context(self, total: int, label: Optional[str] = None) -> Generator[_ProgressUpdater, None, None]:
1156
+ """Context manager for automatic cleanup. Returns a function to update progress.\n
1157
+ ----------------------------------------------------------------------------------------------------
1158
+ - `total` -⠀the total value representing 100% progress (must be greater than `0`)
1159
+ - `label` -⠀an optional label which is inserted at the `{label}` or `{l}` placeholder
1160
+ ----------------------------------------------------------------------------------------------------
1161
+ The returned callable accepts keyword arguments. At least one of these parameters must be provided:
1162
+ - `current` -⠀update the current progress value
1163
+ - `label` -⠀update the progress label\n
1164
+
1165
+ Example usage:
1166
+ ```python
1167
+ with ProgressBar().progress_context(500, "Loading...") as update_progress:
1168
+ update_progress(0) # Show empty bar at start
1169
+
1170
+ for i in range(400):
1171
+ # Do some work...
1172
+ update_progress(i) # Update progress
1173
+
1174
+ update_progress(label="Finalizing...") # Update label
1175
+
1176
+ for i in range(400, 500):
1177
+ # Do some work...
1178
+ update_progress(i, f"Finalizing ({i})") # Update both
1179
+ ```"""
1180
+ current_progress = 0
1181
+ current_label = label
1182
+
1183
+ try:
1184
+
1185
+ def update_progress(*args, **kwargs) -> None: # TYPE HINTS DEFINED IN '_ProgressUpdater' PROTOCOL
1186
+ """Update the progress bar's current value and/or label."""
1187
+ nonlocal current_progress, current_label
1188
+ current = label = None
1189
+
1190
+ if len(args) > 2:
1191
+ raise TypeError(f"update_progress() takes at most 2 positional arguments ({len(args)} given)")
1192
+ elif len(args) >= 1:
1193
+ current = args[0]
1194
+ if len(args) >= 2:
1195
+ label = args[1]
1196
+
1197
+ if "current" in kwargs:
1198
+ if current is not None:
1199
+ raise TypeError("update_progress() got multiple values for argument 'current'")
1200
+ current = kwargs["current"]
1201
+ if "label" in kwargs:
1202
+ if label is not None:
1203
+ raise TypeError("update_progress() got multiple values for argument 'label'")
1204
+ label = kwargs["label"]
1205
+
1206
+ if unexpected := set(kwargs.keys()) - {"current", "label"}:
1207
+ raise TypeError(f"update_progress() got unexpected keyword argument(s): {', '.join(unexpected)}")
1208
+
1209
+ if current is None and label is None:
1210
+ raise TypeError("At least one of 'current' or 'label' must be provided")
1211
+
1212
+ if current is not None:
1213
+ current_progress = current
1214
+ if label is not None:
1215
+ current_label = label
1216
+
1217
+ self.show_progress(current_progress, total, current_label)
1218
+
1219
+ yield update_progress
1220
+ except Exception:
1221
+ self._emergency_cleanup()
1222
+ raise
1223
+ finally:
1224
+ self.hide_progress()
1225
+
1226
+ def _start_intercepting(self) -> None:
1227
+ self.active = True
1228
+ self._original_stdout = _sys.stdout
1229
+ _sys.stdout = _InterceptedOutput(self)
1230
+
1231
+ def _stop_intercepting(self) -> None:
1232
+ if self._original_stdout:
1233
+ _sys.stdout = self._original_stdout
1234
+ self._original_stdout = None
1235
+ self.active = False
1236
+ self._buffer.clear()
1237
+ self._last_line_len = 0
1238
+ self._current_progress_str = ""
1239
+
1240
+ def _emergency_cleanup(self) -> None:
1241
+ """Emergency cleanup to restore stdout in case of exceptions."""
1242
+ try:
1243
+ self._stop_intercepting()
1244
+ except Exception:
1245
+ pass
1246
+
1247
+ def _flush_buffer(self) -> None:
1248
+ if self._buffer and self._original_stdout:
1249
+ self._clear_progress_line()
1250
+ for content in self._buffer:
1251
+ self._original_stdout.write(content)
1252
+ self._original_stdout.flush()
1253
+ self._buffer.clear()
1254
+
1255
+ def _draw_progress_bar(self, current: int, total: int, label: Optional[str] = None) -> None:
1256
+ if total <= 0 or not self._original_stdout:
1257
+ return
1258
+ percentage = min(100, (current / total) * 100)
1259
+ formatted, bar_width = self._get_formatted_info_and_bar_width(self.bar_format, current, total, percentage, label)
1260
+ if bar_width < self.min_width:
1261
+ formatted, bar_width = self._get_formatted_info_and_bar_width(
1262
+ self.limited_bar_format, current, total, percentage, label
1263
+ )
1264
+ bar = self._create_bar(current, total, max(1, bar_width)) + "[*]"
1265
+ progress_text = _COMPILED["bar"].sub(FormatCodes.to_ansi(bar), formatted)
1266
+ self._current_progress_str = progress_text
1267
+ self._last_line_len = len(progress_text)
1268
+ self._original_stdout.write(f"\r{progress_text}")
1269
+ self._original_stdout.flush()
1270
+
1271
+ def _get_formatted_info_and_bar_width(
1272
+ self,
1273
+ bar_format: str,
1274
+ current: int,
1275
+ total: int,
1276
+ percentage: float,
1277
+ label: Optional[str] = None,
1278
+ ) -> tuple[str, int]:
1279
+ formatted = _COMPILED["label"].sub(label or "", bar_format)
1280
+ formatted = _COMPILED["current"].sub(str(current), formatted)
1281
+ formatted = _COMPILED["total"].sub(str(total), formatted)
1282
+ formatted = _COMPILED["percentage"].sub(f"{percentage:.1f}", formatted)
1283
+ formatted = FormatCodes.to_ansi(formatted)
1284
+ bar_space = Console.w - len(FormatCodes.remove_ansi(_COMPILED["bar"].sub("", formatted)))
1285
+ bar_width = min(bar_space, self.max_width) if bar_space > 0 else 0
1286
+ return formatted, bar_width
1287
+
1288
+ def _create_bar(self, current: int, total: int, bar_width: int) -> str:
1289
+ progress = current / total if total > 0 else 0
1290
+ bar = []
1291
+
1292
+ for i in range(bar_width):
1293
+ pos_progress = (i + 1) / bar_width
1294
+ if progress >= pos_progress:
1295
+ bar.append(self.chars[0])
1296
+ elif progress >= pos_progress - (1 / bar_width):
1297
+ remainder = (progress - (pos_progress - (1 / bar_width))) * bar_width
1298
+ char_idx = len(self.chars) - 1 - min(int(remainder * len(self.chars)), len(self.chars) - 1)
1299
+ bar.append(self.chars[char_idx])
1300
+ else:
1301
+ bar.append(self.chars[-1])
1302
+ return "".join(bar)
1303
+
1304
+ def _clear_progress_line(self) -> None:
1305
+ if self._last_line_len > 0 and self._original_stdout:
1306
+ self._original_stdout.write(f"{ANSI.CHAR}[2K\r")
1307
+ self._original_stdout.flush()
1308
+
1309
+ def _redraw_progress_bar(self) -> None:
1310
+ if self._current_progress_str and self._original_stdout:
1311
+ self._original_stdout.write(f"{self._current_progress_str}")
1312
+ self._original_stdout.flush()
1313
+
1314
+
1315
+ class _InterceptedOutput(_io.StringIO):
1316
+ """Custom StringIO that captures output and stores it in the progress bar buffer."""
1317
+
1318
+ def __init__(self, progress_bar: ProgressBar):
1319
+ super().__init__()
1320
+ self.progress_bar = progress_bar
1321
+
1322
+ def write(self, content: str) -> int:
1323
+ try:
1324
+ if content and content != "\r":
1325
+ self.progress_bar._buffer.append(content)
1326
+ return len(content)
1327
+ except Exception:
1328
+ self.progress_bar._emergency_cleanup()
1329
+ raise
1330
+
1331
+ def flush(self) -> None:
1332
+ try:
1333
+ if self.progress_bar.active and self.progress_bar._buffer:
1334
+ self.progress_bar._flush_buffer()
1335
+ self.progress_bar._redraw_progress_bar()
1336
+ except Exception:
1337
+ self.progress_bar._emergency_cleanup()
1338
+ raise
xulbux/data.py CHANGED
@@ -24,11 +24,14 @@ class Data:
24
24
  except UnicodeDecodeError:
25
25
  pass
26
26
  return {key: _base64.b64encode(data).decode("utf-8"), "encoding": "base64"}
27
- raise TypeError("Unsupported data type")
27
+ raise TypeError(f"Unsupported data type '{type(data)}'")
28
28
 
29
29
  @staticmethod
30
30
  def deserialize_bytes(obj: dict[str, str]) -> bytes | bytearray:
31
- """Converts a JSON-compatible bytes/bytearray format (dictionary) back to its original type."""
31
+ """Tries to converts a JSON-compatible bytes/bytearray format (dictionary) back to its original type.\n
32
+ --------------------------------------------------------------------------------------------------------
33
+ If the serialized object was created with `Data.serialize_bytes()`, it will work.
34
+ If it fails to decode the data, it will raise a `ValueError`."""
32
35
  for key in ("bytes", "bytearray"):
33
36
  if key in obj and "encoding" in obj:
34
37
  if obj["encoding"] == "utf-8":
@@ -36,9 +39,9 @@ class Data:
36
39
  elif obj["encoding"] == "base64":
37
40
  data = _base64.b64decode(obj[key].encode("utf-8"))
38
41
  else:
39
- raise ValueError("Unknown encoding method")
42
+ raise ValueError(f"Unknown encoding method '{obj['encoding']}'")
40
43
  return bytearray(data) if key == "bytearray" else data
41
- raise ValueError("Invalid serialized data")
44
+ raise ValueError(f"Invalid serialized data: {obj}")
42
45
 
43
46
  @staticmethod
44
47
  def chars_count(data: DataStructure) -> int:
@@ -387,7 +390,7 @@ class Data:
387
390
 
388
391
  valid_entries = [(path_id, new_val) for path_id, new_val in update_values.items()]
389
392
  if not valid_entries:
390
- raise ValueError(f"No valid update_values found in dictionary: {update_values}")
393
+ raise ValueError(f"No valid 'update_values' found in dictionary: {update_values}")
391
394
  for path_id, new_val in valid_entries:
392
395
  path = Data.__sep_path_id(path_id)
393
396
  data = update_nested(data, path, new_val)
@@ -581,7 +584,7 @@ class Data:
581
584
  @staticmethod
582
585
  def __sep_path_id(path_id: str) -> list[int]:
583
586
  if path_id.count(">") != 1:
584
- raise ValueError(f"Invalid path ID: {path_id}")
587
+ raise ValueError(f"Invalid path ID '{path_id}'")
585
588
  id_part_len = int(path_id.split(">")[0])
586
589
  path_ids_str = path_id.split(">")[1]
587
590
  return [int(path_ids_str[i:i + id_part_len]) for i in range(0, len(path_ids_str), id_part_len)]
xulbux/format_codes.py CHANGED
@@ -166,6 +166,7 @@ from typing import Optional, cast
166
166
  import ctypes as _ctypes
167
167
  import regex as _rx
168
168
  import sys as _sys
169
+ import os as _os
169
170
  import re as _re
170
171
 
171
172
 
@@ -190,7 +191,7 @@ _COMPILED: dict[str, Pattern] = { # PRECOMPILE REGULAR EXPRESSIONS
190
191
  "ansi_seq": _re.compile(ANSI.CHAR + r"(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"),
191
192
  "formatting": _rx.compile(
192
193
  Regex.brackets("[", "]", is_group=True, ignore_in_strings=False)
193
- + r"(?:\s*([/\\]?)\s*"
194
+ + r"(?:([/\\]?)"
194
195
  + Regex.brackets("(", ")", is_group=True, strip_spaces=False, ignore_in_strings=False)
195
196
  + r")?"
196
197
  ),
@@ -317,6 +318,7 @@ class FormatCodes:
317
318
  ]
318
319
  if auto_reset_txt and not auto_reset_escaped:
319
320
  reset_keys = []
321
+ default_color_resets = ("_bg", "default") if use_default else ("_bg", "_c")
320
322
  for k in format_keys:
321
323
  k_lower = k.lower()
322
324
  k_set = set(k_lower.split(":"))
@@ -324,7 +326,7 @@ class FormatCodes:
324
326
  if k_set & _PREFIX["BR"]:
325
327
  for i in range(len(k)):
326
328
  if is_valid_color(k[i:]):
327
- reset_keys.extend(["_bg", "default"] if use_default else ["_bg", "_c"])
329
+ reset_keys.extend(default_color_resets)
328
330
  break
329
331
  else:
330
332
  for i in range(len(k)):
@@ -334,7 +336,7 @@ class FormatCodes:
334
336
  elif is_valid_color(k) or any(
335
337
  k_lower.startswith(pref_colon := f"{prefix}:") and is_valid_color(k[len(pref_colon):])
336
338
  for prefix in _PREFIX["BR"]):
337
- reset_keys.append("default" if use_default else "_c")
339
+ reset_keys.append(default_color_resets[1])
338
340
  else:
339
341
  reset_keys.append(f"_{k}")
340
342
  ansi_resets = [
@@ -418,11 +420,16 @@ class FormatCodes:
418
420
  global _CONSOLE_ANSI_CONFIGURED
419
421
  if not _CONSOLE_ANSI_CONFIGURED:
420
422
  _sys.stdout.flush()
421
- kernel32 = _ctypes.windll.kernel32
422
- h = kernel32.GetStdHandle(-11)
423
- mode = _ctypes.c_ulong()
424
- kernel32.GetConsoleMode(h, _ctypes.byref(mode))
425
- kernel32.SetConsoleMode(h, mode.value | 0x0004)
423
+ if _os.name == "nt":
424
+ try:
425
+ # ENABLE VT100 MODE ON WINDOWS TO BE ABLE TO USE ANSI CODES
426
+ kernel32 = _ctypes.windll.kernel32
427
+ h = kernel32.GetStdHandle(-11)
428
+ mode = _ctypes.c_ulong()
429
+ kernel32.GetConsoleMode(h, _ctypes.byref(mode))
430
+ kernel32.SetConsoleMode(h, mode.value | 0x0004)
431
+ except Exception:
432
+ pass
426
433
  _CONSOLE_ANSI_CONFIGURED = True
427
434
 
428
435
  @staticmethod
xulbux/json.py CHANGED
@@ -134,7 +134,7 @@ class Json:
134
134
  current[idx] = [] if next_key.isdigit() else {}
135
135
  current = current[idx]
136
136
  else:
137
- raise TypeError(f"Cannot navigate through {type(current).__name__}")
137
+ raise TypeError(f"Cannot navigate through '{type(current).__name__}'")
138
138
  return data_obj
139
139
 
140
140
  update = {}
xulbux/path.py CHANGED
@@ -46,7 +46,7 @@ class Path:
46
46
  raise_error: bool = False,
47
47
  use_closest_match: bool = False,
48
48
  ) -> Optional[str]:
49
- """Tries to locate and extend a relative path to an absolute path.\n
49
+ """Tries to resolve and extend a relative path to an absolute path.\n
50
50
  --------------------------------------------------------------------------------
51
51
  If the `rel_path` couldn't be located in predefined directories, it will be
52
52
  searched in the `search_in` directory/s. If the `rel_path` is still not found,
xulbux/system.py CHANGED
@@ -1,3 +1,6 @@
1
+ from .format_codes import FormatCodes
2
+ from .console import Console
3
+
1
4
  from typing import Optional
2
5
  import subprocess as _subprocess
3
6
  import platform as _platform
@@ -12,7 +15,7 @@ class _IsElevated:
12
15
  def __get__(self, obj, owner=None):
13
16
  try:
14
17
  if _os.name == "nt":
15
- return _ctypes.windll.shell32.IsUserAnAdmin() != 0
18
+ return _ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
16
19
  elif _os.name == "posix":
17
20
  return _os.geteuid() == 0 # type: ignore[attr-defined]
18
21
  except Exception:
@@ -67,11 +70,25 @@ class System:
67
70
  raise NotImplementedError(f"Restart not implemented for `{system}`")
68
71
 
69
72
  @staticmethod
70
- def check_libs(lib_names: list[str], install_missing: bool = False, confirm_install: bool = True) -> Optional[list[str]]:
71
- """Checks if the given list of libraries are installed. If not:
72
- - If `install_missing` is false, the missing libraries will be returned as a list.
73
- - If `install_missing` is true, the missing libraries will be installed.
74
- - If `confirm_install` is true, the user will first be asked if they want to install the missing libraries."""
73
+ def check_libs(
74
+ lib_names: list[str],
75
+ install_missing: bool = False,
76
+ missing_libs_msgs: tuple[str, str] = (
77
+ "The following required libraries are missing:",
78
+ "Do you want to install them now?",
79
+ ),
80
+ confirm_install: bool = True,
81
+ ) -> Optional[list[str]]:
82
+ """Checks if the given list of libraries are installed and optionally installs missing libraries.\n
83
+ ------------------------------------------------------------------------------------------------------------
84
+ - `lib_names` -⠀a list of library names to check
85
+ - `install_missing` -⠀whether to directly missing libraries will be installed automatically using pip
86
+ - `missing_libs_msgs` -⠀two messages: the first one is displayed when missing libraries are found,
87
+ the second one is the confirmation message before installing missing libraries
88
+ - `confirm_install` -⠀whether the user will be asked for confirmation before installing missing libraries\n
89
+ ------------------------------------------------------------------------------------------------------------
90
+ If some libraries are missing or they could not be installed, their names will be returned as a list.
91
+ If all libraries are installed (or were installed successfully), `None` will be returned."""
75
92
  missing = []
76
93
  for lib in lib_names:
77
94
  try:
@@ -83,14 +100,22 @@ class System:
83
100
  elif not install_missing:
84
101
  return missing
85
102
  if confirm_install:
86
- print("The following required libraries are missing:")
103
+ FormatCodes.print(f"[b]({missing_libs_msgs[0]})")
87
104
  for lib in missing:
88
- print(f"- {lib}")
89
- if input("Do you want to install them now (Y/n): ").strip().lower() not in ("", "y", "yes"):
90
- raise ImportError("Missing required libraries.")
105
+ FormatCodes.print(f" [dim](•) [i]{lib}[_i]")
106
+ print()
107
+ if not Console.confirm(missing_libs_msgs[1], end="\n"):
108
+ return missing
91
109
  try:
92
- _subprocess.check_call([_sys.executable, "-m", "pip", "install"] + missing)
93
- return None
110
+ for lib in missing:
111
+ try:
112
+ _subprocess.check_call([_sys.executable, "-m", "pip", "install", lib])
113
+ missing.remove(lib)
114
+ except _subprocess.CalledProcessError:
115
+ pass
116
+ if len(missing) == 0:
117
+ return None
118
+ return missing
94
119
  except _subprocess.CalledProcessError:
95
120
  return missing
96
121
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xulbux
3
- Version: 1.8.1
4
- Summary: A Python library which includes lots of helpful classes, types and functions aiming to make common programming tasks simpler.
3
+ Version: 1.8.3
4
+ Summary: A Python library which includes lots of helpful classes, types, and functions aiming to make common programming tasks simpler.
5
5
  Author-email: XulbuX <xulbux.real@gmail.com>
6
6
  Maintainer-email: XulbuX <xulbux.real@gmail.com>
7
7
  License-Expression: MIT
@@ -0,0 +1,20 @@
1
+ xulbux/__init__.py,sha256=qGusxHHAnHPl-qVU9Rj1_WaOOlNCf_cRbyCUTF0wvBM,935
2
+ xulbux/code.py,sha256=CLA3wh6mTvcXTlQgBeFaaLIBciHvXWqVM56rAtfO-JE,6102
3
+ xulbux/color.py,sha256=tTNH30Wf2QNmyopKz4LADdels_TDzDMCptCtm76KkNo,50375
4
+ xulbux/console.py,sha256=5Z7pHp6hNXSHpN5YVQnGWhw5USZR9AJ7xKjN98ssuLE,63288
5
+ xulbux/data.py,sha256=U9T3lLEzas3A5V8Z0jPKnTy8WPhBmmF2bit8J4VkeB8,31219
6
+ xulbux/env_path.py,sha256=HGOSffdIDubzczsXe6umyqotrzqhIW83QBkISAamXT8,4157
7
+ xulbux/file.py,sha256=7pa0-WS_DpXq7HRB1fLS6Acd9CM-ozXPpNJvMqCW4fw,2624
8
+ xulbux/format_codes.py,sha256=kT1vn8_aaOelzkhy2NYcCJPX_n_LHFRVaXbeDLA1Ir8,25020
9
+ xulbux/json.py,sha256=Lo8vraQ3c-BoKFNYbXCdcJndh8tSsP4CMoYoEKarrmc,7450
10
+ xulbux/path.py,sha256=_Xm4k5aOk2CGZ_ErMbnJPzonF03L8pq8nT8Wy44l8Qo,7674
11
+ xulbux/regex.py,sha256=_BtMHRDNcD9zF4SL87dQuUVZcYGfZx9H5YNSDiEtzm8,8059
12
+ xulbux/string.py,sha256=QaTo0TQ9m_2USNgQNaVw5ivQt-A1E-e5x8OpIB3xIlY,5561
13
+ xulbux/system.py,sha256=CAV-mpTGEJ-0VWVRnTqWqhIsqS1HrIlM3LZOcTz9YBU,7793
14
+ xulbux/base/consts.py,sha256=HwgI_Cr_U2QznezN17SP1j-gpyf3tsbu_KHMd8aKzyw,5918
15
+ xulbux/cli/help.py,sha256=iKSEm1zEmng-BpfEa9xYvnIsTnJbZ_sRvMk4NtavwGY,4403
16
+ xulbux-1.8.3.dist-info/METADATA,sha256=C67KKJKYblIESkTwM3Gyh9jlFtWQ4ToRUaY8wORdhAM,11042
17
+ xulbux-1.8.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
+ xulbux-1.8.3.dist-info/entry_points.txt,sha256=aYh89GfiBOB8vw2VPgC6rhBinhnJoAL1kig-3lq_zkg,58
19
+ xulbux-1.8.3.dist-info/top_level.txt,sha256=FkK4EZajwfP36fnlrPaR98OrEvZpvdEOdW1T5zTj6og,7
20
+ xulbux-1.8.3.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- xulbux/__init__.py,sha256=FkJeare6GgT7xau1OcZVKoLk3uUOULFWdP2DYzOCW58,817
2
- xulbux/code.py,sha256=CLA3wh6mTvcXTlQgBeFaaLIBciHvXWqVM56rAtfO-JE,6102
3
- xulbux/color.py,sha256=XCB05xBSEz6-dpX9yVkMB2zTSdTH01MfUazJEthL_zM,49741
4
- xulbux/console.py,sha256=U9t4Fd_ZGrdhTf8KMKJJqw12aijpur3b9J3rlEgntW4,43164
5
- xulbux/data.py,sha256=hB9JxrSC7_6kelJ_TwOaapNrlig-jiyNZS3YoiJX8F8,30884
6
- xulbux/env_path.py,sha256=HGOSffdIDubzczsXe6umyqotrzqhIW83QBkISAamXT8,4157
7
- xulbux/file.py,sha256=7pa0-WS_DpXq7HRB1fLS6Acd9CM-ozXPpNJvMqCW4fw,2624
8
- xulbux/format_codes.py,sha256=94VyxnUZI2dS1glteNs7izKYrTI_W2pukFGF5f9k72E,24720
9
- xulbux/json.py,sha256=Ei5FdCjfM0FrrAEBmuuTcexl7mUY4eirXr-QPct2OS0,7448
10
- xulbux/path.py,sha256=lLAEVZrW0TAwCewlONFVQcQ_8tVn9LTJZVOZpeGvE5s,7673
11
- xulbux/regex.py,sha256=_BtMHRDNcD9zF4SL87dQuUVZcYGfZx9H5YNSDiEtzm8,8059
12
- xulbux/string.py,sha256=QaTo0TQ9m_2USNgQNaVw5ivQt-A1E-e5x8OpIB3xIlY,5561
13
- xulbux/system.py,sha256=Y5x719Ocwi93VJ6DVE8NR_Js7FQ6QT5wmtjX9FAsw9U,6582
14
- xulbux/base/consts.py,sha256=HwgI_Cr_U2QznezN17SP1j-gpyf3tsbu_KHMd8aKzyw,5918
15
- xulbux/cli/help.py,sha256=QtyAqHEC_0p3qvLXdUBlpvjV9khPy70-po7CQOz1Fag,3314
16
- xulbux-1.8.1.dist-info/METADATA,sha256=Z3S8mdtlmI85R3M3Ks38oUUnx4vzBn91_N3CJabSUIo,11041
17
- xulbux-1.8.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
- xulbux-1.8.1.dist-info/entry_points.txt,sha256=aYh89GfiBOB8vw2VPgC6rhBinhnJoAL1kig-3lq_zkg,58
19
- xulbux-1.8.1.dist-info/top_level.txt,sha256=FkK4EZajwfP36fnlrPaR98OrEvZpvdEOdW1T5zTj6og,7
20
- xulbux-1.8.1.dist-info/RECORD,,
File without changes