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:
@ -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())
|
||||
|
@ -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
|
||||
|
@ -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 = (
|
||||
|
@ -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)
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.82"
|
||||
__version__ = "0.1.83"
|
||||
|
@ -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"
|
||||
|
@ -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"}
|
||||
|
Reference in New Issue
Block a user