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.
This commit is contained in:
2025-08-10 15:55:45 -04:00
parent 0417a06ee4
commit 169f228c92
7 changed files with 876 additions and 187 deletions

View File

@ -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())

View File

@ -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

View File

@ -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 = (

View File

@ -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)

View File

@ -1 +1 @@
__version__ = "0.1.82"
__version__ = "0.1.83"

View File

@ -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 <roland@rtj.dev>"]
license = "MIT"

View File

@ -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"}