"""Basic input prompting functions with validation support.
This module provides functionality for prompting users for input via the command line
with built-in parsing, validation, and retry logic.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Generic,
TypeVar,
cast,
)
from valid8r.core.maybe import (
Failure,
Maybe,
Success,
)
from valid8r.prompt.io_provider import BuiltinIOProvider
if TYPE_CHECKING:
from collections.abc import Callable
from valid8r.prompt.io_provider import IOProvider
@dataclass
[docs]
class PromptConfig(Generic[T]):
"""Configuration for the ask function."""
[docs]
parser: Callable[[str], Maybe[T]] | None = None
[docs]
validator: Callable[[T], Maybe[T]] | None = None
[docs]
error_message: str | None = None
[docs]
default: T | None = None
[docs]
retry: bool | int = False
[docs]
io_provider: IOProvider | None = None
_test_mode: bool = False
def _handle_user_input(prompt_text: str, default: T | None, io_provider: IOProvider) -> tuple[str, bool]:
"""Handle getting user input and displaying the prompt.
Args:
prompt_text: The prompt message to display
default: Default value to use if user provides empty input
io_provider: IO provider for handling input
Returns:
A tuple of (user_input, use_default) where use_default is True if the
default value should be used.
"""
# Build prompt text with default if available
display_prompt = prompt_text
if default is not None:
display_prompt = f'{prompt_text} [{default}]: '
# Get user input using the IO provider
user_input = io_provider.input(display_prompt)
# Check if we should use the default value
use_default = not user_input and default is not None
return user_input, use_default
def _process_input(user_input: str, parser: Callable[[str], Maybe[T]], validator: Callable[[T], Maybe[T]]) -> Maybe[T]:
"""Process user input by parsing and validating."""
# Parse input
result = parser(user_input)
# Validate if parsing was successful
match result:
case Success(value):
return validator(value)
case Failure(_):
return result
return result # This line is unreachable but keeps type checkers happy pragma: no cover
[docs]
def ask( # noqa: PLR0913
prompt_text: str,
*, # Force all other parameters to be keyword-only
parser: Callable[[str], Maybe[T]] | None = None,
validator: Callable[[T], Maybe[T]] | None = None,
error_message: str | None = None,
default: T | None = None,
retry: bool | int = False,
io_provider: IOProvider | None = None,
_test_mode: bool = False,
) -> Maybe[T]:
"""Prompt the user for input with parsing and validation.
Displays a prompt to the user, parses their input using the provided parser,
validates the result, and optionally retries on failure. Returns a Maybe monad
containing either the validated input or an error message.
Args:
prompt_text: The prompt message to display to the user
parser: Function to convert string input to desired type (default: returns string as-is)
validator: Function to validate the parsed value (default: accepts any value)
error_message: Custom error message to display on validation failure
default: Default value to use if user provides empty input (displays in prompt)
retry: Enable retry on failure - True for unlimited, integer for max attempts, False to disable
io_provider: IO provider for handling input/output (default: BuiltinIOProvider)
_test_mode: Internal testing parameter (do not use)
Returns:
Maybe[T]: Success with validated input, or Failure with error message
Examples:
Basic integer input with validation::
from valid8r.core import parsers, validators
from valid8r.prompt import ask
result = ask(
"Enter your age: ",
parser=parsers.parse_int,
validator=validators.between(0, 120),
retry=True
)
# User enters "25" -> Success(25)
# User enters "invalid" -> prompts again with error message
Input with default value::
result = ask(
"Enter port: ",
parser=parsers.parse_int,
default=8080
)
# User presses Enter -> Success(8080)
# User enters "3000" -> Success(3000)
Limited retries with custom error::
result = ask(
"Email: ",
parser=parsers.parse_email,
error_message="Invalid email format",
retry=3
)
# User has 3 attempts to enter valid email
Boolean input with retry::
result = ask(
"Continue? (yes/no): ",
parser=parsers.parse_bool,
retry=True
)
# User enters "yes" -> Success(True)
# User enters "maybe" -> error, retry prompt
Note:
The returned Maybe must be unwrapped to access the value.
Use pattern matching or .value_or() to extract the result.
"""
# Create a config object from the parameters
config = PromptConfig(
parser=parser,
validator=validator,
error_message=error_message,
default=default,
retry=retry,
io_provider=io_provider,
_test_mode=_test_mode,
)
return _ask_with_config(prompt_text, config)
def _ask_with_config(prompt_text: str, config: PromptConfig[T]) -> Maybe[T]:
"""Implement ask using a PromptConfig object."""
# For testing the final return path
if config._test_mode: # noqa: SLF001
return Maybe.failure(config.error_message or 'Maximum retry attempts reached')
# Set default parser and validator if not provided
def default_parser(s: str) -> Maybe[T]:
return Maybe.success(cast('T', s))
parser: Callable[[str], Maybe[T]] = config.parser if config.parser is not None else default_parser
validator = config.validator or (lambda v: Maybe.success(v))
# Get or create IO provider
io_provider: IOProvider = config.io_provider if config.io_provider is not None else BuiltinIOProvider()
# Calculate max retries
max_retries = config.retry if isinstance(config.retry, int) else float('inf') if config.retry else 0
return _run_prompt_loop(
prompt_text, parser, validator, config.default, max_retries, config.error_message, io_provider
)
def _run_prompt_loop( # noqa: PLR0913
prompt_text: str,
parser: Callable[[str], Maybe[T]],
validator: Callable[[T], Maybe[T]],
default: T | None,
max_retries: float,
error_message: str | None,
io_provider: IOProvider,
) -> Maybe[T]:
"""Run the prompt loop with retries.
Args:
prompt_text: The prompt message to display
parser: Function to convert string input to desired type
validator: Function to validate the parsed value
default: Default value to use if user provides empty input
max_retries: Maximum number of retry attempts
error_message: Custom error message to display on validation failure
io_provider: IO provider for handling input/output
Returns:
Maybe[T]: Success with validated input, or Failure with error message
"""
attempt = 0
while attempt <= max_retries:
# Get user input
user_input, use_default = _handle_user_input(prompt_text, default, io_provider)
# Use default if requested
if use_default:
if default is None:
return Maybe.failure('No default value provided')
return Maybe.success(default)
# Process the input
result = _process_input(user_input, parser, validator)
match result:
case Success(_):
return result
case Failure(error):
# Handle invalid input
attempt += 1
if attempt <= max_retries:
_display_error(error, error_message, max_retries, attempt, io_provider)
else:
return result # Return the failed result after max retries
return Maybe.failure(error_message or 'Maximum retry attempts reached')
def _display_error(
result_error: str,
custom_error: str | None,
max_retries: float,
attempt: int,
io_provider: IOProvider,
) -> None:
"""Display error message to the user.
Args:
result_error: The original error message from parsing/validation
custom_error: Custom error message to display (overrides result_error)
max_retries: Maximum number of retry attempts
attempt: Current attempt number
io_provider: IO provider for displaying the error
"""
err_msg = custom_error or result_error
remaining = max_retries - attempt if max_retries < float('inf') else None
if remaining is not None:
io_provider.error(f'Error: {err_msg} ({remaining} attempt(s) remaining)')
else:
io_provider.error(f'Error: {err_msg}')