5 Commits

Author SHA1 Message Date
1ce1b2385b feat(parser): add docstrings, centralized suggestion errors, and improved flag handling
-Added descriptive docstrings across `Falyx` and `CommandArgumentParser` internals:
  - `is_cli_mode`, `get_tip`, and `_render_help` in `falyx.py`
  - Validation and parsing helpers in `command_argument_parser.py` (`_validate_nargs`, `_normalize_choices`, `_validate_default_list_type`, `_validate_action`, `_register_store_bool_optional`, `_register_argument`, `_check_if_in_choices`, `_raise_remaining_args_error`, `_consume_nargs`, `_consume_all_positional_args`, `_handle_token`, `_find_last_flag_argument`, `_is_mid_value`, `_is_invalid_choices_state`, `_value_suggestions_for_arg`)
- Introduced `_raise_suggestion_error()` utility to standardize error messages when required values are missing, including defaults and choices.
  - Replaced duplicated inline suggestion/error logic in `APPEND`, `EXTEND`, and generic STORE handlers with this helper.
- Improved error chaining with `from error` for clarity in `_normalize_choices` and `_validate_action`.
- Consolidated `HELP`, `TLDR`, and `COUNT` default-value validation into a single check.
- Enhanced completions:
  - Extended suggestion logic to show remaining flags for `APPEND`, `EXTEND`, and `COUNT` arguments when last tokens are not keywords.
- Added `.config.json` to `.gitignore`.
- Bumped version to 0.1.85.
2025-08-22 05:32:36 -04:00
06bf0e432c feat(help): improve TLDR/help handling for help context commands
- Added `from_help` flag to `get_command()` to allow help rendering without full CLI execution flow.
- Updated `_render_help()` to pass `from_help=True` when fetching commands.
- Enhanced TLDR parsing:
  - Allow TLDR flag to be processed and retained when running inside a help command (`_is_help_command=True`).
  - Skip removing `"tldr"` from results in help context to preserve intended behavior.
  - Ensure TLDR args are still marked consumed to maintain state consistency.
- Simplified required argument validation to skip both `help` and `tldr` without special action checks.
- Adjusted `parse_args_split()` to include `tldr` values in help commands while skipping them for normal commands.
- Expanded `infer_args_from_func()` docstring with supported features and parameter handling details.
- Bumped version to 0.1.84.
2025-08-11 19:51:49 -04:00
169f228c92 feat(parser): POSIX bundling, multi-value/default validation, smarter completions; help UX & examples
- Mark help parser with `_is_help_command=True` so CLI renders as `program help`.
- Add TLDR examples to `Exit` and `History` commands.
- Normalize help TLDR/tag docs to short forms `-T` (tldr) and `-t [TAG]`.
- Also propagate submenu exit help text TLDRs when set.
- Disallow defaults for `HELP`, `TLDR`, `COUNT`, and boolean store actions.
- Enforce list defaults for `APPEND`/`EXTEND` and any `nargs` in `{int, "*", "+"}`; coerce to list when `nargs == 1`.
- Validate default(s) against `choices` (lists must be subset).
- Strengthen `choices` checking at parse-time for both scalars and lists; track invalid-choice state for UX.
- New `_resolve_posix_bundling()` with context:
  - Won’t split negative numbers or dash-prefixed positional/path values.
  - Uses the *last seen flag’s type/action* to decide if a dash token is a value vs. bundle.
- Add `_is_valid_dash_token_positional_value()` and `_find_last_flag_argument()` helpers.
- Completions overhaul
  - Track `consumed_position` and `has_invalid_choice` per-arg (via new `ArgumentState.set_consumed()` / `reset()`).
  - Add `_is_mid_value()` and `_value_suggestions_for_arg()` to produce value suggestions while typing.
  - Persist value context for multi-value args (`nargs="*"`, `"+"`) for each call to parse_args
  - Suppress suggestions when a choice is currently invalid, then recover as the prefix becomes valid.
  - Respect `cursor_at_end_of_token`; do not mutate the user’s prefix; improve path suggestions (`"."` vs prefix).
  - Better behavior after a space: suggest remaining flags when appropriate.
- Consistent `index` naming (vs `i`) and propagate `base_index` into positional consumption to mark positions accurately.
- Return value tweaks for `find_argument_by_dest()` and minor readability changes.
- Replace the minimal completion test with a comprehensive suite covering:
  - Basics (defaults, option parsing, lists, booleans).
  - Validation edges (default/choices, `nargs` list requirements).
  - POSIX bundling (flags only; negative values; dash-prefixed paths).
  - Completions for flags/values/mid-value/path/`nargs="*"` persistence.
  - `store_bool_optional` (feature / no-feature, last one wins).
  - Invalid choice suppression & recovery.
  - Repeated keywords (last one wins) and completion context follows the last.
  - File-system-backed path suggestions.
- Bumped version to 0.1.83.
2025-08-10 15:55:45 -04:00
0417a06ee4 feat: enhance help command UX, completions, and CLI tips
- Expanded help command to accept:
  - `-k/--key` for detailed help on a specific command
  - `-t/--tag` for tag-filtered listings
  - `-T/--tldr` for quick usage examples
- Updated TLDR flag to support `-T` short form and refined help text.
- Improved `_render_help()` to show contextual CLI tips after help or TLDR output.
- Adjusted completer to yield both upper and lower case completions without mutating the prefix.
- Standardized CLI tip strings in root/arg parsers to reference `help` and `preview` subcommands instead of menu `run ?` syntax.
- Passed `options_manager` to history/help/exit commands for consistency.
- Allowed `help_command` to display TLDR examples when invoked without a key.
- Added test assertions for help command key/alias consistency.
- Bumped version to 0.1.82.
2025-08-07 19:27:59 -04:00
55d581b870 feat: redesign help command, improve completer UX, and document Falyx CLI
- Renamed CLI subcommand from `list` to `help` for clarity and discoverability.
- Added `--key` and `--tldr` support to the `help` command for detailed and example-based output.
- Introduced `FalyxMode.HELP` to clearly delineate help-related behavior.
- Enhanced `_render_help()` to support:
  - Tag filtering (`--tag`)
  - Per-command help (`--key`)
  - TLDR example rendering (`--tldr`)
- Updated built-in Help command to:
  - Use `FalyxMode.HELP` internally
  - Provide fallback messages for missing help or TLDR data
  - Remove `LIST` alias (replaced by `help`)
- Documented `FalyxCompleter`:
  - Improved docstrings for public methods and completions
  - Updated internal documentation to reflect all supported completion cases
- Updated `CommandArgumentParser.render_tldr()` with fallback message for missing TLDR entries.
- Updated all parser docstrings and variable names to reference `help` (not `list`) as the proper CLI entrypoint.
- Added test coverage:
  - `tests/test_falyx/test_help.py` for CLI `help` command with `tag`, `key`, `tldr`, and fallback scenarios
  - `tests/test_falyx/test_run.py` for basic CLI parser integration
- Bumped version to 0.1.81
2025-08-06 20:33:51 -04:00
15 changed files with 1244 additions and 265 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ build/
.vscode/ .vscode/
coverage.xml coverage.xml
.coverage .coverage
.config.json

View File

@@ -25,11 +25,24 @@ async def test_args(
path: Path | None = None, path: Path | None = None,
tag: str | None = None, tag: str | None = None,
verbose: bool | None = None, verbose: bool | None = None,
number: int | None = None, numbers: list[int] | None = None,
just_a_bool: bool = False,
) -> str: ) -> str:
if numbers is None:
numbers = []
if verbose: if verbose:
print(f"Deploying {service}:{tag}:{number} to {region} at {place} from {path}...") print(
return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}." 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: 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"], choices=["us-east-1", "us-west-2", "eu-west-1"],
) )
parser.add_argument( parser.add_argument(
"-p",
"--path", "--path",
type=Path, type=Path,
help="Path to the configuration file.", help="Path to the configuration file.",
@@ -65,16 +79,25 @@ def default_config(parser: CommandArgumentParser) -> None:
help="Enable verbose output.", help="Enable verbose output.",
) )
parser.add_argument( parser.add_argument(
"-t",
"--tag", "--tag",
type=str, type=str,
help="Optional tag for the deployment.", help="Optional tag for the deployment.",
suggestions=["latest", "stable", "beta"], suggestions=["latest", "stable", "beta"],
) )
parser.add_argument( parser.add_argument(
"--number", "--numbers",
type=int, type=int,
nargs="*",
default=[1, 2, 3],
help="Optional number argument.", help="Optional number argument.",
) )
parser.add_argument(
"-j",
"--just-a-bool",
action="store_true",
help="Just a boolean flag.",
)
parser.add_tldr_examples( parser.add_tldr_examples(
[ [
("web", "Deploy 'web' to the default location (New York)"), ("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( flx = Falyx(
"Argument Examples", "Argument Examples",
program="argument_examples.py", program="argument_examples.py",
@@ -105,4 +162,30 @@ flx.add_command(
argument_config=default_config, 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()) asyncio.run(flx.run())

View File

@@ -346,6 +346,7 @@ class Command(BaseModel):
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
program = f"{self.program} run " if is_cli_mode else "" program = f"{self.program} run " if is_cli_mode else ""
@@ -365,7 +366,7 @@ class Command(BaseModel):
) )
return ( return (
f"[{self.style}]{program}[/]{command_keys}", f"[{self.style}]{program}[/]{command_keys}",
f"[dim]{self.description}[/dim]", f"[dim]{self.help_text or self.description}[/dim]",
"", "",
) )

View File

@@ -8,9 +8,13 @@ This completer supports:
- Argument flag completion for registered commands (e.g. `--tag`, `--name`) - Argument flag completion for registered commands (e.g. `--tag`, `--name`)
- Context-aware suggestions based on cursor position and argument structure - Context-aware suggestions based on cursor position and argument structure
- Interactive value completions (e.g. choices and suggestions defined per argument) - Interactive value completions (e.g. choices and suggestions defined per argument)
- File/path-friendly behavior, quoting completions with spaces automatically
Completions are generated from:
- Registered commands in `Falyx`
- Argument metadata and `suggest_next()` from `CommandArgumentParser`
Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes
parsed tokens to determine appropriate next arguments, flags, or values.
Integrated with the `Falyx.prompt_session` to enhance the interactive experience. Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
""" """
@@ -42,9 +46,12 @@ class FalyxCompleter(Completer):
- Remaining required or optional flags - Remaining required or optional flags
- Flag value suggestions (choices or custom completions) - Flag value suggestions (choices or custom completions)
- Next positional argument hints - Next positional argument hints
- Inserts longest common prefix (LCP) completions when applicable
- Handles special cases like quoted strings and spaces
- Supports dynamic argument suggestions (e.g. flags, file paths, etc.)
Args: Args:
falyx (Falyx): The Falyx menu instance containing all command mappings and parsers. falyx (Falyx): The active Falyx instance providing command and parser context.
""" """
def __init__(self, falyx: "Falyx"): def __init__(self, falyx: "Falyx"):
@@ -52,14 +59,21 @@ class FalyxCompleter(Completer):
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
""" """
Yield completions based on the current document input. Compute completions for the current user input.
Analyzes the input buffer, determines whether the user is typing:
• A command key/alias
• A flag/option
• An argument value
and yields appropriate completions.
Args: Args:
document (Document): The prompt_toolkit document containing the input buffer. document (Document): The current Prompt Toolkit document (input buffer & cursor).
complete_event: The completion trigger event (unused). complete_event: The triggering event (TAB key, menu display, etc.) — not used here.
Yields: Yields:
Completion objects matching command keys or argument suggestions. Completion: One or more completions matching the current stub text.
""" """
text = document.text_before_cursor text = document.text_before_cursor
try: try:
@@ -97,13 +111,15 @@ class FalyxCompleter(Completer):
""" """
Suggest top-level command keys and aliases based on the given prefix. Suggest top-level command keys and aliases based on the given prefix.
Filters all known commands (and `exit`, `help`, `history` built-ins)
to only those starting with the given prefix.
Args: Args:
prefix (str): The user input to match against available commands. prefix (str): The current typed prefix.
Yields: Yields:
Completion: Matching keys or aliases from all registered commands. Completion: Matching keys or aliases from all registered commands.
""" """
prefix = prefix.upper()
keys = [self.falyx.exit_command.key] keys = [self.falyx.exit_command.key]
keys.extend(self.falyx.exit_command.aliases) keys.extend(self.falyx.exit_command.aliases)
if self.falyx.history_command: if self.falyx.history_command:
@@ -117,11 +133,16 @@ class FalyxCompleter(Completer):
keys.extend(cmd.aliases) keys.extend(cmd.aliases)
for key in keys: for key in keys:
if key.upper().startswith(prefix): if key.upper().startswith(prefix):
yield Completion(key, start_position=-len(prefix)) yield Completion(key.upper(), start_position=-len(prefix))
elif key.lower().startswith(prefix):
yield Completion(key.lower(), start_position=-len(prefix))
def _ensure_quote(self, text: str) -> str: def _ensure_quote(self, text: str) -> str:
""" """
Ensure the text is properly quoted for shell commands. Ensure that a suggestion is shell-safe by quoting if needed.
Adds quotes around completions containing whitespace so they can
be inserted into the CLI without breaking tokenization.
Args: Args:
text (str): The input text to quote. text (str): The input text to quote.
@@ -134,6 +155,22 @@ class FalyxCompleter(Completer):
return text return text
def _yield_lcp_completions(self, suggestions, stub): def _yield_lcp_completions(self, suggestions, stub):
"""
Yield completions for the current stub using longest-common-prefix logic.
Behavior:
- If only one match → yield it fully.
- If multiple matches share a longer prefix → insert the prefix, but also
display all matches in the menu.
- If no shared prefix → list all matches individually.
Args:
suggestions (list[str]): The raw suggestions to consider.
stub (str): The currently typed prefix (used to offset insertion).
Yields:
Completion: Completion objects for the Prompt Toolkit menu.
"""
matches = [s for s in suggestions if s.startswith(stub)] matches = [s for s in suggestions if s.startswith(stub)]
if not matches: if not matches:
return return

View File

@@ -203,10 +203,12 @@ class Falyx:
@property @property
def is_cli_mode(self) -> bool: def is_cli_mode(self) -> bool:
"""Checks if the current mode is a CLI mode."""
return self.options.get("mode") in { return self.options.get("mode") in {
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
def validate_options( def validate_options(
@@ -280,7 +282,7 @@ class Falyx:
def _get_exit_command(self) -> Command: def _get_exit_command(self) -> Command:
"""Returns the back command for the menu.""" """Returns the back command for the menu."""
return Command( exit_command = Command(
key="X", key="X",
description="Exit", description="Exit",
action=Action("Exit", action=_noop), action=Action("Exit", action=_noop),
@@ -290,7 +292,11 @@ class Falyx:
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options,
program=self.program, 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: def _get_history_command(self) -> Command:
"""Returns the history command for the menu.""" """Returns the history command for the menu."""
@@ -300,6 +306,7 @@ class Falyx:
command_style=OneColors.DARK_YELLOW, command_style=OneColors.DARK_YELLOW,
aliases=["HISTORY"], aliases=["HISTORY"],
program=self.program, program=self.program,
options_manager=self.options,
) )
parser.add_argument( parser.add_argument(
"-n", "-n",
@@ -334,6 +341,19 @@ class Falyx:
parser.add_argument( parser.add_argument(
"-l", "--last-result", action="store_true", help="Get the last result" "-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( return Command(
key="Y", key="Y",
description="History", description="History",
@@ -348,6 +368,7 @@ class Falyx:
) )
def get_tip(self) -> str: def get_tip(self) -> str:
"""Returns a random tip for the user about using Falyx."""
program = f"{self.program} run " if self.is_cli_mode else "" program = f"{self.program} run " if self.is_cli_mode else ""
tips = [ tips = [
f"Use '{program}?[COMMAND]' to preview a command.", f"Use '{program}?[COMMAND]' to preview a command.",
@@ -359,11 +380,12 @@ class Falyx:
f"Use '{self.program} --verbose' to enable debug logging for a menu session.", f"Use '{self.program} --verbose' to enable debug logging for a menu session.",
f"'{self.program} --debug-hooks' will trace every before/after hook in action.", f"'{self.program} --debug-hooks' will trace every before/after hook in action.",
f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
"All [COMMAND] keys and aliases are case-insensitive.",
] ]
if self.is_cli_mode: if self.is_cli_mode:
tips.extend( tips.extend(
[ [
f"Use '{self.program} run ?' to list all commands at any time.", f"Use '{self.program} help' to list all commands at any time.",
f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
@@ -382,7 +404,35 @@ class Falyx:
) )
return choice(tips) return choice(tips)
async def _render_help(self, tag: str = "") -> None: async def _render_help(
self, tag: str = "", key: str | None = None, tldr: bool = False
) -> None:
"""Renders the help menu with command details, usage examples, and tips."""
if tldr and not key:
if self.help_command and self.help_command.arg_parser:
self.help_command.arg_parser.render_tldr()
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
if key:
_, command, args, kwargs = await self.get_command(key, from_help=True)
if command and tldr and command.arg_parser:
command.arg_parser.render_tldr()
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
elif command and tldr and not command.arg_parser:
self.console.print(
f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
)
elif command and command.arg_parser:
command.arg_parser.render_help()
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
elif command and not command.arg_parser:
self.console.print(
f"[bold]No detailed help available for '{command.description}'.[/bold]"
)
else:
self.console.print(f"[bold]No command found for '{key}'.[/bold]")
if tag: if tag:
tag_lower = tag.lower() tag_lower = tag.lower()
self.console.print(f"[bold]{tag_lower}:[/bold]") self.console.print(f"[bold]{tag_lower}:[/bold]")
@@ -393,7 +443,7 @@ class Falyx:
] ]
if not commands: if not commands:
self.console.print(f"'{tag}'... Nothing to show here") self.console.print(f"'{tag}'... Nothing to show here")
return return None
for command in commands: for command in commands:
usage, description, _ = command.help_signature usage, description, _ = command.help_signature
self.console.print( self.console.print(
@@ -402,7 +452,8 @@ class Falyx:
(0, 2), (0, 2),
) )
) )
return self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
self.console.print("[bold]help:[/bold]") self.console.print("[bold]help:[/bold]")
for command in self.commands.values(): for command in self.commands.values():
@@ -451,8 +502,10 @@ class Falyx:
command_key="H", command_key="H",
command_description="Help", command_description="Help",
command_style=OneColors.LIGHT_YELLOW, command_style=OneColors.LIGHT_YELLOW,
aliases=["?", "HELP", "LIST"], aliases=["HELP", "?"],
program=self.program, program=self.program,
options_manager=self.options,
_is_help_command=True,
) )
parser.add_argument( parser.add_argument(
"-t", "-t",
@@ -461,11 +514,27 @@ class Falyx:
default="", default="",
help="Optional tag to filter commands by.", help="Optional tag to filter commands by.",
) )
parser.add_argument(
"-k",
"--key",
nargs="?",
default=None,
help="Optional command key or alias to get detailed help for.",
)
parser.add_tldr_examples(
[
("", "Show all commands."),
("-k [COMMAND]", "Show detailed help for a specific command."),
("-Tk [COMMAND]", "Show quick usage examples for a specific command."),
("-T", "Show these quick usage examples."),
("-t [TAG]", "Show commands with the specified tag."),
]
)
return Command( return Command(
key="H", key="H",
aliases=["?", "HELP", "LIST"], aliases=["HELP", "?"],
description="Help", description="Help",
help_text="Show this help menu", help_text="Show this help menu.",
action=Action("Help", self._render_help), action=Action("Help", self._render_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser, arg_parser=parser,
@@ -630,6 +699,7 @@ class Falyx:
style: str = OneColors.DARK_RED, style: str = OneColors.DARK_RED,
confirm: bool = False, confirm: bool = False,
confirm_message: str = "Are you sure?", confirm_message: str = "Are you sure?",
help_text: str = "Exit the program.",
) -> None: ) -> None:
"""Updates the back command of the menu.""" """Updates the back command of the menu."""
self._validate_command_key(key) self._validate_command_key(key)
@@ -647,7 +717,10 @@ class Falyx:
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options,
program=self.program, 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( def add_submenu(
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
@@ -660,7 +733,12 @@ class Falyx:
key, description, submenu.menu, style=style, simple_help_signature=True key, description, submenu.menu, style=style, simple_help_signature=True
) )
if submenu.exit_command.key == "X": if submenu.exit_command.key == "X":
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) submenu.update_exit_command(
key="B",
description="Back",
aliases=["BACK"],
help_text="Go back to the previous menu.",
)
def add_commands(self, commands: list[Command] | list[dict]) -> None: def add_commands(self, commands: list[Command] | list[dict]) -> None:
"""Adds a list of Command instances or config dicts.""" """Adds a list of Command instances or config dicts."""
@@ -866,7 +944,7 @@ class Falyx:
return False, input_str.strip() return False, input_str.strip()
async def get_command( async def get_command(
self, raw_choices: str, from_validate=False self, raw_choices: str, from_validate=False, from_help=False
) -> tuple[bool, Command | None, tuple, dict[str, Any]]: ) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
""" """
Returns the selected command based on user input. Returns the selected command based on user input.
@@ -907,11 +985,7 @@ class Falyx:
logger.info("Command '%s' selected.", run_command.key) logger.info("Command '%s' selected.", run_command.key)
if is_preview: if is_preview:
return True, run_command, args, kwargs return True, run_command, args, kwargs
elif self.options.get("mode") in { elif self.is_cli_mode or from_help:
FalyxMode.RUN,
FalyxMode.RUN_ALL,
FalyxMode.PREVIEW,
}:
return False, run_command, args, kwargs return False, run_command, args, kwargs
try: try:
args, kwargs = await run_command.parse_args(input_args, from_validate) args, kwargs = await run_command.parse_args(input_args, from_validate)
@@ -1166,7 +1240,7 @@ class Falyx:
This method parses CLI arguments, configures the runtime environment, and dispatches This method parses CLI arguments, configures the runtime environment, and dispatches
execution to the appropriate command mode: execution to the appropriate command mode:
- list - Show help output, optionally filtered by tag. - help - Show help output, optionally filtered by tag.
- version - Print the program version and exit. - version - Print the program version and exit.
- preview - Display a preview of the specified command without executing it. - preview - Display a preview of the specified command without executing it.
- run - Execute a single command with parsed arguments and lifecycle hooks. - run - Execute a single command with parsed arguments and lifecycle hooks.
@@ -1255,8 +1329,11 @@ class Falyx:
logger.debug("Enabling global debug hooks for all commands") logger.debug("Enabling global debug hooks for all commands")
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
if self.cli_args.command == "list": if self.cli_args.command == "help":
await self._render_help(tag=self.cli_args.tag) self.options.set("mode", FalyxMode.HELP)
await self._render_help(
tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr
)
sys.exit(0) sys.exit(0)
if self.cli_args.command == "version" or self.cli_args.version: if self.cli_args.command == "version" or self.cli_args.version:

View File

@@ -10,3 +10,4 @@ class FalyxMode(Enum):
RUN = "run" RUN = "run"
PREVIEW = "preview" PREVIEW = "preview"
RUN_ALL = "run-all" RUN_ALL = "run-all"
HELP = "help"

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,18 @@ class ArgumentState:
arg: Argument arg: Argument
consumed: bool = False 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) @dataclass(frozen=True)

View File

@@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI.
This module defines the `FalyxParsers` dataclass and related utilities for building This module defines the `FalyxParsers` dataclass and related utilities for building
structured CLI interfaces with argparse. It supports top-level CLI commands like structured CLI interfaces with argparse. It supports top-level CLI commands like
`run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with `run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with
registered `Command` objects for dynamic help, usage generation, and argument handling. registered `Command` objects for dynamic help, usage generation, and argument handling.
Key Components: Key Components:
@@ -39,7 +39,7 @@ class FalyxParsers:
run: ArgumentParser run: ArgumentParser
run_all: ArgumentParser run_all: ArgumentParser
preview: ArgumentParser preview: ArgumentParser
list: ArgumentParser help: ArgumentParser
version: ArgumentParser version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace: def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
@@ -59,7 +59,7 @@ def get_root_parser(
prog: str | None = "falyx", prog: str | None = "falyx",
usage: str | None = None, usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.", epilog: str | None = "Tip: Use 'falyx help' to show available commands.",
parents: Sequence[ArgumentParser] | None = None, parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, fromfile_prefix_chars: str | None = None,
@@ -178,7 +178,7 @@ def get_arg_parsers(
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: ( epilog: (
str | None str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", ) = "Tip: Use 'falyx preview [COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None, parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, fromfile_prefix_chars: str | None = None,
@@ -196,7 +196,7 @@ def get_arg_parsers(
This function builds the root parser and all subcommand parsers used for structured This function builds the root parser and all subcommand parsers used for structured
CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
`preview`, `list`, and `version`, and integrates with registered `Command` objects `preview`, `help`, and `version`, and integrates with registered `Command` objects
to populate dynamic help and usage documentation. to populate dynamic help and usage documentation.
Args: Args:
@@ -219,7 +219,7 @@ def get_arg_parsers(
Returns: Returns:
FalyxParsers: A structured container of all parsers, including `run`, `run-all`, FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
`preview`, `list`, `version`, and the root parser. `preview`, `help`, `version`, and the root parser.
Raises: Raises:
TypeError: If `root_parser` is not an instance of ArgumentParser or TypeError: If `root_parser` is not an instance of ArgumentParser or
@@ -240,7 +240,7 @@ def get_arg_parsers(
- Use `falyx run ?[COMMAND]` from the CLI to preview a command. - Use `falyx run ?[COMMAND]` from the CLI to preview a command.
""" """
if epilog is None: if epilog is None:
epilog = f"Tip: Use '{prog} run ?' to show available commands." epilog = f"Tip: Use '{prog} help' to show available commands."
if root_parser is None: if root_parser is None:
parser = get_root_parser( parser = get_root_parser(
prog=prog, prog=prog,
@@ -281,7 +281,7 @@ def get_arg_parsers(
command_description = command.help_text or command.description command_description = command.help_text or command.description
run_description.append(f"{' '*24}{command_description}") run_description.append(f"{' '*24}{command_description}")
run_epilog = ( run_epilog = (
f"Tip: Use '{prog} run ?[COMMAND]' to preview commands by their key or alias." f"Tip: Use '{prog} preview [COMMAND]' to preview commands by their key or alias."
) )
run_parser = subparsers.add_parser( run_parser = subparsers.add_parser(
"run", "run",
@@ -375,11 +375,23 @@ def get_arg_parsers(
) )
preview_parser.add_argument("name", help="Key, alias, or description of the command") preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser( help_parser = subparsers.add_parser("help", help="List all available commands")
"list", help="List all available commands with tags"
help_parser.add_argument(
"-k",
"--key",
help="Show help for a specific command by its key or alias",
default=None,
) )
list_parser.add_argument( help_parser.add_argument(
"-T",
"--tldr",
action="store_true",
help="Show a simplified TLDR examples of a command if available",
)
help_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
) )
@@ -391,6 +403,6 @@ def get_arg_parsers(
run=run_parser, run=run_parser,
run_all=run_all_parser, run_all=run_all_parser,
preview=preview_parser, preview=preview_parser,
list=list_parser, help=help_parser,
version=version_parser, version=version_parser,
) )

View File

@@ -26,6 +26,18 @@ def infer_args_from_func(
This utility inspects the parameters of a function and returns a list of dictionaries, This utility inspects the parameters of a function and returns a list of dictionaries,
each of which can be passed to `CommandArgumentParser.add_argument()`. each of which can be passed to `CommandArgumentParser.add_argument()`.
It supports:
- Positional and keyword arguments
- Type hints for argument types
- Default values
- Required vs optional arguments
- Custom help text, choices, and suggestions via metadata
Note:
- Only parameters with kind `POSITIONAL_ONLY`, `POSITIONAL_OR_KEYWORD`, or
`KEYWORD_ONLY` are considered.
- Parameters with kind `VAR_POSITIONAL` or `VAR_KEYWORD` are ignored.
Args: Args:
func (Callable | None): The function to inspect. func (Callable | None): The function to inspect.
arg_metadata (dict | None): Optional metadata overrides for help text, type hints, arg_metadata (dict | None): Optional metadata overrides for help text, type hints,

View File

@@ -1 +1 @@
__version__ = "0.1.80" __version__ = "0.1.85"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.80" version = "0.1.85"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"

View File

@@ -0,0 +1,96 @@
import pytest
from falyx import Falyx
@pytest.mark.asyncio
async def test_help_command(capsys):
flx = Falyx()
assert flx.help_command.arg_parser.aliases[0] == "HELP"
assert flx.help_command.arg_parser.command_key == "H"
await flx.run_key("H")
captured = capsys.readouterr()
assert "Show this help menu" in captured.out
@pytest.mark.asyncio
async def test_help_command_with_new_command(capsys):
flx = Falyx()
async def new_command(falyx: Falyx):
pass
flx.add_command(
"N",
"New Command",
new_command,
aliases=["TEST"],
help_text="This is a new command.",
)
await flx.run_key("H")
captured = capsys.readouterr()
assert "This is a new command." in captured.out
assert "TEST" in captured.out and "N" in captured.out
@pytest.mark.asyncio
async def test_render_help(capsys):
flx = Falyx()
async def sample_command(falyx: Falyx):
pass
flx.add_command(
"S",
"Sample Command",
sample_command,
aliases=["SC"],
help_text="This is a sample command.",
)
await flx._render_help()
captured = capsys.readouterr()
assert "This is a sample command." in captured.out
assert "SC" in captured.out and "S" in captured.out
@pytest.mark.asyncio
async def test_help_command_by_tag(capsys):
flx = Falyx()
async def tagged_command(falyx: Falyx):
pass
flx.add_command(
"T",
"Tagged Command",
tagged_command,
tags=["tag1"],
help_text="This command is tagged.",
)
await flx.run_key("H", args=("tag1",))
captured = capsys.readouterr()
assert "tag1" in captured.out
assert "This command is tagged." in captured.out
assert "HELP" not in captured.out
@pytest.mark.asyncio
async def test_help_command_empty_tags(capsys):
flx = Falyx()
async def untagged_command(falyx: Falyx):
pass
flx.add_command(
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
)
await flx.run_key("H", args=("nonexistent_tag",))
captured = capsys.readouterr()
print(captured.out)
assert "nonexistent_tag" in captured.out
assert "Nothing to show here" in captured.out

View File

@@ -0,0 +1,19 @@
import sys
import pytest
from falyx import Falyx
from falyx.parser import get_arg_parsers
@pytest.mark.asyncio
async def test_run_basic(capsys):
sys.argv = ["falyx", "run", "-h"]
falyx_parsers = get_arg_parsers()
assert falyx_parsers is not None, "Falyx parsers should be initialized"
flx = Falyx()
with pytest.raises(SystemExit):
await flx.run(falyx_parsers)
captured = capsys.readouterr()
assert "Run a command by its key or alias." in captured.out

View File

@@ -1,18 +1,311 @@
from pathlib import Path
import pytest import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser.command_argument_parser import CommandArgumentParser 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.asyncio
@pytest.mark.parametrize( async def test_parse_minimal_positional_and_defaults():
"input_tokens, expected", p = build_default_parser()
[ got = await p.parse_args(["web"])
([""], ["--help", "--tag", "-h"]), assert got["service"] == "web"
(["--ta"], ["--tag"]), assert got["place"] == "New York"
(["--tag"], ["analytics", "build"]), assert got["numbers"] == [1, 2, 3]
], assert got["verbose"] is False
) assert got["tag"] is None
async def test_suggest_next(input_tokens, expected): assert got["path"] is None
parser = CommandArgumentParser(...)
parser.add_argument("--tag", choices=["analytics", "build"])
assert sorted(parser.suggest_next(input_tokens)) == sorted(expected) @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"}