Compare commits
7 Commits
3b2c33d28f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
1ce1b2385b
|
|||
|
06bf0e432c
|
|||
|
169f228c92
|
|||
|
0417a06ee4
|
|||
|
55d581b870
|
|||
|
a25888f316
|
|||
|
8e306b9eaf
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
|
.config.json
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx
|
||||||
from falyx.action import Action
|
from falyx.action import Action
|
||||||
@@ -21,13 +22,27 @@ async def test_args(
|
|||||||
service: str,
|
service: str,
|
||||||
place: Place = Place.NEW_YORK,
|
place: Place = Place.NEW_YORK,
|
||||||
region: str = "us-east-1",
|
region: str = "us-east-1",
|
||||||
|
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}...")
|
print(
|
||||||
return f"{service}:{tag}:{number} deployed to {region} at {place}"
|
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:
|
||||||
@@ -52,22 +67,37 @@ def default_config(parser: CommandArgumentParser) -> None:
|
|||||||
help="Deployment region.",
|
help="Deployment region.",
|
||||||
choices=["us-east-1", "us-west-2", "eu-west-1"],
|
choices=["us-east-1", "us-west-2", "eu-west-1"],
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-p",
|
||||||
|
"--path",
|
||||||
|
type=Path,
|
||||||
|
help="Path to the configuration file.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--verbose",
|
"--verbose",
|
||||||
action="store_bool_optional",
|
action="store_bool_optional",
|
||||||
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)"),
|
||||||
@@ -77,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",
|
||||||
@@ -98,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())
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ Key Features:
|
|||||||
- Columnar layout with automatic width scaling
|
- Columnar layout with automatic width scaling
|
||||||
- Optional integration with `OptionsManager` for dynamic state toggling
|
- Optional integration with `OptionsManager` for dynamic state toggling
|
||||||
|
|
||||||
Usage Example:
|
Example:
|
||||||
|
```
|
||||||
bar = BottomBar(columns=3)
|
bar = BottomBar(columns=3)
|
||||||
bar.add_static("env", "ENV: dev")
|
bar.add_static("env", "ENV: dev")
|
||||||
bar.add_toggle("d", "Debug", get_debug, toggle_debug)
|
bar.add_toggle("d", "Debug", get_debug, toggle_debug)
|
||||||
bar.add_value_tracker("attempts", "Retries", get_retry_count)
|
bar.add_value_tracker("attempts", "Retries", get_retry_count)
|
||||||
bar.render()
|
bar.render()
|
||||||
|
```
|
||||||
|
|
||||||
Used by Falyx to provide a persistent UI element showing toggles, system state,
|
Used by Falyx to provide a persistent UI element showing toggles, system state,
|
||||||
and runtime telemetry below the input prompt.
|
and runtime telemetry below the input prompt.
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ class Command(BaseModel):
|
|||||||
FalyxMode.RUN,
|
FalyxMode.RUN,
|
||||||
FalyxMode.PREVIEW,
|
FalyxMode.PREVIEW,
|
||||||
FalyxMode.RUN_ALL,
|
FalyxMode.RUN_ALL,
|
||||||
|
FalyxMode.HELP,
|
||||||
}
|
}
|
||||||
|
|
||||||
program = f"{self.program} run " if is_cli_mode else ""
|
program = f"{self.program} run " if is_cli_mode else ""
|
||||||
@@ -365,7 +366,7 @@ class Command(BaseModel):
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f"[{self.style}]{program}[/]{command_keys}",
|
f"[{self.style}]{program}[/]{command_keys}",
|
||||||
f"[dim]{self.description}[/dim]",
|
f"[dim]{self.help_text or self.description}[/dim]",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ This completer supports:
|
|||||||
- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
|
- Argument flag completion for registered commands (e.g. `--tag`, `--name`)
|
||||||
- Context-aware suggestions based on cursor position and argument structure
|
- Context-aware suggestions based on cursor position and argument structure
|
||||||
- Interactive value completions (e.g. choices and suggestions defined per argument)
|
- Interactive value completions (e.g. choices and suggestions defined per argument)
|
||||||
|
- File/path-friendly behavior, quoting completions with spaces automatically
|
||||||
|
|
||||||
|
|
||||||
|
Completions are generated from:
|
||||||
|
- Registered commands in `Falyx`
|
||||||
|
- Argument metadata and `suggest_next()` from `CommandArgumentParser`
|
||||||
|
|
||||||
Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes
|
|
||||||
parsed tokens to determine appropriate next arguments, flags, or values.
|
|
||||||
|
|
||||||
Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
|
Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from typing import TYPE_CHECKING, Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
@@ -41,9 +46,12 @@ class FalyxCompleter(Completer):
|
|||||||
- Remaining required or optional flags
|
- Remaining required or optional flags
|
||||||
- Flag value suggestions (choices or custom completions)
|
- Flag value suggestions (choices or custom completions)
|
||||||
- Next positional argument hints
|
- Next positional argument hints
|
||||||
|
- Inserts longest common prefix (LCP) completions when applicable
|
||||||
|
- Handles special cases like quoted strings and spaces
|
||||||
|
- Supports dynamic argument suggestions (e.g. flags, file paths, etc.)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
falyx (Falyx): The Falyx menu instance containing all command mappings and parsers.
|
falyx (Falyx): The active Falyx instance providing command and parser context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, falyx: "Falyx"):
|
def __init__(self, falyx: "Falyx"):
|
||||||
@@ -51,14 +59,21 @@ class FalyxCompleter(Completer):
|
|||||||
|
|
||||||
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
|
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
|
||||||
"""
|
"""
|
||||||
Yield completions based on the current document input.
|
Compute completions for the current user input.
|
||||||
|
|
||||||
|
Analyzes the input buffer, determines whether the user is typing:
|
||||||
|
• A command key/alias
|
||||||
|
• A flag/option
|
||||||
|
• An argument value
|
||||||
|
|
||||||
|
and yields appropriate completions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document (Document): The prompt_toolkit document containing the input buffer.
|
document (Document): The current Prompt Toolkit document (input buffer & cursor).
|
||||||
complete_event: The completion trigger event (unused).
|
complete_event: The triggering event (TAB key, menu display, etc.) — not used here.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Completion objects matching command keys or argument suggestions.
|
Completion: One or more completions matching the current stub text.
|
||||||
"""
|
"""
|
||||||
text = document.text_before_cursor
|
text = document.text_before_cursor
|
||||||
try:
|
try:
|
||||||
@@ -69,7 +84,9 @@ class FalyxCompleter(Completer):
|
|||||||
|
|
||||||
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
|
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
|
||||||
# Suggest command keys and aliases
|
# Suggest command keys and aliases
|
||||||
yield from self._suggest_commands(tokens[0] if tokens else "")
|
stub = tokens[0] if tokens else ""
|
||||||
|
suggestions = [c.text for c in self._suggest_commands(stub)]
|
||||||
|
yield from self._yield_lcp_completions(suggestions, stub)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Identify command
|
# Identify command
|
||||||
@@ -83,21 +100,10 @@ class FalyxCompleter(Completer):
|
|||||||
stub = "" if cursor_at_end_of_token else tokens[-1]
|
stub = "" if cursor_at_end_of_token else tokens[-1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not command.arg_parser:
|
|
||||||
return
|
|
||||||
suggestions = command.arg_parser.suggest_next(
|
suggestions = command.arg_parser.suggest_next(
|
||||||
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
|
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
|
||||||
)
|
)
|
||||||
for suggestion in suggestions:
|
yield from self._yield_lcp_completions(suggestions, stub)
|
||||||
if suggestion.startswith(stub):
|
|
||||||
if len(suggestion.split()) > 1:
|
|
||||||
yield Completion(
|
|
||||||
f'"{suggestion}"',
|
|
||||||
start_position=-len(stub),
|
|
||||||
display=suggestion,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
yield Completion(suggestion, start_position=-len(stub))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -105,13 +111,15 @@ class FalyxCompleter(Completer):
|
|||||||
"""
|
"""
|
||||||
Suggest top-level command keys and aliases based on the given prefix.
|
Suggest top-level command keys and aliases based on the given prefix.
|
||||||
|
|
||||||
|
Filters all known commands (and `exit`, `help`, `history` built-ins)
|
||||||
|
to only those starting with the given prefix.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prefix (str): The user input to match against available commands.
|
prefix (str): The current typed prefix.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Completion: Matching keys or aliases from all registered commands.
|
Completion: Matching keys or aliases from all registered commands.
|
||||||
"""
|
"""
|
||||||
prefix = prefix.upper()
|
|
||||||
keys = [self.falyx.exit_command.key]
|
keys = [self.falyx.exit_command.key]
|
||||||
keys.extend(self.falyx.exit_command.aliases)
|
keys.extend(self.falyx.exit_command.aliases)
|
||||||
if self.falyx.history_command:
|
if self.falyx.history_command:
|
||||||
@@ -125,4 +133,64 @@ class FalyxCompleter(Completer):
|
|||||||
keys.extend(cmd.aliases)
|
keys.extend(cmd.aliases)
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if key.upper().startswith(prefix):
|
if key.upper().startswith(prefix):
|
||||||
yield Completion(key, start_position=-len(prefix))
|
yield Completion(key.upper(), start_position=-len(prefix))
|
||||||
|
elif key.lower().startswith(prefix):
|
||||||
|
yield Completion(key.lower(), start_position=-len(prefix))
|
||||||
|
|
||||||
|
def _ensure_quote(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Ensure that a suggestion is shell-safe by quoting if needed.
|
||||||
|
|
||||||
|
Adds quotes around completions containing whitespace so they can
|
||||||
|
be inserted into the CLI without breaking tokenization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The input text to quote.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The quoted text, suitable for shell command usage.
|
||||||
|
"""
|
||||||
|
if " " in text or "\t" in text:
|
||||||
|
return f'"{text}"'
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _yield_lcp_completions(self, suggestions, stub):
|
||||||
|
"""
|
||||||
|
Yield completions for the current stub using longest-common-prefix logic.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If only one match → yield it fully.
|
||||||
|
- If multiple matches share a longer prefix → insert the prefix, but also
|
||||||
|
display all matches in the menu.
|
||||||
|
- If no shared prefix → list all matches individually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
suggestions (list[str]): The raw suggestions to consider.
|
||||||
|
stub (str): The currently typed prefix (used to offset insertion).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Completion: Completion objects for the Prompt Toolkit menu.
|
||||||
|
"""
|
||||||
|
matches = [s for s in suggestions if s.startswith(stub)]
|
||||||
|
if not matches:
|
||||||
|
return
|
||||||
|
|
||||||
|
lcp = os.path.commonprefix(matches)
|
||||||
|
|
||||||
|
if len(matches) == 1:
|
||||||
|
yield Completion(
|
||||||
|
self._ensure_quote(matches[0]),
|
||||||
|
start_position=-len(stub),
|
||||||
|
display=matches[0],
|
||||||
|
)
|
||||||
|
elif len(lcp) > len(stub) and not lcp.startswith("-"):
|
||||||
|
yield Completion(lcp, start_position=-len(stub), display=lcp)
|
||||||
|
for match in matches:
|
||||||
|
yield Completion(
|
||||||
|
self._ensure_quote(match), start_position=-len(stub), display=match
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for match in matches:
|
||||||
|
yield Completion(
|
||||||
|
self._ensure_quote(match), start_position=-len(stub), display=match
|
||||||
|
)
|
||||||
|
|||||||
225
falyx/falyx.py
225
falyx/falyx.py
@@ -37,6 +37,7 @@ from prompt_toolkit.formatted_text import StyleAndTextTuples
|
|||||||
from prompt_toolkit.history import FileHistory
|
from prompt_toolkit.history import FileHistory
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
from prompt_toolkit.patch_stdout import patch_stdout
|
from prompt_toolkit.patch_stdout import patch_stdout
|
||||||
|
from prompt_toolkit.shortcuts import CompleteStyle
|
||||||
from prompt_toolkit.validation import ValidationError
|
from prompt_toolkit.validation import ValidationError
|
||||||
from rich import box
|
from rich import box
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -202,10 +203,12 @@ class Falyx:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cli_mode(self) -> bool:
|
def is_cli_mode(self) -> bool:
|
||||||
|
"""Checks if the current mode is a CLI mode."""
|
||||||
return self.options.get("mode") in {
|
return self.options.get("mode") in {
|
||||||
FalyxMode.RUN,
|
FalyxMode.RUN,
|
||||||
FalyxMode.PREVIEW,
|
FalyxMode.PREVIEW,
|
||||||
FalyxMode.RUN_ALL,
|
FalyxMode.RUN_ALL,
|
||||||
|
FalyxMode.HELP,
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_options(
|
def validate_options(
|
||||||
@@ -279,7 +282,7 @@ class Falyx:
|
|||||||
|
|
||||||
def _get_exit_command(self) -> Command:
|
def _get_exit_command(self) -> Command:
|
||||||
"""Returns the back command for the menu."""
|
"""Returns the back command for the menu."""
|
||||||
return Command(
|
exit_command = Command(
|
||||||
key="X",
|
key="X",
|
||||||
description="Exit",
|
description="Exit",
|
||||||
action=Action("Exit", action=_noop),
|
action=Action("Exit", action=_noop),
|
||||||
@@ -289,7 +292,11 @@ class Falyx:
|
|||||||
ignore_in_history=True,
|
ignore_in_history=True,
|
||||||
options_manager=self.options,
|
options_manager=self.options,
|
||||||
program=self.program,
|
program=self.program,
|
||||||
|
help_text="Exit the program.",
|
||||||
)
|
)
|
||||||
|
if exit_command.arg_parser:
|
||||||
|
exit_command.arg_parser.add_tldr_examples([("", "Exit the program.")])
|
||||||
|
return exit_command
|
||||||
|
|
||||||
def _get_history_command(self) -> Command:
|
def _get_history_command(self) -> Command:
|
||||||
"""Returns the history command for the menu."""
|
"""Returns the history command for the menu."""
|
||||||
@@ -299,6 +306,7 @@ class Falyx:
|
|||||||
command_style=OneColors.DARK_YELLOW,
|
command_style=OneColors.DARK_YELLOW,
|
||||||
aliases=["HISTORY"],
|
aliases=["HISTORY"],
|
||||||
program=self.program,
|
program=self.program,
|
||||||
|
options_manager=self.options,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-n",
|
"-n",
|
||||||
@@ -333,6 +341,19 @@ class Falyx:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l", "--last-result", action="store_true", help="Get the last result"
|
"-l", "--last-result", action="store_true", help="Get the last result"
|
||||||
)
|
)
|
||||||
|
parser.add_tldr_examples(
|
||||||
|
[
|
||||||
|
("", "Show the full execution history."),
|
||||||
|
("-n build", "Show history entries for the 'build' command."),
|
||||||
|
("-s success", "Show only successful executions."),
|
||||||
|
("-s error", "Show only failed executions."),
|
||||||
|
("-i 3", "Show the history entry at index 3."),
|
||||||
|
("-r 0", "Show the result or traceback for entry index 0."),
|
||||||
|
("-l", "Show the last execution result."),
|
||||||
|
("-c", "Clear the execution history."),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
return Command(
|
return Command(
|
||||||
key="Y",
|
key="Y",
|
||||||
description="History",
|
description="History",
|
||||||
@@ -347,6 +368,7 @@ class Falyx:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_tip(self) -> str:
|
def get_tip(self) -> str:
|
||||||
|
"""Returns a random tip for the user about using Falyx."""
|
||||||
program = f"{self.program} run " if self.is_cli_mode else ""
|
program = f"{self.program} run " if self.is_cli_mode else ""
|
||||||
tips = [
|
tips = [
|
||||||
f"Use '{program}?[COMMAND]' to preview a command.",
|
f"Use '{program}?[COMMAND]' to preview a command.",
|
||||||
@@ -358,11 +380,12 @@ class Falyx:
|
|||||||
f"Use '{self.program} --verbose' to enable debug logging for a menu session.",
|
f"Use '{self.program} --verbose' to enable debug logging for a menu session.",
|
||||||
f"'{self.program} --debug-hooks' will trace every before/after hook in action.",
|
f"'{self.program} --debug-hooks' will trace every before/after hook in action.",
|
||||||
f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
|
f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
|
||||||
|
"All [COMMAND] keys and aliases are case-insensitive.",
|
||||||
]
|
]
|
||||||
if self.is_cli_mode:
|
if self.is_cli_mode:
|
||||||
tips.extend(
|
tips.extend(
|
||||||
[
|
[
|
||||||
f"Use '{self.program} run ?' to list all commands at any time.",
|
f"Use '{self.program} help' to list all commands at any time.",
|
||||||
f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
|
f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
|
||||||
f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
|
f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
|
||||||
f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
|
f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
|
||||||
@@ -381,7 +404,35 @@ class Falyx:
|
|||||||
)
|
)
|
||||||
return choice(tips)
|
return choice(tips)
|
||||||
|
|
||||||
async def _render_help(self, tag: str = "") -> None:
|
async def _render_help(
|
||||||
|
self, tag: str = "", key: str | None = None, tldr: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Renders the help menu with command details, usage examples, and tips."""
|
||||||
|
if tldr and not key:
|
||||||
|
if self.help_command and self.help_command.arg_parser:
|
||||||
|
self.help_command.arg_parser.render_tldr()
|
||||||
|
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
|
||||||
|
return None
|
||||||
|
if key:
|
||||||
|
_, command, args, kwargs = await self.get_command(key, from_help=True)
|
||||||
|
if command and tldr and command.arg_parser:
|
||||||
|
command.arg_parser.render_tldr()
|
||||||
|
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
|
||||||
|
return None
|
||||||
|
elif command and tldr and not command.arg_parser:
|
||||||
|
self.console.print(
|
||||||
|
f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
|
||||||
|
)
|
||||||
|
elif command and command.arg_parser:
|
||||||
|
command.arg_parser.render_help()
|
||||||
|
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
|
||||||
|
return None
|
||||||
|
elif command and not command.arg_parser:
|
||||||
|
self.console.print(
|
||||||
|
f"[bold]No detailed help available for '{command.description}'.[/bold]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.console.print(f"[bold]No command found for '{key}'.[/bold]")
|
||||||
if tag:
|
if tag:
|
||||||
tag_lower = tag.lower()
|
tag_lower = tag.lower()
|
||||||
self.console.print(f"[bold]{tag_lower}:[/bold]")
|
self.console.print(f"[bold]{tag_lower}:[/bold]")
|
||||||
@@ -392,13 +443,17 @@ class Falyx:
|
|||||||
]
|
]
|
||||||
if not commands:
|
if not commands:
|
||||||
self.console.print(f"'{tag}'... Nothing to show here")
|
self.console.print(f"'{tag}'... Nothing to show here")
|
||||||
return
|
return None
|
||||||
for command in commands:
|
for command in commands:
|
||||||
usage, description, _ = command.help_signature
|
usage, description, _ = command.help_signature
|
||||||
self.console.print(usage)
|
self.console.print(
|
||||||
if description:
|
Padding(
|
||||||
self.console.print(description)
|
Panel(usage, expand=False, title=description, title_align="left"),
|
||||||
return
|
(0, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
|
||||||
|
return None
|
||||||
|
|
||||||
self.console.print("[bold]help:[/bold]")
|
self.console.print("[bold]help:[/bold]")
|
||||||
for command in self.commands.values():
|
for command in self.commands.values():
|
||||||
@@ -447,8 +502,10 @@ class Falyx:
|
|||||||
command_key="H",
|
command_key="H",
|
||||||
command_description="Help",
|
command_description="Help",
|
||||||
command_style=OneColors.LIGHT_YELLOW,
|
command_style=OneColors.LIGHT_YELLOW,
|
||||||
aliases=["?", "HELP", "LIST"],
|
aliases=["HELP", "?"],
|
||||||
program=self.program,
|
program=self.program,
|
||||||
|
options_manager=self.options,
|
||||||
|
_is_help_command=True,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-t",
|
"-t",
|
||||||
@@ -457,11 +514,27 @@ class Falyx:
|
|||||||
default="",
|
default="",
|
||||||
help="Optional tag to filter commands by.",
|
help="Optional tag to filter commands by.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--key",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Optional command key or alias to get detailed help for.",
|
||||||
|
)
|
||||||
|
parser.add_tldr_examples(
|
||||||
|
[
|
||||||
|
("", "Show all commands."),
|
||||||
|
("-k [COMMAND]", "Show detailed help for a specific command."),
|
||||||
|
("-Tk [COMMAND]", "Show quick usage examples for a specific command."),
|
||||||
|
("-T", "Show these quick usage examples."),
|
||||||
|
("-t [TAG]", "Show commands with the specified tag."),
|
||||||
|
]
|
||||||
|
)
|
||||||
return Command(
|
return Command(
|
||||||
key="H",
|
key="H",
|
||||||
aliases=["?", "HELP", "LIST"],
|
aliases=["HELP", "?"],
|
||||||
description="Help",
|
description="Help",
|
||||||
help_text="Show this help menu",
|
help_text="Show this help menu.",
|
||||||
action=Action("Help", self._render_help),
|
action=Action("Help", self._render_help),
|
||||||
style=OneColors.LIGHT_YELLOW,
|
style=OneColors.LIGHT_YELLOW,
|
||||||
arg_parser=parser,
|
arg_parser=parser,
|
||||||
@@ -559,6 +632,7 @@ class Falyx:
|
|||||||
history=self.history,
|
history=self.history,
|
||||||
multiline=False,
|
multiline=False,
|
||||||
completer=self._get_completer(),
|
completer=self._get_completer(),
|
||||||
|
complete_style=CompleteStyle.COLUMN,
|
||||||
validator=CommandValidator(self, self._get_validator_error_message()),
|
validator=CommandValidator(self, self._get_validator_error_message()),
|
||||||
bottom_toolbar=self._get_bottom_bar_render(),
|
bottom_toolbar=self._get_bottom_bar_render(),
|
||||||
key_bindings=self.key_bindings,
|
key_bindings=self.key_bindings,
|
||||||
@@ -625,6 +699,7 @@ class Falyx:
|
|||||||
style: str = OneColors.DARK_RED,
|
style: str = OneColors.DARK_RED,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
confirm_message: str = "Are you sure?",
|
confirm_message: str = "Are you sure?",
|
||||||
|
help_text: str = "Exit the program.",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Updates the back command of the menu."""
|
"""Updates the back command of the menu."""
|
||||||
self._validate_command_key(key)
|
self._validate_command_key(key)
|
||||||
@@ -642,7 +717,10 @@ class Falyx:
|
|||||||
ignore_in_history=True,
|
ignore_in_history=True,
|
||||||
options_manager=self.options,
|
options_manager=self.options,
|
||||||
program=self.program,
|
program=self.program,
|
||||||
|
help_text=help_text,
|
||||||
)
|
)
|
||||||
|
if self.exit_command.arg_parser:
|
||||||
|
self.exit_command.arg_parser.add_tldr_examples([("", help_text)])
|
||||||
|
|
||||||
def add_submenu(
|
def add_submenu(
|
||||||
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
|
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
|
||||||
@@ -655,7 +733,12 @@ class Falyx:
|
|||||||
key, description, submenu.menu, style=style, simple_help_signature=True
|
key, description, submenu.menu, style=style, simple_help_signature=True
|
||||||
)
|
)
|
||||||
if submenu.exit_command.key == "X":
|
if submenu.exit_command.key == "X":
|
||||||
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
|
submenu.update_exit_command(
|
||||||
|
key="B",
|
||||||
|
description="Back",
|
||||||
|
aliases=["BACK"],
|
||||||
|
help_text="Go back to the previous menu.",
|
||||||
|
)
|
||||||
|
|
||||||
def add_commands(self, commands: list[Command] | list[dict]) -> None:
|
def add_commands(self, commands: list[Command] | list[dict]) -> None:
|
||||||
"""Adds a list of Command instances or config dicts."""
|
"""Adds a list of Command instances or config dicts."""
|
||||||
@@ -861,7 +944,7 @@ class Falyx:
|
|||||||
return False, input_str.strip()
|
return False, input_str.strip()
|
||||||
|
|
||||||
async def get_command(
|
async def get_command(
|
||||||
self, raw_choices: str, from_validate=False
|
self, raw_choices: str, from_validate=False, from_help=False
|
||||||
) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
|
) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Returns the selected command based on user input.
|
Returns the selected command based on user input.
|
||||||
@@ -902,11 +985,7 @@ class Falyx:
|
|||||||
logger.info("Command '%s' selected.", run_command.key)
|
logger.info("Command '%s' selected.", run_command.key)
|
||||||
if is_preview:
|
if is_preview:
|
||||||
return True, run_command, args, kwargs
|
return True, run_command, args, kwargs
|
||||||
elif self.options.get("mode") in {
|
elif self.is_cli_mode or from_help:
|
||||||
FalyxMode.RUN,
|
|
||||||
FalyxMode.RUN_ALL,
|
|
||||||
FalyxMode.PREVIEW,
|
|
||||||
}:
|
|
||||||
return False, run_command, args, kwargs
|
return False, run_command, args, kwargs
|
||||||
try:
|
try:
|
||||||
args, kwargs = await run_command.parse_args(input_args, from_validate)
|
args, kwargs = await run_command.parse_args(input_args, from_validate)
|
||||||
@@ -1155,7 +1234,59 @@ class Falyx:
|
|||||||
subparsers: _SubParsersAction | None = None,
|
subparsers: _SubParsersAction | None = None,
|
||||||
callback: Callable[..., Any] | None = None,
|
callback: Callable[..., Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run Falyx CLI with structured subcommands."""
|
"""
|
||||||
|
Entrypoint for executing a Falyx CLI application via structured subcommands.
|
||||||
|
|
||||||
|
This method parses CLI arguments, configures the runtime environment, and dispatches
|
||||||
|
execution to the appropriate command mode:
|
||||||
|
|
||||||
|
- help - Show help output, optionally filtered by tag.
|
||||||
|
- version - Print the program version and exit.
|
||||||
|
- preview - Display a preview of the specified command without executing it.
|
||||||
|
- run - Execute a single command with parsed arguments and lifecycle hooks.
|
||||||
|
- run-all - Run all commands matching a tag concurrently (with default args).
|
||||||
|
- (default) - Launch the interactive Falyx menu loop.
|
||||||
|
|
||||||
|
It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting,
|
||||||
|
and supports an optional callback for post-parse setup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
falyx_parsers (FalyxParsers | None):
|
||||||
|
Preconfigured argument parser set. If not provided, a default parser
|
||||||
|
is created using the registered commands and passed-in `root_parser`
|
||||||
|
or `subparsers`.
|
||||||
|
root_parser (ArgumentParser | None):
|
||||||
|
Optional root parser to merge into the CLI (used if `falyx_parsers`
|
||||||
|
is not supplied).
|
||||||
|
subparsers (_SubParsersAction | None):
|
||||||
|
Optional subparser group to extend (used if `falyx_parsers` is not supplied).
|
||||||
|
callback (Callable[..., Any] | None):
|
||||||
|
An optional function or coroutine to run after parsing CLI arguments,
|
||||||
|
typically for initializing logging, environment setup, or other
|
||||||
|
pre-execution configuration.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FalyxError:
|
||||||
|
If invalid parser objects are supplied, or CLI arguments conflict
|
||||||
|
with the expected run mode.
|
||||||
|
SystemExit:
|
||||||
|
Exits with an appropriate exit code based on the selected command
|
||||||
|
or signal (e.g. Ctrl+C triggers exit code 130).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `run-all` executes all tagged commands **in parallel** and does not
|
||||||
|
supply arguments to individual commands; use `ChainedAction` or explicit
|
||||||
|
CLI calls for ordered or parameterized workflows.
|
||||||
|
- Most CLI commands exit the process via `sys.exit()` after completion.
|
||||||
|
- For interactive sessions, this method falls back to `menu()`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
>>> flx = Falyx()
|
||||||
|
>>> await flx.run() # Parses CLI args and dispatches appropriately
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
if self.cli_args:
|
if self.cli_args:
|
||||||
raise FalyxError(
|
raise FalyxError(
|
||||||
"Run is incompatible with CLI arguments. Use 'run_key' instead."
|
"Run is incompatible with CLI arguments. Use 'run_key' instead."
|
||||||
@@ -1198,8 +1329,11 @@ class Falyx:
|
|||||||
logger.debug("Enabling global debug hooks for all commands")
|
logger.debug("Enabling global debug hooks for all commands")
|
||||||
self.register_all_with_debug_hooks()
|
self.register_all_with_debug_hooks()
|
||||||
|
|
||||||
if self.cli_args.command == "list":
|
if self.cli_args.command == "help":
|
||||||
await self._render_help(tag=self.cli_args.tag)
|
self.options.set("mode", FalyxMode.HELP)
|
||||||
|
await self._render_help(
|
||||||
|
tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr
|
||||||
|
)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if self.cli_args.command == "version" or self.cli_args.version:
|
if self.cli_args.command == "version" or self.cli_args.version:
|
||||||
@@ -1247,13 +1381,13 @@ class Falyx:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except QuitSignal:
|
except QuitSignal:
|
||||||
logger.info("[QuitSignal]. <- Exiting run.")
|
logger.info("[QuitSignal]. <- Exiting run.")
|
||||||
sys.exit(0)
|
sys.exit(130)
|
||||||
except BackSignal:
|
except BackSignal:
|
||||||
logger.info("[BackSignal]. <- Exiting run.")
|
logger.info("[BackSignal]. <- Exiting run.")
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
except CancelSignal:
|
except CancelSignal:
|
||||||
logger.info("[CancelSignal]. <- Exiting run.")
|
logger.info("[CancelSignal]. <- Exiting run.")
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
|
|
||||||
if self.cli_args.summary:
|
if self.cli_args.summary:
|
||||||
er.summary()
|
er.summary()
|
||||||
@@ -1278,22 +1412,35 @@ class Falyx:
|
|||||||
f"{self.cli_args.tag}"
|
f"{self.cli_args.tag}"
|
||||||
)
|
)
|
||||||
|
|
||||||
for cmd in matching:
|
tasks = []
|
||||||
self._set_retry_policy(cmd)
|
try:
|
||||||
try:
|
for cmd in matching:
|
||||||
await self.run_key(cmd.key)
|
self._set_retry_policy(cmd)
|
||||||
except FalyxError as error:
|
tasks.append(self.run_key(cmd.key))
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
except Exception as error:
|
||||||
sys.exit(1)
|
self.console.print(
|
||||||
except QuitSignal:
|
f"[{OneColors.DARK_RED}]❌ Unexpected error: {error}[/]"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
had_errors = False
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, QuitSignal):
|
||||||
logger.info("[QuitSignal]. <- Exiting run.")
|
logger.info("[QuitSignal]. <- Exiting run.")
|
||||||
sys.exit(0)
|
sys.exit(130)
|
||||||
except BackSignal:
|
elif isinstance(result, CancelSignal):
|
||||||
logger.info("[BackSignal]. <- Exiting run.")
|
logger.info("[CancelSignal]. <- Execution cancelled.")
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
except CancelSignal:
|
elif isinstance(result, BackSignal):
|
||||||
logger.info("[CancelSignal]. <- Exiting run.")
|
logger.info("[BackSignal]. <- Back signal received.")
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
|
elif isinstance(result, FalyxError):
|
||||||
|
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {result}[/]")
|
||||||
|
had_errors = True
|
||||||
|
|
||||||
|
if had_errors:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if self.cli_args.summary:
|
if self.cli_args.summary:
|
||||||
er.summary()
|
er.summary()
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ class FalyxMode(Enum):
|
|||||||
RUN = "run"
|
RUN = "run"
|
||||||
PREVIEW = "preview"
|
PREVIEW = "preview"
|
||||||
RUN_ALL = "run-all"
|
RUN_ALL = "run-all"
|
||||||
|
HELP = "help"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI.
|
|||||||
|
|
||||||
This module defines the `FalyxParsers` dataclass and related utilities for building
|
This module defines the `FalyxParsers` dataclass and related utilities for building
|
||||||
structured CLI interfaces with argparse. It supports top-level CLI commands like
|
structured CLI interfaces with argparse. It supports top-level CLI commands like
|
||||||
`run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with
|
`run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with
|
||||||
registered `Command` objects for dynamic help, usage generation, and argument handling.
|
registered `Command` objects for dynamic help, usage generation, and argument handling.
|
||||||
|
|
||||||
Key Components:
|
Key Components:
|
||||||
@@ -39,7 +39,7 @@ class FalyxParsers:
|
|||||||
run: ArgumentParser
|
run: ArgumentParser
|
||||||
run_all: ArgumentParser
|
run_all: ArgumentParser
|
||||||
preview: ArgumentParser
|
preview: ArgumentParser
|
||||||
list: ArgumentParser
|
help: ArgumentParser
|
||||||
version: ArgumentParser
|
version: ArgumentParser
|
||||||
|
|
||||||
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
|
def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
|
||||||
@@ -59,7 +59,7 @@ def get_root_parser(
|
|||||||
prog: str | None = "falyx",
|
prog: str | None = "falyx",
|
||||||
usage: str | None = None,
|
usage: str | None = None,
|
||||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||||
epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.",
|
epilog: str | None = "Tip: Use 'falyx help' to show available commands.",
|
||||||
parents: Sequence[ArgumentParser] | None = None,
|
parents: Sequence[ArgumentParser] | None = None,
|
||||||
prefix_chars: str = "-",
|
prefix_chars: str = "-",
|
||||||
fromfile_prefix_chars: str | None = None,
|
fromfile_prefix_chars: str | None = None,
|
||||||
@@ -178,7 +178,7 @@ def get_arg_parsers(
|
|||||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||||
epilog: (
|
epilog: (
|
||||||
str | None
|
str | None
|
||||||
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
|
) = "Tip: Use 'falyx preview [COMMAND]' to preview any command from the CLI.",
|
||||||
parents: Sequence[ArgumentParser] | None = None,
|
parents: Sequence[ArgumentParser] | None = None,
|
||||||
prefix_chars: str = "-",
|
prefix_chars: str = "-",
|
||||||
fromfile_prefix_chars: str | None = None,
|
fromfile_prefix_chars: str | None = None,
|
||||||
@@ -196,7 +196,7 @@ def get_arg_parsers(
|
|||||||
|
|
||||||
This function builds the root parser and all subcommand parsers used for structured
|
This function builds the root parser and all subcommand parsers used for structured
|
||||||
CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
|
CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
|
||||||
`preview`, `list`, and `version`, and integrates with registered `Command` objects
|
`preview`, `help`, and `version`, and integrates with registered `Command` objects
|
||||||
to populate dynamic help and usage documentation.
|
to populate dynamic help and usage documentation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -219,7 +219,7 @@ def get_arg_parsers(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
|
FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
|
||||||
`preview`, `list`, `version`, and the root parser.
|
`preview`, `help`, `version`, and the root parser.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TypeError: If `root_parser` is not an instance of ArgumentParser or
|
TypeError: If `root_parser` is not an instance of ArgumentParser or
|
||||||
@@ -240,7 +240,7 @@ def get_arg_parsers(
|
|||||||
- Use `falyx run ?[COMMAND]` from the CLI to preview a command.
|
- Use `falyx run ?[COMMAND]` from the CLI to preview a command.
|
||||||
"""
|
"""
|
||||||
if epilog is None:
|
if epilog is None:
|
||||||
epilog = f"Tip: Use '{prog} run ?' to show available commands."
|
epilog = f"Tip: Use '{prog} help' to show available commands."
|
||||||
if root_parser is None:
|
if root_parser is None:
|
||||||
parser = get_root_parser(
|
parser = get_root_parser(
|
||||||
prog=prog,
|
prog=prog,
|
||||||
@@ -281,7 +281,7 @@ def get_arg_parsers(
|
|||||||
command_description = command.help_text or command.description
|
command_description = command.help_text or command.description
|
||||||
run_description.append(f"{' '*24}{command_description}")
|
run_description.append(f"{' '*24}{command_description}")
|
||||||
run_epilog = (
|
run_epilog = (
|
||||||
f"Tip: Use '{prog} run ?[COMMAND]' to preview commands by their key or alias."
|
f"Tip: Use '{prog} preview [COMMAND]' to preview commands by their key or alias."
|
||||||
)
|
)
|
||||||
run_parser = subparsers.add_parser(
|
run_parser = subparsers.add_parser(
|
||||||
"run",
|
"run",
|
||||||
@@ -375,11 +375,23 @@ def get_arg_parsers(
|
|||||||
)
|
)
|
||||||
preview_parser.add_argument("name", help="Key, alias, or description of the command")
|
preview_parser.add_argument("name", help="Key, alias, or description of the command")
|
||||||
|
|
||||||
list_parser = subparsers.add_parser(
|
help_parser = subparsers.add_parser("help", help="List all available commands")
|
||||||
"list", help="List all available commands with tags"
|
|
||||||
|
help_parser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--key",
|
||||||
|
help="Show help for a specific command by its key or alias",
|
||||||
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
list_parser.add_argument(
|
help_parser.add_argument(
|
||||||
|
"-T",
|
||||||
|
"--tldr",
|
||||||
|
action="store_true",
|
||||||
|
help="Show a simplified TLDR examples of a command if available",
|
||||||
|
)
|
||||||
|
|
||||||
|
help_parser.add_argument(
|
||||||
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
|
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -391,6 +403,6 @@ def get_arg_parsers(
|
|||||||
run=run_parser,
|
run=run_parser,
|
||||||
run_all=run_all_parser,
|
run_all=run_all_parser,
|
||||||
preview=preview_parser,
|
preview=preview_parser,
|
||||||
list=list_parser,
|
help=help_parser,
|
||||||
version=version_parser,
|
version=version_parser,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ def infer_args_from_func(
|
|||||||
This utility inspects the parameters of a function and returns a list of dictionaries,
|
This utility inspects the parameters of a function and returns a list of dictionaries,
|
||||||
each of which can be passed to `CommandArgumentParser.add_argument()`.
|
each of which can be passed to `CommandArgumentParser.add_argument()`.
|
||||||
|
|
||||||
|
It supports:
|
||||||
|
- Positional and keyword arguments
|
||||||
|
- Type hints for argument types
|
||||||
|
- Default values
|
||||||
|
- Required vs optional arguments
|
||||||
|
- Custom help text, choices, and suggestions via metadata
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Only parameters with kind `POSITIONAL_ONLY`, `POSITIONAL_OR_KEYWORD`, or
|
||||||
|
`KEYWORD_ONLY` are considered.
|
||||||
|
- Parameters with kind `VAR_POSITIONAL` or `VAR_KEYWORD` are ignored.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
func (Callable | None): The function to inspect.
|
func (Callable | None): The function to inspect.
|
||||||
arg_metadata (dict | None): Optional metadata overrides for help text, type hints,
|
arg_metadata (dict | None): Optional metadata overrides for help text, type hints,
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ def words_validator(
|
|||||||
|
|
||||||
def word_validator(word: str) -> Validator:
|
def word_validator(word: str) -> Validator:
|
||||||
"""Validator for specific word inputs."""
|
"""Validator for specific word inputs."""
|
||||||
|
if word.upper() == "N":
|
||||||
|
raise ValueError(
|
||||||
|
"The word 'N' is reserved for yes/no validation. Use yes_no_validator instead."
|
||||||
|
)
|
||||||
|
|
||||||
def validate(text: str) -> bool:
|
def validate(text: str) -> bool:
|
||||||
if text.upper().strip() == "N":
|
if text.upper().strip() == "N":
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.78"
|
__version__ = "0.1.85"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.78"
|
version = "0.1.85"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
97
tests/test_completer/test_completer.py
Normal file
97
tests/test_completer/test_completer.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from prompt_toolkit.completion import Completion
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
|
||||||
|
from falyx.completer import FalyxCompleter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_falyx():
|
||||||
|
fake_arg_parser = SimpleNamespace(
|
||||||
|
suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"]
|
||||||
|
)
|
||||||
|
fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
|
||||||
|
return SimpleNamespace(
|
||||||
|
exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
|
||||||
|
help_command=SimpleNamespace(key="H", aliases=["HELP"]),
|
||||||
|
history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
|
||||||
|
commands={"R": fake_command},
|
||||||
|
_name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_commands(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
completions = list(completer._suggest_commands("R"))
|
||||||
|
assert any(c.text == "R" for c in completions)
|
||||||
|
assert any(c.text == "RUN" for c in completions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_commands_empty(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
completions = list(completer._suggest_commands(""))
|
||||||
|
assert any(c.text == "X" for c in completions)
|
||||||
|
assert any(c.text == "H" for c in completions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_commands_no_match(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
completions = list(completer._suggest_commands("Z"))
|
||||||
|
assert not completions
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_no_input(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document("")
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert any(isinstance(c, Completion) for c in results)
|
||||||
|
assert any(c.text == "X" for c in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_no_match(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document("Z")
|
||||||
|
completions = list(completer.get_completions(doc, None))
|
||||||
|
assert not completions
|
||||||
|
doc = Document("Z Z")
|
||||||
|
completions = list(completer.get_completions(doc, None))
|
||||||
|
assert not completions
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_partial_command(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document("R")
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert any(c.text in ("R", "RUN") for c in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_with_flag(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document("R ")
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert "--tag" in [c.text for c in results]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_partial_flag(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document("R --t")
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert all(c.start_position <= 0 for c in results)
|
||||||
|
assert any(c.text.startswith("--t") or c.display == "--tag" for c in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_bad_input(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document('R "unclosed quote')
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_completions_exception_handling(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0
|
||||||
|
doc = Document("R --tag")
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert results == []
|
||||||
38
tests/test_completer/test_lcp_completions.py
Normal file
38
tests/test_completer/test_lcp_completions.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
|
||||||
|
from falyx.completer import FalyxCompleter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_falyx():
|
||||||
|
fake_arg_parser = SimpleNamespace(
|
||||||
|
suggest_next=lambda tokens, end: ["AETHERWARP", "AETHERZOOM"]
|
||||||
|
)
|
||||||
|
fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
|
||||||
|
return SimpleNamespace(
|
||||||
|
exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
|
||||||
|
help_command=SimpleNamespace(key="H", aliases=["HELP"]),
|
||||||
|
history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
|
||||||
|
commands={"R": fake_command},
|
||||||
|
_name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lcp_completions(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
doc = Document("R A")
|
||||||
|
results = list(completer.get_completions(doc, None))
|
||||||
|
assert any(c.text == "AETHER" for c in results)
|
||||||
|
assert any(c.text == "AETHERWARP" for c in results)
|
||||||
|
assert any(c.text == "AETHERZOOM" for c in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lcp_completions_space(fake_falyx):
|
||||||
|
completer = FalyxCompleter(fake_falyx)
|
||||||
|
suggestions = ["London", "New York", "San Francisco"]
|
||||||
|
stub = "N"
|
||||||
|
completions = list(completer._yield_lcp_completions(suggestions, stub))
|
||||||
|
assert any(c.text == '"New York"' for c in completions)
|
||||||
96
tests/test_falyx/test_help.py
Normal file
96
tests/test_falyx/test_help.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command(capsys):
|
||||||
|
flx = Falyx()
|
||||||
|
assert flx.help_command.arg_parser.aliases[0] == "HELP"
|
||||||
|
assert flx.help_command.arg_parser.command_key == "H"
|
||||||
|
await flx.run_key("H")
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Show this help menu" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command_with_new_command(capsys):
|
||||||
|
flx = Falyx()
|
||||||
|
|
||||||
|
async def new_command(falyx: Falyx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"N",
|
||||||
|
"New Command",
|
||||||
|
new_command,
|
||||||
|
aliases=["TEST"],
|
||||||
|
help_text="This is a new command.",
|
||||||
|
)
|
||||||
|
await flx.run_key("H")
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "This is a new command." in captured.out
|
||||||
|
assert "TEST" in captured.out and "N" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_render_help(capsys):
|
||||||
|
flx = Falyx()
|
||||||
|
|
||||||
|
async def sample_command(falyx: Falyx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"S",
|
||||||
|
"Sample Command",
|
||||||
|
sample_command,
|
||||||
|
aliases=["SC"],
|
||||||
|
help_text="This is a sample command.",
|
||||||
|
)
|
||||||
|
await flx._render_help()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "This is a sample command." in captured.out
|
||||||
|
assert "SC" in captured.out and "S" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command_by_tag(capsys):
|
||||||
|
flx = Falyx()
|
||||||
|
|
||||||
|
async def tagged_command(falyx: Falyx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"T",
|
||||||
|
"Tagged Command",
|
||||||
|
tagged_command,
|
||||||
|
tags=["tag1"],
|
||||||
|
help_text="This command is tagged.",
|
||||||
|
)
|
||||||
|
await flx.run_key("H", args=("tag1",))
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "tag1" in captured.out
|
||||||
|
assert "This command is tagged." in captured.out
|
||||||
|
assert "HELP" not in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command_empty_tags(capsys):
|
||||||
|
flx = Falyx()
|
||||||
|
|
||||||
|
async def untagged_command(falyx: Falyx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
|
||||||
|
)
|
||||||
|
await flx.run_key("H", args=("nonexistent_tag",))
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
print(captured.out)
|
||||||
|
assert "nonexistent_tag" in captured.out
|
||||||
|
assert "Nothing to show here" in captured.out
|
||||||
19
tests/test_falyx/test_run.py
Normal file
19
tests/test_falyx/test_run.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
|
from falyx.parser import get_arg_parsers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_basic(capsys):
|
||||||
|
sys.argv = ["falyx", "run", "-h"]
|
||||||
|
falyx_parsers = get_arg_parsers()
|
||||||
|
assert falyx_parsers is not None, "Falyx parsers should be initialized"
|
||||||
|
flx = Falyx()
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
await flx.run(falyx_parsers)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Run a command by its key or alias." in captured.out
|
||||||
@@ -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"}
|
||||||
|
|||||||
42
tests/test_validators/test_command_validator.py
Normal file
42
tests/test_validators/test_command_validator.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import CommandValidator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_validator_validates_command():
|
||||||
|
fake_falyx = AsyncMock()
|
||||||
|
fake_falyx.get_command.return_value = (False, object(), (), {})
|
||||||
|
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||||
|
|
||||||
|
await validator.validate_async(Document("valid"))
|
||||||
|
fake_falyx.get_command.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_validator_rejects_invalid_command():
|
||||||
|
fake_falyx = AsyncMock()
|
||||||
|
fake_falyx.get_command.return_value = (False, None, (), {})
|
||||||
|
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await validator.validate_async(Document("not_a_command"))
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await validator.validate_async(Document(""))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_validator_is_preview():
|
||||||
|
fake_falyx = AsyncMock()
|
||||||
|
fake_falyx.get_command.return_value = (True, None, (), {})
|
||||||
|
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||||
|
|
||||||
|
await validator.validate_async(Document("?preview_command"))
|
||||||
|
fake_falyx.get_command.assert_awaited_once_with(
|
||||||
|
"?preview_command", from_validate=True
|
||||||
|
)
|
||||||
24
tests/test_validators/test_int_range_validator.py
Normal file
24
tests/test_validators/test_int_range_validator.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import int_range_validator
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_range_validator_accepts_valid_numbers():
|
||||||
|
validator = int_range_validator(1, 10)
|
||||||
|
for valid in ["1", "5", "10"]:
|
||||||
|
validator.validate(Document(valid))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", ["0", "11", "5.5", "hello", "-1", ""])
|
||||||
|
def test_int_range_validator_rejects_invalid(invalid):
|
||||||
|
validator = int_range_validator(1, 10)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(invalid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_range_validator_edge_cases():
|
||||||
|
validator = int_range_validator(1, 10)
|
||||||
|
for valid in ["1", "10"]:
|
||||||
|
validator.validate(Document(valid))
|
||||||
18
tests/test_validators/test_key_validator.py
Normal file
18
tests/test_validators/test_key_validator.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import key_validator
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_validator_accepts_valid_keys():
|
||||||
|
validator = key_validator(["A", "B", "Z"])
|
||||||
|
for valid in ["A", "B", "Z"]:
|
||||||
|
validator.validate(Document(valid))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", ["Y", "D", "C", "", "1", "AB", "ZB"])
|
||||||
|
def test_key_validator_rejects_invalid(invalid):
|
||||||
|
validator = key_validator(["A", "B", "Z"])
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(invalid))
|
||||||
73
tests/test_validators/test_multi_index_validator.py
Normal file
73
tests/test_validators/test_multi_index_validator.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import MultiIndexValidator
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_accepts_valid_indices():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
for valid in ["1,2,3", "2,3,4", "1,4,5"]:
|
||||||
|
validator.validate(Document(valid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_rejects_invalid_indices():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("A,!,F"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("0,6,7"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("1,2,2"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_rejects_invalid_number_of_selections():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("1,2"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("1,2,3,4"))
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=1, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
validator.validate(Document("1"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("2,3"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_cancel_key():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
validator.validate(Document("C"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_cancel_alone():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("1,C"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_empty_input():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(""))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_index_validator_error_message_for_duplicates():
|
||||||
|
validator = MultiIndexValidator(
|
||||||
|
1, 5, number_selections=3, separator=",", allow_duplicates=False, cancel_key="C"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError) as e:
|
||||||
|
validator.validate(Document("1,1,2"))
|
||||||
|
assert "Duplicate selection" in str(e.value)
|
||||||
105
tests/test_validators/test_multi_key_validator.py
Normal file
105
tests/test_validators/test_multi_key_validator.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import MultiKeyValidator
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_accepts_valid_keys():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
for valid in ["A,B", "B,C", "A,C"]:
|
||||||
|
validator.validate(Document(valid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_rejects_invalid_keys():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("D,E,F"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("A,B,A"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("A,B,C,D"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_rejects_invalid_number_of_selections():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("A")) # Not enough selections
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("A,B,C")) # Too many selections
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=1,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
validator.validate(Document("A")) # Exactly one selection is valid
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("B,C")) # Too many selections
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_cancel_key():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
validator.validate(Document("X"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_cancel_alone():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document("A,X"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_empty_input():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(""))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_key_validator_error_message_for_duplicates():
|
||||||
|
validator = MultiKeyValidator(
|
||||||
|
["A", "B", "C"],
|
||||||
|
number_selections=2,
|
||||||
|
separator=",",
|
||||||
|
allow_duplicates=False,
|
||||||
|
cancel_key="X",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError) as e:
|
||||||
|
validator.validate(Document("A,A,B"))
|
||||||
|
assert "Duplicate selection" in str(e.value)
|
||||||
29
tests/test_validators/test_word_validator.py
Normal file
29
tests/test_validators/test_word_validator.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import word_validator
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_validator_accepts_valid_words():
|
||||||
|
validator = word_validator("apple")
|
||||||
|
validator.validate(Document("apple"))
|
||||||
|
validator.validate(Document("N"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_validator_accepts_case_insensitive():
|
||||||
|
validator = word_validator("banana")
|
||||||
|
validator.validate(Document("BANANA"))
|
||||||
|
validator.validate(Document("banana"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_validator_rejects_n():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
word_validator("N")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"])
|
||||||
|
def test_word_validator_rejects_invalid(invalid):
|
||||||
|
validator = word_validator("apple")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(invalid))
|
||||||
18
tests/test_validators/test_words_validator.py
Normal file
18
tests/test_validators/test_words_validator.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import words_validator
|
||||||
|
|
||||||
|
|
||||||
|
def test_words_validator_accepts_valid_words():
|
||||||
|
validator = words_validator(["hello", "world", "falyx"])
|
||||||
|
for valid in ["hello", "world", "falyx"]:
|
||||||
|
validator.validate(Document(valid)) # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"])
|
||||||
|
def test_words_validator_rejects_invalid(invalid):
|
||||||
|
validator = words_validator(["hello", "world", "falyx"])
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(invalid))
|
||||||
18
tests/test_validators/test_yes_no_validator.py
Normal file
18
tests/test_validators/test_yes_no_validator.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pytest
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.validation import ValidationError
|
||||||
|
|
||||||
|
from falyx.validators import yes_no_validator
|
||||||
|
|
||||||
|
|
||||||
|
def test_yes_no_validator_accepts_y_and_n():
|
||||||
|
validator = yes_no_validator()
|
||||||
|
for valid in ["Y", "y", "N", "n"]:
|
||||||
|
validator.validate(Document(valid))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", ["yes", "no", "maybe", "", "1"])
|
||||||
|
def test_yes_no_validator_rejects_invalid(invalid):
|
||||||
|
validator = yes_no_validator()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validator.validate(Document(invalid))
|
||||||
Reference in New Issue
Block a user