falyx 0.1.62__py3-none-any.whl → 0.1.64__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.
@@ -63,7 +63,7 @@ class BaseAction(ABC):
63
63
  hooks: HookManager | None = None,
64
64
  inject_last_result: bool = False,
65
65
  inject_into: str = "last_result",
66
- never_prompt: bool = False,
66
+ never_prompt: bool | None = None,
67
67
  logging_hooks: bool = False,
68
68
  ) -> None:
69
69
  self.name = name
@@ -72,7 +72,7 @@ class BaseAction(ABC):
72
72
  self.shared_context: SharedContext | None = None
73
73
  self.inject_last_result: bool = inject_last_result
74
74
  self.inject_into: str = inject_into
75
- self._never_prompt: bool = never_prompt
75
+ self._never_prompt: bool | None = never_prompt
76
76
  self._skip_in_chain: bool = False
77
77
  self.console: Console = console
78
78
  self.options_manager: OptionsManager | None = None
@@ -122,7 +122,9 @@ class BaseAction(ABC):
122
122
 
123
123
  @property
124
124
  def never_prompt(self) -> bool:
125
- return self.get_option("never_prompt", self._never_prompt)
125
+ if self._never_prompt is not None:
126
+ return self._never_prompt
127
+ return self.get_option("never_prompt", False)
126
128
 
127
129
  def prepare(
128
130
  self, shared_context: SharedContext, options_manager: OptionsManager | None = None
@@ -30,7 +30,7 @@ from falyx.themes import OneColors
30
30
 
31
31
  class SelectFileAction(BaseAction):
32
32
  """
33
- SelectFileAction allows users to select a file from a directory and return:
33
+ SelectFileAction allows users to select a file(s) from a directory and return:
34
34
  - file content (as text, JSON, CSV, etc.)
35
35
  - or the file path itself.
36
36
 
@@ -50,6 +50,9 @@ class SelectFileAction(BaseAction):
50
50
  style (str): Style for the selection options.
51
51
  suffix_filter (str | None): Restrict to certain file types.
52
52
  return_type (FileType): What to return (path, content, parsed).
53
+ number_selections (int | str): How many files to select (1, 2, '*').
54
+ separator (str): Separator for multiple selections.
55
+ allow_duplicates (bool): Allow selecting the same file multiple times.
53
56
  prompt_session (PromptSession | None): Prompt session for user input.
54
57
  """
55
58
 
@@ -217,7 +220,7 @@ class SelectFileAction(BaseAction):
217
220
  er.record(context)
218
221
 
219
222
  async def preview(self, parent: Tree | None = None):
220
- label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'"
223
+ label = f"[{OneColors.GREEN}]📁 SelectFileAction[/] '{self.name}'"
221
224
  tree = parent.add(label) if parent else Tree(label)
222
225
 
223
226
  tree.add(f"[dim]Directory:[/] {str(self.directory)}")
@@ -243,6 +246,6 @@ class SelectFileAction(BaseAction):
243
246
 
244
247
  def __str__(self) -> str:
245
248
  return (
246
- f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, "
249
+ f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, "
247
250
  f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})"
248
251
  )
@@ -25,11 +25,60 @@ from falyx.themes import OneColors
25
25
 
26
26
  class SelectionAction(BaseAction):
27
27
  """
28
- A selection action that prompts the user to select an option from a list or
29
- dictionary. The selected option is then returned as the result of the action.
28
+ A Falyx Action for interactively or programmatically selecting one or more items
29
+ from a list or dictionary of options.
30
30
 
31
- If return_key is True, the key of the selected option is returned instead of
32
- the value.
31
+ `SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]`
32
+ inputs. It renders a prompt (unless `never_prompt=True`), validates user input
33
+ or injected defaults, and returns a structured result based on the specified
34
+ `return_type`.
35
+
36
+ It is commonly used for item pickers, confirmation flows, dynamic parameterization,
37
+ or guided workflows in interactive or headless CLI pipelines.
38
+
39
+ Features:
40
+ - Supports single or multiple selections (`number_selections`)
41
+ - Dictionary mode allows rich metadata (description, value, style)
42
+ - Flexible return values: key(s), value(s), item(s), description(s), or mappings
43
+ - Fully hookable lifecycle (`before`, `on_success`, `on_error`, `after`, `on_teardown`)
44
+ - Default selection logic supports previous results (`last_result`)
45
+ - Can run in headless mode using `never_prompt` and fallback defaults
46
+
47
+ Args:
48
+ name (str): Action name for tracking and logging.
49
+ selections (list[str] | dict[str, SelectionOption] | dict[str, Any]):
50
+ The available choices. If a plain dict is passed, values are converted
51
+ into `SelectionOption` instances.
52
+ title (str): Title shown in the selection UI (default: "Select an option").
53
+ columns (int): Number of columns in the selection table.
54
+ prompt_message (str): Input prompt for the user (default: "Select > ").
55
+ default_selection (str | list[str]): Key(s) or index(es) used as fallback selection.
56
+ number_selections (int | str): Max number of choices allowed (or "*" for unlimited).
57
+ separator (str): Character used to separate multi-selections (default: ",").
58
+ allow_duplicates (bool): Whether duplicate selections are allowed.
59
+ inject_last_result (bool): If True, attempts to inject the last result as default.
60
+ inject_into (str): The keyword name for injected value (default: "last_result").
61
+ return_type (SelectionReturnType | str): The type of result to return.
62
+ prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
63
+ never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
64
+ show_table (bool): Whether to render the selection table before prompting.
65
+
66
+ Returns:
67
+ Any: The selected result(s), shaped according to `return_type`.
68
+
69
+ Raises:
70
+ CancelSignal: If the user chooses the cancel option.
71
+ ValueError: If configuration is invalid or no selection can be resolved.
72
+ TypeError: If `selections` is not a supported type.
73
+
74
+ Example:
75
+ SelectionAction(
76
+ name="PickEnv",
77
+ selections={"dev": "Development", "prod": "Production"},
78
+ return_type="key",
79
+ )
80
+
81
+ This Action supports use in both interactive menus and chained, non-interactive CLI flows.
33
82
  """
34
83
 
35
84
  def __init__(
@@ -46,7 +95,7 @@ class SelectionAction(BaseAction):
46
95
  title: str = "Select an option",
47
96
  columns: int = 5,
48
97
  prompt_message: str = "Select > ",
49
- default_selection: str = "",
98
+ default_selection: str | list[str] = "",
50
99
  number_selections: int | str = 1,
51
100
  separator: str = ",",
52
101
  allow_duplicates: bool = False,
@@ -202,37 +251,105 @@ class SelectionAction(BaseAction):
202
251
  raise ValueError(f"Unsupported return type: {self.return_type}")
203
252
  return result
204
253
 
205
- async def _run(self, *args, **kwargs) -> Any:
206
- kwargs = self._maybe_inject_last_result(kwargs)
207
- context = ExecutionContext(
208
- name=self.name,
209
- args=args,
210
- kwargs=kwargs,
211
- action=self,
212
- )
213
-
214
- effective_default = str(self.default_selection)
215
- maybe_result = str(self.last_result)
216
- if isinstance(self.selections, dict):
217
- if maybe_result in self.selections:
218
- effective_default = maybe_result
219
- elif self.inject_last_result:
254
+ async def _resolve_effective_default(self) -> str:
255
+ effective_default: str | list[str] = self.default_selection
256
+ maybe_result = self.last_result
257
+ if self.number_selections == 1:
258
+ if isinstance(effective_default, list):
259
+ effective_default = effective_default[0] if effective_default else ""
260
+ elif isinstance(maybe_result, list):
261
+ maybe_result = maybe_result[0] if maybe_result else ""
262
+ default = await self._resolve_single_default(maybe_result)
263
+ if not default:
264
+ default = await self._resolve_single_default(effective_default)
265
+ if not default and self.inject_last_result:
220
266
  logger.warning(
221
267
  "[%s] Injected last result '%s' not found in selections",
222
268
  self.name,
223
269
  maybe_result,
224
270
  )
271
+ return default
272
+
273
+ if maybe_result and isinstance(maybe_result, list):
274
+ maybe_result = [
275
+ await self._resolve_single_default(item) for item in maybe_result
276
+ ]
277
+ if (
278
+ maybe_result
279
+ and self.number_selections != "*"
280
+ and len(maybe_result) != self.number_selections
281
+ ):
282
+ raise ValueError(
283
+ f"[{self.name}] 'number_selections' is {self.number_selections}, "
284
+ f"but last_result has a different length: {len(maybe_result)}."
285
+ )
286
+ return self.separator.join(maybe_result)
287
+ elif effective_default and isinstance(effective_default, list):
288
+ effective_default = [
289
+ await self._resolve_single_default(item) for item in effective_default
290
+ ]
291
+ if (
292
+ effective_default
293
+ and self.number_selections != "*"
294
+ and len(effective_default) != self.number_selections
295
+ ):
296
+ raise ValueError(
297
+ f"[{self.name}] 'number_selections' is {self.number_selections}, "
298
+ f"but default_selection has a different length: {len(effective_default)}."
299
+ )
300
+ return self.separator.join(effective_default)
301
+ if self.inject_last_result:
302
+ logger.warning(
303
+ "[%s] Injected last result '%s' not found in selections",
304
+ self.name,
305
+ maybe_result,
306
+ )
307
+ return ""
308
+
309
+ async def _resolve_single_default(self, maybe_result: str) -> str:
310
+ effective_default = ""
311
+ if isinstance(self.selections, dict):
312
+ if str(maybe_result) in self.selections:
313
+ effective_default = str(maybe_result)
314
+ elif maybe_result in (
315
+ selection.value for selection in self.selections.values()
316
+ ):
317
+ selection = [
318
+ key
319
+ for key, sel in self.selections.items()
320
+ if sel.value == maybe_result
321
+ ]
322
+ if selection:
323
+ effective_default = selection[0]
324
+ elif maybe_result in (
325
+ selection.description for selection in self.selections.values()
326
+ ):
327
+ selection = [
328
+ key
329
+ for key, sel in self.selections.items()
330
+ if sel.description == maybe_result
331
+ ]
332
+ if selection:
333
+ effective_default = selection[0]
225
334
  elif isinstance(self.selections, list):
226
- if maybe_result.isdigit() and int(maybe_result) in range(
335
+ if str(maybe_result).isdigit() and int(maybe_result) in range(
227
336
  len(self.selections)
228
337
  ):
229
338
  effective_default = maybe_result
230
- elif self.inject_last_result:
231
- logger.warning(
232
- "[%s] Injected last result '%s' not found in selections",
233
- self.name,
234
- maybe_result,
235
- )
339
+ elif maybe_result in self.selections:
340
+ effective_default = str(self.selections.index(maybe_result))
341
+ return effective_default
342
+
343
+ async def _run(self, *args, **kwargs) -> Any:
344
+ kwargs = self._maybe_inject_last_result(kwargs)
345
+ context = ExecutionContext(
346
+ name=self.name,
347
+ args=args,
348
+ kwargs=kwargs,
349
+ action=self,
350
+ )
351
+
352
+ effective_default = await self._resolve_effective_default()
236
353
 
237
354
  if self.never_prompt and not effective_default:
238
355
  raise ValueError(
@@ -251,6 +368,9 @@ class SelectionAction(BaseAction):
251
368
  columns=self.columns,
252
369
  formatter=self.cancel_formatter,
253
370
  )
371
+ if effective_default is None or isinstance(effective_default, int):
372
+ effective_default = ""
373
+
254
374
  if not self.never_prompt:
255
375
  indices: int | list[int] = await prompt_for_index(
256
376
  len(self.selections),
@@ -265,8 +385,13 @@ class SelectionAction(BaseAction):
265
385
  cancel_key=self.cancel_key,
266
386
  )
267
387
  else:
268
- if effective_default:
388
+ if effective_default and self.number_selections == 1:
269
389
  indices = int(effective_default)
390
+ elif effective_default:
391
+ indices = [
392
+ int(index)
393
+ for index in effective_default.split(self.separator)
394
+ ]
270
395
  else:
271
396
  raise ValueError(
272
397
  f"[{self.name}] 'never_prompt' is True but no valid "
@@ -308,7 +433,15 @@ class SelectionAction(BaseAction):
308
433
  cancel_key=self.cancel_key,
309
434
  )
310
435
  else:
311
- keys = effective_default
436
+ if effective_default and self.number_selections == 1:
437
+ keys = effective_default
438
+ elif effective_default:
439
+ keys = effective_default.split(self.separator)
440
+ else:
441
+ raise ValueError(
442
+ f"[{self.name}] 'never_prompt' is True but no valid "
443
+ "default_selection was provided."
444
+ )
312
445
  if keys == self.cancel_key:
313
446
  raise CancelSignal("User cancelled the selection.")
314
447
 
@@ -337,13 +470,13 @@ class SelectionAction(BaseAction):
337
470
 
338
471
  if isinstance(self.selections, list):
339
472
  sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
340
- for i, item in enumerate(self.selections[:10]): # limit to 10
473
+ for i, item in enumerate(self.selections[:10]):
341
474
  sub.add(f"[dim]{i}[/]: {item}")
342
475
  if len(self.selections) > 10:
343
476
  sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
344
477
  elif isinstance(self.selections, dict):
345
478
  sub = tree.add(
346
- f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)"
479
+ f"[dim]Type:[/] Dict[str, SelectionOption] ({len(self.selections)} items)"
347
480
  )
348
481
  for i, (key, option) in enumerate(list(self.selections.items())[:10]):
349
482
  sub.add(f"[dim]{key}[/]: {option.description}")
@@ -353,9 +486,30 @@ class SelectionAction(BaseAction):
353
486
  tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
354
487
  return
355
488
 
356
- tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
357
- tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
489
+ default = self.default_selection or self.last_result
490
+ if isinstance(default, list):
491
+ default_display = self.separator.join(str(d) for d in default)
492
+ else:
493
+ default_display = str(default or "")
494
+
495
+ tree.add(f"[dim]Default:[/] '{default_display}'")
496
+
497
+ return_behavior = {
498
+ "KEY": "selected key(s)",
499
+ "VALUE": "mapped value(s)",
500
+ "DESCRIPTION": "description(s)",
501
+ "ITEMS": "SelectionOption object(s)",
502
+ "DESCRIPTION_VALUE": "{description: value}",
503
+ }.get(self.return_type.name, self.return_type.name)
504
+
505
+ tree.add(
506
+ f"[dim]Return:[/] {self.return_type.name.capitalize()} → {return_behavior}"
507
+ )
358
508
  tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
509
+ tree.add(f"[dim]Columns:[/] {self.columns}")
510
+ tree.add(
511
+ f"[dim]Multi-select:[/] {'Yes' if self.number_selections != 1 else 'No'}"
512
+ )
359
513
 
360
514
  if not parent:
361
515
  self.console.print(tree)
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]
@@ -75,7 +75,7 @@ class ExecutionRegistry:
75
75
  _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
76
76
  _store_by_index: dict[int, ExecutionContext] = {}
77
77
  _store_all: list[ExecutionContext] = []
78
- _console = Console(color_system="truecolor")
78
+ _console: Console = console
79
79
  _index = 0
80
80
  _lock = Lock()
81
81
 
@@ -205,8 +205,8 @@ class ExecutionRegistry:
205
205
  elif status.lower() in ["all", "success"]:
206
206
  final_status = f"[{OneColors.GREEN}]✅ Success"
207
207
  final_result = repr(ctx.result)
208
- if len(final_result) > 1000:
209
- final_result = f"{final_result[:1000]}..."
208
+ if len(final_result) > 50:
209
+ final_result = f"{final_result[:50]}..."
210
210
  else:
211
211
  continue
212
212
 
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.62"
1
+ __version__ = "0.1.64"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.62
3
+ Version: 0.1.64
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -8,7 +8,7 @@ falyx/action/action_factory.py,sha256=hxSN1PVVnsZ7mYhO0CqbFjcNBPkPRCHZ9JZ0vvwly3
8
8
  falyx/action/action_group.py,sha256=TBovifJ-HIBTBt_uBcoGXmj3MYpG8NdH37bIB5Q0e6Y,7992
9
9
  falyx/action/action_mixins.py,sha256=oUrjbweCeateshg3tqtbQiGuV8u4GvlioIZCUr9D1m4,1244
10
10
  falyx/action/action_types.py,sha256=3dT3k4c4aYcuuqcxPXPYO-YLuOqwNTApRAS9alnljoA,2392
11
- falyx/action/base_action.py,sha256=o9Nml70-SEVTXnu9J0-VYnO-t5wZZM0o59lYDth24Po,5829
11
+ falyx/action/base_action.py,sha256=lSmGB7ibrxm8QHlsnvAG_ESNb5y-HTtoO_9--5j6jAI,5910
12
12
  falyx/action/chained_action.py,sha256=hAFjdUUXhPBIR8AEPV4G3tJq_tjZPrxc6Xk8we2KZNw,9981
13
13
  falyx/action/confirm_action.py,sha256=deyqeO_dYLdumkQD1Io2BajtZ9LkM6MLqTB_9UlEP-o,9385
14
14
  falyx/action/fallback_action.py,sha256=3FGWfoR1MIgY0ZkDNOpKu8p3JqPWzh5ON3943mfgDGs,1708
@@ -21,22 +21,21 @@ falyx/action/process_action.py,sha256=nUNcJD6Ms34vmj8njWzv1R1P9xJTyJmelnyJksHcp7
21
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
- falyx/action/select_file_action.py,sha256=PcV22_wiPeDoJLIhHRiEUmW8N3pYeqQZMVTscQKXuas,9867
25
- falyx/action/selection_action.py,sha256=L1evMm7oAQFGMviZ8nMwFKhWKWe8X7wW6dJPHGxpqAE,15398
24
+ falyx/action/select_file_action.py,sha256=CFHZ2PyX5YwN3R98KbBZE6KLUh2VgRGXmso5th7Gu_w,10084
25
+ falyx/action/selection_action.py,sha256=jj62poMOIhCAQg7r8qjwqi_Ww_UTUl27n9u4WLSeWaQ,22707
26
26
  falyx/action/shell_action.py,sha256=0A_kvZLsYmeLHInMM_4Jpe8GCSnXzGBm7H9PnXPvbAs,4055
27
27
  falyx/action/signal_action.py,sha256=GxV-0zqYqODOQUa3-tvFTZ2AS1W1QpW6ExonxmWNWbs,1351
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
- 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
37
+ falyx/execution_registry.py,sha256=al9x_MZN6A5aobXdNaW3HA4BjTSeRUdf9v9wYpAdtd8,7641
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=cwSKWX9cG1qs0I6C99TSkty5QpTa10uiqSeiXnsoOg0,23
67
- falyx-0.1.62.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
68
- falyx-0.1.62.dist-info/METADATA,sha256=iCnf7bzp09iDj8wsg4Lif4ef4yazOELApWRN_ZNZZqM,5561
69
- falyx-0.1.62.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
70
- falyx-0.1.62.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
71
- falyx-0.1.62.dist-info/RECORD,,
65
+ falyx/version.py,sha256=Ame8JILTBxkJ8UkH5av1b7AlkJlkfWE0hUqkp9JkUZs,23
66
+ falyx-0.1.64.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
67
+ falyx-0.1.64.dist-info/METADATA,sha256=l6esA3CQF8YlMSXqZzoJFhUw4VhX1Hw3dtGyeq2yXk4,5561
68
+ falyx-0.1.64.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
69
+ falyx-0.1.64.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
70
+ falyx-0.1.64.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