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