Compare commits
2 Commits
3b2c33d28f
...
a25888f316
| Author | SHA1 | Date | |
|---|---|---|---|
|
a25888f316
|
|||
|
8e306b9eaf
|
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action
|
||||
@@ -21,13 +22,14 @@ async def test_args(
|
||||
service: str,
|
||||
place: Place = Place.NEW_YORK,
|
||||
region: str = "us-east-1",
|
||||
path: Path | None = None,
|
||||
tag: str | None = None,
|
||||
verbose: bool | None = None,
|
||||
number: int | None = None,
|
||||
) -> str:
|
||||
if verbose:
|
||||
print(f"Deploying {service}:{tag}:{number} to {region} at {place}...")
|
||||
return f"{service}:{tag}:{number} deployed to {region} at {place}"
|
||||
print(f"Deploying {service}:{tag}:{number} to {region} at {place} from {path}...")
|
||||
return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}."
|
||||
|
||||
|
||||
def default_config(parser: CommandArgumentParser) -> None:
|
||||
@@ -52,6 +54,11 @@ def default_config(parser: CommandArgumentParser) -> None:
|
||||
help="Deployment region.",
|
||||
choices=["us-east-1", "us-west-2", "eu-west-1"],
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
type=Path,
|
||||
help="Path to the configuration file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_bool_optional",
|
||||
|
||||
@@ -21,12 +21,14 @@ Key Features:
|
||||
- Columnar layout with automatic width scaling
|
||||
- Optional integration with `OptionsManager` for dynamic state toggling
|
||||
|
||||
Usage Example:
|
||||
Example:
|
||||
```
|
||||
bar = BottomBar(columns=3)
|
||||
bar.add_static("env", "ENV: dev")
|
||||
bar.add_toggle("d", "Debug", get_debug, toggle_debug)
|
||||
bar.add_value_tracker("attempts", "Retries", get_retry_count)
|
||||
bar.render()
|
||||
```
|
||||
|
||||
Used by Falyx to provide a persistent UI element showing toggles, system state,
|
||||
and runtime telemetry below the input prompt.
|
||||
|
||||
@@ -17,6 +17,7 @@ Integrated with the `Falyx.prompt_session` to enhance the interactive experience
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
@@ -69,7 +70,9 @@ class FalyxCompleter(Completer):
|
||||
|
||||
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
|
||||
# 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
|
||||
|
||||
# Identify command
|
||||
@@ -83,21 +86,10 @@ class FalyxCompleter(Completer):
|
||||
stub = "" if cursor_at_end_of_token else tokens[-1]
|
||||
|
||||
try:
|
||||
if not command.arg_parser:
|
||||
return
|
||||
suggestions = command.arg_parser.suggest_next(
|
||||
parsed_args + ([stub] if stub else []), cursor_at_end_of_token
|
||||
)
|
||||
for suggestion in suggestions:
|
||||
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))
|
||||
yield from self._yield_lcp_completions(suggestions, stub)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@@ -126,3 +118,42 @@ class FalyxCompleter(Completer):
|
||||
for key in keys:
|
||||
if key.upper().startswith(prefix):
|
||||
yield Completion(key, start_position=-len(prefix))
|
||||
|
||||
def _ensure_quote(self, text: str) -> str:
|
||||
"""
|
||||
Ensure the text is properly quoted for shell commands.
|
||||
|
||||
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):
|
||||
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
|
||||
)
|
||||
|
||||
114
falyx/falyx.py
114
falyx/falyx.py
@@ -37,6 +37,7 @@ from prompt_toolkit.formatted_text import StyleAndTextTuples
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.shortcuts import CompleteStyle
|
||||
from prompt_toolkit.validation import ValidationError
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
@@ -395,9 +396,12 @@ class Falyx:
|
||||
return
|
||||
for command in commands:
|
||||
usage, description, _ = command.help_signature
|
||||
self.console.print(usage)
|
||||
if description:
|
||||
self.console.print(description)
|
||||
self.console.print(
|
||||
Padding(
|
||||
Panel(usage, expand=False, title=description, title_align="left"),
|
||||
(0, 2),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.console.print("[bold]help:[/bold]")
|
||||
@@ -559,6 +563,7 @@ class Falyx:
|
||||
history=self.history,
|
||||
multiline=False,
|
||||
completer=self._get_completer(),
|
||||
complete_style=CompleteStyle.COLUMN,
|
||||
validator=CommandValidator(self, self._get_validator_error_message()),
|
||||
bottom_toolbar=self._get_bottom_bar_render(),
|
||||
key_bindings=self.key_bindings,
|
||||
@@ -1155,7 +1160,59 @@ class Falyx:
|
||||
subparsers: _SubParsersAction | None = None,
|
||||
callback: Callable[..., Any] | 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:
|
||||
|
||||
- list - 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:
|
||||
raise FalyxError(
|
||||
"Run is incompatible with CLI arguments. Use 'run_key' instead."
|
||||
@@ -1247,13 +1304,13 @@ class Falyx:
|
||||
sys.exit(1)
|
||||
except QuitSignal:
|
||||
logger.info("[QuitSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
sys.exit(130)
|
||||
except BackSignal:
|
||||
logger.info("[BackSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
except CancelSignal:
|
||||
logger.info("[CancelSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
if self.cli_args.summary:
|
||||
er.summary()
|
||||
@@ -1278,22 +1335,35 @@ class Falyx:
|
||||
f"{self.cli_args.tag}"
|
||||
)
|
||||
|
||||
for cmd in matching:
|
||||
self._set_retry_policy(cmd)
|
||||
try:
|
||||
await self.run_key(cmd.key)
|
||||
except FalyxError as error:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||
sys.exit(1)
|
||||
except QuitSignal:
|
||||
tasks = []
|
||||
try:
|
||||
for cmd in matching:
|
||||
self._set_retry_policy(cmd)
|
||||
tasks.append(self.run_key(cmd.key))
|
||||
except Exception as error:
|
||||
self.console.print(
|
||||
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.")
|
||||
sys.exit(0)
|
||||
except BackSignal:
|
||||
logger.info("[BackSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
except CancelSignal:
|
||||
logger.info("[CancelSignal]. <- Exiting run.")
|
||||
sys.exit(0)
|
||||
sys.exit(130)
|
||||
elif isinstance(result, CancelSignal):
|
||||
logger.info("[CancelSignal]. <- Execution cancelled.")
|
||||
sys.exit(1)
|
||||
elif isinstance(result, BackSignal):
|
||||
logger.info("[BackSignal]. <- Back signal received.")
|
||||
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:
|
||||
er.summary()
|
||||
|
||||
@@ -49,6 +49,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from rich.console import Console
|
||||
@@ -1101,6 +1102,24 @@ class CommandArgumentParser:
|
||||
kwargs_dict[arg.dest] = parsed[arg.dest]
|
||||
return tuple(args_list), kwargs_dict
|
||||
|
||||
def _suggest_paths(self, stub: str) -> list[str]:
|
||||
"""Return filesystem path suggestions based on a stub."""
|
||||
path = Path(stub or ".").expanduser()
|
||||
base_dir = path if path.is_dir() else path.parent
|
||||
if not base_dir.exists():
|
||||
return []
|
||||
completions = []
|
||||
for child in base_dir.iterdir():
|
||||
name = str(child)
|
||||
if child.is_dir():
|
||||
name += "/"
|
||||
completions.append(name)
|
||||
if stub and not path.is_dir():
|
||||
completions = [
|
||||
completion for completion in completions if completion.startswith(stub)
|
||||
]
|
||||
return completions[:100]
|
||||
|
||||
def suggest_next(
|
||||
self, args: list[str], cursor_at_end_of_token: bool = False
|
||||
) -> list[str]:
|
||||
@@ -1117,6 +1136,7 @@ class CommandArgumentParser:
|
||||
list[str]: List of suggested completions.
|
||||
"""
|
||||
|
||||
last = args[-1] if args else ""
|
||||
# Case 1: Next positional argument
|
||||
next_non_consumed_positional: Argument | None = None
|
||||
for state in self._last_positional_states.values():
|
||||
@@ -1126,11 +1146,33 @@ class CommandArgumentParser:
|
||||
|
||||
if next_non_consumed_positional:
|
||||
if next_non_consumed_positional.choices:
|
||||
if (
|
||||
cursor_at_end_of_token
|
||||
and last
|
||||
and any(
|
||||
str(choice).startswith(last)
|
||||
for choice in next_non_consumed_positional.choices
|
||||
)
|
||||
and next_non_consumed_positional.nargs in (1, "?", None)
|
||||
):
|
||||
return []
|
||||
return sorted(
|
||||
(str(choice) for choice in next_non_consumed_positional.choices)
|
||||
)
|
||||
if next_non_consumed_positional.suggestions:
|
||||
if (
|
||||
cursor_at_end_of_token
|
||||
and last
|
||||
and any(
|
||||
str(suggestion).startswith(last)
|
||||
for suggestion in next_non_consumed_positional.suggestions
|
||||
)
|
||||
and next_non_consumed_positional.nargs in (1, "?", None)
|
||||
):
|
||||
return []
|
||||
return sorted(next_non_consumed_positional.suggestions)
|
||||
if next_non_consumed_positional.type == Path:
|
||||
return self._suggest_paths(args[-1] if args else "")
|
||||
|
||||
consumed_dests = [
|
||||
state.arg.dest
|
||||
@@ -1163,12 +1205,13 @@ class CommandArgumentParser:
|
||||
and next_to_last in self._keyword
|
||||
and next_to_last in remaining_flags
|
||||
):
|
||||
# If the last token is a mid-flag, suggest based on the previous flag
|
||||
arg = self._keyword[next_to_last]
|
||||
if arg.choices:
|
||||
suggestions.extend(arg.choices)
|
||||
suggestions.extend((str(choice) for choice in arg.choices))
|
||||
elif arg.suggestions:
|
||||
suggestions.extend(arg.suggestions)
|
||||
suggestions.extend(
|
||||
(str(suggestion) for suggestion in arg.suggestions)
|
||||
)
|
||||
else:
|
||||
possible_flags = [
|
||||
flag
|
||||
@@ -1185,9 +1228,11 @@ class CommandArgumentParser:
|
||||
):
|
||||
pass
|
||||
elif arg.choices:
|
||||
suggestions.extend(arg.choices)
|
||||
suggestions.extend((str(choice) for choice in arg.choices))
|
||||
elif arg.suggestions:
|
||||
suggestions.extend(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"])
|
||||
elif next_to_last in self._keyword:
|
||||
arg = self._keyword[next_to_last]
|
||||
@@ -1204,7 +1249,7 @@ class CommandArgumentParser:
|
||||
):
|
||||
pass
|
||||
elif arg.choices and last not in arg.choices and not cursor_at_end_of_token:
|
||||
suggestions.extend(arg.choices)
|
||||
suggestions.extend((str(choice) for choice in arg.choices))
|
||||
elif (
|
||||
arg.suggestions
|
||||
and last not in arg.suggestions
|
||||
@@ -1212,9 +1257,11 @@ class CommandArgumentParser:
|
||||
and any(suggestion.startswith(last) for suggestion in arg.suggestions)
|
||||
and not cursor_at_end_of_token
|
||||
):
|
||||
suggestions.extend(arg.suggestions)
|
||||
suggestions.extend((str(suggestion) for suggestion in arg.suggestions))
|
||||
elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
|
||||
pass
|
||||
elif arg.type == Path and not cursor_at_end_of_token:
|
||||
suggestions.extend(self._suggest_paths(last))
|
||||
else:
|
||||
suggestions.extend(remaining_flags)
|
||||
elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
|
||||
|
||||
@@ -118,6 +118,10 @@ def words_validator(
|
||||
|
||||
def word_validator(word: str) -> Validator:
|
||||
"""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:
|
||||
if text.upper().strip() == "N":
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.78"
|
||||
__version__ = "0.1.80"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.78"
|
||||
version = "0.1.80"
|
||||
description = "Reliable and introspectable async CLI action framework."
|
||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||
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)
|
||||
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