7 Commits

Author SHA1 Message Date
1ce1b2385b feat(parser): add docstrings, centralized suggestion errors, and improved flag handling
-Added descriptive docstrings across `Falyx` and `CommandArgumentParser` internals:
  - `is_cli_mode`, `get_tip`, and `_render_help` in `falyx.py`
  - Validation and parsing helpers in `command_argument_parser.py` (`_validate_nargs`, `_normalize_choices`, `_validate_default_list_type`, `_validate_action`, `_register_store_bool_optional`, `_register_argument`, `_check_if_in_choices`, `_raise_remaining_args_error`, `_consume_nargs`, `_consume_all_positional_args`, `_handle_token`, `_find_last_flag_argument`, `_is_mid_value`, `_is_invalid_choices_state`, `_value_suggestions_for_arg`)
- Introduced `_raise_suggestion_error()` utility to standardize error messages when required values are missing, including defaults and choices.
  - Replaced duplicated inline suggestion/error logic in `APPEND`, `EXTEND`, and generic STORE handlers with this helper.
- Improved error chaining with `from error` for clarity in `_normalize_choices` and `_validate_action`.
- Consolidated `HELP`, `TLDR`, and `COUNT` default-value validation into a single check.
- Enhanced completions:
  - Extended suggestion logic to show remaining flags for `APPEND`, `EXTEND`, and `COUNT` arguments when last tokens are not keywords.
- Added `.config.json` to `.gitignore`.
- Bumped version to 0.1.85.
2025-08-22 05:32:36 -04:00
06bf0e432c feat(help): improve TLDR/help handling for help context commands
- Added `from_help` flag to `get_command()` to allow help rendering without full CLI execution flow.
- Updated `_render_help()` to pass `from_help=True` when fetching commands.
- Enhanced TLDR parsing:
  - Allow TLDR flag to be processed and retained when running inside a help command (`_is_help_command=True`).
  - Skip removing `"tldr"` from results in help context to preserve intended behavior.
  - Ensure TLDR args are still marked consumed to maintain state consistency.
- Simplified required argument validation to skip both `help` and `tldr` without special action checks.
- Adjusted `parse_args_split()` to include `tldr` values in help commands while skipping them for normal commands.
- Expanded `infer_args_from_func()` docstring with supported features and parameter handling details.
- Bumped version to 0.1.84.
2025-08-11 19:51:49 -04:00
169f228c92 feat(parser): POSIX bundling, multi-value/default validation, smarter completions; help UX & examples
- Mark help parser with `_is_help_command=True` so CLI renders as `program help`.
- Add TLDR examples to `Exit` and `History` commands.
- Normalize help TLDR/tag docs to short forms `-T` (tldr) and `-t [TAG]`.
- Also propagate submenu exit help text TLDRs when set.
- Disallow defaults for `HELP`, `TLDR`, `COUNT`, and boolean store actions.
- Enforce list defaults for `APPEND`/`EXTEND` and any `nargs` in `{int, "*", "+"}`; coerce to list when `nargs == 1`.
- Validate default(s) against `choices` (lists must be subset).
- Strengthen `choices` checking at parse-time for both scalars and lists; track invalid-choice state for UX.
- New `_resolve_posix_bundling()` with context:
  - Won’t split negative numbers or dash-prefixed positional/path values.
  - Uses the *last seen flag’s type/action* to decide if a dash token is a value vs. bundle.
- Add `_is_valid_dash_token_positional_value()` and `_find_last_flag_argument()` helpers.
- Completions overhaul
  - Track `consumed_position` and `has_invalid_choice` per-arg (via new `ArgumentState.set_consumed()` / `reset()`).
  - Add `_is_mid_value()` and `_value_suggestions_for_arg()` to produce value suggestions while typing.
  - Persist value context for multi-value args (`nargs="*"`, `"+"`) for each call to parse_args
  - Suppress suggestions when a choice is currently invalid, then recover as the prefix becomes valid.
  - Respect `cursor_at_end_of_token`; do not mutate the user’s prefix; improve path suggestions (`"."` vs prefix).
  - Better behavior after a space: suggest remaining flags when appropriate.
- Consistent `index` naming (vs `i`) and propagate `base_index` into positional consumption to mark positions accurately.
- Return value tweaks for `find_argument_by_dest()` and minor readability changes.
- Replace the minimal completion test with a comprehensive suite covering:
  - Basics (defaults, option parsing, lists, booleans).
  - Validation edges (default/choices, `nargs` list requirements).
  - POSIX bundling (flags only; negative values; dash-prefixed paths).
  - Completions for flags/values/mid-value/path/`nargs="*"` persistence.
  - `store_bool_optional` (feature / no-feature, last one wins).
  - Invalid choice suppression & recovery.
  - Repeated keywords (last one wins) and completion context follows the last.
  - File-system-backed path suggestions.
- Bumped version to 0.1.83.
2025-08-10 15:55:45 -04:00
0417a06ee4 feat: enhance help command UX, completions, and CLI tips
- Expanded help command to accept:
  - `-k/--key` for detailed help on a specific command
  - `-t/--tag` for tag-filtered listings
  - `-T/--tldr` for quick usage examples
- Updated TLDR flag to support `-T` short form and refined help text.
- Improved `_render_help()` to show contextual CLI tips after help or TLDR output.
- Adjusted completer to yield both upper and lower case completions without mutating the prefix.
- Standardized CLI tip strings in root/arg parsers to reference `help` and `preview` subcommands instead of menu `run ?` syntax.
- Passed `options_manager` to history/help/exit commands for consistency.
- Allowed `help_command` to display TLDR examples when invoked without a key.
- Added test assertions for help command key/alias consistency.
- Bumped version to 0.1.82.
2025-08-07 19:27:59 -04:00
55d581b870 feat: redesign help command, improve completer UX, and document Falyx CLI
- Renamed CLI subcommand from `list` to `help` for clarity and discoverability.
- Added `--key` and `--tldr` support to the `help` command for detailed and example-based output.
- Introduced `FalyxMode.HELP` to clearly delineate help-related behavior.
- Enhanced `_render_help()` to support:
  - Tag filtering (`--tag`)
  - Per-command help (`--key`)
  - TLDR example rendering (`--tldr`)
- Updated built-in Help command to:
  - Use `FalyxMode.HELP` internally
  - Provide fallback messages for missing help or TLDR data
  - Remove `LIST` alias (replaced by `help`)
- Documented `FalyxCompleter`:
  - Improved docstrings for public methods and completions
  - Updated internal documentation to reflect all supported completion cases
- Updated `CommandArgumentParser.render_tldr()` with fallback message for missing TLDR entries.
- Updated all parser docstrings and variable names to reference `help` (not `list`) as the proper CLI entrypoint.
- Added test coverage:
  - `tests/test_falyx/test_help.py` for CLI `help` command with `tag`, `key`, `tldr`, and fallback scenarios
  - `tests/test_falyx/test_run.py` for basic CLI parser integration
- Bumped version to 0.1.81
2025-08-06 20:33:51 -04:00
a25888f316 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.
2025-08-03 18:10:32 -04:00
8e306b9eaf feat(run): improve run-all handling, clarify exit codes, and enhance documentation
- Expanded `Falyx.run()` docstring into a detailed Google‑style docstring:
- Refined exit code semantics:
- `QuitSignal` now exits with code 130 (Ctrl+C style)
- `BackSignal` and `CancelSignal` exit with code 1 instead of 0 for script correctness
- Reworked `run-all` execution flow:
- Uses `asyncio.gather()` to run tagged commands concurrently
- Aggregates exceptions and signals for clearer reporting
- Tracks `had_errors` flag and exits with code 1 if any commands fail
- Bumped version to **0.1.79**

These changes make `run-all` safer for automation, standardize exit codes, and provide richer documentation for developers using the CLI.
2025-07-30 23:41:25 -04:00
27 changed files with 1893 additions and 291 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ build/
.vscode/ .vscode/
coverage.xml coverage.xml
.coverage .coverage
.config.json

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,27 @@ async def test_args(
service: str, service: str,
place: Place = Place.NEW_YORK, place: Place = Place.NEW_YORK,
region: str = "us-east-1", region: str = "us-east-1",
path: Path | None = None,
tag: str | None = None, tag: str | None = None,
verbose: bool | None = None, verbose: bool | None = None,
number: int | None = None, numbers: list[int] | None = None,
just_a_bool: bool = False,
) -> str: ) -> str:
if numbers is None:
numbers = []
if verbose: if verbose:
print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") print(
return f"{service}:{tag}:{number} deployed to {region} at {place}" f"Deploying {service}:{tag}:{"|".join(str(number) for number in numbers)} to {region} at {place} from {path}..."
)
return f"{service}:{tag}:{"|".join(str(number) for number in numbers)} deployed to {region} at {place} from {path}."
async def test_path_arg(*paths: Path) -> str:
return f"Path argument received: {'|'.join(str(path) for path in paths)}"
async def test_positional_numbers(*numbers: int) -> str:
return f"Positional numbers received: {', '.join(str(num) for num in numbers)}"
def default_config(parser: CommandArgumentParser) -> None: def default_config(parser: CommandArgumentParser) -> None:
@@ -52,22 +67,37 @@ def default_config(parser: CommandArgumentParser) -> None:
help="Deployment region.", help="Deployment region.",
choices=["us-east-1", "us-west-2", "eu-west-1"], choices=["us-east-1", "us-west-2", "eu-west-1"],
) )
parser.add_argument(
"-p",
"--path",
type=Path,
help="Path to the configuration file.",
)
parser.add_argument( parser.add_argument(
"--verbose", "--verbose",
action="store_bool_optional", action="store_bool_optional",
help="Enable verbose output.", help="Enable verbose output.",
) )
parser.add_argument( parser.add_argument(
"-t",
"--tag", "--tag",
type=str, type=str,
help="Optional tag for the deployment.", help="Optional tag for the deployment.",
suggestions=["latest", "stable", "beta"], suggestions=["latest", "stable", "beta"],
) )
parser.add_argument( parser.add_argument(
"--number", "--numbers",
type=int, type=int,
nargs="*",
default=[1, 2, 3],
help="Optional number argument.", help="Optional number argument.",
) )
parser.add_argument(
"-j",
"--just-a-bool",
action="store_true",
help="Just a boolean flag.",
)
parser.add_tldr_examples( parser.add_tldr_examples(
[ [
("web", "Deploy 'web' to the default location (New York)"), ("web", "Deploy 'web' to the default location (New York)"),
@@ -77,6 +107,40 @@ def default_config(parser: CommandArgumentParser) -> None:
) )
def path_config(parser: CommandArgumentParser) -> None:
"""Argument configuration for path testing command."""
parser.add_argument(
"paths",
type=Path,
nargs="*",
help="One or more file or directory paths.",
)
parser.add_tldr_examples(
[
("/path/to/file.txt", "Single file path"),
("/path/to/dir1 /path/to/dir2", "Multiple directory paths"),
("/path/with spaces/file.txt", "Path with spaces"),
]
)
def numbers_config(parser: CommandArgumentParser) -> None:
"""Argument configuration for positional numbers testing command."""
parser.add_argument(
"numbers",
type=int,
nargs="*",
help="One or more integers.",
)
parser.add_tldr_examples(
[
("1 2 3", "Three numbers"),
("42", "Single number"),
("", "No numbers"),
]
)
flx = Falyx( flx = Falyx(
"Argument Examples", "Argument Examples",
program="argument_examples.py", program="argument_examples.py",
@@ -98,4 +162,30 @@ flx.add_command(
argument_config=default_config, argument_config=default_config,
) )
flx.add_command(
key="P",
aliases=["path"],
description="Path Command",
help_text="A command to test path argument parsing.",
action=Action(
name="test_path_arg",
action=test_path_arg,
),
style="bold #F2B3EB",
argument_config=path_config,
)
flx.add_command(
key="N",
aliases=["numbers"],
description="Numbers Command",
help_text="A command to test positional numbers argument parsing.",
action=Action(
name="test_positional_numbers",
action=test_positional_numbers,
),
style="bold #F2F2B3",
argument_config=numbers_config,
)
asyncio.run(flx.run()) asyncio.run(flx.run())

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

@@ -346,6 +346,7 @@ class Command(BaseModel):
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
program = f"{self.program} run " if is_cli_mode else "" program = f"{self.program} run " if is_cli_mode else ""
@@ -365,7 +366,7 @@ class Command(BaseModel):
) )
return ( return (
f"[{self.style}]{program}[/]{command_keys}", f"[{self.style}]{program}[/]{command_keys}",
f"[dim]{self.description}[/dim]", f"[dim]{self.help_text or self.description}[/dim]",
"", "",
) )

View File

@@ -8,15 +8,20 @@ This completer supports:
- Argument flag completion for registered commands (e.g. `--tag`, `--name`) - Argument flag completion for registered commands (e.g. `--tag`, `--name`)
- Context-aware suggestions based on cursor position and argument structure - Context-aware suggestions based on cursor position and argument structure
- Interactive value completions (e.g. choices and suggestions defined per argument) - Interactive value completions (e.g. choices and suggestions defined per argument)
- File/path-friendly behavior, quoting completions with spaces automatically
Completions are generated from:
- Registered commands in `Falyx`
- Argument metadata and `suggest_next()` from `CommandArgumentParser`
Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes
parsed tokens to determine appropriate next arguments, flags, or values.
Integrated with the `Falyx.prompt_session` to enhance the interactive experience. Integrated with the `Falyx.prompt_session` to enhance the interactive experience.
""" """
from __future__ import annotations from __future__ import annotations
import os
import shlex import shlex
from typing import TYPE_CHECKING, Iterable from typing import TYPE_CHECKING, Iterable
@@ -41,9 +46,12 @@ class FalyxCompleter(Completer):
- Remaining required or optional flags - Remaining required or optional flags
- Flag value suggestions (choices or custom completions) - Flag value suggestions (choices or custom completions)
- Next positional argument hints - Next positional argument hints
- Inserts longest common prefix (LCP) completions when applicable
- Handles special cases like quoted strings and spaces
- Supports dynamic argument suggestions (e.g. flags, file paths, etc.)
Args: Args:
falyx (Falyx): The Falyx menu instance containing all command mappings and parsers. falyx (Falyx): The active Falyx instance providing command and parser context.
""" """
def __init__(self, falyx: "Falyx"): def __init__(self, falyx: "Falyx"):
@@ -51,14 +59,21 @@ class FalyxCompleter(Completer):
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
""" """
Yield completions based on the current document input. Compute completions for the current user input.
Analyzes the input buffer, determines whether the user is typing:
• A command key/alias
• A flag/option
• An argument value
and yields appropriate completions.
Args: Args:
document (Document): The prompt_toolkit document containing the input buffer. document (Document): The current Prompt Toolkit document (input buffer & cursor).
complete_event: The completion trigger event (unused). complete_event: The triggering event (TAB key, menu display, etc.) — not used here.
Yields: Yields:
Completion objects matching command keys or argument suggestions. Completion: One or more completions matching the current stub text.
""" """
text = document.text_before_cursor text = document.text_before_cursor
try: try:
@@ -69,7 +84,9 @@ class FalyxCompleter(Completer):
if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token):
# Suggest command keys and aliases # Suggest command keys and aliases
yield from self._suggest_commands(tokens[0] if tokens else "") stub = tokens[0] if tokens else ""
suggestions = [c.text for c in self._suggest_commands(stub)]
yield from self._yield_lcp_completions(suggestions, stub)
return return
# Identify command # Identify command
@@ -83,21 +100,10 @@ class FalyxCompleter(Completer):
stub = "" if cursor_at_end_of_token else tokens[-1] stub = "" if cursor_at_end_of_token else tokens[-1]
try: try:
if not command.arg_parser:
return
suggestions = command.arg_parser.suggest_next( suggestions = command.arg_parser.suggest_next(
parsed_args + ([stub] if stub else []), cursor_at_end_of_token parsed_args + ([stub] if stub else []), cursor_at_end_of_token
) )
for suggestion in suggestions: yield from self._yield_lcp_completions(suggestions, stub)
if suggestion.startswith(stub):
if len(suggestion.split()) > 1:
yield Completion(
f'"{suggestion}"',
start_position=-len(stub),
display=suggestion,
)
else:
yield Completion(suggestion, start_position=-len(stub))
except Exception: except Exception:
return return
@@ -105,13 +111,15 @@ class FalyxCompleter(Completer):
""" """
Suggest top-level command keys and aliases based on the given prefix. Suggest top-level command keys and aliases based on the given prefix.
Filters all known commands (and `exit`, `help`, `history` built-ins)
to only those starting with the given prefix.
Args: Args:
prefix (str): The user input to match against available commands. prefix (str): The current typed prefix.
Yields: Yields:
Completion: Matching keys or aliases from all registered commands. Completion: Matching keys or aliases from all registered commands.
""" """
prefix = prefix.upper()
keys = [self.falyx.exit_command.key] keys = [self.falyx.exit_command.key]
keys.extend(self.falyx.exit_command.aliases) keys.extend(self.falyx.exit_command.aliases)
if self.falyx.history_command: if self.falyx.history_command:
@@ -125,4 +133,64 @@ class FalyxCompleter(Completer):
keys.extend(cmd.aliases) keys.extend(cmd.aliases)
for key in keys: for key in keys:
if key.upper().startswith(prefix): if key.upper().startswith(prefix):
yield Completion(key, start_position=-len(prefix)) yield Completion(key.upper(), start_position=-len(prefix))
elif key.lower().startswith(prefix):
yield Completion(key.lower(), start_position=-len(prefix))
def _ensure_quote(self, text: str) -> str:
"""
Ensure that a suggestion is shell-safe by quoting if needed.
Adds quotes around completions containing whitespace so they can
be inserted into the CLI without breaking tokenization.
Args:
text (str): The input text to quote.
Returns:
str: The quoted text, suitable for shell command usage.
"""
if " " in text or "\t" in text:
return f'"{text}"'
return text
def _yield_lcp_completions(self, suggestions, stub):
"""
Yield completions for the current stub using longest-common-prefix logic.
Behavior:
- If only one match → yield it fully.
- If multiple matches share a longer prefix → insert the prefix, but also
display all matches in the menu.
- If no shared prefix → list all matches individually.
Args:
suggestions (list[str]): The raw suggestions to consider.
stub (str): The currently typed prefix (used to offset insertion).
Yields:
Completion: Completion objects for the Prompt Toolkit menu.
"""
matches = [s for s in suggestions if s.startswith(stub)]
if not matches:
return
lcp = os.path.commonprefix(matches)
if len(matches) == 1:
yield Completion(
self._ensure_quote(matches[0]),
start_position=-len(stub),
display=matches[0],
)
elif len(lcp) > len(stub) and not lcp.startswith("-"):
yield Completion(lcp, start_position=-len(stub), display=lcp)
for match in matches:
yield Completion(
self._ensure_quote(match), start_position=-len(stub), display=match
)
else:
for match in matches:
yield Completion(
self._ensure_quote(match), start_position=-len(stub), display=match
)

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
@@ -202,10 +203,12 @@ class Falyx:
@property @property
def is_cli_mode(self) -> bool: def is_cli_mode(self) -> bool:
"""Checks if the current mode is a CLI mode."""
return self.options.get("mode") in { return self.options.get("mode") in {
FalyxMode.RUN, FalyxMode.RUN,
FalyxMode.PREVIEW, FalyxMode.PREVIEW,
FalyxMode.RUN_ALL, FalyxMode.RUN_ALL,
FalyxMode.HELP,
} }
def validate_options( def validate_options(
@@ -279,7 +282,7 @@ class Falyx:
def _get_exit_command(self) -> Command: def _get_exit_command(self) -> Command:
"""Returns the back command for the menu.""" """Returns the back command for the menu."""
return Command( exit_command = Command(
key="X", key="X",
description="Exit", description="Exit",
action=Action("Exit", action=_noop), action=Action("Exit", action=_noop),
@@ -289,7 +292,11 @@ class Falyx:
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options,
program=self.program, program=self.program,
help_text="Exit the program.",
) )
if exit_command.arg_parser:
exit_command.arg_parser.add_tldr_examples([("", "Exit the program.")])
return exit_command
def _get_history_command(self) -> Command: def _get_history_command(self) -> Command:
"""Returns the history command for the menu.""" """Returns the history command for the menu."""
@@ -299,6 +306,7 @@ class Falyx:
command_style=OneColors.DARK_YELLOW, command_style=OneColors.DARK_YELLOW,
aliases=["HISTORY"], aliases=["HISTORY"],
program=self.program, program=self.program,
options_manager=self.options,
) )
parser.add_argument( parser.add_argument(
"-n", "-n",
@@ -333,6 +341,19 @@ class Falyx:
parser.add_argument( parser.add_argument(
"-l", "--last-result", action="store_true", help="Get the last result" "-l", "--last-result", action="store_true", help="Get the last result"
) )
parser.add_tldr_examples(
[
("", "Show the full execution history."),
("-n build", "Show history entries for the 'build' command."),
("-s success", "Show only successful executions."),
("-s error", "Show only failed executions."),
("-i 3", "Show the history entry at index 3."),
("-r 0", "Show the result or traceback for entry index 0."),
("-l", "Show the last execution result."),
("-c", "Clear the execution history."),
]
)
return Command( return Command(
key="Y", key="Y",
description="History", description="History",
@@ -347,6 +368,7 @@ class Falyx:
) )
def get_tip(self) -> str: def get_tip(self) -> str:
"""Returns a random tip for the user about using Falyx."""
program = f"{self.program} run " if self.is_cli_mode else "" program = f"{self.program} run " if self.is_cli_mode else ""
tips = [ tips = [
f"Use '{program}?[COMMAND]' to preview a command.", f"Use '{program}?[COMMAND]' to preview a command.",
@@ -358,11 +380,12 @@ class Falyx:
f"Use '{self.program} --verbose' to enable debug logging for a menu session.", f"Use '{self.program} --verbose' to enable debug logging for a menu session.",
f"'{self.program} --debug-hooks' will trace every before/after hook in action.", f"'{self.program} --debug-hooks' will trace every before/after hook in action.",
f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.",
"All [COMMAND] keys and aliases are case-insensitive.",
] ]
if self.is_cli_mode: if self.is_cli_mode:
tips.extend( tips.extend(
[ [
f"Use '{self.program} run ?' to list all commands at any time.", f"Use '{self.program} help' to list all commands at any time.",
f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].",
f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.",
f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.",
@@ -381,7 +404,35 @@ class Falyx:
) )
return choice(tips) return choice(tips)
async def _render_help(self, tag: str = "") -> None: async def _render_help(
self, tag: str = "", key: str | None = None, tldr: bool = False
) -> None:
"""Renders the help menu with command details, usage examples, and tips."""
if tldr and not key:
if self.help_command and self.help_command.arg_parser:
self.help_command.arg_parser.render_tldr()
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
if key:
_, command, args, kwargs = await self.get_command(key, from_help=True)
if command and tldr and command.arg_parser:
command.arg_parser.render_tldr()
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
elif command and tldr and not command.arg_parser:
self.console.print(
f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
)
elif command and command.arg_parser:
command.arg_parser.render_help()
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
elif command and not command.arg_parser:
self.console.print(
f"[bold]No detailed help available for '{command.description}'.[/bold]"
)
else:
self.console.print(f"[bold]No command found for '{key}'.[/bold]")
if tag: if tag:
tag_lower = tag.lower() tag_lower = tag.lower()
self.console.print(f"[bold]{tag_lower}:[/bold]") self.console.print(f"[bold]{tag_lower}:[/bold]")
@@ -392,13 +443,17 @@ class Falyx:
] ]
if not commands: if not commands:
self.console.print(f"'{tag}'... Nothing to show here") self.console.print(f"'{tag}'... Nothing to show here")
return return None
for command in commands: for command in commands:
usage, description, _ = command.help_signature usage, description, _ = command.help_signature
self.console.print(usage) self.console.print(
if description: Padding(
self.console.print(description) Panel(usage, expand=False, title=description, title_align="left"),
return (0, 2),
)
)
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
return None
self.console.print("[bold]help:[/bold]") self.console.print("[bold]help:[/bold]")
for command in self.commands.values(): for command in self.commands.values():
@@ -447,8 +502,10 @@ class Falyx:
command_key="H", command_key="H",
command_description="Help", command_description="Help",
command_style=OneColors.LIGHT_YELLOW, command_style=OneColors.LIGHT_YELLOW,
aliases=["?", "HELP", "LIST"], aliases=["HELP", "?"],
program=self.program, program=self.program,
options_manager=self.options,
_is_help_command=True,
) )
parser.add_argument( parser.add_argument(
"-t", "-t",
@@ -457,11 +514,27 @@ class Falyx:
default="", default="",
help="Optional tag to filter commands by.", help="Optional tag to filter commands by.",
) )
parser.add_argument(
"-k",
"--key",
nargs="?",
default=None,
help="Optional command key or alias to get detailed help for.",
)
parser.add_tldr_examples(
[
("", "Show all commands."),
("-k [COMMAND]", "Show detailed help for a specific command."),
("-Tk [COMMAND]", "Show quick usage examples for a specific command."),
("-T", "Show these quick usage examples."),
("-t [TAG]", "Show commands with the specified tag."),
]
)
return Command( return Command(
key="H", key="H",
aliases=["?", "HELP", "LIST"], aliases=["HELP", "?"],
description="Help", description="Help",
help_text="Show this help menu", help_text="Show this help menu.",
action=Action("Help", self._render_help), action=Action("Help", self._render_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser, arg_parser=parser,
@@ -559,6 +632,7 @@ class Falyx:
history=self.history, history=self.history,
multiline=False, multiline=False,
completer=self._get_completer(), completer=self._get_completer(),
complete_style=CompleteStyle.COLUMN,
validator=CommandValidator(self, self._get_validator_error_message()), validator=CommandValidator(self, self._get_validator_error_message()),
bottom_toolbar=self._get_bottom_bar_render(), bottom_toolbar=self._get_bottom_bar_render(),
key_bindings=self.key_bindings, key_bindings=self.key_bindings,
@@ -625,6 +699,7 @@ class Falyx:
style: str = OneColors.DARK_RED, style: str = OneColors.DARK_RED,
confirm: bool = False, confirm: bool = False,
confirm_message: str = "Are you sure?", confirm_message: str = "Are you sure?",
help_text: str = "Exit the program.",
) -> None: ) -> None:
"""Updates the back command of the menu.""" """Updates the back command of the menu."""
self._validate_command_key(key) self._validate_command_key(key)
@@ -642,7 +717,10 @@ class Falyx:
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options, options_manager=self.options,
program=self.program, program=self.program,
help_text=help_text,
) )
if self.exit_command.arg_parser:
self.exit_command.arg_parser.add_tldr_examples([("", help_text)])
def add_submenu( def add_submenu(
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
@@ -655,7 +733,12 @@ class Falyx:
key, description, submenu.menu, style=style, simple_help_signature=True key, description, submenu.menu, style=style, simple_help_signature=True
) )
if submenu.exit_command.key == "X": if submenu.exit_command.key == "X":
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) submenu.update_exit_command(
key="B",
description="Back",
aliases=["BACK"],
help_text="Go back to the previous menu.",
)
def add_commands(self, commands: list[Command] | list[dict]) -> None: def add_commands(self, commands: list[Command] | list[dict]) -> None:
"""Adds a list of Command instances or config dicts.""" """Adds a list of Command instances or config dicts."""
@@ -861,7 +944,7 @@ class Falyx:
return False, input_str.strip() return False, input_str.strip()
async def get_command( async def get_command(
self, raw_choices: str, from_validate=False self, raw_choices: str, from_validate=False, from_help=False
) -> tuple[bool, Command | None, tuple, dict[str, Any]]: ) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
""" """
Returns the selected command based on user input. Returns the selected command based on user input.
@@ -902,11 +985,7 @@ class Falyx:
logger.info("Command '%s' selected.", run_command.key) logger.info("Command '%s' selected.", run_command.key)
if is_preview: if is_preview:
return True, run_command, args, kwargs return True, run_command, args, kwargs
elif self.options.get("mode") in { elif self.is_cli_mode or from_help:
FalyxMode.RUN,
FalyxMode.RUN_ALL,
FalyxMode.PREVIEW,
}:
return False, run_command, args, kwargs return False, run_command, args, kwargs
try: try:
args, kwargs = await run_command.parse_args(input_args, from_validate) args, kwargs = await run_command.parse_args(input_args, from_validate)
@@ -1155,7 +1234,59 @@ class Falyx:
subparsers: _SubParsersAction | None = None, subparsers: _SubParsersAction | None = None,
callback: Callable[..., Any] | None = None, callback: Callable[..., Any] | None = None,
) -> None: ) -> None:
"""Run Falyx CLI with structured subcommands.""" """
Entrypoint for executing a Falyx CLI application via structured subcommands.
This method parses CLI arguments, configures the runtime environment, and dispatches
execution to the appropriate command mode:
- help - Show help output, optionally filtered by tag.
- version - Print the program version and exit.
- preview - Display a preview of the specified command without executing it.
- run - Execute a single command with parsed arguments and lifecycle hooks.
- run-all - Run all commands matching a tag concurrently (with default args).
- (default) - Launch the interactive Falyx menu loop.
It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting,
and supports an optional callback for post-parse setup.
Args:
falyx_parsers (FalyxParsers | None):
Preconfigured argument parser set. If not provided, a default parser
is created using the registered commands and passed-in `root_parser`
or `subparsers`.
root_parser (ArgumentParser | None):
Optional root parser to merge into the CLI (used if `falyx_parsers`
is not supplied).
subparsers (_SubParsersAction | None):
Optional subparser group to extend (used if `falyx_parsers` is not supplied).
callback (Callable[..., Any] | None):
An optional function or coroutine to run after parsing CLI arguments,
typically for initializing logging, environment setup, or other
pre-execution configuration.
Raises:
FalyxError:
If invalid parser objects are supplied, or CLI arguments conflict
with the expected run mode.
SystemExit:
Exits with an appropriate exit code based on the selected command
or signal (e.g. Ctrl+C triggers exit code 130).
Notes:
- `run-all` executes all tagged commands **in parallel** and does not
supply arguments to individual commands; use `ChainedAction` or explicit
CLI calls for ordered or parameterized workflows.
- Most CLI commands exit the process via `sys.exit()` after completion.
- For interactive sessions, this method falls back to `menu()`.
Example:
```
>>> flx = Falyx()
>>> await flx.run() # Parses CLI args and dispatches appropriately
```
"""
if self.cli_args: if self.cli_args:
raise FalyxError( raise FalyxError(
"Run is incompatible with CLI arguments. Use 'run_key' instead." "Run is incompatible with CLI arguments. Use 'run_key' instead."
@@ -1198,8 +1329,11 @@ class Falyx:
logger.debug("Enabling global debug hooks for all commands") logger.debug("Enabling global debug hooks for all commands")
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
if self.cli_args.command == "list": if self.cli_args.command == "help":
await self._render_help(tag=self.cli_args.tag) self.options.set("mode", FalyxMode.HELP)
await self._render_help(
tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr
)
sys.exit(0) sys.exit(0)
if self.cli_args.command == "version" or self.cli_args.version: if self.cli_args.command == "version" or self.cli_args.version:
@@ -1247,13 +1381,13 @@ class Falyx:
sys.exit(1) sys.exit(1)
except QuitSignal: except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.") logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(0) sys.exit(130)
except BackSignal: except BackSignal:
logger.info("[BackSignal]. <- Exiting run.") logger.info("[BackSignal]. <- Exiting run.")
sys.exit(0) sys.exit(1)
except CancelSignal: except CancelSignal:
logger.info("[CancelSignal]. <- Exiting run.") logger.info("[CancelSignal]. <- Exiting run.")
sys.exit(0) sys.exit(1)
if self.cli_args.summary: if self.cli_args.summary:
er.summary() er.summary()
@@ -1278,22 +1412,35 @@ class Falyx:
f"{self.cli_args.tag}" f"{self.cli_args.tag}"
) )
tasks = []
try:
for cmd in matching: for cmd in matching:
self._set_retry_policy(cmd) self._set_retry_policy(cmd)
try: tasks.append(self.run_key(cmd.key))
await self.run_key(cmd.key) except Exception as error:
except FalyxError as error: self.console.print(
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") f"[{OneColors.DARK_RED}]❌ Unexpected error: {error}[/]"
)
sys.exit(1) sys.exit(1)
except QuitSignal:
had_errors = False
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, QuitSignal):
logger.info("[QuitSignal]. <- Exiting run.") logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(0) sys.exit(130)
except BackSignal: elif isinstance(result, CancelSignal):
logger.info("[BackSignal]. <- Exiting run.") logger.info("[CancelSignal]. <- Execution cancelled.")
sys.exit(0) sys.exit(1)
except CancelSignal: elif isinstance(result, BackSignal):
logger.info("[CancelSignal]. <- Exiting run.") logger.info("[BackSignal]. <- Back signal received.")
sys.exit(0) sys.exit(1)
elif isinstance(result, FalyxError):
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {result}[/]")
had_errors = True
if had_errors:
sys.exit(1)
if self.cli_args.summary: if self.cli_args.summary:
er.summary() er.summary()

View File

@@ -10,3 +10,4 @@ class FalyxMode(Enum):
RUN = "run" RUN = "run"
PREVIEW = "preview" PREVIEW = "preview"
RUN_ALL = "run-all" RUN_ALL = "run-all"
HELP = "help"

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,18 @@ class ArgumentState:
arg: Argument arg: Argument
consumed: bool = False consumed: bool = False
consumed_position: int | None = None
has_invalid_choice: bool = False
def set_consumed(self, position: int | None = None) -> None:
"""Mark this argument as consumed, optionally setting the position."""
self.consumed = True
self.consumed_position = position
def reset(self) -> None:
"""Reset the consumed state."""
self.consumed = False
self.consumed_position = None
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -4,7 +4,7 @@ Provides the argument parser infrastructure for the Falyx CLI.
This module defines the `FalyxParsers` dataclass and related utilities for building This module defines the `FalyxParsers` dataclass and related utilities for building
structured CLI interfaces with argparse. It supports top-level CLI commands like structured CLI interfaces with argparse. It supports top-level CLI commands like
`run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with `run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with
registered `Command` objects for dynamic help, usage generation, and argument handling. registered `Command` objects for dynamic help, usage generation, and argument handling.
Key Components: Key Components:
@@ -39,7 +39,7 @@ class FalyxParsers:
run: ArgumentParser run: ArgumentParser
run_all: ArgumentParser run_all: ArgumentParser
preview: ArgumentParser preview: ArgumentParser
list: ArgumentParser help: ArgumentParser
version: ArgumentParser version: ArgumentParser
def parse_args(self, args: Sequence[str] | None = None) -> Namespace: def parse_args(self, args: Sequence[str] | None = None) -> Namespace:
@@ -59,7 +59,7 @@ def get_root_parser(
prog: str | None = "falyx", prog: str | None = "falyx",
usage: str | None = None, usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.", epilog: str | None = "Tip: Use 'falyx help' to show available commands.",
parents: Sequence[ArgumentParser] | None = None, parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, fromfile_prefix_chars: str | None = None,
@@ -178,7 +178,7 @@ def get_arg_parsers(
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: ( epilog: (
str | None str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", ) = "Tip: Use 'falyx preview [COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None, parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, fromfile_prefix_chars: str | None = None,
@@ -196,7 +196,7 @@ def get_arg_parsers(
This function builds the root parser and all subcommand parsers used for structured This function builds the root parser and all subcommand parsers used for structured
CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`,
`preview`, `list`, and `version`, and integrates with registered `Command` objects `preview`, `help`, and `version`, and integrates with registered `Command` objects
to populate dynamic help and usage documentation. to populate dynamic help and usage documentation.
Args: Args:
@@ -219,7 +219,7 @@ def get_arg_parsers(
Returns: Returns:
FalyxParsers: A structured container of all parsers, including `run`, `run-all`, FalyxParsers: A structured container of all parsers, including `run`, `run-all`,
`preview`, `list`, `version`, and the root parser. `preview`, `help`, `version`, and the root parser.
Raises: Raises:
TypeError: If `root_parser` is not an instance of ArgumentParser or TypeError: If `root_parser` is not an instance of ArgumentParser or
@@ -240,7 +240,7 @@ def get_arg_parsers(
- Use `falyx run ?[COMMAND]` from the CLI to preview a command. - Use `falyx run ?[COMMAND]` from the CLI to preview a command.
""" """
if epilog is None: if epilog is None:
epilog = f"Tip: Use '{prog} run ?' to show available commands." epilog = f"Tip: Use '{prog} help' to show available commands."
if root_parser is None: if root_parser is None:
parser = get_root_parser( parser = get_root_parser(
prog=prog, prog=prog,
@@ -281,7 +281,7 @@ def get_arg_parsers(
command_description = command.help_text or command.description command_description = command.help_text or command.description
run_description.append(f"{' '*24}{command_description}") run_description.append(f"{' '*24}{command_description}")
run_epilog = ( run_epilog = (
f"Tip: Use '{prog} run ?[COMMAND]' to preview commands by their key or alias." f"Tip: Use '{prog} preview [COMMAND]' to preview commands by their key or alias."
) )
run_parser = subparsers.add_parser( run_parser = subparsers.add_parser(
"run", "run",
@@ -375,11 +375,23 @@ def get_arg_parsers(
) )
preview_parser.add_argument("name", help="Key, alias, or description of the command") preview_parser.add_argument("name", help="Key, alias, or description of the command")
list_parser = subparsers.add_parser( help_parser = subparsers.add_parser("help", help="List all available commands")
"list", help="List all available commands with tags"
help_parser.add_argument(
"-k",
"--key",
help="Show help for a specific command by its key or alias",
default=None,
) )
list_parser.add_argument( help_parser.add_argument(
"-T",
"--tldr",
action="store_true",
help="Show a simplified TLDR examples of a command if available",
)
help_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
) )
@@ -391,6 +403,6 @@ def get_arg_parsers(
run=run_parser, run=run_parser,
run_all=run_all_parser, run_all=run_all_parser,
preview=preview_parser, preview=preview_parser,
list=list_parser, help=help_parser,
version=version_parser, version=version_parser,
) )

View File

@@ -26,6 +26,18 @@ def infer_args_from_func(
This utility inspects the parameters of a function and returns a list of dictionaries, This utility inspects the parameters of a function and returns a list of dictionaries,
each of which can be passed to `CommandArgumentParser.add_argument()`. each of which can be passed to `CommandArgumentParser.add_argument()`.
It supports:
- Positional and keyword arguments
- Type hints for argument types
- Default values
- Required vs optional arguments
- Custom help text, choices, and suggestions via metadata
Note:
- Only parameters with kind `POSITIONAL_ONLY`, `POSITIONAL_OR_KEYWORD`, or
`KEYWORD_ONLY` are considered.
- Parameters with kind `VAR_POSITIONAL` or `VAR_KEYWORD` are ignored.
Args: Args:
func (Callable | None): The function to inspect. func (Callable | None): The function to inspect.
arg_metadata (dict | None): Optional metadata overrides for help text, type hints, arg_metadata (dict | None): Optional metadata overrides for help text, type hints,

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.78" __version__ = "0.1.85"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.78" version = "0.1.85"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"

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,96 @@
import pytest
from falyx import Falyx
@pytest.mark.asyncio
async def test_help_command(capsys):
flx = Falyx()
assert flx.help_command.arg_parser.aliases[0] == "HELP"
assert flx.help_command.arg_parser.command_key == "H"
await flx.run_key("H")
captured = capsys.readouterr()
assert "Show this help menu" in captured.out
@pytest.mark.asyncio
async def test_help_command_with_new_command(capsys):
flx = Falyx()
async def new_command(falyx: Falyx):
pass
flx.add_command(
"N",
"New Command",
new_command,
aliases=["TEST"],
help_text="This is a new command.",
)
await flx.run_key("H")
captured = capsys.readouterr()
assert "This is a new command." in captured.out
assert "TEST" in captured.out and "N" in captured.out
@pytest.mark.asyncio
async def test_render_help(capsys):
flx = Falyx()
async def sample_command(falyx: Falyx):
pass
flx.add_command(
"S",
"Sample Command",
sample_command,
aliases=["SC"],
help_text="This is a sample command.",
)
await flx._render_help()
captured = capsys.readouterr()
assert "This is a sample command." in captured.out
assert "SC" in captured.out and "S" in captured.out
@pytest.mark.asyncio
async def test_help_command_by_tag(capsys):
flx = Falyx()
async def tagged_command(falyx: Falyx):
pass
flx.add_command(
"T",
"Tagged Command",
tagged_command,
tags=["tag1"],
help_text="This command is tagged.",
)
await flx.run_key("H", args=("tag1",))
captured = capsys.readouterr()
assert "tag1" in captured.out
assert "This command is tagged." in captured.out
assert "HELP" not in captured.out
@pytest.mark.asyncio
async def test_help_command_empty_tags(capsys):
flx = Falyx()
async def untagged_command(falyx: Falyx):
pass
flx.add_command(
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
)
await flx.run_key("H", args=("nonexistent_tag",))
captured = capsys.readouterr()
print(captured.out)
assert "nonexistent_tag" in captured.out
assert "Nothing to show here" in captured.out

View File

@@ -0,0 +1,19 @@
import sys
import pytest
from falyx import Falyx
from falyx.parser import get_arg_parsers
@pytest.mark.asyncio
async def test_run_basic(capsys):
sys.argv = ["falyx", "run", "-h"]
falyx_parsers = get_arg_parsers()
assert falyx_parsers is not None, "Falyx parsers should be initialized"
flx = Falyx()
with pytest.raises(SystemExit):
await flx.run(falyx_parsers)
captured = capsys.readouterr()
assert "Run a command by its key or alias." in captured.out

View File

@@ -1,18 +1,311 @@
from pathlib import Path
import pytest import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser.command_argument_parser import CommandArgumentParser from falyx.parser.command_argument_parser import CommandArgumentParser
@pytest.mark.asyncio def build_default_parser():
@pytest.mark.parametrize( p = CommandArgumentParser(
"input_tokens, expected", command_key="D", aliases=["deploy"], program="argument_examples.py"
[
([""], ["--help", "--tag", "-h"]),
(["--ta"], ["--tag"]),
(["--tag"], ["analytics", "build"]),
],
) )
async def test_suggest_next(input_tokens, expected): p.add_argument("service", type=str, help="Service name.")
parser = CommandArgumentParser(...) p.add_argument("place", type=str, nargs="?", default="New York", help="Place.")
parser.add_argument("--tag", choices=["analytics", "build"]) p.add_argument(
assert sorted(parser.suggest_next(input_tokens)) == sorted(expected) "--region",
choices=["us-east-1", "us-west-2", "eu-west-1"],
help="Region.",
default="us-east-1",
)
p.add_argument("-p", "--path", type=Path, help="Path.")
p.add_argument("-v", "--verbose", action="store_true", help="Verbose.")
p.add_argument("-t", "--tag", type=str, suggestions=["latest", "stable", "beta"])
p.add_argument("--numbers", type=int, nargs="*", default=[1, 2, 3], help="Nums.")
p.add_argument("-j", "--just-a-bool", action="store_true", help="Bool.")
p.add_argument("-a", action="store_true")
p.add_argument("-b", action="store_true")
return p
@pytest.mark.asyncio
async def test_parse_minimal_positional_and_defaults():
p = build_default_parser()
got = await p.parse_args(["web"])
assert got["service"] == "web"
assert got["place"] == "New York"
assert got["numbers"] == [1, 2, 3]
assert got["verbose"] is False
assert got["tag"] is None
assert got["path"] is None
@pytest.mark.asyncio
async def test_parse_all_keywords_and_lists_and_bools():
p = build_default_parser()
got = await p.parse_args(
[
"web",
"Paris",
"--region",
"eu-west-1",
"--numbers",
"10",
"20",
"-30",
"-t",
"stable",
"-p",
"pyproject.toml",
"-v",
"-j",
]
)
assert got["service"] == "web"
assert got["place"] == "Paris"
assert got["region"] == "eu-west-1"
assert got["numbers"] == [10, 20, -30]
assert got["tag"] == "stable"
assert isinstance(got["path"], Path)
assert got["verbose"] is True and got["just_a_bool"] is True
@pytest.mark.asyncio
async def test_parse_numbers_negative_values_not_flags():
p = build_default_parser()
got = await p.parse_args(["web", "--numbers", "-1", "-2", "-3"])
assert got["numbers"] == [-1, -2, -3]
def test_default_list_must_match_choices_when_choices_present():
p = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
p.add_argument(
"--color", choices=["red", "blue"], nargs="*", default=["red", "green"]
)
def test_default_type_for_nargs_requires_list():
p = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
p.add_argument("--ints", type=int, nargs=2, default=1)
@pytest.mark.asyncio
async def test_choices_enforced_on_result():
p = CommandArgumentParser()
p.add_argument("--env", choices=["prod", "dev"])
with pytest.raises(CommandArgumentError):
await p.parse_args(["--env", "staging"])
@pytest.mark.asyncio
async def test_posix_bundling_flags_only():
p = CommandArgumentParser()
p.add_argument("-a", "--aa", action="store_true")
p.add_argument("-b", "--bb", action="store_true")
p.add_argument("-c", "--cc", action="store_true")
got = await p.parse_args(["-abc"])
assert got["aa"] and got["bb"] and got["cc"]
@pytest.mark.asyncio
async def test_posix_bundling_not_applied_when_value_like():
p = CommandArgumentParser()
p.add_argument("-n", "--num", type=int)
p.add_argument("-a", action="store_true")
p.add_argument("-b", action="store_true")
got = await p.parse_args(["--num", "-123", "-ab"])
assert got["num"] == -123
assert got["a"] and got["b"]
def mk_tmp_tree(tmp_path: Path):
(tmp_path / "dirA").mkdir()
(tmp_path / "dirB").mkdir()
(tmp_path / "file.txt").write_text("x")
def test_complete_initial_flags_and_suggestions():
p = build_default_parser()
sugg = p.suggest_next([""], cursor_at_end_of_token=False)
assert "--tag" in sugg and "--region" in sugg and "-v" in sugg
def test_complete_flag_by_prefix():
p = build_default_parser()
assert p.suggest_next(["--ta"], False) == ["--tag"]
@pytest.mark.asyncio
async def test_complete_values_for_flag_choices():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--region"])
sugg = p.suggest_next(["--region"], True)
assert set(sugg) == {"us-east-1", "us-west-2", "eu-west-1"}
with pytest.raises(CommandArgumentError):
await p.parse_args(["--region", "us-"])
sugg2 = p.suggest_next(["--region", "us-"], False)
assert set(sugg2) == {"us-east-1", "us-west-2"}
@pytest.mark.asyncio
async def test_complete_values_for_flag_suggestions():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag"])
assert set(p.suggest_next(["--tag"], True)) == {"latest", "stable", "beta"}
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag", "st"])
assert set(p.suggest_next(["--tag", "st"], False)) == {"stable"}
def test_complete_mid_flag_hyphen_value_uses_previous_flag_context():
p = build_default_parser()
sugg = p.suggest_next(["--numbers", "-1"], False)
assert "--tag" not in sugg and "--region" not in sugg
def test_complete_multi_value_keeps_suggesting_for_plus_star():
p = build_default_parser()
sugg1 = p.suggest_next(["--numbers"], False)
assert "--tag" not in sugg1 or True
sugg2 = p.suggest_next(["--numbers", "1"], False)
assert "--tag" not in sugg2 or True
@pytest.mark.asyncio
async def test_complete_path_values(tmp_path, monkeypatch):
mk_tmp_tree(tmp_path)
monkeypatch.chdir(tmp_path)
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--path"])
sugg = p.suggest_next(["--path"], True)
assert any(s.endswith("/") for s in sugg) and "file.txt" in sugg
with pytest.raises(CommandArgumentError):
await p.parse_args(["--path", "d"])
sugg2 = p.suggest_next(["--path", "d"], False)
assert "dirA/" in sugg2 or "dirB/" in sugg2
@pytest.mark.asyncio
async def test_complete_positional_path(tmp_path, monkeypatch):
mk_tmp_tree(tmp_path)
monkeypatch.chdir(tmp_path)
p = CommandArgumentParser()
p.add_argument("paths", type=Path, nargs="*")
await p.parse_args([""])
s1 = p.suggest_next([""], False)
assert "file.txt" in s1 or "dirA/" in s1
await p.parse_args(["fi"])
s2 = p.suggest_next(["fi"], False)
assert "file.txt" in s2
@pytest.mark.asyncio
async def test_flag_then_space_yields_flag_suggestions():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag"])
sugg = p.suggest_next(["--tag"], True)
assert "latest" in sugg
def test_complete_multi_value_persists_until_space_or_new_flag():
p = build_default_parser()
s1 = p.suggest_next(["--numbers"], cursor_at_end_of_token=False)
assert "--tag" not in s1 or True
s2 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=False)
assert "--tag" not in s2 or True
s3 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=True)
assert "--tag" not in s3 or True
@pytest.mark.asyncio
async def test_mid_value_suggestions_then_flags_after_space():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag", "st"])
s_mid = p.suggest_next(["--tag", "st"], cursor_at_end_of_token=False)
assert set(s_mid) == {"stable"}
s_after = p.suggest_next(["--tag"], cursor_at_end_of_token=True)
assert any(opt.startswith("-") for opt in s_after)
@pytest.mark.asyncio
async def test_negative_values_then_posix_bundle():
p = build_default_parser()
out = await p.parse_args(["prod", "--numbers", "-3", "-ab"])
assert out["numbers"] == [-3]
assert out["a"] is True and out["b"] is True
def test_mid_flag_token_after_negative_value_uses_prior_flag_context():
p = build_default_parser()
sugg = p.suggest_next(["--numbers", "-1"], cursor_at_end_of_token=False)
assert "--tag" not in sugg and "--region" not in sugg
@pytest.mark.asyncio
async def test_path_dash_prefix_is_value_not_flags():
p = CommandArgumentParser()
p.add_argument("-a", action="store_true")
p.add_argument("--path", type=Path)
out = await p.parse_args(["--path", "-abc", "-a"])
assert str(out["path"]) == "-abc"
assert out["a"] is True
@pytest.mark.asyncio
async def test_store_bool_optional_pair_last_one_wins():
p = CommandArgumentParser()
p.add_argument("--feature", action="store_bool_optional", help="toggle feature")
out0 = await p.parse_args([])
assert out0["feature"] is None
out1 = await p.parse_args(["--feature"])
assert out1["feature"] is True
out2 = await p.parse_args(["--no-feature"])
assert out2["feature"] is False
out3 = await p.parse_args(["--feature", "--no-feature"])
assert out3["feature"] is False
out4 = await p.parse_args(["--no-feature", "--feature"])
assert out4["feature"] is True
@pytest.mark.asyncio
async def test_invalid_choice_suppresses_then_recovers():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--region", "us-"])
s_suppressed = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=True)
assert s_suppressed == []
s_recover = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=False)
assert set(s_recover) == {"us-east-1", "us-west-2"}
@pytest.mark.asyncio
async def test_repeated_keyword_last_one_wins_and_guides_completion():
p = build_default_parser()
out = await p.parse_args(["test", "--tag", "alpha", "--tag", "st"])
assert out["tag"] == "st"
s = p.suggest_next(
["test", "--tag", "alpha", "--tag", "st"], cursor_at_end_of_token=False
)
assert set(s) == {"stable"}

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