falyx 0.1.61__py3-none-any.whl → 0.1.63__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.
@@ -112,7 +112,16 @@ class ActionFactory(BaseAction):
112
112
  tree = parent.add(label) if parent else Tree(label)
113
113
 
114
114
  try:
115
- generated = await self.factory(*self.preview_args, **self.preview_kwargs)
115
+ generated = None
116
+ if self.args or self.kwargs:
117
+ try:
118
+ generated = await self.factory(*self.args, **self.kwargs)
119
+ except TypeError:
120
+ ...
121
+
122
+ if not generated:
123
+ generated = await self.factory(*self.preview_args, **self.preview_kwargs)
124
+
116
125
  if isinstance(generated, BaseAction):
117
126
  await generated.preview(parent=tree)
118
127
  else:
@@ -60,6 +60,8 @@ class ActionGroup(BaseAction, ActionListMixin):
60
60
  Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable]] | None
61
61
  ) = None,
62
62
  *,
63
+ args: tuple[Any, ...] = (),
64
+ kwargs: dict[str, Any] | None = None,
63
65
  hooks: HookManager | None = None,
64
66
  inject_last_result: bool = False,
65
67
  inject_into: str = "last_result",
@@ -71,6 +73,8 @@ class ActionGroup(BaseAction, ActionListMixin):
71
73
  inject_into=inject_into,
72
74
  )
73
75
  ActionListMixin.__init__(self)
76
+ self.args = args
77
+ self.kwargs = kwargs or {}
74
78
  if actions:
75
79
  self.set_actions(actions)
76
80
 
@@ -115,13 +119,17 @@ class ActionGroup(BaseAction, ActionListMixin):
115
119
  async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
116
120
  if not self.actions:
117
121
  raise EmptyGroupError(f"[{self.name}] No actions to execute.")
122
+
123
+ combined_args = args + self.args
124
+ combined_kwargs = {**self.kwargs, **kwargs}
125
+
118
126
  shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
119
127
  if self.shared_context:
120
128
  shared_context.set_shared_result(self.shared_context.last_result())
121
- updated_kwargs = self._maybe_inject_last_result(kwargs)
129
+ updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
122
130
  context = ExecutionContext(
123
131
  name=self.name,
124
- args=args,
132
+ args=combined_args,
125
133
  kwargs=updated_kwargs,
126
134
  action=self,
127
135
  extra={"results": [], "errors": []},
@@ -131,7 +139,7 @@ class ActionGroup(BaseAction, ActionListMixin):
131
139
  async def run_one(action: BaseAction):
132
140
  try:
133
141
  prepared = action.prepare(shared_context, self.options_manager)
134
- result = await prepared(*args, **updated_kwargs)
142
+ result = await prepared(*combined_args, **updated_kwargs)
135
143
  shared_context.add_result((action.name, result))
136
144
  context.extra["results"].append((action.name, result))
137
145
  except Exception as error:
@@ -61,7 +61,9 @@ class ConfirmType(Enum):
61
61
  YES_CANCEL = "yes_cancel"
62
62
  YES_NO_CANCEL = "yes_no_cancel"
63
63
  TYPE_WORD = "type_word"
64
+ TYPE_WORD_CANCEL = "type_word_cancel"
64
65
  OK_CANCEL = "ok_cancel"
66
+ ACKNOWLEDGE = "acknowledge"
65
67
 
66
68
  @classmethod
67
69
  def choices(cls) -> list[ConfirmType]:
@@ -54,6 +54,8 @@ class ChainedAction(BaseAction, ActionListMixin):
54
54
  | None
55
55
  ) = None,
56
56
  *,
57
+ args: tuple[Any, ...] = (),
58
+ kwargs: dict[str, Any] | None = None,
57
59
  hooks: HookManager | None = None,
58
60
  inject_last_result: bool = False,
59
61
  inject_into: str = "last_result",
@@ -67,6 +69,8 @@ class ChainedAction(BaseAction, ActionListMixin):
67
69
  inject_into=inject_into,
68
70
  )
69
71
  ActionListMixin.__init__(self)
72
+ self.args = args
73
+ self.kwargs = kwargs or {}
70
74
  self.auto_inject = auto_inject
71
75
  self.return_list = return_list
72
76
  if actions:
@@ -111,13 +115,16 @@ class ChainedAction(BaseAction, ActionListMixin):
111
115
  if not self.actions:
112
116
  raise EmptyChainError(f"[{self.name}] No actions to execute.")
113
117
 
118
+ combined_args = args + self.args
119
+ combined_kwargs = {**self.kwargs, **kwargs}
120
+
114
121
  shared_context = SharedContext(name=self.name, action=self)
115
122
  if self.shared_context:
116
123
  shared_context.add_result(self.shared_context.last_result())
117
- updated_kwargs = self._maybe_inject_last_result(kwargs)
124
+ updated_kwargs = self._maybe_inject_last_result(combined_kwargs)
118
125
  context = ExecutionContext(
119
126
  name=self.name,
120
- args=args,
127
+ args=combined_args,
121
128
  kwargs=updated_kwargs,
122
129
  action=self,
123
130
  extra={"results": [], "rollback_stack": []},
@@ -136,7 +143,7 @@ class ChainedAction(BaseAction, ActionListMixin):
136
143
  shared_context.current_index = index
137
144
  prepared = action.prepare(shared_context, self.options_manager)
138
145
  try:
139
- result = await prepared(*args, **updated_kwargs)
146
+ result = await prepared(*combined_args, **updated_kwargs)
140
147
  except Exception as error:
141
148
  if index + 1 < len(self.actions) and isinstance(
142
149
  self.actions[index + 1], FallbackAction
@@ -155,10 +162,12 @@ class ChainedAction(BaseAction, ActionListMixin):
155
162
  fallback._skip_in_chain = True
156
163
  else:
157
164
  raise
158
- args, updated_kwargs = self._clear_args()
159
165
  shared_context.add_result(result)
160
166
  context.extra["results"].append(result)
161
- context.extra["rollback_stack"].append(prepared)
167
+ context.extra["rollback_stack"].append(
168
+ (prepared, combined_args, updated_kwargs)
169
+ )
170
+ combined_args, updated_kwargs = self._clear_args()
162
171
 
163
172
  all_results = context.extra["results"]
164
173
  assert (
@@ -171,11 +180,11 @@ class ChainedAction(BaseAction, ActionListMixin):
171
180
  logger.info("[%s] Chain broken: %s", self.name, error)
172
181
  context.exception = error
173
182
  shared_context.add_error(shared_context.current_index, error)
174
- await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
183
+ await self._rollback(context.extra["rollback_stack"])
175
184
  except Exception as error:
176
185
  context.exception = error
177
186
  shared_context.add_error(shared_context.current_index, error)
178
- await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
187
+ await self._rollback(context.extra["rollback_stack"])
179
188
  await self.hooks.trigger(HookType.ON_ERROR, context)
180
189
  raise
181
190
  finally:
@@ -184,7 +193,9 @@ class ChainedAction(BaseAction, ActionListMixin):
184
193
  await self.hooks.trigger(HookType.ON_TEARDOWN, context)
185
194
  er.record(context)
186
195
 
187
- async def _rollback(self, rollback_stack, *args, **kwargs):
196
+ async def _rollback(
197
+ self, rollback_stack: list[tuple[Action, tuple[Any, ...], dict[str, Any]]]
198
+ ):
188
199
  """
189
200
  Roll back all executed actions in reverse order.
190
201
 
@@ -197,12 +208,12 @@ class ChainedAction(BaseAction, ActionListMixin):
197
208
  rollback_stack (list): Actions to roll back.
198
209
  *args, **kwargs: Passed to rollback handlers.
199
210
  """
200
- for action in reversed(rollback_stack):
211
+ for action, args, kwargs in reversed(rollback_stack):
201
212
  rollback = getattr(action, "rollback", None)
202
213
  if rollback:
203
214
  try:
204
215
  logger.warning("[%s] Rolling back...", action.name)
205
- await action.rollback(*args, **kwargs)
216
+ await rollback(*args, **kwargs)
206
217
  except Exception as error:
207
218
  logger.error("[%s] Rollback failed: %s", action.name, error)
208
219
 
@@ -112,6 +112,14 @@ class ConfirmAction(BaseAction):
112
112
  validator=word_validator(self.word),
113
113
  )
114
114
  return answer.upper().strip() != "N"
115
+ case ConfirmType.TYPE_WORD_CANCEL:
116
+ answer = await self.prompt_session.prompt_async(
117
+ f"❓ {self.message} [{self.word}] to confirm or [N/n] > ",
118
+ validator=word_validator(self.word),
119
+ )
120
+ if answer.upper().strip() == "N":
121
+ raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
122
+ return answer.upper().strip() == self.word.upper().strip()
115
123
  case ConfirmType.YES_CANCEL:
116
124
  answer = await confirm_async(
117
125
  self.message,
@@ -131,6 +139,12 @@ class ConfirmAction(BaseAction):
131
139
  if answer.upper() == "C":
132
140
  raise CancelSignal(f"Action '{self.name}' was cancelled by the user.")
133
141
  return answer.upper() == "O"
142
+ case ConfirmType.ACKNOWLEDGE:
143
+ answer = await self.prompt_session.prompt_async(
144
+ f"❓ {self.message} [A]cknowledge > ",
145
+ validator=word_validator("A"),
146
+ )
147
+ return answer.upper().strip() == "A"
134
148
  case _:
135
149
  raise ValueError(f"Unknown confirm_type: {self.confirm_type}")
136
150
 
@@ -151,7 +165,7 @@ class ConfirmAction(BaseAction):
151
165
  and not should_prompt_user(confirm=True, options=self.options_manager)
152
166
  ):
153
167
  logger.debug(
154
- "Skipping confirmation for action '%s' as 'confirm' is False or options manager indicates no prompt.",
168
+ "Skipping confirmation for '%s' due to never_prompt or options_manager settings.",
155
169
  self.name,
156
170
  )
157
171
  if self.return_last_result:
@@ -189,7 +203,7 @@ class ConfirmAction(BaseAction):
189
203
  tree.add(f"[bold]Message:[/] {self.message}")
190
204
  tree.add(f"[bold]Type:[/] {self.confirm_type.value}")
191
205
  tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}")
192
- if self.confirm_type == ConfirmType.TYPE_WORD:
206
+ if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL):
193
207
  tree.add(f"[bold]Confirmation Word:[/] {self.word}")
194
208
  if parent is None:
195
209
  self.console.print(tree)
@@ -91,9 +91,7 @@ class ProcessPoolAction(BaseAction):
91
91
  f"Cannot inject last result into {self.name}: "
92
92
  f"last result is not pickleable."
93
93
  )
94
- print(kwargs)
95
94
  updated_kwargs = self._maybe_inject_last_result(kwargs)
96
- print(updated_kwargs)
97
95
  context = ExecutionContext(
98
96
  name=self.name,
99
97
  args=args,
falyx/completer.py CHANGED
@@ -29,6 +29,26 @@ class FalyxCompleter(Completer):
29
29
  yield from self._suggest_commands(tokens[0] if tokens else "")
30
30
  return
31
31
 
32
+ # Identify command
33
+ command_key = tokens[0].upper()
34
+ command = self.falyx._name_map.get(command_key)
35
+ if not command or not command.arg_parser:
36
+ return
37
+
38
+ # If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it
39
+ parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1]
40
+ stub = "" if cursor_at_end_of_token else tokens[-1]
41
+
42
+ try:
43
+ suggestions = command.arg_parser.suggest_next(
44
+ parsed_args + ([stub] if stub else [])
45
+ )
46
+ for suggestion in suggestions:
47
+ if suggestion.startswith(stub):
48
+ yield Completion(suggestion, start_position=-len(stub))
49
+ except Exception:
50
+ return
51
+
32
52
  def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
33
53
  prefix = prefix.upper()
34
54
  keys = [self.falyx.exit_command.key]
falyx/falyx.py CHANGED
@@ -507,7 +507,6 @@ class Falyx:
507
507
  message=self.prompt,
508
508
  multiline=False,
509
509
  completer=self._get_completer(),
510
- reserve_space_for_menu=1,
511
510
  validator=CommandValidator(self, self._get_validator_error_message()),
512
511
  bottom_toolbar=self._get_bottom_bar_render(),
513
512
  key_bindings=self.key_bindings,
falyx/parser/argument.py CHANGED
@@ -26,6 +26,7 @@ class Argument:
26
26
  resolver (BaseAction | None):
27
27
  An action object that resolves the argument, if applicable.
28
28
  lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
29
+ suggestions (list[str] | None): A list of suggestions for the argument.
29
30
  """
30
31
 
31
32
  flags: tuple[str, ...]
@@ -40,6 +41,7 @@ class Argument:
40
41
  positional: bool = False
41
42
  resolver: BaseAction | None = None
42
43
  lazy_resolver: bool = False
44
+ suggestions: list[str] | None = None
43
45
 
44
46
  def get_positional_text(self) -> str:
45
47
  """Get the positional text for the argument."""
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from collections import defaultdict
6
6
  from copy import deepcopy
7
- from typing import Any, Iterable
7
+ from dataclasses import dataclass
8
+ from typing import Any, Iterable, Sequence
8
9
 
9
10
  from rich.console import Console
10
11
  from rich.markup import escape
@@ -19,6 +20,12 @@ from falyx.parser.utils import coerce_value
19
20
  from falyx.signals import HelpSignal
20
21
 
21
22
 
23
+ @dataclass
24
+ class ArgumentState:
25
+ arg: Argument
26
+ consumed: bool = False
27
+
28
+
22
29
  class CommandArgumentParser:
23
30
  """
24
31
  Custom argument parser for Falyx Commands.
@@ -64,6 +71,8 @@ class CommandArgumentParser:
64
71
  self._flag_map: dict[str, Argument] = {}
65
72
  self._dest_set: set[str] = set()
66
73
  self._add_help()
74
+ self._last_positional_states: dict[str, ArgumentState] = {}
75
+ self._last_keyword_states: dict[str, ArgumentState] = {}
67
76
 
68
77
  def _add_help(self):
69
78
  """Add help argument to the parser."""
@@ -359,19 +368,19 @@ class CommandArgumentParser:
359
368
  )
360
369
 
361
370
  self._register_argument(argument)
362
- self._register_argument(negated_argument)
371
+ self._register_argument(negated_argument, bypass_validation=True)
363
372
 
364
- def _register_argument(self, argument: Argument):
373
+ def _register_argument(
374
+ self, argument: Argument, bypass_validation: bool = False
375
+ ) -> None:
365
376
 
366
377
  for flag in argument.flags:
367
- if (
368
- flag in self._flag_map
369
- and not argument.action == ArgumentAction.STORE_BOOL_OPTIONAL
370
- ):
378
+ if flag in self._flag_map and not bypass_validation:
371
379
  existing = self._flag_map[flag]
372
380
  raise CommandArgumentError(
373
381
  f"Flag '{flag}' is already used by argument '{existing.dest}'"
374
382
  )
383
+
375
384
  for flag in argument.flags:
376
385
  self._flag_map[flag] = argument
377
386
  if not argument.positional:
@@ -396,6 +405,7 @@ class CommandArgumentParser:
396
405
  dest: str | None = None,
397
406
  resolver: BaseAction | None = None,
398
407
  lazy_resolver: bool = True,
408
+ suggestions: list[str] | None = None,
399
409
  ) -> None:
400
410
  """Add an argument to the parser.
401
411
  For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
@@ -415,6 +425,8 @@ class CommandArgumentParser:
415
425
  help: A brief description of the argument.
416
426
  dest: The name of the attribute to be added to the object returned by parse_args().
417
427
  resolver: A BaseAction called with optional nargs specified parsed arguments.
428
+ lazy_resolver: If True, the resolver is called lazily when the argument is accessed.
429
+ suggestions: A list of suggestions for the argument.
418
430
  """
419
431
  expected_type = type
420
432
  self._validate_flags(flags)
@@ -445,6 +457,10 @@ class CommandArgumentParser:
445
457
  f"Default value '{default}' not in allowed choices: {choices}"
446
458
  )
447
459
  required = self._determine_required(required, positional, nargs, action)
460
+ if not isinstance(suggestions, Sequence) and suggestions is not None:
461
+ raise CommandArgumentError(
462
+ f"suggestions must be a list or None, got {type(suggestions)}"
463
+ )
448
464
  if not isinstance(lazy_resolver, bool):
449
465
  raise CommandArgumentError(
450
466
  f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
@@ -465,6 +481,7 @@ class CommandArgumentParser:
465
481
  positional=positional,
466
482
  resolver=resolver,
467
483
  lazy_resolver=lazy_resolver,
484
+ suggestions=suggestions,
468
485
  )
469
486
  self._register_argument(argument)
470
487
 
@@ -490,6 +507,27 @@ class CommandArgumentParser:
490
507
  )
491
508
  return defs
492
509
 
510
+ def raise_remaining_args_error(
511
+ self, token: str, arg_states: dict[str, ArgumentState]
512
+ ) -> None:
513
+ consumed_dests = [
514
+ state.arg.dest for state in arg_states.values() if state.consumed
515
+ ]
516
+ remaining_flags = [
517
+ flag
518
+ for flag, arg in self._keyword.items()
519
+ if arg.dest not in consumed_dests and flag.startswith(token)
520
+ ]
521
+
522
+ if remaining_flags:
523
+ raise CommandArgumentError(
524
+ f"Unrecognized option '{token}'. Did you mean one of: {', '.join(remaining_flags)}?"
525
+ )
526
+ else:
527
+ raise CommandArgumentError(
528
+ f"Unrecognized option '{token}'. Use --help to see available options."
529
+ )
530
+
493
531
  def _consume_nargs(
494
532
  self, args: list[str], start: int, spec: Argument
495
533
  ) -> tuple[list[str], int]:
@@ -535,6 +573,7 @@ class CommandArgumentParser:
535
573
  result: dict[str, Any],
536
574
  positional_args: list[Argument],
537
575
  consumed_positional_indicies: set[int],
576
+ arg_states: dict[str, ArgumentState],
538
577
  from_validate: bool = False,
539
578
  ) -> int:
540
579
  remaining_positional_args = [
@@ -580,17 +619,7 @@ class CommandArgumentParser:
580
619
  except Exception as error:
581
620
  if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"):
582
621
  token = args[i - new_i]
583
- valid_flags = [
584
- flag for flag in self._flag_map if flag.startswith(token)
585
- ]
586
- if valid_flags:
587
- raise CommandArgumentError(
588
- f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
589
- ) from error
590
- else:
591
- raise CommandArgumentError(
592
- f"Unrecognized option '{token}'. Use --help to see available options."
593
- ) from error
622
+ self.raise_remaining_args_error(token, arg_states)
594
623
  else:
595
624
  raise CommandArgumentError(
596
625
  f"Invalid value for '{spec.dest}': {error}"
@@ -606,6 +635,7 @@ class CommandArgumentParser:
606
635
  raise CommandArgumentError(
607
636
  f"[{spec.dest}] Action failed: {error}"
608
637
  ) from error
638
+ arg_states[spec.dest].consumed = True
609
639
  elif not typed and spec.default:
610
640
  result[spec.dest] = spec.default
611
641
  elif spec.action == ArgumentAction.APPEND:
@@ -618,8 +648,10 @@ class CommandArgumentParser:
618
648
  assert result.get(spec.dest) is not None, "dest should not be None"
619
649
  result[spec.dest].extend(typed)
620
650
  elif spec.nargs in (None, 1, "?"):
651
+ arg_states[spec.dest].consumed = True
621
652
  result[spec.dest] = typed[0] if len(typed) == 1 else typed
622
653
  else:
654
+ arg_states[spec.dest].consumed = True
623
655
  result[spec.dest] = typed
624
656
 
625
657
  if spec.nargs not in ("*", "+"):
@@ -628,15 +660,7 @@ class CommandArgumentParser:
628
660
  if i < len(args):
629
661
  if len(args[i:]) == 1 and args[i].startswith("-"):
630
662
  token = args[i]
631
- valid_flags = [flag for flag in self._flag_map if flag.startswith(token)]
632
- if valid_flags:
633
- raise CommandArgumentError(
634
- f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
635
- )
636
- else:
637
- raise CommandArgumentError(
638
- f"Unrecognized option '{token}'. Use --help to see available options."
639
- )
663
+ self.raise_remaining_args_error(token, arg_states)
640
664
  else:
641
665
  plural = "s" if len(args[i:]) > 1 else ""
642
666
  raise CommandArgumentError(
@@ -670,6 +694,7 @@ class CommandArgumentParser:
670
694
  positional_args: list[Argument],
671
695
  consumed_positional_indices: set[int],
672
696
  consumed_indices: set[int],
697
+ arg_states: dict[str, ArgumentState],
673
698
  from_validate: bool = False,
674
699
  ) -> int:
675
700
  if token in self._keyword:
@@ -679,6 +704,7 @@ class CommandArgumentParser:
679
704
  if action == ArgumentAction.HELP:
680
705
  if not from_validate:
681
706
  self.render_help()
707
+ arg_states[spec.dest].consumed = True
682
708
  raise HelpSignal()
683
709
  elif action == ArgumentAction.ACTION:
684
710
  assert isinstance(
@@ -691,24 +717,29 @@ class CommandArgumentParser:
691
717
  raise CommandArgumentError(
692
718
  f"Invalid value for '{spec.dest}': {error}"
693
719
  ) from error
694
- try:
695
- result[spec.dest] = await spec.resolver(*typed_values)
696
- except Exception as error:
697
- raise CommandArgumentError(
698
- f"[{spec.dest}] Action failed: {error}"
699
- ) from error
720
+ if not spec.lazy_resolver or not from_validate:
721
+ try:
722
+ result[spec.dest] = await spec.resolver(*typed_values)
723
+ except Exception as error:
724
+ raise CommandArgumentError(
725
+ f"[{spec.dest}] Action failed: {error}"
726
+ ) from error
727
+ arg_states[spec.dest].consumed = True
700
728
  consumed_indices.update(range(i, new_i))
701
729
  i = new_i
702
730
  elif action == ArgumentAction.STORE_TRUE:
703
731
  result[spec.dest] = True
732
+ arg_states[spec.dest].consumed = True
704
733
  consumed_indices.add(i)
705
734
  i += 1
706
735
  elif action == ArgumentAction.STORE_FALSE:
707
736
  result[spec.dest] = False
737
+ arg_states[spec.dest].consumed = True
708
738
  consumed_indices.add(i)
709
739
  i += 1
710
740
  elif action == ArgumentAction.STORE_BOOL_OPTIONAL:
711
741
  result[spec.dest] = spec.type(True)
742
+ arg_states[spec.dest].consumed = True
712
743
  consumed_indices.add(i)
713
744
  i += 1
714
745
  elif action == ArgumentAction.COUNT:
@@ -778,19 +809,11 @@ class CommandArgumentParser:
778
809
  )
779
810
  else:
780
811
  result[spec.dest] = typed_values
812
+ arg_states[spec.dest].consumed = True
781
813
  consumed_indices.update(range(i, new_i))
782
814
  i = new_i
783
815
  elif token.startswith("-"):
784
- # Handle unrecognized option
785
- valid_flags = [flag for flag in self._flag_map if flag.startswith(token)]
786
- if valid_flags:
787
- raise CommandArgumentError(
788
- f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
789
- )
790
- else:
791
- raise CommandArgumentError(
792
- f"Unrecognized option '{token}'. Use --help to see available options."
793
- )
816
+ self.raise_remaining_args_error(token, arg_states)
794
817
  else:
795
818
  # Get the next flagged argument index if it exists
796
819
  next_flagged_index = -1
@@ -805,6 +828,7 @@ class CommandArgumentParser:
805
828
  result,
806
829
  positional_args,
807
830
  consumed_positional_indices,
831
+ arg_states=arg_states,
808
832
  from_validate=from_validate,
809
833
  )
810
834
  i += args_consumed
@@ -817,6 +841,14 @@ class CommandArgumentParser:
817
841
  if args is None:
818
842
  args = []
819
843
 
844
+ arg_states = {arg.dest: ArgumentState(arg) for arg in self._arguments}
845
+ self._last_positional_states = {
846
+ arg.dest: arg_states[arg.dest] for arg in self._positional.values()
847
+ }
848
+ self._last_keyword_states = {
849
+ arg.dest: arg_states[arg.dest] for arg in self._keyword_list
850
+ }
851
+
820
852
  result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
821
853
  positional_args: list[Argument] = [
822
854
  arg for arg in self._arguments if arg.positional
@@ -838,6 +870,7 @@ class CommandArgumentParser:
838
870
  positional_args,
839
871
  consumed_positional_indices,
840
872
  consumed_indices,
873
+ arg_states=arg_states,
841
874
  from_validate=from_validate,
842
875
  )
843
876
 
@@ -862,6 +895,7 @@ class CommandArgumentParser:
862
895
  )
863
896
 
864
897
  if spec.choices and result.get(spec.dest) not in spec.choices:
898
+ arg_states[spec.dest].consumed = False
865
899
  raise CommandArgumentError(
866
900
  f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
867
901
  )
@@ -914,6 +948,91 @@ class CommandArgumentParser:
914
948
  kwargs_dict[arg.dest] = parsed[arg.dest]
915
949
  return tuple(args_list), kwargs_dict
916
950
 
951
+ def suggest_next(self, args: list[str]) -> list[str]:
952
+ """
953
+ Suggest the next possible flags or values given partially typed arguments.
954
+
955
+ This does NOT raise errors. It is intended for completions, not validation.
956
+
957
+ Returns:
958
+ A list of possible completions based on the current input.
959
+ """
960
+
961
+ # Case 1: Next positional argument
962
+ next_non_consumed_positional: Argument | None = None
963
+ for state in self._last_positional_states.values():
964
+ if not state.consumed:
965
+ next_non_consumed_positional = state.arg
966
+ break
967
+ if next_non_consumed_positional:
968
+ if next_non_consumed_positional.choices:
969
+ return sorted(
970
+ (str(choice) for choice in next_non_consumed_positional.choices)
971
+ )
972
+ if next_non_consumed_positional.suggestions:
973
+ return sorted(next_non_consumed_positional.suggestions)
974
+
975
+ consumed_dests = [
976
+ state.arg.dest
977
+ for state in self._last_keyword_states.values()
978
+ if state.consumed
979
+ ]
980
+
981
+ remaining_flags = [
982
+ flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests
983
+ ]
984
+
985
+ last = args[-1]
986
+ next_to_last = args[-2] if len(args) > 1 else ""
987
+ suggestions: list[str] = []
988
+
989
+ # Case 2: Mid-flag (e.g., "--ver")
990
+ if last.startswith("-") and last not in self._keyword:
991
+ if (
992
+ len(args) > 1
993
+ and next_to_last in self._keyword
994
+ and next_to_last in remaining_flags
995
+ ):
996
+ # If the last token is a mid-flag, suggest based on the previous flag
997
+ arg = self._keyword[next_to_last]
998
+ if arg.choices:
999
+ suggestions.extend(arg.choices)
1000
+ elif arg.suggestions:
1001
+ suggestions.extend(arg.suggestions)
1002
+ else:
1003
+ possible_flags = [
1004
+ flag
1005
+ for flag, arg in self._keyword.items()
1006
+ if flag.startswith(last) and arg.dest not in consumed_dests
1007
+ ]
1008
+ suggestions.extend(possible_flags)
1009
+ # Case 3: Flag that expects a value (e.g., ["--tag"])
1010
+ elif last in self._keyword:
1011
+ arg = self._keyword[last]
1012
+ if arg.choices:
1013
+ suggestions.extend(arg.choices)
1014
+ elif arg.suggestions:
1015
+ suggestions.extend(arg.suggestions)
1016
+ # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"])
1017
+ elif next_to_last in self._keyword:
1018
+ arg = self._keyword[next_to_last]
1019
+ if arg.choices and last not in arg.choices:
1020
+ suggestions.extend(arg.choices)
1021
+ elif (
1022
+ arg.suggestions
1023
+ and last not in arg.suggestions
1024
+ and not any(last.startswith(suggestion) for suggestion in arg.suggestions)
1025
+ and any(suggestion.startswith(last) for suggestion in arg.suggestions)
1026
+ ):
1027
+ suggestions.extend(arg.suggestions)
1028
+ else:
1029
+ suggestions.extend(remaining_flags)
1030
+ # Case 5: Suggest all remaining flags
1031
+ else:
1032
+ suggestions.extend(remaining_flags)
1033
+
1034
+ return sorted(set(suggestions))
1035
+
917
1036
  def get_options_text(self, plain_text=False) -> str:
918
1037
  # Options
919
1038
  # Add all keyword arguments to the options list
falyx/parser/signature.py CHANGED
@@ -54,8 +54,10 @@ def infer_args_from_func(
54
54
  if arg_type is bool:
55
55
  if param.default is False:
56
56
  action = "store_true"
57
- else:
57
+ default = None
58
+ elif param.default is True:
58
59
  action = "store_false"
60
+ default = None
59
61
 
60
62
  if arg_type is list:
61
63
  action = "append"
@@ -75,6 +77,7 @@ def infer_args_from_func(
75
77
  "action": action,
76
78
  "help": metadata.get("help", ""),
77
79
  "choices": metadata.get("choices"),
80
+ "suggestions": metadata.get("suggestions"),
78
81
  }
79
82
  )
80
83
 
falyx/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.61"
1
+ __version__ = "0.1.63"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.61
3
+ Version: 0.1.63
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -4,13 +4,13 @@ falyx/__main__.py,sha256=xHO4pB45rccixo-ougF84QJeB36ef8mEZXWVK_CJL9M,3420
4
4
  falyx/action/.pytyped,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  falyx/action/__init__.py,sha256=_DfOQkNOCTmtC8DmS4rD4h5Vv9tmf0dab6VVyvvdQWU,1464
6
6
  falyx/action/action.py,sha256=Pm9lnxKhJoI1vs-fadAJrXkcDGYcBCMwvQ81HHxd0HA,5879
7
- falyx/action/action_factory.py,sha256=WosLeaYf79e83XHAAkxKi62zi8jJEiVlzvgOC84Z7t0,4840
8
- falyx/action/action_group.py,sha256=RQdHOWCa8XRUme3S5YGJTICLozIApAlIpRUgEeFaiyw,7728
7
+ falyx/action/action_factory.py,sha256=hxSN1PVVnsZ7mYhO0CqbFjcNBPkPRCHZ9JZ0vvwly38,5103
8
+ falyx/action/action_group.py,sha256=TBovifJ-HIBTBt_uBcoGXmj3MYpG8NdH37bIB5Q0e6Y,7992
9
9
  falyx/action/action_mixins.py,sha256=oUrjbweCeateshg3tqtbQiGuV8u4GvlioIZCUr9D1m4,1244
10
- falyx/action/action_types.py,sha256=TjUdwbnWVNzp5B5pFjgwRdA-P-MiY4bwe1dRSz-Ur3s,2318
10
+ falyx/action/action_types.py,sha256=3dT3k4c4aYcuuqcxPXPYO-YLuOqwNTApRAS9alnljoA,2392
11
11
  falyx/action/base_action.py,sha256=o9Nml70-SEVTXnu9J0-VYnO-t5wZZM0o59lYDth24Po,5829
12
- falyx/action/chained_action.py,sha256=TNJz25jnzQQAIwXM6uaK8mzhq4bgRks5n7IZ2nDQM6Q,9614
13
- falyx/action/confirm_action.py,sha256=rBtkaMuMYJEXcLu5VeWA0YPO6Yvj0gJBiGDAfoAkCEI,8601
12
+ falyx/action/chained_action.py,sha256=hAFjdUUXhPBIR8AEPV4G3tJq_tjZPrxc6Xk8we2KZNw,9981
13
+ falyx/action/confirm_action.py,sha256=deyqeO_dYLdumkQD1Io2BajtZ9LkM6MLqTB_9UlEP-o,9385
14
14
  falyx/action/fallback_action.py,sha256=3FGWfoR1MIgY0ZkDNOpKu8p3JqPWzh5ON3943mfgDGs,1708
15
15
  falyx/action/http_action.py,sha256=DNeSBWh58UTFGlfFyTk2GnhS54hpLAJLC0QNbq2cYic,5799
16
16
  falyx/action/io_action.py,sha256=V888tQgAynqsVvkhICnEeE4wRs2vvdTcdlEpDSEbHqo,6128
@@ -18,7 +18,7 @@ falyx/action/literal_input_action.py,sha256=ShXXiUYKg01BMZRChlxEWlNcaLXV1B1LW-w5
18
18
  falyx/action/load_file_action.py,sha256=HcwSVWI8-4_dp3VC4iHR3ARaTpY0Y1rChMqpyqQjIGg,8091
19
19
  falyx/action/menu_action.py,sha256=UwMF3Y3v8AWXGCkVpzj_k3pCge5BlJvKhqGYXh_dNCc,5775
20
20
  falyx/action/process_action.py,sha256=nUNcJD6Ms34vmj8njWzv1R1P9xJTyJmelnyJksHcp7M,4666
21
- falyx/action/process_pool_action.py,sha256=XeL6e7vsy4OkOWGQHD0ET14CzuyJ0TL-c1W5VIgdCP8,6204
21
+ falyx/action/process_pool_action.py,sha256=bBemgzB_shJfPytjbn1n6g9ScrpqrxMCujZU-I-UFf0,6152
22
22
  falyx/action/prompt_menu_action.py,sha256=PTn6US8ql5SU7ilEMVCeoGqKTc31be3AbdCfcrZ6ujU,5034
23
23
  falyx/action/save_file_action.py,sha256=Pe_j0hZjDNsO14bykzVYM0gkWB3zmpB1cExSN01IQOI,9899
24
24
  falyx/action/select_file_action.py,sha256=PcV22_wiPeDoJLIhHRiEUmW8N3pYeqQZMVTscQKXuas,9867
@@ -28,15 +28,14 @@ falyx/action/signal_action.py,sha256=GxV-0zqYqODOQUa3-tvFTZ2AS1W1QpW6ExonxmWNWbs
28
28
  falyx/action/user_input_action.py,sha256=Up47lumscxnhORMvaft0X-NWpxTXc2GmMZMua__pGhA,3524
29
29
  falyx/bottom_bar.py,sha256=B62N3YCQF_h2Rw_hpc2_FUuLNARI-XIGbQkg-1XvaYE,7405
30
30
  falyx/command.py,sha256=QdcwLEFIaq3a4Lfot4cV3zHbVJNQxwSpShprBgLBkh8,16891
31
- falyx/completer.py,sha256=EODbakx5PFAwjNcfuUZPFuSx2Q9MXBlWRZJ2LejF6DI,1686
31
+ falyx/completer.py,sha256=tCePNM6NoVmgbDobE6HSrR34KiJ9N5GwXYL2lcdqCfk,2461
32
32
  falyx/config.py,sha256=OFEq9pFhV39o6_D7dP_QUDjqEusSQNpgomRsh5AAZYY,9621
33
33
  falyx/console.py,sha256=WIZ004R9x6DJp2g3onBQ4DOJ7iDeTOr8HqJCwRt30Rc,143
34
34
  falyx/context.py,sha256=M7iWEKto_NwI3GM-VCDPwXT0dBpFPf1Y_RvHQKodZgI,10804
35
35
  falyx/debug.py,sha256=pguI0XQcZ-7jte5YUPexAufa1oxxalYO1JgmO6GU3rI,1557
36
36
  falyx/exceptions.py,sha256=58D4BYkyJ7PlpZoNk37GsUsFThm_gIlb2Ex2XXhLklI,1099
37
37
  falyx/execution_registry.py,sha256=RLRMOEmfDElFy4tuC8L9tRyNToX7GJ4GoEBh2Iri8zo,7662
38
- falyx/falyx.py,sha256=6hKcD1RC-jTPA0WiF9JJ31QkacNJAJwqoqO1zhegwoM,49332
39
- falyx/falyx_completer.py,sha256=MsfuZXpfGwbsGG-4Zp-j-vNsNnaote-UAJkJh0s2NZI,5236
38
+ falyx/falyx.py,sha256=WajWR9MZwZAno4gnXQR-RCIS4hEqiY25Egrfu6Wz2ls,49290
40
39
  falyx/hook_manager.py,sha256=TFuHQnAncS_rk6vuw-VSx8bnAppLuHfrZCrzLwqcO9o,2979
41
40
  falyx/hooks.py,sha256=xMfQROib0BNsaQF4AXJpmCiGePoE1f1xpcdibgnVZWM,2913
42
41
  falyx/init.py,sha256=fZ8cvJ9rTGOhvAiAUmA7aPw9FsOUIuobTPW3sz47My8,3287
@@ -45,12 +44,12 @@ falyx/menu.py,sha256=9kvLZhkC8PoSQvv1NZQsPIFSDy11dXfFgqVAuDmtfsM,3752
45
44
  falyx/options_manager.py,sha256=dFAnQw543tQ6Xupvh1PwBrhiSWlSACHw8K-sHP_lUh4,2842
46
45
  falyx/parser/.pytyped,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
46
  falyx/parser/__init__.py,sha256=NbxAovKIY-duFTs6DAsdM_OzL7s3VIu19KMOmltX9ts,512
48
- falyx/parser/argument.py,sha256=MIKUj-hrdLDUK8xuW84_l9ms_t5CoNFpVDmxMZIbW-I,4105
47
+ falyx/parser/argument.py,sha256=pX1qTAtsqw2qBlu8dKRdIfViNC3Pg3QZtdUb7ZTT5oc,4226
49
48
  falyx/parser/argument_action.py,sha256=Lcpb9siYr_q2T8qU-jXVtqFb11bFPPKEGH3gurJv2NM,757
50
- falyx/parser/command_argument_parser.py,sha256=W6vfj5acUx9wiA5TDjDO3CYlzsgGHrMM86-Gs19q-Eo,41482
49
+ falyx/parser/command_argument_parser.py,sha256=n6yTIGuwproH0RC2ZJ1T4RNpr97OVs4kt8GxVB2pbcw,46092
51
50
  falyx/parser/parser_types.py,sha256=DLLuIXE8cAVLS41trfsNy-XJmtqSa1HfnJVAYIIc42w,315
52
51
  falyx/parser/parsers.py,sha256=vb-l_NNh5O9L98Lcafhz91flRLxC1BnW6U8JdeabRCw,14118
53
- falyx/parser/signature.py,sha256=fSltLEr8ctj1qpbU-OvTMnREjlb8OTG5t-guJFR7j4E,2529
52
+ falyx/parser/signature.py,sha256=yGcd_Clcoz1YmbCgmP61MR2aNU5H6X_PhdW1fW6pvKs,2673
54
53
  falyx/parser/utils.py,sha256=GlxB1WORwoJ5XUtmmAVBUPaDV2nF9Hio7TbvNJvd8oY,3006
55
54
  falyx/prompt_utils.py,sha256=qgk0bXs7mwzflqzWyFhEOTpKQ_ZtMIqGhKeg-ocwNnE,1542
56
55
  falyx/protocols.py,sha256=vd9JL-TXdLEiAQXLw2UKLd3MUMivoG7iMLo08ZggwYQ,539
@@ -63,9 +62,9 @@ falyx/themes/__init__.py,sha256=1CZhEUCin9cUk8IGYBUFkVvdHRNNJBEFXccHwpUKZCA,284
63
62
  falyx/themes/colors.py,sha256=4aaeAHJetmeNInI0Zytg4E3YqKfPFelpf04vtjSvsS8,19776
64
63
  falyx/utils.py,sha256=U45xnZFUdoFC4xiji_9S1jHS5V7MvxSDtufP8EgB0SM,6732
65
64
  falyx/validators.py,sha256=AXpMGnk1_7J7MAbbol6pkMAiSIdNHoF5pwtA2-xS6H8,6029
66
- falyx/version.py,sha256=CjOPJsj7rCFM2zpom_253GmmHGb2RQ8NuZwsEg0ZmF0,23
67
- falyx-0.1.61.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
68
- falyx-0.1.61.dist-info/METADATA,sha256=JdPjGhW2VQKmq-EPF5IfR5m7lRVx8Xlq6Yb4hb11WR4,5561
69
- falyx-0.1.61.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
70
- falyx-0.1.61.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
71
- falyx-0.1.61.dist-info/RECORD,,
65
+ falyx/version.py,sha256=cTmhGBJnpgNHayq6sOIrPTQIgyT8ZhqsvcCSuLORZYQ,23
66
+ falyx-0.1.63.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
67
+ falyx-0.1.63.dist-info/METADATA,sha256=-gRHGPkv51x3udjxmGyVcavpHi1SlOSZj_EyIUvM40s,5561
68
+ falyx-0.1.63.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
69
+ falyx-0.1.63.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
70
+ falyx-0.1.63.dist-info/RECORD,,
falyx/falyx_completer.py DELETED
@@ -1,128 +0,0 @@
1
- from collections import Counter
2
- from prompt_toolkit.completion import Completer, Completion
3
- from prompt_toolkit.document import Document
4
- from typing import Iterable, Set, Optional
5
- import shlex
6
-
7
- from falyx.command import Command
8
- from falyx.parser.command_argument_parser import CommandArgumentParser
9
- from falyx.parser.argument import Argument
10
- from falyx.parser.argument_action import ArgumentAction
11
-
12
- class FalyxCompleter(Completer):
13
- """Completer for Falyx commands and their arguments."""
14
- def __init__(self, falyx: "Falyx"):
15
- self.falyx = falyx
16
- self._used_args: Set[str] = set()
17
- self._used_args_counter: Counter = Counter()
18
-
19
- def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
20
- text = document.text_before_cursor
21
- try:
22
- tokens = shlex.split(text)
23
- cursor_at_end_of_token = document.text_before_cursor.endswith((' ', '\t'))
24
- except ValueError:
25
- return
26
-
27
- if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
28
- # Suggest command keys and aliases
29
- yield from self._suggest_commands(tokens[0] if tokens else "")
30
- return
31
-
32
- command = self._match_command(tokens[0])
33
- if not command:
34
- return
35
-
36
- if command.arg_parser is None:
37
- return
38
-
39
- self._set_used_args(tokens, command)
40
-
41
- next_arg = self._next_expected_argument(tokens, command.arg_parser)
42
-
43
- if next_arg:
44
- # Positional arguments or required flagged arguments
45
- yield from self._suggest_argument(next_arg, document)
46
- else:
47
- # Optional arguments
48
- for arg in command.arg_parser._keyword.values():
49
- if not self._arg_already_used(arg.dest):
50
- yield from self._suggest_argument(arg, document)
51
-
52
- def _set_used_args(self, tokens: list[str], command: Command) -> None:
53
- """Extracts used argument flags from the provided tokens."""
54
- if not command.arg_parser:
55
- return
56
- self._used_args.clear()
57
- self._used_args_counter.clear()
58
- for token in tokens[1:]:
59
- if token.startswith('-'):
60
- if keyword_argument := command.arg_parser._keyword.get(token):
61
- self._used_args_counter[keyword_argument.dest] += 1
62
- if isinstance(keyword_argument.nargs, int) and self._used_args_counter[keyword_argument.dest] > keyword_argument.nargs:
63
- continue
64
- elif isinstance(keyword_argument.nargs, str) and keyword_argument.nargs in ("?"):
65
- self._used_args.add(keyword_argument.dest)
66
- else:
67
- self._used_args.add(keyword_argument.dest)
68
- else:
69
- # Handle positional arguments
70
- if command.arg_parser._positional:
71
- for arg in command.arg_parser._positional.values():
72
- if arg.dest not in self._used_args:
73
- self._used_args.add(arg.dest)
74
- break
75
- print(f"Used args: {self._used_args}, Counter: {self._used_args_counter}")
76
-
77
- def _suggest_commands(self, prefix: str) -> Iterable[Completion]:
78
- prefix = prefix.upper()
79
- seen = set()
80
- for cmd in self.falyx.commands.values():
81
- for key in [cmd.key] + cmd.aliases:
82
- if key.upper().startswith(prefix) and key not in seen:
83
- yield Completion(key, start_position=-len(prefix))
84
- seen.add(key)
85
-
86
- def _match_command(self, token: str) -> Optional[Command]:
87
- token = token.lstrip("?").upper()
88
- return self.falyx._name_map.get(token)
89
-
90
- def _next_expected_argument(
91
- self, tokens: list[str], parser: CommandArgumentParser
92
- ) -> Optional[Argument]:
93
- """Determine the next expected argument based on the current tokens."""
94
- # Positional arguments first
95
- for arg in parser._positional.values():
96
- if arg.dest not in self._used_args:
97
- return arg
98
-
99
- # Then required keyword arguments
100
- for arg in parser._keyword_list:
101
- if arg.required and not self._arg_already_used(arg.dest):
102
- return arg
103
-
104
- return None
105
-
106
- def _arg_already_used(self, dest: str) -> bool:
107
- print(f"Checking if argument '{dest}' is already used: {dest in self._used_args} - Used args: {self._used_args}")
108
- return dest in self._used_args
109
-
110
- def _suggest_argument(self, arg: Argument, document: Document) -> Iterable[Completion]:
111
- if not arg.positional:
112
- for flag in arg.flags:
113
- yield Completion(flag, start_position=0)
114
-
115
- if arg.choices:
116
- for choice in arg.choices:
117
- yield Completion(
118
- choice,
119
- start_position=0,
120
- display=f"{arg.dest}={choice}"
121
- )
122
-
123
- if arg.default is not None and arg.action == ArgumentAction.STORE:
124
- yield Completion(
125
- str(arg.default),
126
- start_position=0,
127
- display=f"{arg.dest} (default: {arg.default})"
128
- )
File without changes