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

@@ -2,22 +2,23 @@
2
2
  Functions for logging and other small actions within the console.\n
3
3
  ----------------------------------------------------------------------------------------------------------
4
4
  You can also use special formatting codes directly inside the log message to change their appearance.
5
- For more detailed information about formatting codes, see the the `xx_format_codes` module documentation.
5
+ For more detailed information about formatting codes, see the the `format_codes` module documentation.
6
6
  """
7
7
 
8
- from ._consts_ import COLOR, CHARS
9
- from .xx_format_codes import FormatCodes, _COMPILED
10
- from .xx_string import String
11
- from .xx_color import Color, Rgba, Hexa
12
-
13
- from prompt_toolkit.key_binding.key_bindings import KeyBindings
14
- from typing import Optional, Literal, Mapping, Any, cast
15
- import prompt_toolkit as _prompt_toolkit
16
- import pyperclip as _pyperclip
8
+ from .base.consts import COLOR, CHARS
9
+ from .format_codes import FormatCodes, _COMPILED
10
+ from .string import String
11
+ from .color import Color, Rgba, Hexa
12
+
13
+ from typing import Callable, Optional, Literal, Mapping, Any, cast
14
+ from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings
15
+ from prompt_toolkit.validation import ValidationError, Validator
16
+ from prompt_toolkit.styles import Style
17
+ from prompt_toolkit.keys import Keys
18
+ import prompt_toolkit as _pt
17
19
  import keyboard as _keyboard
18
20
  import getpass as _getpass
19
21
  import shutil as _shutil
20
- import mouse as _mouse
21
22
  import sys as _sys
22
23
  import os as _os
23
24
 
@@ -63,7 +64,7 @@ class ArgResult:
63
64
  --------------------------------------------------------------------------------------------------------
64
65
  When the `ArgResult` instance is accessed as a boolean it will correspond to the `exists` attribute."""
65
66
 
66
- def __init__(self, exists: bool, value: Any):
67
+ def __init__(self, exists: bool, value: Any | list[Any]):
67
68
  self.exists: bool = exists
68
69
  self.value: Any = value
69
70
 
@@ -76,12 +77,11 @@ class Args:
76
77
  For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
77
78
  Each such attribute (e.g. `args.foo`) is an instance of `ArgResult`."""
78
79
 
79
- def __init__(self, **kwargs: dict[str, Any]):
80
+ def __init__(self, **kwargs: dict[str, Any | list[Any]]):
80
81
  for alias_name, data_dict in kwargs.items():
81
82
  if not alias_name.isidentifier():
82
83
  raise TypeError(f"Argument alias '{alias_name}' is invalid. It must be a valid Python variable name.")
83
- arg_result_instance = ArgResult(exists=data_dict["exists"], value=data_dict["value"])
84
- setattr(self, alias_name, arg_result_instance)
84
+ setattr(self, alias_name, ArgResult(exists=cast(bool, data_dict["exists"]), value=data_dict["value"]))
85
85
 
86
86
  def __len__(self):
87
87
  return len(vars(self))
@@ -132,7 +132,8 @@ class Console:
132
132
 
133
133
  @staticmethod
134
134
  def get_args(
135
- find_args: Mapping[str, list[str] | tuple[str, ...] | dict[str, list[str] | tuple[str, ...] | Any]],
135
+ find_args: Mapping[str, list[str] | tuple[str, ...] | dict[str, list[str] | tuple[str, ...] | Any]
136
+ | Literal["before", "after"]],
136
137
  allow_spaces: bool = False
137
138
  ) -> Args:
138
139
  """Will search for the specified arguments in the command line
@@ -150,24 +151,31 @@ class Console:
150
151
  "default": "some_value" # Optional
151
152
  }
152
153
  ```
154
+ 3. Positional argument collection (string value):
155
+ ```python
156
+ "alias_name": "before" # Collects non-flagged args before first flag
157
+ "alias_name": "after" # Collects non-flagged args after last flag
158
+ ```
153
159
  Example `find_args`:
154
160
  ```python
155
161
  find_args={
156
- "arg1": { # With default
162
+ "text": "before", # Positional args before flags
163
+ "arg1": { # With default
157
164
  "flags": ["-a1", "--arg1"],
158
165
  "default": "default_val"
159
166
  },
160
- "arg2": ("-a2", "--arg2"), # Without default (original format)
161
- "arg3": ["-a3"], # Without default (list format)
162
- "arg4": { # Flag with default True
167
+ "arg2": ("-a2", "--arg2"), # Without default (original format)
168
+ "arg3": ["-a3"], # Without default (list format)
169
+ "arg4": { # Flag with default True
163
170
  "flags": ["-f"],
164
171
  "default": True
165
172
  }
166
173
  }
167
174
  ```
168
175
  If the script is called via the command line:\n
169
- `python script.py -a1 "value1" --arg2 -f`\n
176
+ `python script.py Hello World -a1 "value1" --arg2 -f`\n
170
177
  ...it would return an `Args` object where:
178
+ - `args.text.exists` is `True`, `args.text.value` is `["Hello", "World"]`
171
179
  - `args.arg1.exists` is `True`, `args.arg1.value` is `"value1"`
172
180
  - `args.arg2.exists` is `True`, `args.arg2.value` is `True` (flag present without value)
173
181
  - `args.arg3.exists` is `False`, `args.arg3.value` is `None` (not present, no default)
@@ -176,19 +184,46 @@ class Console:
176
184
  - `exists` will be `False`
177
185
  - `value` will be the specified `default` value, or `None` if no default was specified.\n
178
186
  ----------------------------------------------------------------
187
+ For positional arguments:
188
+ - `"before"`: Collects all non-flagged arguments that appear before the first flag
189
+ - `"after"`: Collects all non-flagged arguments that appear after the last flag's value
190
+ ----------------------------------------------------------------
179
191
  Normally if `allow_spaces` is false, it will take a space as
180
192
  the end of an args value. If it is true, it will take spaces as
181
- part of the value until the next arg is found.
193
+ part of the value up until the next arg-flag is found.
182
194
  (Multiple spaces will become one space in the value.)"""
183
195
  args = _sys.argv[1:]
184
196
  args_len = len(args)
185
197
  arg_lookup = {}
186
198
  results = {}
199
+ positional_configs = {}
200
+ before_count = 0
201
+ after_count = 0
202
+
203
+ # PARSE "find_args" CONFIGURATION
187
204
  for alias, config in find_args.items():
188
205
  flags = None
189
206
  default_value = None
190
- if isinstance(config, (list, tuple)):
207
+
208
+ if isinstance(config, str):
209
+ # HANDLE POSITIONAL ARGUMENT COLLECTION
210
+ if config not in ("before", "after"):
211
+ raise ValueError(
212
+ f"Invalid positional argument type '{config}' for alias '{alias}'. Must be 'before' or 'after'."
213
+ )
214
+ if config == "before":
215
+ before_count += 1
216
+ if before_count > 1:
217
+ raise ValueError("Only one alias can have the value 'before' for positional argument collection.")
218
+ elif config == "after":
219
+ after_count += 1
220
+ if after_count > 1:
221
+ raise ValueError("Only one alias can have the value 'after' for positional argument collection.")
222
+ positional_configs[alias] = config
223
+ results[alias] = {"exists": False, "value": []}
224
+ elif isinstance(config, (list, tuple)):
191
225
  flags = config
226
+ results[alias] = {"exists": False, "value": default_value}
192
227
  elif isinstance(config, dict):
193
228
  if "flags" not in config:
194
229
  raise ValueError(f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'flags' key.")
@@ -196,15 +231,54 @@ class Console:
196
231
  default_value = config.get("default")
197
232
  if not isinstance(flags, (list, tuple)):
198
233
  raise ValueError(f"Invalid 'flags' for alias '{alias}'. Must be a list or tuple.")
234
+ results[alias] = {"exists": False, "value": default_value}
199
235
  else:
200
- raise TypeError(f"Invalid configuration type for alias '{alias}'. Must be a list, tuple, or dict.")
201
- results[alias] = {"exists": False, "value": default_value}
202
- for flag in flags:
203
- if flag in arg_lookup:
204
- raise ValueError(
205
- f"Duplicate flag '{flag}' found. It's assigned to both '{arg_lookup[flag]}' and '{alias}'."
206
- )
207
- arg_lookup[flag] = alias
236
+ raise TypeError(
237
+ f"Invalid configuration type for alias '{alias}'. Must be a list, tuple, dict or literal 'before' / 'after'."
238
+ )
239
+
240
+ # BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGUMENTS
241
+ if flags is not None:
242
+ for flag in flags:
243
+ if flag in arg_lookup:
244
+ raise ValueError(
245
+ f"Duplicate flag '{flag}' found. It's assigned to both '{arg_lookup[flag]}' and '{alias}'."
246
+ )
247
+ arg_lookup[flag] = alias
248
+
249
+ # FIND POSITIONS OF FIRST AND LAST FLAGS FOR POSITIONAL ARGUMENT COLLECTION
250
+ first_flag_pos = None
251
+ last_flag_with_value_pos = None
252
+
253
+ for i, arg in enumerate(args):
254
+ if arg in arg_lookup:
255
+ if first_flag_pos is None:
256
+ first_flag_pos = i
257
+ # 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)
259
+ if flag_has_value:
260
+ if not allow_spaces:
261
+ last_flag_with_value_pos = i + 1
262
+ else:
263
+ # FIND THE END OF THE MULTI-WORD VALUE
264
+ j = i + 1
265
+ while j < args_len and not args[j].startswith("-") and args[j] not in arg_lookup:
266
+ j += 1
267
+ last_flag_with_value_pos = j - 1
268
+
269
+ # COLLECT "before" POSITIONAL ARGUMENTS
270
+ for alias, pos_type in positional_configs.items():
271
+ if pos_type == "before":
272
+ before_args = []
273
+ end_pos = first_flag_pos if first_flag_pos is not None else args_len
274
+ for i in range(end_pos):
275
+ if not args[i].startswith("-"):
276
+ before_args.append(String.to_type(args[i]))
277
+ if before_args:
278
+ results[alias]["value"] = before_args
279
+ results[alias]["exists"] = len(before_args) > 0
280
+
281
+ # PROCESS FLAGGED ARGUMENTS
208
282
  i = 0
209
283
  while i < args_len:
210
284
  arg = args[i]
@@ -230,19 +304,43 @@ class Console:
230
304
  if not value_found_after_flag:
231
305
  results[alias]["value"] = True
232
306
  i += 1
307
+
308
+ # COLLECT "after" POSITIONAL ARGUMENTS
309
+ for alias, pos_type in positional_configs.items():
310
+ if pos_type == "after":
311
+ after_args = []
312
+ start_pos = (last_flag_with_value_pos + 1) if last_flag_with_value_pos is not None else 0
313
+ # IF NO FLAGS WERE FOUND WITH VALUES, START AFTER THE LAST FLAG
314
+ if last_flag_with_value_pos is None and first_flag_pos is not None:
315
+ # FIND THE LAST FLAG POSITION
316
+ last_flag_pos = None
317
+ for i, arg in enumerate(args):
318
+ if arg in arg_lookup:
319
+ last_flag_pos = i
320
+ if last_flag_pos is not None:
321
+ start_pos = last_flag_pos + 1
322
+
323
+ for i in range(start_pos, args_len):
324
+ if not args[i].startswith("-") and args[i] not in arg_lookup:
325
+ after_args.append(String.to_type(args[i]))
326
+
327
+ if after_args:
328
+ results[alias]["value"] = after_args
329
+ results[alias]["exists"] = len(after_args) > 0
330
+
233
331
  return Args(**results)
234
332
 
235
333
  @staticmethod
236
334
  def pause_exit(
237
- pause: bool = False,
335
+ pause: bool = True,
238
336
  exit: bool = False,
239
337
  prompt: object = "",
240
338
  exit_code: int = 0,
241
339
  reset_ansi: bool = False,
242
340
  ) -> None:
243
- """Will print the `prompt` and then pause the program if `pause` is set
244
- to `True` and after the pause, exit the program if `exit` is set to `True`."""
245
- print(prompt, end="", flush=True)
341
+ """Will print the `prompt` and then pause the program if `pause` is
342
+ true and after the pause, exit the program if `exit` is set true."""
343
+ FormatCodes.print(prompt, end="", flush=True)
246
344
  if reset_ansi:
247
345
  FormatCodes.print("[_]", end="")
248
346
  if pause:
@@ -268,9 +366,12 @@ class Console:
268
366
  end: str = "\n",
269
367
  title_bg_color: Optional[Rgba | Hexa] = None,
270
368
  default_color: Optional[Rgba | Hexa] = None,
271
- _console_tabsize: int = 8,
369
+ tab_size: int = 8,
370
+ title_px: int = 1,
371
+ title_mx: int = 2,
272
372
  ) -> None:
273
- """Will print a formatted log message:
373
+ """Prints a nicely formatted log message.\n
374
+ -------------------------------------------------------------------------------------------
274
375
  - `title` -⠀the title of the log message (e.g. `DEBUG`, `WARN`, `FAIL`, etc.)
275
376
  - `prompt` -⠀the log message
276
377
  - `format_linebreaks` -⠀whether to format (indent after) the line breaks or not
@@ -278,38 +379,40 @@ class Console:
278
379
  - `end` -⠀something to print after the log is printed (e.g. `\\n`)
279
380
  - `title_bg_color` -⠀the background color of the `title`
280
381
  - `default_color` -⠀the default text color of the `prompt`
281
- - `_console_tabsize` -⠀the tab size of the console (default is 8)\n
282
- -----------------------------------------------------------------------------------
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
+ -------------------------------------------------------------------------------------------
283
386
  The log message can be formatted with special formatting codes. For more detailed
284
- information about formatting codes, see `xx_format_codes` module documentation."""
387
+ information about formatting codes, see `format_codes` module documentation."""
388
+ has_title_bg = title_bg_color is not None and Color.is_valid(title_bg_color)
285
389
  title = "" if title is None else title.strip().upper()
286
- title_len, tab_len = len(title) + 4, _console_tabsize - ((len(title) + 4) % _console_tabsize)
287
- if title_bg_color is not None and Color.is_valid(title_bg_color):
288
- title_bg_color = Color.to_hexa(title_bg_color)
289
- title_color = Color.text_color_for_on_bg(title_bg_color)
290
- else:
291
- title_color = "_color" if title_bg_color is None else "#000"
390
+ title_fg = Color.text_color_for_on_bg(
391
+ Color.to_hexa(title_bg_color) # type: ignore[assignment]
392
+ ) if has_title_bg else "_color"
393
+ px, mx = (" " * title_px) if has_title_bg else "", " " * title_mx
394
+ tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size))
292
395
  if format_linebreaks:
293
396
  clean_prompt, removals = FormatCodes.remove_formatting(str(prompt), get_removals=True, _ignore_linebreaks=True)
294
- prompt_lst = (String.split_count(l, Console.w - (title_len + tab_len)) for l in str(clean_prompt).splitlines())
397
+ prompt_lst = (
398
+ String.split_count(l, Console.w - (title_len + len(tab) + 2 * len(mx))) for l in str(clean_prompt).splitlines()
399
+ )
295
400
  prompt_lst = (
296
401
  item for lst in prompt_lst for item in ([""] if lst == [] else (lst if isinstance(lst, list) else [lst]))
297
402
  )
298
- prompt = f"\n{' ' * title_len}\t".join(
403
+ prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join(
299
404
  Console.__add_back_removed_parts(list(prompt_lst), cast(tuple[tuple[int, str], ...], removals))
300
405
  )
301
- else:
302
- prompt = str(prompt)
303
406
  if title == "":
304
407
  FormatCodes.print(
305
- f'{start} {f"[{default_color}]" if default_color else ""}{str(prompt)}[_]',
408
+ f'{start} {f"[{default_color}]" if default_color else ""}{prompt}[_]',
306
409
  default_color=default_color,
307
410
  end=end,
308
411
  )
309
412
  else:
310
413
  FormatCodes.print(
311
- f'{start} [bold][{title_color}]{f"[BG:{title_bg_color}]" if title_bg_color else ""} {title} [_]'
312
- + f'\t{f"[{default_color}]" if default_color else ""}{prompt}[_]',
414
+ f"{start}{mx}[bold][{title_fg}]{f'[BG:{title_bg_color}]' if title_bg_color else ''}{px}{title}{px}[_]{mx}"
415
+ + f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]",
313
416
  default_color=default_color,
314
417
  end=end,
315
418
  )
@@ -342,7 +445,7 @@ class Console:
342
445
  i = find_string_part(pos)
343
446
  adjusted_pos = (pos - cumulative_pos[i]) + offset_adjusts[i]
344
447
  parts = [result[i][:adjusted_pos], removal, result[i][adjusted_pos:]]
345
- result[i] = ''.join(parts)
448
+ result[i] = "".join(parts)
346
449
  offset_adjusts[i] += len(removal)
347
450
  return result
348
451
 
@@ -353,7 +456,7 @@ class Console:
353
456
  format_linebreaks: bool = True,
354
457
  start: str = "",
355
458
  end: str = "\n",
356
- default_color: Optional[Rgba | Hexa] = COLOR.text,
459
+ default_color: Optional[Rgba | Hexa] = None,
357
460
  pause: bool = False,
358
461
  exit: bool = False,
359
462
  ) -> None:
@@ -361,7 +464,7 @@ class Console:
361
464
  at the message and exit the program after the message was printed.
362
465
  If `active` is false, no debug message will be printed."""
363
466
  if active:
364
- Console.log("DEBUG", prompt, format_linebreaks, start, end, COLOR.yellow, default_color)
467
+ Console.log("DEBUG", prompt, format_linebreaks, start, end, COLOR.YELLOW, default_color)
365
468
  Console.pause_exit(pause, exit)
366
469
 
367
470
  @staticmethod
@@ -370,13 +473,13 @@ class Console:
370
473
  format_linebreaks: bool = True,
371
474
  start: str = "",
372
475
  end: str = "\n",
373
- default_color: Optional[Rgba | Hexa] = COLOR.text,
476
+ default_color: Optional[Rgba | Hexa] = None,
374
477
  pause: bool = False,
375
478
  exit: bool = False,
376
479
  ) -> None:
377
480
  """A preset for `log()`: `INFO` log message with the options to pause
378
481
  at the message and exit the program after the message was printed."""
379
- Console.log("INFO", prompt, format_linebreaks, start, end, COLOR.blue, default_color)
482
+ Console.log("INFO", prompt, format_linebreaks, start, end, COLOR.BLUE, default_color)
380
483
  Console.pause_exit(pause, exit)
381
484
 
382
485
  @staticmethod
@@ -385,13 +488,13 @@ class Console:
385
488
  format_linebreaks: bool = True,
386
489
  start: str = "",
387
490
  end: str = "\n",
388
- default_color: Optional[Rgba | Hexa] = COLOR.text,
491
+ default_color: Optional[Rgba | Hexa] = None,
389
492
  pause: bool = False,
390
493
  exit: bool = False,
391
494
  ) -> None:
392
495
  """A preset for `log()`: `DONE` log message with the options to pause
393
496
  at the message and exit the program after the message was printed."""
394
- Console.log("DONE", prompt, format_linebreaks, start, end, COLOR.teal, default_color)
497
+ Console.log("DONE", prompt, format_linebreaks, start, end, COLOR.TEAL, default_color)
395
498
  Console.pause_exit(pause, exit)
396
499
 
397
500
  @staticmethod
@@ -400,13 +503,13 @@ class Console:
400
503
  format_linebreaks: bool = True,
401
504
  start: str = "",
402
505
  end: str = "\n",
403
- default_color: Optional[Rgba | Hexa] = COLOR.text,
506
+ default_color: Optional[Rgba | Hexa] = None,
404
507
  pause: bool = False,
405
508
  exit: bool = False,
406
509
  ) -> None:
407
510
  """A preset for `log()`: `WARN` log message with the options to pause
408
511
  at the message and exit the program after the message was printed."""
409
- Console.log("WARN", prompt, format_linebreaks, start, end, COLOR.orange, default_color)
512
+ Console.log("WARN", prompt, format_linebreaks, start, end, COLOR.ORANGE, default_color)
410
513
  Console.pause_exit(pause, exit)
411
514
 
412
515
  @staticmethod
@@ -415,14 +518,14 @@ class Console:
415
518
  format_linebreaks: bool = True,
416
519
  start: str = "",
417
520
  end: str = "\n",
418
- default_color: Optional[Rgba | Hexa] = COLOR.text,
521
+ default_color: Optional[Rgba | Hexa] = None,
419
522
  pause: bool = False,
420
523
  exit: bool = True,
421
524
  reset_ansi: bool = True,
422
525
  ) -> None:
423
526
  """A preset for `log()`: `FAIL` log message with the options to pause
424
527
  at the message and exit the program after the message was printed."""
425
- Console.log("FAIL", prompt, format_linebreaks, start, end, COLOR.red, default_color)
528
+ Console.log("FAIL", prompt, format_linebreaks, start, end, COLOR.RED, default_color)
426
529
  Console.pause_exit(pause, exit, reset_ansi=reset_ansi)
427
530
 
428
531
  @staticmethod
@@ -431,14 +534,14 @@ class Console:
431
534
  format_linebreaks: bool = True,
432
535
  start: str = "",
433
536
  end: str = "\n",
434
- default_color: Optional[Rgba | Hexa] = COLOR.text,
537
+ default_color: Optional[Rgba | Hexa] = None,
435
538
  pause: bool = False,
436
539
  exit: bool = True,
437
540
  reset_ansi: bool = True,
438
541
  ) -> None:
439
542
  """A preset for `log()`: `EXIT` log message with the options to pause
440
543
  at the message and exit the program after the message was printed."""
441
- Console.log("EXIT", prompt, format_linebreaks, start, end, COLOR.magenta, default_color)
544
+ Console.log("EXIT", prompt, format_linebreaks, start, end, COLOR.MAGENTA, default_color)
442
545
  Console.pause_exit(pause, exit, reset_ansi=reset_ansi)
443
546
 
444
547
  @staticmethod
@@ -463,7 +566,7 @@ class Console:
463
566
  - `indent` -⠀the indentation of the box (in chars)\n
464
567
  -----------------------------------------------------------------------------------
465
568
  The box content can be formatted with special formatting codes. For more detailed
466
- information about formatting codes, see `xx_format_codes` module documentation."""
569
+ information about formatting codes, see `format_codes` module documentation."""
467
570
  lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color)
468
571
  pad_w_full = (Console.w - (max_line_len + (2 * w_padding))) if w_full else 0
469
572
  if box_bg_color is not None and Color.is_valid(box_bg_color):
@@ -489,7 +592,7 @@ class Console:
489
592
  start: str = "",
490
593
  end: str = "\n",
491
594
  border_type: Literal["standard", "rounded", "strong", "double"] = "rounded",
492
- border_style: str | Rgba | Hexa = f"dim|{COLOR.gray}",
595
+ border_style: str | Rgba | Hexa = f"dim|{COLOR.GRAY}",
493
596
  default_color: Optional[Rgba | Hexa] = None,
494
597
  w_padding: int = 1,
495
598
  w_full: bool = False,
@@ -509,7 +612,7 @@ class Console:
509
612
  - `_border_chars` -⠀define your own border characters set (overwrites `border_type`)\n
510
613
  ---------------------------------------------------------------------------------------
511
614
  The box content can be formatted with special formatting codes. For more detailed
512
- information about formatting codes, see `xx_format_codes` module documentation.\n
615
+ information about formatting codes, see `format_codes` module documentation.\n
513
616
  ---------------------------------------------------------------------------------------
514
617
  The `border_type` can be one of the following:
515
618
  - `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│')`
@@ -567,22 +670,28 @@ class Console:
567
670
  def confirm(
568
671
  prompt: object = "Do you want to continue?",
569
672
  start="",
570
- end="\n",
571
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
673
+ end="",
674
+ default_color: Optional[Rgba | Hexa] = None,
572
675
  default_is_yes: bool = True,
573
676
  ) -> bool:
574
677
  """Ask a yes/no question.\n
575
678
  ---------------------------------------------------------------------------------------
679
+ - `prompt` -⠀the input prompt
680
+ - `start` -⠀something to print before the input
681
+ - `end` -⠀something to print after the input (e.g. `\\n`)
682
+ - `default_color` -⠀the default text color of the `prompt`
683
+ - `default_is_yes` -⠀the default answer if the user just presses enter
684
+ ---------------------------------------------------------------------------------------
576
685
  The prompt can be formatted with special formatting codes. For more detailed
577
- information about formatting codes, see the `xx_format_codes` module documentation."""
686
+ information about formatting codes, see the `format_codes` module documentation."""
578
687
  confirmed = input(
579
688
  FormatCodes.to_ansi(
580
- f'{start} {str(prompt)} [_|dim](({"Y" if default_is_yes else "y"}/{"n" if default_is_yes else "N"}): )',
689
+ f'{start}{str(prompt)} [_|dim](({"Y" if default_is_yes else "y"}/{"n" if default_is_yes else "N"}): )',
581
690
  default_color=default_color,
582
691
  )
583
692
  ).strip().lower() in (("", "y", "yes") if default_is_yes else ("y", "yes"))
584
693
  if end:
585
- Console.log("", end, end="")
694
+ FormatCodes.print(end, end="")
586
695
  return confirmed
587
696
 
588
697
  @staticmethod
@@ -590,13 +699,13 @@ class Console:
590
699
  prompt: object = "",
591
700
  start="",
592
701
  end="\n",
593
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
702
+ default_color: Optional[Rgba | Hexa] = None,
594
703
  show_keybindings=True,
595
704
  input_prefix=" ⮡ ",
596
705
  reset_ansi=True,
597
706
  ) -> str:
598
- """An input where users can input (and paste) text over multiple lines.\n
599
- -----------------------------------------------------------------------------------
707
+ """An input where users can write (and paste) text over multiple lines.\n
708
+ ---------------------------------------------------------------------------------------
600
709
  - `prompt` -⠀the input prompt
601
710
  - `start` -⠀something to print before the input
602
711
  - `end` -⠀something to print after the input (e.g. `\\n`)
@@ -604,9 +713,9 @@ class Console:
604
713
  - `show_keybindings` -⠀whether to show the special keybindings or not
605
714
  - `input_prefix` -⠀the prefix of the input line
606
715
  - `reset_ansi` -⠀whether to reset the ANSI codes after the input or not
607
- -----------------------------------------------------------------------------------
716
+ ---------------------------------------------------------------------------------------
608
717
  The input prompt can be formatted with special formatting codes. For more detailed
609
- information about formatting codes, see `xx_format_codes` module documentation."""
718
+ information about formatting codes, see the `format_codes` module documentation."""
610
719
  kb = KeyBindings()
611
720
 
612
721
  @kb.add("c-d", eager=True) # CTRL+D
@@ -616,135 +725,184 @@ class Console:
616
725
  FormatCodes.print(start + str(prompt), default_color=default_color)
617
726
  if show_keybindings:
618
727
  FormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]")
619
- input_string = _prompt_toolkit.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb)
728
+ input_string = _pt.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb)
620
729
  FormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end)
621
730
  return input_string
622
731
 
623
732
  @staticmethod
624
- def restricted_input(
733
+ def input(
625
734
  prompt: object = "",
626
735
  start="",
627
- end="\n",
628
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
629
- allowed_chars: str = CHARS.all, # type: ignore[assignment]
736
+ end="",
737
+ default_color: Optional[Rgba | Hexa] = None,
738
+ placeholder: Optional[str] = None,
739
+ mask_char: Optional[str] = None,
630
740
  min_len: Optional[int] = None,
631
741
  max_len: Optional[int] = None,
632
- mask_char: Optional[str] = None,
633
- reset_ansi: bool = True,
634
- ) -> Optional[str]:
635
- """Acts like a standard Python `input()` with the advantage, that you can specify:
636
- - what text characters the user is allowed to type and
637
- - the minimum and/or maximum length of the users input
638
- - optional mask character (hide user input, e.g. for passwords)
639
- - reset the ANSI formatting codes after the user continues\n
640
- ---------------------------------------------------------------------------------------
641
- The input can be formatted with special formatting codes. For more detailed
642
- information about formatting codes, see the `xx_format_codes` module documentation."""
643
- FormatCodes.print(start + str(prompt), default_color=default_color, end="")
644
- result = ""
645
- select_all = False
646
- last_line_count = 1
647
- last_console_width = 0
648
-
649
- def update_display(console_width: int) -> None:
650
- nonlocal last_line_count, last_console_width
651
- lines = String.split_count(str(prompt) + (mask_char * len(result) if mask_char else result), console_width)
652
- line_count = len(lines)
653
- if (line_count > 1 or line_count < last_line_count) and not last_line_count == 1:
654
- if last_console_width > console_width:
655
- line_count *= 2
656
- for _ in range(line_count if line_count < last_line_count and not line_count > last_line_count else (
657
- line_count - 2 if line_count > last_line_count else line_count - 1)):
658
- _sys.stdout.write("\033[2K\r\033[A")
659
- prompt_len = len(str(prompt)) if prompt else 0
660
- prompt_str = lines[0][:prompt_len]
661
- input_str = (
662
- lines[0][prompt_len:] if len(lines) == 1 else "\n".join([lines[0][prompt_len:]] + lines[1:])
663
- ) # SEPARATE THE PROMPT AND THE INPUT
664
- _sys.stdout.write(
665
- "\033[2K\r" + FormatCodes.to_ansi(prompt_str) + ("\033[7m" if select_all else "") + input_str + "\033[27m"
666
- )
667
- last_line_count, last_console_width = line_count, console_width
668
-
669
- def handle_enter():
670
- if min_len is not None and len(result) < min_len:
671
- return False
672
- FormatCodes.print(f"[_]{end}" if reset_ansi else end, default_color=default_color)
673
- return True
674
-
675
- def handle_backspace_delete():
676
- nonlocal result, select_all
677
- if select_all:
678
- result, select_all = "", False
679
- elif result and event.name == "backspace":
680
- result = result[:-1]
681
- update_display(Console.w)
682
-
683
- def handle_paste():
684
- nonlocal result, select_all
685
- if select_all:
686
- result, select_all = "", False
687
- filtered_text = "".join(char for char in _pyperclip.paste() if allowed_chars == CHARS.all or char in allowed_chars)
688
- if max_len is None or len(result) + len(filtered_text) <= max_len:
689
- result += filtered_text
690
- update_display(Console.w)
691
-
692
- def handle_select_all():
693
- nonlocal select_all
694
- select_all = True
695
- update_display(Console.w)
696
-
697
- def handle_character_input():
698
- nonlocal result
699
- if event.name is not None and ((allowed_chars == CHARS.all or event.name in allowed_chars) and
700
- (max_len is None or len(result) < max_len)):
701
- result += event.name
702
- update_display(Console.w)
703
-
704
- while True:
705
- event = _keyboard.read_event()
706
- if event.event_type == "down":
707
- if event.name == "enter" and handle_enter():
708
- return result.rstrip("\n")
709
- elif event.name in ("backspace", "delete", "entf"):
710
- handle_backspace_delete()
711
- elif (event.name == "v" and _keyboard.is_pressed("ctrl")) or _mouse.is_pressed("right"):
712
- handle_paste()
713
- elif event.name == "a" and _keyboard.is_pressed("ctrl"):
714
- handle_select_all()
715
- elif event.name == "c" and _keyboard.is_pressed("ctrl"):
716
- raise KeyboardInterrupt
717
- elif event.name == "esc":
718
- return None
719
- elif event.name == "space":
720
- handle_character_input()
721
- elif event.name is not None and len(event.name) == 1:
722
- handle_character_input()
742
+ allowed_chars: str = CHARS.ALL, #type: ignore[assignment]
743
+ allow_paste: bool = True,
744
+ validator: Optional[Callable[[str], Optional[str]]] = None,
745
+ ) -> str:
746
+ """Acts like a standard Python `input()` a bunch of cool extra features.\n
747
+ ------------------------------------------------------------------------------------
748
+ - `prompt` -⠀the input prompt
749
+ - `start` -⠀something to print before the input
750
+ - `end` -⠀something to print after the input (e.g. `\\n`)
751
+ - `default_color` -⠀the default text color of the `prompt`
752
+ - `placeholder` -⠀a placeholder text that is shown when the input is empty
753
+ - `mask_char` -⠀if set, the input will be masked with this character
754
+ - `min_len` -⠀the minimum length of the input (required to submit)
755
+ - `max_len` -⠀the maximum length of the input (can't write further if reached)
756
+ - `allowed_chars` -⠀a string of characters that are allowed to be inputted
757
+ (default allows all characters)
758
+ - `allow_paste` -⠀whether to allow pasting text into the input or not
759
+ - `validator` -⠀a function that takes the input string and returns a string error
760
+ message if invalid, or nothing if valid
761
+ ------------------------------------------------------------------------------------
762
+ The input prompt can be formatted with special formatting codes. For more detailed
763
+ information about formatting codes, see the `format_codes` module documentation."""
764
+ result_text = ""
765
+ tried_pasting = False
766
+ filtered_chars = set()
767
+
768
+ class InputValidator(Validator):
769
+
770
+ def validate(self, document) -> None:
771
+ text_to_validate = result_text if mask_char else document.text
772
+ if min_len and len(text_to_validate) < min_len:
773
+ raise ValidationError(message="", cursor_position=len(document.text))
774
+ if validator and validator(text_to_validate) not in ("", None):
775
+ raise ValidationError(message="", cursor_position=len(document.text))
776
+
777
+ def bottom_toolbar() -> _pt.formatted_text.ANSI:
778
+ nonlocal tried_pasting
779
+ try:
780
+ if mask_char:
781
+ text_to_check = result_text
782
+ else:
783
+ app = _pt.application.get_app()
784
+ text_to_check = app.current_buffer.text
785
+ toolbar_msgs = []
786
+ if max_len and len(text_to_check) > max_len:
787
+ toolbar_msgs.append("[b|#FFF|bg:red]( Text too long! )")
788
+ if validator and text_to_check and (validation_error_msg := validator(text_to_check)) not in ("", None):
789
+ toolbar_msgs.append(f"[b|#000|bg:br:red] {validation_error_msg} [_bg]")
790
+ if filtered_chars:
791
+ plural = "" if len(char_list := "".join(sorted(filtered_chars))) == 1 else "s"
792
+ toolbar_msgs.append(f"[b|#000|bg:yellow]( Char{plural} '{char_list}' not allowed )")
793
+ filtered_chars.clear()
794
+ if min_len and len(text_to_check) < min_len:
795
+ toolbar_msgs.append(f"[b|#000|bg:yellow]( Need {min_len - len(text_to_check)} more chars )")
796
+ if tried_pasting:
797
+ toolbar_msgs.append("[b|#000|bg:br:yellow]( Pasting disabled )")
798
+ tried_pasting = False
799
+ if max_len and len(text_to_check) == max_len:
800
+ toolbar_msgs.append("[b|#000|bg:br:yellow]( Maximum length reached )")
801
+ return _pt.formatted_text.ANSI(FormatCodes.to_ansi(" ".join(toolbar_msgs)))
802
+ except Exception:
803
+ return _pt.formatted_text.ANSI("")
804
+
805
+ def process_insert_text(text: str) -> tuple[str, set[str]]:
806
+ removed_chars = set()
807
+ if not text: return "", removed_chars
808
+ processed_text = "".join(c for c in text if ord(c) >= 32)
809
+ if allowed_chars != CHARS.ALL:
810
+ filtered_text = ""
811
+ for char in processed_text:
812
+ if char in allowed_chars:
813
+ filtered_text += char
814
+ else:
815
+ removed_chars.add(char)
816
+ processed_text = filtered_text
817
+ if max_len:
818
+ if (remaining_space := max_len - len(result_text)) > 0:
819
+ if len(processed_text) > remaining_space:
820
+ processed_text = processed_text[:remaining_space]
821
+ else:
822
+ processed_text = ""
823
+ return processed_text, removed_chars
824
+
825
+ def insert_text_event(event: KeyPressEvent) -> None:
826
+ nonlocal result_text, filtered_chars
827
+ try:
828
+ insert_text = event.data
829
+ if not insert_text: return
830
+ buffer = event.app.current_buffer
831
+ cursor_pos = buffer.cursor_position
832
+ insert_text, filtered_chars = process_insert_text(insert_text)
833
+ if insert_text:
834
+ result_text = result_text[:cursor_pos] + insert_text + result_text[cursor_pos:]
835
+ if mask_char:
836
+ buffer.insert_text(mask_char[0] * len(insert_text))
837
+ else:
838
+ buffer.insert_text(insert_text)
839
+ except Exception:
840
+ pass
841
+
842
+ def remove_text_event(event: KeyPressEvent, is_backspace: bool = False) -> None:
843
+ nonlocal result_text
844
+ try:
845
+ buffer = event.app.current_buffer
846
+ cursor_pos = buffer.cursor_position
847
+ has_selection = buffer.selection_state is not None
848
+ if has_selection:
849
+ start, end = buffer.document.selection_range()
850
+ result_text = result_text[:start] + result_text[end:]
851
+ buffer.cursor_position = start
852
+ buffer.delete(end - start)
723
853
  else:
724
- select_all = False
725
- update_display(Console.w)
854
+ if is_backspace:
855
+ if cursor_pos > 0:
856
+ result_text = result_text[:cursor_pos - 1] + result_text[cursor_pos:]
857
+ buffer.delete_before_cursor(1)
858
+ else:
859
+ if cursor_pos < len(result_text):
860
+ result_text = result_text[:cursor_pos] + result_text[cursor_pos + 1:]
861
+ buffer.delete(1)
862
+ except Exception:
863
+ pass
726
864
 
727
- @staticmethod
728
- def pwd_input(
729
- prompt: object = "Password: ",
730
- start="",
731
- end="\n",
732
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
733
- allowed_chars: str = CHARS.standard_ascii,
734
- min_len: Optional[int] = None,
735
- max_len: Optional[int] = None,
736
- reset_ansi: bool = True,
737
- ) -> Optional[str]:
738
- """Password input (preset for `Console.restricted_input()`)
739
- that always masks the entered characters with asterisks."""
740
- return Console.restricted_input(
741
- prompt=prompt,
742
- start=start,
743
- end=end,
744
- default_color=default_color,
745
- allowed_chars=allowed_chars,
746
- min_len=min_len,
747
- max_len=max_len,
748
- mask_char="*",
749
- reset_ansi=reset_ansi,
865
+ kb = KeyBindings()
866
+
867
+ @kb.add(Keys.Delete)
868
+ def _(event: KeyPressEvent) -> None:
869
+ remove_text_event(event)
870
+
871
+ @kb.add(Keys.Backspace)
872
+ def _(event: KeyPressEvent) -> None:
873
+ remove_text_event(event, is_backspace=True)
874
+
875
+ @kb.add(Keys.ControlA)
876
+ def _(event: KeyPressEvent) -> None:
877
+ buffer = event.app.current_buffer
878
+ buffer.cursor_position = 0
879
+ buffer.start_selection()
880
+ buffer.cursor_position = len(buffer.text)
881
+
882
+ @kb.add(Keys.BracketedPaste)
883
+ def _(event: KeyPressEvent) -> None:
884
+ if allow_paste:
885
+ insert_text_event(event)
886
+ else:
887
+ nonlocal tried_pasting
888
+ tried_pasting = True
889
+
890
+ @kb.add(Keys.Any)
891
+ def _(event: KeyPressEvent) -> None:
892
+ insert_text_event(event)
893
+
894
+ custom_style = Style.from_dict({'bottom-toolbar': 'noreverse'})
895
+ session = _pt.PromptSession(
896
+ message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)),
897
+ validator=InputValidator(),
898
+ validate_while_typing=True,
899
+ key_bindings=kb,
900
+ bottom_toolbar=bottom_toolbar,
901
+ placeholder=_pt.formatted_text.ANSI(FormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]"))
902
+ if placeholder else "",
903
+ style=custom_style,
750
904
  )
905
+ FormatCodes.print(start, end="")
906
+ session.prompt()
907
+ FormatCodes.print(end, end="")
908
+ return result_text