mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
Moved the builder, tagger, and setup modules into their own folders, along with constants to help provide better clarity and readability. Additionally added a missing call for the tag_for_artifcact_triggers() function
This commit is contained in:
parent
3a5beebfe2
commit
dbbc8bc66e
20 changed files with 1525 additions and 1737 deletions
445
code/input_handler.py
Normal file
445
code/input_handler.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue