Source code for valid8r.integrations.typer

"""Typer integration for valid8r parsers.

This module provides TyperParser to use valid8r parsers as Typer parameter types.
Since Typer uses Click internally, TyperParser wraps the Click ParamTypeAdapter.

Examples:
    >>> import typer
    >>> from typing_extensions import Annotated
    >>> from valid8r.core import parsers
    >>> from valid8r.integrations.typer import TyperParser
    >>>
    >>> app = typer.Typer()
    >>>
    >>> # Basic usage with email parser
    >>> @app.command()
    ... def create_user(
    ...     email: Annotated[str, typer.Option(parser=TyperParser(parsers.parse_email))]
    ... ) -> None:
    ...     print(f"Creating user: {email.local}@{email.domain}")
    >>>
    >>> # With chained validators for port validation
    >>> from valid8r.core import validators
    >>> def port_parser(text: str | None):
    ...     return parsers.parse_int(text).bind(
    ...         validators.minimum(1) & validators.maximum(65535)
    ...     )
    >>> @app.command()
    ... def start_server(
    ...     port: Annotated[int, typer.Option(parser=TyperParser(port_parser))]
    ... ) -> None:
    ...     print(f"Starting server on port {port}")

"""

from __future__ import annotations

import functools
import inspect
from typing import (
    TYPE_CHECKING,
    Any,
)

import typer

from valid8r.core.maybe import (
    Failure,
    Maybe,
    Success,
)
from valid8r.integrations.click import ParamTypeAdapter

if TYPE_CHECKING:
    from collections.abc import Callable

    import click


[docs] class TyperParser(ParamTypeAdapter): """Typer parameter type adapter for valid8r parsers. This class wraps a valid8r parser function (returning Maybe[T]) into a Typer-compatible parameter type. Since Typer uses Click internally, this is a thin wrapper around ParamTypeAdapter that can be used with Typer's Option() and Argument(). TyperParser can be used in two ways with Typer: 1. As a Click ParamType: typer.Option(click_type=TyperParser(...)) 2. As a callable parser: typer.Option(parser=TyperParser(...)) Args: parser: A function that takes a string and returns Maybe[T] name: Optional custom name for the type (defaults to parser.__name__) error_prefix: Optional prefix for error messages (e.g., "Email address") Examples: >>> from valid8r.core import parsers >>> from valid8r.integrations.typer import TyperParser >>> >>> # Simple email validation >>> email_type = TyperParser(parsers.parse_email) >>> email_type.name 'parse_email' >>> >>> # With custom name >>> port_type = TyperParser(parsers.parse_int, name='port') >>> port_type.name 'port' >>> >>> # With custom error prefix >>> email_type = TyperParser( ... parsers.parse_email, ... error_prefix='Email address' ... ) """ def __init__( self, parser: Callable[[str], Maybe[object]], name: str | None = None, error_prefix: str | None = None, ) -> None: """Initialize the TyperParser. Args: parser: A valid8r parser function name: Custom name for the type (defaults to parser.__name__) error_prefix: Custom prefix for error messages """ # Initialize parent Click ParamTypeAdapter super().__init__(parser, name=name, error_prefix=error_prefix) # Add __name__ attribute for Typer's FuncParamType compatibility # When Typer uses parser=TyperParser(...), it wraps it in FuncParamType # which expects a __name__ attribute
[docs] self.__name__ = self.name
[docs] def __call__(self, value: str, param: click.Parameter | None = None, ctx: click.Context | None = None) -> object: """Make TyperParser callable for use with Typer's parser parameter. When used as parser=TyperParser(...), Typer wraps this in a FuncParamType and calls it directly. We delegate to the convert method which handles the Maybe conversion and error handling. Args: value: The input string to parse param: Optional Click Parameter (can be named param or _param) ctx: Optional Click Context (can be named ctx or _ctx) Returns: The successfully parsed and validated value Raises: click.exceptions.BadParameter: If validation fails """ return self.convert(value, param, ctx)
[docs] def validator_callback( parser: Callable[[str | None], Maybe[object]], *validators: Callable[[object], Maybe[object]], error_prefix: str | None = None, ) -> Callable[[str], object]: """Create a Typer callback function from a valid8r parser and optional validators. This factory function creates a callback that can be used with Typer's Option() or Argument() callback parameter. The callback will parse and validate the input, returning the validated value or raising typer.BadParameter on failure. Args: parser: A valid8r parser function that returns Maybe[T] *validators: Optional validator functions to chain after parsing error_prefix: Optional prefix for error messages (e.g., "Port number") Returns: A callback function compatible with Typer's callback parameter Examples: >>> from valid8r.core import parsers, validators >>> from valid8r.integrations.typer import validator_callback >>> import typer >>> >>> # Simple email validation >>> email_callback = validator_callback(parsers.parse_email) >>> >>> # Port validation with range checking >>> def port_parser(text: str | None): ... return parsers.parse_int(text).bind( ... validators.minimum(1) & validators.maximum(65535) ... ) >>> port_callback = validator_callback(port_parser) >>> >>> # Use in Typer command >>> app = typer.Typer() >>> @app.command() ... def serve( ... port: int = typer.Option(8000, callback=port_callback) ... ) -> None: ... print(f"Serving on port {port}") """ def callback(value: str) -> object: """Validate using valid8r parser and return result or raise BadParameter.""" # Parse the input result = parser(value) # Apply any additional validators for validator in validators: result = result.bind(validator) # Convert Maybe result to Typer exception or return value match result: case Success(val): return val case Failure(err): # Prepend error prefix if provided error_msg = f'{error_prefix}: {err}' if error_prefix else err raise typer.BadParameter(error_msg) # Unreachable but satisfies type checker msg = 'Unexpected Maybe variant' raise RuntimeError(msg) return callback
[docs] def validate_with( param_name: str, parser: Callable[[str | None], Maybe[object]], *validators: Callable[[object], Maybe[object]], ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Add validation to a Typer command parameter. This decorator modifies a Typer command to use a validator callback for a specific parameter. The validated/converted value replaces the original string parameter before the command executes. Note: This decorator should be applied BEFORE @app.command() so that Typer sees the modified default values with callbacks attached. Args: param_name: Name of the parameter to validate parser: A valid8r parser function that returns Maybe[T] *validators: Optional validator functions to chain after parsing Returns: A decorator function that modifies the Typer command Examples: >>> from valid8r.core import parsers, validators >>> from valid8r.integrations.typer import validate_with >>> import typer >>> >>> app = typer.Typer() >>> >>> @app.command() ... @validate_with('email', parsers.parse_email) ... def send(email: str = typer.Option(...)) -> None: ... # email is now an EmailAddress object ... print(f"Sending to {email.local}@{email.domain}") >>> >>> @app.command() ... @validate_with('email', parsers.parse_email) ... @validate_with('age', parsers.parse_int, validators.minimum(0)) ... def register( ... email: str = typer.Option(...), ... age: str = typer.Option(...) ... ) -> None: ... # Both parameters are validated ... print(f"Registered {email.local}, age {age}") """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: """Modify the function to add parameter validation via callback.""" # Create a validator callback for this parameter callback = validator_callback(parser, *validators) # Get the function's signature sig = inspect.signature(func) # Find the parameter if param_name not in sig.parameters: # Parameter doesn't exist, just return the function return func param = sig.parameters[param_name] # Check if parameter has a default that's a typer.Option or typer.Argument if param.default is inspect.Parameter.empty: # No default, can't add callback return func # Modify the default to add our callback if hasattr(param.default, 'callback'): # It's a typer.Option or typer.Argument, add our callback old_callback = param.default.callback # Chain callbacks if one already exists if old_callback is not None: def chained_callback(value: str) -> object: """Chain our validator with existing callback.""" result = callback(value) return old_callback(result) param.default.callback = chained_callback else: param.default.callback = callback @functools.wraps(func) def wrapper(*args: object, **kwargs: object) -> object: """Preserve function signature and forward call.""" return func(*args, **kwargs) return wrapper return decorator
[docs] class ValidatedType: """Create a custom Typer type with valid8r validation. This class wraps a valid8r parser into a Click ParamType that can be used with Typer's type annotations. It provides a cleaner alternative to using TyperParser directly in type hints. Args: parser: A valid8r parser function that returns Maybe[T] name: Optional custom name for the type help_text: Optional help text describing validation constraints (reserved for future use) Examples: >>> from valid8r.core import parsers >>> from valid8r.integrations.typer import ValidatedType >>> import typer >>> >>> # Create custom types >>> Email = ValidatedType(parsers.parse_email) >>> Phone = ValidatedType(parsers.parse_phone) >>> >>> app = typer.Typer() >>> >>> @app.command() ... def contact( ... email: Email = typer.Option(...), # type: ignore[valid-type] ... phone: Phone = typer.Option(None) # type: ignore[valid-type] ... ) -> None: ... # email is EmailAddress, phone is Optional[PhoneNumber] ... print(f"Contact: {email.local}@{email.domain}") """ def __new__( # type: ignore[misc] cls, parser: Callable[[str | None], Maybe[object]], name: str | None = None, help_text: str | None = None, # noqa: ARG004 ) -> TyperParser: """Create a new ValidatedType instance as a TyperParser. This uses __new__ to return a TyperParser instance directly, making ValidatedType a factory for TyperParser with different semantics. Args: parser: A valid8r parser function that returns Maybe[T] name: Optional custom name for the type help_text: Reserved for future help text integration Returns: A TyperParser instance configured with the given parser Note: The type: ignore[misc] is intentional - this class uses __new__ as a factory pattern to return a TyperParser instead of a ValidatedType. """ return TyperParser(parser, name=name, error_prefix=None)
[docs] def validated_prompt( prompt_text: str, parser: Callable[[str | None], Maybe[object]], *validators: Callable[[object], Maybe[object]], max_retries: int = 10, typer_style: bool = False, ) -> object: """Prompt interactively with valid8r validation and retry logic. This function prompts the user for input, validates it using a valid8r parser and optional validators, and re-prompts on validation failure up to max_retries. After max_retries, it raises a Typer exception to exit the CLI. Args: prompt_text: The prompt message to display parser: A valid8r parser function that returns Maybe[T] *validators: Optional validator functions to chain after parsing max_retries: Maximum number of retry attempts (default: 10) typer_style: Whether to use Typer's echo/style for output (default: False) Returns: The validated and parsed value Raises: typer.Exit: If max_retries is exceeded without valid input Examples: >>> from valid8r.core import parsers >>> from valid8r.integrations.typer import validated_prompt >>> import typer >>> >>> app = typer.Typer() >>> >>> @app.command() ... def interactive() -> None: ... email = validated_prompt( ... "Enter email", ... parser=parsers.parse_email, ... typer_style=True ... ) ... print(f"Got email: {email.local}@{email.domain}") """ def _report_error(msg: str) -> None: """Report error using appropriate output method.""" if typer_style: typer.echo(f'Error: {msg}', err=True) else: print(f'Error: {msg}') attempts = 0 while attempts < max_retries: # Prompt for input using appropriate method user_input = typer.prompt(prompt_text) if typer_style else input(f'{prompt_text}: ') # Parse and validate result = parser(user_input) for validator in validators: result = result.bind(validator) # Check result match result: case Success(val): return val case Failure(err): _report_error(err) attempts += 1 # Max retries exceeded _report_error(f'Max retries ({max_retries}) exceeded') raise typer.Exit(code=1)
__all__ = [ 'TyperParser', 'ValidatedType', 'validate_with', 'validated_prompt', 'validator_callback', ]