falyx 0.1.50__py3-none-any.whl → 0.1.52__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.
Files changed (39) hide show
  1. falyx/__main__.py +1 -1
  2. falyx/action/__init__.py +2 -1
  3. falyx/action/action.py +2 -2
  4. falyx/action/action_group.py +3 -1
  5. falyx/action/chained_action.py +2 -0
  6. falyx/action/fallback_action.py +2 -0
  7. falyx/action/io_action.py +1 -97
  8. falyx/action/literal_input_action.py +2 -0
  9. falyx/action/menu_action.py +5 -1
  10. falyx/action/mixins.py +2 -0
  11. falyx/action/process_action.py +2 -0
  12. falyx/action/process_pool_action.py +3 -1
  13. falyx/action/select_file_action.py +32 -4
  14. falyx/action/selection_action.py +85 -23
  15. falyx/action/shell_action.py +105 -0
  16. falyx/action/types.py +2 -0
  17. falyx/action/user_input_action.py +5 -0
  18. falyx/command.py +2 -2
  19. falyx/context.py +1 -1
  20. falyx/execution_registry.py +4 -4
  21. falyx/falyx.py +8 -2
  22. falyx/{parsers → parser}/__init__.py +3 -1
  23. falyx/parser/argument.py +98 -0
  24. falyx/parser/argument_action.py +27 -0
  25. falyx/{parsers/argparse.py → parser/command_argument_parser.py} +4 -116
  26. falyx/{parsers → parser}/signature.py +1 -0
  27. falyx/{parsers → parser}/utils.py +2 -1
  28. falyx/selection.py +73 -11
  29. falyx/validators.py +89 -1
  30. falyx/version.py +1 -1
  31. {falyx-0.1.50.dist-info → falyx-0.1.52.dist-info}/METADATA +1 -1
  32. falyx-0.1.52.dist-info/RECORD +64 -0
  33. falyx/.coverage +0 -0
  34. falyx-0.1.50.dist-info/RECORD +0 -62
  35. /falyx/{parsers → parser}/.pytyped +0 -0
  36. /falyx/{parsers → parser}/parsers.py +0 -0
  37. {falyx-0.1.50.dist-info → falyx-0.1.52.dist-info}/LICENSE +0 -0
  38. {falyx-0.1.50.dist-info → falyx-0.1.52.dist-info}/WHEEL +0 -0
  39. {falyx-0.1.50.dist-info → falyx-0.1.52.dist-info}/entry_points.txt +0 -0
@@ -112,7 +112,7 @@ class ExecutionRegistry:
112
112
  cls,
113
113
  name: str = "",
114
114
  index: int | None = None,
115
- result: int | None = None,
115
+ result_index: int | None = None,
116
116
  clear: bool = False,
117
117
  last_result: bool = False,
118
118
  status: Literal["all", "success", "error"] = "all",
@@ -138,12 +138,12 @@ class ExecutionRegistry:
138
138
  )
139
139
  return
140
140
 
141
- if result is not None and result >= 0:
141
+ if result_index is not None and result_index >= 0:
142
142
  try:
143
- result_context = cls._store_by_index[result]
143
+ result_context = cls._store_by_index[result_index]
144
144
  except KeyError:
145
145
  cls._console.print(
146
- f"[{OneColors.DARK_RED}]❌ No execution found for index {index}."
146
+ f"[{OneColors.DARK_RED}]❌ No execution found for index {result_index}."
147
147
  )
148
148
  return
149
149
  cls._console.print(f"{result_context.signature}:")
falyx/falyx.py CHANGED
@@ -59,7 +59,7 @@ from falyx.execution_registry import ExecutionRegistry as er
59
59
  from falyx.hook_manager import Hook, HookManager, HookType
60
60
  from falyx.logger import logger
61
61
  from falyx.options_manager import OptionsManager
62
- from falyx.parsers import CommandArgumentParser, FalyxParsers, get_arg_parsers
62
+ from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
63
63
  from falyx.protocols import ArgParserProtocol
64
64
  from falyx.retry import RetryPolicy
65
65
  from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
@@ -330,7 +330,13 @@ class Falyx:
330
330
  action="store_true",
331
331
  help="Clear the Execution History.",
332
332
  )
333
- parser.add_argument("-r", "--result", type=int, help="Get the result by index")
333
+ parser.add_argument(
334
+ "-r",
335
+ "--result",
336
+ type=int,
337
+ dest="result_index",
338
+ help="Get the result by index",
339
+ )
334
340
  parser.add_argument(
335
341
  "-l", "--last-result", action="store_true", help="Get the last result"
336
342
  )
@@ -5,7 +5,9 @@ Copyright (c) 2025 rtj.dev LLC.
5
5
  Licensed under the MIT License. See LICENSE file for details.
6
6
  """
7
7
 
8
- from .argparse import Argument, ArgumentAction, CommandArgumentParser
8
+ from .argument import Argument
9
+ from .argument_action import ArgumentAction
10
+ from .command_argument_parser import CommandArgumentParser
9
11
  from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers
10
12
 
11
13
  __all__ = [
@@ -0,0 +1,98 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """argument.py"""
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from falyx.action.base import BaseAction
7
+ from falyx.parser.argument_action import ArgumentAction
8
+
9
+
10
+ @dataclass
11
+ class Argument:
12
+ """Represents a command-line argument."""
13
+
14
+ flags: tuple[str, ...]
15
+ dest: str # Destination name for the argument
16
+ action: ArgumentAction = (
17
+ ArgumentAction.STORE
18
+ ) # Action to be taken when the argument is encountered
19
+ type: Any = str # Type of the argument (e.g., str, int, float) or callable
20
+ default: Any = None # Default value if the argument is not provided
21
+ choices: list[str] | None = None # List of valid choices for the argument
22
+ required: bool = False # True if the argument is required
23
+ help: str = "" # Help text for the argument
24
+ nargs: int | str | None = None # int, '?', '*', '+', None
25
+ positional: bool = False # True if no leading - or -- in flags
26
+ resolver: BaseAction | None = None # Action object for the argument
27
+
28
+ def get_positional_text(self) -> str:
29
+ """Get the positional text for the argument."""
30
+ text = ""
31
+ if self.positional:
32
+ if self.choices:
33
+ text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
34
+ else:
35
+ text = self.dest
36
+ return text
37
+
38
+ def get_choice_text(self) -> str:
39
+ """Get the choice text for the argument."""
40
+ choice_text = ""
41
+ if self.choices:
42
+ choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
43
+ elif (
44
+ self.action
45
+ in (
46
+ ArgumentAction.STORE,
47
+ ArgumentAction.APPEND,
48
+ ArgumentAction.EXTEND,
49
+ )
50
+ and not self.positional
51
+ ):
52
+ choice_text = self.dest.upper()
53
+ elif self.action in (
54
+ ArgumentAction.STORE,
55
+ ArgumentAction.APPEND,
56
+ ArgumentAction.EXTEND,
57
+ ) or isinstance(self.nargs, str):
58
+ choice_text = self.dest
59
+
60
+ if self.nargs == "?":
61
+ choice_text = f"[{choice_text}]"
62
+ elif self.nargs == "*":
63
+ choice_text = f"[{choice_text} ...]"
64
+ elif self.nargs == "+":
65
+ choice_text = f"{choice_text} [{choice_text} ...]"
66
+ return choice_text
67
+
68
+ def __eq__(self, other: object) -> bool:
69
+ if not isinstance(other, Argument):
70
+ return False
71
+ return (
72
+ self.flags == other.flags
73
+ and self.dest == other.dest
74
+ and self.action == other.action
75
+ and self.type == other.type
76
+ and self.choices == other.choices
77
+ and self.required == other.required
78
+ and self.nargs == other.nargs
79
+ and self.positional == other.positional
80
+ and self.default == other.default
81
+ and self.help == other.help
82
+ )
83
+
84
+ def __hash__(self) -> int:
85
+ return hash(
86
+ (
87
+ tuple(self.flags),
88
+ self.dest,
89
+ self.action,
90
+ self.type,
91
+ tuple(self.choices or []),
92
+ self.required,
93
+ self.nargs,
94
+ self.positional,
95
+ self.default,
96
+ self.help,
97
+ )
98
+ )
@@ -0,0 +1,27 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """argument_action.py"""
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class ArgumentAction(Enum):
9
+ """Defines the action to be taken when the argument is encountered."""
10
+
11
+ ACTION = "action"
12
+ STORE = "store"
13
+ STORE_TRUE = "store_true"
14
+ STORE_FALSE = "store_false"
15
+ APPEND = "append"
16
+ EXTEND = "extend"
17
+ COUNT = "count"
18
+ HELP = "help"
19
+
20
+ @classmethod
21
+ def choices(cls) -> list[ArgumentAction]:
22
+ """Return a list of all argument actions."""
23
+ return list(cls)
24
+
25
+ def __str__(self) -> str:
26
+ """Return the string representation of the argument action."""
27
+ return self.value
@@ -1,9 +1,8 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """command_argument_parser.py"""
2
3
  from __future__ import annotations
3
4
 
4
5
  from copy import deepcopy
5
- from dataclasses import dataclass
6
- from enum import Enum
7
6
  from typing import Any, Iterable
8
7
 
9
8
  from rich.console import Console
@@ -12,123 +11,12 @@ from rich.text import Text
12
11
 
13
12
  from falyx.action.base import BaseAction
14
13
  from falyx.exceptions import CommandArgumentError
15
- from falyx.parsers.utils import coerce_value
14
+ from falyx.parser.argument import Argument
15
+ from falyx.parser.argument_action import ArgumentAction
16
+ from falyx.parser.utils import coerce_value
16
17
  from falyx.signals import HelpSignal
17
18
 
18
19
 
19
- class ArgumentAction(Enum):
20
- """Defines the action to be taken when the argument is encountered."""
21
-
22
- ACTION = "action"
23
- STORE = "store"
24
- STORE_TRUE = "store_true"
25
- STORE_FALSE = "store_false"
26
- APPEND = "append"
27
- EXTEND = "extend"
28
- COUNT = "count"
29
- HELP = "help"
30
-
31
- @classmethod
32
- def choices(cls) -> list[ArgumentAction]:
33
- """Return a list of all argument actions."""
34
- return list(cls)
35
-
36
- def __str__(self) -> str:
37
- """Return the string representation of the argument action."""
38
- return self.value
39
-
40
-
41
- @dataclass
42
- class Argument:
43
- """Represents a command-line argument."""
44
-
45
- flags: tuple[str, ...]
46
- dest: str # Destination name for the argument
47
- action: ArgumentAction = (
48
- ArgumentAction.STORE
49
- ) # Action to be taken when the argument is encountered
50
- type: Any = str # Type of the argument (e.g., str, int, float) or callable
51
- default: Any = None # Default value if the argument is not provided
52
- choices: list[str] | None = None # List of valid choices for the argument
53
- required: bool = False # True if the argument is required
54
- help: str = "" # Help text for the argument
55
- nargs: int | str | None = None # int, '?', '*', '+', None
56
- positional: bool = False # True if no leading - or -- in flags
57
- resolver: BaseAction | None = None # Action object for the argument
58
-
59
- def get_positional_text(self) -> str:
60
- """Get the positional text for the argument."""
61
- text = ""
62
- if self.positional:
63
- if self.choices:
64
- text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
65
- else:
66
- text = self.dest
67
- return text
68
-
69
- def get_choice_text(self) -> str:
70
- """Get the choice text for the argument."""
71
- choice_text = ""
72
- if self.choices:
73
- choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
74
- elif (
75
- self.action
76
- in (
77
- ArgumentAction.STORE,
78
- ArgumentAction.APPEND,
79
- ArgumentAction.EXTEND,
80
- )
81
- and not self.positional
82
- ):
83
- choice_text = self.dest.upper()
84
- elif self.action in (
85
- ArgumentAction.STORE,
86
- ArgumentAction.APPEND,
87
- ArgumentAction.EXTEND,
88
- ) or isinstance(self.nargs, str):
89
- choice_text = self.dest
90
-
91
- if self.nargs == "?":
92
- choice_text = f"[{choice_text}]"
93
- elif self.nargs == "*":
94
- choice_text = f"[{choice_text} ...]"
95
- elif self.nargs == "+":
96
- choice_text = f"{choice_text} [{choice_text} ...]"
97
- return choice_text
98
-
99
- def __eq__(self, other: object) -> bool:
100
- if not isinstance(other, Argument):
101
- return False
102
- return (
103
- self.flags == other.flags
104
- and self.dest == other.dest
105
- and self.action == other.action
106
- and self.type == other.type
107
- and self.choices == other.choices
108
- and self.required == other.required
109
- and self.nargs == other.nargs
110
- and self.positional == other.positional
111
- and self.default == other.default
112
- and self.help == other.help
113
- )
114
-
115
- def __hash__(self) -> int:
116
- return hash(
117
- (
118
- tuple(self.flags),
119
- self.dest,
120
- self.action,
121
- self.type,
122
- tuple(self.choices or []),
123
- self.required,
124
- self.nargs,
125
- self.positional,
126
- self.default,
127
- self.help,
128
- )
129
- )
130
-
131
-
132
20
  class CommandArgumentParser:
133
21
  """
134
22
  Custom argument parser for Falyx Commands.
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  import inspect
2
3
  from typing import Any, Callable
3
4
 
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  import types
2
3
  from datetime import datetime
3
4
  from enum import EnumMeta
@@ -7,7 +8,7 @@ from dateutil import parser as date_parser
7
8
 
8
9
  from falyx.action.base import BaseAction
9
10
  from falyx.logger import logger
10
- from falyx.parsers.signature import infer_args_from_func
11
+ from falyx.parser.signature import infer_args_from_func
11
12
 
12
13
 
13
14
  def coerce_bool(value: str) -> bool:
falyx/selection.py CHANGED
@@ -11,7 +11,7 @@ from rich.table import Table
11
11
 
12
12
  from falyx.themes import OneColors
13
13
  from falyx.utils import CaseInsensitiveDict, chunks
14
- from falyx.validators import int_range_validator, key_validator
14
+ from falyx.validators import MultiIndexValidator, MultiKeyValidator
15
15
 
16
16
 
17
17
  @dataclass
@@ -271,7 +271,11 @@ async def prompt_for_index(
271
271
  prompt_session: PromptSession | None = None,
272
272
  prompt_message: str = "Select an option > ",
273
273
  show_table: bool = True,
274
- ) -> int:
274
+ number_selections: int | str = 1,
275
+ separator: str = ",",
276
+ allow_duplicates: bool = False,
277
+ cancel_key: str = "",
278
+ ) -> int | list[int]:
275
279
  prompt_session = prompt_session or PromptSession()
276
280
  console = console or Console(color_system="truecolor")
277
281
 
@@ -280,10 +284,22 @@ async def prompt_for_index(
280
284
 
281
285
  selection = await prompt_session.prompt_async(
282
286
  message=prompt_message,
283
- validator=int_range_validator(min_index, max_index),
287
+ validator=MultiIndexValidator(
288
+ min_index,
289
+ max_index,
290
+ number_selections,
291
+ separator,
292
+ allow_duplicates,
293
+ cancel_key,
294
+ ),
284
295
  default=default_selection,
285
296
  )
286
- return int(selection)
297
+
298
+ if selection.strip() == cancel_key:
299
+ return int(cancel_key)
300
+ if isinstance(number_selections, int) and number_selections == 1:
301
+ return int(selection.strip())
302
+ return [int(index.strip()) for index in selection.strip().split(separator)]
287
303
 
288
304
 
289
305
  async def prompt_for_selection(
@@ -295,7 +311,11 @@ async def prompt_for_selection(
295
311
  prompt_session: PromptSession | None = None,
296
312
  prompt_message: str = "Select an option > ",
297
313
  show_table: bool = True,
298
- ) -> str:
314
+ number_selections: int | str = 1,
315
+ separator: str = ",",
316
+ allow_duplicates: bool = False,
317
+ cancel_key: str = "",
318
+ ) -> str | list[str]:
299
319
  """Prompt the user to select a key from a set of options. Return the selected key."""
300
320
  prompt_session = prompt_session or PromptSession()
301
321
  console = console or Console(color_system="truecolor")
@@ -305,11 +325,17 @@ async def prompt_for_selection(
305
325
 
306
326
  selected = await prompt_session.prompt_async(
307
327
  message=prompt_message,
308
- validator=key_validator(keys),
328
+ validator=MultiKeyValidator(
329
+ keys, number_selections, separator, allow_duplicates, cancel_key
330
+ ),
309
331
  default=default_selection,
310
332
  )
311
333
 
312
- return selected
334
+ if selected.strip() == cancel_key:
335
+ return cancel_key
336
+ if isinstance(number_selections, int) and number_selections == 1:
337
+ return selected.strip()
338
+ return [key.strip() for key in selected.strip().split(separator)]
313
339
 
314
340
 
315
341
  async def select_value_from_list(
@@ -320,6 +346,10 @@ async def select_value_from_list(
320
346
  prompt_session: PromptSession | None = None,
321
347
  prompt_message: str = "Select an option > ",
322
348
  default_selection: str = "",
349
+ number_selections: int | str = 1,
350
+ separator: str = ",",
351
+ allow_duplicates: bool = False,
352
+ cancel_key: str = "",
323
353
  columns: int = 4,
324
354
  caption: str = "",
325
355
  box_style: box.Box = box.SIMPLE,
@@ -332,7 +362,7 @@ async def select_value_from_list(
332
362
  title_style: str = "",
333
363
  caption_style: str = "",
334
364
  highlight: bool = False,
335
- ):
365
+ ) -> str | list[str]:
336
366
  """Prompt for a selection. Return the selected item."""
337
367
  table = render_selection_indexed_table(
338
368
  title=title,
@@ -360,8 +390,14 @@ async def select_value_from_list(
360
390
  console=console,
361
391
  prompt_session=prompt_session,
362
392
  prompt_message=prompt_message,
393
+ number_selections=number_selections,
394
+ separator=separator,
395
+ allow_duplicates=allow_duplicates,
396
+ cancel_key=cancel_key,
363
397
  )
364
398
 
399
+ if isinstance(selection_index, list):
400
+ return [selections[i] for i in selection_index]
365
401
  return selections[selection_index]
366
402
 
367
403
 
@@ -373,7 +409,11 @@ async def select_key_from_dict(
373
409
  prompt_session: PromptSession | None = None,
374
410
  prompt_message: str = "Select an option > ",
375
411
  default_selection: str = "",
376
- ) -> Any:
412
+ number_selections: int | str = 1,
413
+ separator: str = ",",
414
+ allow_duplicates: bool = False,
415
+ cancel_key: str = "",
416
+ ) -> str | list[str]:
377
417
  """Prompt for a key from a dict, returns the key."""
378
418
  prompt_session = prompt_session or PromptSession()
379
419
  console = console or Console(color_system="truecolor")
@@ -387,6 +427,10 @@ async def select_key_from_dict(
387
427
  console=console,
388
428
  prompt_session=prompt_session,
389
429
  prompt_message=prompt_message,
430
+ number_selections=number_selections,
431
+ separator=separator,
432
+ allow_duplicates=allow_duplicates,
433
+ cancel_key=cancel_key,
390
434
  )
391
435
 
392
436
 
@@ -398,7 +442,11 @@ async def select_value_from_dict(
398
442
  prompt_session: PromptSession | None = None,
399
443
  prompt_message: str = "Select an option > ",
400
444
  default_selection: str = "",
401
- ) -> Any:
445
+ number_selections: int | str = 1,
446
+ separator: str = ",",
447
+ allow_duplicates: bool = False,
448
+ cancel_key: str = "",
449
+ ) -> Any | list[Any]:
402
450
  """Prompt for a key from a dict, but return the value."""
403
451
  prompt_session = prompt_session or PromptSession()
404
452
  console = console or Console(color_system="truecolor")
@@ -412,8 +460,14 @@ async def select_value_from_dict(
412
460
  console=console,
413
461
  prompt_session=prompt_session,
414
462
  prompt_message=prompt_message,
463
+ number_selections=number_selections,
464
+ separator=separator,
465
+ allow_duplicates=allow_duplicates,
466
+ cancel_key=cancel_key,
415
467
  )
416
468
 
469
+ if isinstance(selection_key, list):
470
+ return [selections[key].value for key in selection_key]
417
471
  return selections[selection_key].value
418
472
 
419
473
 
@@ -425,7 +479,11 @@ async def get_selection_from_dict_menu(
425
479
  prompt_session: PromptSession | None = None,
426
480
  prompt_message: str = "Select an option > ",
427
481
  default_selection: str = "",
428
- ):
482
+ number_selections: int | str = 1,
483
+ separator: str = ",",
484
+ allow_duplicates: bool = False,
485
+ cancel_key: str = "",
486
+ ) -> Any | list[Any]:
429
487
  """Prompt for a key from a dict, but return the value."""
430
488
  table = render_selection_dict_table(
431
489
  title,
@@ -439,4 +497,8 @@ async def get_selection_from_dict_menu(
439
497
  prompt_session=prompt_session,
440
498
  prompt_message=prompt_message,
441
499
  default_selection=default_selection,
500
+ number_selections=number_selections,
501
+ separator=separator,
502
+ allow_duplicates=allow_duplicates,
503
+ cancel_key=cancel_key,
442
504
  )
falyx/validators.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """validators.py"""
3
3
  from typing import KeysView, Sequence
4
4
 
5
- from prompt_toolkit.validation import Validator
5
+ from prompt_toolkit.validation import ValidationError, Validator
6
6
 
7
7
 
8
8
  def int_range_validator(minimum: int, maximum: int) -> Validator:
@@ -45,3 +45,91 @@ def yes_no_validator() -> Validator:
45
45
  return True
46
46
 
47
47
  return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.")
48
+
49
+
50
+ class MultiIndexValidator(Validator):
51
+ def __init__(
52
+ self,
53
+ minimum: int,
54
+ maximum: int,
55
+ number_selections: int | str,
56
+ separator: str,
57
+ allow_duplicates: bool,
58
+ cancel_key: str,
59
+ ) -> None:
60
+ self.minimum = minimum
61
+ self.maximum = maximum
62
+ self.number_selections = number_selections
63
+ self.separator = separator
64
+ self.allow_duplicates = allow_duplicates
65
+ self.cancel_key = cancel_key
66
+ super().__init__()
67
+
68
+ def validate(self, document):
69
+ selections = [
70
+ index.strip() for index in document.text.strip().split(self.separator)
71
+ ]
72
+ if not selections or selections == [""]:
73
+ raise ValidationError(message="Select at least 1 item.")
74
+ if self.cancel_key in selections and len(selections) == 1:
75
+ return
76
+ elif self.cancel_key in selections:
77
+ raise ValidationError(message="Cancel key must be selected alone.")
78
+ for selection in selections:
79
+ try:
80
+ index = int(selection)
81
+ if not self.minimum <= index <= self.maximum:
82
+ raise ValidationError(
83
+ message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
84
+ )
85
+ except ValueError:
86
+ raise ValidationError(
87
+ message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}."
88
+ )
89
+ if not self.allow_duplicates and selections.count(selection) > 1:
90
+ raise ValidationError(message=f"Duplicate selection: {selection}")
91
+ if isinstance(self.number_selections, int):
92
+ if self.number_selections == 1 and len(selections) > 1:
93
+ raise ValidationError(message="Invalid selection. Select only 1 item.")
94
+ if len(selections) != self.number_selections:
95
+ raise ValidationError(
96
+ message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
97
+ )
98
+
99
+
100
+ class MultiKeyValidator(Validator):
101
+ def __init__(
102
+ self,
103
+ keys: Sequence[str] | KeysView[str],
104
+ number_selections: int | str,
105
+ separator: str,
106
+ allow_duplicates: bool,
107
+ cancel_key: str,
108
+ ) -> None:
109
+ self.keys = keys
110
+ self.separator = separator
111
+ self.number_selections = number_selections
112
+ self.allow_duplicates = allow_duplicates
113
+ self.cancel_key = cancel_key
114
+ super().__init__()
115
+
116
+ def validate(self, document):
117
+ selections = [key.strip() for key in document.text.strip().split(self.separator)]
118
+ if not selections or selections == [""]:
119
+ raise ValidationError(message="Select at least 1 item.")
120
+ if self.cancel_key in selections and len(selections) == 1:
121
+ return
122
+ elif self.cancel_key in selections:
123
+ raise ValidationError(message="Cancel key must be selected alone.")
124
+ for selection in selections:
125
+ if selection.upper() not in [key.upper() for key in self.keys]:
126
+ raise ValidationError(message=f"Invalid selection: {selection}")
127
+ if not self.allow_duplicates and selections.count(selection) > 1:
128
+ raise ValidationError(message=f"Duplicate selection: {selection}")
129
+ if isinstance(self.number_selections, int):
130
+ if self.number_selections == 1 and len(selections) > 1:
131
+ raise ValidationError(message="Invalid selection. Select only 1 item.")
132
+ if len(selections) != self.number_selections:
133
+ raise ValidationError(
134
+ message=f"Select exactly {self.number_selections} items separated by '{self.separator}'"
135
+ )
falyx/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.50"
1
+ __version__ = "0.1.52"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.50
3
+ Version: 0.1.52
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr