Compare commits
	
		
			2 Commits
		
	
	
		
			b0c0e7dc16
			...
			ba562168aa
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ba562168aa | |||
| ddb78bd5a7 | 
| @@ -6,7 +6,7 @@ from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, Selecti | ||||
| # Selection of a post ID to fetch (just an example set) | ||||
| post_selector = SelectionAction( | ||||
|     name="Pick Post ID", | ||||
|     selections=["1", "2", "3", "4", "5"], | ||||
|     selections=["15", "25", "35", "45", "55"], | ||||
|     title="Choose a Post ID to submit", | ||||
|     prompt_message="Post ID > ", | ||||
|     show_table=True, | ||||
| @@ -14,7 +14,7 @@ post_selector = SelectionAction( | ||||
|  | ||||
|  | ||||
| # Factory that builds and executes the actual HTTP POST request | ||||
| def build_post_action(post_id) -> HTTPAction: | ||||
| async def build_post_action(post_id) -> HTTPAction: | ||||
|     print(f"Building HTTPAction for Post ID: {post_id}") | ||||
|     return HTTPAction( | ||||
|         name=f"POST to /posts (id={post_id})", | ||||
|   | ||||
| @@ -2,8 +2,16 @@ import asyncio | ||||
| import time | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     ChainedAction, | ||||
|     MenuAction, | ||||
|     ProcessAction, | ||||
|     PromptMenuAction, | ||||
| ) | ||||
| from falyx.menu import MenuOption, MenuOptionMap | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| # Basic coroutine for Action | ||||
| @@ -77,20 +85,28 @@ parallel = ActionGroup( | ||||
|  | ||||
| process = ProcessAction(name="compute", action=heavy_computation) | ||||
|  | ||||
| menu_options = MenuOptionMap( | ||||
|     { | ||||
|         "A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW), | ||||
|         "C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA), | ||||
|         "P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN), | ||||
|         "H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN), | ||||
|     } | ||||
| ) | ||||
|  | ||||
|  | ||||
| # Menu setup | ||||
|  | ||||
| menu = MenuAction( | ||||
|     name="main-menu", | ||||
|     title="Choose a task to run", | ||||
|     menu_options=MenuOptionMap( | ||||
|         { | ||||
|             "1": MenuOption("Run basic Action", basic_action), | ||||
|             "2": MenuOption("Run ChainedAction", chained), | ||||
|             "3": MenuOption("Run ActionGroup (parallel)", parallel), | ||||
|             "4": MenuOption("Run ProcessAction (heavy task)", process), | ||||
|         } | ||||
|     ), | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
|  | ||||
| prompt_menu = PromptMenuAction( | ||||
|     name="select-user", | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
| flx = Falyx( | ||||
| @@ -108,6 +124,13 @@ flx.add_command( | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="P", | ||||
|     description="Show Prompt Menu", | ||||
|     action=prompt_menu, | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(flx.run()) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import asyncio | ||||
|  | ||||
| from falyx.action import SelectionAction | ||||
| from falyx.selection import SelectionOption | ||||
| from falyx.signals import CancelSignal | ||||
|  | ||||
| selections = { | ||||
|     "1": SelectionOption( | ||||
| @@ -23,4 +24,7 @@ select = SelectionAction( | ||||
|     show_table=True, | ||||
| ) | ||||
|  | ||||
| print(asyncio.run(select())) | ||||
| try: | ||||
|     print(asyncio.run(select())) | ||||
| except CancelSignal: | ||||
|     print("Selection was cancelled.") | ||||
|   | ||||
| @@ -18,6 +18,7 @@ from .action_factory import ActionFactoryAction | ||||
| from .http_action import HTTPAction | ||||
| from .io_action import BaseIOAction, ShellAction | ||||
| from .menu_action import MenuAction | ||||
| from .prompt_menu_action import PromptMenuAction | ||||
| from .select_file_action import SelectFileAction | ||||
| from .selection_action import SelectionAction | ||||
| from .signal_action import SignalAction | ||||
| @@ -40,4 +41,5 @@ __all__ = [ | ||||
|     "FallbackAction", | ||||
|     "LiteralInputAction", | ||||
|     "UserInputAction", | ||||
|     "PromptMenuAction", | ||||
| ] | ||||
|   | ||||
| @@ -726,7 +726,7 @@ class ActionGroup(BaseAction, ActionListMixin): | ||||
|             if context.extra["errors"]: | ||||
|                 context.exception = Exception( | ||||
|                     f"{len(context.extra['errors'])} action(s) failed: " | ||||
|                     f"{' ,'.join(name for name, _ in context.extra["errors"])}" | ||||
|                     f"{' ,'.join(name for name, _ in context.extra['errors'])}" | ||||
|                 ) | ||||
|                 await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|                 raise context.exception | ||||
|   | ||||
							
								
								
									
										134
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """prompt_menu_action.py""" | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.menu import MenuOptionMap | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class PromptMenuAction(BaseAction): | ||||
|     """PromptMenuAction class for creating prompt -> actions.""" | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         menu_options: MenuOptionMap, | ||||
|         *, | ||||
|         prompt_message: str = "Select > ", | ||||
|         default_selection: str = "", | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         console: Console | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
|         include_reserved: bool = True, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.menu_options = menu_options | ||||
|         self.prompt_message = prompt_message | ||||
|         self.default_selection = default_selection | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.include_reserved = include_reserved | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|  | ||||
|         effective_default = self.default_selection | ||||
|         maybe_result = str(self.last_result) | ||||
|         if maybe_result in self.menu_options: | ||||
|             effective_default = maybe_result | ||||
|         elif self.inject_last_result: | ||||
|             logger.warning( | ||||
|                 "[%s] Injected last result '%s' not found in menu options", | ||||
|                 self.name, | ||||
|                 maybe_result, | ||||
|             ) | ||||
|  | ||||
|         if self.never_prompt and not effective_default: | ||||
|             raise ValueError( | ||||
|                 f"[{self.name}] 'never_prompt' is True but no valid default_selection" | ||||
|                 " was provided." | ||||
|             ) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             key = effective_default | ||||
|             if not self.never_prompt: | ||||
|                 placeholder_formatted_text = [] | ||||
|                 for index, (key, option) in enumerate(self.menu_options.items()): | ||||
|                     placeholder_formatted_text.append(option.render_prompt(key)) | ||||
|                     if index < len(self.menu_options) - 1: | ||||
|                         placeholder_formatted_text.append( | ||||
|                             FormattedText([(OneColors.WHITE, " | ")]) | ||||
|                         ) | ||||
|                 placeholder = merge_formatted_text(placeholder_formatted_text) | ||||
|                 key = await self.prompt_session.prompt_async( | ||||
|                     message=self.prompt_message, placeholder=placeholder | ||||
|                 ) | ||||
|             option = self.menu_options[key] | ||||
|             result = await option.action(*args, **kwargs) | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
|  | ||||
|         except BackSignal: | ||||
|             logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) | ||||
|             return None | ||||
|         except QuitSignal: | ||||
|             logger.debug("[%s][QuitSignal] ← Exiting application", self.name) | ||||
|             raise | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = f"[{OneColors.LIGHT_YELLOW_b}]📋 PromptMenuAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|         for key, option in self.menu_options.items(): | ||||
|             tree.add( | ||||
|                 f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]" | ||||
|             ) | ||||
|             await option.action.preview(parent=tree) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"PromptMenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, " | ||||
|             f"default_selection={self.default_selection!r}, " | ||||
|             f"include_reserved={self.include_reserved}, " | ||||
|             f"prompt={'off' if self.never_prompt else 'on'})" | ||||
|         ) | ||||
| @@ -1,5 +1,6 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection_action.py""" | ||||
| from copy import copy | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| @@ -72,6 +73,7 @@ class SelectionAction(BaseAction): | ||||
|         self.default_selection = default_selection | ||||
|         self.prompt_message = prompt_message | ||||
|         self.show_table = show_table | ||||
|         self.cancel_key = self._find_cancel_key() | ||||
|  | ||||
|     def _coerce_return_type( | ||||
|         self, return_type: SelectionReturnType | str | ||||
| @@ -115,12 +117,40 @@ class SelectionAction(BaseAction): | ||||
|             ) | ||||
|  | ||||
|     def _find_cancel_key(self) -> str: | ||||
|         """Return first numeric value not already used in the selection dict.""" | ||||
|         for index in range(len(self.selections)): | ||||
|         """Find the cancel key in the selections.""" | ||||
|         if isinstance(self.selections, dict): | ||||
|             for index in range(len(self.selections) + 1): | ||||
|                 if str(index) not in self.selections: | ||||
|                     return str(index) | ||||
|         return str(len(self.selections)) | ||||
|  | ||||
|     @property | ||||
|     def cancel_key(self) -> str: | ||||
|         return self._cancel_key | ||||
|  | ||||
|     @cancel_key.setter | ||||
|     def cancel_key(self, value: str) -> None: | ||||
|         """Set the cancel key for the selection.""" | ||||
|         if not isinstance(value, str): | ||||
|             raise TypeError("Cancel key must be a string.") | ||||
|         if isinstance(self.selections, dict) and value in self.selections: | ||||
|             raise ValueError( | ||||
|                 "Cancel key cannot be one of the selection keys. " | ||||
|                 f"Current selections: {self.selections}" | ||||
|             ) | ||||
|         if isinstance(self.selections, list): | ||||
|             if not value.isdigit() or int(value) > len(self.selections): | ||||
|                 raise ValueError( | ||||
|                     "cancel_key must be a digit and not greater than the number of selections." | ||||
|                 ) | ||||
|         self._cancel_key = value | ||||
|  | ||||
|     def cancel_formatter(self, index: int, selection: str) -> str: | ||||
|         """Format the cancel option for display.""" | ||||
|         if self.cancel_key == str(index): | ||||
|             return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]" | ||||
|         return f"[{index}] {selection}" | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
| @@ -164,16 +194,17 @@ class SelectionAction(BaseAction): | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             cancel_key = self._find_cancel_key() | ||||
|             self.cancel_key = self._find_cancel_key() | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             if isinstance(self.selections, list): | ||||
|                 table = render_selection_indexed_table( | ||||
|                     title=self.title, | ||||
|                     selections=self.selections + ["Cancel"], | ||||
|                     columns=self.columns, | ||||
|                     formatter=self.cancel_formatter, | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     index = await prompt_for_index( | ||||
|                     index: int | str = await prompt_for_index( | ||||
|                         len(self.selections), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
| @@ -184,12 +215,12 @@ class SelectionAction(BaseAction): | ||||
|                     ) | ||||
|                 else: | ||||
|                     index = effective_default | ||||
|                 if index == cancel_key: | ||||
|                 if int(index) == int(self.cancel_key): | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|                 result: Any = self.selections[int(index)] | ||||
|             elif isinstance(self.selections, dict): | ||||
|                 cancel_option = { | ||||
|                     cancel_key: SelectionOption( | ||||
|                     self.cancel_key: SelectionOption( | ||||
|                         description="Cancel", value=CancelSignal, style=OneColors.DARK_RED | ||||
|                     ) | ||||
|                 } | ||||
| @@ -210,7 +241,7 @@ class SelectionAction(BaseAction): | ||||
|                     ) | ||||
|                 else: | ||||
|                     key = effective_default | ||||
|                 if key == cancel_key: | ||||
|                 if key == self.cancel_key: | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|                 if self.return_type == SelectionReturnType.KEY: | ||||
|                     result = key | ||||
|   | ||||
| @@ -139,7 +139,7 @@ class Command(BaseModel): | ||||
|  | ||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||
|  | ||||
|     def parse_args( | ||||
|     async def parse_args( | ||||
|         self, raw_args: list[str] | str, from_validate: bool = False | ||||
|     ) -> tuple[tuple, dict]: | ||||
|         if callable(self.custom_parser): | ||||
| @@ -165,7 +165,9 @@ class Command(BaseModel): | ||||
|                     raw_args, | ||||
|                 ) | ||||
|                 return ((), {}) | ||||
|         return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate) | ||||
|         return await self.arg_parser.parse_args_split( | ||||
|             raw_args, from_validate=from_validate | ||||
|         ) | ||||
|  | ||||
|     @field_validator("action", mode="before") | ||||
|     @classmethod | ||||
|   | ||||
| @@ -83,8 +83,11 @@ class CommandValidator(Validator): | ||||
|         self.error_message = error_message | ||||
|  | ||||
|     def validate(self, document) -> None: | ||||
|         pass | ||||
|  | ||||
|     async def validate_async(self, document) -> None: | ||||
|         text = document.text | ||||
|         is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True) | ||||
|         is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) | ||||
|         if is_preview: | ||||
|             return None | ||||
|         if not choice: | ||||
| @@ -188,7 +191,7 @@ class Falyx: | ||||
|         self.cli_args: Namespace | None = cli_args | ||||
|         self.render_menu: Callable[[Falyx], None] | None = render_menu | ||||
|         self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table | ||||
|         self.hide_menu_table: bool = hide_menu_table | ||||
|         self._hide_menu_table: bool = hide_menu_table | ||||
|         self.validate_options(cli_args, options) | ||||
|         self._prompt_session: PromptSession | None = None | ||||
|         self.mode = FalyxMode.MENU | ||||
| @@ -740,7 +743,7 @@ class Falyx: | ||||
|             return True, input_str[1:].strip() | ||||
|         return False, input_str.strip() | ||||
|  | ||||
|     def get_command( | ||||
|     async def get_command( | ||||
|         self, raw_choices: str, from_validate=False | ||||
|     ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: | ||||
|         """ | ||||
| @@ -773,7 +776,9 @@ class Falyx: | ||||
|             if is_preview: | ||||
|                 return True, name_map[choice], args, kwargs | ||||
|             try: | ||||
|                 args, kwargs = name_map[choice].parse_args(input_args, from_validate) | ||||
|                 args, kwargs = await name_map[choice].parse_args( | ||||
|                     input_args, from_validate | ||||
|                 ) | ||||
|             except CommandArgumentError as error: | ||||
|                 if not from_validate: | ||||
|                     if not name_map[choice].show_help(): | ||||
| @@ -834,7 +839,7 @@ class Falyx: | ||||
|         """Processes the action of the selected command.""" | ||||
|         with patch_stdout(raw=True): | ||||
|             choice = await self.prompt_session.prompt_async() | ||||
|         is_preview, selected_command, args, kwargs = self.get_command(choice) | ||||
|         is_preview, selected_command, args, kwargs = await self.get_command(choice) | ||||
|         if not selected_command: | ||||
|             logger.info("Invalid command '%s'.", choice) | ||||
|             return True | ||||
| @@ -876,7 +881,7 @@ class Falyx: | ||||
|     ) -> Any: | ||||
|         """Run a command by key without displaying the menu (non-interactive mode).""" | ||||
|         self.debug_hooks() | ||||
|         is_preview, selected_command, _, __ = self.get_command(command_key) | ||||
|         is_preview, selected_command, _, __ = await self.get_command(command_key) | ||||
|         kwargs = kwargs or {} | ||||
|  | ||||
|         self.last_run_command = selected_command | ||||
| @@ -975,7 +980,7 @@ class Falyx: | ||||
|             self.print_message(self.welcome_message) | ||||
|         try: | ||||
|             while True: | ||||
|                 if not self.hide_menu_table: | ||||
|                 if not self.options.get("hide_menu_table", self._hide_menu_table): | ||||
|                     if callable(self.render_menu): | ||||
|                         self.render_menu(self) | ||||
|                     else: | ||||
| @@ -1012,6 +1017,9 @@ class Falyx: | ||||
|         if not self.options.get("force_confirm"): | ||||
|             self.options.set("force_confirm", self._force_confirm) | ||||
|  | ||||
|         if not self.options.get("hide_menu_table"): | ||||
|             self.options.set("hide_menu_table", self._hide_menu_table) | ||||
|  | ||||
|         if self.cli_args.verbose: | ||||
|             logging.getLogger("falyx").setLevel(logging.DEBUG) | ||||
|  | ||||
| @@ -1029,7 +1037,7 @@ class Falyx: | ||||
|  | ||||
|         if self.cli_args.command == "preview": | ||||
|             self.mode = FalyxMode.PREVIEW | ||||
|             _, command, args, kwargs = self.get_command(self.cli_args.name) | ||||
|             _, command, args, kwargs = await self.get_command(self.cli_args.name) | ||||
|             if not command: | ||||
|                 self.console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." | ||||
| @@ -1043,7 +1051,7 @@ class Falyx: | ||||
|  | ||||
|         if self.cli_args.command == "run": | ||||
|             self.mode = FalyxMode.RUN | ||||
|             is_preview, command, _, __ = self.get_command(self.cli_args.name) | ||||
|             is_preview, command, _, __ = await self.get_command(self.cli_args.name) | ||||
|             if is_preview: | ||||
|                 if command is None: | ||||
|                     sys.exit(1) | ||||
| @@ -1054,7 +1062,7 @@ class Falyx: | ||||
|                 sys.exit(1) | ||||
|             self._set_retry_policy(command) | ||||
|             try: | ||||
|                 args, kwargs = command.parse_args(self.cli_args.command_args) | ||||
|                 args, kwargs = await command.parse_args(self.cli_args.command_args) | ||||
|             except HelpSignal: | ||||
|                 sys.exit(0) | ||||
|             try: | ||||
|   | ||||
| @@ -2,6 +2,8 @@ from __future__ import annotations | ||||
|  | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| from prompt_toolkit.formatted_text import FormattedText | ||||
|  | ||||
| from falyx.action import BaseAction | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
| @@ -26,6 +28,12 @@ class MenuOption: | ||||
|         """Render the menu option for display.""" | ||||
|         return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" | ||||
|  | ||||
|     def render_prompt(self, key: str) -> FormattedText: | ||||
|         """Render the menu option for prompt display.""" | ||||
|         return FormattedText( | ||||
|             [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class MenuOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
|   | ||||
| @@ -39,7 +39,7 @@ class ArgumentAction(Enum): | ||||
| class Argument: | ||||
|     """Represents a command-line argument.""" | ||||
|  | ||||
|     flags: list[str] | ||||
|     flags: tuple[str, ...] | ||||
|     dest: str  # Destination name for the argument | ||||
|     action: ArgumentAction = ( | ||||
|         ArgumentAction.STORE | ||||
| @@ -49,7 +49,7 @@ class Argument: | ||||
|     choices: list[str] | None = None  # List of valid choices for the argument | ||||
|     required: bool = False  # True if the argument is required | ||||
|     help: str = ""  # Help text for the argument | ||||
|     nargs: int | str = 1  # int, '?', '*', '+' | ||||
|     nargs: int | str | None = None  # int, '?', '*', '+', None | ||||
|     positional: bool = False  # True if no leading - or -- in flags | ||||
|  | ||||
|     def get_positional_text(self) -> str: | ||||
| @@ -151,6 +151,7 @@ class CommandArgumentParser: | ||||
|         aliases: list[str] | None = None, | ||||
|     ) -> None: | ||||
|         """Initialize the CommandArgumentParser.""" | ||||
|         self.console = Console(color_system="auto") | ||||
|         self.command_key: str = command_key | ||||
|         self.command_description: str = command_description | ||||
|         self.command_style: str = command_style | ||||
| @@ -163,7 +164,6 @@ class CommandArgumentParser: | ||||
|         self._flag_map: dict[str, Argument] = {} | ||||
|         self._dest_set: set[str] = set() | ||||
|         self._add_help() | ||||
|         self.console = Console(color_system="auto") | ||||
|  | ||||
|     def _add_help(self): | ||||
|         """Add help argument to the parser.""" | ||||
| @@ -185,9 +185,7 @@ class CommandArgumentParser: | ||||
|             raise CommandArgumentError("Positional arguments cannot have multiple flags") | ||||
|         return positional | ||||
|  | ||||
|     def _get_dest_from_flags( | ||||
|         self, flags: tuple[str, ...], dest: str | None | ||||
|     ) -> str | None: | ||||
|     def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: | ||||
|         """Convert flags to a destination name.""" | ||||
|         if dest: | ||||
|             if not dest.replace("_", "").isalnum(): | ||||
| @@ -216,7 +214,7 @@ class CommandArgumentParser: | ||||
|         return dest | ||||
|  | ||||
|     def _determine_required( | ||||
|         self, required: bool, positional: bool, nargs: int | str | ||||
|         self, required: bool, positional: bool, nargs: int | str | None | ||||
|     ) -> bool: | ||||
|         """Determine if the argument is required.""" | ||||
|         if required: | ||||
| @@ -234,7 +232,22 @@ class CommandArgumentParser: | ||||
|  | ||||
|         return required | ||||
|  | ||||
|     def _validate_nargs(self, nargs: int | str) -> int | str: | ||||
|     def _validate_nargs( | ||||
|         self, nargs: int | str | None, action: ArgumentAction | ||||
|     ) -> int | str | None: | ||||
|         if action in ( | ||||
|             ArgumentAction.STORE_FALSE, | ||||
|             ArgumentAction.STORE_TRUE, | ||||
|             ArgumentAction.COUNT, | ||||
|             ArgumentAction.HELP, | ||||
|         ): | ||||
|             if nargs is not None: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"nargs cannot be specified for {action} actions" | ||||
|                 ) | ||||
|             return None | ||||
|         if nargs is None: | ||||
|             nargs = 1 | ||||
|         allowed_nargs = ("?", "*", "+") | ||||
|         if isinstance(nargs, int): | ||||
|             if nargs <= 0: | ||||
| @@ -246,7 +259,9 @@ class CommandArgumentParser: | ||||
|             raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") | ||||
|         return nargs | ||||
|  | ||||
|     def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]: | ||||
|     def _normalize_choices( | ||||
|         self, choices: Iterable | None, expected_type: Any | ||||
|     ) -> list[Any]: | ||||
|         if choices is not None: | ||||
|             if isinstance(choices, dict): | ||||
|                 raise CommandArgumentError("choices cannot be a dict") | ||||
| @@ -293,8 +308,34 @@ class CommandArgumentParser: | ||||
|                             f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" | ||||
|                         ) | ||||
|  | ||||
|     def _validate_action( | ||||
|         self, action: ArgumentAction | str, positional: bool | ||||
|     ) -> ArgumentAction: | ||||
|         if not isinstance(action, ArgumentAction): | ||||
|             try: | ||||
|                 action = ArgumentAction(action) | ||||
|             except ValueError: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid action '{action}' is not a valid ArgumentAction" | ||||
|                 ) | ||||
|         if action in ( | ||||
|             ArgumentAction.STORE_TRUE, | ||||
|             ArgumentAction.STORE_FALSE, | ||||
|             ArgumentAction.COUNT, | ||||
|             ArgumentAction.HELP, | ||||
|         ): | ||||
|             if positional: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Action '{action}' cannot be used with positional arguments" | ||||
|                 ) | ||||
|  | ||||
|         return action | ||||
|  | ||||
|     def _resolve_default( | ||||
|         self, action: ArgumentAction, default: Any, nargs: str | int | ||||
|         self, | ||||
|         default: Any, | ||||
|         action: ArgumentAction, | ||||
|         nargs: str | int | None, | ||||
|     ) -> Any: | ||||
|         """Get the default value for the argument.""" | ||||
|         if default is None: | ||||
| @@ -328,7 +369,18 @@ class CommandArgumentParser: | ||||
|                     f"Flag '{flag}' must be a single character or start with '--'" | ||||
|                 ) | ||||
|  | ||||
|     def add_argument(self, *flags, **kwargs): | ||||
|     def add_argument( | ||||
|         self, | ||||
|         *flags, | ||||
|         action: str | ArgumentAction = "store", | ||||
|         nargs: int | str | None = None, | ||||
|         default: Any = None, | ||||
|         type: Any = str, | ||||
|         choices: Iterable | None = None, | ||||
|         required: bool = False, | ||||
|         help: str = "", | ||||
|         dest: str | None = None, | ||||
|     ) -> None: | ||||
|         """Add an argument to the parser. | ||||
|         Args: | ||||
|             name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). | ||||
| @@ -341,9 +393,10 @@ class CommandArgumentParser: | ||||
|             help: A brief description of the argument. | ||||
|             dest: The name of the attribute to be added to the object returned by parse_args(). | ||||
|         """ | ||||
|         expected_type = type | ||||
|         self._validate_flags(flags) | ||||
|         positional = self._is_positional(flags) | ||||
|         dest = self._get_dest_from_flags(flags, kwargs.get("dest")) | ||||
|         dest = self._get_dest_from_flags(flags, dest) | ||||
|         if dest in self._dest_set: | ||||
|             raise CommandArgumentError( | ||||
|                 f"Destination '{dest}' is already defined.\n" | ||||
| @@ -351,18 +404,9 @@ class CommandArgumentParser: | ||||
|                 "is not supported. Define a unique 'dest' for each argument." | ||||
|             ) | ||||
|         self._dest_set.add(dest) | ||||
|         action = kwargs.get("action", ArgumentAction.STORE) | ||||
|         if not isinstance(action, ArgumentAction): | ||||
|             try: | ||||
|                 action = ArgumentAction(action) | ||||
|             except ValueError: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid action '{action}' is not a valid ArgumentAction" | ||||
|                 ) | ||||
|         flags = list(flags) | ||||
|         nargs = self._validate_nargs(kwargs.get("nargs", 1)) | ||||
|         default = self._resolve_default(action, kwargs.get("default"), nargs) | ||||
|         expected_type = kwargs.get("type", str) | ||||
|         action = self._validate_action(action, positional) | ||||
|         nargs = self._validate_nargs(nargs, action) | ||||
|         default = self._resolve_default(default, action, nargs) | ||||
|         if ( | ||||
|             action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) | ||||
|             and default is not None | ||||
| @@ -371,14 +415,12 @@ class CommandArgumentParser: | ||||
|                 self._validate_default_list_type(default, expected_type, dest) | ||||
|             else: | ||||
|                 self._validate_default_type(default, expected_type, dest) | ||||
|         choices = self._normalize_choices(kwargs.get("choices"), expected_type) | ||||
|         choices = self._normalize_choices(choices, expected_type) | ||||
|         if default is not None and choices and default not in choices: | ||||
|             raise CommandArgumentError( | ||||
|                 f"Default value '{default}' not in allowed choices: {choices}" | ||||
|             ) | ||||
|         required = self._determine_required( | ||||
|             kwargs.get("required", False), positional, nargs | ||||
|         ) | ||||
|         required = self._determine_required(required, positional, nargs) | ||||
|         argument = Argument( | ||||
|             flags=flags, | ||||
|             dest=dest, | ||||
| @@ -387,7 +429,7 @@ class CommandArgumentParser: | ||||
|             default=default, | ||||
|             choices=choices, | ||||
|             required=required, | ||||
|             help=kwargs.get("help", ""), | ||||
|             help=help, | ||||
|             nargs=nargs, | ||||
|             positional=positional, | ||||
|         ) | ||||
| @@ -430,11 +472,11 @@ class CommandArgumentParser: | ||||
|         values = [] | ||||
|         i = start | ||||
|         if isinstance(spec.nargs, int): | ||||
|             # assert i + spec.nargs <= len( | ||||
|             #     args | ||||
|             # ), "Not enough arguments provided: shouldn't happen" | ||||
|             values = args[i : i + spec.nargs] | ||||
|             return values, i + spec.nargs | ||||
|         elif spec.nargs is None: | ||||
|             values = [args[i]] | ||||
|             return values, i + 1 | ||||
|         elif spec.nargs == "+": | ||||
|             if i >= len(args): | ||||
|                 raise CommandArgumentError( | ||||
| @@ -479,6 +521,8 @@ class CommandArgumentParser: | ||||
|             for next_spec in positional_args[j + 1 :]: | ||||
|                 if isinstance(next_spec.nargs, int): | ||||
|                     min_required += next_spec.nargs | ||||
|                 elif next_spec.nargs is None: | ||||
|                     min_required += 1 | ||||
|                 elif next_spec.nargs == "+": | ||||
|                     min_required += 1 | ||||
|                 elif next_spec.nargs == "?": | ||||
| @@ -521,7 +565,7 @@ class CommandArgumentParser: | ||||
|  | ||||
|         return i | ||||
|  | ||||
|     def parse_args( | ||||
|     async def parse_args( | ||||
|         self, args: list[str] | None = None, from_validate: bool = False | ||||
|     ) -> dict[str, Any]: | ||||
|         """Parse Falyx Command arguments.""" | ||||
| @@ -669,7 +713,7 @@ class CommandArgumentParser: | ||||
|         result.pop("help", None) | ||||
|         return result | ||||
|  | ||||
|     def parse_args_split( | ||||
|     async def parse_args_split( | ||||
|         self, args: list[str], from_validate: bool = False | ||||
|     ) -> tuple[tuple[Any, ...], dict[str, Any]]: | ||||
|         """ | ||||
| @@ -677,7 +721,7 @@ class CommandArgumentParser: | ||||
|             tuple[args, kwargs] - Positional arguments in defined order, | ||||
|             followed by keyword argument mapping. | ||||
|         """ | ||||
|         parsed = self.parse_args(args, from_validate) | ||||
|         parsed = await self.parse_args(args, from_validate) | ||||
|         args_list = [] | ||||
|         kwargs_dict = {} | ||||
|         for arg in self._arguments: | ||||
|   | ||||
| @@ -42,7 +42,7 @@ def infer_args_from_func( | ||||
|         else: | ||||
|             flags = [f"--{name.replace('_', '-')}"] | ||||
|         action = "store" | ||||
|         nargs: int | str = 1 | ||||
|         nargs: int | str | None = None | ||||
|  | ||||
|         if arg_type is bool: | ||||
|             if param.default is False: | ||||
|   | ||||
| @@ -271,7 +271,7 @@ async def prompt_for_index( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     show_table: bool = True, | ||||
| ): | ||||
| ) -> int: | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.33" | ||||
| __version__ = "0.1.35" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.33" | ||||
| version = "0.1.35" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # test_command.py | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction | ||||
| from falyx.action import Action, BaseIOAction, ChainedAction | ||||
| from falyx.command import Command | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.retry import RetryPolicy | ||||
|   | ||||
| @@ -5,98 +5,109 @@ from falyx.parsers import ArgumentAction, CommandArgumentParser | ||||
| from falyx.signals import HelpSignal | ||||
|  | ||||
|  | ||||
| def build_parser_and_parse(args, config): | ||||
| async def build_parser_and_parse(args, config): | ||||
|     cap = CommandArgumentParser() | ||||
|     config(cap) | ||||
|     return cap.parse_args(args) | ||||
|     return await cap.parse_args(args) | ||||
|  | ||||
|  | ||||
| def test_none(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_none(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--foo", type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(None, config) | ||||
|     parsed = await build_parser_and_parse(None, config) | ||||
|     assert parsed["foo"] is None | ||||
|  | ||||
|  | ||||
| def test_append_multiple_flags(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_multiple_flags(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config) | ||||
|     parsed = await build_parser_and_parse( | ||||
|         ["--tag", "a", "--tag", "b", "--tag", "c"], config | ||||
|     ) | ||||
|     assert parsed["tag"] == ["a", "b", "c"] | ||||
|  | ||||
|  | ||||
| def test_positional_nargs_plus_and_single(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_positional_nargs_plus_and_single(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("files", nargs="+", type=str) | ||||
|         parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||
|     parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||
|     assert parsed["files"] == ["a", "b", "c"] | ||||
|     assert parsed["mode"] == "prod" | ||||
|  | ||||
|  | ||||
| def test_type_validation_failure(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_type_validation_failure(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--count", type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse(["--count", "abc"], config) | ||||
|         await build_parser_and_parse(["--count", "abc"], config) | ||||
|  | ||||
|  | ||||
| def test_required_field_missing(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_required_field_missing(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--env", type=str, required=True) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse([], config) | ||||
|         await build_parser_and_parse([], config) | ||||
|  | ||||
|  | ||||
| def test_choices_enforced(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_choices_enforced(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--mode", choices=["dev", "prod"]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse(["--mode", "staging"], config) | ||||
|         await build_parser_and_parse(["--mode", "staging"], config) | ||||
|  | ||||
|  | ||||
| def test_boolean_flags(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_boolean_flags(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) | ||||
|         parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--debug", "--no-debug"], config) | ||||
|     parsed = await build_parser_and_parse(["--debug", "--no-debug"], config) | ||||
|     assert parsed["debug"] is True | ||||
|     assert parsed["no_debug"] is False | ||||
|     parsed = build_parser_and_parse([], config) | ||||
|     print(parsed) | ||||
|     parsed = await build_parser_and_parse([], config) | ||||
|     assert parsed["debug"] is False | ||||
|     assert parsed["no_debug"] is True | ||||
|  | ||||
|  | ||||
| def test_count_action(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_count_action(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("-v", action=ArgumentAction.COUNT) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["-v", "-v", "-v"], config) | ||||
|     parsed = await build_parser_and_parse(["-v", "-v", "-v"], config) | ||||
|     assert parsed["v"] == 3 | ||||
|  | ||||
|  | ||||
| def test_nargs_star(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_nargs_star(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("args", nargs="*", type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["one", "two", "three"], config) | ||||
|     parsed = await build_parser_and_parse(["one", "two", "three"], config) | ||||
|     assert parsed["args"] == ["one", "two", "three"] | ||||
|  | ||||
|  | ||||
| def test_flag_and_positional_mix(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_flag_and_positional_mix(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--env", type=str) | ||||
|         parser.add_argument("tasks", nargs="+") | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||
|     parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||
|     assert parsed["env"] == "prod" | ||||
|     assert parsed["tasks"] == ["build", "test"] | ||||
|  | ||||
| @@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest(): | ||||
|     parser.add_argument("-f", "--falyx") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx"] | ||||
|     assert arg.flags == ("-f", "--falyx") | ||||
|  | ||||
|  | ||||
| def test_add_argument_flag_dest_conflict(): | ||||
| @@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest(): | ||||
|     parser.add_argument("-f", "--falyx", "--test", dest="falyx") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx", "--test"] | ||||
|     assert arg.flags == ("-f", "--falyx", "--test") | ||||
|  | ||||
|  | ||||
| def test_add_argument_multiple_flags_dest(): | ||||
| @@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest(): | ||||
|     parser.add_argument("-f", "--falyx", "--test") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx", "--test"] | ||||
|     assert arg.flags == ("-f", "--falyx", "--test") | ||||
|  | ||||
|  | ||||
| def test_add_argument_single_flag_dest(): | ||||
| @@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest(): | ||||
|     parser.add_argument("-f") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "f" | ||||
|     assert arg.flags == ["-f"] | ||||
|     assert arg.flags == ("-f",) | ||||
|  | ||||
|  | ||||
| def test_add_argument_bad_dest(): | ||||
| @@ -257,7 +268,7 @@ def test_add_argument_default_value(): | ||||
|     parser.add_argument("--falyx", default="default_value") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.default == "default_value" | ||||
|  | ||||
|  | ||||
| @@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices(): | ||||
|         parser.add_argument("--falyx", choices=["a", "b"], default="c") | ||||
|  | ||||
|  | ||||
| def test_add_argument_choices(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_add_argument_choices(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     # ✅ Choices provided | ||||
|     parser.add_argument("--falyx", choices=["a", "b", "c"]) | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.choices == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["--falyx", "a"]) | ||||
|     args = await parser.parse_args(["--falyx", "a"]) | ||||
|     assert args["falyx"] == "a" | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--falyx", "d"]) | ||||
|         await parser.parse_args(["--falyx", "d"]) | ||||
|  | ||||
|  | ||||
| def test_add_argument_choices_invalid(): | ||||
| @@ -352,7 +364,7 @@ def test_add_argument_nargs(): | ||||
|     parser.add_argument("--falyx", nargs=2) | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.nargs == 2 | ||||
|  | ||||
|  | ||||
| @@ -377,56 +389,60 @@ def test_get_argument(): | ||||
|     parser.add_argument("--falyx", type=str, default="default_value") | ||||
|     arg = parser.get_argument("falyx") | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.default == "default_value" | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|  | ||||
|     assert args["files"] == ["a", "b"] | ||||
|     assert args["mode"] == "c" | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["a"]) | ||||
|     args = await parser.parse_args(["a"]) | ||||
|     assert args["files"] == ["a"] | ||||
|  | ||||
|  | ||||
| def test_parse_args_flagged_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_flagged_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["--files", "a", "b", "c"]) | ||||
|     args = await parser.parse_args(["--files", "a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["--files", "a"]) | ||||
|     args = await parser.parse_args(["--files", "a"]) | ||||
|     print(args) | ||||
|     assert args["files"] == ["a"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_numbered_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_numbered_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs=2, type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b"]) | ||||
|     args = await parser.parse_args(["a", "b"]) | ||||
|     assert args["files"] == ["a", "b"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         args = parser.parse_args(["a"]) | ||||
|         args = await parser.parse_args(["a"]) | ||||
|         print(args) | ||||
|  | ||||
|  | ||||
| @@ -436,48 +452,53 @@ def test_parse_args_nargs_zero(): | ||||
|         parser.add_argument("files", nargs=0, type=str) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_more_than_expected(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_more_than_expected(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs=2, type=str) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["a", "b", "c", "d"]) | ||||
|         await parser.parse_args(["a", "b", "c", "d"]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_one_or_none(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_one_or_none(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="?", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a"]) | ||||
|     args = await parser.parse_args(["a"]) | ||||
|     assert args["files"] == "a" | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] is None | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="*", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_positional_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_positional_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         args = parser.parse_args([]) | ||||
|         args = await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_multiple_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_multiple_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
| @@ -485,7 +506,7 @@ def test_parse_args_nargs_multiple_positional(): | ||||
|     parser.add_argument("target", nargs="*") | ||||
|     parser.add_argument("extra", nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c", "d", "e"]) | ||||
|     args = await parser.parse_args(["a", "b", "c", "d", "e"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|     assert args["mode"] == "d" | ||||
|     assert args["action"] == [] | ||||
| @@ -493,186 +514,209 @@ def test_parse_args_nargs_multiple_positional(): | ||||
|     assert args["extra"] == ["e"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args([]) | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_invalid_positional_arguments(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_invalid_positional_arguments(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", nargs="*", type=int) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["1", "2", "c", "d"]) | ||||
|         await parser.parse_args(["1", "2", "c", "d"]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_append(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     assert args["numbers"] == [1, 2, 3] | ||||
|  | ||||
|     args = parser.parse_args(["--numbers", "1"]) | ||||
|     args = await parser.parse_args(["--numbers", "1"]) | ||||
|     assert args["numbers"] == [1] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["numbers"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_append(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") | ||||
|     parser.add_argument("--mode") | ||||
|  | ||||
|     args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||
|     args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||
|     assert args["numbers"] == [[1, 2, 3], [4, 5]] | ||||
|  | ||||
|     args = parser.parse_args(["1"]) | ||||
|     args = await parser.parse_args(["1"]) | ||||
|     assert args["numbers"] == [[1]] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["numbers"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_append_flagged_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_append_flagged_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--numbers", "a"]) | ||||
|         await parser.parse_args(["--numbers", "a"]) | ||||
|  | ||||
|  | ||||
| def test_append_groups_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_groups_nargs(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) | ||||
|  | ||||
|     parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||
|     parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||
|     assert parsed["item"] == [["a", "b"], ["c", "d"]] | ||||
|  | ||||
|  | ||||
| def test_extend_flattened(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_flattened(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) | ||||
|  | ||||
|     parsed = cap.parse_args(["--value", "x", "--value", "y"]) | ||||
|     parsed = await cap.parse_args(["--value", "x", "--value", "y"]) | ||||
|     assert parsed["value"] == ["x", "y"] | ||||
|  | ||||
|  | ||||
| def test_parse_args_split_order(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_split_order(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("a") | ||||
|     cap.add_argument("--x") | ||||
|     cap.add_argument("b", nargs="*") | ||||
|     args, kwargs = cap.parse_args_split(["1", "--x", "100", "2"]) | ||||
|     args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) | ||||
|     assert args == ("1", ["2"]) | ||||
|     assert kwargs == {"x": "100"} | ||||
|  | ||||
|  | ||||
| def test_help_signal_triggers(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_help_signal_triggers(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--foo") | ||||
|     with pytest.raises(HelpSignal): | ||||
|         parser.parse_args(["--help"]) | ||||
|         await parser.parse_args(["--help"]) | ||||
|  | ||||
|  | ||||
| def test_empty_parser_defaults(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_empty_parser_defaults(): | ||||
|     parser = CommandArgumentParser() | ||||
|     with pytest.raises(HelpSignal): | ||||
|         parser.parse_args(["--help"]) | ||||
|         await parser.parse_args(["--help"]) | ||||
|  | ||||
|  | ||||
| def test_extend_basic(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_basic(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) | ||||
|  | ||||
|     args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||
|     args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||
|     assert args["tag"] == ["a", "b", "c"] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_2(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_2(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) | ||||
|  | ||||
|     args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||
|     args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||
|     assert args["pair"] == ["a", "b", "c", "d"] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_star(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_star(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||
|  | ||||
|     args = parser.parse_args(["--files", "x", "y", "z"]) | ||||
|     args = await parser.parse_args(["--files", "x", "y", "z"]) | ||||
|     assert args["files"] == ["x", "y", "z"] | ||||
|  | ||||
|     args = parser.parse_args(["--files"]) | ||||
|     args = await parser.parse_args(["--files"]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||
|     args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||
|     assert args["inputs"] == [1, 2, 3, 4] | ||||
|  | ||||
|  | ||||
| def test_extend_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--nums", "a"]) | ||||
|         await parser.parse_args(["--nums", "a"]) | ||||
|  | ||||
|  | ||||
| def test_greedy_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_greedy_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--nums", nargs="*", type=int) | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--nums", "a"]) | ||||
|         await parser.parse_args(["--nums", "a"]) | ||||
|  | ||||
|  | ||||
| def test_append_vs_extend_behavior(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_vs_extend_behavior(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||
|  | ||||
|     args = parser.parse_args( | ||||
|     args = await parser.parse_args( | ||||
|         ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] | ||||
|     ) | ||||
|     assert args["x"] == [["a", "b"], ["c", "d"]] | ||||
|     assert args["y"] == ["1", "2", "3", "4"] | ||||
|  | ||||
|  | ||||
| def test_append_vs_extend_behavior_error(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_vs_extend_behavior_error(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||
|  | ||||
|     # This should raise an error because the last argument is not a valid pair | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]) | ||||
|         await parser.parse_args( | ||||
|             ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"] | ||||
|         ) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]) | ||||
|         await parser.parse_args( | ||||
|             ["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| def test_extend_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_extend_positional_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_positional_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args([]) | ||||
|         await parser.parse_args([]) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user