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
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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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