falyx 0.1.30__tar.gz → 0.1.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {falyx-0.1.30 → falyx-0.1.32}/PKG-INFO +1 -1
  2. {falyx-0.1.30 → falyx-0.1.32}/falyx/__init__.py +0 -1
  3. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/action.py +10 -16
  4. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/action_factory.py +9 -3
  5. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/io_action.py +5 -7
  6. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/menu_action.py +2 -2
  7. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/select_file_action.py +29 -11
  8. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/selection_action.py +82 -21
  9. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/types.py +15 -0
  10. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/user_input_action.py +2 -2
  11. {falyx-0.1.30 → falyx-0.1.32}/falyx/command.py +9 -32
  12. {falyx-0.1.30 → falyx-0.1.32}/falyx/config.py +0 -1
  13. {falyx-0.1.30 → falyx-0.1.32}/falyx/falyx.py +30 -29
  14. {falyx-0.1.30 → falyx-0.1.32}/falyx/hook_manager.py +8 -7
  15. {falyx-0.1.30 → falyx-0.1.32}/falyx/menu.py +20 -8
  16. {falyx-0.1.30 → falyx-0.1.32}/falyx/parsers/argparse.py +11 -0
  17. {falyx-0.1.30 → falyx-0.1.32}/falyx/parsers/utils.py +2 -1
  18. {falyx-0.1.30 → falyx-0.1.32}/falyx/selection.py +57 -1
  19. falyx-0.1.32/falyx/version.py +1 -0
  20. {falyx-0.1.30 → falyx-0.1.32}/pyproject.toml +1 -1
  21. falyx-0.1.30/falyx/version.py +0 -1
  22. {falyx-0.1.30 → falyx-0.1.32}/LICENSE +0 -0
  23. {falyx-0.1.30 → falyx-0.1.32}/README.md +0 -0
  24. {falyx-0.1.30 → falyx-0.1.32}/falyx/.pytyped +0 -0
  25. {falyx-0.1.30 → falyx-0.1.32}/falyx/__main__.py +0 -0
  26. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/.pytyped +0 -0
  27. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/__init__.py +0 -0
  28. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/http_action.py +0 -0
  29. {falyx-0.1.30 → falyx-0.1.32}/falyx/action/signal_action.py +0 -0
  30. {falyx-0.1.30 → falyx-0.1.32}/falyx/bottom_bar.py +0 -0
  31. {falyx-0.1.30 → falyx-0.1.32}/falyx/context.py +0 -0
  32. {falyx-0.1.30 → falyx-0.1.32}/falyx/debug.py +0 -0
  33. {falyx-0.1.30 → falyx-0.1.32}/falyx/exceptions.py +0 -0
  34. {falyx-0.1.30 → falyx-0.1.32}/falyx/execution_registry.py +0 -0
  35. {falyx-0.1.30 → falyx-0.1.32}/falyx/hooks.py +0 -0
  36. {falyx-0.1.30 → falyx-0.1.32}/falyx/init.py +0 -0
  37. {falyx-0.1.30 → falyx-0.1.32}/falyx/logger.py +0 -0
  38. {falyx-0.1.30 → falyx-0.1.32}/falyx/options_manager.py +0 -0
  39. {falyx-0.1.30 → falyx-0.1.32}/falyx/parsers/.pytyped +0 -0
  40. {falyx-0.1.30 → falyx-0.1.32}/falyx/parsers/__init__.py +0 -0
  41. {falyx-0.1.30 → falyx-0.1.32}/falyx/parsers/parsers.py +0 -0
  42. {falyx-0.1.30 → falyx-0.1.32}/falyx/parsers/signature.py +0 -0
  43. {falyx-0.1.30 → falyx-0.1.32}/falyx/prompt_utils.py +0 -0
  44. {falyx-0.1.30 → falyx-0.1.32}/falyx/protocols.py +0 -0
  45. {falyx-0.1.30 → falyx-0.1.32}/falyx/retry.py +0 -0
  46. {falyx-0.1.30 → falyx-0.1.32}/falyx/retry_utils.py +0 -0
  47. {falyx-0.1.30 → falyx-0.1.32}/falyx/signals.py +0 -0
  48. {falyx-0.1.30 → falyx-0.1.32}/falyx/tagged_table.py +0 -0
  49. {falyx-0.1.30 → falyx-0.1.32}/falyx/themes/__init__.py +0 -0
  50. {falyx-0.1.30 → falyx-0.1.32}/falyx/themes/colors.py +0 -0
  51. {falyx-0.1.30 → falyx-0.1.32}/falyx/utils.py +0 -0
  52. {falyx-0.1.30 → falyx-0.1.32}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.30
3
+ Version: 0.1.32
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -12,7 +12,6 @@ from .command import Command
12
12
  from .context import ExecutionContext, SharedContext
13
13
  from .execution_registry import ExecutionRegistry
14
14
  from .falyx import Falyx
15
- from .hook_manager import HookType
16
15
 
17
16
  logger = logging.getLogger("falyx")
18
17
 
@@ -62,8 +62,7 @@ class BaseAction(ABC):
62
62
  inject_last_result (bool): Whether to inject the previous action's result
63
63
  into kwargs.
64
64
  inject_into (str): The name of the kwarg key to inject the result as
65
- (default: 'last_result').
66
- _requires_injection (bool): Whether the action requires input injection.
65
+ (default: 'last_result').
67
66
  """
68
67
 
69
68
  def __init__(
@@ -83,7 +82,6 @@ class BaseAction(ABC):
83
82
  self.inject_last_result: bool = inject_last_result
84
83
  self.inject_into: str = inject_into
85
84
  self._never_prompt: bool = never_prompt
86
- self._requires_injection: bool = False
87
85
  self._skip_in_chain: bool = False
88
86
  self.console = Console(color_system="auto")
89
87
  self.options_manager: OptionsManager | None = None
@@ -103,7 +101,7 @@ class BaseAction(ABC):
103
101
  raise NotImplementedError("preview must be implemented by subclasses")
104
102
 
105
103
  @abstractmethod
106
- def get_infer_target(self) -> Callable[..., Any] | None:
104
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
107
105
  """
108
106
  Returns the callable to be used for argument inference.
109
107
  By default, it returns None.
@@ -163,10 +161,6 @@ class BaseAction(ABC):
163
161
  async def _write_stdout(self, data: str) -> None:
164
162
  """Override in subclasses that produce terminal output."""
165
163
 
166
- def requires_io_injection(self) -> bool:
167
- """Checks to see if the action requires input injection."""
168
- return self._requires_injection
169
-
170
164
  def __repr__(self) -> str:
171
165
  return str(self)
172
166
 
@@ -255,12 +249,12 @@ class Action(BaseAction):
255
249
  if policy.enabled:
256
250
  self.enable_retry()
257
251
 
258
- def get_infer_target(self) -> Callable[..., Any]:
252
+ def get_infer_target(self) -> tuple[Callable[..., Any], None]:
259
253
  """
260
254
  Returns the callable to be used for argument inference.
261
255
  By default, it returns the action itself.
262
256
  """
263
- return self.action
257
+ return self.action, None
264
258
 
265
259
  async def _run(self, *args, **kwargs) -> Any:
266
260
  combined_args = args + self.args
@@ -493,10 +487,10 @@ class ChainedAction(BaseAction, ActionListMixin):
493
487
  if hasattr(action, "register_teardown") and callable(action.register_teardown):
494
488
  action.register_teardown(self.hooks)
495
489
 
496
- def get_infer_target(self) -> Callable[..., Any] | None:
490
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
497
491
  if self.actions:
498
492
  return self.actions[0].get_infer_target()
499
- return None
493
+ return None, None
500
494
 
501
495
  def _clear_args(self):
502
496
  return (), {}
@@ -690,7 +684,7 @@ class ActionGroup(BaseAction, ActionListMixin):
690
684
  if hasattr(action, "register_teardown") and callable(action.register_teardown):
691
685
  action.register_teardown(self.hooks)
692
686
 
693
- def get_infer_target(self) -> Callable[..., Any] | None:
687
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
694
688
  arg_defs = same_argument_definitions(self.actions)
695
689
  if arg_defs:
696
690
  return self.actions[0].get_infer_target()
@@ -698,7 +692,7 @@ class ActionGroup(BaseAction, ActionListMixin):
698
692
  "[%s] auto_args disabled: mismatched ActionGroup arguments",
699
693
  self.name,
700
694
  )
701
- return None
695
+ return None, None
702
696
 
703
697
  async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
704
698
  shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
@@ -818,8 +812,8 @@ class ProcessAction(BaseAction):
818
812
  self.executor = executor or ProcessPoolExecutor()
819
813
  self.is_retryable = True
820
814
 
821
- def get_infer_target(self) -> Callable[..., Any] | None:
822
- return self.action
815
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
816
+ return self.action, None
823
817
 
824
818
  async def _run(self, *args, **kwargs) -> Any:
825
819
  if self.inject_last_result and self.shared_context:
@@ -35,6 +35,8 @@ class ActionFactoryAction(BaseAction):
35
35
  *,
36
36
  inject_last_result: bool = False,
37
37
  inject_into: str = "last_result",
38
+ args: tuple[Any, ...] = (),
39
+ kwargs: dict[str, Any] | None = None,
38
40
  preview_args: tuple[Any, ...] = (),
39
41
  preview_kwargs: dict[str, Any] | None = None,
40
42
  ):
@@ -44,6 +46,8 @@ class ActionFactoryAction(BaseAction):
44
46
  inject_into=inject_into,
45
47
  )
46
48
  self.factory = factory
49
+ self.args = args
50
+ self.kwargs = kwargs or {}
47
51
  self.preview_args = preview_args
48
52
  self.preview_kwargs = preview_kwargs or {}
49
53
 
@@ -55,10 +59,12 @@ class ActionFactoryAction(BaseAction):
55
59
  def factory(self, value: ActionFactoryProtocol):
56
60
  self._factory = ensure_async(value)
57
61
 
58
- def get_infer_target(self) -> Callable[..., Any]:
59
- return self.factory
62
+ def get_infer_target(self) -> tuple[Callable[..., Any], None]:
63
+ return self.factory, None
60
64
 
61
65
  async def _run(self, *args, **kwargs) -> Any:
66
+ args = (*self.args, *args)
67
+ kwargs = {**self.kwargs, **kwargs}
62
68
  updated_kwargs = self._maybe_inject_last_result(kwargs)
63
69
  context = ExecutionContext(
64
70
  name=f"{self.name} (factory)",
@@ -88,7 +94,7 @@ class ActionFactoryAction(BaseAction):
88
94
  )
89
95
  if self.options_manager:
90
96
  generated_action.set_options_manager(self.options_manager)
91
- context.result = await generated_action(*args, **kwargs)
97
+ context.result = await generated_action()
92
98
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
93
99
  return context.result
94
100
  except Exception as error:
@@ -73,7 +73,6 @@ class BaseIOAction(BaseAction):
73
73
  inject_last_result=inject_last_result,
74
74
  )
75
75
  self.mode = mode
76
- self._requires_injection = True
77
76
 
78
77
  def from_input(self, raw: str | bytes) -> Any:
79
78
  raise NotImplementedError
@@ -99,8 +98,8 @@ class BaseIOAction(BaseAction):
99
98
  )
100
99
  raise FalyxError("No input provided and no last result to inject.")
101
100
 
102
- def get_infer_target(self) -> Callable[..., Any] | None:
103
- return None
101
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
102
+ return None, None
104
103
 
105
104
  async def __call__(self, *args, **kwargs):
106
105
  context = ExecutionContext(
@@ -198,7 +197,6 @@ class ShellAction(BaseIOAction):
198
197
  - Captures stdout and stderr from shell execution
199
198
  - Raises on non-zero exit codes with stderr as the error
200
199
  - Result is returned as trimmed stdout string
201
- - Compatible with ChainedAction and Command.requires_input detection
202
200
 
203
201
  Args:
204
202
  name (str): Name of the action.
@@ -223,10 +221,10 @@ class ShellAction(BaseIOAction):
223
221
  )
224
222
  return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
225
223
 
226
- def get_infer_target(self) -> Callable[..., Any] | None:
224
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
227
225
  if sys.stdin.isatty():
228
- return self._run
229
- return None
226
+ return self._run, {"parsed_input": {"help": self.command_template}}
227
+ return None, None
230
228
 
231
229
  async def _run(self, parsed_input: str) -> str:
232
230
  # Replace placeholder in template, or use raw input as full command
@@ -73,8 +73,8 @@ class MenuAction(BaseAction):
73
73
  table.add_row(*row)
74
74
  return table
75
75
 
76
- def get_infer_target(self) -> None:
77
- return None
76
+ def get_infer_target(self) -> tuple[None, None]:
77
+ return None, None
78
78
 
79
79
  async def _run(self, *args, **kwargs) -> Any:
80
80
  kwargs = self._maybe_inject_last_result(kwargs)
@@ -25,6 +25,7 @@ from falyx.selection import (
25
25
  prompt_for_selection,
26
26
  render_selection_dict_table,
27
27
  )
28
+ from falyx.signals import CancelSignal
28
29
  from falyx.themes import OneColors
29
30
 
30
31
 
@@ -121,8 +122,15 @@ class SelectFileAction(BaseAction):
121
122
  logger.warning("[ERROR] Failed to parse %s: %s", file.name, error)
122
123
  return options
123
124
 
124
- def get_infer_target(self) -> None:
125
- return None
125
+ def _find_cancel_key(self, options) -> str:
126
+ """Return first numeric value not already used in the selection dict."""
127
+ for index in range(len(options)):
128
+ if str(index) not in options:
129
+ return str(index)
130
+ return str(len(options))
131
+
132
+ def get_infer_target(self) -> tuple[None, None]:
133
+ return None, None
126
134
 
127
135
  async def _run(self, *args, **kwargs) -> Any:
128
136
  context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
@@ -131,28 +139,38 @@ class SelectFileAction(BaseAction):
131
139
  await self.hooks.trigger(HookType.BEFORE, context)
132
140
 
133
141
  files = [
134
- f
135
- for f in self.directory.iterdir()
136
- if f.is_file()
137
- and (self.suffix_filter is None or f.suffix == self.suffix_filter)
142
+ file
143
+ for file in self.directory.iterdir()
144
+ if file.is_file()
145
+ and (self.suffix_filter is None or file.suffix == self.suffix_filter)
138
146
  ]
139
147
  if not files:
140
148
  raise FileNotFoundError("No files found in directory.")
141
149
 
142
150
  options = self.get_options(files)
143
151
 
152
+ cancel_key = self._find_cancel_key(options)
153
+ cancel_option = {
154
+ cancel_key: SelectionOption(
155
+ description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED
156
+ )
157
+ }
158
+
144
159
  table = render_selection_dict_table(
145
- title=self.title, selections=options, columns=self.columns
160
+ title=self.title, selections=options | cancel_option, columns=self.columns
146
161
  )
147
162
 
148
163
  key = await prompt_for_selection(
149
- options.keys(),
164
+ (options | cancel_option).keys(),
150
165
  table,
151
166
  console=self.console,
152
167
  prompt_session=self.prompt_session,
153
168
  prompt_message=self.prompt_message,
154
169
  )
155
170
 
171
+ if key == cancel_key:
172
+ raise CancelSignal("User canceled the selection.")
173
+
156
174
  result = options[key].value
157
175
  context.result = result
158
176
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -179,11 +197,11 @@ class SelectFileAction(BaseAction):
179
197
  try:
180
198
  files = list(self.directory.iterdir())
181
199
  if self.suffix_filter:
182
- files = [f for f in files if f.suffix == self.suffix_filter]
200
+ files = [file for file in files if file.suffix == self.suffix_filter]
183
201
  sample = files[:10]
184
202
  file_list = tree.add("[dim]Files:[/]")
185
- for f in sample:
186
- file_list.add(f"[dim]{f.name}[/]")
203
+ for file in sample:
204
+ file_list.add(f"[dim]{file.name}[/]")
187
205
  if len(files) > 10:
188
206
  file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
189
207
  except Exception as error:
@@ -7,19 +7,21 @@ from rich.console import Console
7
7
  from rich.tree import Tree
8
8
 
9
9
  from falyx.action.action import BaseAction
10
+ from falyx.action.types import SelectionReturnType
10
11
  from falyx.context import ExecutionContext
11
12
  from falyx.execution_registry import ExecutionRegistry as er
12
13
  from falyx.hook_manager import HookType
13
14
  from falyx.logger import logger
14
15
  from falyx.selection import (
15
16
  SelectionOption,
17
+ SelectionOptionMap,
16
18
  prompt_for_index,
17
19
  prompt_for_selection,
18
20
  render_selection_dict_table,
19
21
  render_selection_indexed_table,
20
22
  )
23
+ from falyx.signals import CancelSignal
21
24
  from falyx.themes import OneColors
22
- from falyx.utils import CaseInsensitiveDict
23
25
 
24
26
 
25
27
  class SelectionAction(BaseAction):
@@ -34,7 +36,13 @@ class SelectionAction(BaseAction):
34
36
  def __init__(
35
37
  self,
36
38
  name: str,
37
- selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
39
+ selections: (
40
+ list[str]
41
+ | set[str]
42
+ | tuple[str, ...]
43
+ | dict[str, SelectionOption]
44
+ | dict[str, Any]
45
+ ),
38
46
  *,
39
47
  title: str = "Select an option",
40
48
  columns: int = 5,
@@ -42,7 +50,7 @@ class SelectionAction(BaseAction):
42
50
  default_selection: str = "",
43
51
  inject_last_result: bool = False,
44
52
  inject_into: str = "last_result",
45
- return_key: bool = False,
53
+ return_type: SelectionReturnType | str = "value",
46
54
  console: Console | None = None,
47
55
  prompt_session: PromptSession | None = None,
48
56
  never_prompt: bool = False,
@@ -55,8 +63,8 @@ class SelectionAction(BaseAction):
55
63
  never_prompt=never_prompt,
56
64
  )
57
65
  # Setter normalizes to correct type, mypy can't infer that
58
- self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment]
59
- self.return_key = return_key
66
+ self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
67
+ self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
60
68
  self.title = title
61
69
  self.columns = columns
62
70
  self.console = console or Console(color_system="auto")
@@ -65,8 +73,15 @@ class SelectionAction(BaseAction):
65
73
  self.prompt_message = prompt_message
66
74
  self.show_table = show_table
67
75
 
76
+ def _coerce_return_type(
77
+ self, return_type: SelectionReturnType | str
78
+ ) -> SelectionReturnType:
79
+ if isinstance(return_type, SelectionReturnType):
80
+ return return_type
81
+ return SelectionReturnType(return_type)
82
+
68
83
  @property
69
- def selections(self) -> list[str] | CaseInsensitiveDict:
84
+ def selections(self) -> list[str] | SelectionOptionMap:
70
85
  return self._selections
71
86
 
72
87
  @selections.setter
@@ -74,19 +89,40 @@ class SelectionAction(BaseAction):
74
89
  self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
75
90
  ):
76
91
  if isinstance(value, (list, tuple, set)):
77
- self._selections: list[str] | CaseInsensitiveDict = list(value)
92
+ self._selections: list[str] | SelectionOptionMap = list(value)
78
93
  elif isinstance(value, dict):
79
- cid = CaseInsensitiveDict()
80
- cid.update(value)
81
- self._selections = cid
94
+ som = SelectionOptionMap()
95
+ if all(isinstance(key, str) for key in value) and all(
96
+ not isinstance(value[key], SelectionOption) for key in value
97
+ ):
98
+ som.update(
99
+ {
100
+ str(index): SelectionOption(key, option)
101
+ for index, (key, option) in enumerate(value.items())
102
+ }
103
+ )
104
+ elif all(isinstance(key, str) for key in value) and all(
105
+ isinstance(value[key], SelectionOption) for key in value
106
+ ):
107
+ som.update(value)
108
+ else:
109
+ raise ValueError("Invalid dictionary format. Keys must be strings")
110
+ self._selections = som
82
111
  else:
83
112
  raise TypeError(
84
113
  "'selections' must be a list[str] or dict[str, SelectionOption], "
85
114
  f"got {type(value).__name__}"
86
115
  )
87
116
 
88
- def get_infer_target(self) -> None:
89
- return None
117
+ def _find_cancel_key(self) -> str:
118
+ """Return first numeric value not already used in the selection dict."""
119
+ for index in range(len(self.selections)):
120
+ if str(index) not in self.selections:
121
+ return str(index)
122
+ return str(len(self.selections))
123
+
124
+ def get_infer_target(self) -> tuple[None, None]:
125
+ return None, None
90
126
 
91
127
  async def _run(self, *args, **kwargs) -> Any:
92
128
  kwargs = self._maybe_inject_last_result(kwargs)
@@ -128,16 +164,17 @@ class SelectionAction(BaseAction):
128
164
 
129
165
  context.start_timer()
130
166
  try:
167
+ cancel_key = self._find_cancel_key()
131
168
  await self.hooks.trigger(HookType.BEFORE, context)
132
169
  if isinstance(self.selections, list):
133
170
  table = render_selection_indexed_table(
134
171
  title=self.title,
135
- selections=self.selections,
172
+ selections=self.selections + ["Cancel"],
136
173
  columns=self.columns,
137
174
  )
138
175
  if not self.never_prompt:
139
176
  index = await prompt_for_index(
140
- len(self.selections) - 1,
177
+ len(self.selections),
141
178
  table,
142
179
  default_selection=effective_default,
143
180
  console=self.console,
@@ -147,14 +184,23 @@ class SelectionAction(BaseAction):
147
184
  )
148
185
  else:
149
186
  index = effective_default
150
- result = self.selections[int(index)]
187
+ if index == cancel_key:
188
+ raise CancelSignal("User cancelled the selection.")
189
+ result: Any = self.selections[int(index)]
151
190
  elif isinstance(self.selections, dict):
191
+ cancel_option = {
192
+ cancel_key: SelectionOption(
193
+ description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
194
+ )
195
+ }
152
196
  table = render_selection_dict_table(
153
- title=self.title, selections=self.selections, columns=self.columns
197
+ title=self.title,
198
+ selections=self.selections | cancel_option,
199
+ columns=self.columns,
154
200
  )
155
201
  if not self.never_prompt:
156
202
  key = await prompt_for_selection(
157
- self.selections.keys(),
203
+ (self.selections | cancel_option).keys(),
158
204
  table,
159
205
  default_selection=effective_default,
160
206
  console=self.console,
@@ -164,10 +210,25 @@ class SelectionAction(BaseAction):
164
210
  )
165
211
  else:
166
212
  key = effective_default
167
- result = key if self.return_key else self.selections[key].value
213
+ if key == cancel_key:
214
+ raise CancelSignal("User cancelled the selection.")
215
+ if self.return_type == SelectionReturnType.KEY:
216
+ result = key
217
+ elif self.return_type == SelectionReturnType.VALUE:
218
+ result = self.selections[key].value
219
+ elif self.return_type == SelectionReturnType.ITEMS:
220
+ result = {key: self.selections[key]}
221
+ elif self.return_type == SelectionReturnType.DESCRIPTION:
222
+ result = self.selections[key].description
223
+ elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
224
+ result = {
225
+ self.selections[key].description: self.selections[key].value
226
+ }
227
+ else:
228
+ raise ValueError(f"Unsupported return type: {self.return_type}")
168
229
  else:
169
230
  raise TypeError(
170
- "'selections' must be a list[str] or dict[str, tuple[str, Any]], "
231
+ "'selections' must be a list[str] or dict[str, Any], "
171
232
  f"got {type(self.selections).__name__}"
172
233
  )
173
234
  context.result = result
@@ -206,7 +267,7 @@ class SelectionAction(BaseAction):
206
267
  return
207
268
 
208
269
  tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
209
- tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}")
270
+ tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
210
271
  tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
211
272
 
212
273
  if not parent:
@@ -221,6 +282,6 @@ class SelectionAction(BaseAction):
221
282
  return (
222
283
  f"SelectionAction(name={self.name!r}, type={selection_type}, "
223
284
  f"default_selection={self.default_selection!r}, "
224
- f"return_key={self.return_key}, "
285
+ f"return_type={self.return_type!r}, "
225
286
  f"prompt={'off' if self.never_prompt else 'on'})"
226
287
  )
@@ -35,3 +35,18 @@ class FileReturnType(Enum):
35
35
  return member
36
36
  valid = ", ".join(member.value for member in cls)
37
37
  raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")
38
+
39
+
40
+ class SelectionReturnType(Enum):
41
+ """Enum for dictionary return types."""
42
+
43
+ KEY = "key"
44
+ VALUE = "value"
45
+ DESCRIPTION = "description"
46
+ DESCRIPTION_VALUE = "description_value"
47
+ ITEMS = "items"
48
+
49
+ @classmethod
50
+ def _missing_(cls, value: object) -> SelectionReturnType:
51
+ valid = ", ".join(member.value for member in cls)
52
+ raise ValueError(f"Invalid DictReturnType: '{value}'. Must be one of: {valid}")
@@ -43,8 +43,8 @@ class UserInputAction(BaseAction):
43
43
  self.console = console or Console(color_system="auto")
44
44
  self.prompt_session = prompt_session or PromptSession()
45
45
 
46
- def get_infer_target(self) -> None:
47
- return None
46
+ def get_infer_target(self) -> tuple[None, None]:
47
+ return None, None
48
48
 
49
49
  async def _run(self, *args, **kwargs) -> str:
50
50
  context = ExecutionContext(
@@ -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,8 +26,7 @@ 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 Action, ActionGroup, BaseAction, ChainedAction
31
- from falyx.action.io_action import BaseIOAction
29
+ from falyx.action.action import Action, BaseAction
32
30
  from falyx.context import ExecutionContext
33
31
  from falyx.debug import register_debug_hooks
34
32
  from falyx.execution_registry import ExecutionRegistry as er
@@ -90,7 +88,6 @@ class Command(BaseModel):
90
88
  retry_policy (RetryPolicy): Retry behavior configuration.
91
89
  tags (list[str]): Organizational tags for the command.
92
90
  logging_hooks (bool): Whether to attach logging hooks automatically.
93
- requires_input (bool | None): Indicates if the action needs input.
94
91
  options_manager (OptionsManager): Manages global command-line options.
95
92
  arg_parser (CommandArgumentParser): Parses command arguments.
96
93
  custom_parser (ArgParserProtocol | None): Custom argument parser.
@@ -129,7 +126,6 @@ class Command(BaseModel):
129
126
  retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
130
127
  tags: list[str] = Field(default_factory=list)
131
128
  logging_hooks: bool = False
132
- requires_input: bool | None = None
133
129
  options_manager: OptionsManager = Field(default_factory=OptionsManager)
134
130
  arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
135
131
  arguments: list[dict[str, Any]] = Field(default_factory=list)
@@ -146,7 +142,7 @@ class Command(BaseModel):
146
142
  def parse_args(
147
143
  self, raw_args: list[str] | str, from_validate: bool = False
148
144
  ) -> tuple[tuple, dict]:
149
- if self.custom_parser:
145
+ if callable(self.custom_parser):
150
146
  if isinstance(raw_args, str):
151
147
  try:
152
148
  raw_args = shlex.split(raw_args)
@@ -183,13 +179,15 @@ class Command(BaseModel):
183
179
  def get_argument_definitions(self) -> list[dict[str, Any]]:
184
180
  if self.arguments:
185
181
  return self.arguments
186
- elif self.argument_config:
182
+ elif callable(self.argument_config):
187
183
  self.argument_config(self.arg_parser)
188
184
  elif self.auto_args:
189
185
  if isinstance(self.action, BaseAction):
190
- return infer_args_from_func(
191
- self.action.get_infer_target(), self.arg_metadata
192
- )
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)
193
191
  elif callable(self.action):
194
192
  return infer_args_from_func(self.action, self.arg_metadata)
195
193
  return []
@@ -217,30 +215,9 @@ class Command(BaseModel):
217
215
  if self.logging_hooks and isinstance(self.action, BaseAction):
218
216
  register_debug_hooks(self.action.hooks)
219
217
 
220
- if self.requires_input is None and self.detect_requires_input:
221
- self.requires_input = True
222
- self.hidden = True
223
- elif self.requires_input is None:
224
- self.requires_input = False
225
-
226
218
  for arg_def in self.get_argument_definitions():
227
219
  self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
228
220
 
229
- @cached_property
230
- def detect_requires_input(self) -> bool:
231
- """Detect if the action requires input based on its type."""
232
- if isinstance(self.action, BaseIOAction):
233
- return True
234
- elif isinstance(self.action, ChainedAction):
235
- return (
236
- isinstance(self.action.actions[0], BaseIOAction)
237
- if self.action.actions
238
- else False
239
- )
240
- elif isinstance(self.action, ActionGroup):
241
- return any(isinstance(action, BaseIOAction) for action in self.action.actions)
242
- return False
243
-
244
221
  def _inject_options_manager(self) -> None:
245
222
  """Inject the options manager into the action if applicable."""
246
223
  if isinstance(self.action, BaseAction):
@@ -333,7 +310,7 @@ class Command(BaseModel):
333
310
 
334
311
  def show_help(self) -> bool:
335
312
  """Display the help message for the command."""
336
- if self.custom_help:
313
+ if callable(self.custom_help):
337
314
  output = self.custom_help()
338
315
  if output:
339
316
  console.print(output)
@@ -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
 
@@ -61,7 +61,7 @@ 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
66
  from falyx.utils import CaseInsensitiveDict, _noop, chunks
67
67
  from falyx.version import __version__
@@ -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
 
@@ -160,6 +162,7 @@ class Falyx:
160
162
  options: OptionsManager | None = None,
161
163
  render_menu: Callable[[Falyx], None] | None = None,
162
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
@@ -185,6 +188,7 @@ class Falyx:
185
188
  self.cli_args: Namespace | None = cli_args
186
189
  self.render_menu: Callable[[Falyx], None] | None = render_menu
187
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
 
@@ -608,7 +609,6 @@ 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,
@@ -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)]
@@ -975,10 +975,11 @@ class Falyx:
975
975
  self.print_message(self.welcome_message)
976
976
  try:
977
977
  while True:
978
- if callable(self.render_menu):
979
- self.render_menu(self)
980
- else:
981
- 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")
982
983
  try:
983
984
  task = asyncio.create_task(self.process_command())
984
985
  should_continue = await task
@@ -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:
@@ -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
@@ -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:
@@ -13,7 +13,8 @@ def same_argument_definitions(
13
13
  arg_sets = []
14
14
  for action in actions:
15
15
  if isinstance(action, BaseAction):
16
- arg_defs = infer_args_from_func(action.get_infer_target(), arg_metadata)
16
+ infer_target, _ = action.get_infer_target()
17
+ arg_defs = infer_args_from_func(infer_target, arg_metadata)
17
18
  elif callable(action):
18
19
  arg_defs = infer_args_from_func(action, arg_metadata)
19
20
  else:
@@ -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
  *,
@@ -0,0 +1 @@
1
+ __version__ = "0.1.32"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.30"
3
+ version = "0.1.32"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
@@ -1 +0,0 @@
1
- __version__ = "0.1.30"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes