feat: add path completion, LCP-based suggestions, and validator tests
- Refactored `FalyxCompleter` to support longest common prefix (LCP) completions by default. - Added `_ensure_quote` helper to auto-quote completions containing spaces/tabs. - Integrated `_yield_lcp_completions` for consistent completion insertion logic. - Added `_suggest_paths()` helper to dynamically suggest filesystem paths for arguments of type `Path`. - Integrated path completion into `suggest_next()` for both positional and flagged arguments. - Updated `argument_examples.py` to include a `--path` argument (`Path | None`), demonstrating file path completion. - Enabled `CompleteStyle.COLUMN` for tab-completion menu formatting in interactive sessions. - Improved bottom bar docstring formatting with fenced code block examples. - Added safeguard to `word_validator` to reject `"N"` since it’s reserved for `yes_no_validator`. - Improved help panel rendering for commands (using `Padding` + `Panel`). - Added full test coverage for: - `FalyxCompleter` and LCP behavior (`tests/test_completer/`) - All validators (`tests/test_validators/`) - Bumped version to 0.1.80.
This commit is contained in:
@ -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
|
||||
)
|
||||
|
@ -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,
|
||||
@ -1161,12 +1166,12 @@ class Falyx:
|
||||
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.
|
||||
- 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.
|
||||
@ -1202,8 +1207,10 @@ class Falyx:
|
||||
- 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:
|
||||
|
@ -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.79"
|
||||
__version__ = "0.1.80"
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.79"
|
||||
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