mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
Merge pull request #3 from mwisnowski/origin/refactor_deck_builder
Origin/refactor deck builder
This commit is contained in:
commit
3fc3c584a4
11 changed files with 7254 additions and 2371 deletions
1642
builder_utils.py
Normal file
1642
builder_utils.py
Normal file
File diff suppressed because it is too large
Load diff
3494
deck_builder.py
3494
deck_builder.py
File diff suppressed because it is too large
Load diff
1406
exceptions.py
1406
exceptions.py
File diff suppressed because it is too large
Load diff
443
input_handler.py
Normal file
443
input_handler.py
Normal file
|
@ -0,0 +1,443 @@
|
||||||
|
"""Input handling and validation module for MTG Python Deckbuilder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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
|
34
main.py
34
main.py
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NoReturn, Optional
|
from typing import NoReturn, Optional
|
||||||
|
|
||||||
|
@ -21,15 +22,19 @@ MTG Python Deckbuilder. It handles menu display, user input processing, and
|
||||||
routing to different application features like setup, deck building, card info
|
routing to different application features like setup, deck building, card info
|
||||||
lookup and CSV file tagging.
|
lookup and CSV file tagging.
|
||||||
"""
|
"""
|
||||||
# Configure logging
|
# Create logs directory if it doesn't exist
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.makedirs('logs')
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.StreamHandler(),
|
logging.StreamHandler(),
|
||||||
logging.FileHandler('main.log', mode='w')
|
logging.FileHandler('logs/main.log', mode='a', encoding='utf-8')
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Menu constants
|
# Menu constants
|
||||||
MENU_SETUP = 'Setup'
|
MENU_SETUP = 'Setup'
|
||||||
|
@ -62,8 +67,9 @@ def get_menu_choice() -> Optional[str]:
|
||||||
answer = inquirer.prompt(question) # type: ignore
|
answer = inquirer.prompt(question) # type: ignore
|
||||||
return answer['menu'] if answer else None
|
return answer['menu'] if answer else None
|
||||||
except (KeyError, TypeError) as e:
|
except (KeyError, TypeError) as e:
|
||||||
logging.error(f"Error getting menu choice: {e}")
|
logger.error(f"Error getting menu choice: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def handle_card_info() -> None:
|
def handle_card_info() -> None:
|
||||||
"""Handle the card info menu option with proper error handling.
|
"""Handle the card info menu option with proper error handling.
|
||||||
|
|
||||||
|
@ -91,12 +97,13 @@ def handle_card_info() -> None:
|
||||||
if not answer or not answer['continue']:
|
if not answer or not answer['continue']:
|
||||||
break
|
break
|
||||||
except (KeyError, TypeError) as e:
|
except (KeyError, TypeError) as e:
|
||||||
logging.error(f"Error in card info continuation prompt: {e}")
|
logger.error(f"Error in card info continuation prompt: {e}")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error in card info handling: {e}")
|
logger.error(f"Error in card info handling: {e}")
|
||||||
|
|
||||||
def run_menu() -> NoReturn:
|
def run_menu() -> NoReturn:
|
||||||
"""Main menu loop with improved error handling and logging.
|
"""Main menu loop with improved error handling and logger.
|
||||||
|
|
||||||
Provides the main application loop that displays the menu and handles user selections.
|
Provides the main application loop that displays the menu and handles user selections.
|
||||||
Creates required directories, processes menu choices, and handles errors gracefully.
|
Creates required directories, processes menu choices, and handles errors gracefully.
|
||||||
|
@ -117,7 +124,7 @@ def run_menu() -> NoReturn:
|
||||||
4. Tag CSV Files
|
4. Tag CSV Files
|
||||||
5. Quit
|
5. Quit
|
||||||
"""
|
"""
|
||||||
logging.info("Starting MTG Python Deckbuilder")
|
logger.info("Starting MTG Python Deckbuilder")
|
||||||
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
@ -126,29 +133,30 @@ def run_menu() -> NoReturn:
|
||||||
choice = get_menu_choice()
|
choice = get_menu_choice()
|
||||||
|
|
||||||
if choice is None:
|
if choice is None:
|
||||||
logging.info("Menu operation cancelled")
|
logger.info("Menu operation cancelled")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logging.info(f"User selected: {choice}")
|
logger.info(f"User selected: {choice}")
|
||||||
|
|
||||||
match choice:
|
match choice:
|
||||||
case 'Setup':
|
case 'Setup':
|
||||||
setup.setup()
|
setup.setup()
|
||||||
tagger.run_tagging()
|
tagger.run_tagging()
|
||||||
case 'Build a Deck':
|
case 'Build a Deck':
|
||||||
logging.info("Deck building not yet implemented")
|
logger.info("Deck building not yet implemented")
|
||||||
print('Deck building not yet implemented')
|
print('Deck building not yet implemented')
|
||||||
case 'Get Card Info':
|
case 'Get Card Info':
|
||||||
handle_card_info()
|
handle_card_info()
|
||||||
case 'Tag CSV Files':
|
case 'Tag CSV Files':
|
||||||
tagger.run_tagging()
|
tagger.run_tagging()
|
||||||
case 'Quit':
|
case 'Quit':
|
||||||
logging.info("Exiting application")
|
logger.info("Exiting application")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
case _:
|
case _:
|
||||||
logging.warning(f"Invalid menu choice: {choice}")
|
logger.warning(f"Invalid menu choice: {choice}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Unexpected error in main menu: {e}")
|
logger.error(f"Unexpected error in main menu: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_menu()
|
run_menu()
|
207
price_check.py
Normal file
207
price_check.py
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
"""Price checking functionality for MTG Python Deckbuilder.
|
||||||
|
|
||||||
|
This module provides functionality to check card prices using the Scryfall API
|
||||||
|
through the scrython library. It includes caching and error handling for reliable
|
||||||
|
price lookups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Standard library imports
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
# Third-party imports
|
||||||
|
import scrython
|
||||||
|
from scrython.cards import Named as ScryfallCard
|
||||||
|
|
||||||
|
# Local imports
|
||||||
|
from exceptions import (
|
||||||
|
PriceAPIError,
|
||||||
|
PriceLimitError,
|
||||||
|
PriceTimeoutError,
|
||||||
|
PriceValidationError
|
||||||
|
)
|
||||||
|
from settings import (
|
||||||
|
BATCH_PRICE_CHECK_SIZE,
|
||||||
|
DEFAULT_MAX_CARD_PRICE,
|
||||||
|
DEFAULT_MAX_DECK_PRICE,
|
||||||
|
DEFAULT_PRICE_DELAY,
|
||||||
|
MAX_PRICE_CHECK_ATTEMPTS,
|
||||||
|
PRICE_CACHE_SIZE,
|
||||||
|
PRICE_CHECK_TIMEOUT,
|
||||||
|
PRICE_TOLERANCE_MULTIPLIER
|
||||||
|
)
|
||||||
|
from type_definitions import PriceCache
|
||||||
|
|
||||||
|
class PriceChecker:
|
||||||
|
"""Class for handling MTG card price checking and validation.
|
||||||
|
|
||||||
|
This class provides functionality for checking card prices via the Scryfall API,
|
||||||
|
validating prices against thresholds, and managing price caching.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
price_cache (Dict[str, float]): Cache of card prices
|
||||||
|
max_card_price (float): Maximum allowed price per card
|
||||||
|
max_deck_price (float): Maximum allowed total deck price
|
||||||
|
current_deck_price (float): Current total price of the deck
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_card_price: float = DEFAULT_MAX_CARD_PRICE,
|
||||||
|
max_deck_price: float = DEFAULT_MAX_DECK_PRICE
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the PriceChecker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_card_price: Maximum allowed price per card
|
||||||
|
max_deck_price: Maximum allowed total deck price
|
||||||
|
"""
|
||||||
|
self.price_cache: PriceCache = {}
|
||||||
|
self.max_card_price: float = max_card_price
|
||||||
|
self.max_deck_price: float = max_deck_price
|
||||||
|
self.current_deck_price: float = 0.0
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=PRICE_CACHE_SIZE)
|
||||||
|
def get_card_price(self, card_name: str, attempts: int = 0) -> float:
|
||||||
|
"""Get the price of a card with caching and retry logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_name: Name of the card to check
|
||||||
|
attempts: Current number of retry attempts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Float price of the card in USD
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PriceAPIError: If price lookup fails after max attempts
|
||||||
|
PriceTimeoutError: If request times out
|
||||||
|
PriceValidationError: If received price data is invalid
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if card_name in self.price_cache:
|
||||||
|
return self.price_cache[card_name]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add delay between API calls
|
||||||
|
time.sleep(DEFAULT_PRICE_DELAY)
|
||||||
|
|
||||||
|
# Make API request with type hints
|
||||||
|
card: ScryfallCard = scrython.cards.Named(fuzzy=card_name, timeout=PRICE_CHECK_TIMEOUT)
|
||||||
|
price: Optional[str] = card.prices('usd')
|
||||||
|
|
||||||
|
# Handle None or empty string cases
|
||||||
|
if price is None or price == "":
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Validate and cache price
|
||||||
|
if isinstance(price, (int, float, str)):
|
||||||
|
try:
|
||||||
|
# Convert string or numeric price to float
|
||||||
|
price_float = float(price)
|
||||||
|
self.price_cache[card_name] = price_float
|
||||||
|
return price_float
|
||||||
|
except ValueError:
|
||||||
|
raise PriceValidationError(card_name, str(price))
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
except scrython.foundation.ScryfallError as e:
|
||||||
|
if attempts < MAX_PRICE_CHECK_ATTEMPTS:
|
||||||
|
logging.warning(f"Retrying price check for {card_name} (attempt {attempts + 1})")
|
||||||
|
return self.get_card_price(card_name, attempts + 1)
|
||||||
|
raise PriceAPIError(card_name, {"error": str(e)})
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
raise PriceTimeoutError(card_name, PRICE_CHECK_TIMEOUT)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if attempts < MAX_PRICE_CHECK_ATTEMPTS:
|
||||||
|
logging.warning(f"Unexpected error checking price for {card_name}, retrying")
|
||||||
|
return self.get_card_price(card_name, attempts + 1)
|
||||||
|
raise PriceAPIError(card_name, {"error": str(e)})
|
||||||
|
|
||||||
|
def validate_card_price(self, card_name: str, price: float) -> bool | None:
|
||||||
|
"""Validate if a card's price is within allowed limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_name: Name of the card to validate
|
||||||
|
price: Price to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if price is valid, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PriceLimitError: If price exceeds maximum allowed
|
||||||
|
"""
|
||||||
|
if price > self.max_card_price * PRICE_TOLERANCE_MULTIPLIER:
|
||||||
|
raise PriceLimitError(card_name, price, self.max_card_price)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_deck_price(self) -> bool | None:
|
||||||
|
"""Validate if the current deck price is within allowed limits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deck price is valid, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PriceLimitError: If deck price exceeds maximum allowed
|
||||||
|
"""
|
||||||
|
if self.current_deck_price > self.max_deck_price * PRICE_TOLERANCE_MULTIPLIER:
|
||||||
|
raise PriceLimitError("deck", self.current_deck_price, self.max_deck_price)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def batch_check_prices(self, card_names: List[str]) -> Dict[str, float]:
|
||||||
|
"""Check prices for multiple cards efficiently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_names: List of card names to check prices for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping card names to their prices
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PriceAPIError: If batch price lookup fails
|
||||||
|
"""
|
||||||
|
results: Dict[str, float] = {}
|
||||||
|
errors: List[Tuple[str, Exception]] = []
|
||||||
|
|
||||||
|
# Process in batches
|
||||||
|
for i in range(0, len(card_names), BATCH_PRICE_CHECK_SIZE):
|
||||||
|
batch = card_names[i:i + BATCH_PRICE_CHECK_SIZE]
|
||||||
|
|
||||||
|
for card_name in batch:
|
||||||
|
try:
|
||||||
|
price = self.get_card_price(card_name)
|
||||||
|
results[card_name] = price
|
||||||
|
except Exception as e:
|
||||||
|
errors.append((card_name, e))
|
||||||
|
logging.error(f"Error checking price for {card_name}: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
logging.warning(f"Failed to get prices for {len(errors)} cards")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def update_deck_price(self, price: float) -> None:
|
||||||
|
"""Update the current deck price.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price: Price to add to current deck total
|
||||||
|
"""
|
||||||
|
self.current_deck_price += price
|
||||||
|
logging.debug(f"Updated deck price to ${self.current_deck_price:.2f}")
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the price cache."""
|
||||||
|
self.price_cache.clear()
|
||||||
|
self.get_card_price.cache_clear()
|
||||||
|
logging.info("Price cache cleared")
|
541
settings.py
541
settings.py
|
@ -1,15 +1,245 @@
|
||||||
"""Constants and configuration settings for the MTG Python Deckbuilder.
|
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
|
||||||
|
import ast
|
||||||
|
|
||||||
This module contains all the constant values and configuration settings used throughout
|
# Commander selection configuration
|
||||||
the application for card filtering, processing, and analysis. Constants are organized
|
# Format string for displaying duplicate cards in deck lists
|
||||||
into logical sections with clear documentation.
|
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
|
||||||
|
|
||||||
All constants are properly typed according to PEP 484 standards to ensure type safety
|
COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv'
|
||||||
and enable static type checking with mypy.
|
FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching
|
||||||
"""
|
MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
|
||||||
|
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
|
||||||
|
# Commander-related constants
|
||||||
|
COMMANDER_POWER_DEFAULT: Final[int] = 0
|
||||||
|
COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0
|
||||||
|
COMMANDER_MANA_VALUE_DEFAULT: Final[int] = 0
|
||||||
|
COMMANDER_TYPE_DEFAULT: Final[str] = ''
|
||||||
|
COMMANDER_TEXT_DEFAULT: Final[str] = ''
|
||||||
|
COMMANDER_MANA_COST_DEFAULT: Final[str] = ''
|
||||||
|
COMMANDER_COLOR_IDENTITY_DEFAULT: Final[str] = ''
|
||||||
|
COMMANDER_COLORS_DEFAULT: Final[List[str]] = []
|
||||||
|
COMMANDER_CREATURE_TYPES_DEFAULT: Final[str] = ''
|
||||||
|
COMMANDER_TAGS_DEFAULT: Final[List[str]] = []
|
||||||
|
COMMANDER_THEMES_DEFAULT: Final[List[str]] = []
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
# Price checking configuration
|
||||||
|
DEFAULT_PRICE_DELAY: Final[float] = 0.1 # Delay between price checks in seconds
|
||||||
|
MAX_PRICE_CHECK_ATTEMPTS: Final[int] = 3 # Maximum attempts for price checking
|
||||||
|
PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache
|
||||||
|
PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
|
||||||
|
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
|
||||||
|
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
|
||||||
|
|
||||||
|
# Deck composition defaults
|
||||||
|
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
|
||||||
|
DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count
|
||||||
|
DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
|
||||||
|
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
|
||||||
|
DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color
|
||||||
|
|
||||||
|
# Miscellaneous land configuration
|
||||||
|
MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add
|
||||||
|
MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add
|
||||||
|
MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from
|
||||||
|
|
||||||
|
# Default fetch land count
|
||||||
|
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
|
||||||
|
|
||||||
|
# Basic land mappings
|
||||||
|
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
|
||||||
|
'W': 'Plains',
|
||||||
|
'U': 'Island',
|
||||||
|
'B': 'Swamp',
|
||||||
|
'R': 'Mountain',
|
||||||
|
'G': 'Forest',
|
||||||
|
'C': 'Wastes'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dual land type mappings
|
||||||
|
DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = {
|
||||||
|
'azorius': 'Plains Island',
|
||||||
|
'dimir': 'Island Swamp',
|
||||||
|
'rakdos': 'Swamp Mountain',
|
||||||
|
'gruul': 'Mountain Forest',
|
||||||
|
'selesnya': 'Forest Plains',
|
||||||
|
'orzhov': 'Plains Swamp',
|
||||||
|
'golgari': 'Swamp Forest',
|
||||||
|
'simic': 'Forest Island',
|
||||||
|
'izzet': 'Island Mountain',
|
||||||
|
'boros': 'Mountain Plains'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Triple land type mappings
|
||||||
|
TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = {
|
||||||
|
'bant': 'Forest Plains Island',
|
||||||
|
'esper': 'Plains Island Swamp',
|
||||||
|
'grixis': 'Island Swamp Mountain',
|
||||||
|
'jund': 'Swamp Mountain Forest',
|
||||||
|
'naya': 'Mountain Forest Plains',
|
||||||
|
'mardu': 'Mountain Plains Swamp',
|
||||||
|
'abzan': 'Plains Swamp Forest',
|
||||||
|
'sultai': 'Swamp Forest Island',
|
||||||
|
'temur': 'Forest Island Mountain',
|
||||||
|
'jeska': 'Island Mountain Plains'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default preference for including dual lands
|
||||||
|
DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True
|
||||||
|
|
||||||
|
# Default preference for including triple lands
|
||||||
|
DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True
|
||||||
|
|
||||||
|
|
||||||
|
SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
|
||||||
|
'W': 'Snow-Covered Plains',
|
||||||
|
'U': 'Snow-Covered Island',
|
||||||
|
'B': 'Snow-Covered Swamp',
|
||||||
|
'G': 'Snow-Covered Forest'
|
||||||
|
}
|
||||||
|
|
||||||
|
SNOW_BASIC_LAND_MAPPING: Final[Dict[str, str]] = {
|
||||||
|
'W': 'Snow-Covered Plains',
|
||||||
|
'U': 'Snow-Covered Island',
|
||||||
|
'B': 'Snow-Covered Swamp',
|
||||||
|
'R': 'Snow-Covered Mountain',
|
||||||
|
'G': 'Snow-Covered Forest',
|
||||||
|
'C': 'Wastes' # Note: No snow-covered version exists for Wastes
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generic fetch lands list
|
||||||
|
GENERIC_FETCH_LANDS: Final[List[str]] = [
|
||||||
|
'Evolving Wilds',
|
||||||
|
'Terramorphic Expanse',
|
||||||
|
'Shire Terrace',
|
||||||
|
'Escape Tunnel',
|
||||||
|
'Promising Vein',
|
||||||
|
'Myriad Landscape',
|
||||||
|
'Fabled Passage',
|
||||||
|
'Terminal Moraine',
|
||||||
|
'Prismatic Vista'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Kindred land constants
|
||||||
|
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
|
||||||
|
{
|
||||||
|
'name': 'Path of Ancestry',
|
||||||
|
'type': 'Land'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Three Tree City',
|
||||||
|
'type': 'Legendary Land'
|
||||||
|
},
|
||||||
|
{'name': 'Cavern of Souls', 'type': 'Land'}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Color-specific fetch land mappings
|
||||||
|
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
|
||||||
|
'W': [
|
||||||
|
'Flooded Strand',
|
||||||
|
'Windswept Heath',
|
||||||
|
'Marsh Flats',
|
||||||
|
'Arid Mesa',
|
||||||
|
'Brokers Hideout',
|
||||||
|
'Obscura Storefront',
|
||||||
|
'Cabaretti Courtyard'
|
||||||
|
],
|
||||||
|
'U': [
|
||||||
|
'Flooded Strand',
|
||||||
|
'Polluted Delta',
|
||||||
|
'Scalding Tarn',
|
||||||
|
'Misty Rainforest',
|
||||||
|
'Brokers Hideout',
|
||||||
|
'Obscura Storefront',
|
||||||
|
'Maestros Theater'
|
||||||
|
],
|
||||||
|
'B': [
|
||||||
|
'Polluted Delta',
|
||||||
|
'Bloodstained Mire',
|
||||||
|
'Marsh Flats',
|
||||||
|
'Verdant Catacombs',
|
||||||
|
'Obscura Storefront',
|
||||||
|
'Maestros Theater',
|
||||||
|
'Riveteers Overlook'
|
||||||
|
],
|
||||||
|
'R': [
|
||||||
|
'Bloodstained Mire',
|
||||||
|
'Wooded Foothills',
|
||||||
|
'Scalding Tarn',
|
||||||
|
'Arid Mesa',
|
||||||
|
'Maestros Theater',
|
||||||
|
'Riveteers Overlook',
|
||||||
|
'Cabaretti Courtyard'
|
||||||
|
],
|
||||||
|
'G': [
|
||||||
|
'Wooded Foothills',
|
||||||
|
'Windswept Heath',
|
||||||
|
'Verdant Catacombs',
|
||||||
|
'Misty Rainforest',
|
||||||
|
'Brokers Hideout',
|
||||||
|
'Riveteers Overlook',
|
||||||
|
'Cabaretti Courtyard'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
|
||||||
|
DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
|
||||||
|
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes
|
||||||
|
|
||||||
|
# Staple land conditions mapping
|
||||||
|
STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bool]]] = {
|
||||||
|
'Reliquary Tower': lambda commander_tags, colors, commander_power: True, # Always include
|
||||||
|
'Ash Barrens': lambda commander_tags, colors, commander_power: 'Landfall' not in commander_tags,
|
||||||
|
'Command Tower': lambda commander_tags, colors, commander_power: len(colors) > 1,
|
||||||
|
'Exotic Orchard': lambda commander_tags, colors, commander_power: len(colors) > 1,
|
||||||
|
'War Room': lambda commander_tags, colors, commander_power: len(colors) <= 2,
|
||||||
|
'Rogue\'s Passage': lambda commander_tags, colors, commander_power: commander_power >= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces
|
||||||
|
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells
|
||||||
|
|
||||||
|
# Deck composition prompts
|
||||||
|
DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
|
||||||
|
'ramp': 'Enter desired number of ramp pieces (default: 8):',
|
||||||
|
'lands': 'Enter desired number of total lands (default: 35):',
|
||||||
|
'basic_lands': 'Enter minimum number of basic lands (default: 20):',
|
||||||
|
'creatures': 'Enter desired number of creatures (default: 25):',
|
||||||
|
'removal': 'Enter desired number of spot removal spells (default: 10):',
|
||||||
|
'wipes': 'Enter desired number of board wipes (default: 2):',
|
||||||
|
'card_advantage': 'Enter desired number of card advantage pieces (default: 10):',
|
||||||
|
'protection': 'Enter desired number of protection spells (default: 8):',
|
||||||
|
'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):',
|
||||||
|
'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):'
|
||||||
|
}
|
||||||
|
DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price
|
||||||
|
BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch
|
||||||
|
# Constants for input validation
|
||||||
|
|
||||||
|
# Type aliases
|
||||||
|
CardName = str
|
||||||
|
CardType = str
|
||||||
|
ThemeTag = str
|
||||||
|
ColorIdentity = str
|
||||||
|
ColorList = List[str]
|
||||||
|
ColorInfo = Tuple[str, List[str], List[str]]
|
||||||
|
|
||||||
|
INPUT_VALIDATION = {
|
||||||
|
'max_attempts': 3,
|
||||||
|
'default_text_message': 'Please enter a valid text response.',
|
||||||
|
'default_number_message': 'Please enter a valid number.',
|
||||||
|
'default_confirm_message': 'Please enter Y/N or Yes/No.',
|
||||||
|
'default_choice_message': 'Please select a valid option from the list.'
|
||||||
|
}
|
||||||
|
|
||||||
|
QUESTION_TYPES = [
|
||||||
|
'Text',
|
||||||
|
'Number',
|
||||||
|
'Confirm',
|
||||||
|
'Choice'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Card type constants
|
||||||
artifact_tokens: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
artifact_tokens: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||||
'Junk','Map','Powerstone', 'Treasure']
|
'Junk','Map','Powerstone', 'Treasure']
|
||||||
|
|
||||||
|
@ -35,8 +265,13 @@ banned_cards = [# in commander
|
||||||
'Jihad', 'Imprison', 'Crusade'
|
'Jihad', 'Imprison', 'Crusade'
|
||||||
]
|
]
|
||||||
|
|
||||||
basic_lands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
|
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
|
||||||
basic_lands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
|
|
||||||
|
# Constants for land removal functionality
|
||||||
|
LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
|
||||||
|
|
||||||
|
# Protected lands that cannot be removed during land removal process
|
||||||
|
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
|
||||||
|
|
||||||
# Constants for lands matter functionality
|
# Constants for lands matter functionality
|
||||||
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
|
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
|
||||||
|
@ -239,6 +474,7 @@ ARISTOCRAT_EXCLUSION_PATTERNS = [
|
||||||
'from your library',
|
'from your library',
|
||||||
'into your hand'
|
'into your hand'
|
||||||
]
|
]
|
||||||
|
|
||||||
STAX_TEXT_PATTERNS = [
|
STAX_TEXT_PATTERNS = [
|
||||||
'an opponent controls'
|
'an opponent controls'
|
||||||
'can\'t attack',
|
'can\'t attack',
|
||||||
|
@ -308,7 +544,7 @@ REMOVAL_TEXT_PATTERNS = [
|
||||||
'returns target.*to.*hand'
|
'returns target.*to.*hand'
|
||||||
]
|
]
|
||||||
|
|
||||||
REMOVAL_SPECIFIC_CARDS = [] # type: list
|
REMOVAL_SPECIFIC_CARDS = ['from.*graveyard.*hand'] # type: list
|
||||||
|
|
||||||
REMOVAL_EXCLUSION_PATTERNS = [] # type: list
|
REMOVAL_EXCLUSION_PATTERNS = [] # type: list
|
||||||
|
|
||||||
|
@ -512,10 +748,31 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [
|
||||||
'that player\'s library'
|
'that player\'s library'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
|
||||||
card_types = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
|
|
||||||
'Kindred', 'Dungeon', 'Battle']
|
'Kindred', 'Dungeon', 'Battle']
|
||||||
|
|
||||||
|
# Card type sorting order for organizing libraries
|
||||||
|
# This constant defines the order in which different card types should be sorted
|
||||||
|
# when organizing a deck library. The order is designed to group cards logically,
|
||||||
|
# starting with Planeswalkers and ending with Lands.
|
||||||
|
CARD_TYPE_SORT_ORDER: Final[List[str]] = [
|
||||||
|
'Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery',
|
||||||
|
'Artifact', 'Enchantment', 'Land'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Default counts for each card type
|
||||||
|
CARD_TYPE_COUNT_DEFAULTS: Final[Dict[str, int]] = {
|
||||||
|
'Artifact': 0,
|
||||||
|
'Battle': 0,
|
||||||
|
'Creature': 0,
|
||||||
|
'Enchantment': 0,
|
||||||
|
'Instant': 0,
|
||||||
|
'Kindred': 0,
|
||||||
|
'Land': 0,
|
||||||
|
'Planeswalker': 0,
|
||||||
|
'Sorcery': 0
|
||||||
|
}
|
||||||
|
|
||||||
# Mapping of card types to their corresponding theme tags
|
# Mapping of card types to their corresponding theme tags
|
||||||
TYPE_TAG_MAPPING = {
|
TYPE_TAG_MAPPING = {
|
||||||
'Artifact': ['Artifacts Matter'],
|
'Artifact': ['Artifacts Matter'],
|
||||||
|
@ -530,9 +787,111 @@ TYPE_TAG_MAPPING = {
|
||||||
'Sorcery': ['Spells Matter', 'Spellslinger']
|
'Sorcery': ['Spells Matter', 'Spellslinger']
|
||||||
}
|
}
|
||||||
|
|
||||||
csv_directory = 'csv_files'
|
CSV_DIRECTORY = 'csv_files'
|
||||||
|
|
||||||
colors = ['colorless', 'white', 'blue', 'black', 'red', 'green',
|
# Color identity constants and mappings
|
||||||
|
|
||||||
|
# Basic mana colors
|
||||||
|
MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G']
|
||||||
|
|
||||||
|
# Mana pip patterns for each color
|
||||||
|
MANA_PIP_PATTERNS: Final[Dict[str, str]] = {
|
||||||
|
color: f'{{{color}}}' for color in MANA_COLORS
|
||||||
|
}
|
||||||
|
|
||||||
|
MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = {
|
||||||
|
'COLORLESS': ('Colorless', ['colorless']),
|
||||||
|
'W': ('White', ['colorless', 'white']),
|
||||||
|
'U': ('Blue', ['colorless', 'blue']),
|
||||||
|
'B': ('Black', ['colorless', 'black']),
|
||||||
|
'R': ('Red', ['colorless', 'red']),
|
||||||
|
'G': ('Green', ['colorless', 'green'])
|
||||||
|
}
|
||||||
|
|
||||||
|
DUAL_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
|
||||||
|
'B, G': ('Golgari: Black/Green', ['B', 'G', 'B, G'], ['colorless', 'black', 'green', 'golgari']),
|
||||||
|
'B, R': ('Rakdos: Black/Red', ['B', 'R', 'B, R'], ['colorless', 'black', 'red', 'rakdos']),
|
||||||
|
'B, U': ('Dimir: Black/Blue', ['B', 'U', 'B, U'], ['colorless', 'black', 'blue', 'dimir']),
|
||||||
|
'B, W': ('Orzhov: Black/White', ['B', 'W', 'B, W'], ['colorless', 'black', 'white', 'orzhov']),
|
||||||
|
'G, R': ('Gruul: Green/Red', ['G', 'R', 'G, R'], ['colorless', 'green', 'red', 'gruul']),
|
||||||
|
'G, U': ('Simic: Green/Blue', ['G', 'U', 'G, U'], ['colorless', 'green', 'blue', 'simic']),
|
||||||
|
'G, W': ('Selesnya: Green/White', ['G', 'W', 'G, W'], ['colorless', 'green', 'white', 'selesnya']),
|
||||||
|
'R, U': ('Izzet: Blue/Red', ['U', 'R', 'U, R'], ['colorless', 'blue', 'red', 'izzet']),
|
||||||
|
'U, W': ('Azorius: Blue/White', ['U', 'W', 'U, W'], ['colorless', 'blue', 'white', 'azorius']),
|
||||||
|
'R, W': ('Boros: Red/White', ['R', 'W', 'R, W'], ['colorless', 'red', 'white', 'boros'])
|
||||||
|
}
|
||||||
|
|
||||||
|
TRI_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
|
||||||
|
'B, G, U': ('Sultai: Black/Blue/Green', ['B', 'G', 'U', 'B, G', 'B, U', 'G, U', 'B, G, U'],
|
||||||
|
['colorless', 'black', 'blue', 'green', 'dimir', 'golgari', 'simic', 'sultai']),
|
||||||
|
'B, G, R': ('Jund: Black/Red/Green', ['B', 'G', 'R', 'B, G', 'B, R', 'G, R', 'B, G, R'],
|
||||||
|
['colorless', 'black', 'green', 'red', 'golgari', 'rakdos', 'gruul', 'jund']),
|
||||||
|
'B, G, W': ('Abzan: Black/Green/White', ['B', 'G', 'W', 'B, G', 'B, W', 'G, W', 'B, G, W'],
|
||||||
|
['colorless', 'black', 'green', 'white', 'golgari', 'orzhov', 'selesnya', 'abzan']),
|
||||||
|
'B, R, U': ('Grixis: Black/Blue/Red', ['B', 'R', 'U', 'B, R', 'B, U', 'R, U', 'B, R, U'],
|
||||||
|
['colorless', 'black', 'blue', 'red', 'dimir', 'rakdos', 'izzet', 'grixis']),
|
||||||
|
'B, R, W': ('Mardu: Black/Red/White', ['B', 'R', 'W', 'B, R', 'B, W', 'R, W', 'B, R, W'],
|
||||||
|
['colorless', 'black', 'red', 'white', 'rakdos', 'orzhov', 'boros', 'mardu']),
|
||||||
|
'B, U, W': ('Esper: Black/Blue/White', ['B', 'U', 'W', 'B, U', 'B, W', 'U, W', 'B, U, W'],
|
||||||
|
['colorless', 'black', 'blue', 'white', 'dimir', 'orzhov', 'azorius', 'esper']),
|
||||||
|
'G, R, U': ('Temur: Blue/Green/Red', ['G', 'R', 'U', 'G, R', 'G, U', 'R, U', 'G, R, U'],
|
||||||
|
['colorless', 'green', 'red', 'blue', 'simic', 'izzet', 'gruul', 'temur']),
|
||||||
|
'G, R, W': ('Naya: Green/Red/White', ['G', 'R', 'W', 'G, R', 'G, W', 'R, W', 'G, R, W'],
|
||||||
|
['colorless', 'green', 'red', 'white', 'gruul', 'selesnya', 'boros', 'naya']),
|
||||||
|
'G, U, W': ('Bant: Blue/Green/White', ['G', 'U', 'W', 'G, U', 'G, W', 'U, W', 'G, U, W'],
|
||||||
|
['colorless', 'green', 'blue', 'white', 'simic', 'azorius', 'selesnya', 'bant']),
|
||||||
|
'R, U, W': ('Jeskai: Blue/Red/White', ['R', 'U', 'W', 'R, U', 'U, W', 'R, W', 'R, U, W'],
|
||||||
|
['colorless', 'blue', 'red', 'white', 'izzet', 'azorius', 'boros', 'jeskai'])
|
||||||
|
}
|
||||||
|
|
||||||
|
OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
|
||||||
|
'B, G, R, U': ('Glint: Black/Blue/Green/Red',
|
||||||
|
['B', 'G', 'R', 'U', 'B, G', 'B, R', 'B, U', 'G, R', 'G, U', 'R, U', 'B, G, R',
|
||||||
|
'B, G, U', 'B, R, U', 'G, R, U', 'B, G, R, U'],
|
||||||
|
['colorless', 'black', 'blue', 'green', 'red', 'golgari', 'rakdos', 'dimir',
|
||||||
|
'gruul', 'simic', 'izzet', 'jund', 'sultai', 'grixis', 'temur', 'glint']),
|
||||||
|
'B, G, R, W': ('Dune: Black/Green/Red/White',
|
||||||
|
['B', 'G', 'R', 'W', 'B, G', 'B, R', 'B, W', 'G, R', 'G, W', 'R, W', 'B, G, R',
|
||||||
|
'B, G, W', 'B, R, W', 'G, R, W', 'B, G, R, W'],
|
||||||
|
['colorless', 'black', 'green', 'red', 'white', 'golgari', 'rakdos', 'orzhov',
|
||||||
|
'gruul', 'selesnya', 'boros', 'jund', 'abzan', 'mardu', 'naya', 'dune']),
|
||||||
|
'B, G, U, W': ('Witch: Black/Blue/Green/White',
|
||||||
|
['B', 'G', 'U', 'W', 'B, G', 'B, U', 'B, W', 'G, U', 'G, W', 'U, W', 'B, G, U',
|
||||||
|
'B, G, W', 'B, U, W', 'G, U, W', 'B, G, U, W'],
|
||||||
|
['colorless', 'black', 'blue', 'green', 'white', 'golgari', 'dimir', 'orzhov',
|
||||||
|
'simic', 'selesnya', 'azorius', 'sultai', 'abzan', 'esper', 'bant', 'witch']),
|
||||||
|
'B, R, U, W': ('Yore: Black/Blue/Red/White',
|
||||||
|
['B', 'R', 'U', 'W', 'B, R', 'B, U', 'B, W', 'R, U', 'R, W', 'U, W', 'B, R, U',
|
||||||
|
'B, R, W', 'B, U, W', 'R, U, W', 'B, R, U, W'],
|
||||||
|
['colorless', 'black', 'blue', 'red', 'white', 'rakdos', 'dimir', 'orzhov',
|
||||||
|
'izzet', 'boros', 'azorius', 'grixis', 'mardu', 'esper', 'jeskai', 'yore']),
|
||||||
|
'G, R, U, W': ('Ink: Blue/Green/Red/White',
|
||||||
|
['G', 'R', 'U', 'W', 'G, R', 'G, U', 'G, W', 'R, U', 'R, W', 'U, W', 'G, R, U',
|
||||||
|
'G, R, W', 'G, U, W', 'R, U, W', 'G, R, U, W'],
|
||||||
|
['colorless', 'blue', 'green', 'red', 'white', 'gruul', 'simic', 'selesnya',
|
||||||
|
'izzet', 'boros', 'azorius', 'temur', 'naya', 'bant', 'jeskai', 'ink']),
|
||||||
|
'B, G, R, U, W': ('WUBRG: All colors',
|
||||||
|
['B', 'G', 'R', 'U', 'W', 'B, G', 'B, R', 'B, U', 'B, W', 'G, R', 'G, U',
|
||||||
|
'G, W', 'R, U', 'R, W', 'U, W', 'B, G, R', 'B, G, U', 'B, G, W', 'B, R, U',
|
||||||
|
'B, R, W', 'B, U, W', 'G, R, U', 'G, R, W', 'G, U, W', 'R, U, W',
|
||||||
|
'B, G, R, U', 'B, G, R, W', 'B, G, U, W', 'B, R, U, W', 'G, R, U, W',
|
||||||
|
'B, G, R, U, W'],
|
||||||
|
['colorless', 'black', 'green', 'red', 'blue', 'white', 'golgari', 'rakdos',
|
||||||
|
'dimir', 'orzhov', 'gruul', 'simic', 'selesnya', 'izzet', 'boros', 'azorius',
|
||||||
|
'jund', 'sultai', 'abzan', 'grixis', 'mardu', 'esper', 'temur', 'naya',
|
||||||
|
'bant', 'jeskai', 'glint', 'dune', 'witch', 'yore', 'ink', 'wubrg'])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Color identity validation patterns
|
||||||
|
COLOR_IDENTITY_PATTERNS: Final[Dict[str, str]] = {
|
||||||
|
'mono': r'^[WUBRG]$',
|
||||||
|
'dual': r'^[WUBRG], [WUBRG]$',
|
||||||
|
'tri': r'^[WUBRG], [WUBRG], [WUBRG]$',
|
||||||
|
'four': r'^[WUBRG], [WUBRG], [WUBRG], [WUBRG]$',
|
||||||
|
'five': r'^[WUBRG], [WUBRG], [WUBRG], [WUBRG], [WUBRG]$'
|
||||||
|
}
|
||||||
|
|
||||||
|
COLORS = ['colorless', 'white', 'blue', 'black', 'red', 'green',
|
||||||
'azorius', 'orzhov', 'selesnya', 'boros', 'dimir',
|
'azorius', 'orzhov', 'selesnya', 'boros', 'dimir',
|
||||||
'simic', 'izzet', 'golgari', 'rakdos', 'gruul',
|
'simic', 'izzet', 'golgari', 'rakdos', 'gruul',
|
||||||
'bant', 'esper', 'grixis', 'jund', 'naya',
|
'bant', 'esper', 'grixis', 'jund', 'naya',
|
||||||
|
@ -673,6 +1032,31 @@ REQUIRED_COLUMNS: List[str] = [
|
||||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Constants for theme weight management and selection
|
||||||
|
|
||||||
|
# Multiplier for initial card pool size during theme-based selection
|
||||||
|
THEME_POOL_SIZE_MULTIPLIER: Final[float] = 2.0
|
||||||
|
|
||||||
|
# Bonus multiplier for cards that match multiple deck themes
|
||||||
|
THEME_PRIORITY_BONUS: Final[float] = 1.2
|
||||||
|
|
||||||
|
# Safety multiplier to avoid overshooting target counts
|
||||||
|
THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9
|
||||||
|
|
||||||
|
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
|
||||||
|
'primary': 1.0,
|
||||||
|
'secondary': 0.6,
|
||||||
|
'tertiary': 0.3,
|
||||||
|
'hidden': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = {
|
||||||
|
'kindred_primary': 1.5, # Boost for Kindred themes as primary
|
||||||
|
'kindred_secondary': 1.3, # Boost for Kindred themes as secondary
|
||||||
|
'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary
|
||||||
|
'theme_synergy': 1.2 # Boost for themes that work well together
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_THEME_TAGS = [
|
DEFAULT_THEME_TAGS = [
|
||||||
'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink',
|
'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink',
|
||||||
'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones',
|
'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones',
|
||||||
|
@ -690,6 +1074,21 @@ COLUMN_ORDER = [
|
||||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PRETAG_COLUMN_ORDER: List[str] = [
|
||||||
|
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||||
|
'manaCost', 'manaValue', 'type', 'text', 'power', 'toughness',
|
||||||
|
'keywords', 'layout', 'side'
|
||||||
|
]
|
||||||
|
|
||||||
|
TAGGED_COLUMN_ORDER: List[str] = [
|
||||||
|
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||||
|
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||||
|
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||||
|
]
|
||||||
|
|
||||||
|
EXCLUDED_CARD_TYPES: List[str] = ['Plane —', 'Conspiracy', 'Vanguard', 'Scheme',
|
||||||
|
'Phenomenon', 'Stickers', 'Attraction', 'Hero',
|
||||||
|
'Contraption']
|
||||||
# Constants for type detection and processing
|
# Constants for type detection and processing
|
||||||
OUTLAW_TYPES = ['Assassin', 'Mercenary', 'Pirate', 'Rogue', 'Warlock']
|
OUTLAW_TYPES = ['Assassin', 'Mercenary', 'Pirate', 'Rogue', 'Warlock']
|
||||||
TYPE_DETECTION_BATCH_SIZE = 1000
|
TYPE_DETECTION_BATCH_SIZE = 1000
|
||||||
|
@ -702,6 +1101,7 @@ AURA_SPECIFIC_CARDS = [
|
||||||
'Ivy, Gleeful Spellthief', # Copies spells that have single target
|
'Ivy, Gleeful Spellthief', # Copies spells that have single target
|
||||||
'Killian, Ink Duelist', # Targetted spell cost reduction
|
'Killian, Ink Duelist', # Targetted spell cost reduction
|
||||||
]
|
]
|
||||||
|
|
||||||
# Equipment-related constants
|
# Equipment-related constants
|
||||||
EQUIPMENT_EXCLUSIONS = [
|
EQUIPMENT_EXCLUSIONS = [
|
||||||
'Bruenor Battlehammer', # Equipment cost reduction
|
'Bruenor Battlehammer', # Equipment cost reduction
|
||||||
|
@ -740,7 +1140,7 @@ EQUIPMENT_TEXT_PATTERNS = [
|
||||||
'unattach', # Equipment removal
|
'unattach', # Equipment removal
|
||||||
'unequip', # Equipment removal
|
'unequip', # Equipment removal
|
||||||
]
|
]
|
||||||
TYPE_DETECTION_BATCH_SIZE = 1000
|
|
||||||
|
|
||||||
# Constants for Voltron strategy
|
# Constants for Voltron strategy
|
||||||
VOLTRON_COMMANDER_CARDS = [
|
VOLTRON_COMMANDER_CARDS = [
|
||||||
|
@ -777,6 +1177,115 @@ VOLTRON_PATTERNS = [
|
||||||
'reconfigure'
|
'reconfigure'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Constants for price checking functionality
|
||||||
|
PRICE_CHECK_CONFIG: Dict[str, float] = {
|
||||||
|
# Maximum number of retry attempts for price checking requests
|
||||||
|
'max_retries': 3,
|
||||||
|
|
||||||
|
# Timeout in seconds for price checking requests
|
||||||
|
'timeout': 0.1,
|
||||||
|
|
||||||
|
# Maximum size of the price check cache
|
||||||
|
'cache_size': 128,
|
||||||
|
|
||||||
|
# Price tolerance factor (e.g., 1.1 means accept prices within 10% difference)
|
||||||
|
'price_tolerance': 1.1
|
||||||
|
}
|
||||||
|
|
||||||
|
# DataFrame processing configuration
|
||||||
|
BATCH_SIZE: Final[int] = 1000 # Number of records to process at once
|
||||||
|
DATAFRAME_BATCH_SIZE: Final[int] = 500 # Batch size for DataFrame operations
|
||||||
|
TRANSFORM_BATCH_SIZE: Final[int] = 250 # Batch size for data transformations
|
||||||
|
CSV_DOWNLOAD_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV downloads
|
||||||
|
PROGRESS_UPDATE_INTERVAL: Final[int] = 100 # Number of records between progress updates
|
||||||
|
|
||||||
|
# DataFrame operation timeouts
|
||||||
|
DATAFRAME_READ_TIMEOUT: Final[int] = 30 # Timeout for DataFrame read operations
|
||||||
|
DATAFRAME_WRITE_TIMEOUT: Final[int] = 30 # Timeout for DataFrame write operations
|
||||||
|
DATAFRAME_TRANSFORM_TIMEOUT: Final[int] = 45 # Timeout for DataFrame transformations
|
||||||
|
DATAFRAME_VALIDATION_TIMEOUT: Final[int] = 20 # Timeout for DataFrame validation
|
||||||
|
|
||||||
|
# DataFrame validation configuration
|
||||||
|
MIN_EDHREC_RANK: int = 0
|
||||||
|
MAX_EDHREC_RANK: int = 100000
|
||||||
|
MIN_MANA_VALUE: int = 0
|
||||||
|
MAX_MANA_VALUE: int = 20
|
||||||
|
|
||||||
|
# DataFrame validation rules
|
||||||
|
DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
|
||||||
|
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
|
||||||
|
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
|
||||||
|
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
|
||||||
|
'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
|
||||||
|
'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
|
||||||
|
'colorIdentity': {'type': ('str', 'object'), 'required': True},
|
||||||
|
'text': {'type': ('str', 'object'), 'required': False}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Card category validation rules
|
||||||
|
CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
|
||||||
|
'power': {'type': ('str', 'int', 'float', 'object'), 'required': True},
|
||||||
|
'toughness': {'type': ('str', 'int', 'float', 'object'), 'required': True},
|
||||||
|
'creatureTypes': {'type': ('list', 'object'), 'required': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
|
||||||
|
'manaCost': {'type': 'str', 'required': True},
|
||||||
|
'text': {'type': 'str', 'required': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
|
||||||
|
'type': {'type': ('str', 'object'), 'required': True},
|
||||||
|
'text': {'type': ('str', 'object'), 'required': False}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Column mapping configurations
|
||||||
|
DATAFRAME_COLUMN_MAPS: Final[Dict[str, Dict[str, str]]] = {
|
||||||
|
'creature': {
|
||||||
|
'name': 'Card Name',
|
||||||
|
'type': 'Card Type',
|
||||||
|
'manaCost': 'Mana Cost',
|
||||||
|
'manaValue': 'Mana Value',
|
||||||
|
'power': 'Power',
|
||||||
|
'toughness': 'Toughness'
|
||||||
|
},
|
||||||
|
'spell': {
|
||||||
|
'name': 'Card Name',
|
||||||
|
'type': 'Card Type',
|
||||||
|
'manaCost': 'Mana Cost',
|
||||||
|
'manaValue': 'Mana Value'
|
||||||
|
},
|
||||||
|
'land': {
|
||||||
|
'name': 'Card Name',
|
||||||
|
'type': 'Card Type'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Required DataFrame columns
|
||||||
|
DATAFRAME_REQUIRED_COLUMNS: Final[List[str]] = [
|
||||||
|
'name', 'type', 'colorIdentity', 'manaValue', 'text',
|
||||||
|
'edhrecRank', 'themeTags', 'keywords'
|
||||||
|
]
|
||||||
|
|
||||||
|
# CSV processing configuration
|
||||||
|
CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations
|
||||||
|
CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch
|
||||||
|
|
||||||
|
# CSV validation configuration
|
||||||
|
CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = {
|
||||||
|
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
|
||||||
|
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
|
||||||
|
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
|
||||||
|
'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
|
||||||
|
'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Required columns for CSV validation
|
||||||
|
CSV_REQUIRED_COLUMNS: Final[List[str]] = [
|
||||||
|
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||||
|
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||||
|
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||||
|
]
|
||||||
# Constants for setup and CSV processing
|
# Constants for setup and CSV processing
|
||||||
MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv'
|
MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv'
|
||||||
|
|
||||||
|
|
84
setup.py
84
setup.py
|
@ -1,31 +1,65 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""MTG Python Deckbuilder setup module.
|
||||||
|
|
||||||
|
This module provides the main setup functionality for the MTG Python Deckbuilder
|
||||||
|
application. It handles initial setup tasks such as downloading card data,
|
||||||
|
creating color-filtered card lists, and generating commander-eligible card lists.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Initial setup and configuration
|
||||||
|
- Card data download and processing
|
||||||
|
- Color-based card filtering
|
||||||
|
- Commander card list generation
|
||||||
|
- CSV file management and validation
|
||||||
|
|
||||||
|
The module works in conjunction with setup_utils.py for utility functions and
|
||||||
|
exceptions.py for error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union, List, Dict, Any
|
from typing import Union, List, Dict, Any
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
import pandas as pd
|
|
||||||
import inquirer
|
import inquirer
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
# Local application imports
|
# Local application imports
|
||||||
from settings import (
|
from settings import (
|
||||||
banned_cards, csv_directory, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL
|
banned_cards,
|
||||||
|
COLOR_ABRV,
|
||||||
|
CSV_DIRECTORY,
|
||||||
|
MTGJSON_API_URL,
|
||||||
|
SETUP_COLORS
|
||||||
)
|
)
|
||||||
from setup_utils import (
|
from setup_utils import (
|
||||||
download_cards_csv, filter_dataframe, process_legendary_cards, filter_by_color_identity
|
download_cards_csv,
|
||||||
|
filter_by_color_identity,
|
||||||
|
filter_dataframe,
|
||||||
|
process_legendary_cards
|
||||||
)
|
)
|
||||||
from exceptions import (
|
from exceptions import (
|
||||||
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError,
|
CSVFileNotFoundError,
|
||||||
ColorFilterError, CommanderValidationError
|
ColorFilterError,
|
||||||
|
CommanderValidationError,
|
||||||
|
DataFrameProcessingError,
|
||||||
|
MTGJSONDownloadError
|
||||||
)
|
)
|
||||||
# Configure logging
|
# Create logs directory if it doesn't exist
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.makedirs('logs')
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler('logs/setup.log', mode='w', encoding='utf-8')
|
||||||
|
]
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -65,7 +99,7 @@ def initial_setup() -> None:
|
||||||
logger.info('Checking for cards.csv file')
|
logger.info('Checking for cards.csv file')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cards_file = f'{csv_directory}/cards.csv'
|
cards_file = f'{CSV_DIRECTORY}/cards.csv'
|
||||||
try:
|
try:
|
||||||
with open(cards_file, 'r', encoding='utf-8'):
|
with open(cards_file, 'r', encoding='utf-8'):
|
||||||
logger.info('cards.csv exists')
|
logger.info('cards.csv exists')
|
||||||
|
@ -81,11 +115,11 @@ def initial_setup() -> None:
|
||||||
for i in range(min(len(SETUP_COLORS), len(COLOR_ABRV))):
|
for i in range(min(len(SETUP_COLORS), len(COLOR_ABRV))):
|
||||||
logger.info(f'Checking for {SETUP_COLORS[i]}_cards.csv')
|
logger.info(f'Checking for {SETUP_COLORS[i]}_cards.csv')
|
||||||
try:
|
try:
|
||||||
with open(f'{csv_directory}/{SETUP_COLORS[i]}_cards.csv', 'r', encoding='utf-8'):
|
with open(f'{CSV_DIRECTORY}/{SETUP_COLORS[i]}_cards.csv', 'r', encoding='utf-8'):
|
||||||
logger.info(f'{SETUP_COLORS[i]}_cards.csv exists')
|
logger.info(f'{SETUP_COLORS[i]}_cards.csv exists')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.info(f'{SETUP_COLORS[i]}_cards.csv not found, creating one')
|
logger.info(f'{SETUP_COLORS[i]}_cards.csv not found, creating one')
|
||||||
filter_by_color(df, 'colorIdentity', COLOR_ABRV[i], f'{csv_directory}/{SETUP_COLORS[i]}_cards.csv')
|
filter_by_color(df, 'colorIdentity', COLOR_ABRV[i], f'{CSV_DIRECTORY}/{SETUP_COLORS[i]}_cards.csv')
|
||||||
|
|
||||||
# Generate commander list
|
# Generate commander list
|
||||||
determine_commanders()
|
determine_commanders()
|
||||||
|
@ -136,7 +170,7 @@ def determine_commanders() -> None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check for cards.csv with progress tracking
|
# Check for cards.csv with progress tracking
|
||||||
cards_file = f'{csv_directory}/cards.csv'
|
cards_file = f'{CSV_DIRECTORY}/cards.csv'
|
||||||
if not check_csv_exists(cards_file):
|
if not check_csv_exists(cards_file):
|
||||||
logger.info('cards.csv not found, initiating download')
|
logger.info('cards.csv not found, initiating download')
|
||||||
download_cards_csv(MTGJSON_API_URL, cards_file)
|
download_cards_csv(MTGJSON_API_URL, cards_file)
|
||||||
|
@ -162,7 +196,7 @@ def determine_commanders() -> None:
|
||||||
|
|
||||||
# Save commander cards
|
# Save commander cards
|
||||||
logger.info('Saving validated commander cards')
|
logger.info('Saving validated commander cards')
|
||||||
filtered_df.to_csv(f'{csv_directory}/commander_cards.csv', index=False)
|
filtered_df.to_csv(f'{CSV_DIRECTORY}/commander_cards.csv', index=False)
|
||||||
|
|
||||||
logger.info('Commander card generation completed successfully')
|
logger.info('Commander card generation completed successfully')
|
||||||
|
|
||||||
|
@ -189,10 +223,10 @@ def regenerate_csvs_all() -> None:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info('Downloading latest card data from MTGJSON')
|
logger.info('Downloading latest card data from MTGJSON')
|
||||||
download_cards_csv(MTGJSON_API_URL, f'{csv_directory}/cards.csv')
|
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
|
||||||
|
|
||||||
logger.info('Loading and processing card data')
|
logger.info('Loading and processing card data')
|
||||||
df = pd.read_csv(f'{csv_directory}/cards.csv', low_memory=False)
|
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||||
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||||
|
|
||||||
logger.info('Regenerating color identity sorted files')
|
logger.info('Regenerating color identity sorted files')
|
||||||
|
@ -200,7 +234,7 @@ def regenerate_csvs_all() -> None:
|
||||||
color = SETUP_COLORS[i]
|
color = SETUP_COLORS[i]
|
||||||
color_id = COLOR_ABRV[i]
|
color_id = COLOR_ABRV[i]
|
||||||
logger.info(f'Processing {color} cards')
|
logger.info(f'Processing {color} cards')
|
||||||
filter_by_color(df, 'colorIdentity', color_id, f'{csv_directory}/{color}_cards.csv')
|
filter_by_color(df, 'colorIdentity', color_id, f'{CSV_DIRECTORY}/{color}_cards.csv')
|
||||||
|
|
||||||
logger.info('Regenerating commander cards')
|
logger.info('Regenerating commander cards')
|
||||||
determine_commanders()
|
determine_commanders()
|
||||||
|
@ -232,14 +266,14 @@ def regenerate_csv_by_color(color: str) -> None:
|
||||||
color_abv = COLOR_ABRV[SETUP_COLORS.index(color)]
|
color_abv = COLOR_ABRV[SETUP_COLORS.index(color)]
|
||||||
|
|
||||||
logger.info(f'Downloading latest card data for {color} cards')
|
logger.info(f'Downloading latest card data for {color} cards')
|
||||||
download_cards_csv(MTGJSON_API_URL, f'{csv_directory}/cards.csv')
|
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
|
||||||
|
|
||||||
logger.info('Loading and processing card data')
|
logger.info('Loading and processing card data')
|
||||||
df = pd.read_csv(f'{csv_directory}/cards.csv', low_memory=False)
|
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||||
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||||
|
|
||||||
logger.info(f'Regenerating {color} cards CSV')
|
logger.info(f'Regenerating {color} cards CSV')
|
||||||
filter_by_color(df, 'colorIdentity', color_abv, f'{csv_directory}/{color}_cards.csv')
|
filter_by_color(df, 'colorIdentity', color_abv, f'{CSV_DIRECTORY}/{color}_cards.csv')
|
||||||
|
|
||||||
logger.info(f'Successfully regenerated {color} cards database')
|
logger.info(f'Successfully regenerated {color} cards database')
|
||||||
|
|
||||||
|
@ -288,23 +322,23 @@ def setup() -> bool:
|
||||||
choice = _display_setup_menu()
|
choice = _display_setup_menu()
|
||||||
|
|
||||||
if choice == SetupOption.INITIAL_SETUP:
|
if choice == SetupOption.INITIAL_SETUP:
|
||||||
logging.info('Starting initial setup')
|
logger.info('Starting initial setup')
|
||||||
initial_setup()
|
initial_setup()
|
||||||
logging.info('Initial setup completed successfully')
|
logger.info('Initial setup completed successfully')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif choice == SetupOption.REGENERATE_CSV:
|
elif choice == SetupOption.REGENERATE_CSV:
|
||||||
logging.info('Starting CSV regeneration')
|
logger.info('Starting CSV regeneration')
|
||||||
regenerate_csvs_all()
|
regenerate_csvs_all()
|
||||||
logging.info('CSV regeneration completed successfully')
|
logger.info('CSV regeneration completed successfully')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif choice == SetupOption.BACK:
|
elif choice == SetupOption.BACK:
|
||||||
logging.info('Setup cancelled by user')
|
logger.info('Setup cancelled by user')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f'Error during setup: {e}')
|
logger.error(f'Error during setup: {e}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
154
setup_utils.py
154
setup_utils.py
|
@ -1,7 +1,24 @@
|
||||||
|
"""MTG Python Deckbuilder setup utilities.
|
||||||
|
|
||||||
|
This module provides utility functions for setting up and managing the MTG Python Deckbuilder
|
||||||
|
application. It handles tasks such as downloading card data, filtering cards by various criteria,
|
||||||
|
and processing legendary creatures for commander format.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Card data download from MTGJSON
|
||||||
|
- DataFrame filtering and processing
|
||||||
|
- Color identity filtering
|
||||||
|
- Commander validation
|
||||||
|
- CSV file management
|
||||||
|
|
||||||
|
The module integrates with settings.py for configuration and exceptions.py for error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import requests
|
import requests
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Union, TypedDict
|
from typing import List, Optional, Union, TypedDict
|
||||||
|
@ -19,6 +36,10 @@ from settings import (
|
||||||
FILL_NA_COLUMNS,
|
FILL_NA_COLUMNS,
|
||||||
SORT_CONFIG,
|
SORT_CONFIG,
|
||||||
FILTER_CONFIG,
|
FILTER_CONFIG,
|
||||||
|
COLUMN_ORDER,
|
||||||
|
PRETAG_COLUMN_ORDER,
|
||||||
|
EXCLUDED_CARD_TYPES,
|
||||||
|
TAGGED_COLUMN_ORDER
|
||||||
)
|
)
|
||||||
from exceptions import (
|
from exceptions import (
|
||||||
MTGJSONDownloadError,
|
MTGJSONDownloadError,
|
||||||
|
@ -26,22 +47,21 @@ from exceptions import (
|
||||||
ColorFilterError,
|
ColorFilterError,
|
||||||
CommanderValidationError
|
CommanderValidationError
|
||||||
)
|
)
|
||||||
|
from type_definitions import CardLibraryDF
|
||||||
|
|
||||||
"""MTG Python Deckbuilder setup utilities.
|
# Create logs directory if it doesn't exist
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.makedirs('logs')
|
||||||
|
|
||||||
This module provides utility functions for setting up and managing the MTG Python Deckbuilder
|
logging.basicConfig(
|
||||||
application. It handles tasks such as downloading card data, filtering cards by various criteria,
|
level=logging.INFO,
|
||||||
and processing legendary creatures for commander format.
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
Key Features:
|
logging.StreamHandler(),
|
||||||
- Card data download from MTGJSON
|
logging.FileHandler('logs/setup_utils.log', mode='a', encoding='utf-8')
|
||||||
- DataFrame filtering and processing
|
]
|
||||||
- Color identity filtering
|
)
|
||||||
- Commander validation
|
logger = logging.getLogger(__name__)
|
||||||
- CSV file management
|
|
||||||
|
|
||||||
The module integrates with settings.py for configuration and exceptions.py for error handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Type definitions
|
# Type definitions
|
||||||
class FilterRule(TypedDict):
|
class FilterRule(TypedDict):
|
||||||
|
@ -83,7 +103,7 @@ def download_cards_csv(url: str, output_path: Union[str, Path]) -> None:
|
||||||
pbar.update(size)
|
pbar.update(size)
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logging.error(f'Failed to download cards data from {url}')
|
logger.error(f'Failed to download cards data from {url}')
|
||||||
raise MTGJSONDownloadError(
|
raise MTGJSONDownloadError(
|
||||||
"Failed to download cards data",
|
"Failed to download cards data",
|
||||||
url,
|
url,
|
||||||
|
@ -128,14 +148,14 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
||||||
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
|
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logging.info('Starting standard DataFrame filtering')
|
logger.info('Starting standard DataFrame filtering')
|
||||||
|
|
||||||
# Fill null values according to configuration
|
# Fill null values according to configuration
|
||||||
for col, fill_value in FILL_NA_COLUMNS.items():
|
for col, fill_value in FILL_NA_COLUMNS.items():
|
||||||
if col == 'faceName':
|
if col == 'faceName':
|
||||||
fill_value = df['name']
|
fill_value = df['name']
|
||||||
df[col] = df[col].fillna(fill_value)
|
df[col] = df[col].fillna(fill_value)
|
||||||
logging.debug(f'Filled NA values in {col} with {fill_value}')
|
logger.debug(f'Filled NA values in {col} with {fill_value}')
|
||||||
|
|
||||||
# Apply basic filters from configuration
|
# Apply basic filters from configuration
|
||||||
filtered_df = df.copy()
|
filtered_df = df.copy()
|
||||||
|
@ -148,22 +168,22 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
||||||
elif rule_type == 'require':
|
elif rule_type == 'require':
|
||||||
for value in values:
|
for value in values:
|
||||||
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
|
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
|
||||||
logging.debug(f'Applied {rule_type} filter for {field}: {values}')
|
logger.debug(f'Applied {rule_type} filter for {field}: {values}')
|
||||||
|
|
||||||
# Remove illegal sets
|
# Remove illegal sets
|
||||||
for set_code in NON_LEGAL_SETS:
|
for set_code in NON_LEGAL_SETS:
|
||||||
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
||||||
logging.debug('Removed illegal sets')
|
logger.debug('Removed illegal sets')
|
||||||
|
|
||||||
# Remove banned cards
|
# Remove banned cards
|
||||||
for card in banned_cards:
|
for card in banned_cards:
|
||||||
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
||||||
logging.debug('Removed banned cards')
|
logger.debug('Removed banned cards')
|
||||||
|
|
||||||
# Remove special card types
|
# Remove special card types
|
||||||
for card_type in CARD_TYPES_TO_EXCLUDE:
|
for card_type in CARD_TYPES_TO_EXCLUDE:
|
||||||
filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)]
|
filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)]
|
||||||
logging.debug('Removed special card types')
|
logger.debug('Removed special card types')
|
||||||
|
|
||||||
# Select columns, sort, and drop duplicates
|
# Select columns, sort, and drop duplicates
|
||||||
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
|
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
|
||||||
|
@ -172,12 +192,12 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
||||||
key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col
|
key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col
|
||||||
)
|
)
|
||||||
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
|
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
|
||||||
logging.info('Completed standard DataFrame filtering')
|
logger.info('Completed standard DataFrame filtering')
|
||||||
|
|
||||||
return filtered_df
|
return filtered_df
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f'Failed to filter DataFrame: {str(e)}')
|
logger.error(f'Failed to filter DataFrame: {str(e)}')
|
||||||
raise DataFrameProcessingError(
|
raise DataFrameProcessingError(
|
||||||
"Failed to filter DataFrame",
|
"Failed to filter DataFrame",
|
||||||
"standard_filtering",
|
"standard_filtering",
|
||||||
|
@ -202,7 +222,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
|
||||||
DataFrameProcessingError: If general filtering operations fail
|
DataFrameProcessingError: If general filtering operations fail
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logging.info(f'Filtering cards for color identity: {color_identity}')
|
logger.info(f'Filtering cards for color identity: {color_identity}')
|
||||||
|
|
||||||
# Validate color identity
|
# Validate color identity
|
||||||
with tqdm(total=1, desc='Validating color identity') as pbar:
|
with tqdm(total=1, desc='Validating color identity') as pbar:
|
||||||
|
@ -222,14 +242,14 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
|
||||||
# Filter by color identity
|
# Filter by color identity
|
||||||
with tqdm(total=1, desc='Filtering by color identity') as pbar:
|
with tqdm(total=1, desc='Filtering by color identity') as pbar:
|
||||||
filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity]
|
filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity]
|
||||||
logging.debug(f'Applied color identity filter: {color_identity}')
|
logger.debug(f'Applied color identity filter: {color_identity}')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
|
|
||||||
# Additional color-specific processing
|
# Additional color-specific processing
|
||||||
with tqdm(total=1, desc='Performing color-specific processing') as pbar:
|
with tqdm(total=1, desc='Performing color-specific processing') as pbar:
|
||||||
# Placeholder for future color-specific processing
|
# Placeholder for future color-specific processing
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
logging.info(f'Completed color identity filtering for {color_identity}')
|
logger.info(f'Completed color identity filtering for {color_identity}')
|
||||||
return filtered_df
|
return filtered_df
|
||||||
|
|
||||||
except DataFrameProcessingError as e:
|
except DataFrameProcessingError as e:
|
||||||
|
@ -259,7 +279,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
DataFrameProcessingError: If general processing fails
|
DataFrameProcessingError: If general processing fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logging.info('Starting commander validation process')
|
logger.info('Starting commander validation process')
|
||||||
|
|
||||||
filtered_df = df.copy()
|
filtered_df = df.copy()
|
||||||
# Step 1: Check legendary status
|
# Step 1: Check legendary status
|
||||||
|
@ -273,7 +293,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"DataFrame contains no cards matching legendary criteria"
|
"DataFrame contains no cards matching legendary criteria"
|
||||||
)
|
)
|
||||||
filtered_df = filtered_df[mask].copy()
|
filtered_df = filtered_df[mask].copy()
|
||||||
logging.debug(f'Found {len(filtered_df)} legendary cards')
|
logger.debug(f'Found {len(filtered_df)} legendary cards')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommanderValidationError(
|
raise CommanderValidationError(
|
||||||
|
@ -288,7 +308,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
special_cases = df['text'].str.contains('can be your commander', na=False)
|
special_cases = df['text'].str.contains('can be your commander', na=False)
|
||||||
special_commanders = df[special_cases].copy()
|
special_commanders = df[special_cases].copy()
|
||||||
filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates()
|
filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates()
|
||||||
logging.debug(f'Added {len(special_commanders)} special commander cards')
|
logger.debug(f'Added {len(special_commanders)} special commander cards')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommanderValidationError(
|
raise CommanderValidationError(
|
||||||
|
@ -306,7 +326,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
~filtered_df['printings'].str.contains(set_code, na=False)
|
~filtered_df['printings'].str.contains(set_code, na=False)
|
||||||
]
|
]
|
||||||
removed_count = initial_count - len(filtered_df)
|
removed_count = initial_count - len(filtered_df)
|
||||||
logging.debug(f'Removed {removed_count} cards from illegal sets')
|
logger.debug(f'Removed {removed_count} cards from illegal sets')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommanderValidationError(
|
raise CommanderValidationError(
|
||||||
|
@ -314,7 +334,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"set_legality",
|
"set_legality",
|
||||||
str(e)
|
str(e)
|
||||||
) from e
|
) from e
|
||||||
logging.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
|
logger.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
|
||||||
return filtered_df
|
return filtered_df
|
||||||
|
|
||||||
except CommanderValidationError:
|
except CommanderValidationError:
|
||||||
|
@ -324,4 +344,74 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"Failed to process legendary cards",
|
"Failed to process legendary cards",
|
||||||
"commander_processing",
|
"commander_processing",
|
||||||
str(e)
|
str(e)
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
def process_card_dataframe(df: CardLibraryDF, batch_size: int = 1000, columns_to_keep: Optional[List[str]] = None,
|
||||||
|
include_commander_cols: bool = False, skip_availability_checks: bool = False) -> CardLibraryDF:
|
||||||
|
"""Process DataFrame with common operations in batches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: DataFrame to process
|
||||||
|
batch_size: Size of batches for processing
|
||||||
|
columns_to_keep: List of columns to keep (default: COLUMN_ORDER)
|
||||||
|
include_commander_cols: Whether to include commander-specific columns
|
||||||
|
skip_availability_checks: Whether to skip availability and security checks (default: False)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: DataFrame to process
|
||||||
|
batch_size: Size of batches for processing
|
||||||
|
columns_to_keep: List of columns to keep (default: COLUMN_ORDER)
|
||||||
|
include_commander_cols: Whether to include commander-specific columns
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CardLibraryDF: Processed DataFrame with standardized structure
|
||||||
|
"""
|
||||||
|
logger.info("Processing card DataFrame...")
|
||||||
|
|
||||||
|
if columns_to_keep is None:
|
||||||
|
columns_to_keep = TAGGED_COLUMN_ORDER.copy()
|
||||||
|
if include_commander_cols:
|
||||||
|
commander_cols = ['printings', 'text', 'power', 'toughness', 'keywords']
|
||||||
|
columns_to_keep.extend(col for col in commander_cols if col not in columns_to_keep)
|
||||||
|
|
||||||
|
# Fill NA values
|
||||||
|
df.loc[:, 'colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||||
|
df.loc[:, 'faceName'] = df['faceName'].fillna(df['name'])
|
||||||
|
|
||||||
|
# Process in batches
|
||||||
|
total_batches = len(df) // batch_size + 1
|
||||||
|
processed_dfs = []
|
||||||
|
|
||||||
|
for i in tqdm(range(total_batches), desc="Processing batches"):
|
||||||
|
start_idx = i * batch_size
|
||||||
|
end_idx = min((i + 1) * batch_size, len(df))
|
||||||
|
batch = df.iloc[start_idx:end_idx].copy()
|
||||||
|
|
||||||
|
if not skip_availability_checks:
|
||||||
|
columns_to_keep = COLUMN_ORDER.copy()
|
||||||
|
logger.debug("Performing column checks...")
|
||||||
|
# Common processing steps
|
||||||
|
batch = batch[batch['availability'].str.contains('paper', na=False)]
|
||||||
|
batch = batch.loc[batch['layout'] != 'reversible_card']
|
||||||
|
batch = batch.loc[batch['promoTypes'] != 'playtest']
|
||||||
|
batch = batch.loc[batch['securityStamp'] != 'heart']
|
||||||
|
batch = batch.loc[batch['securityStamp'] != 'acorn']
|
||||||
|
# Keep only specified columns
|
||||||
|
batch = batch[columns_to_keep]
|
||||||
|
processed_dfs.append(batch)
|
||||||
|
else:
|
||||||
|
logger.debug("Skipping column checks...")
|
||||||
|
|
||||||
|
# Keep only specified columns
|
||||||
|
batch = batch[columns_to_keep]
|
||||||
|
processed_dfs.append(batch)
|
||||||
|
|
||||||
|
# Combine processed batches
|
||||||
|
result = pd.concat(processed_dfs, ignore_index=True)
|
||||||
|
|
||||||
|
# Final processing
|
||||||
|
result.drop_duplicates(subset='faceName', keep='first', inplace=True)
|
||||||
|
result.sort_values(by=['name', 'side'], key=lambda col: col.str.lower(), inplace=True)
|
||||||
|
|
||||||
|
logger.info("DataFrame processing completed")
|
||||||
|
return result
|
||||||
|
|
50
type_definitions.py
Normal file
50
type_definitions.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List, TypedDict, Union
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
class CardDict(TypedDict):
|
||||||
|
"""Type definition for card dictionary structure used in deck_builder.py.
|
||||||
|
|
||||||
|
Contains all the necessary fields to represent a Magic: The Gathering card
|
||||||
|
in the deck building process.
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
manaCost: Union[str, None]
|
||||||
|
manaValue: int
|
||||||
|
|
||||||
|
class CommanderDict(TypedDict):
|
||||||
|
"""Type definition for commander dictionary structure used in deck_builder.py.
|
||||||
|
|
||||||
|
Contains all the necessary fields to represent a commander card and its
|
||||||
|
associated metadata.
|
||||||
|
"""
|
||||||
|
Commander_Name: str
|
||||||
|
Mana_Cost: str
|
||||||
|
Mana_Value: int
|
||||||
|
Color_Identity: str
|
||||||
|
Colors: List[str]
|
||||||
|
Type: str
|
||||||
|
Creature_Types: str
|
||||||
|
Text: str
|
||||||
|
Power: int
|
||||||
|
Toughness: int
|
||||||
|
Themes: List[str]
|
||||||
|
CMC: float
|
||||||
|
|
||||||
|
# Type alias for price cache dictionary used in price_checker.py
|
||||||
|
PriceCache = Dict[str, float]
|
||||||
|
|
||||||
|
# DataFrame type aliases for different card categories
|
||||||
|
CardLibraryDF = pd.DataFrame
|
||||||
|
CommanderDF = pd.DataFrame
|
||||||
|
LandDF = pd.DataFrame
|
||||||
|
ArtifactDF = pd.DataFrame
|
||||||
|
CreatureDF = pd.DataFrame
|
||||||
|
NonCreatureDF = pd.DataFrame
|
||||||
|
EnchantmentDF = pd.DataFrame
|
||||||
|
InstantDF = pd.DataFrame
|
||||||
|
PlaneswalkerDF = pd.DataFrame
|
||||||
|
NonPlaneswalkerDF = pd.DataFrame
|
||||||
|
SorceryDF = pd.DataFrame
|
Loading…
Add table
Add a link
Reference in a new issue