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:
2025-08-03 18:10:32 -04:00
parent 8e306b9eaf
commit a25888f316
18 changed files with 594 additions and 34 deletions

View File

@ -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,14 @@ 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, number: int | None = None,
) -> str: ) -> str:
if verbose: if verbose:
print(f"Deploying {service}:{tag}:{number} 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}" return f"{service}:{tag}:{number} deployed to {region} at {place} from {path}."
def default_config(parser: CommandArgumentParser) -> None: def default_config(parser: CommandArgumentParser) -> None:
@ -52,6 +54,11 @@ 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(
"--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",

View File

@ -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.

View File

@ -17,6 +17,7 @@ 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
@ -69,7 +70,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 +86,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
@ -126,3 +118,42 @@ class FalyxCompleter(Completer):
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, 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
)

View File

@ -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
@ -395,9 +396,12 @@ class Falyx:
return return
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"),
(0, 2),
)
)
return return
self.console.print("[bold]help:[/bold]") self.console.print("[bold]help:[/bold]")
@ -559,6 +563,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,
@ -1161,12 +1166,12 @@ class Falyx:
This method parses CLI arguments, configures the runtime environment, and dispatches This method parses CLI arguments, configures the runtime environment, and dispatches
execution to the appropriate command mode: execution to the appropriate command mode:
• **list** - Show help output, optionally filtered by tag. - list - Show help output, optionally filtered by tag.
• **version** - Print the program version and exit. - version - Print the program version and exit.
• **preview** - Display a preview of the specified command without executing it. - preview - Display a preview of the specified command without executing it.
• **run** - Execute a single command with parsed arguments and lifecycle hooks. - run - Execute a single command with parsed arguments and lifecycle hooks.
• **run-all** - Run all commands matching a tag concurrently (with default args). - run-all - Run all commands matching a tag concurrently (with default args).
(default) - Launch the interactive Falyx menu loop. - (default) - Launch the interactive Falyx menu loop.
It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting, It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting,
and supports an optional callback for post-parse setup. and supports an optional callback for post-parse setup.
@ -1202,8 +1207,10 @@ class Falyx:
- For interactive sessions, this method falls back to `menu()`. - For interactive sessions, this method falls back to `menu()`.
Example: Example:
```
>>> flx = Falyx() >>> flx = Falyx()
>>> await flx.run() # Parses CLI args and dispatches appropriately >>> await flx.run() # Parses CLI args and dispatches appropriately
```
""" """
if self.cli_args: if self.cli_args:

View File

@ -49,6 +49,7 @@ from __future__ import annotations
from collections import Counter, defaultdict from collections import Counter, defaultdict
from copy import deepcopy from copy import deepcopy
from pathlib import Path
from typing import Any, Iterable, Sequence from typing import Any, Iterable, Sequence
from rich.console import Console from rich.console import Console
@ -1101,6 +1102,24 @@ class CommandArgumentParser:
kwargs_dict[arg.dest] = parsed[arg.dest] kwargs_dict[arg.dest] = parsed[arg.dest]
return tuple(args_list), kwargs_dict 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( def suggest_next(
self, args: list[str], cursor_at_end_of_token: bool = False self, args: list[str], cursor_at_end_of_token: bool = False
) -> list[str]: ) -> list[str]:
@ -1117,6 +1136,7 @@ class CommandArgumentParser:
list[str]: List of suggested completions. list[str]: List of suggested completions.
""" """
last = args[-1] if args else ""
# Case 1: Next positional argument # Case 1: Next positional argument
next_non_consumed_positional: Argument | None = None next_non_consumed_positional: Argument | None = None
for state in self._last_positional_states.values(): for state in self._last_positional_states.values():
@ -1126,11 +1146,33 @@ class CommandArgumentParser:
if next_non_consumed_positional: if next_non_consumed_positional:
if next_non_consumed_positional.choices: 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( return sorted(
(str(choice) for choice in next_non_consumed_positional.choices) (str(choice) for choice in next_non_consumed_positional.choices)
) )
if next_non_consumed_positional.suggestions: 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) 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 = [ consumed_dests = [
state.arg.dest state.arg.dest
@ -1163,12 +1205,13 @@ class CommandArgumentParser:
and next_to_last in self._keyword and next_to_last in self._keyword
and next_to_last in remaining_flags 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] arg = self._keyword[next_to_last]
if arg.choices: if arg.choices:
suggestions.extend(arg.choices) suggestions.extend((str(choice) for choice in arg.choices))
elif arg.suggestions: elif arg.suggestions:
suggestions.extend(arg.suggestions) suggestions.extend(
(str(suggestion) for suggestion in arg.suggestions)
)
else: else:
possible_flags = [ possible_flags = [
flag flag
@ -1185,9 +1228,11 @@ class CommandArgumentParser:
): ):
pass pass
elif arg.choices: elif arg.choices:
suggestions.extend(arg.choices) suggestions.extend((str(choice) for choice in arg.choices))
elif arg.suggestions: 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"]) # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"])
elif next_to_last in self._keyword: elif next_to_last in self._keyword:
arg = self._keyword[next_to_last] arg = self._keyword[next_to_last]
@ -1204,7 +1249,7 @@ class CommandArgumentParser:
): ):
pass pass
elif arg.choices and last not in arg.choices and not cursor_at_end_of_token: elif arg.choices and last not in arg.choices and not cursor_at_end_of_token:
suggestions.extend(arg.choices) suggestions.extend((str(choice) for choice in arg.choices))
elif ( elif (
arg.suggestions arg.suggestions
and last not in 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 any(suggestion.startswith(last) for suggestion in arg.suggestions)
and not cursor_at_end_of_token and not cursor_at_end_of_token
): ):
suggestions.extend(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: elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
pass pass
elif arg.type == Path and not cursor_at_end_of_token:
suggestions.extend(self._suggest_paths(last))
else: else:
suggestions.extend(remaining_flags) suggestions.extend(remaining_flags)
elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:

View File

@ -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":

View File

@ -1 +1 @@
__version__ = "0.1.79" __version__ = "0.1.80"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.79" version = "0.1.80"
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"

View 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 == []

View 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)

View 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
)

View 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))

View 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))

View 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)

View 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)

View 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))

View 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))

View 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))