Began work on overhauling the deck_builder

This commit is contained in:
mwisnowski 2025-01-14 12:07:49 -08:00
parent e0dd09adee
commit 319f7848d3
10 changed files with 2589 additions and 761 deletions

View file

@ -1,40 +1,74 @@
"""Input validation and handling for MTG Python Deckbuilder.
"""Input handling and validation module for MTG Python Deckbuilder."""
This module provides the InputHandler class which encapsulates all input validation
and handling logic. It supports different types of input validation including text,
numbers, confirmations, and multiple choice questions.
"""
from __future__ import annotations
from typing import Any, List, Optional, Union
import inquirer
import logging
import os
from typing import Any, List, Optional, Tuple, Union
from exceptions import InputValidationError
from settings import INPUT_VALIDATION, QUESTION_TYPES
import inquirer.prompt # type: ignore
from settings import (
COLORS, COLOR_ABRV, DEFAULT_MAX_CARD_PRICE,
DEFAULT_MAX_DECK_PRICE, DEFAULT_THEME_TAGS, MONO_COLOR_MAP,
DUAL_COLOR_MAP, TRI_COLOR_MAP, OTHER_COLOR_MAP
)
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):
os.makedirs('logs')
from exceptions import (
CommanderColorError,
CommanderStatsError,
CommanderTagError,
CommanderThemeError,
CommanderTypeError,
DeckBuilderError,
EmptyInputError,
InvalidNumberError,
InvalidQuestionTypeError,
MaxAttemptsError,
PriceError,
PriceLimitError,
PriceValidationError
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('logs/input_handlers.log', mode='a', encoding='utf-8')
]
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class InputHandler:
"""Handles input validation and user interaction.
"""Handles user input operations with validation and error handling.
This class provides methods for validating different types of user input
and handling user interaction through questionnaires. It uses constants
from settings.py for validation messages and configuration.
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.
@ -42,171 +76,367 @@ class InputHandler:
result: Text input to validate
Returns:
bool: True if text is not empty after stripping whitespace
True if text is not empty after stripping whitespace
Raises:
InputValidationError: If text validation fails
EmptyInputError: If input is empty or whitespace only
"""
try:
if not result or not result.strip():
raise InputValidationError(
INPUT_VALIDATION['default_text_message'],
'text',
'Input cannot be empty'
)
return True
except Exception as e:
raise InputValidationError(
str(e),
'text',
'Unexpected error during text validation'
)
def validate_number(self, result: str) -> Optional[float]:
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:
float | None: Converted float value or None if invalid
Converted float value
Raises:
InputValidationError: If number validation fails
InvalidNumberError: If input cannot be converted to float
"""
try:
if not result:
raise InputValidationError(
INPUT_VALIDATION['default_number_message'],
'number',
'Input cannot be empty'
)
return float(result)
except ValueError:
raise InputValidationError(
INPUT_VALIDATION['default_number_message'],
'number',
'Input must be a valid number'
)
except Exception as e:
raise InputValidationError(
str(e),
'number',
'Unexpected error during number validation'
)
except (ValueError, TypeError):
raise InvalidNumberError(result)
def validate_confirm(self, result: Any) -> bool:
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: Confirmation input to validate
result: Boolean confirmation input
Returns:
bool: True for positive confirmation, False otherwise
Raises:
InputValidationError: If confirmation validation fails
The boolean input value
"""
try:
if isinstance(result, bool):
return result
if isinstance(result, str):
result = result.lower().strip()
if result in ('y', 'yes', 'true', '1'):
return True
if result in ('n', 'no', 'false', '0'):
return False
raise InputValidationError(
INPUT_VALIDATION['default_confirm_message'],
'confirm',
'Invalid confirmation response'
)
except InputValidationError:
raise
except Exception as e:
raise InputValidationError(
str(e),
'confirm',
'Unexpected error during confirmation validation'
)
return bool(result)
def questionnaire(
self,
question_type: str,
default_value: Union[str, bool, float] = '',
choices_list: List[str] = []
) -> Union[str, bool, float]:
"""Present questions to user and validate input.
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:
Union[str, bool, float]: Validated user input
Validated user input of appropriate type
Raises:
InputValidationError: If input validation fails
ValueError: If question type is not supported
InvalidQuestionTypeError: If question_type is not supported
MaxAttemptsError: If maximum retry attempts are exceeded
"""
if question_type not in QUESTION_TYPES:
raise ValueError(f"Unsupported question type: {question_type}")
attempts = 0
while attempts < INPUT_VALIDATION['max_attempts']:
while attempts < self.max_attempts:
try:
if question_type == 'Text':
question = [inquirer.Text('text')]
question = [
inquirer.Text(
'text',
message=message or 'Enter text',
default=default_value or self.default_text
)
]
result = inquirer.prompt(question)['text']
if self.validate_text(result):
return result
elif question_type == 'Price':
question = [
inquirer.Text(
'price',
message=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 price
elif question_type == 'Number':
question = [inquirer.Text('number', default=str(default_value))]
question = [
inquirer.Text(
'number',
message=message or 'Enter number',
default=str(default_value or self.default_number)
)
]
result = inquirer.prompt(question)['number']
validated = self.validate_number(result)
if validated is not None:
return validated
return self.validate_number(result)
elif question_type == 'Confirm':
question = [inquirer.Confirm('confirm', default=default_value)]
question = [
inquirer.Confirm(
'confirm',
message=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 InputValidationError(
INPUT_VALIDATION['default_choice_message'],
'choice',
'No choices provided'
)
raise ValueError("Choices list cannot be empty for Choice type")
question = [
inquirer.List('selection',
inquirer.List(
'selection',
message=message or 'Select an option',
choices=choices_list,
carousel=True)
carousel=True
)
]
return inquirer.prompt(question)['selection']
except InputValidationError as e:
else:
raise InvalidQuestionTypeError(question_type)
except DeckBuilderError as e:
logger.warning(f"Input validation failed: {e}")
attempts += 1
if attempts >= INPUT_VALIDATION['max_attempts']:
raise InputValidationError(
"Maximum input attempts reached",
question_type,
str(e)
if attempts >= self.max_attempts:
raise MaxAttemptsError(
self.max_attempts,
question_type.lower(),
{"last_error": str(e)}
)
logger.warning(f"Invalid input ({attempts}/{INPUT_VALIDATION['max_attempts']}): {str(e)}")
except Exception as e:
raise InputValidationError(
str(e),
question_type,
'Unexpected error during questionnaire'
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"
)
raise InputValidationError(
"Maximum input attempts reached",
question_type,
"Failed to get valid input"
)
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