diff --git a/examples/argument_examples.py b/examples/argument_examples.py index 38946c8..e9f294a 100644 --- a/examples/argument_examples.py +++ b/examples/argument_examples.py @@ -25,11 +25,24 @@ async def test_args( path: Path | None = None, tag: str | None = None, verbose: bool | None = None, - number: int | None = None, + numbers: list[int] | None = None, + just_a_bool: bool = False, ) -> str: + if numbers is None: + numbers = [] if verbose: - print(f"Deploying {service}:{tag}:{number} to {region} at {place} from {path}...") - return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}." + print( + f"Deploying {service}:{tag}:{"|".join(str(number) for number in numbers)} to {region} at {place} from {path}..." + ) + return f"{service}:{tag}:{"|".join(str(number) for number in numbers)} deployed to {region} at {place} from {path}." + + +async def test_path_arg(*paths: Path) -> str: + return f"Path argument received: {'|'.join(str(path) for path in paths)}" + + +async def test_positional_numbers(*numbers: int) -> str: + return f"Positional numbers received: {', '.join(str(num) for num in numbers)}" def default_config(parser: CommandArgumentParser) -> None: @@ -55,6 +68,7 @@ def default_config(parser: CommandArgumentParser) -> None: choices=["us-east-1", "us-west-2", "eu-west-1"], ) parser.add_argument( + "-p", "--path", type=Path, help="Path to the configuration file.", @@ -65,16 +79,25 @@ def default_config(parser: CommandArgumentParser) -> None: help="Enable verbose output.", ) parser.add_argument( + "-t", "--tag", type=str, help="Optional tag for the deployment.", suggestions=["latest", "stable", "beta"], ) parser.add_argument( - "--number", + "--numbers", type=int, + nargs="*", + default=[1, 2, 3], help="Optional number argument.", ) + parser.add_argument( + "-j", + "--just-a-bool", + action="store_true", + help="Just a boolean flag.", + ) parser.add_tldr_examples( [ ("web", "Deploy 'web' to the default location (New York)"), @@ -84,6 +107,40 @@ def default_config(parser: CommandArgumentParser) -> None: ) +def path_config(parser: CommandArgumentParser) -> None: + """Argument configuration for path testing command.""" + parser.add_argument( + "paths", + type=Path, + nargs="*", + help="One or more file or directory paths.", + ) + parser.add_tldr_examples( + [ + ("/path/to/file.txt", "Single file path"), + ("/path/to/dir1 /path/to/dir2", "Multiple directory paths"), + ("/path/with spaces/file.txt", "Path with spaces"), + ] + ) + + +def numbers_config(parser: CommandArgumentParser) -> None: + """Argument configuration for positional numbers testing command.""" + parser.add_argument( + "numbers", + type=int, + nargs="*", + help="One or more integers.", + ) + parser.add_tldr_examples( + [ + ("1 2 3", "Three numbers"), + ("42", "Single number"), + ("", "No numbers"), + ] + ) + + flx = Falyx( "Argument Examples", program="argument_examples.py", @@ -105,4 +162,30 @@ flx.add_command( argument_config=default_config, ) +flx.add_command( + key="P", + aliases=["path"], + description="Path Command", + help_text="A command to test path argument parsing.", + action=Action( + name="test_path_arg", + action=test_path_arg, + ), + style="bold #F2B3EB", + argument_config=path_config, +) + +flx.add_command( + key="N", + aliases=["numbers"], + description="Numbers Command", + help_text="A command to test positional numbers argument parsing.", + action=Action( + name="test_positional_numbers", + action=test_positional_numbers, + ), + style="bold #F2F2B3", + argument_config=numbers_config, +) + asyncio.run(flx.run()) diff --git a/falyx/falyx.py b/falyx/falyx.py index bca7aad..96e6e76 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -281,7 +281,7 @@ class Falyx: def _get_exit_command(self) -> Command: """Returns the back command for the menu.""" - return Command( + exit_command = Command( key="X", description="Exit", action=Action("Exit", action=_noop), @@ -293,6 +293,9 @@ class Falyx: program=self.program, help_text="Exit the program.", ) + if exit_command.arg_parser: + exit_command.arg_parser.add_tldr_examples([("", "Exit the program.")]) + return exit_command def _get_history_command(self) -> Command: """Returns the history command for the menu.""" @@ -337,6 +340,19 @@ class Falyx: parser.add_argument( "-l", "--last-result", action="store_true", help="Get the last result" ) + parser.add_tldr_examples( + [ + ("", "Show the full execution history."), + ("-n build", "Show history entries for the 'build' command."), + ("-s success", "Show only successful executions."), + ("-s error", "Show only failed executions."), + ("-i 3", "Show the history entry at index 3."), + ("-r 0", "Show the result or traceback for entry index 0."), + ("-l", "Show the last execution result."), + ("-c", "Clear the execution history."), + ] + ) + return Command( key="Y", description="History", @@ -486,6 +502,7 @@ class Falyx: aliases=["HELP", "?"], program=self.program, options_manager=self.options, + _is_help_command=True, ) parser.add_argument( "-t", @@ -506,8 +523,8 @@ class Falyx: ("", "Show all commands."), ("-k [COMMAND]", "Show detailed help for a specific command."), ("-Tk [COMMAND]", "Show quick usage examples for a specific command."), - ("--tldr", "Show these quick usage examples."), - ("--tag [TAG]", "Show commands with the specified tag."), + ("-T", "Show these quick usage examples."), + ("-t [TAG]", "Show commands with the specified tag."), ] ) return Command( @@ -699,6 +716,8 @@ class Falyx: program=self.program, help_text=help_text, ) + if self.exit_command.arg_parser: + self.exit_command.arg_parser.add_tldr_examples([("", help_text)]) def add_submenu( self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index eca9483..4120525 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -103,6 +103,7 @@ class CommandArgumentParser: tldr_examples: list[tuple[str, str]] | None = None, program: str | None = None, options_manager: OptionsManager | None = None, + _is_help_command: bool = False, ) -> None: """Initialize the CommandArgumentParser.""" self.console: Console = console @@ -123,6 +124,7 @@ class CommandArgumentParser: self._last_positional_states: dict[str, ArgumentState] = {} self._last_keyword_states: dict[str, ArgumentState] = {} self._tldr_examples: list[TLDRExample] = [] + self._is_help_command: bool = _is_help_command if tldr_examples: self.add_tldr_examples(tldr_examples) self.options_manager: OptionsManager = options_manager or OptionsManager() @@ -396,6 +398,32 @@ class CommandArgumentParser: raise CommandArgumentError( f"Default value cannot be set for action {action}. It is a boolean flag." ) + elif action == ArgumentAction.HELP: + raise CommandArgumentError( + "Default value cannot be set for action HELP. It is a help flag." + ) + elif action == ArgumentAction.TLDR: + raise CommandArgumentError( + "Default value cannot be set for action TLDR. It is a tldr flag." + ) + elif action == ArgumentAction.COUNT: + raise CommandArgumentError( + "Default value cannot be set for action COUNT. It is a count flag." + ) + if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance( + default, list + ): + raise CommandArgumentError( + f"Default value for action {action} must be a list, got {type(default).__name__}" + ) + if isinstance(nargs, int) and nargs == 1: + if not isinstance(default, list): + default = [default] + if isinstance(nargs, int) or nargs in ("*", "+"): + if not isinstance(default, list): + raise CommandArgumentError( + f"Default value for action {action} with nargs {nargs} must be a list, got {type(default).__name__}" + ) return default def _validate_flags(self, flags: tuple[str, ...]) -> None: @@ -540,10 +568,17 @@ class CommandArgumentParser: else: self._validate_default_type(default, expected_type, dest) choices = self._normalize_choices(choices, expected_type, action) - if default is not None and choices and default not in choices: - raise CommandArgumentError( - f"Default value '{default}' not in allowed choices: {choices}" - ) + if default is not None and choices: + if isinstance(default, list): + if not all(choice in choices for choice in default): + raise CommandArgumentError( + f"Default list value {default!r} for '{dest}' must be a subset of choices: {choices}" + ) + elif default not in choices: + # If default is not in choices, raise an error + raise CommandArgumentError( + f"Default value '{default}' not in allowed choices: {choices}" + ) required = self._determine_required(required, positional, nargs, action) if not isinstance(suggestions, Sequence) and suggestions is not None: raise CommandArgumentError( @@ -583,7 +618,9 @@ class CommandArgumentParser: Returns: Argument or None: Matching Argument instance, if defined. """ - return next((a for a in self._arguments if a.dest == dest), None) + return next( + (argument for argument in self._arguments if argument.dest == dest), None + ) def to_definition_list(self) -> list[dict[str, Any]]: """ @@ -620,11 +657,12 @@ class CommandArgumentParser: return None value_check = result.get(spec.dest) if isinstance(value_check, list): - for value in value_check: - if value in spec.choices: - return None + if all(value in spec.choices for value in value_check): + return None if value_check in spec.choices: return None + arg_states[spec.dest].reset() + arg_states[spec.dest].has_invalid_choice = True raise CommandArgumentError( f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" ) @@ -651,7 +689,7 @@ class CommandArgumentParser: ) def _consume_nargs( - self, args: list[str], start: int, spec: Argument + self, args: list[str], index: int, spec: Argument ) -> tuple[list[str], int]: assert ( spec.nargs is None @@ -660,33 +698,32 @@ class CommandArgumentParser: and spec.nargs in ("+", "*", "?") ), f"Invalid nargs value: {spec.nargs}" values = [] - i = start if isinstance(spec.nargs, int): - values = args[i : i + spec.nargs] - return values, i + spec.nargs + values = args[index : index + spec.nargs] + return values, index + spec.nargs elif spec.nargs == "+": - if i >= len(args): + if index >= len(args): raise CommandArgumentError( f"Expected at least one value for '{spec.dest}'" ) - while i < len(args) and args[i] not in self._keyword: - values.append(args[i]) - i += 1 + while index < len(args) and args[index] not in self._keyword: + values.append(args[index]) + index += 1 assert values, "Expected at least one value for '+' nargs: shouldn't happen" - return values, i + return values, index elif spec.nargs == "*": - while i < len(args) and args[i] not in self._keyword: - values.append(args[i]) - i += 1 - return values, i + while index < len(args) and args[index] not in self._keyword: + values.append(args[index]) + index += 1 + return values, index elif spec.nargs == "?": - if i < len(args) and args[i] not in self._keyword: - return [args[i]], i + 1 - return [], i + if index < len(args) and args[index] not in self._keyword: + return [args[index]], index + 1 + return [], index elif spec.nargs is None: - if i < len(args) and args[i] not in self._keyword: - return [args[i]], i + 1 - return [], i + if index < len(args) and args[index] not in self._keyword: + return [args[index]], index + 1 + return [], index assert False, "Invalid nargs value: shouldn't happen" async def _consume_all_positional_args( @@ -697,20 +734,21 @@ class CommandArgumentParser: consumed_positional_indicies: set[int], arg_states: dict[str, ArgumentState], from_validate: bool = False, + base_index: int = 0, ) -> int: remaining_positional_args = [ - (j, spec) - for j, spec in enumerate(positional_args) - if j not in consumed_positional_indicies + (spec_index, spec) + for spec_index, spec in enumerate(positional_args) + if spec_index not in consumed_positional_indicies ] - i = 0 + index = 0 - for j, spec in remaining_positional_args: + for spec_index, spec in remaining_positional_args: # estimate how many args the remaining specs might need - is_last = j == len(positional_args) - 1 - remaining = len(args) - i + is_last = spec_index == len(positional_args) - 1 + remaining = len(args) - index min_required = 0 - for next_spec in positional_args[j + 1 :]: + for next_spec in positional_args[spec_index + 1 :]: assert ( next_spec.nargs is None or isinstance(next_spec.nargs, int) @@ -732,17 +770,25 @@ class CommandArgumentParser: elif next_spec.nargs == "*": continue - slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)] - values, new_i = self._consume_nargs(slice_args, 0, spec) - i += new_i + slice_args = ( + args[index:] + if is_last + else args[index : index + (remaining - min_required)] + ) + values, new_index = self._consume_nargs(slice_args, 0, spec) + index += new_index try: typed = [coerce_value(value, spec.type) for value in values] except Exception as error: - if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"): - token = args[i - new_i] + if len(args[index - new_index :]) == 1 and args[ + index - new_index + ].startswith("-"): + token = args[index - new_index] self._raise_remaining_args_error(token, arg_states) else: + arg_states[spec.dest].reset() + arg_states[spec.dest].has_invalid_choice = True raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" ) from error @@ -758,7 +804,7 @@ class CommandArgumentParser: f"[{spec.dest}] Action failed: {error}" ) from error self._check_if_in_choices(spec, result, arg_states) - arg_states[spec.dest].consumed = True + arg_states[spec.dest].set_consumed(base_index + index) elif not typed and spec.default: result[spec.dest] = spec.default elif spec.action == ArgumentAction.APPEND: @@ -773,31 +819,53 @@ class CommandArgumentParser: elif spec.nargs in (None, 1, "?"): result[spec.dest] = typed[0] if len(typed) == 1 else typed self._check_if_in_choices(spec, result, arg_states) - arg_states[spec.dest].consumed = True + arg_states[spec.dest].set_consumed(base_index + index) else: self._check_if_in_choices(spec, result, arg_states) - arg_states[spec.dest].consumed = True + arg_states[spec.dest].set_consumed(base_index + index) result[spec.dest] = typed if spec.nargs not in ("*", "+"): - consumed_positional_indicies.add(j) + consumed_positional_indicies.add(spec_index) - if i < len(args): - if len(args[i:]) == 1 and args[i].startswith("-"): - token = args[i] + if index < len(args): + if len(args[index:]) == 1 and args[index].startswith("-"): + token = args[index] self._raise_remaining_args_error(token, arg_states) else: - plural = "s" if len(args[i:]) > 1 else "" + plural = "s" if len(args[index:]) > 1 else "" raise CommandArgumentError( - f"Unexpected positional argument{plural}: {', '.join(args[i:])}" + f"Unexpected positional argument{plural}: {', '.join(args[index:])}" ) - return i + return index - def _expand_posix_bundling(self, token: str) -> list[str] | str: + def _expand_posix_bundling( + self, token: str, last_flag_argument: Argument | None + ) -> list[str] | str: """Expand POSIX-style bundled arguments into separate arguments.""" expanded = [] - if token.startswith("-") and not token.startswith("--") and len(token) > 2: + if last_flag_argument: + if last_flag_argument.type is not str and last_flag_argument.action not in ( + ArgumentAction.STORE_TRUE, + ArgumentAction.STORE_FALSE, + ArgumentAction.STORE_BOOL_OPTIONAL, + ArgumentAction.COUNT, + ArgumentAction.HELP, + ArgumentAction.TLDR, + ): + try: + last_flag_argument.type(token) + return token + except (ValueError, TypeError): + pass + + if ( + token.startswith("-") + and not token.startswith("--") + and len(token) > 2 + and not self._is_valid_dash_token_positional_value(token) + ): # POSIX bundle # e.g. -abc -> -a -b -c for char in token[1:]: @@ -810,11 +878,24 @@ class CommandArgumentParser: return token return expanded + def _is_valid_dash_token_positional_value(self, token: str) -> bool: + """Checks if any remaining positional arguments take valid dash-prefixed values.""" + valid = False + try: + for arg in self._positional.values(): + if arg.type is not str: + arg.type(token) + valid = True + break + except (ValueError, TypeError): + valid = False + return valid + async def _handle_token( self, token: str, args: list[str], - i: int, + index: int, result: dict[str, Any], positional_args: list[Argument], consumed_positional_indices: set[int], @@ -829,21 +910,23 @@ class CommandArgumentParser: if action == ArgumentAction.HELP: if not from_validate: self.render_help() - arg_states[spec.dest].consumed = True + arg_states[spec.dest].set_consumed() raise HelpSignal() elif action == ArgumentAction.TLDR: if not from_validate: self.render_tldr() - arg_states[spec.dest].consumed = True + arg_states[spec.dest].set_consumed() raise HelpSignal() elif action == ArgumentAction.ACTION: assert isinstance( spec.resolver, BaseAction ), "resolver should be an instance of BaseAction" - values, new_i = self._consume_nargs(args, i + 1, spec) + values, new_index = self._consume_nargs(args, index + 1, spec) try: typed_values = [coerce_value(value, spec.type) for value in values] except ValueError as error: + arg_states[spec.dest].reset() + arg_states[spec.dest].has_invalid_choice = True raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" ) from error @@ -855,34 +938,36 @@ class CommandArgumentParser: f"[{spec.dest}] Action failed: {error}" ) from error self._check_if_in_choices(spec, result, arg_states) - arg_states[spec.dest].consumed = True - consumed_indices.update(range(i, new_i)) - i = new_i + arg_states[spec.dest].set_consumed(new_index) + consumed_indices.update(range(index, new_index)) + index = new_index elif action == ArgumentAction.STORE_TRUE: result[spec.dest] = True - arg_states[spec.dest].consumed = True - consumed_indices.add(i) - i += 1 + arg_states[spec.dest].set_consumed(index) + consumed_indices.add(index) + index += 1 elif action == ArgumentAction.STORE_FALSE: result[spec.dest] = False - arg_states[spec.dest].consumed = True - consumed_indices.add(i) - i += 1 + arg_states[spec.dest].set_consumed(index) + consumed_indices.add(index) + index += 1 elif action == ArgumentAction.STORE_BOOL_OPTIONAL: result[spec.dest] = spec.type(True) - arg_states[spec.dest].consumed = True - consumed_indices.add(i) - i += 1 + arg_states[spec.dest].set_consumed(index) + consumed_indices.add(index) + index += 1 elif action == ArgumentAction.COUNT: result[spec.dest] = result.get(spec.dest, 0) + 1 - consumed_indices.add(i) - i += 1 + consumed_indices.add(index) + index += 1 elif action == ArgumentAction.APPEND: assert result.get(spec.dest) is not None, "dest should not be None" - values, new_i = self._consume_nargs(args, i + 1, spec) + values, new_index = self._consume_nargs(args, index + 1, spec) try: typed_values = [coerce_value(value, spec.type) for value in values] except ValueError as error: + arg_states[spec.dest].reset() + arg_states[spec.dest].has_invalid_choice = True raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" ) from error @@ -890,25 +975,29 @@ class CommandArgumentParser: result[spec.dest].append(spec.type(values[0])) else: result[spec.dest].append(typed_values) - consumed_indices.update(range(i, new_i)) - i = new_i + consumed_indices.update(range(index, new_index)) + index = new_index elif action == ArgumentAction.EXTEND: assert result.get(spec.dest) is not None, "dest should not be None" - values, new_i = self._consume_nargs(args, i + 1, spec) + values, new_index = self._consume_nargs(args, index + 1, spec) try: typed_values = [coerce_value(value, spec.type) for value in values] except ValueError as error: + arg_states[spec.dest].reset() + arg_states[spec.dest].has_invalid_choice = True raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" ) from error result[spec.dest].extend(typed_values) - consumed_indices.update(range(i, new_i)) - i = new_i + consumed_indices.update(range(index, new_index)) + index = new_index else: - values, new_i = self._consume_nargs(args, i + 1, spec) + values, new_index = self._consume_nargs(args, index + 1, spec) try: typed_values = [coerce_value(value, spec.type) for value in values] except ValueError as error: + arg_states[spec.dest].reset() + arg_states[spec.dest].has_invalid_choice = True raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" ) from error @@ -934,37 +1023,61 @@ class CommandArgumentParser: raise CommandArgumentError( f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values." ) - if spec.nargs in (None, 1, "?") and spec.action != ArgumentAction.APPEND: + if spec.nargs in (None, 1, "?"): result[spec.dest] = ( typed_values[0] if len(typed_values) == 1 else typed_values ) else: result[spec.dest] = typed_values self._check_if_in_choices(spec, result, arg_states) - arg_states[spec.dest].consumed = True - consumed_indices.update(range(i, new_i)) - i = new_i - elif token.startswith("-"): + arg_states[spec.dest].set_consumed(new_index) + consumed_indices.update(range(index, new_index)) + index = new_index + elif token.startswith("-") and not self._is_valid_dash_token_positional_value( + token + ): self._raise_remaining_args_error(token, arg_states) else: # Get the next flagged argument index if it exists next_flagged_index = -1 - for index, arg in enumerate(args[i:], start=i): + for scan_index, arg in enumerate(args[index:], start=index): if arg in self._keyword: - next_flagged_index = index + next_flagged_index = scan_index break if next_flagged_index == -1: next_flagged_index = len(args) args_consumed = await self._consume_all_positional_args( - args[i:next_flagged_index], + args[index:next_flagged_index], result, positional_args, consumed_positional_indices, arg_states=arg_states, from_validate=from_validate, + base_index=index, ) - i += args_consumed - return i + index += args_consumed + return index + + def _find_last_flag_argument(self, args: list[str]) -> Argument | None: + last_flag_argument = None + for arg in reversed(args): + if arg in self._keyword: + last_flag_argument = self._keyword[arg] + break + return last_flag_argument + + def _resolve_posix_bundling(self, args: list[str]) -> None: + """Expand POSIX-style bundled arguments into separate arguments.""" + last_flag_argument: Argument | None = None + expand_index = 0 + while expand_index < len(args): + last_flag_argument = self._find_last_flag_argument(args[:expand_index]) + expand_token = self._expand_posix_bundling( + args[expand_index], last_flag_argument + ) + if isinstance(expand_token, list): + args[expand_index : expand_index + 1] = expand_token + expand_index += len(expand_token) if isinstance(expand_token, list) else 1 async def parse_args( self, args: list[str] | None = None, from_validate: bool = False @@ -997,16 +1110,15 @@ class CommandArgumentParser: consumed_positional_indices: set[int] = set() consumed_indices: set[int] = set() - i = 0 - while i < len(args): - token = self._expand_posix_bundling(args[i]) - if isinstance(token, list): - args[i : i + 1] = token - token = args[i] - i = await self._handle_token( + self._resolve_posix_bundling(args) + + index = 0 + while index < len(args): + token = args[index] + index = await self._handle_token( token, args, - i, + index, result, positional_args, consumed_positional_indices, @@ -1031,21 +1143,17 @@ class CommandArgumentParser: and from_validate ): if not args: - arg_states[spec.dest].consumed = False + arg_states[spec.dest].reset() raise CommandArgumentError( f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" ) continue # Lazy resolvers are not validated here - arg_states[spec.dest].consumed = False + arg_states[spec.dest].reset() raise CommandArgumentError( f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" ) - if spec.choices and result.get(spec.dest) not in spec.choices: - arg_states[spec.dest].consumed = False - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" - ) + self._check_if_in_choices(spec, result, arg_states) if spec.action == ArgumentAction.ACTION: continue @@ -1059,18 +1167,18 @@ class CommandArgumentParser: if spec.action == ArgumentAction.APPEND: for group in result[spec.dest]: if len(group) % spec.nargs != 0: - arg_states[spec.dest].consumed = False + arg_states[spec.dest].reset() raise CommandArgumentError( f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" ) elif spec.action == ArgumentAction.EXTEND: if len(result[spec.dest]) % spec.nargs != 0: - arg_states[spec.dest].consumed = False + arg_states[spec.dest].reset() raise CommandArgumentError( f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" ) elif len(result[spec.dest]) != spec.nargs: - arg_states[spec.dest].consumed = False + arg_states[spec.dest].reset() raise CommandArgumentError( f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}" ) @@ -1120,6 +1228,72 @@ class CommandArgumentParser: ] return completions[:100] + def _is_mid_value( + self, state: ArgumentState | None, args: list[str], cursor_at_end_of_token: bool + ) -> bool: + if state is None: + return False + if cursor_at_end_of_token: + return False + if not state.consumed: + return False + return state.consumed_position == len(args) + + def _is_invalid_choices_state( + self, + state: ArgumentState, + cursor_at_end_of_token: bool, + num_args_since_last_keyword: int, + ) -> bool: + if isinstance(state.arg.nargs, int): + return ( + state.has_invalid_choice + and not state.consumed + and ( + num_args_since_last_keyword > state.arg.nargs + or (num_args_since_last_keyword >= 1 and cursor_at_end_of_token) + ) + ) + if state.arg.nargs in ("?", None): + return ( + state.has_invalid_choice + and not state.consumed + and ( + num_args_since_last_keyword > 1 + or (num_args_since_last_keyword == 1 and cursor_at_end_of_token) + ) + ) + return False + + def _value_suggestions_for_arg( + self, + state: ArgumentState, + prefix: str, + cursor_at_end_of_token: bool, + num_args_since_last_keyword: int, + ) -> list[str]: + if self._is_invalid_choices_state( + state, cursor_at_end_of_token, num_args_since_last_keyword + ): + return [] + arg = state.arg + suggestion_filter = ( + (lambda _: True) + if cursor_at_end_of_token + else (lambda suggestion: (not prefix) or str(suggestion).startswith(prefix)) + ) + if arg.choices: + return [str(choice) for choice in arg.choices if suggestion_filter(choice)] + if arg.suggestions: + return [ + str(suggestion) + for suggestion in arg.suggestions + if suggestion_filter(suggestion) + ] + if arg.type is Path: + return self._suggest_paths(prefix if not cursor_at_end_of_token else ".") + return [] + def suggest_next( self, args: list[str], cursor_at_end_of_token: bool = False ) -> list[str]: @@ -1135,44 +1309,67 @@ class CommandArgumentParser: Returns: list[str]: List of suggested completions. """ - + self._resolve_posix_bundling(args) last = args[-1] if args else "" # Case 1: Next positional argument - next_non_consumed_positional: Argument | None = None + last_consumed_positional_index = -1 + num_args_since_last_positional = 0 + next_non_consumed_positional_arg: Argument | None = None + next_non_consumed_positional_state: ArgumentState | None = None for state in self._last_positional_states.values(): - if not state.consumed: - next_non_consumed_positional = state.arg + if not state.consumed or self._is_mid_value( + state, args, cursor_at_end_of_token + ): + next_non_consumed_positional_arg = state.arg + next_non_consumed_positional_state = state break + elif state.consumed_position is not None: + last_consumed_positional_index = max( + last_consumed_positional_index, state.consumed_position + ) - if next_non_consumed_positional: - if next_non_consumed_positional.choices: + if last_consumed_positional_index != -1: + num_args_since_last_positional = len(args) - last_consumed_positional_index + else: + num_args_since_last_positional = len(args) + if next_non_consumed_positional_arg and next_non_consumed_positional_state: + if next_non_consumed_positional_arg.choices: if ( cursor_at_end_of_token and last and any( str(choice).startswith(last) - for choice in next_non_consumed_positional.choices + for choice in next_non_consumed_positional_arg.choices ) - and next_non_consumed_positional.nargs in (1, "?", None) + and next_non_consumed_positional_arg.nargs in (1, "?", None) + ): + return [] + if self._is_invalid_choices_state( + next_non_consumed_positional_state, + cursor_at_end_of_token, + num_args_since_last_positional, ): return [] return sorted( - (str(choice) for choice in next_non_consumed_positional.choices) + (str(choice) for choice in next_non_consumed_positional_arg.choices) ) - if next_non_consumed_positional.suggestions: + if next_non_consumed_positional_arg.suggestions: if ( cursor_at_end_of_token and last and any( str(suggestion).startswith(last) - for suggestion in next_non_consumed_positional.suggestions + for suggestion in next_non_consumed_positional_arg.suggestions ) - and next_non_consumed_positional.nargs in (1, "?", None) + and next_non_consumed_positional_arg.nargs in (1, "?", None) ): return [] - return sorted(next_non_consumed_positional.suggestions) - if next_non_consumed_positional.type == Path: - return self._suggest_paths(args[-1] if args else "") + return sorted(next_non_consumed_positional_arg.suggestions) + if next_non_consumed_positional_arg.type == Path: + if cursor_at_end_of_token: + return self._suggest_paths(".") + else: + return self._suggest_paths(args[-1] if args else ".") consumed_dests = [ state.arg.dest @@ -1185,59 +1382,95 @@ class CommandArgumentParser: ] last_keyword_state_in_args = None + last_keyword = None for last_arg in reversed(args): if last_arg in self._keyword: last_keyword_state_in_args = self._last_keyword_states.get( self._keyword[last_arg].dest ) + last_keyword = last_arg break + num_args_since_last_keyword = ( + len(args) - 1 - args.index(last_keyword) if last_keyword else 0 + ) next_to_last = args[-2] if len(args) > 1 else "" suggestions: list[str] = [] - # Case 2: Mid-flag (e.g., "--ver") if last.startswith("-") and last not in self._keyword: - if last_keyword_state_in_args and not last_keyword_state_in_args.consumed: + if last_keyword_state_in_args and ( + not last_keyword_state_in_args.consumed + or self._is_mid_value( + last_keyword_state_in_args, args, cursor_at_end_of_token + ) + ): + # Previous keyword still needs values (or we're mid-value) → suggest values for it. + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) + elif not cursor_at_end_of_token: + # Suggest all flags that start with the last token + suggestions.extend( + flag for flag in remaining_flags if flag.startswith(last) + ) + else: + # If space at end of token, suggest all remaining flags + suggestions.extend(flag for flag in remaining_flags) + + if ( + last_keyword_state_in_args + and last_keyword_state_in_args.consumed + and last_keyword_state_in_args.arg.nargs in ("*", "?") + ): + suggestions.extend( + flag for flag in remaining_flags if flag.startswith(last) + ) + # Case 3: Flag that expects a value (e.g., ["--tag"]) + elif last in self._keyword and last_keyword_state_in_args: + arg = last_keyword_state_in_args.arg + if last_keyword_state_in_args.consumed and not cursor_at_end_of_token: + # If last flag is already consumed, (e.g., ["--verbose"]) + # and space not at end of token, suggest nothing. pass elif ( - len(args) > 1 - and next_to_last in self._keyword - and next_to_last in remaining_flags + last_keyword_state_in_args.consumed + and cursor_at_end_of_token + and last_keyword_state_in_args.arg.nargs not in ("*", "?") ): - arg = self._keyword[next_to_last] - if arg.choices: - suggestions.extend((str(choice) for choice in arg.choices)) - elif arg.suggestions: - suggestions.extend( - (str(suggestion) for suggestion in arg.suggestions) - ) + # space at end of token, suggest remaining flags + suggestions.extend(flag for flag in remaining_flags) else: - possible_flags = [ - flag - for flag, arg in self._keyword.items() - if flag.startswith(last) and arg.dest not in consumed_dests - ] - suggestions.extend(possible_flags) - # Case 3: Flag that expects a value (e.g., ["--tag"]) - elif last in self._keyword: - arg = self._keyword[last] - if ( - self._last_keyword_states.get(last.strip("-")) - and self._last_keyword_states[last.strip("-")].consumed - ): - pass - elif arg.choices: - suggestions.extend((str(choice) for choice in arg.choices)) - elif arg.suggestions: - suggestions.extend((str(suggestion) for suggestion in arg.suggestions)) - elif arg.type == Path: - suggestions.extend(self._suggest_paths(".")) + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"]) - elif next_to_last in self._keyword: - arg = self._keyword[next_to_last] - if ( - self._last_keyword_states.get(next_to_last.strip("-")) - and self._last_keyword_states[next_to_last.strip("-")].consumed + elif next_to_last in self._keyword and last_keyword_state_in_args: + arg = last_keyword_state_in_args.arg + if self._is_mid_value( + last_keyword_state_in_args, + args, + cursor_at_end_of_token, + ): + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) + elif ( + last_keyword_state_in_args.consumed and last_keyword_state_in_args and Counter(args)[next_to_last] > ( @@ -1248,7 +1481,9 @@ class CommandArgumentParser: ): pass elif arg.choices and last not in arg.choices and not cursor_at_end_of_token: - suggestions.extend((str(choice) for choice in arg.choices)) + suggestions.extend( + (str(choice) for choice in arg.choices if choice.startswith(last)) + ) elif ( arg.suggestions and last not in arg.suggestions @@ -1256,19 +1491,68 @@ class CommandArgumentParser: and any(suggestion.startswith(last) for suggestion in arg.suggestions) and not cursor_at_end_of_token ): - suggestions.extend((str(suggestion) for suggestion in arg.suggestions)) - elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: - pass + suggestions.extend( + ( + str(suggestion) + for suggestion in arg.suggestions + if suggestion.startswith(last) + ) + ) elif arg.type == Path and not cursor_at_end_of_token: suggestions.extend(self._suggest_paths(last)) + elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) else: suggestions.extend(remaining_flags) + # Case 5: Last flag is incomplete and expects a value (e.g., ["--tag", "value1", "va"]) elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: - pass - # Case 5: Suggest all remaining flags + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) + # Case 6: Last keyword state is mid-value (e.g., ["--tag", "value1", "va"]) but consumed + elif self._is_mid_value(last_keyword_state_in_args, args, cursor_at_end_of_token): + if not last_keyword_state_in_args: + pass + else: + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) + # Case 7: Suggest all remaining flags else: suggestions.extend(remaining_flags) + # Case 8: Last keyword state is a multi-value argument + # (e.g., ["--tags", "value1", "value2", "va"]) + # and it accepts multiple values + # (e.g., nargs='*', nargs='+') + if last_keyword_state_in_args: + if last_keyword_state_in_args.arg.nargs in ("*", "+"): + suggestions.extend( + self._value_suggestions_for_arg( + last_keyword_state_in_args, + last, + cursor_at_end_of_token, + num_args_since_last_keyword, + ) + ) + return sorted(set(suggestions)) def get_options_text(self, plain_text=False) -> str: @@ -1408,11 +1692,9 @@ class CommandArgumentParser: FalyxMode.RUN_ALL, FalyxMode.HELP, } - is_help_command = self.aliases[0] == "HELP" and self.command_key == "H" - program = self.program or "falyx" command = self.aliases[0] if self.aliases else self.command_key - if is_help_command and is_cli_mode: + if self._is_help_command and is_cli_mode: command = f"[{self.command_style}]{program} help[/{self.command_style}]" elif is_cli_mode: command = ( diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py index b4644fc..c803cf4 100644 --- a/falyx/parser/parser_types.py +++ b/falyx/parser/parser_types.py @@ -28,6 +28,18 @@ class ArgumentState: arg: Argument consumed: bool = False + consumed_position: int | None = None + has_invalid_choice: bool = False + + def set_consumed(self, position: int | None = None) -> None: + """Mark this argument as consumed, optionally setting the position.""" + self.consumed = True + self.consumed_position = position + + def reset(self) -> None: + """Reset the consumed state.""" + self.consumed = False + self.consumed_position = None @dataclass(frozen=True) diff --git a/falyx/version.py b/falyx/version.py index 2db3392..2459c36 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.82" +__version__ = "0.1.83" diff --git a/pyproject.toml b/pyproject.toml index 1d9c9a4..1040f13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.82" +version = "0.1.83" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_parsers/test_completions.py b/tests/test_parsers/test_completions.py index 672cd37..35bf4c9 100644 --- a/tests/test_parsers/test_completions.py +++ b/tests/test_parsers/test_completions.py @@ -1,18 +1,311 @@ +from pathlib import Path + import pytest +from falyx.exceptions import CommandArgumentError from falyx.parser.command_argument_parser import CommandArgumentParser +def build_default_parser(): + p = CommandArgumentParser( + command_key="D", aliases=["deploy"], program="argument_examples.py" + ) + p.add_argument("service", type=str, help="Service name.") + p.add_argument("place", type=str, nargs="?", default="New York", help="Place.") + p.add_argument( + "--region", + choices=["us-east-1", "us-west-2", "eu-west-1"], + help="Region.", + default="us-east-1", + ) + p.add_argument("-p", "--path", type=Path, help="Path.") + p.add_argument("-v", "--verbose", action="store_true", help="Verbose.") + p.add_argument("-t", "--tag", type=str, suggestions=["latest", "stable", "beta"]) + p.add_argument("--numbers", type=int, nargs="*", default=[1, 2, 3], help="Nums.") + p.add_argument("-j", "--just-a-bool", action="store_true", help="Bool.") + p.add_argument("-a", action="store_true") + p.add_argument("-b", action="store_true") + return p + + @pytest.mark.asyncio -@pytest.mark.parametrize( - "input_tokens, expected", - [ - ([""], ["--help", "--tag", "-h"]), - (["--ta"], ["--tag"]), - (["--tag"], ["analytics", "build"]), - ], -) -async def test_suggest_next(input_tokens, expected): - parser = CommandArgumentParser(...) - parser.add_argument("--tag", choices=["analytics", "build"]) - assert sorted(parser.suggest_next(input_tokens)) == sorted(expected) +async def test_parse_minimal_positional_and_defaults(): + p = build_default_parser() + got = await p.parse_args(["web"]) + assert got["service"] == "web" + assert got["place"] == "New York" + assert got["numbers"] == [1, 2, 3] + assert got["verbose"] is False + assert got["tag"] is None + assert got["path"] is None + + +@pytest.mark.asyncio +async def test_parse_all_keywords_and_lists_and_bools(): + p = build_default_parser() + got = await p.parse_args( + [ + "web", + "Paris", + "--region", + "eu-west-1", + "--numbers", + "10", + "20", + "-30", + "-t", + "stable", + "-p", + "pyproject.toml", + "-v", + "-j", + ] + ) + assert got["service"] == "web" + assert got["place"] == "Paris" + assert got["region"] == "eu-west-1" + assert got["numbers"] == [10, 20, -30] + assert got["tag"] == "stable" + assert isinstance(got["path"], Path) + assert got["verbose"] is True and got["just_a_bool"] is True + + +@pytest.mark.asyncio +async def test_parse_numbers_negative_values_not_flags(): + p = build_default_parser() + got = await p.parse_args(["web", "--numbers", "-1", "-2", "-3"]) + assert got["numbers"] == [-1, -2, -3] + + +def test_default_list_must_match_choices_when_choices_present(): + p = CommandArgumentParser() + with pytest.raises(CommandArgumentError): + p.add_argument( + "--color", choices=["red", "blue"], nargs="*", default=["red", "green"] + ) + + +def test_default_type_for_nargs_requires_list(): + p = CommandArgumentParser() + with pytest.raises(CommandArgumentError): + p.add_argument("--ints", type=int, nargs=2, default=1) + + +@pytest.mark.asyncio +async def test_choices_enforced_on_result(): + p = CommandArgumentParser() + p.add_argument("--env", choices=["prod", "dev"]) + with pytest.raises(CommandArgumentError): + await p.parse_args(["--env", "staging"]) + + +@pytest.mark.asyncio +async def test_posix_bundling_flags_only(): + p = CommandArgumentParser() + p.add_argument("-a", "--aa", action="store_true") + p.add_argument("-b", "--bb", action="store_true") + p.add_argument("-c", "--cc", action="store_true") + got = await p.parse_args(["-abc"]) + assert got["aa"] and got["bb"] and got["cc"] + + +@pytest.mark.asyncio +async def test_posix_bundling_not_applied_when_value_like(): + p = CommandArgumentParser() + p.add_argument("-n", "--num", type=int) + p.add_argument("-a", action="store_true") + p.add_argument("-b", action="store_true") + got = await p.parse_args(["--num", "-123", "-ab"]) + assert got["num"] == -123 + assert got["a"] and got["b"] + + +def mk_tmp_tree(tmp_path: Path): + (tmp_path / "dirA").mkdir() + (tmp_path / "dirB").mkdir() + (tmp_path / "file.txt").write_text("x") + + +def test_complete_initial_flags_and_suggestions(): + p = build_default_parser() + sugg = p.suggest_next([""], cursor_at_end_of_token=False) + assert "--tag" in sugg and "--region" in sugg and "-v" in sugg + + +def test_complete_flag_by_prefix(): + p = build_default_parser() + assert p.suggest_next(["--ta"], False) == ["--tag"] + + +@pytest.mark.asyncio +async def test_complete_values_for_flag_choices(): + p = build_default_parser() + with pytest.raises(CommandArgumentError): + await p.parse_args(["--region"]) + sugg = p.suggest_next(["--region"], True) + assert set(sugg) == {"us-east-1", "us-west-2", "eu-west-1"} + with pytest.raises(CommandArgumentError): + await p.parse_args(["--region", "us-"]) + sugg2 = p.suggest_next(["--region", "us-"], False) + assert set(sugg2) == {"us-east-1", "us-west-2"} + + +@pytest.mark.asyncio +async def test_complete_values_for_flag_suggestions(): + p = build_default_parser() + with pytest.raises(CommandArgumentError): + await p.parse_args(["--tag"]) + assert set(p.suggest_next(["--tag"], True)) == {"latest", "stable", "beta"} + with pytest.raises(CommandArgumentError): + await p.parse_args(["--tag", "st"]) + assert set(p.suggest_next(["--tag", "st"], False)) == {"stable"} + + +def test_complete_mid_flag_hyphen_value_uses_previous_flag_context(): + p = build_default_parser() + sugg = p.suggest_next(["--numbers", "-1"], False) + assert "--tag" not in sugg and "--region" not in sugg + + +def test_complete_multi_value_keeps_suggesting_for_plus_star(): + p = build_default_parser() + sugg1 = p.suggest_next(["--numbers"], False) + assert "--tag" not in sugg1 or True + sugg2 = p.suggest_next(["--numbers", "1"], False) + assert "--tag" not in sugg2 or True + + +@pytest.mark.asyncio +async def test_complete_path_values(tmp_path, monkeypatch): + mk_tmp_tree(tmp_path) + monkeypatch.chdir(tmp_path) + p = build_default_parser() + with pytest.raises(CommandArgumentError): + await p.parse_args(["--path"]) + sugg = p.suggest_next(["--path"], True) + assert any(s.endswith("/") for s in sugg) and "file.txt" in sugg + with pytest.raises(CommandArgumentError): + await p.parse_args(["--path", "d"]) + sugg2 = p.suggest_next(["--path", "d"], False) + assert "dirA/" in sugg2 or "dirB/" in sugg2 + + +@pytest.mark.asyncio +async def test_complete_positional_path(tmp_path, monkeypatch): + mk_tmp_tree(tmp_path) + monkeypatch.chdir(tmp_path) + p = CommandArgumentParser() + p.add_argument("paths", type=Path, nargs="*") + await p.parse_args([""]) + s1 = p.suggest_next([""], False) + assert "file.txt" in s1 or "dirA/" in s1 + await p.parse_args(["fi"]) + s2 = p.suggest_next(["fi"], False) + assert "file.txt" in s2 + + +@pytest.mark.asyncio +async def test_flag_then_space_yields_flag_suggestions(): + p = build_default_parser() + with pytest.raises(CommandArgumentError): + await p.parse_args(["--tag"]) + sugg = p.suggest_next(["--tag"], True) + assert "latest" in sugg + + +def test_complete_multi_value_persists_until_space_or_new_flag(): + p = build_default_parser() + + s1 = p.suggest_next(["--numbers"], cursor_at_end_of_token=False) + assert "--tag" not in s1 or True + + s2 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=False) + assert "--tag" not in s2 or True + + s3 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=True) + assert "--tag" not in s3 or True + + +@pytest.mark.asyncio +async def test_mid_value_suggestions_then_flags_after_space(): + p = build_default_parser() + with pytest.raises(CommandArgumentError): + await p.parse_args(["--tag", "st"]) + s_mid = p.suggest_next(["--tag", "st"], cursor_at_end_of_token=False) + assert set(s_mid) == {"stable"} + + s_after = p.suggest_next(["--tag"], cursor_at_end_of_token=True) + assert any(opt.startswith("-") for opt in s_after) + + +@pytest.mark.asyncio +async def test_negative_values_then_posix_bundle(): + p = build_default_parser() + out = await p.parse_args(["prod", "--numbers", "-3", "-ab"]) + assert out["numbers"] == [-3] + assert out["a"] is True and out["b"] is True + + +def test_mid_flag_token_after_negative_value_uses_prior_flag_context(): + p = build_default_parser() + sugg = p.suggest_next(["--numbers", "-1"], cursor_at_end_of_token=False) + assert "--tag" not in sugg and "--region" not in sugg + + +@pytest.mark.asyncio +async def test_path_dash_prefix_is_value_not_flags(): + p = CommandArgumentParser() + p.add_argument("-a", action="store_true") + p.add_argument("--path", type=Path) + + out = await p.parse_args(["--path", "-abc", "-a"]) + assert str(out["path"]) == "-abc" + assert out["a"] is True + + +@pytest.mark.asyncio +async def test_store_bool_optional_pair_last_one_wins(): + p = CommandArgumentParser() + p.add_argument("--feature", action="store_bool_optional", help="toggle feature") + + out0 = await p.parse_args([]) + assert out0["feature"] is None + + out1 = await p.parse_args(["--feature"]) + assert out1["feature"] is True + + out2 = await p.parse_args(["--no-feature"]) + assert out2["feature"] is False + + out3 = await p.parse_args(["--feature", "--no-feature"]) + assert out3["feature"] is False + + out4 = await p.parse_args(["--no-feature", "--feature"]) + assert out4["feature"] is True + + +@pytest.mark.asyncio +async def test_invalid_choice_suppresses_then_recovers(): + p = build_default_parser() + + with pytest.raises(CommandArgumentError): + await p.parse_args(["--region", "us-"]) + + s_suppressed = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=True) + assert s_suppressed == [] + + s_recover = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=False) + assert set(s_recover) == {"us-east-1", "us-west-2"} + + +@pytest.mark.asyncio +async def test_repeated_keyword_last_one_wins_and_guides_completion(): + p = build_default_parser() + + out = await p.parse_args(["test", "--tag", "alpha", "--tag", "st"]) + assert out["tag"] == "st" + + s = p.suggest_next( + ["test", "--tag", "alpha", "--tag", "st"], cursor_at_end_of_token=False + ) + assert set(s) == {"stable"}