"""Input handling and validation module for MTG Python Deckbuilder.""" from __future__ import annotations import logging import os from typing import Any, List, Optional, Tuple, Union import inquirer.prompt from settings import ( COLORS, COLOR_ABRV ) from deck_builder.builder_constants import (DEFAULT_MAX_CARD_PRICE, DEFAULT_MAX_DECK_PRICE, DEFAULT_THEME_TAGS, MONO_COLOR_MAP, DUAL_COLOR_MAP, TRI_COLOR_MAP, OTHER_COLOR_MAP ) from exceptions import ( CommanderColorError, CommanderStatsError, CommanderTagError, CommanderThemeError, CommanderTypeError, DeckBuilderError, EmptyInputError, InvalidNumberError, InvalidQuestionTypeError, MaxAttemptsError, PriceError, PriceLimitError, PriceValidationError ) import logging_util # Create logger for this module logger = logging_util.logging.getLogger(__name__) logger.setLevel(logging_util.LOG_LEVEL) logger.addHandler(logging_util.file_handler) logger.addHandler(logging_util.stream_handler) class InputHandler: """Handles user input operations with validation and error handling. This class provides methods for collecting and validating different types of user input including text, numbers, confirmations, and choices. Attributes: max_attempts (int): Maximum number of retry attempts for invalid input default_text (str): Default value for text input default_number (float): Default value for number input default_confirm (bool): Default value for confirmation input """ def __init__( self, max_attempts: int = 3, default_text: str = '', default_number: float = 0.0, default_confirm: bool = True ): """Initialize input handler with configuration. Args: max_attempts: Maximum number of retry attempts default_text: Default value for text input default_number: Default value for number input default_confirm: Default value for confirmation input """ self.max_attempts = max_attempts self.default_text = default_text self.default_number = default_number self.default_confirm = default_confirm def validate_text(self, result: str) -> bool: """Validate text input is not empty. Args: result: Text input to validate Returns: True if text is not empty after stripping whitespace Raises: EmptyInputError: If input is empty or whitespace only """ if not result or not result.strip(): raise EmptyInputError() return True def validate_number(self, result: str) -> float: """Validate and convert string input to float. Args: result: Number input to validate Returns: Converted float value Raises: InvalidNumberError: If input cannot be converted to float """ try: return float(result) except (ValueError, TypeError): raise InvalidNumberError(result) def validate_price(self, result: str) -> Tuple[float, bool]: """Validate and convert price input to float with format checking. Args: result: Price input to validate Returns: Tuple of (price value, is_unlimited flag) Raises: PriceValidationError: If price format is invalid """ result = result.strip().lower() # Check for unlimited budget if result in ['unlimited', 'any']: return (float('inf'), True) # Remove currency symbol if present if result.startswith('$'): result = result[1:] try: price = float(result) if price < 0: raise PriceValidationError('Price cannot be negative') return (price, False) except ValueError: raise PriceValidationError(f"Invalid price format: '{result}'") def validate_price_threshold(self, price: float, threshold: float = DEFAULT_MAX_CARD_PRICE) -> bool: """Validate price against maximum threshold. Args: price: Price value to check threshold: Maximum allowed price (default from settings) Returns: True if price is within threshold Raises: PriceLimitError: If price exceeds threshold """ if price > threshold and price != float('inf'): raise PriceLimitError('Card', price, threshold) return True def validate_confirm(self, result: bool) -> bool: """Validate confirmation input. Args: result: Boolean confirmation input Returns: The boolean input value """ return bool(result) def questionnaire( self, question_type: str, message: str = '', default_value: Any = None, choices_list: List[str] = None ) -> Union[str, float, bool]: """Present questions to user and handle input validation. Args: question_type: Type of question ('Text', 'Number', 'Confirm', 'Choice') message: Question message to display default_value: Default value for the question choices_list: List of choices for Choice type questions Returns: Validated user input of appropriate type Raises: InvalidQuestionTypeError: If question_type is not supported MaxAttemptsError: If maximum retry attempts are exceeded """ attempts = 0 while attempts < self.max_attempts: try: if question_type == 'Text': question = [ inquirer.Text( 'text', message=f'{message}' or 'Enter text', default=default_value or self.default_text ) ] result = inquirer.prompt(question)['text'] if self.validate_text(result): return str(result) elif question_type == 'Price': question = [ inquirer.Text( 'price', message=f'{message}' or 'Enter price (or "unlimited")', default=str(default_value or DEFAULT_MAX_CARD_PRICE) ) ] result = inquirer.prompt(question)['price'] price, is_unlimited = self.validate_price(result) if not is_unlimited: self.validate_price_threshold(price) return float(price) elif question_type == 'Number': question = [ inquirer.Text( 'number', message=f'{message}' or 'Enter number', default=str(default_value or self.default_number) ) ] result = inquirer.prompt(question)['number'] return self.validate_number(result) elif question_type == 'Confirm': question = [ inquirer.Confirm( 'confirm', message=f'{message}' or 'Confirm?', default=default_value if default_value is not None else self.default_confirm ) ] result = inquirer.prompt(question)['confirm'] return self.validate_confirm(result) elif question_type == 'Choice': if not choices_list: raise ValueError("Choices list cannot be empty for Choice type") question = [ inquirer.List( 'selection', message=f'{message}' or 'Select an option', choices=choices_list, carousel=True ) ] return inquirer.prompt(question)['selection'] else: raise InvalidQuestionTypeError(question_type) except DeckBuilderError as e: logger.warning(f"Input validation failed: {e}") attempts += 1 if attempts >= self.max_attempts: raise MaxAttemptsError( self.max_attempts, question_type.lower(), {"last_error": str(e)} ) except Exception as e: logger.error(f"Unexpected error in questionnaire: {e}") raise raise MaxAttemptsError(self.max_attempts, question_type.lower()) def validate_commander_type(self, type_line: str) -> str: """Validate commander type line requirements. Args: type_line: Commander's type line to validate Returns: Validated type line Raises: CommanderTypeError: If type line validation fails """ if not type_line: raise CommanderTypeError("Type line cannot be empty") type_line = type_line.strip() # Check for legendary creature requirement if not ('Legendary' in type_line and 'Creature' in type_line): # Check for 'can be your commander' text if 'can be your commander' not in type_line.lower(): raise CommanderTypeError( "Commander must be a legendary creature or have 'can be your commander' text" ) return type_line def validate_commander_stats(self, stat_name: str, value: str) -> int: """Validate commander numerical statistics. Args: stat_name: Name of the stat (power, toughness, mana value) value: Value to validate Returns: Validated integer value Raises: CommanderStatsError: If stat validation fails """ try: stat_value = int(value) if stat_value < 0 and stat_name != 'power': raise CommanderStatsError(f"{stat_name} cannot be negative") return stat_value except ValueError: raise CommanderStatsError( f"Invalid {stat_name} value: '{value}'. Must be a number." ) def _normalize_color_string(self, colors: str) -> str: """Helper method to standardize color string format. Args: colors: Raw color string to normalize Returns: Normalized color string """ if not colors: return 'colorless' # Remove whitespace and sort color symbols colors = colors.strip().upper() color_symbols = [c for c in colors if c in 'WUBRG'] return ', '.join(sorted(color_symbols)) def _validate_color_combination(self, colors: str) -> bool: """Helper method to validate color combinations. Args: colors: Normalized color string to validate Returns: True if valid, False otherwise """ if colors == 'colorless': return True # Check against valid combinations from settings return (colors in COLOR_ABRV or any(colors in combo for combo in [MONO_COLOR_MAP, DUAL_COLOR_MAP, TRI_COLOR_MAP, OTHER_COLOR_MAP])) def validate_color_identity(self, colors: str) -> str: """Validate commander color identity using settings constants. Args: colors: Color identity string to validate Returns: Validated color identity string Raises: CommanderColorError: If color validation fails """ # Normalize the color string normalized = self._normalize_color_string(colors) # Validate the combination if not self._validate_color_combination(normalized): raise CommanderColorError( f"Invalid color identity: '{colors}'. Must be a valid color combination." ) return normalized def validate_commander_colors(self, colors: str) -> str: """Validate commander color identity. Args: colors: Color identity string to validate Returns: Validated color identity string Raises: CommanderColorError: If color validation fails """ try: return self.validate_color_identity(colors) except CommanderColorError as e: logger.error(f"Color validation failed: {e}") raise def validate_commander_tags(self, tags: List[str]) -> List[str]: """Validate commander theme tags. Args: tags: List of theme tags to validate Returns: Validated list of theme tags Raises: CommanderTagError: If tag validation fails """ if not isinstance(tags, list): raise CommanderTagError("Tags must be provided as a list") validated_tags = [] for tag in tags: if not isinstance(tag, str): raise CommanderTagError(f"Invalid tag type: {type(tag)}. Must be string.") tag = tag.strip() if tag: validated_tags.append(tag) return validated_tags def validate_commander_themes(self, themes: List[str]) -> List[str]: """Validate commander themes. Args: themes: List of themes to validate Returns: Validated list of themes Raises: CommanderThemeError: If theme validation fails """ if not isinstance(themes, list): raise CommanderThemeError("Themes must be provided as a list") validated_themes = [] for theme in themes: if not isinstance(theme, str): raise CommanderThemeError(f"Invalid theme type: {type(theme)}. Must be string.") theme = theme.strip() if theme and theme in DEFAULT_THEME_TAGS: validated_themes.append(theme) else: raise CommanderThemeError(f"Invalid theme: '{theme}'") return validated_themes