falyx 0.1.29__py3-none-any.whl → 0.1.31__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.
falyx/command.py CHANGED
@@ -19,7 +19,6 @@ in building robust interactive menus.
19
19
  from __future__ import annotations
20
20
 
21
21
  import shlex
22
- from functools import cached_property
23
22
  from typing import Any, Callable
24
23
 
25
24
  from prompt_toolkit.formatted_text import FormattedText
@@ -27,25 +26,15 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
27
26
  from rich.console import Console
28
27
  from rich.tree import Tree
29
28
 
30
- from falyx.action.action import (
31
- Action,
32
- ActionGroup,
33
- BaseAction,
34
- ChainedAction,
35
- ProcessAction,
36
- )
37
- from falyx.action.io_action import BaseIOAction
29
+ from falyx.action.action import Action, BaseAction
38
30
  from falyx.context import ExecutionContext
39
31
  from falyx.debug import register_debug_hooks
40
32
  from falyx.execution_registry import ExecutionRegistry as er
41
33
  from falyx.hook_manager import HookManager, HookType
42
34
  from falyx.logger import logger
43
35
  from falyx.options_manager import OptionsManager
44
- from falyx.parsers import (
45
- CommandArgumentParser,
46
- infer_args_from_func,
47
- same_argument_definitions,
48
- )
36
+ from falyx.parsers.argparse import CommandArgumentParser
37
+ from falyx.parsers.signature import infer_args_from_func
49
38
  from falyx.prompt_utils import confirm_async, should_prompt_user
50
39
  from falyx.protocols import ArgParserProtocol
51
40
  from falyx.retry import RetryPolicy
@@ -99,7 +88,6 @@ class Command(BaseModel):
99
88
  retry_policy (RetryPolicy): Retry behavior configuration.
100
89
  tags (list[str]): Organizational tags for the command.
101
90
  logging_hooks (bool): Whether to attach logging hooks automatically.
102
- requires_input (bool | None): Indicates if the action needs input.
103
91
  options_manager (OptionsManager): Manages global command-line options.
104
92
  arg_parser (CommandArgumentParser): Parses command arguments.
105
93
  custom_parser (ArgParserProtocol | None): Custom argument parser.
@@ -116,7 +104,7 @@ class Command(BaseModel):
116
104
 
117
105
  key: str
118
106
  description: str
119
- action: BaseAction | Callable[[Any], Any]
107
+ action: BaseAction | Callable[..., Any]
120
108
  args: tuple = ()
121
109
  kwargs: dict[str, Any] = Field(default_factory=dict)
122
110
  hidden: bool = False
@@ -138,14 +126,13 @@ class Command(BaseModel):
138
126
  retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
139
127
  tags: list[str] = Field(default_factory=list)
140
128
  logging_hooks: bool = False
141
- requires_input: bool | None = None
142
129
  options_manager: OptionsManager = Field(default_factory=OptionsManager)
143
130
  arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
144
131
  arguments: list[dict[str, Any]] = Field(default_factory=list)
145
132
  argument_config: Callable[[CommandArgumentParser], None] | None = None
146
133
  custom_parser: ArgParserProtocol | None = None
147
134
  custom_help: Callable[[], str | None] | None = None
148
- auto_args: bool = False
135
+ auto_args: bool = True
149
136
  arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
150
137
 
151
138
  _context: ExecutionContext | None = PrivateAttr(default=None)
@@ -155,7 +142,7 @@ class Command(BaseModel):
155
142
  def parse_args(
156
143
  self, raw_args: list[str] | str, from_validate: bool = False
157
144
  ) -> tuple[tuple, dict]:
158
- if self.custom_parser:
145
+ if callable(self.custom_parser):
159
146
  if isinstance(raw_args, str):
160
147
  try:
161
148
  raw_args = shlex.split(raw_args)
@@ -192,28 +179,15 @@ class Command(BaseModel):
192
179
  def get_argument_definitions(self) -> list[dict[str, Any]]:
193
180
  if self.arguments:
194
181
  return self.arguments
195
- elif self.argument_config:
182
+ elif callable(self.argument_config):
196
183
  self.argument_config(self.arg_parser)
197
184
  elif self.auto_args:
198
- if isinstance(self.action, (Action, ProcessAction)):
199
- return infer_args_from_func(self.action.action, self.arg_metadata)
200
- elif isinstance(self.action, ChainedAction):
201
- if self.action.actions:
202
- action = self.action.actions[0]
203
- if isinstance(action, Action):
204
- return infer_args_from_func(action.action, self.arg_metadata)
205
- elif callable(action):
206
- return infer_args_from_func(action, self.arg_metadata)
207
- elif isinstance(self.action, ActionGroup):
208
- arg_defs = same_argument_definitions(
209
- self.action.actions, self.arg_metadata
210
- )
211
- if arg_defs:
212
- return arg_defs
213
- logger.debug(
214
- "[Command:%s] auto_args disabled: mismatched ActionGroup arguments",
215
- self.key,
216
- )
185
+ if isinstance(self.action, BaseAction):
186
+ infer_target, maybe_metadata = self.action.get_infer_target()
187
+ # merge metadata with the action's metadata if not already in self.arg_metadata
188
+ if maybe_metadata:
189
+ self.arg_metadata = {**maybe_metadata, **self.arg_metadata}
190
+ return infer_args_from_func(infer_target, self.arg_metadata)
217
191
  elif callable(self.action):
218
192
  return infer_args_from_func(self.action, self.arg_metadata)
219
193
  return []
@@ -241,30 +215,9 @@ class Command(BaseModel):
241
215
  if self.logging_hooks and isinstance(self.action, BaseAction):
242
216
  register_debug_hooks(self.action.hooks)
243
217
 
244
- if self.requires_input is None and self.detect_requires_input:
245
- self.requires_input = True
246
- self.hidden = True
247
- elif self.requires_input is None:
248
- self.requires_input = False
249
-
250
218
  for arg_def in self.get_argument_definitions():
251
219
  self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
252
220
 
253
- @cached_property
254
- def detect_requires_input(self) -> bool:
255
- """Detect if the action requires input based on its type."""
256
- if isinstance(self.action, BaseIOAction):
257
- return True
258
- elif isinstance(self.action, ChainedAction):
259
- return (
260
- isinstance(self.action.actions[0], BaseIOAction)
261
- if self.action.actions
262
- else False
263
- )
264
- elif isinstance(self.action, ActionGroup):
265
- return any(isinstance(action, BaseIOAction) for action in self.action.actions)
266
- return False
267
-
268
221
  def _inject_options_manager(self) -> None:
269
222
  """Inject the options manager into the action if applicable."""
270
223
  if isinstance(self.action, BaseAction):
@@ -357,7 +310,7 @@ class Command(BaseModel):
357
310
 
358
311
  def show_help(self) -> bool:
359
312
  """Display the help message for the command."""
360
- if self.custom_help:
313
+ if callable(self.custom_help):
361
314
  output = self.custom_help()
362
315
  if output:
363
316
  console.print(output)
falyx/config.py CHANGED
@@ -98,7 +98,6 @@ class RawCommand(BaseModel):
98
98
  retry: bool = False
99
99
  retry_all: bool = False
100
100
  retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
101
- requires_input: bool | None = None
102
101
  hidden: bool = False
103
102
  help_text: str = ""
104
103
 
falyx/falyx.py CHANGED
@@ -61,9 +61,9 @@ from falyx.options_manager import OptionsManager
61
61
  from falyx.parsers import CommandArgumentParser, get_arg_parsers
62
62
  from falyx.protocols import ArgParserProtocol
63
63
  from falyx.retry import RetryPolicy
64
- from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
64
+ from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
65
65
  from falyx.themes import OneColors, get_nord_theme
66
- from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation
66
+ from falyx.utils import CaseInsensitiveDict, _noop, chunks
67
67
  from falyx.version import __version__
68
68
 
69
69
 
@@ -90,7 +90,7 @@ class CommandValidator(Validator):
90
90
  if not choice:
91
91
  raise ValidationError(
92
92
  message=self.error_message,
93
- cursor_position=document.get_end_of_document_position(),
93
+ cursor_position=len(text),
94
94
  )
95
95
 
96
96
 
@@ -111,6 +111,8 @@ class Falyx:
111
111
  - Submenu nesting and action chaining
112
112
  - History tracking, help generation, and run key execution modes
113
113
  - Seamless CLI argument parsing and integration via argparse
114
+ - Declarative option management with OptionsManager
115
+ - Command level argument parsing and validation
114
116
  - Extensible with user-defined hooks, bottom bars, and custom layouts
115
117
 
116
118
  Args:
@@ -126,7 +128,7 @@ class Falyx:
126
128
  never_prompt (bool): Seed default for `OptionsManager["never_prompt"]`
127
129
  force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
128
130
  cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
129
- options (OptionsManager | None): Declarative option mappings.
131
+ options (OptionsManager | None): Declarative option mappings for global state.
130
132
  custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
131
133
  generator.
132
134
 
@@ -158,8 +160,9 @@ class Falyx:
158
160
  force_confirm: bool = False,
159
161
  cli_args: Namespace | None = None,
160
162
  options: OptionsManager | None = None,
161
- render_menu: Callable[["Falyx"], None] | None = None,
162
- custom_table: Callable[["Falyx"], Table] | Table | None = None,
163
+ render_menu: Callable[[Falyx], None] | None = None,
164
+ custom_table: Callable[[Falyx], Table] | Table | None = None,
165
+ hide_menu_table: bool = False,
163
166
  ) -> None:
164
167
  """Initializes the Falyx object."""
165
168
  self.title: str | Markdown = title
@@ -183,8 +186,9 @@ class Falyx:
183
186
  self._never_prompt: bool = never_prompt
184
187
  self._force_confirm: bool = force_confirm
185
188
  self.cli_args: Namespace | None = cli_args
186
- self.render_menu: Callable[["Falyx"], None] | None = render_menu
187
- self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
189
+ self.render_menu: Callable[[Falyx], None] | None = render_menu
190
+ self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
191
+ self.hide_menu_table: bool = hide_menu_table
188
192
  self.validate_options(cli_args, options)
189
193
  self._prompt_session: PromptSession | None = None
190
194
  self.mode = FalyxMode.MENU
@@ -287,8 +291,6 @@ class Falyx:
287
291
 
288
292
  for command in self.commands.values():
289
293
  help_text = command.help_text or command.description
290
- if command.requires_input:
291
- help_text += " [dim](requires input)[/dim]"
292
294
  table.add_row(
293
295
  f"[{command.style}]{command.key}[/]",
294
296
  ", ".join(command.aliases) if command.aliases else "",
@@ -445,7 +447,6 @@ class Falyx:
445
447
  bottom_toolbar=self._get_bottom_bar_render(),
446
448
  key_bindings=self.key_bindings,
447
449
  validate_while_typing=False,
448
- interrupt_exception=FlowSignal,
449
450
  )
450
451
  return self._prompt_session
451
452
 
@@ -526,7 +527,7 @@ class Falyx:
526
527
  key: str = "X",
527
528
  description: str = "Exit",
528
529
  aliases: list[str] | None = None,
529
- action: Callable[[Any], Any] | None = None,
530
+ action: Callable[..., Any] | None = None,
530
531
  style: str = OneColors.DARK_RED,
531
532
  confirm: bool = False,
532
533
  confirm_message: str = "Are you sure?",
@@ -580,7 +581,7 @@ class Falyx:
580
581
  self,
581
582
  key: str,
582
583
  description: str,
583
- action: BaseAction | Callable[[Any], Any],
584
+ action: BaseAction | Callable[..., Any],
584
585
  *,
585
586
  args: tuple = (),
586
587
  kwargs: dict[str, Any] | None = None,
@@ -608,13 +609,12 @@ class Falyx:
608
609
  retry: bool = False,
609
610
  retry_all: bool = False,
610
611
  retry_policy: RetryPolicy | None = None,
611
- requires_input: bool | None = None,
612
612
  arg_parser: CommandArgumentParser | None = None,
613
613
  arguments: list[dict[str, Any]] | None = None,
614
614
  argument_config: Callable[[CommandArgumentParser], None] | None = None,
615
615
  custom_parser: ArgParserProtocol | None = None,
616
616
  custom_help: Callable[[], str | None] | None = None,
617
- auto_args: bool = False,
617
+ auto_args: bool = True,
618
618
  arg_metadata: dict[str, str | dict[str, Any]] | None = None,
619
619
  ) -> Command:
620
620
  """Adds an command to the menu, preventing duplicates."""
@@ -660,7 +660,6 @@ class Falyx:
660
660
  retry=retry,
661
661
  retry_all=retry_all,
662
662
  retry_policy=retry_policy or RetryPolicy(),
663
- requires_input=requires_input,
664
663
  options_manager=self.options,
665
664
  arg_parser=arg_parser,
666
665
  arguments=arguments or [],
@@ -768,26 +767,27 @@ class Falyx:
768
767
 
769
768
  choice = choice.upper()
770
769
  name_map = self._name_map
771
- if choice in name_map:
770
+ if name_map.get(choice):
772
771
  if not from_validate:
773
772
  logger.info("Command '%s' selected.", choice)
774
- if input_args and name_map[choice].arg_parser:
775
- try:
776
- args, kwargs = name_map[choice].parse_args(input_args, from_validate)
777
- except CommandArgumentError as error:
778
- if not from_validate:
779
- if not name_map[choice].show_help():
780
- self.console.print(
781
- f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
782
- )
783
- else:
784
- name_map[choice].show_help()
785
- raise ValidationError(
786
- message=str(error), cursor_position=len(raw_choices)
773
+ if is_preview:
774
+ return True, name_map[choice], args, kwargs
775
+ try:
776
+ args, kwargs = name_map[choice].parse_args(input_args, from_validate)
777
+ except CommandArgumentError as error:
778
+ if not from_validate:
779
+ if not name_map[choice].show_help():
780
+ self.console.print(
781
+ f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
787
782
  )
788
- return is_preview, None, args, kwargs
789
- except HelpSignal:
790
- return True, None, args, kwargs
783
+ else:
784
+ name_map[choice].show_help()
785
+ raise ValidationError(
786
+ message=str(error), cursor_position=len(raw_choices)
787
+ )
788
+ return is_preview, None, args, kwargs
789
+ except HelpSignal:
790
+ return True, None, args, kwargs
791
791
  return is_preview, name_map[choice], args, kwargs
792
792
 
793
793
  prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
@@ -844,15 +844,6 @@ class Falyx:
844
844
  await selected_command.preview()
845
845
  return True
846
846
 
847
- if selected_command.requires_input:
848
- program = get_program_invocation()
849
- self.console.print(
850
- f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
851
- f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
852
- f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
853
- )
854
- return True
855
-
856
847
  self.last_run_command = selected_command
857
848
 
858
849
  if selected_command == self.exit_command:
@@ -984,10 +975,11 @@ class Falyx:
984
975
  self.print_message(self.welcome_message)
985
976
  try:
986
977
  while True:
987
- if callable(self.render_menu):
988
- self.render_menu(self)
989
- else:
990
- self.console.print(self.table, justify="center")
978
+ if not self.hide_menu_table:
979
+ if callable(self.render_menu):
980
+ self.render_menu(self)
981
+ else:
982
+ self.console.print(self.table, justify="center")
991
983
  try:
992
984
  task = asyncio.create_task(self.process_command())
993
985
  should_continue = await task
falyx/hook_manager.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import inspect
6
6
  from enum import Enum
7
- from typing import Awaitable, Callable, Dict, List, Optional, Union
7
+ from typing import Awaitable, Callable, Union
8
8
 
9
9
  from falyx.context import ExecutionContext
10
10
  from falyx.logger import logger
@@ -24,7 +24,7 @@ class HookType(Enum):
24
24
  ON_TEARDOWN = "on_teardown"
25
25
 
26
26
  @classmethod
27
- def choices(cls) -> List[HookType]:
27
+ def choices(cls) -> list[HookType]:
28
28
  """Return a list of all hook type choices."""
29
29
  return list(cls)
30
30
 
@@ -37,16 +37,17 @@ class HookManager:
37
37
  """HookManager"""
38
38
 
39
39
  def __init__(self) -> None:
40
- self._hooks: Dict[HookType, List[Hook]] = {
40
+ self._hooks: dict[HookType, list[Hook]] = {
41
41
  hook_type: [] for hook_type in HookType
42
42
  }
43
43
 
44
- def register(self, hook_type: HookType, hook: Hook):
45
- if hook_type not in HookType:
46
- raise ValueError(f"Unsupported hook type: {hook_type}")
44
+ def register(self, hook_type: HookType | str, hook: Hook):
45
+ """Raises ValueError if the hook type is not supported."""
46
+ if not isinstance(hook_type, HookType):
47
+ hook_type = HookType(hook_type)
47
48
  self._hooks[hook_type].append(hook)
48
49
 
49
- def clear(self, hook_type: Optional[HookType] = None):
50
+ def clear(self, hook_type: HookType | None = None):
50
51
  if hook_type:
51
52
  self._hooks[hook_type] = []
52
53
  else:
falyx/menu.py CHANGED
@@ -33,7 +33,7 @@ class MenuOptionMap(CaseInsensitiveDict):
33
33
  and special signal entries like Quit and Back.
34
34
  """
35
35
 
36
- RESERVED_KEYS = {"Q", "B"}
36
+ RESERVED_KEYS = {"B", "X"}
37
37
 
38
38
  def __init__(
39
39
  self,
@@ -49,14 +49,14 @@ class MenuOptionMap(CaseInsensitiveDict):
49
49
  def _inject_reserved_defaults(self):
50
50
  from falyx.action import SignalAction
51
51
 
52
- self._add_reserved(
53
- "Q",
54
- MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
55
- )
56
52
  self._add_reserved(
57
53
  "B",
58
54
  MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
59
55
  )
56
+ self._add_reserved(
57
+ "X",
58
+ MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
59
+ )
60
60
 
61
61
  def _add_reserved(self, key: str, option: MenuOption) -> None:
62
62
  """Add a reserved key, bypassing validation."""
@@ -78,8 +78,20 @@ class MenuOptionMap(CaseInsensitiveDict):
78
78
  raise ValueError(f"Cannot delete reserved option '{key}'.")
79
79
  super().__delitem__(key)
80
80
 
81
+ def update(self, other=None, **kwargs):
82
+ """Update the selection options with another dictionary."""
83
+ if other:
84
+ for key, option in other.items():
85
+ if not isinstance(option, MenuOption):
86
+ raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
87
+ self[key] = option
88
+ for key, option in kwargs.items():
89
+ if not isinstance(option, MenuOption):
90
+ raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
91
+ self[key] = option
92
+
81
93
  def items(self, include_reserved: bool = True):
82
- for k, v in super().items():
83
- if not include_reserved and k in self.RESERVED_KEYS:
94
+ for key, option in super().items():
95
+ if not include_reserved and key in self.RESERVED_KEYS:
84
96
  continue
85
- yield k, v
97
+ yield key, option
falyx/parsers/__init__.py CHANGED
@@ -7,8 +7,6 @@ Licensed under the MIT License. See LICENSE file for details.
7
7
 
8
8
  from .argparse import Argument, ArgumentAction, CommandArgumentParser
9
9
  from .parsers import FalyxParsers, get_arg_parsers
10
- from .signature import infer_args_from_func
11
- from .utils import same_argument_definitions
12
10
 
13
11
  __all__ = [
14
12
  "Argument",
@@ -16,6 +14,4 @@ __all__ = [
16
14
  "CommandArgumentParser",
17
15
  "get_arg_parsers",
18
16
  "FalyxParsers",
19
- "infer_args_from_func",
20
- "same_argument_definitions",
21
17
  ]
falyx/parsers/argparse.py CHANGED
@@ -1,4 +1,6 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ from __future__ import annotations
3
+
2
4
  from copy import deepcopy
3
5
  from dataclasses import dataclass
4
6
  from enum import Enum
@@ -23,6 +25,15 @@ class ArgumentAction(Enum):
23
25
  COUNT = "count"
24
26
  HELP = "help"
25
27
 
28
+ @classmethod
29
+ def choices(cls) -> list[ArgumentAction]:
30
+ """Return a list of all argument actions."""
31
+ return list(cls)
32
+
33
+ def __str__(self) -> str:
34
+ """Return the string representation of the argument action."""
35
+ return self.value
36
+
26
37
 
27
38
  @dataclass
28
39
  class Argument:
@@ -1,17 +1,20 @@
1
1
  import inspect
2
2
  from typing import Any, Callable
3
3
 
4
- from falyx import logger
4
+ from falyx.logger import logger
5
5
 
6
6
 
7
7
  def infer_args_from_func(
8
- func: Callable[[Any], Any],
8
+ func: Callable[[Any], Any] | None,
9
9
  arg_metadata: dict[str, str | dict[str, Any]] | None = None,
10
10
  ) -> list[dict[str, Any]]:
11
11
  """
12
12
  Infer argument definitions from a callable's signature.
13
13
  Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
14
14
  """
15
+ if not callable(func):
16
+ logger.debug("Provided argument is not callable: %s", func)
17
+ return []
15
18
  arg_metadata = arg_metadata or {}
16
19
  signature = inspect.signature(func)
17
20
  arg_defs = []
falyx/parsers/utils.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from typing import Any
2
2
 
3
3
  from falyx import logger
4
- from falyx.action.action import Action, ChainedAction, ProcessAction
5
4
  from falyx.parsers.signature import infer_args_from_func
6
5
 
7
6
 
@@ -9,17 +8,13 @@ def same_argument_definitions(
9
8
  actions: list[Any],
10
9
  arg_metadata: dict[str, str | dict[str, Any]] | None = None,
11
10
  ) -> list[dict[str, Any]] | None:
11
+ from falyx.action.action import BaseAction
12
+
12
13
  arg_sets = []
13
14
  for action in actions:
14
- if isinstance(action, (Action, ProcessAction)):
15
- arg_defs = infer_args_from_func(action.action, arg_metadata)
16
- elif isinstance(action, ChainedAction):
17
- if action.actions:
18
- action = action.actions[0]
19
- if isinstance(action, Action):
20
- arg_defs = infer_args_from_func(action.action, arg_metadata)
21
- elif callable(action):
22
- arg_defs = infer_args_from_func(action, arg_metadata)
15
+ if isinstance(action, BaseAction):
16
+ infer_target, _ = action.get_infer_target()
17
+ arg_defs = infer_args_from_func(infer_target, arg_metadata)
23
18
  elif callable(action):
24
19
  arg_defs = infer_args_from_func(action, arg_metadata)
25
20
  else:
falyx/selection.py CHANGED
@@ -10,7 +10,7 @@ from rich.markup import escape
10
10
  from rich.table import Table
11
11
 
12
12
  from falyx.themes import OneColors
13
- from falyx.utils import chunks
13
+ from falyx.utils import CaseInsensitiveDict, chunks
14
14
  from falyx.validators import int_range_validator, key_validator
15
15
 
16
16
 
@@ -32,6 +32,62 @@ class SelectionOption:
32
32
  return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
33
33
 
34
34
 
35
+ class SelectionOptionMap(CaseInsensitiveDict):
36
+ """
37
+ Manages selection options including validation and reserved key protection.
38
+ """
39
+
40
+ RESERVED_KEYS: set[str] = set()
41
+
42
+ def __init__(
43
+ self,
44
+ options: dict[str, SelectionOption] | None = None,
45
+ allow_reserved: bool = False,
46
+ ):
47
+ super().__init__()
48
+ self.allow_reserved = allow_reserved
49
+ if options:
50
+ self.update(options)
51
+
52
+ def _add_reserved(self, key: str, option: SelectionOption) -> None:
53
+ """Add a reserved key, bypassing validation."""
54
+ norm_key = key.upper()
55
+ super().__setitem__(norm_key, option)
56
+
57
+ def __setitem__(self, key: str, option: SelectionOption) -> None:
58
+ if not isinstance(option, SelectionOption):
59
+ raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
60
+ norm_key = key.upper()
61
+ if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
62
+ raise ValueError(
63
+ f"Key '{key}' is reserved and cannot be used in SelectionOptionMap."
64
+ )
65
+ super().__setitem__(norm_key, option)
66
+
67
+ def __delitem__(self, key: str) -> None:
68
+ if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
69
+ raise ValueError(f"Cannot delete reserved option '{key}'.")
70
+ super().__delitem__(key)
71
+
72
+ def update(self, other=None, **kwargs):
73
+ """Update the selection options with another dictionary."""
74
+ if other:
75
+ for key, option in other.items():
76
+ if not isinstance(option, SelectionOption):
77
+ raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
78
+ self[key] = option
79
+ for key, option in kwargs.items():
80
+ if not isinstance(option, SelectionOption):
81
+ raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
82
+ self[key] = option
83
+
84
+ def items(self, include_reserved: bool = True):
85
+ for k, v in super().items():
86
+ if not include_reserved and k in self.RESERVED_KEYS:
87
+ continue
88
+ yield k, v
89
+
90
+
35
91
  def render_table_base(
36
92
  title: str,
37
93
  *,
falyx/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.29"
1
+ __version__ = "0.1.31"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.29
3
+ Version: 0.1.31
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr