mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
445 lines
No EOL
15 KiB
Python
445 lines
No EOL
15 KiB
Python
"""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 |