Merge pull request #3 from mwisnowski/origin/refactor_deck_builder

Origin/refactor deck builder
This commit is contained in:
mwisnowski 2025-01-17 15:10:41 -08:00 committed by GitHub
commit 3fc3c584a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 7254 additions and 2371 deletions

1642
builder_utils.py Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

443
input_handler.py Normal file
View 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
View file

@ -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
View 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")

View file

@ -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'

View file

@ -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

View file

@ -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

1570
tagger.py

File diff suppressed because it is too large Load diff

50
type_definitions.py Normal file
View 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