From 503068b20c6f7b100411d4d81b2e51a1fe947103 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Tue, 14 Jan 2025 10:08:44 -0800 Subject: [PATCH 1/6] Began work on refactoring deck_builder Fixed logging for the other files such that they actually log to the file instead of just creating it --- deck_builder.py | 465 +++++++++++++++++++----------------------------- 1 file changed, 185 insertions(+), 280 deletions(-) diff --git a/deck_builder.py b/deck_builder.py index 82347e1..aa43755 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -1,38 +1,3 @@ -from __future__ import annotations - -import logging -import inquirer.prompt # type: ignore -import keyboard # type: ignore -import math -import numpy as np -import pandas as pd # type: ignore -import pprint # type: ignore -import random -import time - -from functools import lru_cache -from fuzzywuzzy import process # type: ignore - -from settings import basic_lands, card_types, csv_directory, multiple_copy_cards -from setup import determine_commanders - -try: - import scrython # type: ignore - use_scrython = True -except ImportError: - scrython = None - use_scrython = False - logging.warning("Scrython is not installed. Some pricing features will be unavailable.") - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) - -pd.set_option('display.max_columns', None) -pd.set_option('display.max_rows', None) -pd.set_option('display.max_colwidth', 50) - """ Basic deck builder, primarily intended for building Kindred decks. Logic for other themes (such as Spellslinger or Wheels), is added. @@ -46,6 +11,52 @@ Land spread will ideally be handled based on pips and some adjustment is planned based on mana curve and ramp added. """ +from __future__ import annotations +from input_handler import InputHandler +from price_check import check_price + +import logging +import math +import numpy as np +import pandas as pd # type: ignore +import pprint # type: ignore +import random +import time +import os + +from fuzzywuzzy import process # type: ignore + +from exceptions import PriceCheckError +from settings import basic_lands, card_types, csv_directory, multiple_copy_cards +from setup import determine_commanders + +# Create logs directory if it doesn't exist +if not os.path.exists('logs'): + os.makedirs('logs') + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('logs/deck_builder.log', mode='w', encoding='utf-8') + ] +) +logger = logging.getLogger(__name__) + +try: + import scrython # type: ignore + use_scrython = True +except ImportError: + scrython = None + use_scrython = False + logger.warning("Scrython is not installed. Some pricing features will be unavailable.") + + +pd.set_option('display.max_columns', None) +pd.set_option('display.max_rows', None) +pd.set_option('display.max_colwidth', 50) + def new_line(num_lines: int = 1) -> None: """Print specified number of newlines for formatting output. @@ -60,7 +71,6 @@ def new_line(num_lines: int = 1) -> None: print('\n' * num_lines) class DeckBuilder: - def __init__(self): self.card_library = pd.DataFrame() self.card_library['Card Name'] = pd.Series(dtype='str') @@ -69,122 +79,16 @@ class DeckBuilder: self.card_library['Mana Value'] = pd.Series(dtype='int') self.card_library['Commander'] = pd.Series(dtype='bool') + self.input_handler = InputHandler() self.set_max_deck_price = False self.set_max_card_price = False - self.card_prices = {} if use_scrython else None + self.card_prices = {} def pause_with_message(self, message="Press Enter to continue..."): """Helper function to pause execution with a message.""" print(f"\n{message}") input() - - def validate_text(self, result: str) -> bool: - """Validate text input is not empty. - - Args: - result (str): Text input to validate - - Returns: - bool: True if text is not empty after stripping whitespace - """ - return bool(result and result.strip()) - - def validate_number(self, result: str) -> float | None: - """Validate and convert string input to float. - - Args: - result (str): Number input to validate - - Returns: - float | None: Converted float value or None if invalid - """ - try: - return float(result) - except (ValueError, TypeError): - return None - def validate_confirm(self, result): - return bool(result) - - def questionnaire(self, question_type, default_value='', choices_list=[]): - MAX_ATTEMPTS = 3 - - if question_type == 'Text': - question = [inquirer.Text('text')] - result = inquirer.prompt(question)['text'] - while not result.strip(): - question = [ - inquirer.Text('text', message='Input cannot be empty') - ] - result = inquirer.prompt(question)['text'] - return result - - elif question_type == 'Number': - attempts = 0 - question = [ - inquirer.Text('number', default=default_value) - ] - result = inquirer.prompt(question)['number'] - - while attempts < MAX_ATTEMPTS: - try: - result = float(result) - break - except ValueError: - attempts += 1 - if attempts < MAX_ATTEMPTS: - question = [ - inquirer.Text('number', - message='Input must be a valid number', - default=default_value) - ] - result = inquirer.prompt(question)['number'] - else: - logging.error("Maximum input attempts reached for Number type.") - raise ValueError("Invalid number input.") - return result - - elif question_type == 'Confirm': - question = [ - inquirer.Confirm('confirm', default=default_value) - ] - result = inquirer.prompt(question)['confirm'] - return self.validate_confirm(result) - - elif question_type == 'Choice': - question = [ - inquirer.List('selection', - choices=choices_list, - carousel=True) - ] - result = inquirer.prompt(question)['selection'] - return result - - raise ValueError(f"Unsupported question type: {question_type}") - - @lru_cache(maxsize=128) - def price_check(self, card_name): - try: - time.sleep(0.1) - card = scrython.cards.Named(fuzzy=card_name) - card_price = card.prices('usd') - if card_price is not None and isinstance(card_price, (int, float)): - try: - self.card_prices[card_name] = card_price - return float(card_price) - except ValueError: - logging.error(f"Invalid price format for '{card_name}': {card_price}") - return 0.0 - return 0.0 - except (scrython.foundation.ScryfallError, scrython.foundation.ScryfallRequestError) as e: - logging.error(f"Scryfall API error for '{card_name}': {e}") - return 0.0 - except TimeoutError: - logging.error(f"Request timed out while fetching price for '{card_name}'") - return 0.0 - except Exception as e: - logging.error(f"Unexpected error fetching price for '{card_name}': {e}") - return 0.0 - + def determine_commander(self): # Setup dataframe try: @@ -199,8 +103,7 @@ class DeckBuilder: commander_chosen = False while not commander_chosen: print('Enter a card name to be your commander, note that at this time only cards that have the \'Creature\' type may be chosen') - card_choice = self.questionnaire('Text', '') - + card_choice = self.input_handler.questionnaire('Text', '') # Logic to find the card in the commander_cards csv, then display it's information # If the card can't be found, or doesn't have enough of a match score, display a # list to choose from @@ -212,11 +115,11 @@ class DeckBuilder: print(fuzzy_card_choice) fuzzy_chosen = True else: - logging.warning('Multiple options found, which is correct?') + logger.warning('Multiple options found, which is correct?') fuzzy_card_choices = process.extract(card_choice, df['name'], limit=5) fuzzy_card_choices.append('Neither') print(fuzzy_card_choices) - fuzzy_card_choice = self.questionnaire('Choice', choices_list=fuzzy_card_choices) + fuzzy_card_choice = self.input_handler.questionnaire('Choice', choices_list=fuzzy_card_choices) if isinstance(fuzzy_card_choice, tuple): fuzzy_card_choice = fuzzy_card_choice[0] if fuzzy_card_choice != 'Neither': @@ -233,14 +136,13 @@ class DeckBuilder: self.commander_df = pd.DataFrame(df_dict) # Confirm if card entered was correct - commander_confirmed = self.questionnaire('Confirm', True) + commander_confirmed = self.input_handler.questionnaire('Confirm', True) # If correct, set it as the commander if commander_confirmed: commander_chosen = True self.commander_info = df_dict self.commander = self.commander_df.at[0, 'name'] - self.price_check(self.commander) - logging.info(f"Commander selected: {self.commander}") + logger.info(f"Commander selected: {self.commander}") break else: commander_chosen = False @@ -277,7 +179,7 @@ class DeckBuilder: self.color_identity_full = '' self.determine_color_identity() except Exception as e: - logging.error(f"Failed to set color identity: {e}") + logger.error(f"Failed to set color identity: {e}") raise ValueError("Could not determine color identity") from e # Set creature colors @@ -326,15 +228,15 @@ class DeckBuilder: self.card_library.to_csv(f'{csv_directory}/test_deck_presort.csv', index=False) self.organize_library() self.card_library.to_csv(f'{csv_directory}/test_deck_preconcat.csv', index=False) - logging.info(f'Creature cards (including commander): {self.creature_cards}') - logging.info(f'Planeswalker cards: {self.planeswalker_cards}') - logging.info(f'Battle cards: {self.battle_cards}') - logging.info(f'Instant cards: {self.instant_cards}') - logging.info(f'Sorcery cards: {self.sorcery_cards}') - logging.info(f'Artifact cards: {self.artifact_cards}') - logging.info(f'Enchantment cards: {self.enchantment_cards}') - logging.info(f'Land cards cards: {self.land_cards}') - logging.info(f'Number of cards in Library: {len(self.card_library)}') + logger.info(f'Creature cards (including commander): {self.creature_cards}') + logger.info(f'Planeswalker cards: {self.planeswalker_cards}') + logger.info(f'Battle cards: {self.battle_cards}') + logger.info(f'Instant cards: {self.instant_cards}') + logger.info(f'Sorcery cards: {self.sorcery_cards}') + logger.info(f'Artifact cards: {self.artifact_cards}') + logger.info(f'Enchantment cards: {self.enchantment_cards}') + logger.info(f'Land cards cards: {self.land_cards}') + logger.info(f'Number of cards in Library: {len(self.card_library)}') self.get_cmc() self.count_pips() self.concatenate_duplicates() @@ -463,16 +365,16 @@ class DeckBuilder: return # If we get here, it's an unknown color identity - logging.warning(f"Unknown color identity: {self.color_identity}") + logger.warning(f"Unknown color identity: {self.color_identity}") self.color_identity_full = 'Unknown' self.files_to_load = ['colorless'] except Exception as e: - logging.error(f"Error in determine_color_identity: {e}") + logger.error(f"Error in determine_color_identity: {e}") raise def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame: - """Read CSV file with error handling and logging. + """Read CSV file with error handling and logger. Args: filename: Name of the CSV file without extension @@ -484,17 +386,17 @@ class DeckBuilder: try: filepath = f'{csv_directory}/{filename}_cards.csv' df = pd.read_csv(filepath, converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval}) - logging.debug(f"Successfully read {filename}_cards.csv") + logger.debug(f"Successfully read {filename}_cards.csv") return df except FileNotFoundError as e: - logging.error(f"File {filename}_cards.csv not found: {e}") + logger.error(f"File {filename}_cards.csv not found: {e}") raise except Exception as e: - logging.error(f"Error reading {filename}_cards.csv: {e}") + logger.error(f"Error reading {filename}_cards.csv: {e}") raise def write_csv(self, df: pd.DataFrame, filename: str) -> None: - """Write DataFrame to CSV with error handling and logging. + """Write DataFrame to CSV with error handling and logger. Args: df: DataFrame to write @@ -503,9 +405,9 @@ class DeckBuilder: try: filepath = f'{csv_directory}/{filename}.csv' df.to_csv(filepath, index=False) - logging.debug(f"Successfully wrote {filename}.csv") + logger.debug(f"Successfully wrote {filename}.csv") except Exception as e: - logging.error(f"Error writing {filename}.csv: {e}") + logger.error(f"Error writing {filename}.csv: {e}") raise def setup_dataframes(self): @@ -568,7 +470,7 @@ class DeckBuilder: # Choose a primary theme print('Choose a primary theme for your commander deck.\n' 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.') - choice = self.questionnaire('Choice', choices_list=themes) + choice = self.input_handler.questionnaire('Choice', choices_list=themes) self.primary_theme = choice weights_default = { 'primary': 1.0, @@ -589,11 +491,11 @@ class DeckBuilder: # Secondary theme print('Choose a secondary theme for your commander deck.\n' 'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.') - choice = self.questionnaire('Choice', choices_list=themes) + choice = self.input_handler.questionnaire('Choice', choices_list=themes) while True: if choice == 'Stop Here': - logging.warning('You\'ve only selected one theme, are you sure you want to stop?\n') - confirm_done = self.questionnaire('Confirm', False) + logger.warning('You\'ve only selected one theme, are you sure you want to stop?\n') + confirm_done = self.input_handler.questionnaire('Confirm', False) if confirm_done: secondary_theme_chosen = True self.secondary_theme = False @@ -627,11 +529,11 @@ class DeckBuilder: # Tertiary theme print('Choose a tertiary theme for your commander deck.\n' 'This will typically be a tertiary focus, or just something else to do that your commander is good at.') - choice = self.questionnaire('Choice', choices_list=themes) + choice = self.input_handler.questionnaire('Choice', choices_list=themes) while True: if choice == 'Stop Here': - logging.warning('You\'ve only selected two themes, are you sure you want to stop?\n') - confirm_done = self.questionnaire('Confirm', False) + logger.warning('You\'ve only selected two themes, are you sure you want to stop?\n') + confirm_done = self.input_handler.questionnaire('Confirm', False) if confirm_done: tertiary_theme_chosen = True self.tertiary_theme = False @@ -690,8 +592,8 @@ class DeckBuilder: if (hidden_themes[i] in self.themes and hidden_themes[i] != 'Rat Kindred' and color[i] in self.colors): - logging.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.questionnaire('Confirm', False) + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.input_handler.questionnaire('Confirm', False) if choice: self.hidden_theme = theme_cards[i] self.themes.append(self.hidden_theme) @@ -709,11 +611,12 @@ class DeckBuilder: elif (hidden_themes[i] in self.themes and hidden_themes[i] == 'Rat Kindred' and color[i] in self.colors): - logging.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') - choice = self.questionnaire('Confirm', False) + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') + choice = self.input_handler.questionnaire('Confirm', False) + choice = self.input_handler.questionnaire('Confirm', False) if choice: print('Which one?') - choice = self.questionnaire('Choice', choices_list=theme_cards[i]) + choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i]) if choice: self.hidden_theme = choice self.themes.append(self.hidden_theme) @@ -735,8 +638,8 @@ class DeckBuilder: for i in range(min(len(hidden_themes), len(theme_cards), len(color))): if (hidden_themes[i] in self.themes and color[i] in self.colors): - logging.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.questionnaire('Confirm', False) + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.input_handler.questionnaire('Confirm', False) if choice: self.hidden_theme = theme_cards[i] self.themes.append(self.hidden_theme) @@ -760,12 +663,12 @@ class DeckBuilder: if use_scrython: print('Would you like to set an intended max price of the deck?\n' 'There will be some leeway of ~10%, with a couple alternative options provided.') - choice = self.questionnaire('Confirm', False) + choice = self.input_handler.questionnaire('Confirm', False) if choice: self.set_max_deck_price = True self.deck_cost = 0.0 print('What would you like the max price to be?') - self.max_deck_price = float(self.questionnaire('Number', 400)) + self.max_deck_price = float(self.input_handler.questionnaire('Number', 400)) new_line() else: self.set_max_deck_price = False @@ -773,11 +676,11 @@ class DeckBuilder: print('Would you like to set a max price per card?\n' 'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.') - choice = self.questionnaire('Confirm', False) + choice = self.input_handler.questionnaire('Confirm', False) if choice: self.set_max_card_price = True print('What would you like the max price to be?') - answer = float(self.questionnaire('Number', 20)) + answer = float(self.input_handler.questionnaire('Number', 20)) self.max_card_price = answer self.card_library['Card Price'] = pd.Series(dtype='float') new_line() @@ -790,7 +693,7 @@ class DeckBuilder: 'This includes mana rocks, mana dorks, and land ramp spells.\n' 'A good baseline is 8-12 pieces, scaling up with higher average CMC\n' 'Default: 8') - answer = self.questionnaire('Number', 8) + answer = self.input_handler.questionnaire('Number', 8) self.ideal_ramp = int(answer) self.free_slots -= self.ideal_ramp new_line() @@ -801,7 +704,7 @@ class DeckBuilder: "For landfall decks, consider starting at 40 lands before ramp.\n" 'As a guideline, each mana source from ramp can reduce land count by ~1.\n' 'Default: 35') - answer = self.questionnaire('Number', 35) + answer = self.input_handler.questionnaire('Number', 35) self.ideal_land_count = int(answer) self.free_slots -= self.ideal_land_count new_line() @@ -811,7 +714,8 @@ class DeckBuilder: 'This can vary widely depending on your commander, colors in color identity, and what you want to do.\n' 'Some decks may be fine with as low as 10, others may want 25.\n' 'Default: 20') - answer = self.questionnaire('Number', 20) + answer = self.input_handler.questionnaire('Number', 20) + answer = self.input_handler.questionnaire('Number', 20) self.min_basics = int(answer) new_line() @@ -821,7 +725,7 @@ class DeckBuilder: "If you're going for a kindred theme, going past 30 is likely normal.\n" "Also be sure to take into account token generation, but remember you'll want enough to stay safe\n" 'Default: 25') - answer = self.questionnaire('Number', 25) + answer = self.input_handler.questionnaire('Number', 25) self.ideal_creature_count = int(answer) self.free_slots -= self.ideal_creature_count new_line() @@ -832,7 +736,7 @@ class DeckBuilder: 'Counterspells can be considered proactive removal and protection.\n' 'If you\'re going spellslinger, more would be a good idea as you might have less cretaures.\n' 'Default: 10') - answer = self.questionnaire('Number', 10) + answer = self.input_handler.questionnaire('Number', 10) self.ideal_removal = int(answer) self.free_slots -= self.ideal_removal new_line() @@ -842,7 +746,7 @@ class DeckBuilder: 'Somewhere around 2-3 is good to help eliminate threats, but also prevent the game from running long\n.' 'This can include damaging wipes like "Blasphemous Act" or toughness reduction like "Meathook Massacre".\n' 'Default: 2') - answer = self.questionnaire('Number', 2) + answer = self.input_handler.questionnaire('Number', 2) self.ideal_wipes = int(answer) self.free_slots -= self.ideal_wipes new_line() @@ -852,7 +756,7 @@ class DeckBuilder: '10 pieces of card advantage is good, up to 14 is better.\n' 'Try to have a majority of it be non-conditional, and only have a couple of "Rhystic Study" style effects.\n' 'Default: 10') - answer = self.questionnaire('Number', 10) + answer = self.input_handler.questionnaire('Number', 10) self.ideal_card_advantage = int(answer) self.free_slots -= self.ideal_card_advantage new_line() @@ -863,7 +767,8 @@ class DeckBuilder: 'Things that grant indestructible, hexproof, phase out, or even just counterspells.\n' 'It\'s recommended to have 5 to 15, depending on your commander and preferred strategy.\n' 'Default: 8') - answer = self.questionnaire('Number', 8) + answer = self.input_handler.questionnaire('Number', 8) + answer = self.input_handler.questionnaire('Number', 8) self.ideal_protection = int(answer) self.free_slots -= self.ideal_protection new_line() @@ -896,15 +801,14 @@ class DeckBuilder: # Handle price checking card_price = 0.0 if use_scrython and self.set_max_card_price: - # Get price from cache or API - if card in self.card_prices: - card_price = self.card_prices[card] - else: - card_price = self.price_check(card) - - # Skip if card is too expensive - if card_price is not None and card_price > self.max_card_price * 1.1: - logging.info(f"Skipping {card} - price {card_price} exceeds maximum") + try: + card_price = check_price(card) + # Skip if card is too expensive + if card_price > self.max_card_price * 1.1: + logger.info(f"Skipping {card} - price {card_price} exceeds maximum") + return + except PriceCheckError as e: + logger.error(f"Error checking price for {card}: {e}") return # Create card entry @@ -919,7 +823,7 @@ class DeckBuilder: if self.set_max_deck_price: self.deck_cost += card_price - logging.debug(f"Added {card} to deck library") + logger.debug(f"Added {card} to deck library") def organize_library(self): # Initialize counters dictionary dynamically from card_types including Kindred @@ -969,7 +873,7 @@ class DeckBuilder: try: commander_row = self.card_library[self.card_library['Commander']].copy() if commander_row.empty: - logging.warning("No commander found in library") + logger.warning("No commander found in library") return self.card_library = self.card_library[~self.card_library['Commander']] @@ -977,9 +881,9 @@ class DeckBuilder: self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) commander_name = commander_row['Card Name'].iloc[0] - logging.info(f"Successfully moved commander '{commander_name}' to top") + logger.info(f"Successfully moved commander '{commander_name}' to top") except Exception as e: - logging.error(f"Error moving commander to top: {str(e)}") + logger.error(f"Error moving commander to top: {str(e)}") def concatenate_duplicates(self): """Handle duplicate cards in the library while maintaining data integrity.""" duplicate_lists = basic_lands + multiple_copy_cards @@ -992,7 +896,7 @@ class DeckBuilder: count = mask.sum() if count > 0: - logging.info(f'Found {count} copies of {duplicate}') + logger.info(f'Found {count} copies of {duplicate}') # Keep first occurrence with updated count first_idx = mask.idxmax() @@ -1024,7 +928,7 @@ class DeckBuilder: try: dataframe.drop(index, inplace=True) except KeyError: - logging.warning(f"Attempted to drop non-existent index {index}") + logger.warning(f"Attempted to drop non-existent index {index}") def add_lands(self): """ Add lands to the deck based on ideal count and deck requirements. @@ -1065,23 +969,23 @@ class DeckBuilder: # Adjust to ideal land count self.check_basics() - logging.info('Adjusting total land count to match ideal count...') + logger.info('Adjusting total land count to match ideal count...') self.organize_library() attempts = 0 while self.land_cards > int(self.ideal_land_count) and attempts < MAX_ADJUSTMENT_ATTEMPTS: - logging.info(f'Current lands: {self.land_cards}, Target: {self.ideal_land_count}') + logger.info(f'Current lands: {self.land_cards}, Target: {self.ideal_land_count}') self.remove_basic() self.organize_library() attempts += 1 if attempts >= MAX_ADJUSTMENT_ATTEMPTS: - logging.warning(f"Could not reach ideal land count after {MAX_ADJUSTMENT_ATTEMPTS} attempts") + logger.warning(f"Could not reach ideal land count after {MAX_ADJUSTMENT_ATTEMPTS} attempts") - logging.info(f'Final land count: {self.land_cards}') + logger.info(f'Final land count: {self.land_cards}') except Exception as e: - logging.error(f"Error during land addition: {e}") + logger.error(f"Error during land addition: {e}") raise def add_basics(self): @@ -1135,7 +1039,7 @@ class DeckBuilder: def add_standard_non_basics(self): """Add staple utility lands based on deck requirements.""" - logging.info('Adding staple non-basic lands') + logger.info('Adding staple non-basic lands') # Define staple lands and their conditions staple_lands = { @@ -1155,23 +1059,23 @@ class DeckBuilder: if land not in self.card_library['Card Name'].values: self.add_card(land, 'Land', None, 0) self.staples.append(land) - logging.debug(f"Added staple land: {land}") + logger.debug(f"Added staple land: {land}") # Update land database self.land_df = self.land_df[~self.land_df['name'].isin(self.staples)] self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) - logging.info(f'Added {len(self.staples)} staple lands') + logger.info(f'Added {len(self.staples)} staple lands') except Exception as e: - logging.error(f"Error adding staple lands: {e}") + logger.error(f"Error adding staple lands: {e}") raise def add_fetches(self): # Determine how many fetches in total print('How many fetch lands would you like to include?\n' 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') - answer = self.questionnaire('Number', 2) + answer = self.input_handler.questionnaire('Number', 2) MAX_ATTEMPTS = 50 # Maximum attempts to prevent infinite loops attempt_count = 0 desired_fetches = int(answer) @@ -1242,7 +1146,7 @@ class DeckBuilder: fetches_to_add.append(fetch) if attempt_count >= MAX_ATTEMPTS: - logging.warning(f"Reached maximum attempts ({MAX_ATTEMPTS}) while selecting fetch lands") + logger.warning(f"Reached maximum attempts ({MAX_ATTEMPTS}) while selecting fetch lands") for card in fetches_to_add: self.add_card(card, 'Land', None, 0) @@ -1252,7 +1156,7 @@ class DeckBuilder: def add_kindred_lands(self): """Add lands that support tribal/kindred themes.""" - logging.info('Adding Kindred-themed lands') + logger.info('Adding Kindred-themed lands') # Standard Kindred support lands KINDRED_STAPLES = [ @@ -1269,7 +1173,7 @@ class DeckBuilder: for theme in self.themes: if 'Kindred' in theme: creature_type = theme.replace(' Kindred', '') - logging.info(f'Searching for {creature_type}-specific lands') + logger.info(f'Searching for {creature_type}-specific lands') # Filter lands by creature type type_specific = self.land_df[ @@ -1299,17 +1203,18 @@ class DeckBuilder: self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) - logging.info(f'Added {len(lands_to_remove)} Kindred-themed lands') + logger.info(f'Added {len(lands_to_remove)} Kindred-themed lands') except Exception as e: - logging.error(f"Error adding Kindred lands: {e}") + logger.error(f"Error adding Kindred lands: {e}") raise def add_dual_lands(self): # Determine dual-color lands available # Determine if using the dual-type lands print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') - choice = self.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', True) color_filter = [] color_dict = { 'azorius': 'Plains Island', @@ -1351,15 +1256,15 @@ class DeckBuilder: self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) - logging.info(f'Added {len(card_pool)} Dual-type land cards.') + logger.info(f'Added {len(card_pool)} Dual-type land cards.') if not choice: - logging.info('Skipping adding Dual-type land cards.') + logger.info('Skipping adding Dual-type land cards.') def add_triple_lands(self): # Determine if using Triome lands print('Would you like to include triome lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') - choice = self.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', True) color_filter = [] color_dict = { @@ -1402,14 +1307,14 @@ class DeckBuilder: self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) - logging.info(f'Added {len(card_pool)} Triome land cards.') + logger.info(f'Added {len(card_pool)} Triome land cards.') if not choice: - logging.info('Skipping adding Triome land cards.') + logger.info('Skipping adding Triome land cards.') def add_misc_lands(self): """Add additional utility lands that fit the deck's color identity.""" - logging.info('Adding miscellaneous utility lands') + logger.info('Adding miscellaneous utility lands') MIN_MISC_LANDS = 5 MAX_MISC_LANDS = 15 @@ -1435,7 +1340,7 @@ class DeckBuilder: ] if not card_pool: - logging.warning("No eligible misc lands found") + logger.warning("No eligible misc lands found") return # Randomly select lands within constraints @@ -1465,10 +1370,10 @@ class DeckBuilder: self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) - logging.info(f'Added {len(cards_to_add)} miscellaneous lands') + logger.info(f'Added {len(cards_to_add)} miscellaneous lands') except Exception as e: - logging.error(f"Error adding misc lands: {e}") + logger.error(f"Error adding misc lands: {e}") raise def check_basics(self): """Check and display counts of each basic land type.""" @@ -1491,11 +1396,11 @@ class DeckBuilder: basic_lands[land] = count self.total_basics += count - logging.info("\nBasic Land Counts:") + logger.info("\nBasic Land Counts:") for land, count in basic_lands.items(): if count > 0: - logging.info(f"{land}: {count}") - logging.info(f"Total basic lands: {self.total_basics}\n") + logger.info(f"{land}: {count}") + logger.info(f"Total basic lands: {self.total_basics}\n") def remove_basic(self, max_attempts: int = 3): """ @@ -1505,7 +1410,7 @@ class DeckBuilder: Args: max_attempts: Maximum number of removal attempts before falling back to non-basics """ - logging.info('Land count over ideal count, removing a basic land.') + logger.info('Land count over ideal count, removing a basic land.') color_to_basic = { 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', @@ -1524,7 +1429,7 @@ class DeckBuilder: while attempts < max_attempts and sum_basics > self.min_basics: if not basic_counts: - logging.warning("No basic lands found to remove") + logger.warning("No basic lands found to remove") break basic_land = max(basic_counts.items(), key=lambda x: x[1])[0] @@ -1537,22 +1442,22 @@ class DeckBuilder: index_to_drop = self.card_library[mask].index[0] self.card_library = self.card_library.drop(index_to_drop).reset_index(drop=True) - logging.info(f'{basic_land} removed successfully') + logger.info(f'{basic_land} removed successfully') return except (IndexError, KeyError) as e: - logging.error(f"Error removing {basic_land}: {e}") + logger.error(f"Error removing {basic_land}: {e}") basic_counts.pop(basic_land) attempts += 1 # If we couldn't remove a basic land, try removing a non-basic - logging.warning("Could not remove basic land, attempting to remove non-basic") + logger.warning("Could not remove basic land, attempting to remove non-basic") self.remove_land() def remove_land(self): """Remove a random non-basic, non-staple land from the deck.""" - logging.info('Removing a random nonbasic land.') + logger.info('Removing a random nonbasic land.') # Define basic lands including snow-covered variants basic_lands = [ @@ -1569,25 +1474,25 @@ class DeckBuilder: ].copy() if len(library_filter) == 0: - logging.warning("No suitable non-basic lands found to remove.") + logger.warning("No suitable non-basic lands found to remove.") return # Select random land to remove card_index = np.random.choice(library_filter.index) card_name = self.card_library.loc[card_index, 'Card Name'] - logging.info(f"Removing {card_name}") + logger.info(f"Removing {card_name}") self.card_library.drop(card_index, inplace=True) self.card_library.reset_index(drop=True, inplace=True) - logging.info("Card removed successfully.") + logger.info("Card removed successfully.") except Exception as e: - logging.error(f"Error removing land: {e}") - logging.warning("Failed to remove land card.") + logger.error(f"Error removing land: {e}") + logger.warning("Failed to remove land card.") def count_pips(self): """Count and display the number of colored mana symbols in casting costs using vectorized operations.""" - logging.info('Analyzing color pip distribution...') + logger.info('Analyzing color pip distribution...') # Define colors to check colors = ['W', 'U', 'B', 'R', 'G'] @@ -1598,19 +1503,19 @@ class DeckBuilder: total_pips = sum(pip_counts.values()) if total_pips == 0: - logging.error("No colored mana symbols found in casting costs.") + logger.error("No colored mana symbols found in casting costs.") return - logging.info("\nColor Pip Distribution:") + logger.info("\nColor Pip Distribution:") for color, count in pip_counts.items(): if count > 0: percentage = (count / total_pips) * 100 print(f"{color}: {count} pips ({percentage:.1f}%)") - logging.info(f"Total colored pips: {total_pips}\n") + logger.info(f"Total colored pips: {total_pips}\n") def get_cmc(self): """Calculate average converted mana cost of non-land cards.""" - logging.info('Calculating average mana value of non-land cards.') + logger.info('Calculating average mana value of non-land cards.') try: # Filter non-land cards @@ -1619,17 +1524,17 @@ class DeckBuilder: ].copy() if non_land.empty: - logging.warning("No non-land cards found") + logger.warning("No non-land cards found") self.cmc = 0.0 else: total_cmc = non_land['Mana Value'].sum() self.cmc = round(total_cmc / len(non_land), 2) self.commander_dict.update({'CMC': float(self.cmc)}) - logging.info(f"Average CMC: {self.cmc}") + logger.info(f"Average CMC: {self.cmc}") except Exception as e: - logging.error(f"Error calculating CMC: {e}") + logger.error(f"Error calculating CMC: {e}") self.cmc = 0.0 def weight_by_theme(self, tag, ideal=1, weight=1, df=None): @@ -1692,7 +1597,7 @@ class DeckBuilder: elif (card['name'] not in multiple_copy_cards and card['name'] in self.card_library['Card Name'].values): - logging.warning(f"{card['name']} already in Library, skipping it.") + logger.warning(f"{card['name']} already in Library, skipping it.") continue # Add selected cards to library @@ -1703,7 +1608,7 @@ class DeckBuilder: card_pool_names = [item['name'] for item in card_pool] self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] - logging.info(f'Added {len(cards_to_add)} {tag} cards') + logger.info(f'Added {len(cards_to_add)} {tag} cards') #tag_df.to_csv(f'{csv_directory}/test_{tag}.csv', index=False) def add_by_tags(self, tag, ideal_value=1, df=None): @@ -1763,7 +1668,7 @@ class DeckBuilder: card_pool_names = [item['name'] for item in card_pool] self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] - logging.info(f'Added {len(cards_to_add)} {tag} cards') + logger.info(f'Added {len(cards_to_add)} {tag} cards') #tag_df.to_csv(f'{csv_directory}/test_{tag}.csv', index=False) def add_creatures(self): @@ -1797,10 +1702,10 @@ class DeckBuilder: self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df) except Exception as e: - logging.error(f"Error while adding creatures: {e}") + logger.error(f"Error while adding creatures: {e}") finally: self.organize_library() - logging.info(f'Creature addition complete. Total creatures (including commander): {self.creature_cards}') + logger.info(f'Creature addition complete. Total creatures (including commander): {self.creature_cards}') def add_ramp(self): try: @@ -1808,45 +1713,45 @@ class DeckBuilder: self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df) self.add_by_tags('Ramp', math.ceil(self.ideal_ramp / 2), self.noncreature_df) except Exception as e: - logging.error(f"Error while adding Ramp: {e}") + logger.error(f"Error while adding Ramp: {e}") finally: - logging.info('Adding Ramp complete.') + logger.info('Adding Ramp complete.') def add_interaction(self): try: self.add_by_tags('Removal', self.ideal_removal, self.noncreature_nonplaneswaker_df) self.add_by_tags('Protection', self.ideal_protection, self.noncreature_nonplaneswaker_df) except Exception as e: - logging.error(f"Error while adding Interaction: {e}") + logger.error(f"Error while adding Interaction: {e}") finally: - logging.info('Adding Interaction complete.') + logger.info('Adding Interaction complete.') def add_board_wipes(self): try: self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df) except Exception as e: - logging.error(f"Error while adding Board Wipes: {e}") + logger.error(f"Error while adding Board Wipes: {e}") finally: - logging.info('Adding Board Wipes complete.') + logger.info('Adding Board Wipes complete.') def add_card_advantage(self): try: self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df) self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.noncreature_nonplaneswaker_df) except Exception as e: - logging.error(f"Error while adding Card Draw: {e}") + logger.error(f"Error while adding Card Draw: {e}") finally: - logging.info('Adding Card Draw complete.') + logger.info('Adding Card Draw complete.') def fill_out_deck(self): """Fill out the deck to 100 cards with theme-appropriate cards.""" - logging.info('Filling out the Library to 100 with cards fitting the themes.') + logger.info('Filling out the Library to 100 with cards fitting the themes.') cards_needed = 100 - len(self.card_library) if cards_needed <= 0: return - logging.info(f"Need to add {cards_needed} more cards") + logger.info(f"Need to add {cards_needed} more cards") # Define maximum attempts and timeout MAX_ATTEMPTS = max(20, cards_needed * 2) @@ -1857,7 +1762,7 @@ class DeckBuilder: while len(self.card_library) < 100 and attempts < MAX_ATTEMPTS: # Check timeout if time.time() - start_time > MAX_TIME: - logging.error("Timeout reached while filling deck") + logger.error("Timeout reached while filling deck") break initial_count = len(self.card_library) @@ -1884,23 +1789,23 @@ class DeckBuilder: if len(self.card_library) == initial_count: attempts += 1 if attempts % 5 == 0: - logging.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") + logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") # Break early if we're stuck if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4): - logging.warning("Insufficient progress being made, breaking early") + logger.warning("Insufficient progress being made, breaking early") break except Exception as e: - logging.error(f"Error while adding cards: {e}") + logger.error(f"Error while adding cards: {e}") attempts += 1 final_count = len(self.card_library) if final_count < 100: message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed." - logging.warning(message) + logger.warning(message) else: - logging.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") + logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") def main(): """Main entry point for deck builder application.""" build_deck = DeckBuilder() From e0dd09adeea99a658d0c6cff5b1c2acf2ea59fdc Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Tue, 14 Jan 2025 10:10:30 -0800 Subject: [PATCH 2/6] Began work on refactoring deck_builder Fixed logging for the other files such that they actually log to the file instead of just creating it --- exceptions.py | 76 ++- input_handler.py | 212 +++++++ main.py | 34 +- price_check.py | 60 ++ settings.py | 31 + setup.py | 25 +- setup_utils.py | 77 ++- tagger.py | 1520 +++++++++++++++++++++++----------------------- 8 files changed, 1228 insertions(+), 807 deletions(-) create mode 100644 input_handler.py create mode 100644 price_check.py diff --git a/exceptions.py b/exceptions.py index adcab51..62e1baf 100644 --- a/exceptions.py +++ b/exceptions.py @@ -119,4 +119,78 @@ class CommanderValidationError(MTGSetupError): self.validation_type = validation_type self.details = details error_info = f" - {details}" if details else "" - super().__init__(f"{message} [{validation_type}]{error_info}") \ No newline at end of file + super().__init__(f"{message} [{validation_type}]{error_info}") + + +class InputValidationError(MTGSetupError): + """Exception raised when input validation fails. + + This exception is raised when there are issues validating user input, + such as invalid text formats, number ranges, or confirmation responses. + + Args: + message: Explanation of the error + input_type: Type of input validation that failed (e.g., 'text', 'number', 'confirm') + details: Additional error details + + Examples: + >>> raise InputValidationError( + ... "Invalid number input", + ... "number", + ... "Value must be between 1 and 100" + ... ) + + >>> raise InputValidationError( + ... "Invalid confirmation response", + ... "confirm", + ... "Please enter 'y' or 'n'" + ... ) + + >>> raise InputValidationError( + ... "Invalid text format", + ... "text", + ... "Input contains invalid characters" + ... ) + """ + def __init__(self, message: str, input_type: str, details: str = None) -> None: + self.input_type = input_type + self.details = details + error_info = f" - {details}" if details else "" + super().__init__(f"{message} [{input_type}]{error_info}") + + +class PriceCheckError(MTGSetupError): + """Exception raised when price checking operations fail. + + This exception is raised when there are issues retrieving or processing + card prices, such as API failures, invalid responses, or parsing errors. + + Args: + message: Explanation of the error + card_name: Name of the card that caused the error + details: Additional error details + + Examples: + >>> raise PriceCheckError( + ... "Failed to retrieve price", + ... "Black Lotus", + ... "API request timeout" + ... ) + + >>> raise PriceCheckError( + ... "Invalid price data format", + ... "Lightning Bolt", + ... "Unexpected response structure" + ... ) + + >>> raise PriceCheckError( + ... "Price data unavailable", + ... "Underground Sea", + ... "No price information found" + ... ) + """ + def __init__(self, message: str, card_name: str, details: str = None) -> None: + self.card_name = card_name + self.details = details + error_info = f" - {details}" if details else "" + super().__init__(f"{message} for card '{card_name}'{error_info}") \ No newline at end of file diff --git a/input_handler.py b/input_handler.py new file mode 100644 index 0000000..2b7a842 --- /dev/null +++ b/input_handler.py @@ -0,0 +1,212 @@ +"""Input validation and handling for MTG Python Deckbuilder. + +This module provides the InputHandler class which encapsulates all input validation +and handling logic. It supports different types of input validation including text, +numbers, confirmations, and multiple choice questions. +""" + +from typing import Any, List, Optional, Union +import inquirer +import logging +import os + +from exceptions import InputValidationError +from settings import INPUT_VALIDATION, QUESTION_TYPES + +# Create logs directory if it doesn't exist +if not os.path.exists('logs'): + os.makedirs('logs') + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('logs/input_handlers.log', mode='a', encoding='utf-8') + ] +) +logger = logging.getLogger(__name__) + +class InputHandler: + """Handles input validation and user interaction. + + This class provides methods for validating different types of user input + and handling user interaction through questionnaires. It uses constants + from settings.py for validation messages and configuration. + """ + + def validate_text(self, result: str) -> bool: + """Validate text input is not empty. + + Args: + result: Text input to validate + + Returns: + bool: True if text is not empty after stripping whitespace + + Raises: + InputValidationError: If text validation fails + """ + try: + if not result or not result.strip(): + raise InputValidationError( + INPUT_VALIDATION['default_text_message'], + 'text', + 'Input cannot be empty' + ) + return True + except Exception as e: + raise InputValidationError( + str(e), + 'text', + 'Unexpected error during text validation' + ) + + def validate_number(self, result: str) -> Optional[float]: + """Validate and convert string input to float. + + Args: + result: Number input to validate + + Returns: + float | None: Converted float value or None if invalid + + Raises: + InputValidationError: If number validation fails + """ + try: + if not result: + raise InputValidationError( + INPUT_VALIDATION['default_number_message'], + 'number', + 'Input cannot be empty' + ) + return float(result) + except ValueError: + raise InputValidationError( + INPUT_VALIDATION['default_number_message'], + 'number', + 'Input must be a valid number' + ) + except Exception as e: + raise InputValidationError( + str(e), + 'number', + 'Unexpected error during number validation' + ) + + def validate_confirm(self, result: Any) -> bool: + """Validate confirmation input. + + Args: + result: Confirmation input to validate + + Returns: + bool: True for positive confirmation, False otherwise + + Raises: + InputValidationError: If confirmation validation fails + """ + try: + if isinstance(result, bool): + return result + if isinstance(result, str): + result = result.lower().strip() + if result in ('y', 'yes', 'true', '1'): + return True + if result in ('n', 'no', 'false', '0'): + return False + raise InputValidationError( + INPUT_VALIDATION['default_confirm_message'], + 'confirm', + 'Invalid confirmation response' + ) + except InputValidationError: + raise + except Exception as e: + raise InputValidationError( + str(e), + 'confirm', + 'Unexpected error during confirmation validation' + ) + + def questionnaire( + self, + question_type: str, + default_value: Union[str, bool, float] = '', + choices_list: List[str] = [] + ) -> Union[str, bool, float]: + """Present questions to user and validate input. + + Args: + question_type: Type of question ('Text', 'Number', 'Confirm', 'Choice') + default_value: Default value for the question + choices_list: List of choices for Choice type questions + + Returns: + Union[str, bool, float]: Validated user input + + Raises: + InputValidationError: If input validation fails + ValueError: If question type is not supported + """ + if question_type not in QUESTION_TYPES: + raise ValueError(f"Unsupported question type: {question_type}") + + attempts = 0 + while attempts < INPUT_VALIDATION['max_attempts']: + try: + if question_type == 'Text': + question = [inquirer.Text('text')] + result = inquirer.prompt(question)['text'] + if self.validate_text(result): + return result + + elif question_type == 'Number': + question = [inquirer.Text('number', default=str(default_value))] + result = inquirer.prompt(question)['number'] + validated = self.validate_number(result) + if validated is not None: + return validated + + elif question_type == 'Confirm': + question = [inquirer.Confirm('confirm', default=default_value)] + result = inquirer.prompt(question)['confirm'] + return self.validate_confirm(result) + + elif question_type == 'Choice': + if not choices_list: + raise InputValidationError( + INPUT_VALIDATION['default_choice_message'], + 'choice', + 'No choices provided' + ) + question = [ + inquirer.List('selection', + choices=choices_list, + carousel=True) + ] + return inquirer.prompt(question)['selection'] + + except InputValidationError as e: + attempts += 1 + if attempts >= INPUT_VALIDATION['max_attempts']: + raise InputValidationError( + "Maximum input attempts reached", + question_type, + str(e) + ) + logger.warning(f"Invalid input ({attempts}/{INPUT_VALIDATION['max_attempts']}): {str(e)}") + + except Exception as e: + raise InputValidationError( + str(e), + question_type, + 'Unexpected error during questionnaire' + ) + + raise InputValidationError( + "Maximum input attempts reached", + question_type, + "Failed to get valid input" + ) \ No newline at end of file diff --git a/main.py b/main.py index 9c58cbc..e8ece27 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ from __future__ import annotations # Standard library imports import sys import logging +import os from pathlib import Path 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 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( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ 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_SETUP = 'Setup' @@ -62,8 +67,9 @@ def get_menu_choice() -> Optional[str]: answer = inquirer.prompt(question) # type: ignore return answer['menu'] if answer else None except (KeyError, TypeError) as e: - logging.error(f"Error getting menu choice: {e}") + logger.error(f"Error getting menu choice: {e}") return None + def handle_card_info() -> None: """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']: break 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 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: - """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. Creates required directories, processes menu choices, and handles errors gracefully. @@ -117,7 +124,7 @@ def run_menu() -> NoReturn: 4. Tag CSV Files 5. Quit """ - logging.info("Starting MTG Python Deckbuilder") + logger.info("Starting MTG Python Deckbuilder") Path('csv_files').mkdir(parents=True, exist_ok=True) while True: @@ -126,29 +133,30 @@ def run_menu() -> NoReturn: choice = get_menu_choice() if choice is None: - logging.info("Menu operation cancelled") + logger.info("Menu operation cancelled") continue - logging.info(f"User selected: {choice}") + logger.info(f"User selected: {choice}") match choice: case 'Setup': setup.setup() tagger.run_tagging() case 'Build a Deck': - logging.info("Deck building not yet implemented") + logger.info("Deck building not yet implemented") print('Deck building not yet implemented') case 'Get Card Info': handle_card_info() case 'Tag CSV Files': tagger.run_tagging() case 'Quit': - logging.info("Exiting application") + logger.info("Exiting application") sys.exit(0) case _: - logging.warning(f"Invalid menu choice: {choice}") + logger.warning(f"Invalid menu choice: {choice}") 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__": run_menu() \ No newline at end of file diff --git a/price_check.py b/price_check.py new file mode 100644 index 0000000..7023a87 --- /dev/null +++ b/price_check.py @@ -0,0 +1,60 @@ +"""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. +""" + +import time +from functools import lru_cache +from typing import Optional + +import scrython +from scrython.cards import Named + +from exceptions import PriceCheckError +from settings import PRICE_CHECK_CONFIG + +@lru_cache(maxsize=PRICE_CHECK_CONFIG['cache_size']) +def check_price(card_name: str) -> float: + """Retrieve the current price of a Magic: The Gathering card. + + Args: + card_name: The name of the card to check. + + Returns: + float: The current price of the card in USD. + + Raises: + PriceCheckError: If there are any issues retrieving the price. + """ + retries = 0 + last_error = None + + while retries < PRICE_CHECK_CONFIG['max_retries']: + try: + card = Named(fuzzy=card_name) + price = card.prices('usd') + print(price) + + if price is None: + raise PriceCheckError( + "No price data available", + card_name, + "Card may be too new or not available in USD" + ) + + return float(price) + + except (scrython.ScryfallError, ValueError) as e: + last_error = str(e) + retries += 1 + if retries < PRICE_CHECK_CONFIG['max_retries']: + time.sleep(0.1) # Brief delay before retry + continue + + raise PriceCheckError( + "Failed to retrieve price after multiple attempts", + card_name, + f"Last error: {last_error}" + ) \ No newline at end of file diff --git a/settings.py b/settings.py index 08f6a95..2177c74 100644 --- a/settings.py +++ b/settings.py @@ -10,6 +10,23 @@ and enable static type checking with mypy. from typing import Dict, List, Optional +# Constants for input validation +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', 'Junk','Map','Powerstone', 'Treasure'] @@ -777,6 +794,20 @@ VOLTRON_PATTERNS = [ '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 +} # Constants for setup and CSV processing MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv' diff --git a/setup.py b/setup.py index 99c8d9b..e0f865a 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from enum import Enum from pathlib import Path +import os from typing import Union, List, Dict, Any # Third-party imports @@ -21,11 +22,17 @@ from exceptions import ( CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError, ColorFilterError, CommanderValidationError ) -# Configure logging +# Create logs directory if it doesn't exist +if not os.path.exists('logs'): + os.makedirs('logs') + logging.basicConfig( level=logging.INFO, 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__) @@ -288,23 +295,23 @@ def setup() -> bool: choice = _display_setup_menu() if choice == SetupOption.INITIAL_SETUP: - logging.info('Starting initial setup') + logger.info('Starting initial setup') initial_setup() - logging.info('Initial setup completed successfully') + logger.info('Initial setup completed successfully') return True elif choice == SetupOption.REGENERATE_CSV: - logging.info('Starting CSV regeneration') + logger.info('Starting CSV regeneration') regenerate_csvs_all() - logging.info('CSV regeneration completed successfully') + logger.info('CSV regeneration completed successfully') return True elif choice == SetupOption.BACK: - logging.info('Setup cancelled by user') + logger.info('Setup cancelled by user') return False except Exception as e: - logging.error(f'Error during setup: {e}') + logger.error(f'Error during setup: {e}') raise - return False \ No newline at end of file + return False diff --git a/setup_utils.py b/setup_utils.py index 6a1b4cb..ee60062 100644 --- a/setup_utils.py +++ b/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 # Standard library imports import logging +import os import requests from pathlib import Path from typing import List, Optional, Union, TypedDict @@ -27,21 +44,19 @@ from exceptions import ( CommanderValidationError ) -"""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 -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. -""" +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('logs/setup_utils.log', mode='a', encoding='utf-8') + ] +) +logger = logging.getLogger(__name__) # Type definitions class FilterRule(TypedDict): @@ -83,7 +98,7 @@ def download_cards_csv(url: str, output_path: Union[str, Path]) -> None: pbar.update(size) 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( "Failed to download cards data", url, @@ -128,14 +143,14 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame: >>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus']) """ try: - logging.info('Starting standard DataFrame filtering') + logger.info('Starting standard DataFrame filtering') # Fill null values according to configuration for col, fill_value in FILL_NA_COLUMNS.items(): if col == 'faceName': fill_value = df['name'] 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 filtered_df = df.copy() @@ -148,22 +163,22 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame: elif rule_type == 'require': for value in values: 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 for set_code in NON_LEGAL_SETS: 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 for card in banned_cards: 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 for card_type in CARD_TYPES_TO_EXCLUDE: 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 filtered_df = filtered_df[CSV_PROCESSING_COLUMNS] @@ -172,12 +187,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 ) 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 except Exception as e: - logging.error(f'Failed to filter DataFrame: {str(e)}') + logger.error(f'Failed to filter DataFrame: {str(e)}') raise DataFrameProcessingError( "Failed to filter DataFrame", "standard_filtering", @@ -202,7 +217,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr DataFrameProcessingError: If general filtering operations fail """ try: - logging.info(f'Filtering cards for color identity: {color_identity}') + logger.info(f'Filtering cards for color identity: {color_identity}') # Validate color identity with tqdm(total=1, desc='Validating color identity') as pbar: @@ -222,14 +237,14 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr # Filter by color identity with tqdm(total=1, desc='Filtering by color identity') as pbar: 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) # Additional color-specific processing with tqdm(total=1, desc='Performing color-specific processing') as pbar: # Placeholder for future color-specific processing 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 except DataFrameProcessingError as e: @@ -259,7 +274,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: DataFrameProcessingError: If general processing fails """ try: - logging.info('Starting commander validation process') + logger.info('Starting commander validation process') filtered_df = df.copy() # Step 1: Check legendary status @@ -273,7 +288,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: "DataFrame contains no cards matching legendary criteria" ) 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) except Exception as e: raise CommanderValidationError( @@ -288,7 +303,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: special_cases = df['text'].str.contains('can be your commander', na=False) special_commanders = df[special_cases].copy() 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) except Exception as e: raise CommanderValidationError( @@ -306,7 +321,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: ~filtered_df['printings'].str.contains(set_code, na=False) ] 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) except Exception as e: raise CommanderValidationError( @@ -314,7 +329,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: "set_legality", str(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 except CommanderValidationError: diff --git a/tagger.py b/tagger.py index 4d01c99..51bfc82 100644 --- a/tagger.py +++ b/tagger.py @@ -10,7 +10,7 @@ from typing import Union import pandas as pd # type: ignore import settings # type: ignore -import tag_utility # type: ignore +import tag_utils # type: ignore # Local application imports from settings import csv_directory, multiple_copy_cards, num_to_search, triggers @@ -40,14 +40,19 @@ PATTERN_GROUPS = { "cost_reduction": r"cost[s]? \{[\d\w]\} less|affinity for|cost[s]? less to cast|chosen type cost|copy cost|from exile cost|from exile this turn cost|from your graveyard cost|has undaunted|have affinity for artifacts|other than your hand cost|spells cost|spells you cast cost|that target .* cost|those spells cost|you cast cost|you pay cost" } +# Create logs directory if it doesn't exist +if not os.path.exists('logs'): + os.makedirs('logs') + logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(), - logging.FileHandler('tagger.log', mode='w') + logging.FileHandler('logs/tagger.log', mode='w', encoding='utf-8') ] ) +logger = logging.getLogger(__name__) ### Setup ## Load the dataframe @@ -67,7 +72,7 @@ def load_dataframe(color: str) -> None: # Check if file exists, regenerate if needed if not os.path.exists(filepath): - logging.warning(f'{color}_cards.csv not found, regenerating it.') + logger.warning(f'{color}_cards.csv not found, regenerating it.') regenerate_csv_by_color(color) if not os.path.exists(filepath): raise FileNotFoundError(f"Failed to generate {filepath}") @@ -81,7 +86,7 @@ def load_dataframe(color: str) -> None: # Handle missing columns if missing_columns: - logging.warning(f"Missing columns: {missing_columns}") + logger.warning(f"Missing columns: {missing_columns}") if 'creatureTypes' not in check_df.columns: kindred_tagging(check_df, color) if 'themeTags' not in check_df.columns: @@ -100,13 +105,13 @@ def load_dataframe(color: str) -> None: tag_by_color(df, color) except FileNotFoundError as e: - logging.error(f'Error: {e}') + logger.error(f'Error: {e}') raise except pd.errors.ParserError as e: - logging.error(f'Error parsing the CSV file: {e}') + logger.error(f'Error parsing the CSV file: {e}') raise except Exception as e: - logging.error(f'An unexpected error occurred: {e}') + logger.error(f'An unexpected error occurred: {e}') raise ## Tag cards on a color-by-color basis @@ -168,7 +173,7 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: df.to_csv(f'{csv_directory}/{color}_cards.csv', index=False) #print(df) print('\n====================\n') - logging.info(f'Tags are done being set on {color}_cards.csv') + logger.info(f'Tags are done being set on {color}_cards.csv') #keyboard.wait('esc') ## Determine any non-creature cards that have creature types mentioned @@ -180,18 +185,18 @@ def kindred_tagging(df: pd.DataFrame, color: str) -> None: color: Color identifier for logging """ start_time = pd.Timestamp.now() - logging.info(f'Setting creature type tags on {color}_cards.csv') + logger.info(f'Setting creature type tags on {color}_cards.csv') try: # Initialize creatureTypes column vectorized df['creatureTypes'] = pd.Series([[] for _ in range(len(df))]) # Detect creature types using mask - creature_mask = tag_utility.create_type_mask(df, 'Creature') + creature_mask = tag_utils.create_type_mask(df, 'Creature') if creature_mask.any(): creature_rows = df[creature_mask] for idx, row in creature_rows.iterrows(): - types = tag_utility.extract_creature_types( + types = tag_utils.extract_creature_types( row['type'], settings.creature_types, settings.non_creature_types @@ -200,25 +205,25 @@ def kindred_tagging(df: pd.DataFrame, color: str) -> None: df.at[idx, 'creatureTypes'] = types creature_time = pd.Timestamp.now() - logging.info(f'Creature type detection completed in {(creature_time - start_time).total_seconds():.2f}s') + logger.info(f'Creature type detection completed in {(creature_time - start_time).total_seconds():.2f}s') print('\n==========\n') - logging.info(f'Setting Outlaw creature type tags on {color}_cards.csv') + logger.info(f'Setting Outlaw creature type tags on {color}_cards.csv') # Process outlaw types outlaws = settings.OUTLAW_TYPES df['creatureTypes'] = df.apply( - lambda row: tag_utility.add_outlaw_type(row['creatureTypes'], outlaws) + lambda row: tag_utils.add_outlaw_type(row['creatureTypes'], outlaws) if isinstance(row['creatureTypes'], list) else row['creatureTypes'], axis=1 ) outlaw_time = pd.Timestamp.now() - logging.info(f'Outlaw type processing completed in {(outlaw_time - creature_time).total_seconds():.2f}s') + logger.info(f'Outlaw type processing completed in {(outlaw_time - creature_time).total_seconds():.2f}s') # Find creature types in text - logging.info('Checking for creature types in card text') + logger.info('Checking for creature types in card text') # Check for creature types in text (i.e. how 'Voja, Jaws of the Conclave' cares about Elves) - logging.info(f'Checking for and setting creature types found in the text of cards in {color}_cards.csv') + logger.info(f'Checking for and setting creature types found in the text of cards in {color}_cards.csv') ignore_list = [ 'Elite Inquisitor', 'Breaker of Armies', 'Cleopatra, Exiled Pharaoh', 'Nath\'s Buffoon' @@ -226,7 +231,7 @@ def kindred_tagging(df: pd.DataFrame, color: str) -> None: for idx, row in df.iterrows(): if row['name'] not in ignore_list: - text_types = tag_utility.find_types_in_text( + text_types = tag_utils.find_types_in_text( row['text'], row['name'], settings.creature_types @@ -239,7 +244,7 @@ def kindred_tagging(df: pd.DataFrame, color: str) -> None: ) text_time = pd.Timestamp.now() - logging.info(f'Text-based type detection completed in {(text_time - outlaw_time).total_seconds():.2f}s') + logger.info(f'Text-based type detection completed in {(text_time - outlaw_time).total_seconds():.2f}s') # Save results try: @@ -252,14 +257,14 @@ def kindred_tagging(df: pd.DataFrame, color: str) -> None: df = df[columns_to_keep] df.to_csv(f'{settings.csv_directory}/{color}_cards.csv', index=False) total_time = pd.Timestamp.now() - start_time - logging.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') + logger.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error saving results: {e}') + logger.error(f'Error saving results: {e}') # Overwrite file with creature type tags except Exception as e: - logging.error(f'Error in kindred_tagging: {e}') + logger.error(f'Error in kindred_tagging: {e}') raise def create_theme_tags(df: pd.DataFrame, color: str) -> None: @@ -281,7 +286,7 @@ def create_theme_tags(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info('Initializing theme tags for %s cards', color) + logger.info('Initializing theme tags for %s cards', color) # Validate inputs if not isinstance(df, pd.DataFrame): @@ -317,18 +322,18 @@ def create_theme_tags(df: pd.DataFrame, color: str) -> None: try: df.to_csv(f'{settings.csv_directory}/{color}_cards.csv', index=False) total_time = pd.Timestamp.now() - start_time - logging.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') + logger.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') # Log performance metrics end_time = pd.Timestamp.now() duration = (end_time - start_time).total_seconds() - logging.info('Theme tags initialized in %.2f seconds', duration) + logger.info('Theme tags initialized in %.2f seconds', duration) except Exception as e: - logging.error(f'Error saving results: {e}') + logger.error(f'Error saving results: {e}') except Exception as e: - logging.error('Error initializing theme tags: %s', str(e)) + logger.error('Error initializing theme tags: %s', str(e)) raise def tag_for_card_types(df: pd.DataFrame, color: str) -> None: @@ -346,7 +351,7 @@ def tag_for_card_types(df: pd.DataFrame, color: str) -> None: ValueError: If required columns are missing """ start_time = pd.Timestamp.now() - logging.info('Setting card type tags on %s_cards.csv', color) + logger.info('Setting card type tags on %s_cards.csv', color) try: # Validate required columns @@ -359,20 +364,20 @@ def tag_for_card_types(df: pd.DataFrame, color: str) -> None: # Process each card type for card_type, tags in type_tag_map.items(): - mask = tag_utility.create_type_mask(df, card_type) + mask = tag_utils.create_type_mask(df, card_type) if mask.any(): - tag_utility.apply_tag_vectorized(df, mask, tags) - logging.info('Tagged %d cards with %s type', mask.sum(), card_type) + tag_utils.apply_tag_vectorized(df, mask, tags) + logger.info('Tagged %d cards with %s type', mask.sum(), card_type) # Log completion duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Card type tagging completed in %.2fs', duration) + logger.info('Card type tagging completed in %.2fs', duration) except Exception as e: - logging.error('Error in tag_for_card_types: %s', str(e)) + logger.error('Error in tag_for_card_types: %s', str(e)) raise # Overwrite file with artifact tag added - logging.info(f'Card type tags set on {color}_cards.csv.') + logger.info(f'Card type tags set on {color}_cards.csv.') ## Add creature types to the theme tags def add_creatures_to_tags(df: pd.DataFrame, color: str) -> None: @@ -390,7 +395,7 @@ def add_creatures_to_tags(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Adding creature types to theme tags in {color}_cards.csv') + logger.info(f'Adding creature types to theme tags in {color}_cards.csv') try: # Validate inputs @@ -422,16 +427,16 @@ def add_creatures_to_tags(df: pd.DataFrame, color: str) -> None: df.loc[has_creatures_mask, 'themeTags'] = creature_rows.apply(add_kindred_tags, axis=1) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Added kindred tags to {has_creatures_mask.sum()} cards in {duration:.2f}s') + logger.info(f'Added kindred tags to {has_creatures_mask.sum()} cards in {duration:.2f}s') else: - logging.info('No cards with creature types found') + logger.info('No cards with creature types found') except Exception as e: - logging.error(f'Error in add_creatures_to_tags: {str(e)}') + logger.error(f'Error in add_creatures_to_tags: {str(e)}') raise - logging.info(f'Creature types added to theme tags in {color}_cards.csv') + logger.info(f'Creature types added to theme tags in {color}_cards.csv') ## Add keywords to theme tags def tag_for_keywords(df: pd.DataFrame, color: str) -> None: @@ -441,7 +446,7 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info('Tagging cards with keywords in %s_cards.csv', color) + logger.info('Tagging cards with keywords in %s_cards.csv', color) start_time = pd.Timestamp.now() try: @@ -463,21 +468,21 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None: df.at[idx, 'themeTags'] = new_tags duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Tagged %d cards with keywords in %.2f seconds', has_keywords.sum(), duration) + logger.info('Tagged %d cards with keywords in %.2f seconds', has_keywords.sum(), duration) except Exception as e: - logging.error('Error tagging keywords: %s', str(e)) + logger.error('Error tagging keywords: %s', str(e)) raise ## Sort any set tags def sort_theme_tags(df, color): - logging.info(f'Alphabetically sorting theme tags in {color}_cards.csv.') + logger.info(f'Alphabetically sorting theme tags in {color}_cards.csv.') - df['themeTags'] = df['themeTags'].apply(tag_utility.sort_list) + df['themeTags'] = df['themeTags'].apply(tag_utils.sort_list) columns_to_keep = ['name', 'faceName','edhrecRank', 'colorIdentity', 'colors', 'manaCost', 'manaValue', 'type', 'creatureTypes', 'text', 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'] df = df[columns_to_keep] - logging.info(f'Theme tags alphabetically sorted in {color}_cards.csv.') + logger.info(f'Theme tags alphabetically sorted in {color}_cards.csv.') ### Cost reductions def tag_for_cost_reduction(df: pd.DataFrame, color: str) -> None: @@ -493,12 +498,12 @@ def tag_for_cost_reduction(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info('Tagging cost reduction cards in %s_cards.csv', color) + logger.info('Tagging cost reduction cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create masks for different cost reduction patterns - cost_mask = tag_utility.create_text_mask(df, PATTERN_GROUPS['cost_reduction']) + cost_mask = tag_utils.create_text_mask(df, PATTERN_GROUPS['cost_reduction']) # Add specific named cards named_cards = [ @@ -513,23 +518,23 @@ def tag_for_cost_reduction(df: pd.DataFrame, color: str) -> None: 'Thryx, the Sudden Storm', 'Urza\'s Filter', 'Will, Scion of Peace', 'Will Kenrith' ] - named_mask = tag_utility.create_name_mask(df, named_cards) + named_mask = tag_utils.create_name_mask(df, named_cards) # Combine masks final_mask = cost_mask | named_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Cost Reduction']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Cost Reduction']) # Add spellslinger tags for noncreature spell cost reduction - spell_mask = final_mask & tag_utility.create_text_mask(df, r"Sorcery|Instant|noncreature") - tag_utility.apply_tag_vectorized(df, spell_mask, ['Spellslinger', 'Spells Matter']) + spell_mask = final_mask & tag_utils.create_text_mask(df, r"Sorcery|Instant|noncreature") + tag_utils.apply_tag_vectorized(df, spell_mask, ['Spellslinger', 'Spells Matter']) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Tagged %d cost reduction cards in %.2fs', final_mask.sum(), duration) + logger.info('Tagged %d cost reduction cards in %.2fs', final_mask.sum(), duration) except Exception as e: - logging.error('Error tagging cost reduction cards: %s', str(e)) + logger.error('Error tagging cost reduction cards: %s', str(e)) raise ### Card draw/advantage @@ -557,7 +562,7 @@ def tag_for_card_draw(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting card draw effect tagging for {color}_cards.csv') + logger.info(f'Starting card draw effect tagging for {color}_cards.csv') try: # Validate inputs @@ -568,39 +573,39 @@ def tag_for_card_draw(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of draw effect tag_for_conditional_draw(df, color) - logging.info('Completed conditional draw tagging') + logger.info('Completed conditional draw tagging') print('\n==========\n') tag_for_loot_effects(df, color) - logging.info('Completed loot effects tagging') + logger.info('Completed loot effects tagging') print('\n==========\n') tag_for_cost_draw(df, color) - logging.info('Completed cost-based draw tagging') + logger.info('Completed cost-based draw tagging') print('\n==========\n') tag_for_replacement_draw(df, color) - logging.info('Completed replacement draw tagging') + logger.info('Completed replacement draw tagging') print('\n==========\n') tag_for_wheels(df, color) - logging.info('Completed wheel effects tagging') + logger.info('Completed wheel effects tagging') print('\n==========\n') tag_for_unconditional_draw(df, color) - logging.info('Completed unconditional draw tagging') + logger.info('Completed unconditional draw tagging') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all card draw tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all card draw tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_card_draw: {str(e)}') + logger.error(f'Error in tag_for_card_draw: {str(e)}') raise ## Conditional card draw (i.e. Rhystic Study or Trouble In Pairs) @@ -615,15 +620,15 @@ def create_unconditional_draw_mask(df: pd.DataFrame) -> pd.Series: """ # Create pattern for draw effects using num_to_search draw_patterns = [f'draw {num} card' for num in num_to_search] - draw_mask = tag_utility.create_text_mask(df, draw_patterns) + draw_mask = tag_utils.create_text_mask(df, draw_patterns) # Create exclusion mask for conditional effects excluded_tags = settings.DRAW_RELATED_TAGS - tag_mask = tag_utility.create_tag_mask(df, excluded_tags) + tag_mask = tag_utils.create_tag_mask(df, excluded_tags) # Create text-based exclusions text_patterns = settings.DRAW_EXCLUSION_PATTERNS - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) return draw_mask & ~(tag_mask | text_mask) @@ -638,7 +643,7 @@ def tag_for_unconditional_draw(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging unconditional draw effects in {color}_cards.csv') + logger.info(f'Tagging unconditional draw effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -646,14 +651,14 @@ def tag_for_unconditional_draw(df: pd.DataFrame, color: str) -> None: draw_mask = create_unconditional_draw_mask(df) # Apply tags - tag_utility.apply_tag_vectorized(df, draw_mask, ['Unconditional Draw', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, draw_mask, ['Unconditional Draw', 'Card Draw']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {draw_mask.sum()} cards with unconditional draw effects in {duration:.2f}s') + logger.info(f'Tagged {draw_mask.sum()} cards with unconditional draw effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging unconditional draw effects: {str(e)}') + logger.error(f'Error tagging unconditional draw effects: {str(e)}') raise ## Conditional card draw (i.e. Rhystic Study or Trouble In Pairs) @@ -668,15 +673,15 @@ def create_conditional_draw_exclusion_mask(df: pd.DataFrame) -> pd.Series: """ # Create tag-based exclusions excluded_tags = settings.DRAW_RELATED_TAGS - tag_mask = tag_utility.create_tag_mask(df, excluded_tags) + tag_mask = tag_utils.create_tag_mask(df, excluded_tags) # Create text-based exclusions text_patterns = settings.DRAW_EXCLUSION_PATTERNS + ['whenever you draw a card'] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Create name-based exclusions excluded_names = ['relic vial', 'vexing bauble'] - name_mask = tag_utility.create_name_mask(df, excluded_names) + name_mask = tag_utils.create_name_mask(df, excluded_names) return tag_mask | text_mask | name_mask @@ -709,11 +714,11 @@ def create_conditional_draw_trigger_mask(df: pd.DataFrame) -> pd.Series: trigger_patterns.append(f'{trigger} .* attacks') # Create trigger mask - trigger_mask = tag_utility.create_text_mask(df, trigger_patterns) + trigger_mask = tag_utils.create_text_mask(df, trigger_patterns) # Add other trigger patterns other_patterns = ['created a token', 'draw a card for each'] - other_mask = tag_utility.create_text_mask(df, other_patterns) + other_mask = tag_utils.create_text_mask(df, other_patterns) return trigger_mask | other_mask @@ -756,7 +761,7 @@ def tag_for_conditional_draw(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging conditional draw effects in {color}_cards.csv') + logger.info(f'Tagging conditional draw effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -775,19 +780,19 @@ def tag_for_conditional_draw(df: pd.DataFrame, color: str) -> None: 'draw a card for each' ]) - draw_mask = tag_utility.create_text_mask(df, draw_patterns) + draw_mask = tag_utils.create_text_mask(df, draw_patterns) # Combine masks final_mask = trigger_mask & draw_mask & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Conditional Draw', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Conditional Draw', 'Card Draw']) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with conditional draw effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with conditional draw effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging conditional draw effects: {str(e)}') + logger.error(f'Error tagging conditional draw effects: {str(e)}') raise ## Loot effects, I.E. draw a card, discard a card. Or discard a card, draw a card @@ -801,7 +806,7 @@ def create_loot_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards have loot effects """ # Exclude cards that already have other loot-like effects - has_other_loot = tag_utility.create_tag_mask(df, ['Cycling', 'Connive']) | df['text'].str.contains('blood token', case=False, na=False) + has_other_loot = tag_utils.create_tag_mask(df, ['Cycling', 'Connive']) | df['text'].str.contains('blood token', case=False, na=False) # Match draw + discard patterns draw_patterns = [f'draw {num} card' for num in num_to_search] @@ -812,8 +817,8 @@ def create_loot_mask(df: pd.DataFrame) -> pd.Series: 'then discard' ] - has_draw = tag_utility.create_text_mask(df, draw_patterns) - has_discard = tag_utility.create_text_mask(df, discard_patterns) + has_draw = tag_utils.create_text_mask(df, draw_patterns) + has_discard = tag_utils.create_text_mask(df, discard_patterns) return ~has_other_loot & has_draw & has_discard @@ -826,8 +831,8 @@ def create_connive_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have connive effects """ - has_keyword = tag_utility.create_keyword_mask(df, 'Connive') - has_text = tag_utility.create_text_mask(df, 'connives?') + has_keyword = tag_utils.create_keyword_mask(df, 'Connive') + has_text = tag_utils.create_text_mask(df, 'connives?') return has_keyword | has_text def create_cycling_mask(df: pd.DataFrame) -> pd.Series: @@ -839,8 +844,8 @@ def create_cycling_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have cycling effects """ - has_keyword = tag_utility.create_keyword_mask(df, 'Cycling') - has_text = tag_utility.create_text_mask(df, 'cycling') + has_keyword = tag_utils.create_keyword_mask(df, 'Cycling') + has_text = tag_utils.create_text_mask(df, 'cycling') return has_keyword | has_text def create_blood_mask(df: pd.DataFrame) -> pd.Series: @@ -852,7 +857,7 @@ def create_blood_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have blood token effects """ - return tag_utility.create_text_mask(df, 'blood token') + return tag_utils.create_text_mask(df, 'blood token') def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None: """Tag cards with loot-like effects using vectorized operations. @@ -867,7 +872,7 @@ def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging loot-like effects in {color}_cards.csv') + logger.info(f'Tagging loot-like effects in {color}_cards.csv') # Create masks for each effect type loot_mask = create_loot_mask(df) @@ -877,22 +882,22 @@ def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None: # Apply tags based on masks if loot_mask.any(): - tag_utility.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw']) - logging.info(f'Tagged {loot_mask.sum()} cards with standard loot effects') + tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw']) + logger.info(f'Tagged {loot_mask.sum()} cards with standard loot effects') if connive_mask.any(): - tag_utility.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw']) - logging.info(f'Tagged {connive_mask.sum()} cards with connive effects') + tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw']) + logger.info(f'Tagged {connive_mask.sum()} cards with connive effects') if cycling_mask.any(): - tag_utility.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw']) - logging.info(f'Tagged {cycling_mask.sum()} cards with cycling effects') + tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw']) + logger.info(f'Tagged {cycling_mask.sum()} cards with cycling effects') if blood_mask.any(): - tag_utility.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw']) - logging.info(f'Tagged {blood_mask.sum()} cards with blood token effects') + tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw']) + logger.info(f'Tagged {blood_mask.sum()} cards with blood token effects') - logging.info('Completed tagging loot-like effects') + logger.info('Completed tagging loot-like effects') ## Sacrifice or pay life to draw effects def tag_for_cost_draw(df: pd.DataFrame, color: str) -> None: @@ -902,7 +907,7 @@ def tag_for_cost_draw(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info('Tagging cost-based draw effects in %s_cards.csv', color) + logger.info('Tagging cost-based draw effects in %s_cards.csv', color) # Split into life and sacrifice patterns life_pattern = 'life: draw' @@ -917,15 +922,15 @@ def tag_for_cost_draw(df: pd.DataFrame, color: str) -> None: # Apply life draw tags if life_mask.any(): - tag_utility.apply_tag_vectorized(df, life_mask, ['Life to Draw', 'Card Draw']) - logging.info('Tagged %d cards with life payment draw effects', life_mask.sum()) + tag_utils.apply_tag_vectorized(df, life_mask, ['Life to Draw', 'Card Draw']) + logger.info('Tagged %d cards with life payment draw effects', life_mask.sum()) # Apply sacrifice draw tags if sac_mask.any(): - tag_utility.apply_tag_vectorized(df, sac_mask, ['Sacrifice to Draw', 'Card Draw']) - logging.info('Tagged %d cards with sacrifice draw effects', sac_mask.sum()) + tag_utils.apply_tag_vectorized(df, sac_mask, ['Sacrifice to Draw', 'Card Draw']) + logger.info('Tagged %d cards with sacrifice draw effects', sac_mask.sum()) - logging.info('Completed tagging cost-based draw effects') + logger.info('Completed tagging cost-based draw effects') ## Replacement effects, that might have you draw more cards def create_replacement_draw_mask(df: pd.DataFrame) -> pd.Series: @@ -958,14 +963,14 @@ def create_replacement_draw_mask(df: pd.DataFrame) -> pd.Series: all_patterns = '|'.join(trigger_patterns + replacement_patterns) # Create base mask for replacement effects - base_mask = tag_utility.create_text_mask(df, all_patterns) + base_mask = tag_utils.create_text_mask(df, all_patterns) # Add mask for specific card numbers number_patterns = [f'draw {num} card' for num in num_to_search] - number_mask = tag_utility.create_text_mask(df, number_patterns) + number_mask = tag_utils.create_text_mask(df, number_patterns) # Add mask for non-specific numbers - nonspecific_mask = tag_utility.create_text_mask(df, 'draw that many plus|draws that many plus') # df['text'].str.contains('draw that many plus|draws that many plus', case=False, na=False) + nonspecific_mask = tag_utils.create_text_mask(df, 'draw that many plus|draws that many plus') # df['text'].str.contains('draw that many plus|draws that many plus', case=False, na=False) return base_mask & (number_mask | nonspecific_mask) @@ -980,11 +985,11 @@ def create_replacement_draw_exclusion_mask(df: pd.DataFrame) -> pd.Series: """ # Create tag-based exclusions excluded_tags = settings.DRAW_RELATED_TAGS - tag_mask = tag_utility.create_tag_mask(df, excluded_tags) + tag_mask = tag_utils.create_tag_mask(df, excluded_tags) # Create text-based exclusions text_patterns = settings.DRAW_EXCLUSION_PATTERNS + ['skips that turn instead'] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) return tag_mask | text_mask @@ -1004,7 +1009,7 @@ def tag_for_replacement_draw(df: pd.DataFrame, color: str) -> None: - Specific card number replacements - Non-specific card number replacements ("draw that many plus") """ - logging.info(f'Tagging replacement draw effects in {color}_cards.csv') + logger.info(f'Tagging replacement draw effects in {color}_cards.csv') try: # Create replacement draw mask @@ -1014,21 +1019,21 @@ def tag_for_replacement_draw(df: pd.DataFrame, color: str) -> None: exclusion_mask = create_replacement_draw_exclusion_mask(df) # Add specific card names - specific_cards_mask = tag_utility.create_name_mask(df, 'sylvan library') + specific_cards_mask = tag_utils.create_name_mask(df, 'sylvan library') # Combine masks final_mask = (replacement_mask & ~exclusion_mask) | specific_cards_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Replacement Draw', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Replacement Draw', 'Card Draw']) - logging.info(f'Tagged {final_mask.sum()} cards with replacement draw effects') + logger.info(f'Tagged {final_mask.sum()} cards with replacement draw effects') except Exception as e: - logging.error(f'Error tagging replacement draw effects: {str(e)}') + logger.error(f'Error tagging replacement draw effects: {str(e)}') raise - logging.info(f'Completed tagging replacement draw effects in {color}_cards.csv') + logger.info(f'Completed tagging replacement draw effects in {color}_cards.csv') ## Wheels def tag_for_wheels(df: pd.DataFrame, color: str) -> None: @@ -1043,7 +1048,7 @@ def tag_for_wheels(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging "Wheel" effects in {color}_cards.csv') + logger.info(f'Tagging "Wheel" effects in {color}_cards.csv') try: # Create masks for different wheel conditions @@ -1084,24 +1089,24 @@ def tag_for_wheels(df: pd.DataFrame, color: str) -> None: 'waste not', 'wedding ring', 'whispering madness' ] - text_mask = tag_utility.create_text_mask(df, wheel_patterns) - name_mask = tag_utility.create_name_mask(df, wheel_cards) + text_mask = tag_utils.create_text_mask(df, wheel_patterns) + name_mask = tag_utils.create_name_mask(df, wheel_cards) # Combine masks final_mask = text_mask | name_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Card Draw', 'Wheels']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Card Draw', 'Wheels']) # Add Draw Triggers tag for cards with trigger words trigger_pattern = '|'.join(triggers) trigger_mask = final_mask & df['text'].str.contains(trigger_pattern, case=False, na=False) - tag_utility.apply_tag_vectorized(df, trigger_mask, ['Draw Triggers']) + tag_utils.apply_tag_vectorized(df, trigger_mask, ['Draw Triggers']) - logging.info(f'Tagged {final_mask.sum()} cards with "Wheel" effects') + logger.info(f'Tagged {final_mask.sum()} cards with "Wheel" effects') except Exception as e: - logging.error(f'Error tagging "Wheel" effects: {str(e)}') + logger.error(f'Error tagging "Wheel" effects: {str(e)}') raise ### Artifacts @@ -1127,7 +1132,7 @@ def tag_for_artifacts(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting "Artifact" and "Artifacts Matter" tagging for {color}_cards.csv') + logger.info(f'Starting "Artifact" and "Artifacts Matter" tagging for {color}_cards.csv') print('\n==========\n') try: @@ -1139,27 +1144,27 @@ def tag_for_artifacts(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of draw effect tag_for_artifact_tokens(df, color) - logging.info('Completed Artifact token tagging') + logger.info('Completed Artifact token tagging') print('\n==========\n') tag_equipment(df, color) - logging.info('Completed Equipment tagging') + logger.info('Completed Equipment tagging') print('\n==========\n') tag_vehicles(df, color) - logging.info('Completed Vehicle tagging') + logger.info('Completed Vehicle tagging') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all "Artifact" and "Artifacts Matter" tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all "Artifact" and "Artifacts Matter" tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_artifacts: {str(e)}') + logger.error(f'Error in tag_for_artifacts: {str(e)}') raise ## Artifact Tokens @@ -1178,22 +1183,22 @@ def tag_for_artifact_tokens(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info('Setting artifact token tags on %s_cards.csv', color) + logger.info('Setting artifact token tags on %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Tag generic artifact tokens generic_mask = create_generic_artifact_mask(df) if generic_mask.any(): - tag_utility.apply_tag_vectorized(df, generic_mask, + tag_utils.apply_tag_vectorized(df, generic_mask, ['Artifact Tokens', 'Artifacts Matter', 'Token Creation', 'Tokens Matter']) - logging.info('Tagged %d cards with generic artifact token effects', generic_mask.sum()) + logger.info('Tagged %d cards with generic artifact token effects', generic_mask.sum()) # Tag predefined artifact tokens predefined_mask, token_map = create_predefined_artifact_mask(df) if predefined_mask.any(): # Apply base artifact token tags - tag_utility.apply_tag_vectorized(df, predefined_mask, + tag_utils.apply_tag_vectorized(df, predefined_mask, ['Artifact Tokens', 'Artifacts Matter', 'Token Creation', 'Tokens Matter']) # Track token type counts @@ -1202,26 +1207,26 @@ def tag_for_artifact_tokens(df: pd.DataFrame, color: str) -> None: # Apply specific token type tags for idx, token_type in token_map.items(): specific_tag = f'{token_type} Token' - tag_utility.apply_tag_vectorized(df.loc[idx:idx], pd.Series([True], index=[idx]), [specific_tag]) + tag_utils.apply_tag_vectorized(df.loc[idx:idx], pd.Series([True], index=[idx]), [specific_tag]) token_counts[token_type] = token_counts.get(token_type, 0) + 1 # Log results with token type counts - logging.info('Tagged %d cards with predefined artifact tokens:', predefined_mask.sum()) + logger.info('Tagged %d cards with predefined artifact tokens:', predefined_mask.sum()) for token_type, count in token_counts.items(): - logging.info(' - %s: %d cards', token_type, count) + logger.info(' - %s: %d cards', token_type, count) # Tag fabricate cards fabricate_mask = create_fabricate_mask(df) if fabricate_mask.any(): - tag_utility.apply_tag_vectorized(df, fabricate_mask, + tag_utils.apply_tag_vectorized(df, fabricate_mask, ['Artifact Tokens', 'Artifacts Matter', 'Token Creation', 'Tokens Matter']) - logging.info('Tagged %d cards with Fabricate', fabricate_mask.sum()) + logger.info('Tagged %d cards with Fabricate', fabricate_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed artifact token tagging in %.2fs', duration) + logger.info('Completed artifact token tagging in %.2fs', duration) except Exception as e: - logging.error('Error in tag_for_artifact_tokens: %s', str(e)) + logger.error('Error in tag_for_artifact_tokens: %s', str(e)) raise # Generic Artifact tokens, such as karnstructs, or artifact soldiers @@ -1241,11 +1246,11 @@ def create_generic_artifact_mask(df: pd.DataFrame) -> pd.Series: 'sandsteppe war riders', 'transmutation font' ] - name_exclusions = tag_utility.create_name_mask(df, excluded_cards) + name_exclusions = tag_utils.create_name_mask(df, excluded_cards) # Create text pattern matches create_pattern = r'create|put' - has_create = tag_utility.create_text_mask(df, create_pattern) + has_create = tag_utils.create_text_mask(df, create_pattern) token_patterns = [ 'artifact creature token', @@ -1255,7 +1260,7 @@ def create_generic_artifact_mask(df: pd.DataFrame) -> pd.Series: 'copy of target artifact', 'copy of that artifact' ] - has_token = tag_utility.create_text_mask(df, token_patterns) + has_token = tag_utils.create_text_mask(df, token_patterns) # Named cards that create artifact tokens named_cards = [ @@ -1266,10 +1271,10 @@ def create_generic_artifact_mask(df: pd.DataFrame) -> pd.Series: 'season of weaving', 'shaun, father of synths', 'sophia, dogged detective', 'vaultborn tyrant', 'wedding ring' ] - named_matches = tag_utility.create_name_mask(df, named_cards) + named_matches = tag_utils.create_name_mask(df, named_cards) # Exclude fabricate cards - has_fabricate = tag_utility.create_text_mask(df, 'fabricate') + has_fabricate = tag_utils.create_text_mask(df, 'fabricate') return (has_create & has_token & ~name_exclusions & ~has_fabricate) | named_matches @@ -1286,7 +1291,7 @@ def create_predefined_artifact_mask(df: pd.DataFrame) -> tuple[pd.Series, dict[i """ # Create base mask for 'create' text create_pattern = r'create|put' - has_create = tag_utility.create_text_mask(df, create_pattern) + has_create = tag_utils.create_text_mask(df, create_pattern) # Initialize token mapping dictionary token_map = {} @@ -1295,7 +1300,7 @@ def create_predefined_artifact_mask(df: pd.DataFrame) -> tuple[pd.Series, dict[i token_masks = [] for token in settings.artifact_tokens: - token_mask = tag_utility.create_text_mask(df, token.lower()) + token_mask = tag_utils.create_text_mask(df, token.lower()) # Handle exclusions if token == 'Blood': @@ -1326,7 +1331,7 @@ def create_fabricate_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have fabricate """ - return tag_utility.create_text_mask(df, 'fabricate') + return tag_utils.create_text_mask(df, 'fabricate') ## Artifact Triggers def create_artifact_triggers_mask(df: pd.DataFrame) -> pd.Series: @@ -1402,7 +1407,7 @@ def tag_for_artifact_triggers(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging cards that care about artifacts in {color}_cards.csv') + logger.info(f'Tagging cards that care about artifacts in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -1410,17 +1415,17 @@ def tag_for_artifact_triggers(df: pd.DataFrame, color: str) -> None: triggers_mask = create_artifact_triggers_mask(df) # Apply tags - tag_utility.apply_tag_vectorized(df, triggers_mask, ['Artifacts Matter']) + tag_utils.apply_tag_vectorized(df, triggers_mask, ['Artifacts Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {triggers_mask.sum()} cards with artifact triggers in {duration:.2f}s') + logger.info(f'Tagged {triggers_mask.sum()} cards with artifact triggers in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging artifact triggers: {str(e)}') + logger.error(f'Error tagging artifact triggers: {str(e)}') raise - logging.info(f'Completed tagging cards that care about artifacts in {color}_cards.csv') + logger.info(f'Completed tagging cards that care about artifacts in {color}_cards.csv') ## Equipment def create_equipment_mask(df: pd.DataFrame) -> pd.Series: @@ -1436,7 +1441,7 @@ def create_equipment_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards are Equipment """ # Create type-based mask - type_mask = tag_utility.create_type_mask(df, 'Equipment') + type_mask = tag_utils.create_type_mask(df, 'Equipment') return type_mask @@ -1466,15 +1471,15 @@ def create_equipment_cares_mask(df: pd.DataFrame) -> pd.Series: 'modified', 'reconfigure' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Create keyword mask keyword_patterns = ['Modified', 'Equip', 'Reconfigure'] - keyword_mask = tag_utility.create_keyword_mask(df, keyword_patterns) + keyword_mask = tag_utils.create_keyword_mask(df, keyword_patterns) # Create specific cards mask specific_cards = settings.EQUIPMENT_SPECIFIC_CARDS - name_mask = tag_utility.create_name_mask(df, specific_cards) + name_mask = tag_utils.create_name_mask(df, specific_cards) return text_mask | keyword_mask | name_mask @@ -1494,28 +1499,28 @@ def tag_equipment(df: pd.DataFrame, color: str) -> None: Raises: ValueError: If required DataFrame columns are missing """ - logging.info('Tagging Equipment cards in %s_cards.csv', color) + logger.info('Tagging Equipment cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create equipment mask equipment_mask = create_equipment_mask(df) if equipment_mask.any(): - tag_utility.apply_tag_vectorized(df, equipment_mask, ['Equipment', 'Equipment Matters', 'Voltron']) - logging.info('Tagged %d Equipment cards', equipment_mask.sum()) + tag_utils.apply_tag_vectorized(df, equipment_mask, ['Equipment', 'Equipment Matters', 'Voltron']) + logger.info('Tagged %d Equipment cards', equipment_mask.sum()) # Create equipment cares mask cares_mask = create_equipment_cares_mask(df) if cares_mask.any(): - tag_utility.apply_tag_vectorized(df, cares_mask, + tag_utils.apply_tag_vectorized(df, cares_mask, ['Artifacts Matter', 'Equipment Matters', 'Voltron']) - logging.info('Tagged %d cards that care about Equipment', cares_mask.sum()) + logger.info('Tagged %d cards that care about Equipment', cares_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Equipment tagging in %.2fs', duration) + logger.info('Completed Equipment tagging in %.2fs', duration) except Exception as e: - logging.error('Error tagging Equipment cards: %s', str(e)) + logger.error('Error tagging Equipment cards: %s', str(e)) raise ## Vehicles @@ -1534,13 +1539,13 @@ def create_vehicle_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards are Vehicles or care about them """ # Create type-based mask - type_mask = tag_utility.create_type_mask(df, ['Vehicle', 'Pilot']) + type_mask = tag_utils.create_type_mask(df, ['Vehicle', 'Pilot']) # Create text-based mask text_patterns = [ 'vehicle', 'crew', 'pilot', ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) return type_mask | text_mask @@ -1560,22 +1565,22 @@ def tag_vehicles(df: pd.DataFrame, color: str) -> None: Raises: ValueError: If required DataFrame columns are missing """ - logging.info('Tagging Vehicle cards in %s_cards.csv', color) + logger.info('Tagging Vehicle cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create vehicle mask vehicle_mask = create_vehicle_mask(df) if vehicle_mask.any(): - tag_utility.apply_tag_vectorized(df, vehicle_mask, + tag_utils.apply_tag_vectorized(df, vehicle_mask, ['Artifacts Matter', 'Vehicles']) - logging.info('Tagged %d Vehicle-related cards', vehicle_mask.sum()) + logger.info('Tagged %d Vehicle-related cards', vehicle_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Vehicle tagging in %.2fs', duration) + logger.info('Completed Vehicle tagging in %.2fs', duration) except Exception as e: - logging.error('Error tagging Vehicle cards: %s', str(e)) + logger.error('Error tagging Vehicle cards: %s', str(e)) raise ### Enchantments @@ -1606,7 +1611,7 @@ def tag_for_enchantments(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting "Enchantment" and "Enchantments Matter" tagging for {color}_cards.csv') + logger.info(f'Starting "Enchantment" and "Enchantments Matter" tagging for {color}_cards.csv') print('\n==========\n') try: # Validate inputs @@ -1617,51 +1622,51 @@ def tag_for_enchantments(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of enchantment effect tag_for_enchantment_tokens(df, color) - logging.info('Completed Enchantment token tagging') + logger.info('Completed Enchantment token tagging') print('\n==========\n') tag_for_enchantments_matter(df, color) - logging.info('Completed "Enchantments Matter" tagging') + logger.info('Completed "Enchantments Matter" tagging') print('\n==========\n') tag_auras(df, color) - logging.info('Completed Aura tagging') + logger.info('Completed Aura tagging') print('\n==========\n') tag_constellation(df, color) - logging.info('Completed Constellation tagging') + logger.info('Completed Constellation tagging') print('\n==========\n') tag_sagas(df, color) - logging.info('Completed Saga tagging') + logger.info('Completed Saga tagging') print('\n==========\n') tag_cases(df, color) - logging.info('Completed Case tagging') + logger.info('Completed Case tagging') print('\n==========\n') tag_rooms(df, color) - logging.info('Completed Room tagging') + logger.info('Completed Room tagging') print('\n==========\n') tag_backgrounds(df, color) - logging.info('Completed Background tagging') + logger.info('Completed Background tagging') print('\n==========\n') tag_shrines(df, color) - logging.info('Completed Shrine tagging') + logger.info('Completed Shrine tagging') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all "Enchantment" and "Enchantments Matter" tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all "Enchantment" and "Enchantments Matter" tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_artifacts: {str(e)}') + logger.error(f'Error in tag_for_artifacts: {str(e)}') raise ## Enchantment tokens @@ -1676,29 +1681,29 @@ def tag_for_enchantment_tokens(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info('Setting ehcantment token tags on %s_cards.csv', color) + logger.info('Setting ehcantment token tags on %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Tag generic artifact tokens generic_mask = create_generic_enchantment_mask(df) if generic_mask.any(): - tag_utility.apply_tag_vectorized(df, generic_mask, + tag_utils.apply_tag_vectorized(df, generic_mask, ['Enchantment Tokens', 'Enchantments Matter', 'Token Creation', 'Tokens Matter']) - logging.info('Tagged %d cards with generic enchantment token effects', generic_mask.sum()) + logger.info('Tagged %d cards with generic enchantment token effects', generic_mask.sum()) # Tag predefined artifact tokens predefined_mask = create_predefined_enchantment_mask(df) if predefined_mask.any(): - tag_utility.apply_tag_vectorized(df, predefined_mask, + tag_utils.apply_tag_vectorized(df, predefined_mask, ['Enchantment Tokens', 'Enchantments Matter', 'Token Creation', 'Tokens Matter']) - logging.info('Tagged %d cards with predefined enchantment tokens', predefined_mask.sum()) + logger.info('Tagged %d cards with predefined enchantment tokens', predefined_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed enchantment token tagging in %.2fs', duration) + logger.info('Completed enchantment token tagging in %.2fs', duration) except Exception as e: - logging.error('Error in tag_for_enchantment_tokens: %s', str(e)) + logger.error('Error in tag_for_enchantment_tokens: %s', str(e)) raise def create_generic_enchantment_mask(df: pd.DataFrame) -> pd.Series: @@ -1712,7 +1717,7 @@ def create_generic_enchantment_mask(df: pd.DataFrame) -> pd.Series: """ # Create text pattern matches create_pattern = r'create|put' - has_create = tag_utility.create_text_mask(df, create_pattern) + has_create = tag_utils.create_text_mask(df, create_pattern) token_patterns = [ 'copy of enchanted enchantment', @@ -1721,7 +1726,7 @@ def create_generic_enchantment_mask(df: pd.DataFrame) -> pd.Series: 'enchantment creature token', 'enchantment token' ] - has_token = tag_utility.create_text_mask(df, token_patterns) + has_token = tag_utils.create_text_mask(df, token_patterns) # Named cards that create enchantment tokens named_cards = [ @@ -1729,7 +1734,7 @@ def create_generic_enchantment_mask(df: pd.DataFrame) -> pd.Series: 'fellhide spiritbinder', 'hammer of purphoros' ] - named_matches = tag_utility.create_name_mask(df, named_cards) + named_matches = tag_utils.create_name_mask(df, named_cards) return (has_create & has_token) | named_matches @@ -1748,7 +1753,7 @@ def create_predefined_enchantment_mask(df: pd.DataFrame) -> pd.Series: # Create masks for each token type token_masks = [] for token in settings.enchantment_tokens: - token_mask = tag_utility.create_text_mask(df, token.lower()) + token_mask = tag_utils.create_text_mask(df, token.lower()) token_masks.append(token_mask) @@ -1768,7 +1773,7 @@ def tag_for_enchantments_matter(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging cards that care about enchantments in {color}_cards.csv') + logger.info(f'Tagging cards that care about enchantments in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -1811,26 +1816,26 @@ def tag_for_enchantments_matter(df: pd.DataFrame, color: str) -> None: ability_patterns + state_patterns + type_patterns + casting_patterns + counting_patterns + search_patterns + trigger_patterns ) - triggers_mask = tag_utility.create_text_mask(df, all_patterns) + triggers_mask = tag_utils.create_text_mask(df, all_patterns) # Create exclusion mask - exclusion_mask = tag_utility.create_name_mask(df, 'luxa river shrine') + exclusion_mask = tag_utils.create_name_mask(df, 'luxa river shrine') # Combine masks final_mask = triggers_mask & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Enchantments Matter']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Enchantments Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with enchantment triggers in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with enchantment triggers in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging enchantment triggers: {str(e)}') + logger.error(f'Error tagging enchantment triggers: {str(e)}') raise - logging.info(f'Completed tagging cards that care about enchantments in {color}_cards.csv') + logger.info(f'Completed tagging cards that care about enchantments in {color}_cards.csv') ## Aura def tag_auras(df: pd.DataFrame, color: str) -> None: @@ -1849,16 +1854,16 @@ def tag_auras(df: pd.DataFrame, color: str) -> None: Raises: ValueError: If required DataFrame columns are missing """ - logging.info('Tagging Aura cards in %s_cards.csv', color) + logger.info('Tagging Aura cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create Aura mask - aura_mask = tag_utility.create_type_mask(df, 'Aura') + aura_mask = tag_utils.create_type_mask(df, 'Aura') if aura_mask.any(): - tag_utility.apply_tag_vectorized(df, aura_mask, + tag_utils.apply_tag_vectorized(df, aura_mask, ['Auras', 'Enchantments Matter', 'Voltron']) - logging.info('Tagged %d Aura cards', aura_mask.sum()) + logger.info('Tagged %d Aura cards', aura_mask.sum()) # Create cares mask text_patterns = [ @@ -1867,17 +1872,17 @@ def tag_auras(df: pd.DataFrame, color: str) -> None: 'aura you control enters', 'enchanted' ] - cares_mask = tag_utility.create_text_mask(df, text_patterns) | tag_utility.create_name_mask(df, settings.AURA_SPECIFIC_CARDS) + cares_mask = tag_utils.create_text_mask(df, text_patterns) | tag_utils.create_name_mask(df, settings.AURA_SPECIFIC_CARDS) if cares_mask.any(): - tag_utility.apply_tag_vectorized(df, cares_mask, + tag_utils.apply_tag_vectorized(df, cares_mask, ['Auras', 'Enchantments Matter', 'Voltron']) - logging.info('Tagged %d cards that care about Auras', cares_mask.sum()) + logger.info('Tagged %d cards that care about Auras', cares_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Aura tagging in %.2fs', duration) + logger.info('Completed Aura tagging in %.2fs', duration) except Exception as e: - logging.error('Error tagging Aura cards: %s', str(e)) + logger.error('Error tagging Aura cards: %s', str(e)) raise ## Constellation @@ -1888,25 +1893,25 @@ def tag_constellation(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Constellation cards in {color}_cards.csv') + logger.info(f'Tagging Constellation cards in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create mask for constellation keyword - constellation_mask = tag_utility.create_keyword_mask(df, 'Constellation') + constellation_mask = tag_utils.create_keyword_mask(df, 'Constellation') # Apply tags - tag_utility.apply_tag_vectorized(df, constellation_mask, ['Constellation', 'Enchantments Matter']) + tag_utils.apply_tag_vectorized(df, constellation_mask, ['Constellation', 'Enchantments Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {constellation_mask.sum()} Constellation cards in {duration:.2f}s') + logger.info(f'Tagged {constellation_mask.sum()} Constellation cards in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Constellation cards: {str(e)}') + logger.error(f'Error tagging Constellation cards: {str(e)}') raise - logging.info('Completed tagging Constellation cards') + logger.info('Completed tagging Constellation cards') ## Sagas def tag_sagas(df: pd.DataFrame, color: str) -> None: @@ -1919,16 +1924,16 @@ def tag_sagas(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFramecolumns are missing """ - logging.info('Tagging Saga cards in %s_cards.csv', color) + logger.info('Tagging Saga cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create mask for Saga type - saga_mask = tag_utility.create_type_mask(df, 'Saga') + saga_mask = tag_utils.create_type_mask(df, 'Saga') if saga_mask.any(): - tag_utility.apply_tag_vectorized(df, saga_mask, + tag_utils.apply_tag_vectorized(df, saga_mask, ['Enchantments Matter', 'Sagas Matter']) - logging.info('Tagged %d Saga cards', saga_mask.sum()) + logger.info('Tagged %d Saga cards', saga_mask.sum()) # Create mask for cards that care about Sagas text_patterns = [ @@ -1937,20 +1942,20 @@ def tag_sagas(df: pd.DataFrame, color: str) -> None: 'final chapter', 'lore counter' ] - cares_mask = tag_utility.create_text_mask(df, text_patterns) # create_saga_cares_mask(df) + cares_mask = tag_utils.create_text_mask(df, text_patterns) # create_saga_cares_mask(df) if cares_mask.any(): - tag_utility.apply_tag_vectorized(df, cares_mask, + tag_utils.apply_tag_vectorized(df, cares_mask, ['Enchantments Matter', 'Sagas Matter']) - logging.info('Tagged %d cards that care about Sagas', cares_mask.sum()) + logger.info('Tagged %d cards that care about Sagas', cares_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Saga tagging in %.2fs', duration) + logger.info('Completed Saga tagging in %.2fs', duration) except Exception as e: - logging.error(f'Error tagging Saga cards: {str(e)}') + logger.error(f'Error tagging Saga cards: {str(e)}') raise - logging.info('Completed tagging Saga cards') + logger.info('Completed tagging Saga cards') ## Cases def tag_cases(df: pd.DataFrame, color: str) -> None: @@ -1963,32 +1968,32 @@ def tag_cases(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFramecolumns are missing """ - logging.info('Tagging Case cards in %s_cards.csv', color) + logger.info('Tagging Case cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create mask for Case type - saga_mask = tag_utility.create_type_mask(df, 'Case') + saga_mask = tag_utils.create_type_mask(df, 'Case') if saga_mask.any(): - tag_utility.apply_tag_vectorized(df, saga_mask, + tag_utils.apply_tag_vectorized(df, saga_mask, ['Enchantments Matter', 'Cases Matter']) - logging.info('Tagged %d Saga cards', saga_mask.sum()) + logger.info('Tagged %d Saga cards', saga_mask.sum()) # Create Case cares_mask - cares_mask = tag_utility.create_text_mask(df, 'solve a case') + cares_mask = tag_utils.create_text_mask(df, 'solve a case') if cares_mask.any(): - tag_utility.apply_tag_vectorized(df, cares_mask, + tag_utils.apply_tag_vectorized(df, cares_mask, ['Enchantments Matter', 'Cases Matter']) - logging.info('Tagged %d cards that care about Cases', cares_mask.sum()) + logger.info('Tagged %d cards that care about Cases', cares_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Case tagging in %.2fs', duration) + logger.info('Completed Case tagging in %.2fs', duration) except Exception as e: - logging.error(f'Error tagging Case cards: {str(e)}') + logger.error(f'Error tagging Case cards: {str(e)}') raise - logging.info('Completed tagging Case cards') + logger.info('Completed tagging Case cards') ## Rooms def tag_rooms(df: pd.DataFrame, color: str) -> None: @@ -2001,38 +2006,38 @@ def tag_rooms(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFramecolumns are missing """ - logging.info('Tagging Room cards in %s_cards.csv', color) + logger.info('Tagging Room cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create mask for Room type - room_mask = tag_utility.create_type_mask(df, 'Room') + room_mask = tag_utils.create_type_mask(df, 'Room') if room_mask.any(): - tag_utility.apply_tag_vectorized(df, room_mask, + tag_utils.apply_tag_vectorized(df, room_mask, ['Enchantments Matter', 'Rooms Matter']) - logging.info('Tagged %d Room cards', room_mask.sum()) + logger.info('Tagged %d Room cards', room_mask.sum()) # Create keyword mask for rooms - keyword_mask = tag_utility.create_keyword_mask(df, 'Eerie') + keyword_mask = tag_utils.create_keyword_mask(df, 'Eerie') if keyword_mask.any(): - tag_utility.apply_tag_vectorized(df, keyword_mask, + tag_utils.apply_tag_vectorized(df, keyword_mask, ['Enchantments Matter', 'Rooms Matter']) # Create rooms care mask - cares_mask = tag_utility.create_text_mask(df, 'target room') + cares_mask = tag_utils.create_text_mask(df, 'target room') if cares_mask.any(): - tag_utility.apply_tag_vectorized(df, cares_mask, + tag_utils.apply_tag_vectorized(df, cares_mask, ['Enchantments Matter', 'Rooms Matter']) - logging.info('Tagged %d cards that care about Rooms', cares_mask.sum()) + logger.info('Tagged %d cards that care about Rooms', cares_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Room tagging in %.2fs', duration) + logger.info('Completed Room tagging in %.2fs', duration) except Exception as e: - logging.error(f'Error tagging Room cards: {str(e)}') + logger.error(f'Error tagging Room cards: {str(e)}') raise - logging.info('Completed tagging Room cards') + logger.info('Completed tagging Room cards') ## Classes def tag_classes(df: pd.DataFrame, color: str) -> None: @@ -2045,25 +2050,25 @@ def tag_classes(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFramecolumns are missing """ - logging.info('Tagging Class cards in %s_cards.csv', color) + logger.info('Tagging Class cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create mask for class type - class_mask = tag_utility.create_type_mask(df, 'Class') + class_mask = tag_utils.create_type_mask(df, 'Class') if class_mask.any(): - tag_utility.apply_tag_vectorized(df, class_mask, + tag_utils.apply_tag_vectorized(df, class_mask, ['Enchantments Matter', 'Classes Matter']) - logging.info('Tagged %d Class cards', class_mask.sum()) + logger.info('Tagged %d Class cards', class_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Class tagging in %.2fs', duration) + logger.info('Completed Class tagging in %.2fs', duration) except Exception as e: - logging.error(f'Error tagging Class cards: {str(e)}') + logger.error(f'Error tagging Class cards: {str(e)}') raise - logging.info('Completed tagging Class cards') + logger.info('Completed tagging Class cards') ## Background def tag_backgrounds(df: pd.DataFrame, color: str) -> None: @@ -2076,32 +2081,32 @@ def tag_backgrounds(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFramecolumns are missing """ - logging.info('Tagging Background cards in %s_cards.csv', color) + logger.info('Tagging Background cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create mask for background type - class_mask = tag_utility.create_type_mask(df, 'Background') + class_mask = tag_utils.create_type_mask(df, 'Background') if class_mask.any(): - tag_utility.apply_tag_vectorized(df, class_mask, + tag_utils.apply_tag_vectorized(df, class_mask, ['Enchantments Matter', 'Backgrounds Matter']) - logging.info('Tagged %d Background cards', class_mask.sum()) + logger.info('Tagged %d Background cards', class_mask.sum()) # Create mask for Choose a Background - cares_mask = tag_utility.create_text_mask(df, 'Background') + cares_mask = tag_utils.create_text_mask(df, 'Background') if cares_mask.any(): - tag_utility.apply_tag_vectorized(df, cares_mask, + tag_utils.apply_tag_vectorized(df, cares_mask, ['Enchantments Matter', 'Backgroundss Matter']) - logging.info('Tagged %d cards that have Choose a Background', cares_mask.sum()) + logger.info('Tagged %d cards that have Choose a Background', cares_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Background tagging in %.2fs', duration) + logger.info('Completed Background tagging in %.2fs', duration) except Exception as e: - logging.error(f'Error tagging Background cards: {str(e)}') + logger.error(f'Error tagging Background cards: {str(e)}') raise - logging.info('Completed tagging Background cards') + logger.info('Completed tagging Background cards') ## Shrines def tag_shrines(df: pd.DataFrame, color: str) -> None: @@ -2114,25 +2119,25 @@ def tag_shrines(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFramecolumns are missing """ - logging.info('Tagging Shrine cards in %s_cards.csv', color) + logger.info('Tagging Shrine cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: # Create mask for shrine type - class_mask = tag_utility.create_type_mask(df, 'Shrine') + class_mask = tag_utils.create_type_mask(df, 'Shrine') if class_mask.any(): - tag_utility.apply_tag_vectorized(df, class_mask, + tag_utils.apply_tag_vectorized(df, class_mask, ['Enchantments Matter', 'Shrines Matter']) - logging.info('Tagged %d Shrine cards', class_mask.sum()) + logger.info('Tagged %d Shrine cards', class_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Shrine tagging in %.2fs', duration) + logger.info('Completed Shrine tagging in %.2fs', duration) except Exception as e: - logging.error(f'Error tagging Shrine cards: {str(e)}') + logger.error(f'Error tagging Shrine cards: {str(e)}') raise - logging.info('Completed tagging Shrine cards') + logger.info('Completed tagging Shrine cards') ### Exile Matters ## Exile Matter effects, such as Impuse draw, foretell, etc... @@ -2160,7 +2165,7 @@ def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting "Exile Matters" tagging for {color}_cards.csv') + logger.info(f'Starting "Exile Matters" tagging for {color}_cards.csv') print('\n==========\n') try: # Validate inputs @@ -2171,48 +2176,48 @@ def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of Exile matters effect tag_for_general_exile_matters(df, color) - logging.info('Completed general Exile Matters tagging') + logger.info('Completed general Exile Matters tagging') print('\n==========\n') tag_for_cascade(df, color) - logging.info('Completed Cascade tagging') + logger.info('Completed Cascade tagging') print('\n==========\n') tag_for_discover(df, color) - logging.info('Completed Disxover tagging') + logger.info('Completed Disxover tagging') print('\n==========\n') tag_for_foretell(df, color) - logging.info('Completed Foretell tagging') + logger.info('Completed Foretell tagging') print('\n==========\n') tag_for_imprint(df, color) - logging.info('Completed Imprint tagging') + logger.info('Completed Imprint tagging') print('\n==========\n') tag_for_impulse(df, color) - logging.info('Completed Impulse tagging') + logger.info('Completed Impulse tagging') print('\n==========\n') tag_for_plot(df, color) - logging.info('Completed Plot tagging') + logger.info('Completed Plot tagging') print('\n==========\n') tag_for_suspend(df, color) - logging.info('Completed Suspend tagging') + logger.info('Completed Suspend tagging') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_exile_matters: {str(e)}') + logger.error(f'Error in tag_for_exile_matters: {str(e)}') raise def tag_for_general_exile_matters(df: pd.DataFrame, color: str) -> None: @@ -2230,7 +2235,7 @@ def tag_for_general_exile_matters(df: pd.DataFrame, color: str) -> None: Raises: ValueError: if required DataFrame columns are missing """ - logging.info('Tagging Exile Matters cards in %s_cards.csv', color) + logger.info('Tagging Exile Matters cards in %s_cards.csv', color) start_time =pd.Timestamp.now() try: @@ -2254,16 +2259,16 @@ def tag_for_general_exile_matters(df: pd.DataFrame, color: str) -> None: 'put into exile', 'remains exiled' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) if text_mask.any(): - tag_utility.apply_tag_vectorized(df, text_mask, ['Exile Matters']) - logging.info('Tagged %d Exile Matters cards', text_mask.sum()) + tag_utils.apply_tag_vectorized(df, text_mask, ['Exile Matters']) + logger.info('Tagged %d Exile Matters cards', text_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Exile Matters tagging in %.2fs', duration) + logger.info('Completed Exile Matters tagging in %.2fs', duration) except Exception as e: - logging.error('Error tagging Exile Matters cards: %s', str(e)) + logger.error('Error tagging Exile Matters cards: %s', str(e)) raise ## Cascade cards @@ -2277,7 +2282,7 @@ def tag_for_cascade(df: pd.DataFrame, color: str) -> None: Raises: ValueError: If required DataFrame columns are missing """ - logging.info('Tagging Cascade cards in %s_cards.csv', color) + logger.info('Tagging Cascade cards in %s_cards.csv', color) start_time = pd.Timestamp.now() try: @@ -2289,21 +2294,21 @@ def tag_for_cascade(df: pd.DataFrame, color: str) -> None: 'have "cascade', 'with cascade', ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) if text_mask.any(): - tag_utility.apply_tag_vectorized(df, text_mask, ['Cascade', 'Exile Matters']) - logging.info('Tagged %d cards relating to Cascade', text_mask.sum()) + tag_utils.apply_tag_vectorized(df, text_mask, ['Cascade', 'Exile Matters']) + logger.info('Tagged %d cards relating to Cascade', text_mask.sum()) - keyword_mask = tag_utility.create_keyword_mask(df, 'Cascade') + keyword_mask = tag_utils.create_keyword_mask(df, 'Cascade') if keyword_mask.any(): - tag_utility.apply_tag_vectorized(df, text_mask, ['Cascade', 'Exile Matters']) - logging.info('Tagged %d cards that have Cascade', keyword_mask.sum()) + tag_utils.apply_tag_vectorized(df, text_mask, ['Cascade', 'Exile Matters']) + logger.info('Tagged %d cards that have Cascade', keyword_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed Cascade tagging in %.2fs', duration) + logger.info('Completed Cascade tagging in %.2fs', duration) except Exception as e: - logging.error('Error tagging Cacade cards: %s', str(e)) + logger.error('Error tagging Cacade cards: %s', str(e)) raise ## Dsicover cards @@ -2314,25 +2319,25 @@ def tag_for_discover(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Discover cards in {color}_cards.csv') + logger.info(f'Tagging Discover cards in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create mask for Discover keyword - keyword_mask = tag_utility.create_keyword_mask(df, 'Discover') + keyword_mask = tag_utils.create_keyword_mask(df, 'Discover') # Apply tags - tag_utility.apply_tag_vectorized(df, keyword_mask, ['Discover', 'Exile Matters']) + tag_utils.apply_tag_vectorized(df, keyword_mask, ['Discover', 'Exile Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {keyword_mask.sum()} Discover cards in {duration:.2f}s') + logger.info(f'Tagged {keyword_mask.sum()} Discover cards in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Discover cards: {str(e)}') + logger.error(f'Error tagging Discover cards: {str(e)}') raise - logging.info('Completed tagging Discover cards') + logger.info('Completed tagging Discover cards') ## Foretell cards, and cards that care about foretell def tag_for_foretell(df: pd.DataFrame, color: str) -> None: @@ -2342,29 +2347,29 @@ def tag_for_foretell(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Foretell cards in {color}_cards.csv') + logger.info(f'Tagging Foretell cards in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create mask for Foretell keyword - keyword_mask = tag_utility.create_keyword_mask(df, 'Foretell') + keyword_mask = tag_utils.create_keyword_mask(df, 'Foretell') # Create mask for Foretell text - text_mask = tag_utility.create_text_mask(df, 'Foretell') + text_mask = tag_utils.create_text_mask(df, 'Foretell') final_mask = keyword_mask | text_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Foretell', 'Exile Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Foretell', 'Exile Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} Foretell cards in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} Foretell cards in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Foretell cards: {str(e)}') + logger.error(f'Error tagging Foretell cards: {str(e)}') raise - logging.info('Completed tagging Foretell cards') + logger.info('Completed tagging Foretell cards') ## Cards that have or care about imprint def tag_for_imprint(df: pd.DataFrame, color: str) -> None: @@ -2374,29 +2379,29 @@ def tag_for_imprint(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Imprint cards in {color}_cards.csv') + logger.info(f'Tagging Imprint cards in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create mask for Imprint keyword - keyword_mask = tag_utility.create_keyword_mask(df, 'Imprint') + keyword_mask = tag_utils.create_keyword_mask(df, 'Imprint') # Create mask for Imprint text - text_mask = tag_utility.create_text_mask(df, 'Imprint') + text_mask = tag_utils.create_text_mask(df, 'Imprint') final_mask = keyword_mask | text_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Imprint', 'Exile Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Imprint', 'Exile Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} Imprint cards in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} Imprint cards in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Imprint cards: {str(e)}') + logger.error(f'Error tagging Imprint cards: {str(e)}') raise - logging.info('Completed tagging Imprint cards') + logger.info('Completed tagging Imprint cards') ## Cards that have or care about impulse def create_impulse_mask(df: pd.DataFrame) -> pd.Series: @@ -2445,14 +2450,14 @@ def create_impulse_mask(df: pd.DataFrame) -> pd.Series: ] # Create masks - tag_mask = tag_utility.create_tag_mask(df, 'Imprint') - exile_mask = tag_utility.create_text_mask(df, exile_patterns) - play_mask = tag_utility.create_text_mask(df, play_patterns) - named_mask = tag_utility.create_name_mask(df, impulse_cards) - junk_mask = tag_utility.create_text_mask(df, 'junk token') - first_exclusion_mask = tag_utility.create_text_mask(df, exclusion_patterns) + tag_mask = tag_utils.create_tag_mask(df, 'Imprint') + exile_mask = tag_utils.create_text_mask(df, exile_patterns) + play_mask = tag_utils.create_text_mask(df, play_patterns) + named_mask = tag_utils.create_name_mask(df, impulse_cards) + junk_mask = tag_utils.create_text_mask(df, 'junk token') + first_exclusion_mask = tag_utils.create_text_mask(df, exclusion_patterns) planeswalker_mask = df['type'].str.contains('Planeswalker', case=False, na=False) - second_exclusion_mask = tag_utility.create_text_mask(df, secondary_exclusion_patterns) + second_exclusion_mask = tag_utils.create_text_mask(df, secondary_exclusion_patterns) exclusion_mask = (~first_exclusion_mask & ~planeswalker_mask) & second_exclusion_mask # Combine masks @@ -2474,7 +2479,7 @@ def tag_for_impulse(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Impulse effects in {color}_cards.csv') + logger.info(f'Tagging Impulse effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -2482,21 +2487,21 @@ def tag_for_impulse(df: pd.DataFrame, color: str) -> None: impulse_mask = create_impulse_mask(df) # Apply tags - tag_utility.apply_tag_vectorized(df, impulse_mask, ['Exile Matters', 'Impulse']) + tag_utils.apply_tag_vectorized(df, impulse_mask, ['Exile Matters', 'Impulse']) # Add Junk Tokens tag where applicable - junk_mask = impulse_mask & tag_utility.create_text_mask(df, 'junk token') - tag_utility.apply_tag_vectorized(df, junk_mask, ['Junk Tokens']) + junk_mask = impulse_mask & tag_utils.create_text_mask(df, 'junk token') + tag_utils.apply_tag_vectorized(df, junk_mask, ['Junk Tokens']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {impulse_mask.sum()} cards with Impulse effects in {duration:.2f}s') + logger.info(f'Tagged {impulse_mask.sum()} cards with Impulse effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Impulse effects: {str(e)}') + logger.error(f'Error tagging Impulse effects: {str(e)}') raise - logging.info('Completed tagging Impulse effects') + logger.info('Completed tagging Impulse effects') ## Cards that have or care about plotting def tag_for_plot(df: pd.DataFrame, color: str) -> None: """Tag cards with Plot using vectorized operations. @@ -2505,29 +2510,29 @@ def tag_for_plot(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Plot cards in {color}_cards.csv') + logger.info(f'Tagging Plot cards in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create mask for Plot keyword - keyword_mask = tag_utility.create_keyword_mask(df, 'Plot') + keyword_mask = tag_utils.create_keyword_mask(df, 'Plot') # Create mask for Plot keyword - text_mask = tag_utility.create_text_mask(df, 'Plot') + text_mask = tag_utils.create_text_mask(df, 'Plot') final_mask = keyword_mask | text_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Plot', 'Exile Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Plot', 'Exile Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} Plot cards in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} Plot cards in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Plot cards: {str(e)}') + logger.error(f'Error tagging Plot cards: {str(e)}') raise - logging.info('Completed tagging Plot cards') + logger.info('Completed tagging Plot cards') ## Cards that have or care about suspend def tag_for_suspend(df: pd.DataFrame, color: str) -> None: @@ -2537,29 +2542,29 @@ def tag_for_suspend(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Suspend cards in {color}_cards.csv') + logger.info(f'Tagging Suspend cards in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create mask for Suspend keyword - keyword_mask = tag_utility.create_keyword_mask(df, 'Suspend') + keyword_mask = tag_utils.create_keyword_mask(df, 'Suspend') # Create mask for Suspend keyword - text_mask = tag_utility.create_text_mask(df, 'Suspend') + text_mask = tag_utils.create_text_mask(df, 'Suspend') final_mask = keyword_mask | text_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Suspend', 'Exile Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Suspend', 'Exile Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} Suspend cards in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} Suspend cards in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Suspend cards: {str(e)}') + logger.error(f'Error tagging Suspend cards: {str(e)}') raise - logging.info('Completed tagging Suspend cards') + logger.info('Completed tagging Suspend cards') ### Tokens def create_creature_token_mask(df: pd.DataFrame) -> pd.Series: @@ -2573,7 +2578,7 @@ def create_creature_token_mask(df: pd.DataFrame) -> pd.Series: """ # Create base pattern for token creation create_pattern = r'create|put' - has_create = tag_utility.create_text_mask(df, create_pattern) + has_create = tag_utils.create_text_mask(df, create_pattern) # Create pattern for creature tokens token_patterns = [ @@ -2581,15 +2586,15 @@ def create_creature_token_mask(df: pd.DataFrame) -> pd.Series: 'creature token', 'enchantment creature token' ] - has_token = tag_utility.create_text_mask(df, token_patterns) + has_token = tag_utils.create_text_mask(df, token_patterns) # Create exclusion mask exclusion_patterns = ['fabricate', 'modular'] - exclusion_mask = tag_utility.create_text_mask(df, exclusion_patterns) + exclusion_mask = tag_utils.create_text_mask(df, exclusion_patterns) # Create name exclusion mask excluded_cards = ['agatha\'s soul cauldron'] - name_exclusions = tag_utility.create_name_mask(df, excluded_cards) + name_exclusions = tag_utils.create_name_mask(df, excluded_cards) return has_create & has_token & ~exclusion_mask & ~name_exclusions @@ -2612,11 +2617,11 @@ def create_token_modifier_mask(df: pd.DataFrame) -> pd.Series: 'one or more tokens you control', 'put one or more' ] - has_modifier = tag_utility.create_text_mask(df, modifier_patterns) + has_modifier = tag_utils.create_text_mask(df, modifier_patterns) # Create patterns for token effects effect_patterns = ['instead', 'plus'] - has_effect = tag_utility.create_text_mask(df, effect_patterns) + has_effect = tag_utils.create_text_mask(df, effect_patterns) # Create name exclusion mask excluded_cards = [ @@ -2624,7 +2629,7 @@ def create_token_modifier_mask(df: pd.DataFrame) -> pd.Series: 'neyali, sun\'s vanguard', 'staff of the storyteller' ] - name_exclusions = tag_utility.create_name_mask(df, excluded_cards) + name_exclusions = tag_utils.create_name_mask(df, excluded_cards) return has_modifier & has_effect & ~name_exclusions @@ -2644,33 +2649,33 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info('Tagging token-related cards in %s_cards.csv', color) + logger.info('Tagging token-related cards in %s_cards.csv', color) print('\n==========\n') try: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create creature token mask creature_mask = create_creature_token_mask(df) if creature_mask.any(): - tag_utility.apply_tag_vectorized(df, creature_mask, + tag_utils.apply_tag_vectorized(df, creature_mask, ['Creature Tokens', 'Token Creation', 'Tokens Matter']) - logging.info('Tagged %d cards that create creature tokens', creature_mask.sum()) + logger.info('Tagged %d cards that create creature tokens', creature_mask.sum()) # Create token modifier mask modifier_mask = create_token_modifier_mask(df) if modifier_mask.any(): - tag_utility.apply_tag_vectorized(df, modifier_mask, + tag_utils.apply_tag_vectorized(df, modifier_mask, ['Token Modification', 'Token Creation', 'Tokens Matter']) - logging.info('Tagged %d cards that modify token creation', modifier_mask.sum()) + logger.info('Tagged %d cards that modify token creation', modifier_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info('Completed token tagging in %.2fs', duration) + logger.info('Completed token tagging in %.2fs', duration) except Exception as e: - logging.error('Error tagging token cards: %s', str(e)) + logger.error('Error tagging token cards: %s', str(e)) raise ### Life Matters @@ -2693,7 +2698,7 @@ def tag_for_life_matters(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting "Life Matters" tagging for {color}_cards.csv') + logger.info(f'Starting "Life Matters" tagging for {color}_cards.csv') print('\n==========\n') try: @@ -2705,35 +2710,35 @@ def tag_for_life_matters(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'type', 'creatureTypes'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of life effect tag_for_lifegain(df, color) - logging.info('Completed lifegain tagging') + logger.info('Completed lifegain tagging') print('\n==========\n') tag_for_lifelink(df, color) - logging.info('Completed lifelink tagging') + logger.info('Completed lifelink tagging') print('\n==========\n') tag_for_life_loss(df, color) - logging.info('Completed life loss tagging') + logger.info('Completed life loss tagging') print('\n==========\n') tag_for_food(df, color) - logging.info('Completed food token tagging') + logger.info('Completed food token tagging') print('\n==========\n') tag_for_life_kindred(df, color) - logging.info('Completed life kindred tagging') + logger.info('Completed life kindred tagging') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all "Life Matters" tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all "Life Matters" tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_life_matters: {str(e)}') + logger.error(f'Error in tag_for_life_matters: {str(e)}') raise def tag_for_lifegain(df: pd.DataFrame, color: str) -> None: @@ -2743,7 +2748,7 @@ def tag_for_lifegain(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging lifegain effects in {color}_cards.csv') + logger.info(f'Tagging lifegain effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -2752,28 +2757,28 @@ def tag_for_lifegain(df: pd.DataFrame, color: str) -> None: gain_patterns.extend([f'gains {num} life' for num in settings.num_to_search]) gain_patterns.extend(['gain life', 'gains life']) - gain_mask = tag_utility.create_text_mask(df, gain_patterns) + gain_mask = tag_utils.create_text_mask(df, gain_patterns) # Exclude replacement effects - replacement_mask = tag_utility.create_text_mask(df, ['if you would gain life', 'whenever you gain life']) + replacement_mask = tag_utils.create_text_mask(df, ['if you would gain life', 'whenever you gain life']) # Apply lifegain tags final_mask = gain_mask & ~replacement_mask if final_mask.any(): - tag_utility.apply_tag_vectorized(df, final_mask, ['Lifegain', 'Life Matters']) - logging.info(f'Tagged {final_mask.sum()} cards with lifegain effects') + tag_utils.apply_tag_vectorized(df, final_mask, ['Lifegain', 'Life Matters']) + logger.info(f'Tagged {final_mask.sum()} cards with lifegain effects') # Tag lifegain triggers - trigger_mask = tag_utility.create_text_mask(df, ['if you would gain life', 'whenever you gain life']) + trigger_mask = tag_utils.create_text_mask(df, ['if you would gain life', 'whenever you gain life']) if trigger_mask.any(): - tag_utility.apply_tag_vectorized(df, trigger_mask, ['Lifegain', 'Lifegain Triggers', 'Life Matters']) - logging.info(f'Tagged {trigger_mask.sum()} cards with lifegain triggers') + tag_utils.apply_tag_vectorized(df, trigger_mask, ['Lifegain', 'Lifegain Triggers', 'Life Matters']) + logger.info(f'Tagged {trigger_mask.sum()} cards with lifegain triggers') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed lifegain tagging in {duration:.2f}s') + logger.info(f'Completed lifegain tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging lifegain effects: {str(e)}') + logger.error(f'Error tagging lifegain effects: {str(e)}') raise def tag_for_lifelink(df: pd.DataFrame, color: str) -> None: @@ -2783,19 +2788,19 @@ def tag_for_lifelink(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging lifelink effects in {color}_cards.csv') + logger.info(f'Tagging lifelink effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create masks for different lifelink patterns - lifelink_mask = tag_utility.create_text_mask(df, 'lifelink') - lifelike_mask = tag_utility.create_text_mask(df, [ + lifelink_mask = tag_utils.create_text_mask(df, 'lifelink') + lifelike_mask = tag_utils.create_text_mask(df, [ 'deals damage, you gain that much life', 'loses life.*gain that much life' ]) # Exclude combat damage references for life loss conversion - damage_mask = tag_utility.create_text_mask(df, 'deals damage') + damage_mask = tag_utils.create_text_mask(df, 'deals damage') life_loss_mask = lifelike_mask & ~damage_mask # Combine masks @@ -2803,14 +2808,14 @@ def tag_for_lifelink(df: pd.DataFrame, color: str) -> None: # Apply tags if final_mask.any(): - tag_utility.apply_tag_vectorized(df, final_mask, ['Lifelink', 'Lifegain', 'Life Matters']) - logging.info(f'Tagged {final_mask.sum()} cards with lifelink effects') + tag_utils.apply_tag_vectorized(df, final_mask, ['Lifelink', 'Lifegain', 'Life Matters']) + logger.info(f'Tagged {final_mask.sum()} cards with lifelink effects') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed lifelink tagging in {duration:.2f}s') + logger.info(f'Completed lifelink tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging lifelink effects: {str(e)}') + logger.error(f'Error tagging lifelink effects: {str(e)}') raise def tag_for_life_loss(df: pd.DataFrame, color: str) -> None: @@ -2820,7 +2825,7 @@ def tag_for_life_loss(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging life loss effects in {color}_cards.csv') + logger.info(f'Tagging life loss effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -2835,18 +2840,18 @@ def tag_for_life_loss(df: pd.DataFrame, color: str) -> None: 'whenever you gain or lose life', 'whenever you lose life' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Apply tags if text_mask.any(): - tag_utility.apply_tag_vectorized(df, text_mask, ['Lifeloss', 'Lifeloss Triggers', 'Life Matters']) - logging.info(f'Tagged {text_mask.sum()} cards with life loss effects') + tag_utils.apply_tag_vectorized(df, text_mask, ['Lifeloss', 'Lifeloss Triggers', 'Life Matters']) + logger.info(f'Tagged {text_mask.sum()} cards with life loss effects') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed life loss tagging in {duration:.2f}s') + logger.info(f'Completed life loss tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging life loss effects: {str(e)}') + logger.error(f'Error tagging life loss effects: {str(e)}') raise def tag_for_food(df: pd.DataFrame, color: str) -> None: @@ -2856,27 +2861,27 @@ def tag_for_food(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging Food token in {color}_cards.csv') + logger.info(f'Tagging Food token in {color}_cards.csv') start_time = pd.Timestamp.now() try: # Create masks for Food tokens - text_mask = tag_utility.create_text_mask(df, 'food') - type_mask = tag_utility.create_type_mask(df, 'food') + text_mask = tag_utils.create_text_mask(df, 'food') + type_mask = tag_utils.create_type_mask(df, 'food') # Combine masks final_mask = text_mask | type_mask # Apply tags if final_mask.any(): - tag_utility.apply_tag_vectorized(df, final_mask, ['Food', 'Lifegain', 'Life Matters']) - logging.info(f'Tagged {final_mask.sum()} cards with Food effects') + tag_utils.apply_tag_vectorized(df, final_mask, ['Food', 'Lifegain', 'Life Matters']) + logger.info(f'Tagged {final_mask.sum()} cards with Food effects') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed Food tagging in {duration:.2f}s') + logger.info(f'Completed Food tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging Food effects: {str(e)}') + logger.error(f'Error tagging Food effects: {str(e)}') raise def tag_for_life_kindred(df: pd.DataFrame, color: str) -> None: @@ -2886,7 +2891,7 @@ def tag_for_life_kindred(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging life-related kindred effects in {color}_cards.csv') + logger.info(f'Tagging life-related kindred effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -2896,14 +2901,14 @@ def tag_for_life_kindred(df: pd.DataFrame, color: str) -> None: # Apply tags if kindred_mask.any(): - tag_utility.apply_tag_vectorized(df, kindred_mask, ['Lifegain', 'Life Matters']) - logging.info(f'Tagged {kindred_mask.sum()} cards with life-related kindred effects') + tag_utils.apply_tag_vectorized(df, kindred_mask, ['Lifegain', 'Life Matters']) + logger.info(f'Tagged {kindred_mask.sum()} cards with life-related kindred effects') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed life kindred tagging in {duration:.2f}s') + logger.info(f'Completed life kindred tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging life kindred effects: {str(e)}') + logger.error(f'Error tagging life kindred effects: {str(e)}') raise ### Counters @@ -2928,7 +2933,7 @@ def tag_for_counters(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting counter-related tagging for {color}_cards.csv') + logger.info(f'Starting counter-related tagging for {color}_cards.csv') print('\n==========\n') try: @@ -2940,31 +2945,31 @@ def tag_for_counters(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'name', 'creatureTypes'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of counter effect tag_for_general_counters(df, color) - logging.info('Completed general counter tagging') + logger.info('Completed general counter tagging') print('\n==========\n') tag_for_plus_counters(df, color) - logging.info('Completed +1/+1 counter tagging') + logger.info('Completed +1/+1 counter tagging') print('\n==========\n') tag_for_minus_counters(df, color) - logging.info('Completed -1/-1 counter tagging') + logger.info('Completed -1/-1 counter tagging') print('\n==========\n') tag_for_special_counters(df, color) - logging.info('Completed special counter tagging') + logger.info('Completed special counter tagging') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all counter-related tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all counter-related tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_counters: {str(e)}') + logger.error(f'Error in tag_for_counters: {str(e)}') raise def tag_for_general_counters(df: pd.DataFrame, color: str) -> None: @@ -2974,7 +2979,7 @@ def tag_for_general_counters(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging general counter effects in {color}_cards.csv') + logger.info(f'Tagging general counter effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -2988,7 +2993,7 @@ def tag_for_general_counters(df: pd.DataFrame, color: str) -> None: 'remove a counter', 'with counters on them' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Create mask for specific cards specific_cards = [ @@ -2996,20 +3001,20 @@ def tag_for_general_counters(df: pd.DataFrame, color: str) -> None: 'damning verdict', 'ozolith' ] - name_mask = tag_utility.create_name_mask(df, specific_cards) + name_mask = tag_utils.create_name_mask(df, specific_cards) # Combine masks final_mask = text_mask | name_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Counters Matter']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Counters Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with general counter effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with general counter effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging general counter effects: {str(e)}') + logger.error(f'Error tagging general counter effects: {str(e)}') raise def tag_for_plus_counters(df: pd.DataFrame, color: str) -> None: @@ -3019,7 +3024,7 @@ def tag_for_plus_counters(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging +1/+1 counter effects in {color}_cards.csv') + logger.info(f'Tagging +1/+1 counter effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -3033,7 +3038,7 @@ def tag_for_plus_counters(df: pd.DataFrame, color: str) -> None: r'undying', r'with counters on them' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Create creature type mask type_mask = df['creatureTypes'].apply(lambda x: 'Hydra' in x if isinstance(x, list) else False) @@ -3041,14 +3046,14 @@ def tag_for_plus_counters(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | type_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['+1/+1 Counters', 'Counters Matter', 'Voltron']) + tag_utils.apply_tag_vectorized(df, final_mask, ['+1/+1 Counters', 'Counters Matter', 'Voltron']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with +1/+1 counter effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with +1/+1 counter effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging +1/+1 counter effects: {str(e)}') + logger.error(f'Error tagging +1/+1 counter effects: {str(e)}') raise def tag_for_minus_counters(df: pd.DataFrame, color: str) -> None: @@ -3058,7 +3063,7 @@ def tag_for_minus_counters(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging -1/-1 counter effects in {color}_cards.csv') + logger.info(f'Tagging -1/-1 counter effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -3073,17 +3078,17 @@ def tag_for_minus_counters(df: pd.DataFrame, color: str) -> None: 'proliferate', 'wither' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Apply tags - tag_utility.apply_tag_vectorized(df, text_mask, ['-1/-1 Counters', 'Counters Matter']) + tag_utils.apply_tag_vectorized(df, text_mask, ['-1/-1 Counters', 'Counters Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {text_mask.sum()} cards with -1/-1 counter effects in {duration:.2f}s') + logger.info(f'Tagged {text_mask.sum()} cards with -1/-1 counter effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error tagging -1/-1 counter effects: {str(e)}') + logger.error(f'Error tagging -1/-1 counter effects: {str(e)}') raise def tag_for_special_counters(df: pd.DataFrame, color: str) -> None: @@ -3093,7 +3098,7 @@ def tag_for_special_counters(df: pd.DataFrame, color: str) -> None: df: DataFrame containing card data color: Color identifier for logging purposes """ - logging.info(f'Tagging special counter effects in {color}_cards.csv') + logger.info(f'Tagging special counter effects in {color}_cards.csv') start_time = pd.Timestamp.now() try: @@ -3102,24 +3107,24 @@ def tag_for_special_counters(df: pd.DataFrame, color: str) -> None: for counter_type in settings.counter_types: # Create pattern for this counter type pattern = f'{counter_type} counter' - mask = tag_utility.create_text_mask(df, pattern) + mask = tag_utils.create_text_mask(df, pattern) if mask.any(): # Apply tags tags = [f'{counter_type} Counters', 'Counters Matter'] - tag_utility.apply_tag_vectorized(df, mask, tags) + tag_utils.apply_tag_vectorized(df, mask, tags) counter_counts[counter_type] = mask.sum() # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() total_cards = sum(counter_counts.values()) - logging.info(f'Tagged {total_cards} cards with special counter effects in {duration:.2f}s') + logger.info(f'Tagged {total_cards} cards with special counter effects in {duration:.2f}s') for counter_type, count in counter_counts.items(): if count > 0: - logging.info(f' - {counter_type}: {count} cards') + logger.info(f' - {counter_type}: {count} cards') except Exception as e: - logging.error(f'Error tagging special counter effects: {str(e)}') + logger.error(f'Error tagging special counter effects: {str(e)}') raise ### Voltron @@ -3132,7 +3137,7 @@ def create_voltron_commander_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are Voltron commanders """ - return tag_utility.create_name_mask(df, settings.VOLTRON_COMMANDER_CARDS) + return tag_utils.create_name_mask(df, settings.VOLTRON_COMMANDER_CARDS) def create_voltron_support_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that support Voltron strategies. @@ -3143,7 +3148,7 @@ def create_voltron_support_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards support Voltron strategies """ - return tag_utility.create_text_mask(df, settings.VOLTRON_PATTERNS) + return tag_utils.create_text_mask(df, settings.VOLTRON_PATTERNS) def create_voltron_equipment_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for Equipment-based Voltron cards. @@ -3154,7 +3159,7 @@ def create_voltron_equipment_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are Equipment-based Voltron cards """ - return tag_utility.create_type_mask(df, 'Equipment') + return tag_utils.create_type_mask(df, 'Equipment') def create_voltron_aura_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for Aura-based Voltron cards. @@ -3165,7 +3170,7 @@ def create_voltron_aura_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are Aura-based Voltron cards """ - return tag_utility.create_type_mask(df, 'Aura') + return tag_utils.create_type_mask(df, 'Aura') def tag_for_voltron(df: pd.DataFrame, color: str) -> None: """Tag cards that fit the Voltron strategy. @@ -3188,7 +3193,7 @@ def tag_for_voltron(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting Voltron strategy tagging for {color}_cards.csv') + logger.info(f'Starting Voltron strategy tagging for {color}_cards.csv') try: # Validate inputs @@ -3199,7 +3204,7 @@ def tag_for_voltron(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'type', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different Voltron aspects commander_mask = create_voltron_commander_mask(df) @@ -3211,20 +3216,20 @@ def tag_for_voltron(df: pd.DataFrame, color: str) -> None: final_mask = commander_mask | support_mask | equipment_mask | aura_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Voltron']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Voltron']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with Voltron strategy in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with Voltron strategy in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_voltron: {str(e)}') + logger.error(f'Error in tag_for_voltron: {str(e)}') raise duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all "Life Matters" tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all "Life Matters" tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_voltron: {str(e)}') + logger.error(f'Error in tag_for_voltron: {str(e)}') raise ### Lands matter @@ -3238,12 +3243,12 @@ def create_lands_matter_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards have lands matter effects """ # Create mask for named cards - name_mask = tag_utility.create_name_mask(df, settings.LANDS_MATTER_SPECIFIC_CARDS) + name_mask = tag_utils.create_name_mask(df, settings.LANDS_MATTER_SPECIFIC_CARDS) # Create text pattern masks - play_mask = tag_utility.create_text_mask(df, settings.LANDS_MATTER_PATTERNS['land_play']) - search_mask = tag_utility.create_text_mask(df, settings.LANDS_MATTER_PATTERNS['land_search']) - state_mask = tag_utility.create_text_mask(df, settings.LANDS_MATTER_PATTERNS['land_state']) + play_mask = tag_utils.create_text_mask(df, settings.LANDS_MATTER_PATTERNS['land_play']) + search_mask = tag_utils.create_text_mask(df, settings.LANDS_MATTER_PATTERNS['land_search']) + state_mask = tag_utils.create_text_mask(df, settings.LANDS_MATTER_PATTERNS['land_state']) # Combine all masks return name_mask | play_mask | search_mask | state_mask @@ -3257,8 +3262,8 @@ def create_domain_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have domain effects """ - keyword_mask = tag_utility.create_keyword_mask(df, settings.DOMAIN_PATTERNS['keyword']) - text_mask = tag_utility.create_text_mask(df, settings.DOMAIN_PATTERNS['text']) + keyword_mask = tag_utils.create_keyword_mask(df, settings.DOMAIN_PATTERNS['keyword']) + text_mask = tag_utils.create_text_mask(df, settings.DOMAIN_PATTERNS['text']) return keyword_mask | text_mask def create_landfall_mask(df: pd.DataFrame) -> pd.Series: @@ -3270,8 +3275,8 @@ def create_landfall_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have landfall effects """ - keyword_mask = tag_utility.create_keyword_mask(df, settings.LANDFALL_PATTERNS['keyword']) - trigger_mask = tag_utility.create_text_mask(df, settings.LANDFALL_PATTERNS['triggers']) + keyword_mask = tag_utils.create_keyword_mask(df, settings.LANDFALL_PATTERNS['keyword']) + trigger_mask = tag_utils.create_text_mask(df, settings.LANDFALL_PATTERNS['triggers']) return keyword_mask | trigger_mask def create_landwalk_mask(df: pd.DataFrame) -> pd.Series: @@ -3283,8 +3288,8 @@ def create_landwalk_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have landwalk abilities """ - basic_mask = tag_utility.create_text_mask(df, settings.LANDWALK_PATTERNS['basic']) - nonbasic_mask = tag_utility.create_text_mask(df, settings.LANDWALK_PATTERNS['nonbasic']) + basic_mask = tag_utils.create_text_mask(df, settings.LANDWALK_PATTERNS['basic']) + nonbasic_mask = tag_utils.create_text_mask(df, settings.LANDWALK_PATTERNS['nonbasic']) return basic_mask | nonbasic_mask def create_land_types_mask(df: pd.DataFrame) -> pd.Series: @@ -3297,7 +3302,7 @@ def create_land_types_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards care about specific land types """ # Create type-based mask - type_mask = tag_utility.create_type_mask(df, settings.LAND_TYPES) + type_mask = tag_utils.create_type_mask(df, settings.LAND_TYPES) # Create text pattern masks for each land type text_masks = [] @@ -3307,7 +3312,7 @@ def create_land_types_mask(df: pd.DataFrame) -> pd.Series: f'search your library for up to two {land_type.lower()}', f'{land_type} you control' ] - text_masks.append(tag_utility.create_text_mask(df, patterns)) + text_masks.append(tag_utils.create_text_mask(df, patterns)) # Combine all masks return type_mask | pd.concat(text_masks, axis=1).any(axis=1) @@ -3330,13 +3335,13 @@ def tag_for_lands_matter(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting lands matter tagging for {color}_cards.csv') + logger.info(f'Starting lands matter tagging for {color}_cards.csv') print('\n==========\n') try: # Validate required columns required_cols = {'text', 'themeTags', 'type', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different land effects lands_mask = create_lands_matter_mask(df) @@ -3347,30 +3352,30 @@ def tag_for_lands_matter(df: pd.DataFrame, color: str) -> None: # Apply tags based on masks if lands_mask.any(): - tag_utility.apply_tag_vectorized(df, lands_mask, ['Lands Matter']) - logging.info(f'Tagged {lands_mask.sum()} cards with general lands matter effects') + tag_utils.apply_tag_vectorized(df, lands_mask, ['Lands Matter']) + logger.info(f'Tagged {lands_mask.sum()} cards with general lands matter effects') if domain_mask.any(): - tag_utility.apply_tag_vectorized(df, domain_mask, ['Domain', 'Lands Matter']) - logging.info(f'Tagged {domain_mask.sum()} cards with domain effects') + tag_utils.apply_tag_vectorized(df, domain_mask, ['Domain', 'Lands Matter']) + logger.info(f'Tagged {domain_mask.sum()} cards with domain effects') if landfall_mask.any(): - tag_utility.apply_tag_vectorized(df, landfall_mask, ['Landfall', 'Lands Matter']) - logging.info(f'Tagged {landfall_mask.sum()} cards with landfall effects') + tag_utils.apply_tag_vectorized(df, landfall_mask, ['Landfall', 'Lands Matter']) + logger.info(f'Tagged {landfall_mask.sum()} cards with landfall effects') if landwalk_mask.any(): - tag_utility.apply_tag_vectorized(df, landwalk_mask, ['Landwalk', 'Lands Matter']) - logging.info(f'Tagged {landwalk_mask.sum()} cards with landwalk abilities') + tag_utils.apply_tag_vectorized(df, landwalk_mask, ['Landwalk', 'Lands Matter']) + logger.info(f'Tagged {landwalk_mask.sum()} cards with landwalk abilities') if types_mask.any(): - tag_utility.apply_tag_vectorized(df, types_mask, ['Land Types Matter', 'Lands Matter']) - logging.info(f'Tagged {types_mask.sum()} cards with specific land type effects') + tag_utils.apply_tag_vectorized(df, types_mask, ['Land Types Matter', 'Lands Matter']) + logger.info(f'Tagged {types_mask.sum()} cards with specific land type effects') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed lands matter tagging in {duration:.2f}s') + logger.info(f'Completed lands matter tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_lands_matter: {str(e)}') + logger.error(f'Error in tag_for_lands_matter: {str(e)}') raise ### Spells Matter @@ -3407,7 +3412,7 @@ def create_spellslinger_text_mask(df: pd.DataFrame) -> pd.Series: 'you cast an instant', 'you cast a spell' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_spellslinger_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with spellslinger-related keywords. @@ -3430,7 +3435,7 @@ def create_spellslinger_keyword_mask(df: pd.DataFrame) -> pd.Series: 'Prowess', 'Surge' ] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_spellslinger_type_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for instant/sorcery type cards. @@ -3441,7 +3446,7 @@ def create_spellslinger_type_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are instants or sorceries """ - return tag_utility.create_type_mask(df, ['Instant', 'Sorcery']) + return tag_utils.create_type_mask(df, ['Instant', 'Sorcery']) def create_spellslinger_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from spellslinger tagging. @@ -3457,7 +3462,7 @@ def create_spellslinger_exclusion_mask(df: pd.DataFrame) -> pd.Series: 'Possibility Storm', 'Wild-Magic Sorcerer' ] - return tag_utility.create_name_mask(df, excluded_names) + return tag_utils.create_name_mask(df, excluded_names) def tag_for_spellslinger(df: pd.DataFrame, color: str) -> None: """Tag cards that care about casting spells using vectorized operations. @@ -3479,13 +3484,13 @@ def tag_for_spellslinger(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting Spellslinger tagging for {color}_cards.csv') + logger.info(f'Starting Spellslinger tagging for {color}_cards.csv') print('\n==========\n') try: # Validate required columns required_cols = {'text', 'themeTags', 'type', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different spellslinger patterns text_mask = create_spellslinger_text_mask(df) @@ -3497,8 +3502,8 @@ def tag_for_spellslinger(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | keyword_mask | type_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Spellslinger', 'Spells Matter']) - logging.info(f'Tagged {final_mask.sum()} general Spellslinger cards') + tag_utils.apply_tag_vectorized(df, final_mask, ['Spellslinger', 'Spells Matter']) + logger.info(f'Tagged {final_mask.sum()} general Spellslinger cards') # Run non-generalized tags tag_for_storm(df, color) @@ -3508,10 +3513,10 @@ def tag_for_spellslinger(df: pd.DataFrame, color: str) -> None: # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed Spellslinger tagging in {duration:.2f}s') + logger.info(f'Completed Spellslinger tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_spellslinger: {str(e)}') + logger.error(f'Error in tag_for_spellslinger: {str(e)}') raise def create_storm_mask(df: pd.DataFrame) -> pd.Series: @@ -3524,7 +3529,7 @@ def create_storm_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards have storm effects """ # Create keyword mask - keyword_mask = tag_utility.create_keyword_mask(df, 'Storm') + keyword_mask = tag_utils.create_keyword_mask(df, 'Storm') # Create text mask text_patterns = [ @@ -3532,7 +3537,7 @@ def create_storm_mask(df: pd.DataFrame) -> pd.Series: 'has storm', 'have storm' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) return keyword_mask | text_mask @@ -3553,20 +3558,20 @@ def tag_for_storm(df: pd.DataFrame, color: str) -> None: try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create storm mask storm_mask = create_storm_mask(df) # Apply tags - tag_utility.apply_tag_vectorized(df, storm_mask, ['Storm', 'Spellslinger', 'Spells Matter']) + tag_utils.apply_tag_vectorized(df, storm_mask, ['Storm', 'Spellslinger', 'Spells Matter']) # Log results storm_count = storm_mask.sum() - logging.info(f'Tagged {storm_count} cards with Storm effects') + logger.info(f'Tagged {storm_count} cards with Storm effects') except Exception as e: - logging.error(f'Error tagging Storm effects: {str(e)}') + logger.error(f'Error tagging Storm effects: {str(e)}') raise ## Tag for Cantrips @@ -3586,8 +3591,8 @@ def tag_for_cantrips(df: pd.DataFrame, color: str) -> None: df['manaValue'] = pd.to_numeric(df['manaValue'], errors='coerce') # Create exclusion masks - excluded_types = tag_utility.create_type_mask(df, 'Land|Equipment') - excluded_keywords = tag_utility.create_keyword_mask(df, ['Channel', 'Cycling', 'Connive', 'Learn', 'Ravenous']) + excluded_types = tag_utils.create_type_mask(df, 'Land|Equipment') + excluded_keywords = tag_utils.create_keyword_mask(df, ['Channel', 'Cycling', 'Connive', 'Learn', 'Ravenous']) has_loot = df['themeTags'].apply(lambda x: 'Loot' in x) # Define name exclusions @@ -3609,7 +3614,7 @@ def tag_for_cantrips(df: pd.DataFrame, color: str) -> None: excluded_names = df['name'].isin(EXCLUDED_NAMES) # Create cantrip condition masks - has_draw = tag_utility.create_text_mask(df, PATTERN_GROUPS['draw']) + has_draw = tag_utils.create_text_mask(df, PATTERN_GROUPS['draw']) low_cost = df['manaValue'].fillna(float('inf')) <= 2 # Combine conditions @@ -3623,14 +3628,14 @@ def tag_for_cantrips(df: pd.DataFrame, color: str) -> None: ) # Apply tags - tag_utility.apply_tag_vectorized(df, cantrip_mask, TAG_GROUPS['Cantrips']) + tag_utils.apply_tag_vectorized(df, cantrip_mask, TAG_GROUPS['Cantrips']) # Log results cantrip_count = cantrip_mask.sum() - logging.info(f'Tagged {cantrip_count} Cantrip cards') + logger.info(f'Tagged {cantrip_count} Cantrip cards') except Exception as e: - logging.error('Error tagging Cantrips in %s_cards.csv: %s', color, str(e)) + logger.error('Error tagging Cantrips in %s_cards.csv: %s', color, str(e)) raise @@ -3643,7 +3648,7 @@ def create_magecraft_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have magecraft effects """ - return tag_utility.create_keyword_mask(df, 'Magecraft') + return tag_utils.create_keyword_mask(df, 'Magecraft') def tag_for_magecraft(df: pd.DataFrame, color: str) -> None: """Tag cards with magecraft using vectorized operations. @@ -3658,20 +3663,20 @@ def tag_for_magecraft(df: pd.DataFrame, color: str) -> None: try: # Validate required columns required_cols = {'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create magecraft mask magecraft_mask = create_magecraft_mask(df) # Apply tags - tag_utility.apply_tag_vectorized(df, magecraft_mask, ['Magecraft', 'Spellslinger', 'Spells Matter']) + tag_utils.apply_tag_vectorized(df, magecraft_mask, ['Magecraft', 'Spellslinger', 'Spells Matter']) # Log results magecraft_count = magecraft_mask.sum() - logging.info(f'Tagged {magecraft_count} cards with Magecraft effects') + logger.info(f'Tagged {magecraft_count} cards with Magecraft effects') except Exception as e: - logging.error(f'Error tagging Magecraft effects: {str(e)}') + logger.error(f'Error tagging Magecraft effects: {str(e)}') raise ## Spell Copy @@ -3699,7 +3704,7 @@ def create_spell_copy_text_mask(df: pd.DataFrame) -> pd.Series: 'create a copy', 'creates a copy' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_spell_copy_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with spell copy related keywords. @@ -3722,7 +3727,7 @@ def create_spell_copy_keyword_mask(df: pd.DataFrame) -> pd.Series: 'Replicate', 'Storm' ] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def tag_for_spell_copy(df: pd.DataFrame, color: str) -> None: """Tag cards that copy spells using vectorized operations. @@ -3742,7 +3747,7 @@ def tag_for_spell_copy(df: pd.DataFrame, color: str) -> None: try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different spell copy patterns text_mask = create_spell_copy_text_mask(df) @@ -3752,14 +3757,14 @@ def tag_for_spell_copy(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | keyword_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Spell Copy', 'Spellslinger', 'Spells Matter']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Spell Copy', 'Spellslinger', 'Spells Matter']) # Log results spellcopy_count = final_mask.sum() - logging.info(f'Tagged {spellcopy_count} spell copy cards') + logger.info(f'Tagged {spellcopy_count} spell copy cards') except Exception as e: - logging.error(f'Error in tag_for_spell_copy: {str(e)}') + logger.error(f'Error in tag_for_spell_copy: {str(e)}') raise ### Ramp @@ -3773,19 +3778,19 @@ def create_mana_dork_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards are mana dorks """ # Create base creature mask - creature_mask = tag_utility.create_type_mask(df, 'Creature') + creature_mask = tag_utils.create_type_mask(df, 'Creature') # Create text pattern masks - tap_mask = tag_utility.create_text_mask(df, ['{T}: Add', '{T}: Untap']) - sac_mask = tag_utility.create_text_mask(df, ['creature: add', 'control: add']) + tap_mask = tag_utils.create_text_mask(df, ['{T}: Add', '{T}: Untap']) + sac_mask = tag_utils.create_text_mask(df, ['creature: add', 'control: add']) # Create mana symbol mask mana_patterns = [f'add {{{c}}}' for c in ['C', 'W', 'U', 'B', 'R', 'G']] - mana_mask = tag_utility.create_text_mask(df, mana_patterns) + mana_mask = tag_utils.create_text_mask(df, mana_patterns) # Create specific cards mask specific_cards = ['Awaken the Woods', 'Forest Dryad'] - name_mask = tag_utility.create_name_mask(df, specific_cards) + name_mask = tag_utils.create_name_mask(df, specific_cards) return creature_mask & (tap_mask | sac_mask | mana_mask) | name_mask @@ -3799,19 +3804,19 @@ def create_mana_rock_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards are mana rocks """ # Create base artifact mask - artifact_mask = tag_utility.create_type_mask(df, 'Artifact') + artifact_mask = tag_utils.create_type_mask(df, 'Artifact') # Create text pattern masks - tap_mask = tag_utility.create_text_mask(df, ['{T}: Add', '{T}: Untap']) - sac_mask = tag_utility.create_text_mask(df, ['creature: add', 'control: add']) + tap_mask = tag_utils.create_text_mask(df, ['{T}: Add', '{T}: Untap']) + sac_mask = tag_utils.create_text_mask(df, ['creature: add', 'control: add']) # Create mana symbol mask mana_patterns = [f'add {{{c}}}' for c in ['C', 'W', 'U', 'B', 'R', 'G']] - mana_mask = tag_utility.create_text_mask(df, mana_patterns) + mana_mask = tag_utils.create_text_mask(df, mana_patterns) # Create token mask - token_mask = tag_utility.create_tag_mask(df, ['Powerstone Tokens', 'Treasure Tokens', 'Gold Tokens']) | \ - tag_utility.create_text_mask(df, 'token named meteorite') + token_mask = tag_utils.create_tag_mask(df, ['Powerstone Tokens', 'Treasure Tokens', 'Gold Tokens']) | \ + tag_utils.create_text_mask(df, 'token named meteorite') return (artifact_mask & (tap_mask | sac_mask | mana_mask)) | token_mask @@ -3835,7 +3840,7 @@ def create_extra_lands_mask(df: pd.DataFrame) -> pd.Series: 'return target land' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_land_search_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that search for lands. @@ -3864,7 +3869,7 @@ def create_land_search_mask(df: pd.DataFrame) -> pd.Series: f'search your library for an {land_type.lower()}' ]) - return tag_utility.create_text_mask(df, search_patterns) + return tag_utils.create_text_mask(df, search_patterns) def tag_for_ramp(df: pd.DataFrame, color: str) -> None: """Tag cards that provide mana acceleration using vectorized operations. @@ -3883,7 +3888,7 @@ def tag_for_ramp(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting ramp tagging for {color}_cards.csv') + logger.info(f'Starting ramp tagging for {color}_cards.csv') print('\n==========\n') try: @@ -3895,27 +3900,27 @@ def tag_for_ramp(df: pd.DataFrame, color: str) -> None: # Apply tags for each category if dork_mask.any(): - tag_utility.apply_tag_vectorized(df, dork_mask, ['Mana Dork', 'Ramp']) - logging.info(f'Tagged {dork_mask.sum()} mana dork cards') + tag_utils.apply_tag_vectorized(df, dork_mask, ['Mana Dork', 'Ramp']) + logger.info(f'Tagged {dork_mask.sum()} mana dork cards') if rock_mask.any(): - tag_utility.apply_tag_vectorized(df, rock_mask, ['Mana Rock', 'Ramp']) - logging.info(f'Tagged {rock_mask.sum()} mana rock cards') + tag_utils.apply_tag_vectorized(df, rock_mask, ['Mana Rock', 'Ramp']) + logger.info(f'Tagged {rock_mask.sum()} mana rock cards') if lands_mask.any(): - tag_utility.apply_tag_vectorized(df, lands_mask, ['Lands Matter', 'Ramp']) - logging.info(f'Tagged {lands_mask.sum()} extra lands cards') + tag_utils.apply_tag_vectorized(df, lands_mask, ['Lands Matter', 'Ramp']) + logger.info(f'Tagged {lands_mask.sum()} extra lands cards') if search_mask.any(): - tag_utility.apply_tag_vectorized(df, search_mask, ['Lands Matter', 'Ramp']) - logging.info(f'Tagged {search_mask.sum()} land search cards') + tag_utils.apply_tag_vectorized(df, search_mask, ['Lands Matter', 'Ramp']) + logger.info(f'Tagged {search_mask.sum()} land search cards') # Log completion duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed ramp tagging in {duration:.2f}s') + logger.info(f'Completed ramp tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_ramp: {str(e)}') + logger.error(f'Error in tag_for_ramp: {str(e)}') raise ### Other Misc Themes @@ -3953,7 +3958,7 @@ def tag_for_themes(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting tagging for remaining themes in {color}_cards.csv') + logger.info(f'Starting tagging for remaining themes in {color}_cards.csv') print('\n===============\n') tag_for_aggro(df, color) print('\n==========\n') @@ -3999,7 +4004,7 @@ def tag_for_themes(df: pd.DataFrame, color: str) -> None: print('\n==========\n') duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed theme tagging in {duration:.2f}s') + logger.info(f'Completed theme tagging in {duration:.2f}s') ## Aggro def create_aggro_text_mask(df: pd.DataFrame) -> pd.Series: @@ -4024,7 +4029,7 @@ def create_aggro_text_mask(df: pd.DataFrame) -> pd.Series: 'you control deals combat', 'untap all attacking creatures' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_aggro_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with aggro-related keywords. @@ -4050,7 +4055,7 @@ def create_aggro_keyword_mask(df: pd.DataFrame) -> pd.Series: 'Spectacle', 'Trample' ] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_aggro_theme_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with aggro-related themes. @@ -4061,7 +4066,7 @@ def create_aggro_theme_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have aggro themes """ - return tag_utility.create_tag_mask(df, ['Voltron']) + return tag_utils.create_tag_mask(df, ['Voltron']) def tag_for_aggro(df: pd.DataFrame, color: str) -> None: """Tag cards that fit the Aggro theme using vectorized operations. @@ -4081,7 +4086,7 @@ def tag_for_aggro(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting Aggro strategy tagging for {color}_cards.csv') + logger.info(f'Starting Aggro strategy tagging for {color}_cards.csv') try: # Validate inputs @@ -4092,7 +4097,7 @@ def tag_for_aggro(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different aggro aspects text_mask = create_aggro_text_mask(df) @@ -4103,14 +4108,14 @@ def tag_for_aggro(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | keyword_mask | theme_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Aggro', 'Combat Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Aggro', 'Combat Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with Aggro strategy in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with Aggro strategy in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_aggro: {str(e)}') + logger.error(f'Error in tag_for_aggro: {str(e)}') raise @@ -4124,7 +4129,7 @@ def create_aristocrat_text_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have aristocrat text patterns """ - return tag_utility.create_text_mask(df, settings.ARISTOCRAT_TEXT_PATTERNS) + return tag_utils.create_text_mask(df, settings.ARISTOCRAT_TEXT_PATTERNS) def create_aristocrat_name_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for specific aristocrat-related cards. @@ -4135,7 +4140,7 @@ def create_aristocrat_name_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are specific aristocrat cards """ - return tag_utility.create_name_mask(df, settings.ARISTOCRAT_SPECIFIC_CARDS) + return tag_utils.create_name_mask(df, settings.ARISTOCRAT_SPECIFIC_CARDS) def create_aristocrat_self_sacrifice_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for creatures with self-sacrifice effects. @@ -4147,7 +4152,7 @@ def create_aristocrat_self_sacrifice_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which creatures have self-sacrifice effects """ # Create base creature mask - creature_mask = tag_utility.create_type_mask(df, 'Creature') + creature_mask = tag_utils.create_type_mask(df, 'Creature') # Create name-based patterns def check_self_sacrifice(row): @@ -4169,7 +4174,7 @@ def create_aristocrat_keyword_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have aristocrat keywords """ - return tag_utility.create_keyword_mask(df, 'Blitz') + return tag_utils.create_keyword_mask(df, 'Blitz') def create_aristocrat_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from aristocrat effects. @@ -4180,7 +4185,7 @@ def create_aristocrat_exclusion_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards should be excluded """ - return tag_utility.create_text_mask(df, settings.ARISTOCRAT_EXCLUSION_PATTERNS) + return tag_utils.create_text_mask(df, settings.ARISTOCRAT_EXCLUSION_PATTERNS) def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None: """Tag cards that fit the Aristocrats or Sacrifice Matters themes using vectorized operations. @@ -4207,12 +4212,12 @@ def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting aristocrats effect tagging for {color}_cards.csv') + logger.info(f'Starting aristocrats effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'name', 'type', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different aristocrat patterns text_mask = create_aristocrat_text_mask(df) @@ -4225,14 +4230,14 @@ def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | name_mask | self_sacrifice_mask | keyword_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Aristocrats', 'Sacrifice Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Aristocrats', 'Sacrifice Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with aristocrats effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with aristocrats effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_aristocrats: {str(e)}') + logger.error(f'Error in tag_for_aristocrats: {str(e)}') raise @@ -4273,7 +4278,7 @@ def tag_for_big_mana(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting big mana tagging for {color}_cards.csv') + logger.info(f'Starting big mana tagging for {color}_cards.csv') try: # Validate inputs @@ -4284,27 +4289,27 @@ def tag_for_big_mana(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'manaValue', 'manaCost', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different big mana patterns - text_mask = tag_utility.create_text_mask(df, settings.BIG_MANA_TEXT_PATTERNS) - keyword_mask = tag_utility.create_keyword_mask(df, settings.BIG_MANA_KEYWORDS) + text_mask = tag_utils.create_text_mask(df, settings.BIG_MANA_TEXT_PATTERNS) + keyword_mask = tag_utils.create_keyword_mask(df, settings.BIG_MANA_KEYWORDS) cost_mask = create_big_mana_cost_mask(df) - specific_mask = tag_utility.create_name_mask(df, settings.BIG_MANA_SPECIFIC_CARDS) - tag_mask = tag_utility.create_tag_mask(df, 'Cost Reduction') + specific_mask = tag_utils.create_name_mask(df, settings.BIG_MANA_SPECIFIC_CARDS) + tag_mask = tag_utils.create_tag_mask(df, 'Cost Reduction') # Combine all masks final_mask = text_mask | keyword_mask | cost_mask | specific_mask | tag_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Big Mana']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Big Mana']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with big mana effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with big mana effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_big_mana: {str(e)}') + logger.error(f'Error in tag_for_big_mana: {str(e)}') raise ## Blink @@ -4326,7 +4331,7 @@ def create_etb_mask(df: pd.DataFrame) -> pd.Series: 'when this creature enters', 'whenever this creature enters' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_ltb_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with leave-the-battlefield effects. @@ -4341,7 +4346,7 @@ def create_ltb_mask(df: pd.DataFrame) -> pd.Series: 'when this creature leaves', 'whenever this creature leaves' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_blink_text_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with blink/flicker text patterns. @@ -4363,7 +4368,7 @@ def create_blink_text_mask(df: pd.DataFrame) -> pd.Series: 'return those cards to the battlefield', 'triggered ability of a permanent' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def tag_for_blink(df: pd.DataFrame, color: str) -> None: """Tag cards that have blink/flicker effects using vectorized operations. @@ -4386,7 +4391,7 @@ def tag_for_blink(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting blink/flicker effect tagging for {color}_cards.csv') + logger.info(f'Starting blink/flicker effect tagging for {color}_cards.csv') try: # Validate inputs @@ -4397,7 +4402,7 @@ def tag_for_blink(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different blink patterns etb_mask = create_etb_mask(df) @@ -4418,14 +4423,14 @@ def tag_for_blink(df: pd.DataFrame, color: str) -> None: final_mask = etb_mask | ltb_mask | blink_mask | name_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Blink', 'Enter the Battlefield', 'Leave the Battlefield']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Blink', 'Enter the Battlefield', 'Leave the Battlefield']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with blink/flicker effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with blink/flicker effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_blink: {str(e)}') + logger.error(f'Error in tag_for_blink: {str(e)}') raise ## Burn @@ -4440,7 +4445,7 @@ def create_burn_damage_mask(df: pd.DataFrame) -> pd.Series: """ # Create damage number patterns using list comprehension damage_patterns = [f'deals {i} damage' for i in range(1, 101)] + ['deals x damage'] - damage_mask = tag_utility.create_text_mask(df, damage_patterns) + damage_mask = tag_utils.create_text_mask(df, damage_patterns) # Create general damage trigger patterns trigger_patterns = [ @@ -4454,11 +4459,11 @@ def create_burn_damage_mask(df: pd.DataFrame) -> pd.Series: 'would deal damage', 'would deal noncombat damage' ] - trigger_mask = tag_utility.create_text_mask(df, trigger_patterns) + trigger_mask = tag_utils.create_text_mask(df, trigger_patterns) # Create pinger patterns pinger_patterns = ['deals 1 damage', 'exactly 1 damage'] - pinger_mask = tag_utility.create_text_mask(df, pinger_patterns) + pinger_mask = tag_utils.create_text_mask(df, pinger_patterns) return damage_mask | trigger_mask | pinger_mask @@ -4476,7 +4481,7 @@ def create_burn_life_loss_mask(df: pd.DataFrame) -> pd.Series: life_patterns.extend([f'loses {i} life' for i in range(1, 101)]) life_patterns.append('lose x life') life_patterns.append('loses x life') - life_mask = tag_utility.create_text_mask(df, life_patterns) + life_mask = tag_utils.create_text_mask(df, life_patterns) # Create general life loss trigger patterns trigger_patterns = [ @@ -4488,7 +4493,7 @@ def create_burn_life_loss_mask(df: pd.DataFrame) -> pd.Series: 'unspent mana causes that player to lose that much life', 'would lose life' ] - trigger_mask = tag_utility.create_text_mask(df, trigger_patterns) + trigger_mask = tag_utils.create_text_mask(df, trigger_patterns) return life_mask | trigger_mask @@ -4502,7 +4507,7 @@ def create_burn_keyword_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards have burn keywords """ keyword_patterns = ['Bloodthirst', 'Spectacle'] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_burn_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from burn effects. @@ -4533,12 +4538,12 @@ def tag_for_burn(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting burn effect tagging for {color}_cards.csv') + logger.info(f'Starting burn effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different burn patterns damage_mask = create_burn_damage_mask(df) @@ -4548,18 +4553,18 @@ def tag_for_burn(df: pd.DataFrame, color: str) -> None: # Combine masks burn_mask = (damage_mask | life_mask | keyword_mask) & ~exclusion_mask - pinger_mask = tag_utility.create_text_mask(df, ['deals 1 damage', 'exactly 1 damage', 'loses 1 life']) + pinger_mask = tag_utils.create_text_mask(df, ['deals 1 damage', 'exactly 1 damage', 'loses 1 life']) # Apply tags - tag_utility.apply_tag_vectorized(df, burn_mask, ['Burn']) - tag_utility.apply_tag_vectorized(df, pinger_mask & ~exclusion_mask, ['Pingers']) + tag_utils.apply_tag_vectorized(df, burn_mask, ['Burn']) + tag_utils.apply_tag_vectorized(df, pinger_mask & ~exclusion_mask, ['Pingers']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {burn_mask.sum()} cards with burn effects in {duration:.2f}s') + logger.info(f'Tagged {burn_mask.sum()} cards with burn effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_burn: {str(e)}') + logger.error(f'Error in tag_for_burn: {str(e)}') raise ## Clones @@ -4582,7 +4587,7 @@ def create_clone_text_mask(df: pd.DataFrame) -> pd.Series: '"legend rule" doesn\'t apply', 'twice that many of those tokens' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_clone_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with clone-related keywords. @@ -4593,7 +4598,7 @@ def create_clone_keyword_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have clone keywords """ - return tag_utility.create_keyword_mask(df, 'Myriad') + return tag_utils.create_keyword_mask(df, 'Myriad') def create_clone_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from clone effects. @@ -4624,12 +4629,12 @@ def tag_for_clones(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting clone effect tagging for {color}_cards.csv') + logger.info(f'Starting clone effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different clone patterns text_mask = create_clone_text_mask(df) @@ -4640,14 +4645,14 @@ def tag_for_clones(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | keyword_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Clones']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Clones']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with clone effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with clone effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_clones: {str(e)}') + logger.error(f'Error in tag_for_clones: {str(e)}') raise ## Control @@ -4673,7 +4678,7 @@ def create_control_text_mask(df: pd.DataFrame) -> pd.Series: 'tap an untapped creature', 'your opponents cast' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_control_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with control-related keywords. @@ -4685,7 +4690,7 @@ def create_control_keyword_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards have control keywords """ keyword_patterns = ['Council\'s dilemma'] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_control_specific_cards_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for specific control-related cards. @@ -4704,7 +4709,7 @@ def create_control_specific_cards_mask(df: pd.DataFrame) -> pd.Series: 'Lavinia, Azorius Renegade', 'Talrand, Sky Summoner' ] - return tag_utility.create_name_mask(df, specific_cards) + return tag_utils.create_name_mask(df, specific_cards) def tag_for_control(df: pd.DataFrame, color: str) -> None: """Tag cards that fit the Control theme using vectorized operations. @@ -4724,12 +4729,12 @@ def tag_for_control(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting control effect tagging for {color}_cards.csv') + logger.info(f'Starting control effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different control patterns text_mask = create_control_text_mask(df) @@ -4740,14 +4745,14 @@ def tag_for_control(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | keyword_mask | specific_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Control']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Control']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with control effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with control effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_control: {str(e)}') + logger.error(f'Error in tag_for_control: {str(e)}') raise ## Energy @@ -4767,25 +4772,25 @@ def tag_for_energy(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting energy counter tagging for {color}_cards.csv') + logger.info(f'Starting energy counter tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create mask for energy text energy_mask = df['text'].str.contains('{e}', case=False, na=False) # Apply tags - tag_utility.apply_tag_vectorized(df, energy_mask, ['Energy']) + tag_utils.apply_tag_vectorized(df, energy_mask, ['Energy']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {energy_mask.sum()} cards with energy effects in {duration:.2f}s') + logger.info(f'Tagged {energy_mask.sum()} cards with energy effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_energy: {str(e)}') + logger.error(f'Error in tag_for_energy: {str(e)}') raise ## Infect @@ -4803,7 +4808,7 @@ def create_infect_text_mask(df: pd.DataFrame) -> pd.Series: 'poison counter', 'toxic [1-10]', ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_infect_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with infect-related keywords. @@ -4819,7 +4824,7 @@ def create_infect_keyword_mask(df: pd.DataFrame) -> pd.Series: 'Proliferate', 'Toxic', ] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_infect_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from infect effects. @@ -4850,12 +4855,12 @@ def tag_for_infect(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting infect effect tagging for {color}_cards.csv') + logger.info(f'Starting infect effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different infect patterns text_mask = create_infect_text_mask(df) @@ -4866,14 +4871,14 @@ def tag_for_infect(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | keyword_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Infect']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Infect']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with infect effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with infect effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_infect: {str(e)}') + logger.error(f'Error in tag_for_infect: {str(e)}') raise ## Legends Matter @@ -4908,7 +4913,7 @@ def create_legends_text_mask(df: pd.DataFrame) -> pd.Series: 'target legendary', 'the "legend rule" doesn\'t' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_legends_type_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with Legendary in their type line. @@ -4919,7 +4924,7 @@ def create_legends_type_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are Legendary """ - return tag_utility.create_type_mask(df, 'Legendary') + return tag_utils.create_type_mask(df, 'Legendary') def tag_for_legends_matter(df: pd.DataFrame, color: str) -> None: """Tag cards that care about legendary permanents using vectorized operations. @@ -4938,12 +4943,12 @@ def tag_for_legends_matter(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting legendary/historic tagging for {color}_cards.csv') + logger.info(f'Starting legendary/historic tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'type'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different legendary patterns text_mask = create_legends_text_mask(df) @@ -4953,14 +4958,14 @@ def tag_for_legends_matter(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | type_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Historics Matter', 'Legends Matter']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Historics Matter', 'Legends Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with legendary/historic effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with legendary/historic effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_legends_matter: {str(e)}') + logger.error(f'Error in tag_for_legends_matter: {str(e)}') raise ## Little Fellas @@ -4999,7 +5004,7 @@ def tag_for_little_guys(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting low-power creature tagging for {color}_cards.csv') + logger.info(f'Starting low-power creature tagging for {color}_cards.csv') try: # Validate inputs @@ -5010,24 +5015,24 @@ def tag_for_little_guys(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'power', 'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different patterns power_mask = create_little_guys_power_mask(df) - text_mask = tag_utility.create_text_mask(df, 'power 2 or less') + text_mask = tag_utils.create_text_mask(df, 'power 2 or less') # Combine masks final_mask = power_mask | text_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Little Fellas']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Little Fellas']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with Little Fellas in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with Little Fellas in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_little_guys: {str(e)}') + logger.error(f'Error in tag_for_little_guys: {str(e)}') raise ## Mill @@ -5058,12 +5063,12 @@ def create_mill_text_mask(df: pd.DataFrame) -> pd.Series: 'surveil', 'would mill' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Create mill number patterns mill_patterns = [f'mill {num}' for num in settings.num_to_search] mill_patterns.extend([f'mills {num}' for num in settings.num_to_search]) - number_mask = tag_utility.create_text_mask(df, mill_patterns) + number_mask = tag_utils.create_text_mask(df, mill_patterns) return text_mask | number_mask @@ -5077,7 +5082,7 @@ def create_mill_keyword_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards have mill keywords """ keyword_patterns = ['Descend', 'Mill', 'Surveil'] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def tag_for_mill(df: pd.DataFrame, color: str) -> None: """Tag cards that mill cards or care about milling using vectorized operations. @@ -5096,12 +5101,12 @@ def tag_for_mill(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting mill effect tagging for {color}_cards.csv') + logger.info(f'Starting mill effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different mill patterns text_mask = create_mill_text_mask(df) @@ -5111,14 +5116,14 @@ def tag_for_mill(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | keyword_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Mill']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Mill']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with mill effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with mill effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_mill: {str(e)}') + logger.error(f'Error in tag_for_mill: {str(e)}') raise def tag_for_monarch(df: pd.DataFrame, color: str) -> None: @@ -5142,7 +5147,7 @@ def tag_for_monarch(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting monarch mechanic tagging for {color}_cards.csv') + logger.info(f'Starting monarch mechanic tagging for {color}_cards.csv') try: # Validate inputs @@ -5153,7 +5158,7 @@ def tag_for_monarch(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create text pattern mask text_patterns = [ @@ -5166,23 +5171,23 @@ def tag_for_monarch(df: pd.DataFrame, color: str) -> None: 'you can\'t become the monarch', 'you\'re the monarch' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) # Create keyword mask - keyword_mask = tag_utility.create_keyword_mask(df, 'Monarch') + keyword_mask = tag_utils.create_keyword_mask(df, 'Monarch') # Combine masks final_mask = text_mask | keyword_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Monarch']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Monarch']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with monarch effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with monarch effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_monarch: {str(e)}') + logger.error(f'Error in tag_for_monarch: {str(e)}') raise ## Multi-copy cards @@ -5202,7 +5207,7 @@ def tag_for_multiple_copies(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting multiple copies tagging for {color}_cards.csv') + logger.info(f'Starting multiple copies tagging for {color}_cards.csv') try: # Validate inputs @@ -5213,10 +5218,10 @@ def tag_for_multiple_copies(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'name', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create mask for multiple copy cards - multiple_copies_mask = tag_utility.create_name_mask(df, multiple_copy_cards) + multiple_copies_mask = tag_utils.create_name_mask(df, multiple_copy_cards) # Apply tags if multiple_copies_mask.any(): @@ -5224,21 +5229,21 @@ def tag_for_multiple_copies(df: pd.DataFrame, color: str) -> None: matching_cards = df[multiple_copies_mask]['name'].unique() # Apply base tag - tag_utility.apply_tag_vectorized(df, multiple_copies_mask, ['Multiple Copies']) + tag_utils.apply_tag_vectorized(df, multiple_copies_mask, ['Multiple Copies']) # Apply individual card name tags for card_name in matching_cards: card_mask = df['name'] == card_name - tag_utility.apply_tag_vectorized(df, card_mask, [card_name]) + tag_utils.apply_tag_vectorized(df, card_mask, [card_name]) - logging.info(f'Tagged {multiple_copies_mask.sum()} cards with multiple copies effects') + logger.info(f'Tagged {multiple_copies_mask.sum()} cards with multiple copies effects') # Log completion duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Completed multiple copies tagging in {duration:.2f}s') + logger.info(f'Completed multiple copies tagging in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_multiple_copies: {str(e)}') + logger.error(f'Error in tag_for_multiple_copies: {str(e)}') raise ## Planeswalkers @@ -5262,7 +5267,7 @@ def create_planeswalker_text_mask(df: pd.DataFrame) -> pd.Series: 'planeswalker spells', 'planeswalker type' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_planeswalker_type_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with Planeswalker type. @@ -5273,7 +5278,7 @@ def create_planeswalker_type_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are Planeswalkers """ - return tag_utility.create_type_mask(df, 'Planeswalker') + return tag_utils.create_type_mask(df, 'Planeswalker') def create_planeswalker_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with planeswalker-related keywords. @@ -5284,7 +5289,7 @@ def create_planeswalker_keyword_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have planeswalker keywords """ - return tag_utility.create_keyword_mask(df, 'Proliferate') + return tag_utils.create_keyword_mask(df, 'Proliferate') def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None: """Tag cards that care about planeswalkers using vectorized operations. @@ -5304,7 +5309,7 @@ def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting planeswalker tagging for {color}_cards.csv') + logger.info(f'Starting planeswalker tagging for {color}_cards.csv') try: # Validate inputs @@ -5315,7 +5320,7 @@ def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'type', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different planeswalker patterns text_mask = create_planeswalker_text_mask(df) @@ -5326,14 +5331,14 @@ def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | type_mask | keyword_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Planeswalkers', 'Super Friends']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Planeswalkers', 'Super Friends']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with planeswalker effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with planeswalker effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_planeswalkers: {str(e)}') + logger.error(f'Error in tag_for_planeswalkers: {str(e)}') raise ## Reanimator @@ -5357,7 +5362,7 @@ def create_reanimator_text_mask(df: pd.DataFrame) -> pd.Series: 'into your graveyard', 'leave your graveyard' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_reanimator_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with reanimator-related keywords. @@ -5376,7 +5381,7 @@ def create_reanimator_keyword_mask(df: pd.DataFrame) -> pd.Series: 'Flashback', 'Mill' ] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_reanimator_type_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with reanimator-related creature types. @@ -5406,12 +5411,12 @@ def tag_for_reanimate(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting reanimator effect tagging for {color}_cards.csv') + logger.info(f'Starting reanimator effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords', 'creatureTypes'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different reanimator patterns text_mask = create_reanimator_text_mask(df) @@ -5422,14 +5427,14 @@ def tag_for_reanimate(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | keyword_mask | type_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Reanimate']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Reanimate']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with reanimator effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with reanimator effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_reanimate: {str(e)}') + logger.error(f'Error in tag_for_reanimate: {str(e)}') raise ## Stax @@ -5442,7 +5447,7 @@ def create_stax_text_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have stax text patterns """ - return tag_utility.create_text_mask(df, settings.STAX_TEXT_PATTERNS) + return tag_utils.create_text_mask(df, settings.STAX_TEXT_PATTERNS) def create_stax_name_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards used in stax strategies. @@ -5453,7 +5458,7 @@ def create_stax_name_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have stax text patterns """ - return tag_utility.create_text_mask(df, settings.STAX_SPECIFIC_CARDS) + return tag_utils.create_text_mask(df, settings.STAX_SPECIFIC_CARDS) def create_stax_tag_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with stax-related tags. @@ -5464,7 +5469,7 @@ def create_stax_tag_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have stax tags """ - return tag_utility.create_tag_mask(df, 'Control') + return tag_utils.create_tag_mask(df, 'Control') def create_stax_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from stax effects. @@ -5476,7 +5481,7 @@ def create_stax_exclusion_mask(df: pd.DataFrame) -> pd.Series: Boolean Series indicating which cards should be excluded """ # Add specific exclusion patterns here if needed - return tag_utility.create_text_mask(df, settings.STAX_EXCLUSION_PATTERNS) + return tag_utils.create_text_mask(df, settings.STAX_EXCLUSION_PATTERNS) def tag_for_stax(df: pd.DataFrame, color: str) -> None: """Tag cards that fit the Stax theme using vectorized operations. @@ -5495,12 +5500,12 @@ def tag_for_stax(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting stax effect tagging for {color}_cards.csv') + logger.info(f'Starting stax effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different stax patterns text_mask = create_stax_text_mask(df) @@ -5512,14 +5517,14 @@ def tag_for_stax(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | tag_mask | name_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Stax']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Stax']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with stax effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with stax effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_stax: {str(e)}') + logger.error(f'Error in tag_for_stax: {str(e)}') raise ## Theft @@ -5532,7 +5537,7 @@ def create_theft_text_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have theft text patterns """ - return tag_utility.create_text_mask(df, settings.THEFT_TEXT_PATTERNS) + return tag_utils.create_text_mask(df, settings.THEFT_TEXT_PATTERNS) def create_theft_name_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for specific theft-related cards. @@ -5543,7 +5548,7 @@ def create_theft_name_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are specific theft cards """ - return tag_utility.create_name_mask(df, settings.THEFT_SPECIFIC_CARDS) + return tag_utils.create_name_mask(df, settings.THEFT_SPECIFIC_CARDS) def tag_for_theft(df: pd.DataFrame, color: str) -> None: """Tag cards that steal or use opponents' resources using vectorized operations. @@ -5562,12 +5567,12 @@ def tag_for_theft(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting theft effect tagging for {color}_cards.csv') + logger.info(f'Starting theft effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different theft patterns text_mask = create_theft_text_mask(df) @@ -5577,14 +5582,14 @@ def tag_for_theft(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | name_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Theft']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Theft']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with theft effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with theft effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_theft: {str(e)}') + logger.error(f'Error in tag_for_theft: {str(e)}') raise ## Toughness Matters @@ -5606,7 +5611,7 @@ def create_toughness_text_mask(df: pd.DataFrame) -> pd.Series: 'toughness greater', 'with defender' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_toughness_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with toughness-related keywords. @@ -5617,7 +5622,7 @@ def create_toughness_keyword_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have toughness keywords """ - return tag_utility.create_keyword_mask(df, 'Defender') + return tag_utils.create_keyword_mask(df, 'Defender') def _is_valid_numeric_comparison(power: Union[int, str, None], toughness: Union[int, str, None]) -> bool: """Check if power and toughness values allow valid numeric comparison. @@ -5670,12 +5675,12 @@ def tag_for_toughness(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting toughness tagging for {color}_cards.csv') + logger.info(f'Starting toughness tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords', 'power', 'toughness'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different toughness patterns text_mask = create_toughness_text_mask(df) @@ -5686,14 +5691,14 @@ def tag_for_toughness(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | keyword_mask | power_toughness_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Toughness Matters']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Toughness Matters']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with toughness effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with toughness effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_toughness: {str(e)}') + logger.error(f'Error in tag_for_toughness: {str(e)}') raise ## Topdeck @@ -5706,7 +5711,7 @@ def create_topdeck_text_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have topdeck text patterns """ - return tag_utility.create_text_mask(df, settings.TOPDECK_TEXT_PATTERNS) + return tag_utils.create_text_mask(df, settings.TOPDECK_TEXT_PATTERNS) def create_topdeck_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with topdeck-related keywords. @@ -5717,7 +5722,7 @@ def create_topdeck_keyword_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have topdeck keywords """ - return tag_utility.create_keyword_mask(df, settings.TOPDECK_KEYWORDS) + return tag_utils.create_keyword_mask(df, settings.TOPDECK_KEYWORDS) def create_topdeck_specific_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for specific topdeck-related cards. @@ -5728,7 +5733,7 @@ def create_topdeck_specific_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are specific topdeck cards """ - return tag_utility.create_name_mask(df, settings.TOPDECK_SPECIFIC_CARDS) + return tag_utils.create_name_mask(df, settings.TOPDECK_SPECIFIC_CARDS) def create_topdeck_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from topdeck effects. @@ -5739,7 +5744,7 @@ def create_topdeck_exclusion_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards should be excluded """ - return tag_utility.create_text_mask(df, settings.TOPDECK_EXCLUSION_PATTERNS) + return tag_utils.create_text_mask(df, settings.TOPDECK_EXCLUSION_PATTERNS) def tag_for_topdeck(df: pd.DataFrame, color: str) -> None: """Tag cards that manipulate the top of library using vectorized operations. @@ -5758,12 +5763,12 @@ def tag_for_topdeck(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting topdeck effect tagging for {color}_cards.csv') + logger.info(f'Starting topdeck effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different topdeck patterns text_mask = create_topdeck_text_mask(df) @@ -5775,14 +5780,14 @@ def tag_for_topdeck(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | keyword_mask | specific_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Topdeck']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Topdeck']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with topdeck effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with topdeck effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_topdeck: {str(e)}') + logger.error(f'Error in tag_for_topdeck: {str(e)}') raise ## X Spells @@ -5808,7 +5813,7 @@ def create_x_spells_text_mask(df: pd.DataFrame) -> pd.Series: 'you cast cost {4} less', 'you cast cost {5} less' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_x_spells_mana_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with X in their mana cost. @@ -5838,12 +5843,12 @@ def tag_for_x_spells(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting X spells tagging for {color}_cards.csv') + logger.info(f'Starting X spells tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'manaCost'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different X spell patterns text_mask = create_x_spells_text_mask(df) @@ -5853,14 +5858,14 @@ def tag_for_x_spells(df: pd.DataFrame, color: str) -> None: final_mask = text_mask | mana_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['X Spells']) + tag_utils.apply_tag_vectorized(df, final_mask, ['X Spells']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with X spell effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with X spell effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_x_spells: {str(e)}') + logger.error(f'Error in tag_for_x_spells: {str(e)}') raise ### Interaction @@ -5887,7 +5892,7 @@ def tag_for_interaction(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting interaction effect tagging for {color}_cards.csv') + logger.info(f'Starting interaction effect tagging for {color}_cards.csv') print('\n==========\n') try: @@ -5899,40 +5904,40 @@ def tag_for_interaction(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'name', 'type', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Process each type of interaction sub_start = pd.Timestamp.now() tag_for_counterspells(df, color) - logging.info(f'Completed counterspell tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') + logger.info(f'Completed counterspell tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') print('\n==========\n') sub_start = pd.Timestamp.now() tag_for_board_wipes(df, color) - logging.info(f'Completed board wipe tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') + logger.info(f'Completed board wipe tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') print('\n==========\n') sub_start = pd.Timestamp.now() tag_for_combat_tricks(df, color) - logging.info(f'Completed combat trick tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') + logger.info(f'Completed combat trick tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') print('\n==========\n') sub_start = pd.Timestamp.now() tag_for_protection(df, color) - logging.info(f'Completed protection tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') + logger.info(f'Completed protection tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') print('\n==========\n') sub_start = pd.Timestamp.now() tag_for_removal(df, color) - logging.info(f'Completed removal tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') + logger.info(f'Completed removal tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') print('\n==========\n') # Log completion and performance metrics duration = pd.Timestamp.now() - start_time - logging.info(f'Completed all interaction tagging in {duration.total_seconds():.2f}s') + logger.info(f'Completed all interaction tagging in {duration.total_seconds():.2f}s') except Exception as e: - logging.error(f'Error in tag_for_interaction: {str(e)}') + logger.error(f'Error in tag_for_interaction: {str(e)}') raise ## Counterspells @@ -5945,7 +5950,7 @@ def create_counterspell_text_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have counterspell text patterns """ - return tag_utility.create_text_mask(df, settings.COUNTERSPELL_TEXT_PATTERNS) + return tag_utils.create_text_mask(df, settings.COUNTERSPELL_TEXT_PATTERNS) def create_counterspell_specific_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for specific counterspell cards. @@ -5956,7 +5961,7 @@ def create_counterspell_specific_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are specific counterspell cards """ - return tag_utility.create_name_mask(df, settings.COUNTERSPELL_SPECIFIC_CARDS) + return tag_utils.create_name_mask(df, settings.COUNTERSPELL_SPECIFIC_CARDS) def create_counterspell_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from counterspell effects. @@ -5967,7 +5972,7 @@ def create_counterspell_exclusion_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards should be excluded """ - return tag_utility.create_text_mask(df, settings.COUNTERSPELL_EXCLUSION_PATTERNS) + return tag_utils.create_text_mask(df, settings.COUNTERSPELL_EXCLUSION_PATTERNS) def tag_for_counterspells(df: pd.DataFrame, color: str) -> None: """Tag cards that counter spells using vectorized operations. @@ -5986,12 +5991,12 @@ def tag_for_counterspells(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - logging.info(f'Starting counterspell effect tagging for {color}_cards.csv') + logger.info(f'Starting counterspell effect tagging for {color}_cards.csv') try: # Validate required columns required_cols = {'text', 'themeTags', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different counterspell patterns text_mask = create_counterspell_text_mask(df) @@ -6002,14 +6007,14 @@ def tag_for_counterspells(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | specific_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Counterspells', 'Interaction', 'Spellslinger', 'Spells Matter']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Counterspells', 'Interaction', 'Spellslinger', 'Spells Matter']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with counterspell effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with counterspell effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_counterspells: {str(e)}') + logger.error(f'Error in tag_for_counterspells: {str(e)}') raise ## Board Wipes @@ -6035,7 +6040,7 @@ def tag_for_board_wipes(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting board wipe effect tagging for {color}_cards.csv') + logger.info(f'Starting board wipe effect tagging for {color}_cards.csv') try: # Validate inputs @@ -6046,20 +6051,20 @@ def tag_for_board_wipes(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'name'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different board wipe types - destroy_mask = tag_utility.create_mass_effect_mask(df, 'mass_destruction') - exile_mask = tag_utility.create_mass_effect_mask(df, 'mass_exile') - bounce_mask = tag_utility.create_mass_effect_mask(df, 'mass_bounce') - sacrifice_mask = tag_utility.create_mass_effect_mask(df, 'mass_sacrifice') - damage_mask = tag_utility.create_mass_damage_mask(df) + destroy_mask = tag_utils.create_mass_effect_mask(df, 'mass_destruction') + exile_mask = tag_utils.create_mass_effect_mask(df, 'mass_exile') + bounce_mask = tag_utils.create_mass_effect_mask(df, 'mass_bounce') + sacrifice_mask = tag_utils.create_mass_effect_mask(df, 'mass_sacrifice') + damage_mask = tag_utils.create_mass_damage_mask(df) # Create exclusion mask - exclusion_mask = tag_utility.create_text_mask(df, settings.BOARD_WIPE_EXCLUSION_PATTERNS) + exclusion_mask = tag_utils.create_text_mask(df, settings.BOARD_WIPE_EXCLUSION_PATTERNS) # Create specific cards mask - specific_mask = tag_utility.create_name_mask(df, settings.BOARD_WIPE_SPECIFIC_CARDS) + specific_mask = tag_utils.create_name_mask(df, settings.BOARD_WIPE_SPECIFIC_CARDS) # Combine all masks final_mask = ( @@ -6068,17 +6073,17 @@ def tag_for_board_wipes(df: pd.DataFrame, color: str) -> None: ) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Board Wipes', 'Interaction']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Board Wipes', 'Interaction']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with board wipe effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with board wipe effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_board_wipes: {str(e)}') + logger.error(f'Error in tag_for_board_wipes: {str(e)}') raise - logging.info(f'Completed board wipe tagging for {color}_cards.csv') + logger.info(f'Completed board wipe tagging for {color}_cards.csv') ## Combat Tricks def create_combat_tricks_text_mask(df: pd.DataFrame) -> pd.Series: @@ -6128,7 +6133,7 @@ def create_combat_tricks_text_mask(df: pd.DataFrame) -> pd.Series: # Combine all patterns all_patterns = buff_patterns + other_patterns - return tag_utility.create_text_mask(df, all_patterns) + return tag_utils.create_text_mask(df, all_patterns) def create_combat_tricks_type_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for instant-speed combat tricks. @@ -6139,7 +6144,7 @@ def create_combat_tricks_type_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards are instant-speed combat tricks """ - return tag_utility.create_type_mask(df, 'Instant') + return tag_utils.create_type_mask(df, 'Instant') def create_combat_tricks_flash_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for flash-based combat tricks. @@ -6150,7 +6155,7 @@ def create_combat_tricks_flash_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have flash-based combat tricks """ - return tag_utility.create_keyword_mask(df, 'Flash') + return tag_utils.create_keyword_mask(df, 'Flash') def create_combat_tricks_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from combat tricks. @@ -6167,14 +6172,14 @@ def create_combat_tricks_exclusion_mask(df: pd.DataFrame) -> pd.Series: 'Mantle of Leadership', 'Michiko\'s Reign of Truth // Portrait of Michiko' ] - name_mask = tag_utility.create_name_mask(df, excluded_cards) + name_mask = tag_utils.create_name_mask(df, excluded_cards) # Text patterns to exclude text_patterns = [ 'remains tapped', 'only as a sorcery' ] - text_mask = tag_utility.create_text_mask(df, text_patterns) + text_mask = tag_utils.create_text_mask(df, text_patterns) return name_mask | text_mask @@ -6196,7 +6201,7 @@ def tag_for_combat_tricks(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting combat trick tagging for {color}_cards.csv') + logger.info(f'Starting combat trick tagging for {color}_cards.csv') try: # Validate inputs @@ -6207,7 +6212,7 @@ def tag_for_combat_tricks(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'type', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different combat trick patterns text_mask = create_combat_tricks_text_mask(df) @@ -6217,17 +6222,17 @@ def tag_for_combat_tricks(df: pd.DataFrame, color: str) -> None: # Combine masks final_mask = ((text_mask & (type_mask | flash_mask)) | - (flash_mask & tag_utility.create_type_mask(df, 'Enchantment'))) & ~exclusion_mask + (flash_mask & tag_utils.create_type_mask(df, 'Enchantment'))) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Combat Tricks', 'Interaction']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Combat Tricks', 'Interaction']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with combat trick effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with combat trick effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_combat_tricks: {str(e)}') + logger.error(f'Error in tag_for_combat_tricks: {str(e)}') raise ## Protection/Safety spells @@ -6263,7 +6268,7 @@ def create_protection_text_mask(df: pd.DataFrame) -> pd.Series: 'phases out', 'protection from' ] - return tag_utility.create_text_mask(df, text_patterns) + return tag_utils.create_text_mask(df, text_patterns) def create_protection_keyword_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with protection-related keywords. @@ -6281,7 +6286,7 @@ def create_protection_keyword_mask(df: pd.DataFrame) -> pd.Series: 'Shroud', 'Ward' ] - return tag_utility.create_keyword_mask(df, keyword_patterns) + return tag_utils.create_keyword_mask(df, keyword_patterns) def create_protection_exclusion_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that should be excluded from protection effects. @@ -6296,7 +6301,7 @@ def create_protection_exclusion_mask(df: pd.DataFrame) -> pd.Series: 'Out of Time', 'The War Doctor' ] - return tag_utility.create_name_mask(df, excluded_cards) + return tag_utils.create_name_mask(df, excluded_cards) def tag_for_protection(df: pd.DataFrame, color: str) -> None: """Tag cards that provide or have protection effects using vectorized operations. @@ -6320,7 +6325,7 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting protection effect tagging for {color}_cards.csv') + logger.info(f'Starting protection effect tagging for {color}_cards.csv') try: # Validate inputs @@ -6331,7 +6336,7 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different protection patterns text_mask = create_protection_text_mask(df) @@ -6342,14 +6347,14 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None: final_mask = (text_mask | keyword_mask) & ~exclusion_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Protection', 'Interaction']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Protection', 'Interaction']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with protection effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with protection effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_protection: {str(e)}') + logger.error(f'Error in tag_for_protection: {str(e)}') raise ## Spot removal @@ -6362,7 +6367,7 @@ def create_removal_text_mask(df: pd.DataFrame) -> pd.Series: Returns: Boolean Series indicating which cards have removal text patterns """ - return tag_utility.create_text_mask(df, settings.REMOVAL_TEXT_PATTERNS) + return tag_utils.create_text_mask(df, settings.REMOVAL_TEXT_PATTERNS) def tag_for_removal(df: pd.DataFrame, color: str) -> None: """Tag cards that provide spot removal using vectorized operations. @@ -6385,7 +6390,7 @@ def tag_for_removal(df: pd.DataFrame, color: str) -> None: TypeError: If inputs are not of correct type """ start_time = pd.Timestamp.now() - logging.info(f'Starting removal effect tagging for {color}_cards.csv') + logger.info(f'Starting removal effect tagging for {color}_cards.csv') try: # Validate inputs @@ -6396,7 +6401,7 @@ def tag_for_removal(df: pd.DataFrame, color: str) -> None: # Validate required columns required_cols = {'text', 'themeTags', 'keywords'} - tag_utility.validate_dataframe_columns(df, required_cols) + tag_utils.validate_dataframe_columns(df, required_cols) # Create masks for different removal patterns text_mask = create_removal_text_mask(df) @@ -6405,12 +6410,21 @@ def tag_for_removal(df: pd.DataFrame, color: str) -> None: final_mask = text_mask # Apply tags - tag_utility.apply_tag_vectorized(df, final_mask, ['Removal', 'Interaction']) + tag_utils.apply_tag_vectorized(df, final_mask, ['Removal', 'Interaction']) # Log results duration = (pd.Timestamp.now() - start_time).total_seconds() - logging.info(f'Tagged {final_mask.sum()} cards with removal effects in {duration:.2f}s') + logger.info(f'Tagged {final_mask.sum()} cards with removal effects in {duration:.2f}s') except Exception as e: - logging.error(f'Error in tag_for_removal: {str(e)}') + logger.error(f'Error in tag_for_removal: {str(e)}') raise + +def run_tagging(): + start_time = pd.Timestamp.now() + for color in settings.colors: + load_dataframe(color) + duration = (pd.Timestamp.now() - start_time).total_seconds() + logger.info(f'Tagged cards in {duration:.2f}s') + +run_tagging() \ No newline at end of file From 319f7848d3106104084fa7ec4e1030915af3cf61 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Tue, 14 Jan 2025 12:07:49 -0800 Subject: [PATCH 3/6] Began work on overhauling the deck_builder --- builder_utils.py | 331 ++++++++++++ deck_builder.py | 1180 ++++++++++++++++++++++++++++--------------- exceptions.py | 676 +++++++++++++++++++------ input_handler.py | 498 +++++++++++++----- price_check.py | 241 +++++++-- settings.py | 260 +++++++++- setup.py | 24 +- setup_utils.py | 77 ++- tagger.py | 14 +- type_definitions.py | 49 ++ 10 files changed, 2589 insertions(+), 761 deletions(-) create mode 100644 builder_utils.py create mode 100644 type_definitions.py diff --git a/builder_utils.py b/builder_utils.py new file mode 100644 index 0000000..d3ff1b5 --- /dev/null +++ b/builder_utils.py @@ -0,0 +1,331 @@ +from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union +import logging +import functools +import time +import pandas as pd +from fuzzywuzzy import process +from settings import ( + COMMANDER_CSV_PATH, + FUZZY_MATCH_THRESHOLD, + MAX_FUZZY_CHOICES, + COMMANDER_CONVERTERS, + DATAFRAME_VALIDATION_RULES, + DATAFRAME_VALIDATION_TIMEOUT, + DATAFRAME_BATCH_SIZE, + DATAFRAME_TRANSFORM_TIMEOUT, + DATAFRAME_REQUIRED_COLUMNS +) +from exceptions import ( + DeckBuilderError, + CSVValidationError, + DataFrameValidationError, + DataFrameTimeoutError, + EmptyDataFrameError +) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + +# Type variables for generic functions +T = TypeVar('T') +DataFrame = TypeVar('DataFrame', bound=pd.DataFrame) + +def timeout_wrapper(timeout: float) -> Callable: + """Decorator to add timeout to functions. + + Args: + timeout: Maximum execution time in seconds + + Returns: + Decorated function with timeout + + Raises: + DataFrameTimeoutError: If operation exceeds timeout + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> T: + start_time = time.time() + result = func(*args, **kwargs) + elapsed = time.time() - start_time + + if elapsed > timeout: + raise DataFrameTimeoutError( + func.__name__, + timeout, + elapsed, + {'args': args, 'kwargs': kwargs} + ) + return result + return wrapper + return decorator + +def get_validation_rules(data_type: str) -> Dict[str, Dict[str, Any]]: + """Get validation rules for specific data type. + + Args: + data_type: Type of data to get rules for + + Returns: + Dictionary of validation rules + """ + from settings import ( + CREATURE_VALIDATION_RULES, + SPELL_VALIDATION_RULES, + LAND_VALIDATION_RULES + ) + + rules_map = { + 'creature': CREATURE_VALIDATION_RULES, + 'spell': SPELL_VALIDATION_RULES, + 'land': LAND_VALIDATION_RULES + } + + return rules_map.get(data_type, DATAFRAME_VALIDATION_RULES) + +@timeout_wrapper(DATAFRAME_VALIDATION_TIMEOUT) +def validate_dataframe(df: pd.DataFrame, rules: Dict[str, Dict[str, Any]]) -> bool: + """Validate DataFrame against provided rules. + + Args: + df: DataFrame to validate + rules: Validation rules to apply + + Returns: + True if validation passes + + Raises: + DataFrameValidationError: If validation fails + """ + #print(df.columns) + if df.empty: + raise EmptyDataFrameError("validate_dataframe") + + try: + validate_required_columns(df) + validate_column_types(df, rules) + return True + except Exception as e: + raise DataFrameValidationError( + "DataFrame validation failed", + {'rules': rules, 'error': str(e)} + ) + +def validate_column_types(df: pd.DataFrame, rules: Dict[str, Dict[str, Any]]) -> bool: + """Validate column types against rules. + + Args: + df: DataFrame to validate + rules: Type validation rules + + Returns: + True if validation passes + + Raises: + DataFrameValidationError: If type validation fails + """ + for col, rule in rules.items(): + if col not in df.columns: + continue + + expected_type = rule.get('type') + if not expected_type: + continue + + if isinstance(expected_type, tuple): + valid = any(df[col].dtype.name.startswith(t) for t in expected_type) + else: + valid = df[col].dtype.name.startswith(expected_type) + + if not valid: + raise DataFrameValidationError( + col, + rule, + {'actual_type': df[col].dtype.name} + ) + + return True + +def validate_required_columns(df: pd.DataFrame) -> bool: + """Validate presence of required columns. + + Args: + df: DataFrame to validate + + Returns: + True if validation passes + + Raises: + DataFrameValidationError: If required columns are missing + """ + #print(df.columns) + missing = set(DATAFRAME_REQUIRED_COLUMNS) - set(df.columns) + if missing: + raise DataFrameValidationError( + "missing_columns", + {'required': DATAFRAME_REQUIRED_COLUMNS}, + {'missing': list(missing)} + ) + return True + +@timeout_wrapper(DATAFRAME_TRANSFORM_TIMEOUT) +def process_dataframe_batch(df: pd.DataFrame, batch_size: int = DATAFRAME_BATCH_SIZE) -> pd.DataFrame: + """Process DataFrame in batches. + + Args: + df: DataFrame to process + batch_size: Size of each batch + + Returns: + Processed DataFrame + + Raises: + DataFrameTimeoutError: If processing exceeds timeout + """ + processed_dfs = [] + + for i in range(0, len(df), batch_size): + batch = df.iloc[i:i + batch_size].copy() + processed = transform_dataframe(batch) + processed_dfs.append(processed) + + return pd.concat(processed_dfs, ignore_index=True) + +def transform_dataframe(df: pd.DataFrame) -> pd.DataFrame: + """Apply transformations to DataFrame. + + Args: + df: DataFrame to transform + + Returns: + Transformed DataFrame + """ + df = df.copy() + + # Fill missing values + df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') + df['colors'] = df['colors'].fillna('COLORLESS') + + # Convert types + numeric_cols = ['manaValue', 'edhrecRank'] + for col in numeric_cols: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + return df + +def combine_dataframes(dfs: List[pd.DataFrame]) -> pd.DataFrame: + """Combine multiple DataFrames with validation. + + Args: + dfs: List of DataFrames to combine + + Returns: + Combined DataFrame + + Raises: + EmptyDataFrameError: If no valid DataFrames to combine + """ + if not dfs: + raise EmptyDataFrameError("No DataFrames to combine") + + valid_dfs = [] + for df in dfs: + try: + if validate_dataframe(df, DATAFRAME_VALIDATION_RULES): + valid_dfs.append(df) + except DataFrameValidationError as e: + logger.warning(f"Skipping invalid DataFrame: {e}") + + if not valid_dfs: + raise EmptyDataFrameError("No valid DataFrames to combine") + + return pd.concat(valid_dfs, ignore_index=True) + +def load_commander_data(csv_path: str = COMMANDER_CSV_PATH, + converters: Dict = COMMANDER_CONVERTERS) -> pd.DataFrame: + """Load and prepare commander data from CSV file. + + Args: + csv_path (str): Path to commander CSV file. Defaults to COMMANDER_CSV_PATH. + converters (Dict): Column converters for CSV loading. Defaults to COMMANDER_CONVERTERS. + + Returns: + pd.DataFrame: Processed commander dataframe + + Raises: + DeckBuilderError: If CSV file cannot be loaded or processed + """ + try: + df = pd.read_csv(csv_path, converters=converters) + df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') + df['colors'] = df['colors'].fillna('COLORLESS') + return df + except FileNotFoundError: + logger.error(f"Commander CSV file not found at {csv_path}") + raise DeckBuilderError(f"Commander data file not found: {csv_path}") + except Exception as e: + logger.error(f"Error loading commander data: {e}") + raise DeckBuilderError(f"Failed to load commander data: {str(e)}") + +def process_fuzzy_matches(card_name: str, + df: pd.DataFrame, + threshold: int = FUZZY_MATCH_THRESHOLD, + max_choices: int = MAX_FUZZY_CHOICES) -> Tuple[str, List[Tuple[str, int]], bool]: + """Process fuzzy matching for commander name selection. + + Args: + card_name (str): Input card name to match + df (pd.DataFrame): Commander dataframe to search + threshold (int): Minimum score for direct match. Defaults to FUZZY_MATCH_THRESHOLD. + max_choices (int): Maximum number of choices to return. Defaults to MAX_FUZZY_CHOICES. + + Returns: + Tuple[str, List[Tuple[str, int]], bool]: Selected card name, list of matches with scores, and match status + """ + try: + match, score, _ = process.extractOne(card_name, df['name']) + if score >= threshold: + return match, [], True + + fuzzy_choices = process.extract(card_name, df['name'], limit=max_choices) + fuzzy_choices = [(name, score) for name, score in fuzzy_choices] + return "", fuzzy_choices, False + except Exception as e: + logger.error(f"Error in fuzzy matching: {e}") + raise DeckBuilderError(f"Failed to process fuzzy matches: {str(e)}") + +def validate_commander_selection(df: pd.DataFrame, commander_name: str) -> Dict: + """Validate and format commander data from selection. + + Args: + df (pd.DataFrame): Commander dataframe + commander_name (str): Selected commander name + + Returns: + Dict: Formatted commander data dictionary + + Raises: + DeckBuilderError: If commander data is invalid or missing + """ + try: + filtered_df = df[df['name'] == commander_name] + if filtered_df.empty: + raise DeckBuilderError(f"No commander found with name: {commander_name}") + + commander_dict = filtered_df.to_dict('list') + + # Validate required fields + required_fields = ['name', 'type', 'colorIdentity', 'colors', 'manaCost', 'manaValue'] + for field in required_fields: + if field not in commander_dict or not commander_dict[field]: + raise DeckBuilderError(f"Missing required commander data: {field}") + + return commander_dict + except Exception as e: + logger.error(f"Error validating commander selection: {e}") + raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}") \ No newline at end of file diff --git a/deck_builder.py b/deck_builder.py index aa43755..0912ade 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -1,3 +1,86 @@ +from __future__ import annotations + +import logging +import math +import numpy as np +import random +import time +from functools import lru_cache +from typing import Dict, List, Optional, Union + +import inquirer.prompt # type: ignore +import keyboard # type: ignore +import pandas as pd # type: ignore +import pprint # type: ignore +from fuzzywuzzy import process # type: ignore + +from settings import ( + BASIC_LANDS, CARD_TYPES, CSV_DIRECTORY, multiple_copy_cards, + COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, + COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, + COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, + COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, + COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, + CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS +) +import builder_utils +import setup_utils +from setup import determine_commanders +from input_handler import InputHandler +from exceptions import ( + CommanderColorError, + CommanderLoadError, + CommanderSelectionError, + CommanderValidationError, + CSVError, + CSVReadError, + CSVTimeoutError, + CSVValidationError, + DataFrameValidationError, + DeckBuilderError, + EmptyDataFrameError, + EmptyInputError, + InvalidNumberError, + InvalidQuestionTypeError, + MaxAttemptsError, + PriceAPIError, + PriceLimitError, + PriceTimeoutError, + PriceValidationError +) +from type_definitions import ( + CardDict, + CommanderDict, + CardLibraryDF, + CommanderDF, + LandDF, + ArtifactDF, + CreatureDF, + NonCreatureDF) + +# Try to import scrython and price_checker +try: + import scrython # type: ignore + from price_check import PriceChecker + use_scrython = True +except ImportError: + scrython = None + PriceChecker = None + use_scrython = False + logging.warning("Scrython is not installed. Price checking features will be unavailable." + ) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + +pd.set_option('display.max_columns', None) +pd.set_option('display.max_rows', None) +pd.set_option('display.max_colwidth', 50) + """ Basic deck builder, primarily intended for building Kindred decks. Logic for other themes (such as Spellslinger or Wheels), is added. @@ -11,52 +94,6 @@ Land spread will ideally be handled based on pips and some adjustment is planned based on mana curve and ramp added. """ -from __future__ import annotations -from input_handler import InputHandler -from price_check import check_price - -import logging -import math -import numpy as np -import pandas as pd # type: ignore -import pprint # type: ignore -import random -import time -import os - -from fuzzywuzzy import process # type: ignore - -from exceptions import PriceCheckError -from settings import basic_lands, card_types, csv_directory, multiple_copy_cards -from setup import determine_commanders - -# Create logs directory if it doesn't exist -if not os.path.exists('logs'): - os.makedirs('logs') - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), - logging.FileHandler('logs/deck_builder.log', mode='w', encoding='utf-8') - ] -) -logger = logging.getLogger(__name__) - -try: - import scrython # type: ignore - use_scrython = True -except ImportError: - scrython = None - use_scrython = False - logger.warning("Scrython is not installed. Some pricing features will be unavailable.") - - -pd.set_option('display.max_columns', None) -pd.set_option('display.max_rows', None) -pd.set_option('display.max_colwidth', 50) - def new_line(num_lines: int = 1) -> None: """Print specified number of newlines for formatting output. @@ -71,134 +108,326 @@ def new_line(num_lines: int = 1) -> None: print('\n' * num_lines) class DeckBuilder: - def __init__(self): - self.card_library = pd.DataFrame() - self.card_library['Card Name'] = pd.Series(dtype='str') - self.card_library['Card Type'] = pd.Series(dtype='str') - self.card_library['Mana Cost'] = pd.Series(dtype='str') - self.card_library['Mana Value'] = pd.Series(dtype='int') - self.card_library['Commander'] = pd.Series(dtype='bool') + + def __init__(self) -> None: + """Initialize DeckBuilder with empty dataframes and default attributes.""" + # Initialize dataframes with type hints + self.card_library: CardLibraryDF = pd.DataFrame({ + 'Card Name': pd.Series(dtype='str'), + 'Card Type': pd.Series(dtype='str'), + 'Mana Cost': pd.Series(dtype='str'), + 'Mana Value': pd.Series(dtype='int'), + 'Commander': pd.Series(dtype='bool') + }) + # Initialize component dataframes + self.commander_df: CommanderDF = pd.DataFrame() + self.land_df: LandDF = pd.DataFrame() + self.artifact_df: ArtifactDF = pd.DataFrame() + self.creature_df: CreatureDF = pd.DataFrame() + self.noncreature_df: NonCreatureDF = pd.DataFrame() + + # Initialize other attributes with type hints + self.commander_info: Dict = {} + self.commander: str = '' + self.commander_type: str = '' + self.commander_text: str = '' + self.commander_power: int = 0 + self.commander_toughness: int = 0 + self.commander_mana_cost: str = '' + self.commander_mana_value: int = 0 + self.color_identity: Union[str, List[str]] = '' + self.color_identity_full: str = '' + self.colors: List[str] = [] + self.creature_types: str = '' + self.commander_tags: List[str] = [] + self.themes: List[str] = [] + + # Initialize handlers + self.price_checker = PriceChecker() if PriceChecker else None self.input_handler = InputHandler() - self.set_max_deck_price = False - self.set_max_card_price = False - self.card_prices = {} + + def pause_with_message(self, message: str = "Press Enter to continue...") -> None: + """Display a message and wait for user input. - def pause_with_message(self, message="Press Enter to continue..."): + Args: + message: Message to display before pausing + """ """Helper function to pause execution with a message.""" print(f"\n{message}") input() - - def determine_commander(self): - # Setup dataframe + + # Determine and Validate commander + def determine_commander(self) -> None: + """Main orchestrator method for commander selection and initialization process. + + This method coordinates the commander selection workflow by: + 1. Loading commander data + 2. Facilitating commander selection + 3. Confirming the selection + 4. Initializing commander attributes + + Raises: + CommanderLoadError: If commander data cannot be loaded + CommanderSelectionError: If commander selection fails + CommanderValidationError: If commander data is invalid + """ + logger.info("Starting commander selection process") + try: - df = pd.read_csv('csv_files/commander_cards.csv', converters={'themeTags': pd.eval, 'creatureTypes': pd.eval}) - except FileNotFoundError: - determine_commanders() - df = pd.read_csv('csv_files/commander_cards.csv', converters={'themeTags': pd.eval, 'creatureTypes': pd.eval}) - # Determine the commander of the deck - # Set frames that have nothing for color identity to be 'COLORLESS' instead - df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') - df['colors'] = df['colors'].fillna('COLORLESS') - commander_chosen = False - while not commander_chosen: - print('Enter a card name to be your commander, note that at this time only cards that have the \'Creature\' type may be chosen') - card_choice = self.input_handler.questionnaire('Text', '') - # Logic to find the card in the commander_cards csv, then display it's information - # If the card can't be found, or doesn't have enough of a match score, display a - # list to choose from - fuzzy_chosen = False - while not fuzzy_chosen: - match, score, _ = process.extractOne(card_choice, df['name']) - if score >= 90: - fuzzy_card_choice = match - print(fuzzy_card_choice) - fuzzy_chosen = True - else: - logger.warning('Multiple options found, which is correct?') - fuzzy_card_choices = process.extract(card_choice, df['name'], limit=5) - fuzzy_card_choices.append('Neither') - print(fuzzy_card_choices) - fuzzy_card_choice = self.input_handler.questionnaire('Choice', choices_list=fuzzy_card_choices) - if isinstance(fuzzy_card_choice, tuple): - fuzzy_card_choice = fuzzy_card_choice[0] - if fuzzy_card_choice != 'Neither': - print(fuzzy_card_choice) - fuzzy_chosen = True + # Load commander data using builder_utils + df = builder_utils.load_commander_data() + logger.debug("Commander data loaded successfully") + + # Select commander + commander_name = self._select_commander(df) + logger.info(f"Commander selected: {commander_name}") + + # Confirm selection + commander_data = self._confirm_commander(df, commander_name) + logger.info("Commander selection confirmed") + + # Initialize commander + self._initialize_commander(commander_data) + logger.info("Commander initialization complete") + + except DeckBuilderError as e: + logger.error(f"Commander selection failed: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in commander selection: {e}") + raise DeckBuilderError(f"Commander selection failed: {str(e)}") - else: - break - - filtered_df = df[df['name'] == fuzzy_card_choice] - df_dict = filtered_df.to_dict('list') - print('Is this the card you chose?') - pprint.pprint(df_dict, sort_dicts=False) - self.commander_df = pd.DataFrame(df_dict) - - # Confirm if card entered was correct - commander_confirmed = self.input_handler.questionnaire('Confirm', True) - # If correct, set it as the commander - if commander_confirmed: - commander_chosen = True - self.commander_info = df_dict - self.commander = self.commander_df.at[0, 'name'] - logger.info(f"Commander selected: {self.commander}") - break - else: - commander_chosen = False - - # Send commander info to setup commander, including extracting info on colors, color identity, - # creature types, and other information, like keywords, abilities, etc... - self.commander_setup() + def _select_commander(self, df: pd.DataFrame) -> str: + """Handle the commander selection process including fuzzy matching. + + Args: + df: DataFrame containing commander data + + Returns: + Selected commander name + + Raises: + CommanderSelectionError: If commander selection fails + """ + while True: + try: + card_choice = self.input_handler.questionnaire( + 'Text', + 'Enter a card name to be your commander' + ) - def commander_setup(self): - # Load commander info into a dataframe + # Use builder_utils for fuzzy matching + match, choices, exact_match = builder_utils.process_fuzzy_matches(card_choice, df) + + if exact_match: + return match + + # Handle multiple matches + choices.append(('Neither', 0)) + logger.info("Multiple commander matches found") + + choice = self.input_handler.questionnaire( + 'Choice', + 'Multiple matches found. Please select:', + choices_list=[name for name, _ in choices] + ) + + if choice != 'Neither': + return choice + + except DeckBuilderError as e: + logger.warning(f"Commander selection attempt failed: {e}") + continue + + def _confirm_commander(self, df: pd.DataFrame, commander_name: str) -> Dict: + """Confirm commander selection and validate data. + + Args: + df: DataFrame containing commander data + commander_name: Name of selected commander + + Returns: + Dictionary containing commander data + + Raises: + CommanderValidationError: If commander data is invalid + """ + try: + # Validate commander data + commander_data = builder_utils.validate_commander_selection(df, commander_name) + + # Store commander DataFrame + self.commander_df = pd.DataFrame(commander_data) + + # Display commander info + print('\nSelected Commander:') + pprint.pprint(commander_data, sort_dicts=False) + + # Confirm selection + if not self.input_handler.questionnaire('Confirm', 'Is this the commander you want?', True): + raise CommanderSelectionError("Commander selection cancelled by user") + + # Check price if enabled + if self.price_checker: + self.price_checker.get_card_price(commander_name) + + return commander_data + + except DeckBuilderError as e: + logger.error(f"Commander confirmation failed: {e}") + raise + + def _initialize_commander(self, commander_data: Dict) -> None: + """Initialize commander attributes from validated data. + + Args: + commander_data: Dictionary containing commander information + + Raises: + CommanderValidationError: If required attributes are missing + """ + try: + # Store commander info + self.commander_info = commander_data + self.commander = commander_data['name'][0] + + # Initialize commander attributes + self.commander_setup() + logger.debug("Commander attributes initialized successfully") + + except Exception as e: + logger.error(f"Commander initialization failed: {e}") + raise CommanderValidationError(f"Failed to initialize commander: {str(e)}") + + # Setup Commander + def commander_setup(self) -> None: + """Set up commander attributes and initialize deck building. + + This method orchestrates the commander setup process by calling specialized + helper methods to handle different aspects of initialization. + + Raises: + CommanderValidationError: If commander validation fails + DeckBuilderError: If deck building initialization fails + """ + try: + # Initialize commander attributes + self._initialize_commander_attributes() + + # Set up commander components + self._setup_commander_type_and_text() + self._setup_commander_stats() + self._setup_color_identity() + self._setup_creature_types() + self._setup_commander_tags() + + # Initialize commander dictionary and deck + self._initialize_commander_dict() + self._initialize_deck_building() + + logger.info("Commander setup completed successfully") + + except CommanderValidationError as e: + logger.error(f"Commander validation failed: {e}") + raise + except DeckBuilderError as e: + logger.error(f"Deck building initialization failed: {e}") + raise + + def _initialize_commander_attributes(self) -> None: + """Initialize basic commander attributes with defaults. + + Uses settings.py constants for default values. + """ + self.commander_power = COMMANDER_POWER_DEFAULT + self.commander_toughness = COMMANDER_TOUGHNESS_DEFAULT + self.commander_mana_value = COMMANDER_MANA_VALUE_DEFAULT + self.commander_type = COMMANDER_TYPE_DEFAULT + self.commander_text = COMMANDER_TEXT_DEFAULT + self.commander_mana_cost = COMMANDER_MANA_COST_DEFAULT + self.color_identity = COMMANDER_COLOR_IDENTITY_DEFAULT + self.colors = COMMANDER_COLORS_DEFAULT.copy() + self.creature_types = COMMANDER_CREATURE_TYPES_DEFAULT + self.commander_tags = COMMANDER_TAGS_DEFAULT.copy() + self.themes = COMMANDER_THEMES_DEFAULT.copy() + + def _setup_commander_type_and_text(self) -> None: + """Set up and validate commander type line and text. + + Raises: + CommanderTypeError: If type line validation fails + """ df = self.commander_df - - # Set type line - self.commander_type = str(df.at[0, 'type']) - - # Set text line + type_line = str(df.at[0, 'type']) + self.commander_type = self.input_handler.validate_commander_type(type_line) self.commander_text = str(df.at[0, 'text']) - # Set Power - self.commander_power = int(df.at[0, 'power']) - - # Set Toughness - self.commander_toughness = int(df.at[0, 'toughness']) - - # Set Mana Cost + def _setup_commander_stats(self) -> None: + """Set up and validate commander power, toughness, and mana values. + + Raises: + CommanderStatsError: If stats validation fails + """ + df = self.commander_df + + # Validate power and toughness + self.commander_power = self.input_handler.validate_commander_stats( + 'power', str(df.at[0, 'power'])) + self.commander_toughness = self.input_handler.validate_commander_stats( + 'toughness', str(df.at[0, 'toughness'])) + + # Set mana cost and value self.commander_mana_cost = str(df.at[0, 'manaCost']) - self.commander_mana_value = int(df.at[0, 'manaValue']) + self.commander_mana_value = self.input_handler.validate_commander_stats( + 'mana value', int(df.at[0, 'manaValue'])) - # Set color identity + def _setup_color_identity(self) -> None: + """Set up and validate commander color identity. + + Raises: + CommanderColorError: If color identity validation fails + """ + df = self.commander_df try: - self.color_identity = df.at[0, 'colorIdentity'] - if pd.isna(self.color_identity): - self.color_identity = 'COLORLESS' + color_id = df.at[0, 'colorIdentity'] + if pd.isna(color_id): + color_id = 'COLORLESS' + + self.color_identity = self.input_handler.validate_commander_colors(color_id) self.color_identity_full = '' self.determine_color_identity() - except Exception as e: - logger.error(f"Failed to set color identity: {e}") - raise ValueError("Could not determine color identity") from e - - # Set creature colors - if pd.notna(df.at[0, 'colors']) and df.at[0, 'colors'].strip(): - self.colors = [color.strip() for color in df.at[0, 'colors'].split(',') if color.strip()] - if not self.colors: + print(self.color_identity_full) + + # Set colors list + if pd.notna(df.at[0, 'colors']) and df.at[0, 'colors'].strip(): + self.colors = [color.strip() for color in df.at[0, 'colors'].split(',') if color.strip()] + if not self.colors: + self.colors = ['COLORLESS'] + else: self.colors = ['COLORLESS'] - else: - self.colors = ['COLORLESS'] + + except Exception as e: + raise CommanderColorError(f"Failed to set color identity: {str(e)}") - # Set creature types + def _setup_creature_types(self) -> None: + """Set up commander creature types.""" + df = self.commander_df self.creature_types = str(df.at[0, 'creatureTypes']) - # Set deck theme tags - self.commander_tags = list(df.at[0, 'themeTags']) - - self.determine_themes() + def _setup_commander_tags(self) -> None: + """Set up and validate commander theme tags. + Raises: + CommanderTagError: If tag validation fails + """ + df = self.commander_df + tags = list(df.at[0, 'themeTags']) + self.commander_tags = self.input_handler.validate_commander_tags(tags) + self.determine_themes() + def _initialize_commander_dict(self) -> None: + """Initialize the commander dictionary with validated data.""" self.commander_dict = { 'Commander Name': self.commander, 'Mana Cost': self.commander_mana_cost, @@ -212,22 +441,57 @@ class DeckBuilder: 'Toughness': self.commander_toughness, 'Themes': self.themes } - self.add_card(self.commander, self.commander_type, self.commander_mana_cost, self.commander_mana_value, True) + self.add_card(self.commander, self.commander_type, + self.commander_mana_cost, self.commander_mana_value, True) - # Begin Building the Deck - self.setup_dataframes() - self.determine_ideals() - self.add_lands() - self.add_creatures() - self.add_ramp() - self.add_board_wipes() - self.add_interaction() - self.add_card_advantage() - if len(self.card_library) < 100: - self.fill_out_deck() - self.card_library.to_csv(f'{csv_directory}/test_deck_presort.csv', index=False) - self.organize_library() - self.card_library.to_csv(f'{csv_directory}/test_deck_preconcat.csv', index=False) + def _initialize_deck_building(self) -> None: + """Initialize deck building process. + + Raises: + DeckBuilderError: If deck building initialization fails + """ + try: + # Set up initial deck structure + self.setup_dataframes() + self.determine_ideals() + + # Add cards by category + self.add_lands() + self.add_creatures() + self.add_ramp() + self.add_board_wipes() + self.add_interaction() + self.add_card_advantage() + + # Fill remaining slots if needed + if len(self.card_library) < 100: + self.fill_out_deck() + + # Process and organize deck + self.card_library.to_csv(f'{CSV_DIRECTORY}/test_deck_presort.csv', index=False) + self.organize_library() + self.card_library.to_csv(f'{CSV_DIRECTORY}/test_deck_preconcat.csv', index=False) + + # Log deck composition + self._log_deck_composition() + + # Finalize deck + self.get_cmc() + self.count_pips() + self.concatenate_duplicates() + self.organize_library() + self.sort_library() + self.commander_to_top() + + # Save final deck + self.card_library.to_csv(f'{CSV_DIRECTORY}/test_deck_done.csv', index=False) + self.full_df.to_csv(f'{CSV_DIRECTORY}/test_all_after_done.csv', index=False) + + except Exception as e: + raise DeckBuilderError(f"Failed to initialize deck building: {str(e)}") + + def _log_deck_composition(self) -> None: + """Log the deck composition statistics.""" logger.info(f'Creature cards (including commander): {self.creature_cards}') logger.info(f'Planeswalker cards: {self.planeswalker_cards}') logger.info(f'Battle cards: {self.battle_cards}') @@ -237,164 +501,196 @@ class DeckBuilder: logger.info(f'Enchantment cards: {self.enchantment_cards}') logger.info(f'Land cards cards: {self.land_cards}') logger.info(f'Number of cards in Library: {len(self.card_library)}') - self.get_cmc() - self.count_pips() - self.concatenate_duplicates() - self.organize_library() - self.sort_library() - self.commander_to_top() - self.card_library.to_csv(f'{csv_directory}/test_deck_done.csv', index=False) - self.full_df.to_csv(f'{csv_directory}/test_all_after_done.csv', index=False) + # Determine and validate color identity def determine_color_identity(self) -> None: - """Determine the deck's color identity and set related attributes.""" - # Single color mapping - mono_color_map = { - 'COLORLESS': ('Colorless', ['colorless']), - 'B': ('Black', ['colorless', 'black']), - 'G': ('Green', ['colorless', 'green']), - 'R': ('Red', ['colorless', 'red']), - 'U': ('Blue', ['colorless', 'blue']), - 'w': ('White', ['colorless', 'white']) - } - - # Two-color mapping - dual_color_map = { - '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']) - } - - # Three-color mapping - tri_color_map = { - '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'], - ['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 ={ - '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', 'mardu', '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', 'B, 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']) - } - + """Determine the deck's color identity and set related attributes. + + This method orchestrates the color identity determination process by: + 1. Validating the color identity input + 2. Determining the appropriate color combination type + 3. Setting color identity attributes based on the combination + + Raises: + CommanderColorError: If color identity validation fails + """ try: - # Handle mono-color identities - if self.color_identity in mono_color_map: - self.color_identity_full, self.files_to_load = mono_color_map[self.color_identity] - return - - # Handle two-color identities - if self.color_identity in dual_color_map: - identity_info = dual_color_map[self.color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] + # Validate color identity using input handler + validated_identity = self.input_handler.validate_commander_colors(self.color_identity) + + # Determine color combination type and set attributes + if self._determine_mono_color(validated_identity): return - # Handle three-color identities - if self.color_identity in tri_color_map: - identity_info = tri_color_map[self.color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] + if self._determine_dual_color(validated_identity): return - # Handle four-color/five-color identities - if self.color_identity in other_color_map: - identity_info = other_color_map[self.color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] + if self._determine_tri_color(validated_identity): return - - # If we get here, it's an unknown color identity - logger.warning(f"Unknown color identity: {self.color_identity}") + + if self._determine_other_color(validated_identity): + return + + # Handle unknown color identity + logger.warning(f"Unknown color identity: {validated_identity}") self.color_identity_full = 'Unknown' self.files_to_load = ['colorless'] + except CommanderColorError as e: + logger.error(f"Color identity validation failed: {e}") + raise except Exception as e: logger.error(f"Error in determine_color_identity: {e}") - raise - - def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame: - """Read CSV file with error handling and logger. + raise CommanderColorError(f"Failed to determine color identity: {str(e)}") + + def _determine_mono_color(self, color_identity: str) -> bool: + """Handle single color identities. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import MONO_COLOR_MAP + if color_identity in MONO_COLOR_MAP: + self.color_identity_full, self.files_to_load = MONO_COLOR_MAP[color_identity] + return True + return False + + def _determine_dual_color(self, color_identity: str) -> bool: + """Handle two-color combinations. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import DUAL_COLOR_MAP + + if color_identity in DUAL_COLOR_MAP: + identity_info = DUAL_COLOR_MAP[color_identity] + self.color_identity_full = identity_info[0] + self.color_identity_options = identity_info[1] + self.files_to_load = identity_info[2] + return True + return False + + def _determine_tri_color(self, color_identity: str) -> bool: + """Handle three-color combinations. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import TRI_COLOR_MAP + + if color_identity in TRI_COLOR_MAP: + identity_info = TRI_COLOR_MAP[color_identity] + self.color_identity_full = identity_info[0] + self.color_identity_options = identity_info[1] + self.files_to_load = identity_info[2] + return True + return False + + def _determine_other_color(self, color_identity: str) -> bool: + """Handle four and five color combinations. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import OTHER_COLOR_MAP + + if color_identity in OTHER_COLOR_MAP: + identity_info = OTHER_COLOR_MAP[color_identity] + self.color_identity_full = identity_info[0] + self.color_identity_options = identity_info[1] + self.files_to_load = identity_info[2] + return True + return False + + # CSV and dataframe functionality + def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame: + """Read and validate CSV file with comprehensive error handling. + Args: filename: Name of the CSV file without extension converters: Dictionary of converters for specific columns - + Returns: - DataFrame from CSV file + pd.DataFrame: Validated and processed DataFrame + + Raises: + CSVReadError: If file cannot be read + CSVValidationError: If data fails validation + CSVTimeoutError: If read operation times out + EmptyDataFrameError: If DataFrame is empty """ + filepath = f'{CSV_DIRECTORY}/{filename}_cards.csv' + try: - filepath = f'{csv_directory}/{filename}_cards.csv' - df = pd.read_csv(filepath, converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval}) - logger.debug(f"Successfully read {filename}_cards.csv") + # Read with timeout + df = pd.read_csv( + filepath, + converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval}, + ) + + # Check for empty DataFrame + if df.empty: + raise EmptyDataFrameError(f"Empty DataFrame from {filename}_cards.csv") + + # Validate required columns + missing_cols = set(CSV_REQUIRED_COLUMNS) - set(df.columns) + if missing_cols: + raise CSVValidationError(f"Missing required columns: {missing_cols}") + + # Process in batches + processed_dfs = [] + for i in range(0, len(df), CSV_PROCESSING_BATCH_SIZE): + batch = df.iloc[i:i + CSV_PROCESSING_BATCH_SIZE] + processed_batch = setup_utils.process_card_dataframe(batch, skip_availability_checks=True) + processed_dfs.append(processed_batch) + + df = pd.concat(processed_dfs, ignore_index=True) + + # Validate data rules + for col, rules in CSV_VALIDATION_RULES.items(): + if rules.get('required', False) and df[col].isnull().any(): + raise CSVValidationError(f"Missing required values in column: {col}") + if 'type' in rules: + expected_type = rules['type'] + actual_type = df[col].dtype.name + if expected_type == 'str' and not actual_type in ['object', 'string']: + raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") + elif expected_type != 'str' and not actual_type.startswith(expected_type): + raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") + + logger.debug(f"Successfully read and validated {filename}_cards.csv") + #print(df.columns) return df + + except pd.errors.EmptyDataError: + raise EmptyDataFrameError(f"Empty CSV file: {filename}_cards.csv") + except FileNotFoundError as e: logger.error(f"File {filename}_cards.csv not found: {e}") - raise + setup_utils.regenerate_csvs_all() + return self.read_csv(filename, converters) + + except TimeoutError: + raise CSVTimeoutError(f"Timeout reading {filename}_cards.csv", CSV_READ_TIMEOUT) + except Exception as e: logger.error(f"Error reading {filename}_cards.csv: {e}") - raise - + raise CSVReadError(f"Failed to read {filename}_cards.csv: {str(e)}") + def write_csv(self, df: pd.DataFrame, filename: str) -> None: """Write DataFrame to CSV with error handling and logger. @@ -403,65 +699,161 @@ class DeckBuilder: filename: Name of the CSV file without extension """ try: - filepath = f'{csv_directory}/{filename}.csv' + filepath = f'{CSV_DIRECTORY}/{filename}.csv' df.to_csv(filepath, index=False) logger.debug(f"Successfully wrote {filename}.csv") except Exception as e: logger.error(f"Error writing {filename}.csv: {e}") + def _load_and_combine_data(self) -> pd.DataFrame: + """Load and combine data from multiple CSV files. + + Returns: + Combined DataFrame from all source files + + Raises: + CSVError: If data loading or combining fails + EmptyDataFrameError: If no valid data is loaded + """ + logger.info("Loading and combining data from CSV files...") + all_df = [] + + try: + for file in self.files_to_load: + df = self.read_csv(file) + if df.empty: + raise EmptyDataFrameError(f"Empty DataFrame from {file}") + all_df.append(df) + #print(df.columns) + return builder_utils.combine_dataframes(all_df) + + except (CSVError, EmptyDataFrameError) as e: + logger.error(f"Error loading and combining data: {e}") raise - def setup_dataframes(self): - """Initialize and setup all required DataFrames.""" - all_df = [] - for file in self.files_to_load: - df = self.read_csv(file) - all_df.append(df) - self.full_df = pd.concat(all_df, ignore_index=True) - self.full_df.sort_values(by='edhrecRank', inplace=True) + def _split_into_specialized_frames(self, df: pd.DataFrame) -> None: + """Split combined DataFrame into specialized component frames. + + Args: + df: Source DataFrame to split + + Raises: + DataFrameValidationError: If data splitting fails + """ + try: + # Extract lands + self.land_df = df[df['type'].str.contains('Land')].copy() + self.land_df.sort_values(by='edhrecRank', inplace=True) + + # Remove lands from main DataFrame + df = df[~df['type'].str.contains('Land')] + + # Create specialized frames + self.artifact_df = df[df['type'].str.contains('Artifact')].copy() + self.battle_df = df[df['type'].str.contains('Battle')].copy() + self.creature_df = df[df['type'].str.contains('Creature')].copy() + self.noncreature_df = df[~df['type'].str.contains('Creature')].copy() + self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy() + self.instant_df = df[df['type'].str.contains('Instant')].copy() + self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy() + self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy() + + # Sort all frames + for frame in [self.artifact_df, self.battle_df, self.creature_df, + self.noncreature_df, self.enchantment_df, self.instant_df, + self.planeswalker_df, self.sorcery_df]: + frame.sort_values(by='edhrecRank', inplace=True) + + except Exception as e: + logger.error(f"Error splitting DataFrames: {e}") + raise DataFrameValidationError("DataFrame splitting failed", {}, {"error": str(e)}) + + def _validate_dataframes(self) -> None: + """Validate all component DataFrames. + + Raises: + DataFrameValidationError: If validation fails + """ + try: + frames_to_validate = { + 'land': self.land_df, + 'artifact': self.artifact_df, + 'battle': self.battle_df, + 'creature': self.creature_df, + 'noncreature': self.noncreature_df, + 'enchantment': self.enchantment_df, + 'instant': self.instant_df, + 'planeswalker': self.planeswalker_df, + 'sorcery': self.sorcery_df + } + + for name, frame in frames_to_validate.items(): + rules = builder_utils.get_validation_rules(name) + if not builder_utils.validate_dataframe(frame, rules): + raise DataFrameValidationError(f"{name} validation failed", rules) + + except Exception as e: + logger.error(f"DataFrame validation failed: {e}") + raise + + def _save_intermediate_results(self) -> None: + """Save intermediate DataFrames for debugging and analysis. + + Raises: + CSVError: If saving fails + """ + try: + frames_to_save = { + 'lands': self.land_df, + 'artifacts': self.artifact_df, + 'battles': self.battle_df, + 'creatures': self.creature_df, + 'noncreatures': self.noncreature_df, + 'enchantments': self.enchantment_df, + 'instants': self.instant_df, + 'planeswalkers': self.planeswalker_df, + 'sorcerys': self.sorcery_df + } + + for name, frame in frames_to_save.items(): + self.write_csv(frame, f'test_{name}') + + except Exception as e: + logger.error(f"Error saving intermediate results: {e}") + raise CSVError(f"Failed to save intermediate results: {str(e)}") + + def setup_dataframes(self) -> None: + """Initialize and validate all required DataFrames. - self.land_df = self.full_df[self.full_df['type'].str.contains('Land')].copy() - self.land_df.sort_values(by='edhrecRank', inplace=True) - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + This method orchestrates the DataFrame setup process by: + 1. Loading and combining data from CSV files + 2. Splitting into specialized component frames + 3. Validating all DataFrames + 4. Saving intermediate results - self.full_df = self.full_df[~self.full_df['type'].str.contains('Land')] - self.full_df.to_csv(f'{csv_directory}/test_all.csv', index=False) - - self.artifact_df = self.full_df[self.full_df['type'].str.contains('Artifact')].copy() - self.artifact_df.sort_values(by='edhrecRank', inplace=True) - self.artifact_df.to_csv(f'{csv_directory}/test_artifacts.csv', index=False) - - self.battle_df = self.full_df[self.full_df['type'].str.contains('Battle')].copy() - self.battle_df.sort_values(by='edhrecRank', inplace=True) - self.battle_df.to_csv(f'{csv_directory}/test_battles.csv', index=False) - - self.creature_df = self.full_df[self.full_df['type'].str.contains('Creature')].copy() - self.creature_df.sort_values(by='edhrecRank', inplace=True) - self.creature_df.to_csv(f'{csv_directory}/test_creatures.csv', index=False) - - self.noncreature_df = self.full_df[~self.full_df['type'].str.contains('Creature')].copy() - self.noncreature_df.sort_values(by='edhrecRank', inplace=True) - self.noncreature_df.to_csv(f'{csv_directory}/test_noncreatures.csv', index=False) - - self.noncreature_nonplaneswaker_df = self.noncreature_df[~self.noncreature_df['type'].str.contains('Planeswalker')].copy() - self.noncreature_nonplaneswaker_df.sort_values(by='edhrecRank', inplace=True) - self.noncreature_nonplaneswaker_df.to_csv(f'{csv_directory}/test_noncreatures.csv', index=False) - - self.enchantment_df = self.full_df[self.full_df['type'].str.contains('Enchantment')].copy() - self.enchantment_df.sort_values(by='edhrecRank', inplace=True) - self.enchantment_df.to_csv(f'{csv_directory}/test_enchantments.csv', index=False) - - self.instant_df = self.full_df[self.full_df['type'].str.contains('Instant')].copy() - self.instant_df.sort_values(by='edhrecRank', inplace=True) - self.instant_df.to_csv(f'{csv_directory}/test_instants.csv', index=False) - - self.planeswalker_df = self.full_df[self.full_df['type'].str.contains('Planeswalker')].copy() - self.planeswalker_df.sort_values(by='edhrecRank', inplace=True) - self.planeswalker_df.to_csv(f'{csv_directory}/test_planeswalkers.csv', index=False) - - self.sorcery_df = self.full_df[self.full_df['type'].str.contains('Sorcery')].copy() - self.sorcery_df.sort_values(by='edhrecRank', inplace=True) - self.sorcery_df.to_csv(f'{csv_directory}/test_sorcerys.csv', index=False) - + Raises: + CSVError: If any CSV operations fail + EmptyDataFrameError: If any required DataFrame is empty + DataFrameValidationError: If validation fails + """ + try: + # Load and combine data + self.full_df = self._load_and_combine_data() + self.full_df.sort_values(by='edhrecRank', inplace=True) + + # Split into specialized frames + self._split_into_specialized_frames(self.full_df) + + # Validate all frames + self._validate_dataframes() + + # Save intermediate results + self._save_intermediate_results() + + logger.info("DataFrame setup completed successfully") + + except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e: + logger.error(f"Error in DataFrame setup: {e}") + raise def determine_themes(self): themes = self.commander_tags print('Your commander deck will likely have a number of viable themes, but you\'ll want to narrow it down for focus.\n' @@ -613,7 +1005,6 @@ class DeckBuilder: and color[i] in self.colors): logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') choice = self.input_handler.questionnaire('Confirm', False) - choice = self.input_handler.questionnaire('Confirm', False) if choice: print('Which one?') choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i]) @@ -665,27 +1056,22 @@ class DeckBuilder: 'There will be some leeway of ~10%, with a couple alternative options provided.') choice = self.input_handler.questionnaire('Confirm', False) if choice: - self.set_max_deck_price = True - self.deck_cost = 0.0 print('What would you like the max price to be?') - self.max_deck_price = float(self.input_handler.questionnaire('Number', 400)) + max_deck_price = float(self.input_handler.questionnaire('Number', 400)) + self.price_checker.max_deck_price = max_deck_price new_line() else: - self.set_max_deck_price = False new_line() print('Would you like to set a max price per card?\n' 'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.') choice = self.input_handler.questionnaire('Confirm', False) if choice: - self.set_max_card_price = True print('What would you like the max price to be?') answer = float(self.input_handler.questionnaire('Number', 20)) - self.max_card_price = answer - self.card_library['Card Price'] = pd.Series(dtype='float') + self.price_checker.max_card_price = answer new_line() else: - self.set_max_card_price = False new_line() # Determine ramp @@ -715,7 +1101,6 @@ class DeckBuilder: 'Some decks may be fine with as low as 10, others may want 25.\n' 'Default: 20') answer = self.input_handler.questionnaire('Number', 20) - answer = self.input_handler.questionnaire('Number', 20) self.min_basics = int(answer) new_line() @@ -768,7 +1153,6 @@ class DeckBuilder: 'It\'s recommended to have 5 to 15, depending on your commander and preferred strategy.\n' 'Default: 8') answer = self.input_handler.questionnaire('Number', 8) - answer = self.input_handler.questionnaire('Number', 8) self.ideal_protection = int(answer) self.free_slots -= self.ideal_protection new_line() @@ -790,9 +1174,12 @@ class DeckBuilder: None Raises: - ValueError: If card price exceeds maximum allowed price when price checking is enabled + PriceLimitError: If card price exceeds maximum allowed price + PriceAPIError: If there is an error fetching the price + PriceTimeoutError: If the price check times out + PriceValidationError: If the price data is invalid """ - multiple_copies = basic_lands + multiple_copy_cards + multiple_copies = BASIC_LANDS + multiple_copy_cards # Skip if card already exists and isn't allowed multiple copies if card in pd.Series(self.card_library['Card Name']).values and card not in multiple_copies: @@ -800,38 +1187,30 @@ class DeckBuilder: # Handle price checking card_price = 0.0 - if use_scrython and self.set_max_card_price: - try: - card_price = check_price(card) - # Skip if card is too expensive - if card_price > self.max_card_price * 1.1: - logger.info(f"Skipping {card} - price {card_price} exceeds maximum") - return - except PriceCheckError as e: - logger.error(f"Error checking price for {card}: {e}") - return + try: + # Get price and validate + card_price = self.price_checker.get_card_price(card) + self.price_checker.validate_card_price(card, card_price) + self.price_checker.update_deck_price(card_price) + except (PriceAPIError, PriceTimeoutError, PriceValidationError, PriceLimitError) as e: + logger.warning(str(e)) + return # Create card entry card_entry = [card, card_type, mana_cost, mana_value, is_commander] - if use_scrython and self.set_max_card_price: - card_entry.append(card_price) # Add to library self.card_library.loc[len(self.card_library)] = card_entry - # Update deck cost if tracking - if self.set_max_deck_price: - self.deck_cost += card_price - logger.debug(f"Added {card} to deck library") def organize_library(self): - # Initialize counters dictionary dynamically from card_types including Kindred - all_types = card_types + ['Kindred'] if 'Kindred' not in card_types else card_types + # Initialize counters dictionary dynamically from CARD_TYPES including Kindred + all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES card_counters = {card_type: 0 for card_type in all_types} # Count cards by type - for card_type in card_types: + for card_type in CARD_TYPES: type_df = self.card_library[self.card_library['Card Type'].apply(lambda x: card_type in x)] card_counters[card_type] = len(type_df) @@ -849,7 +1228,7 @@ class DeckBuilder: def sort_library(self): self.card_library['Sort Order'] = pd.Series(dtype='str') for index, row in self.card_library.iterrows(): - for card_type in card_types: + for card_type in CARD_TYPES: if card_type in row['Card Type']: if row['Sort Order'] == 'Creature': continue @@ -886,7 +1265,7 @@ class DeckBuilder: logger.error(f"Error moving commander to top: {str(e)}") def concatenate_duplicates(self): """Handle duplicate cards in the library while maintaining data integrity.""" - duplicate_lists = basic_lands + multiple_copy_cards + duplicate_lists = BASIC_LANDS + multiple_copy_cards # Create a count column for duplicates self.card_library['Card Count'] = 1 @@ -965,7 +1344,7 @@ class DeckBuilder: # Clean up land database mask = self.land_df['name'].isin(self.card_library['Card Name']) self.land_df = self.land_df[~mask] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) # Adjust to ideal land count self.check_basics() @@ -1035,7 +1414,7 @@ class DeckBuilder: lands_to_remove.append(basic) self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) def add_standard_non_basics(self): """Add staple utility lands based on deck requirements.""" @@ -1063,7 +1442,7 @@ class DeckBuilder: # Update land database self.land_df = self.land_df[~self.land_df['name'].isin(self.staples)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) logger.info(f'Added {len(self.staples)} staple lands') @@ -1091,7 +1470,7 @@ class DeckBuilder: # Adding in expensive fetches if (use_scrython and self.set_max_card_price): - if self.price_check('Prismatic Vista') <= self.max_card_price * 1.1: + if self.price_checker.get_card_price('Prismatic Vista') <= self.max_card_price * 1.1: lands_to_remove.append('Prismatic Vista') fetches.append('Prismatic Vista') else: @@ -1129,7 +1508,7 @@ class DeckBuilder: fetch_choice = random.choice(fetches) if use_scrython and self.set_max_card_price: - if self.price_check(fetch_choice) <= self.max_card_price * 1.1: + if self.price_checker.get_card_price(fetch_choice) <= self.max_card_price * 1.1: chosen_fetches.append(fetch_choice) fetches.remove(fetch_choice) else: @@ -1152,7 +1531,7 @@ class DeckBuilder: self.add_card(card, 'Land', None, 0) self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) def add_kindred_lands(self): """Add lands that support tribal/kindred themes.""" @@ -1201,7 +1580,7 @@ class DeckBuilder: # Update land database self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) logger.info(f'Added {len(lands_to_remove)} Kindred-themed lands') @@ -1214,7 +1593,6 @@ class DeckBuilder: # Determine if using the dual-type lands print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') choice = self.input_handler.questionnaire('Confirm', True) - choice = self.input_handler.questionnaire('Confirm', True) color_filter = [] color_dict = { 'azorius': 'Plains Island', @@ -1254,7 +1632,7 @@ class DeckBuilder: lands_to_remove.append(card['name']) self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) logger.info(f'Added {len(card_pool)} Dual-type land cards.') @@ -1305,7 +1683,7 @@ class DeckBuilder: lands_to_remove.append(card['name']) self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) logger.info(f'Added {len(card_pool)} Triome land cards.') @@ -1353,7 +1731,7 @@ class DeckBuilder: # Check price if enabled if use_scrython and self.set_max_card_price: - price = self.price_check(card['name']) + price = self.price_checker.get_card_price(card['name']) if price > self.max_card_price * 1.1: continue @@ -1368,7 +1746,7 @@ class DeckBuilder: # Update land database self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) logger.info(f'Added {len(cards_to_add)} miscellaneous lands') @@ -1573,7 +1951,7 @@ class DeckBuilder: # Check price constraints if enabled if use_scrython and self.set_max_card_price: - price = self.price_check(card['name']) + price = self.price_checker.get_card_price(card['name']) if price > self.max_card_price * 1.1: continue @@ -1609,7 +1987,7 @@ class DeckBuilder: self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] logger.info(f'Added {len(cards_to_add)} {tag} cards') - #tag_df.to_csv(f'{csv_directory}/test_{tag}.csv', index=False) + #tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False) def add_by_tags(self, tag, ideal_value=1, df=None): """Add cards with specific tag up to ideal_value count""" @@ -1643,7 +2021,7 @@ class DeckBuilder: # Check price constraints if enabled if use_scrython and self.set_max_card_price: - price = self.price_check(card['name']) + price = self.price_checker.get_card_price(card['name']) if price > self.max_card_price * 1.1: continue @@ -1669,7 +2047,7 @@ class DeckBuilder: self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] logger.info(f'Added {len(cards_to_add)} {tag} cards') - #tag_df.to_csv(f'{csv_directory}/test_{tag}.csv', index=False) + #tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False) def add_creatures(self): """ @@ -1813,4 +2191,4 @@ def main(): pprint.pprint(build_deck.commander_dict, sort_dicts=False) if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/exceptions.py b/exceptions.py index 62e1baf..876256e 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1,64 +1,237 @@ -"""Custom exceptions for MTG Python Deckbuilder setup operations.""" +"""Custom exceptions for the MTG Python Deckbuilder application.""" -class MTGSetupError(Exception): - """Base exception class for MTG setup-related errors.""" - pass +class DeckBuilderError(Exception): + """Base exception class for deck builder errors. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "DECK_ERR", details: dict | None = None): + """Initialize the base deck builder error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + self.code = code + self.message = message + self.details = details or {} + super().__init__(self.message) + + def __str__(self) -> str: + """Format the error message with code and details.""" + error_msg = f"[{self.code}] {self.message}" + if self.details: + error_msg += f"\nDetails: {self.details}" + return error_msg + +class MTGSetupError(DeckBuilderError): + """Base exception class for MTG setup-related errors. + + This exception serves as the base for all setup-related errors in the deck builder, + including file operations, data processing, and validation during setup. + """ + + def __init__(self, message: str, code: str = "SETUP_ERR", details: dict | None = None): + """Initialize the base setup error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) class CSVFileNotFoundError(MTGSetupError): """Exception raised when a required CSV file is not found. This exception is raised when attempting to access or process a CSV file that does not exist in the expected location. - - Args: - message: Explanation of the error - filename: Name of the missing CSV file """ - def __init__(self, message: str, filename: str) -> None: - self.filename = filename - super().__init__(f"{message}: {filename}") + + def __init__(self, filename: str, details: dict | None = None): + """Initialize CSV file not found error. + + Args: + filename: Name of the missing CSV file + details: Additional context about the missing file + """ + message = f"Required CSV file not found: '{filename}'" + super().__init__(message, code="CSV_MISSING", details=details) class MTGJSONDownloadError(MTGSetupError): """Exception raised when downloading data from MTGJSON fails. This exception is raised when there are issues downloading card data from the MTGJSON API, such as network errors or API failures. - - Args: - message: Explanation of the error - url: The URL that failed to download - status_code: HTTP status code if available """ - def __init__(self, message: str, url: str, status_code: int = None) -> None: - self.url = url - self.status_code = status_code + + def __init__(self, url: str, status_code: int | None = None, details: dict | None = None): + """Initialize MTGJSON download error. + + Args: + url: The URL that failed to download + status_code: HTTP status code if available + details: Additional context about the download failure + """ status_info = f" (Status: {status_code})" if status_code else "" - super().__init__(f"{message}: {url}{status_info}") + message = f"Failed to download from MTGJSON: {url}{status_info}" + super().__init__(message, code="MTGJSON_ERR", details=details) -class DataFrameProcessingError(MTGSetupError): - """Exception raised when DataFrame operations fail during setup. +# Input Handler Exceptions +class EmptyInputError(DeckBuilderError): + """Raised when text input validation fails due to empty or whitespace-only input. - This exception is raised when there are issues processing card data - in pandas DataFrames, such as filtering, sorting, or transformation errors. - - Args: - message: Explanation of the error - operation: The DataFrame operation that failed (e.g., 'color_filtering', 'commander_processing') - details: Additional error details - - Examples: - >>> raise DataFrameProcessingError( - ... "Invalid color identity", - ... "color_filtering", - ... "Color 'P' is not a valid MTG color" - ... ) + This exception is used by the validate_text method when checking user input. """ - def __init__(self, message: str, operation: str, details: str = None) -> None: - self.operation = operation - self.details = details - error_info = f" - {details}" if details else "" - super().__init__(f"{message} during {operation}{error_info}") + + def __init__(self, field_name: str = "input", details: dict | None = None): + """Initialize empty input error. + + Args: + field_name: Name of the input field that was empty + details: Additional context about the validation failure + """ + message = f"Empty or whitespace-only {field_name} is not allowed" + super().__init__(message, code="EMPTY_INPUT", details=details) +class InvalidNumberError(DeckBuilderError): + """Raised when number input validation fails. + + This exception is used by the validate_number method when checking numeric input. + """ + + def __init__(self, value: str, details: dict | None = None): + """Initialize invalid number error. + + Args: + value: The invalid input value + details: Additional context about the validation failure + """ + message = f"Invalid number format: '{value}'" + super().__init__(message, code="INVALID_NUM", details=details) + +class InvalidQuestionTypeError(DeckBuilderError): + """Raised when an unsupported question type is used in the questionnaire method. + + This exception is raised when the questionnaire method receives an unknown question type. + """ + + def __init__(self, question_type: str, details: dict | None = None): + """Initialize invalid question type error. + + Args: + question_type: The unsupported question type + details: Additional context about the error + """ + message = f"Unsupported question type: '{question_type}'" + super().__init__(message, code="INVALID_QTYPE", details=details) + +class MaxAttemptsError(DeckBuilderError): + """Raised when maximum input attempts are exceeded. + + This exception is used when user input validation fails multiple times. + """ + + def __init__(self, max_attempts: int, input_type: str = "input", details: dict | None = None): + """Initialize maximum attempts error. + + Args: + max_attempts: Maximum number of attempts allowed + input_type: Type of input that failed validation + details: Additional context about the attempts + """ + message = f"Maximum {input_type} attempts ({max_attempts}) exceeded" + super().__init__(message, code="MAX_ATTEMPTS", details=details) + +# CSV Exceptions +class CSVError(DeckBuilderError): + """Base exception class for CSV-related errors. + + This exception serves as the base for all CSV-related errors in the deck builder, + including file reading, processing, validation, and timeout issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "CSV_ERR", details: dict | None = None): + """Initialize the base CSV error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class CSVReadError(CSVError): + """Raised when there are issues reading CSV files. + + This exception is used when CSV files cannot be opened, read, or parsed. + """ + + def __init__(self, filename: str, details: dict | None = None): + """Initialize CSV read error. + + Args: + filename: Name of the CSV file that failed to read + details: Additional context about the read failure + """ + message = f"Failed to read CSV file: '{filename}'" + super().__init__(message, code="CSV_READ", details=details) + +class CSVProcessingError(CSVError): + """Base class for CSV and DataFrame processing errors. + + This exception is used when operations fail during data processing, + including batch operations and transformations. + """ + + def __init__(self, message: str, operation_context: dict | None = None, details: dict | None = None): + """Initialize processing error with context. + + Args: + message: Descriptive error message + operation_context: Details about the failed operation + details: Additional error context + """ + if operation_context: + details = details or {} + details['operation_context'] = operation_context + super().__init__(message, code="CSV_PROC", details=details) + +class DataFrameProcessingError(CSVProcessingError): + """Raised when DataFrame batch operations fail. + + This exception provides detailed context about batch processing failures + including operation state and progress information. + """ + + def __init__(self, operation: str, batch_state: dict, processed_count: int, total_count: int, details: dict | None = None): + """Initialize DataFrame processing error. + + Args: + operation: Name of the operation that failed + batch_state: Current state of batch processing + processed_count: Number of items processed + total_count: Total number of items to process + details: Additional error context + """ + message = f"DataFrame batch operation '{operation}' failed after processing {processed_count}/{total_count} items" + operation_context = { + 'operation': operation, + 'batch_state': batch_state, + 'processed_count': processed_count, + 'total_count': total_count + } + super().__init__(message, operation_context, details) class ColorFilterError(MTGSetupError): """Exception raised when color-specific filtering operations fail. @@ -84,113 +257,330 @@ class ColorFilterError(MTGSetupError): error_info = f" - {details}" if details else "" super().__init__(f"{message} for color '{color}'{error_info}") - -class CommanderValidationError(MTGSetupError): - """Exception raised when commander validation fails. +class CSVValidationError(CSVError): + """Base class for CSV and DataFrame validation errors. - This exception is raised when there are issues validating commander cards, - such as non-legendary creatures, color identity mismatches, or banned cards. - - Args: - message: Explanation of the error - validation_type: Type of validation that failed (e.g., 'legendary_check', 'color_identity', 'banned_set') - details: Additional error details - - Examples: - >>> raise CommanderValidationError( - ... "Card must be legendary", - ... "legendary_check", - ... "Lightning Bolt is not a legendary creature" - ... ) - - >>> raise CommanderValidationError( - ... "Commander color identity mismatch", - ... "color_identity", - ... "Omnath, Locus of Creation cannot be used in Golgari deck" - ... ) - - >>> raise CommanderValidationError( - ... "Commander banned in format", - ... "banned_set", - ... "Golos, Tireless Pilgrim is banned in Commander" - ... ) + This exception is used when data fails validation checks, including field validation, + data type validation, and data consistency validation. """ - def __init__(self, message: str, validation_type: str, details: str = None) -> None: - self.validation_type = validation_type - self.details = details - error_info = f" - {details}" if details else "" - super().__init__(f"{message} [{validation_type}]{error_info}") - - -class InputValidationError(MTGSetupError): - """Exception raised when input validation fails. - This exception is raised when there are issues validating user input, - such as invalid text formats, number ranges, or confirmation responses. - - Args: - message: Explanation of the error - input_type: Type of input validation that failed (e.g., 'text', 'number', 'confirm') - details: Additional error details - - Examples: - >>> raise InputValidationError( - ... "Invalid number input", - ... "number", - ... "Value must be between 1 and 100" - ... ) + def __init__(self, message: str, validation_context: dict | None = None, details: dict | None = None): + """Initialize validation error with context. - >>> raise InputValidationError( - ... "Invalid confirmation response", - ... "confirm", - ... "Please enter 'y' or 'n'" - ... ) - - >>> raise InputValidationError( - ... "Invalid text format", - ... "text", - ... "Input contains invalid characters" - ... ) + Args: + message: Descriptive error message + validation_context: Specific validation failure details + details: Additional error context + """ + if validation_context: + details = details or {} + details['validation_context'] = validation_context + super().__init__(message, code="CSV_VALID", details=details) + +class DataFrameValidationError(CSVValidationError): + """Raised when DataFrame validation fails. + + This exception provides detailed context about validation failures including + rule violations, invalid values, and data type mismatches. """ - def __init__(self, message: str, input_type: str, details: str = None) -> None: - self.input_type = input_type - self.details = details - error_info = f" - {details}" if details else "" - super().__init__(f"{message} [{input_type}]{error_info}") - - -class PriceCheckError(MTGSetupError): - """Exception raised when price checking operations fail. - This exception is raised when there are issues retrieving or processing - card prices, such as API failures, invalid responses, or parsing errors. - - Args: - message: Explanation of the error - card_name: Name of the card that caused the error - details: Additional error details - - Examples: - >>> raise PriceCheckError( - ... "Failed to retrieve price", - ... "Black Lotus", - ... "API request timeout" - ... ) + def __init__(self, field: str, validation_rules: dict, invalid_data: dict | None = None, details: dict | None = None): + """Initialize DataFrame validation error. - >>> raise PriceCheckError( - ... "Invalid price data format", - ... "Lightning Bolt", - ... "Unexpected response structure" - ... ) - - >>> raise PriceCheckError( - ... "Price data unavailable", - ... "Underground Sea", - ... "No price information found" - ... ) + Args: + field: Name of the field that failed validation + validation_rules: Rules that were violated + invalid_data: The invalid data that caused the failure + details: Additional error context + """ + message = f"DataFrame validation failed for field '{field}'" + validation_context = { + 'field': field, + 'rules': validation_rules, + 'invalid_data': invalid_data or {} + } + super().__init__(message, validation_context, details) + +class EmptyDataFrameError(CSVError): + """Raised when a DataFrame is unexpectedly empty. + + This exception is used when a DataFrame operation requires non-empty data + but receives an empty DataFrame. """ - def __init__(self, message: str, card_name: str, details: str = None) -> None: - self.card_name = card_name - self.details = details - error_info = f" - {details}" if details else "" - super().__init__(f"{message} for card '{card_name}'{error_info}") \ No newline at end of file + + def __init__(self, operation: str, details: dict | None = None): + """Initialize empty DataFrame error. + + Args: + operation: Name of the operation that requires non-empty data + details: Additional context about the empty DataFrame + """ + message = f"Empty DataFrame encountered during: '{operation}'" + super().__init__(message, code="CSV_EMPTY", details=details) + +class CSVTimeoutError(CSVError): + """Base class for CSV and DataFrame timeout errors. + + This exception is used when operations exceed their timeout thresholds. + """ + + def __init__(self, message: str, timeout_context: dict | None = None, details: dict | None = None): + """Initialize timeout error with context. + + Args: + message: Descriptive error message + timeout_context: Details about the timeout + details: Additional error context + """ + if timeout_context: + details = details or {} + details['timeout_context'] = timeout_context + super().__init__(message, code="CSV_TIMEOUT", details=details) + +class DataFrameTimeoutError(CSVTimeoutError): + """Raised when DataFrame operations timeout. + + This exception provides detailed context about operation timeouts + including operation type and duration information. + """ + + def __init__(self, operation: str, timeout: float, elapsed: float, operation_state: dict | None = None, details: dict | None = None): + """Initialize DataFrame timeout error. + + Args: + operation: Name of the operation that timed out + timeout: Timeout threshold in seconds + elapsed: Actual time elapsed in seconds + operation_state: State of the operation when timeout occurred + details: Additional error context + """ + message = f"DataFrame operation '{operation}' timed out after {elapsed:.1f}s (threshold: {timeout}s)" + timeout_context = { + 'operation': operation, + 'timeout_threshold': timeout, + 'elapsed_time': elapsed, + 'operation_state': operation_state or {} + } + super().__init__(message, timeout_context, details) + +# For PriceCheck/Scrython functions +class PriceError(DeckBuilderError): + """Base exception class for price-related errors. + + This exception serves as the base for all price-related errors in the deck builder, + including API issues, validation errors, and price limit violations. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "PRICE_ERR", details: dict | None = None): + """Initialize the base price error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class PriceAPIError(PriceError): + """Raised when there are issues with the Scryfall API price lookup. + + This exception is used when the price API request fails, returns invalid data, + or encounters other API-related issues. + """ + + def __init__(self, card_name: str, details: dict | None = None): + """Initialize price API error. + + Args: + card_name: Name of the card that failed price lookup + details: Additional context about the API failure + """ + message = f"Failed to fetch price data for '{card_name}' from Scryfall API" + super().__init__(message, code="PRICE_API", details=details) + +class PriceLimitError(PriceError): + """Raised when a card or deck price exceeds the specified limit. + + This exception is used when price thresholds are violated during deck building. + """ + + def __init__(self, card_name: str, price: float, limit: float, details: dict | None = None): + """Initialize price limit error. + + Args: + card_name: Name of the card exceeding the price limit + price: Actual price of the card + limit: Maximum allowed price + details: Additional context about the price limit violation + """ + message = f"Price of '{card_name}' (${price:.2f}) exceeds limit of ${limit:.2f}" + super().__init__(message, code="PRICE_LIMIT", details=details) + +class PriceTimeoutError(PriceError): + """Raised when a price lookup request times out. + + This exception is used when the Scryfall API request exceeds the timeout threshold. + """ + + def __init__(self, card_name: str, timeout: float, details: dict | None = None): + """Initialize price timeout error. + + Args: + card_name: Name of the card that timed out + timeout: Timeout threshold in seconds + details: Additional context about the timeout + """ + message = f"Price lookup for '{card_name}' timed out after {timeout} seconds" + super().__init__(message, code="PRICE_TIMEOUT", details=details) + +class PriceValidationError(PriceError): + """Raised when price data fails validation. + + This exception is used when received price data is invalid, malformed, + or cannot be properly parsed. + """ + + def __init__(self, card_name: str, price_data: str, details: dict | None = None): + """Initialize price validation error. + + Args: + card_name: Name of the card with invalid price data + price_data: The invalid price data received + details: Additional context about the validation failure + """ + message = f"Invalid price data for '{card_name}': {price_data}" + super().__init__(message, code="PRICE_INVALID", details=details) + +# Commander Exceptions +class CommanderLoadError(DeckBuilderError): + """Raised when there are issues loading commander data from CSV. + + This exception is used when the commander CSV file cannot be loaded, + is missing required columns, or contains invalid data. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander load error. + + Args: + message: Description of the loading failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_LOAD", details=details) + +class CommanderSelectionError(DeckBuilderError): + """Raised when there are issues with the commander selection process. + + This exception is used when the commander selection process fails, + such as no matches found or ambiguous matches. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_SELECT", details=details) + +class CommanderValidationError(DeckBuilderError): + """Raised when commander data fails validation. + + This exception is used when the selected commander's data is invalid, + missing required fields, or contains inconsistent information. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_VALID", details=details) + +class CommanderTypeError(CommanderValidationError): + """Raised when commander type validation fails. + + This exception is used when a commander fails the legendary creature requirement + or has an invalid creature type. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander type error. + + Args: + message: Description of the type validation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_TYPE_ERR", details=details) + +class CommanderStatsError(CommanderValidationError): + """Raised when commander stats validation fails. + + This exception is used when a commander's power, toughness, or mana value + fails validation requirements. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander stats error. + + Args: + message: Description of the stats validation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_STATS_ERR", details=details) + +class CommanderColorError(CommanderValidationError): + """Raised when commander color identity validation fails. + + This exception is used when a commander's color identity is invalid + or incompatible with deck requirements. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander color error. + + Args: + message: Description of the color validation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_COLOR_ERR", details=details) + +class CommanderTagError(CommanderValidationError): + """Raised when commander theme tag validation fails. + + This exception is used when a commander's theme tags are invalid + or incompatible with deck requirements. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander tag error. + + Args: + message: Description of the tag validation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_TAG_ERR", details=details) + +class CommanderThemeError(CommanderValidationError): + """Raised when commander theme validation fails. + + This exception is used when a commander's themes are invalid + or incompatible with deck requirements. + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander theme error. + + Args: + message: Description of the theme validation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_THEME_ERR", details=details) \ No newline at end of file diff --git a/input_handler.py b/input_handler.py index 2b7a842..ee9c083 100644 --- a/input_handler.py +++ b/input_handler.py @@ -1,40 +1,74 @@ -"""Input validation and handling for MTG Python Deckbuilder. +"""Input handling and validation module for MTG Python Deckbuilder.""" -This module provides the InputHandler class which encapsulates all input validation -and handling logic. It supports different types of input validation including text, -numbers, confirmations, and multiple choice questions. -""" +from __future__ import annotations -from typing import Any, List, Optional, Union -import inquirer import logging -import os +from typing import Any, List, Optional, Tuple, Union -from exceptions import InputValidationError -from settings import INPUT_VALIDATION, QUESTION_TYPES +import inquirer.prompt # type: ignore +from settings import ( + COLORS, COLOR_ABRV, DEFAULT_MAX_CARD_PRICE, + DEFAULT_MAX_DECK_PRICE, DEFAULT_THEME_TAGS, MONO_COLOR_MAP, + DUAL_COLOR_MAP, TRI_COLOR_MAP, OTHER_COLOR_MAP +) -# Create logs directory if it doesn't exist -if not os.path.exists('logs'): - os.makedirs('logs') +from exceptions import ( + CommanderColorError, + CommanderStatsError, + CommanderTagError, + CommanderThemeError, + CommanderTypeError, + DeckBuilderError, + EmptyInputError, + InvalidNumberError, + InvalidQuestionTypeError, + MaxAttemptsError, + PriceError, + PriceLimitError, + PriceValidationError +) +# Configure logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), - logging.FileHandler('logs/input_handlers.log', mode='a', encoding='utf-8') - ] + format='%(asctime)s - %(levelname)s - %(message)s' ) + logger = logging.getLogger(__name__) class InputHandler: - """Handles input validation and user interaction. + """Handles user input operations with validation and error handling. - This class provides methods for validating different types of user input - and handling user interaction through questionnaires. It uses constants - from settings.py for validation messages and configuration. + This class provides methods for collecting and validating different types + of user input including text, numbers, confirmations, and choices. + + Attributes: + max_attempts (int): Maximum number of retry attempts for invalid input + default_text (str): Default value for text input + default_number (float): Default value for number input + default_confirm (bool): Default value for confirmation input """ + def __init__( + self, + max_attempts: int = 3, + default_text: str = '', + default_number: float = 0.0, + default_confirm: bool = True + ): + """Initialize input handler with configuration. + + Args: + max_attempts: Maximum number of retry attempts + default_text: Default value for text input + default_number: Default value for number input + default_confirm: Default value for confirmation input + """ + self.max_attempts = max_attempts + self.default_text = default_text + self.default_number = default_number + self.default_confirm = default_confirm + def validate_text(self, result: str) -> bool: """Validate text input is not empty. @@ -42,171 +76,367 @@ class InputHandler: result: Text input to validate Returns: - bool: True if text is not empty after stripping whitespace + True if text is not empty after stripping whitespace Raises: - InputValidationError: If text validation fails + EmptyInputError: If input is empty or whitespace only """ - try: - if not result or not result.strip(): - raise InputValidationError( - INPUT_VALIDATION['default_text_message'], - 'text', - 'Input cannot be empty' - ) - return True - except Exception as e: - raise InputValidationError( - str(e), - 'text', - 'Unexpected error during text validation' - ) - - def validate_number(self, result: str) -> Optional[float]: + if not result or not result.strip(): + raise EmptyInputError() + return True + + def validate_number(self, result: str) -> float: """Validate and convert string input to float. Args: result: Number input to validate Returns: - float | None: Converted float value or None if invalid + Converted float value Raises: - InputValidationError: If number validation fails + InvalidNumberError: If input cannot be converted to float """ try: - if not result: - raise InputValidationError( - INPUT_VALIDATION['default_number_message'], - 'number', - 'Input cannot be empty' - ) return float(result) - except ValueError: - raise InputValidationError( - INPUT_VALIDATION['default_number_message'], - 'number', - 'Input must be a valid number' - ) - except Exception as e: - raise InputValidationError( - str(e), - 'number', - 'Unexpected error during number validation' - ) + except (ValueError, TypeError): + raise InvalidNumberError(result) - def validate_confirm(self, result: Any) -> bool: + def validate_price(self, result: str) -> Tuple[float, bool]: + """Validate and convert price input to float with format checking. + + Args: + result: Price input to validate + + Returns: + Tuple of (price value, is_unlimited flag) + + Raises: + PriceValidationError: If price format is invalid + """ + result = result.strip().lower() + + # Check for unlimited budget + if result in ['unlimited', 'any']: + return (float('inf'), True) + + # Remove currency symbol if present + if result.startswith('$'): + result = result[1:] + + try: + price = float(result) + if price < 0: + raise PriceValidationError('Price cannot be negative') + return (price, False) + except ValueError: + raise PriceValidationError(f"Invalid price format: '{result}'") + + def validate_price_threshold(self, price: float, threshold: float = DEFAULT_MAX_CARD_PRICE) -> bool: + """Validate price against maximum threshold. + + Args: + price: Price value to check + threshold: Maximum allowed price (default from settings) + + Returns: + True if price is within threshold + + Raises: + PriceLimitError: If price exceeds threshold + """ + if price > threshold and price != float('inf'): + raise PriceLimitError('Card', price, threshold) + return True + + def validate_confirm(self, result: bool) -> bool: """Validate confirmation input. Args: - result: Confirmation input to validate + result: Boolean confirmation input Returns: - bool: True for positive confirmation, False otherwise - - Raises: - InputValidationError: If confirmation validation fails + The boolean input value """ - try: - if isinstance(result, bool): - return result - if isinstance(result, str): - result = result.lower().strip() - if result in ('y', 'yes', 'true', '1'): - return True - if result in ('n', 'no', 'false', '0'): - return False - raise InputValidationError( - INPUT_VALIDATION['default_confirm_message'], - 'confirm', - 'Invalid confirmation response' - ) - except InputValidationError: - raise - except Exception as e: - raise InputValidationError( - str(e), - 'confirm', - 'Unexpected error during confirmation validation' - ) - + return bool(result) + def questionnaire( self, question_type: str, - default_value: Union[str, bool, float] = '', - choices_list: List[str] = [] - ) -> Union[str, bool, float]: - """Present questions to user and validate input. + message: str = '', + default_value: Any = None, + choices_list: List[str] = None + ) -> Union[str, float, bool]: + """Present questions to user and handle input validation. Args: question_type: Type of question ('Text', 'Number', 'Confirm', 'Choice') + message: Question message to display default_value: Default value for the question choices_list: List of choices for Choice type questions Returns: - Union[str, bool, float]: Validated user input + Validated user input of appropriate type Raises: - InputValidationError: If input validation fails - ValueError: If question type is not supported + InvalidQuestionTypeError: If question_type is not supported + MaxAttemptsError: If maximum retry attempts are exceeded """ - if question_type not in QUESTION_TYPES: - raise ValueError(f"Unsupported question type: {question_type}") - attempts = 0 - while attempts < INPUT_VALIDATION['max_attempts']: + + while attempts < self.max_attempts: try: if question_type == 'Text': - question = [inquirer.Text('text')] + question = [ + inquirer.Text( + 'text', + message=message or 'Enter text', + default=default_value or self.default_text + ) + ] result = inquirer.prompt(question)['text'] if self.validate_text(result): return result - + + elif question_type == 'Price': + question = [ + inquirer.Text( + 'price', + message=message or 'Enter price (or "unlimited")', + default=str(default_value or DEFAULT_MAX_CARD_PRICE) + ) + ] + result = inquirer.prompt(question)['price'] + price, is_unlimited = self.validate_price(result) + if not is_unlimited: + self.validate_price_threshold(price) + return price elif question_type == 'Number': - question = [inquirer.Text('number', default=str(default_value))] + question = [ + inquirer.Text( + 'number', + message=message or 'Enter number', + default=str(default_value or self.default_number) + ) + ] result = inquirer.prompt(question)['number'] - validated = self.validate_number(result) - if validated is not None: - return validated - + return self.validate_number(result) + elif question_type == 'Confirm': - question = [inquirer.Confirm('confirm', default=default_value)] + question = [ + inquirer.Confirm( + 'confirm', + message=message or 'Confirm?', + default=default_value if default_value is not None else self.default_confirm + ) + ] result = inquirer.prompt(question)['confirm'] return self.validate_confirm(result) - + elif question_type == 'Choice': if not choices_list: - raise InputValidationError( - INPUT_VALIDATION['default_choice_message'], - 'choice', - 'No choices provided' - ) + raise ValueError("Choices list cannot be empty for Choice type") question = [ - inquirer.List('selection', + inquirer.List( + 'selection', + message=message or 'Select an option', choices=choices_list, - carousel=True) + carousel=True + ) ] return inquirer.prompt(question)['selection'] - - except InputValidationError as e: + + else: + raise InvalidQuestionTypeError(question_type) + + except DeckBuilderError as e: + logger.warning(f"Input validation failed: {e}") attempts += 1 - if attempts >= INPUT_VALIDATION['max_attempts']: - raise InputValidationError( - "Maximum input attempts reached", - question_type, - str(e) + if attempts >= self.max_attempts: + raise MaxAttemptsError( + self.max_attempts, + question_type.lower(), + {"last_error": str(e)} ) - logger.warning(f"Invalid input ({attempts}/{INPUT_VALIDATION['max_attempts']}): {str(e)}") - + except Exception as e: - raise InputValidationError( - str(e), - question_type, - 'Unexpected error during questionnaire' + logger.error(f"Unexpected error in questionnaire: {e}") + raise + + raise MaxAttemptsError(self.max_attempts, question_type.lower()) + + def validate_commander_type(self, type_line: str) -> str: + """Validate commander type line requirements. + + Args: + type_line: Commander's type line to validate + + Returns: + Validated type line + + Raises: + CommanderTypeError: If type line validation fails + """ + if not type_line: + raise CommanderTypeError("Type line cannot be empty") + + type_line = type_line.strip() + + # Check for legendary creature requirement + if not ('Legendary' in type_line and 'Creature' in type_line): + # Check for 'can be your commander' text + if 'can be your commander' not in type_line.lower(): + raise CommanderTypeError( + "Commander must be a legendary creature or have 'can be your commander' text" ) - raise InputValidationError( - "Maximum input attempts reached", - question_type, - "Failed to get valid input" - ) \ No newline at end of file + 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 \ No newline at end of file diff --git a/price_check.py b/price_check.py index 7023a87..82acfb7 100644 --- a/price_check.py +++ b/price_check.py @@ -4,57 +4,204 @@ 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 Optional - +from typing import Dict, List, Optional, Tuple, Union + +# Third-party imports import scrython -from scrython.cards import Named - -from exceptions import PriceCheckError -from settings import PRICE_CHECK_CONFIG - -@lru_cache(maxsize=PRICE_CHECK_CONFIG['cache_size']) -def check_price(card_name: str) -> float: - """Retrieve the current price of a Magic: The Gathering card. - - Args: - card_name: The name of the card to check. - - Returns: - float: The current price of the card in USD. - - Raises: - PriceCheckError: If there are any issues retrieving the price. +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 """ - retries = 0 - last_error = None - - while retries < PRICE_CHECK_CONFIG['max_retries']: + + 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: - card = Named(fuzzy=card_name) - price = card.prices('usd') - print(price) + # Add delay between API calls + time.sleep(DEFAULT_PRICE_DELAY) - if price is None: - raise PriceCheckError( - "No price data available", - card_name, - "Card may be too new or not available in USD" - ) + # Make API request with type hints + card: ScryfallCard = scrython.cards.Named(fuzzy=card_name, timeout=PRICE_CHECK_TIMEOUT) + price: Optional[str] = card.prices('usd') - return float(price) - - except (scrython.ScryfallError, ValueError) as e: - last_error = str(e) - retries += 1 - if retries < PRICE_CHECK_CONFIG['max_retries']: - time.sleep(0.1) # Brief delay before retry - continue - - raise PriceCheckError( - "Failed to retrieve price after multiple attempts", - card_name, - f"Last error: {last_error}" - ) \ No newline at end of file + # 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") \ No newline at end of file diff --git a/settings.py b/settings.py index 2177c74..2ef5348 100644 --- a/settings.py +++ b/settings.py @@ -1,16 +1,44 @@ -"""Constants and configuration settings for the MTG Python Deckbuilder. +from typing import Dict, List, Optional, Final, Tuple, Pattern, Union +import ast -This module contains all the constant values and configuration settings used throughout -the application for card filtering, processing, and analysis. Constants are organized -into logical sections with clear documentation. +# Commander selection configuration +COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv' +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 -All constants are properly typed according to PEP 484 standards to ensure type safety -and enable static type checking with mypy. -""" - -from typing import Dict, List, Optional +# 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]] = [] +# 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 +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.', @@ -52,8 +80,7 @@ banned_cards = [# in commander '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 lands matter functionality LANDS_MATTER_PATTERNS: Dict[str, List[str]] = { @@ -530,7 +557,7 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [ ] -card_types = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', +CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', 'Kindred', 'Dungeon', 'Battle'] # Mapping of card types to their corresponding theme tags @@ -547,9 +574,102 @@ TYPE_TAG_MAPPING = { 'Sorcery': ['Spells Matter', 'Spellslinger'] } -csv_directory = 'csv_files' +CSV_DIRECTORY = 'csv_files' -colors = ['colorless', 'white', 'blue', 'black', 'red', 'green', +# Color identity constants and mappings +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', 'simic', 'izzet', 'golgari', 'rakdos', 'gruul', 'bant', 'esper', 'grixis', 'jund', 'naya', @@ -707,6 +827,21 @@ COLUMN_ORDER = [ '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 OUTLAW_TYPES = ['Assassin', 'Mercenary', 'Pirate', 'Rogue', 'Warlock'] TYPE_DETECTION_BATCH_SIZE = 1000 @@ -757,7 +892,7 @@ EQUIPMENT_TEXT_PATTERNS = [ 'unattach', # Equipment removal 'unequip', # Equipment removal ] -TYPE_DETECTION_BATCH_SIZE = 1000 + # Constants for Voltron strategy VOLTRON_COMMANDER_CARDS = [ @@ -808,6 +943,101 @@ PRICE_CHECK_CONFIG: Dict[str, float] = { # 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'), 'required': True}, + 'toughness': {'type': ('str', 'int', 'float'), 'required': True}, + 'creatureTypes': {'type': 'list', '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 MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv' diff --git a/setup.py b/setup.py index e0f865a..7640374 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ import inquirer # Local application imports from settings import ( - banned_cards, csv_directory, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL + banned_cards, CSV_DIRECTORY, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL ) from setup_utils import ( download_cards_csv, filter_dataframe, process_legendary_cards, filter_by_color_identity @@ -72,7 +72,7 @@ def initial_setup() -> None: logger.info('Checking for cards.csv file') try: - cards_file = f'{csv_directory}/cards.csv' + cards_file = f'{CSV_DIRECTORY}/cards.csv' try: with open(cards_file, 'r', encoding='utf-8'): logger.info('cards.csv exists') @@ -88,11 +88,11 @@ def initial_setup() -> None: for i in range(min(len(SETUP_COLORS), len(COLOR_ABRV))): logger.info(f'Checking for {SETUP_COLORS[i]}_cards.csv') 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') except FileNotFoundError: 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 determine_commanders() @@ -143,7 +143,7 @@ def determine_commanders() -> None: try: # 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): logger.info('cards.csv not found, initiating download') download_cards_csv(MTGJSON_API_URL, cards_file) @@ -169,7 +169,7 @@ def determine_commanders() -> None: # Save 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') @@ -196,10 +196,10 @@ def regenerate_csvs_all() -> None: """ try: 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') - 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') logger.info('Regenerating color identity sorted files') @@ -207,7 +207,7 @@ def regenerate_csvs_all() -> None: color = SETUP_COLORS[i] color_id = COLOR_ABRV[i] 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') determine_commanders() @@ -239,14 +239,14 @@ def regenerate_csv_by_color(color: str) -> None: color_abv = COLOR_ABRV[SETUP_COLORS.index(color)] 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') - 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') 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') diff --git a/setup_utils.py b/setup_utils.py index ee60062..a0b2ed4 100644 --- a/setup_utils.py +++ b/setup_utils.py @@ -36,6 +36,10 @@ from settings import ( FILL_NA_COLUMNS, SORT_CONFIG, FILTER_CONFIG, + COLUMN_ORDER, + PRETAG_COLUMN_ORDER, + EXCLUDED_CARD_TYPES, + TAGGED_COLUMN_ORDER ) from exceptions import ( MTGJSONDownloadError, @@ -43,6 +47,7 @@ from exceptions import ( ColorFilterError, CommanderValidationError ) +from type_definitions import CardLibraryDF # Create logs directory if it doesn't exist if not os.path.exists('logs'): @@ -339,4 +344,74 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: "Failed to process legendary cards", "commander_processing", str(e) - ) from e \ No newline at end of file + ) 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) -> pd.DataFrame: + """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 diff --git a/tagger.py b/tagger.py index 51bfc82..021dbbe 100644 --- a/tagger.py +++ b/tagger.py @@ -13,7 +13,7 @@ import settings # type: ignore import tag_utils # type: ignore # Local application imports -from settings import csv_directory, multiple_copy_cards, num_to_search, triggers +from settings import CSV_DIRECTORY, multiple_copy_cards, num_to_search, triggers from setup import regenerate_csv_by_color @@ -68,7 +68,7 @@ def load_dataframe(color: str) -> None: ValueError: If required columns are missing """ try: - filepath = f'{csv_directory}/{color}_cards.csv' + filepath = f'{CSV_DIRECTORY}/{color}_cards.csv' # Check if file exists, regenerate if needed if not os.path.exists(filepath): @@ -170,7 +170,7 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: # Lastly, sort all theme tags for easier reading sort_theme_tags(df, color) - df.to_csv(f'{csv_directory}/{color}_cards.csv', index=False) + df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False) #print(df) print('\n====================\n') logger.info(f'Tags are done being set on {color}_cards.csv') @@ -255,7 +255,7 @@ def kindred_tagging(df: pd.DataFrame, color: str) -> None: 'keywords', 'layout', 'side' ] df = df[columns_to_keep] - df.to_csv(f'{settings.csv_directory}/{color}_cards.csv', index=False) + df.to_csv(f'{settings.CSV_DIRECTORY}/{color}_cards.csv', index=False) total_time = pd.Timestamp.now() - start_time logger.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') @@ -320,7 +320,7 @@ def create_theme_tags(df: pd.DataFrame, color: str) -> None: # Save results try: - df.to_csv(f'{settings.csv_directory}/{color}_cards.csv', index=False) + df.to_csv(f'{settings.CSV_DIRECTORY}/{color}_cards.csv', index=False) total_time = pd.Timestamp.now() - start_time logger.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') @@ -6425,6 +6425,4 @@ def run_tagging(): for color in settings.colors: load_dataframe(color) duration = (pd.Timestamp.now() - start_time).total_seconds() - logger.info(f'Tagged cards in {duration:.2f}s') - -run_tagging() \ No newline at end of file + logger.info(f'Tagged cards in {duration:.2f}s') \ No newline at end of file diff --git a/type_definitions.py b/type_definitions.py new file mode 100644 index 0000000..b4ca8fb --- /dev/null +++ b/type_definitions.py @@ -0,0 +1,49 @@ +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 +SorceryDF = pd.DataFrame \ No newline at end of file From 47c2cee00f412c434f2cd58c449e011f8151dd18 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Wed, 15 Jan 2025 11:56:25 -0800 Subject: [PATCH 4/6] Started refactoring up through the add_dual_lands function in deck_builder --- builder_utils.py | 681 ++++++++++++++++++++++++- deck_builder.py | 1251 +++++++++++++++++++++++----------------------- exceptions.py | 470 ++++++++++++++++- input_handler.py | 15 +- settings.py | 193 ++++++- 5 files changed, 1970 insertions(+), 640 deletions(-) diff --git a/builder_utils.py b/builder_utils.py index d3ff1b5..ea33dd3 100644 --- a/builder_utils.py +++ b/builder_utils.py @@ -1,4 +1,7 @@ -from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union +from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union, cast +import pandas as pd +from price_check import PriceChecker +from input_handler import InputHandler import logging import functools import time @@ -13,14 +16,39 @@ from settings import ( DATAFRAME_VALIDATION_TIMEOUT, DATAFRAME_BATCH_SIZE, DATAFRAME_TRANSFORM_TIMEOUT, - DATAFRAME_REQUIRED_COLUMNS + DATAFRAME_REQUIRED_COLUMNS, + WEIGHT_ADJUSTMENT_FACTORS, + DEFAULT_MAX_DECK_PRICE, + DEFAULT_MAX_CARD_PRICE, + DECK_COMPOSITION_PROMPTS, + DEFAULT_RAMP_COUNT, + DEFAULT_LAND_COUNT, + DEFAULT_BASIC_LAND_COUNT, + DEFAULT_CREATURE_COUNT, + DEFAULT_REMOVAL_COUNT, + DEFAULT_CARD_ADVANTAGE_COUNT, + DEFAULT_PROTECTION_COUNT, + DEFAULT_WIPES_COUNT, + CARD_TYPE_SORT_ORDER, + DUPLICATE_CARD_FORMAT, + COLOR_TO_BASIC_LAND, + SNOW_BASIC_LAND_MAPPING, + KINDRED_STAPLE_LANDS, ) from exceptions import ( DeckBuilderError, + DuplicateCardError, CSVValidationError, DataFrameValidationError, DataFrameTimeoutError, - EmptyDataFrameError + EmptyDataFrameError, + FetchLandSelectionError, + FetchLandValidationError, + KindredLandSelectionError, + KindredLandValidationError, + ThemeSelectionError, + ThemeWeightError, + CardTypeCountError ) logging.basicConfig( @@ -328,4 +356,649 @@ def validate_commander_selection(df: pd.DataFrame, commander_name: str) -> Dict: return commander_dict except Exception as e: logger.error(f"Error validating commander selection: {e}") - raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}") \ No newline at end of file + raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}") + +def select_theme(themes_list: List[str], prompt: str, optional=False) -> str: + """Handle the selection of a theme from a list with user interaction. + + Args: + themes_list: List of available themes to choose from + prompt: Message to display when prompting for theme selection + + Returns: + str: Selected theme name + + Raises: + ThemeSelectionError: If user chooses to stop without selecting a theme + """ + try: + if not themes_list: + raise ThemeSelectionError("No themes available for selection") + + print(prompt) + for idx, theme in enumerate(themes_list, 1): + print(f"{idx}. {theme}") + print("0. Stop selection") + + while True: + try: + choice = int(input("Enter the number of your choice: ")) + if choice == 0: + return 'Stop Here' + if 1 <= choice <= len(themes_list): + return themes_list[choice - 1] + print("Invalid choice. Please try again.") + except ValueError: + print("Please enter a valid number.") + + except Exception as e: + logger.error(f"Error in theme selection: {e}") + raise ThemeSelectionError(f"Theme selection failed: {str(e)}") + +def adjust_theme_weights(primary_theme: str, + secondary_theme: str, + tertiary_theme: str, + weights: Dict[str, float]) -> Dict[str, float]: + """Calculate adjusted theme weights based on theme combinations. + + Args: + primary_theme: The main theme selected + secondary_theme: The second theme selected + tertiary_theme: The third theme selected + weights: Initial theme weights dictionary + + Returns: + Dict[str, float]: Adjusted theme weights + + Raises: + ThemeWeightError: If weight calculations fail + """ + try: + adjusted_weights = weights.copy() + + for theme, factors in WEIGHT_ADJUSTMENT_FACTORS.items(): + if theme in [primary_theme, secondary_theme, tertiary_theme]: + for target_theme, factor in factors.items(): + if target_theme in adjusted_weights: + adjusted_weights[target_theme] = round(adjusted_weights[target_theme] * factor, 2) + + # Normalize weights to ensure they sum to 1.0 + total_weight = sum(adjusted_weights.values()) + if total_weight > 0: + adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()} + + print(adjusted_weights) + return adjusted_weights + + except Exception as e: + logger.error(f"Error adjusting theme weights: {e}") + raise ThemeWeightError(f"Failed to adjust theme weights: {str(e)}") +def configure_price_settings(price_checker: Optional[PriceChecker], input_handler: InputHandler) -> None: + """Handle configuration of price settings if price checking is enabled. + + Args: + price_checker: Optional PriceChecker instance for price validation + input_handler: InputHandler instance for user input + + Returns: + None + + Raises: + ValueError: If invalid price values are provided + """ + if not price_checker: + return + + try: + # Configure max deck price + print('Would you like to set an intended max price of the deck?\n' + 'There will be some leeway of ~10%, with a couple alternative options provided.') + if input_handler.questionnaire('Confirm', message='', default_value=False): + print('What would you like the max price to be?') + max_deck_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_DECK_PRICE)) + price_checker.max_deck_price = max_deck_price + print() + + # Configure max card price + print('Would you like to set a max price per card?\n' + 'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.') + if input_handler.questionnaire('Confirm', message='', default_value=False): + print('What would you like the max price to be?') + max_card_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_CARD_PRICE)) + price_checker.max_card_price = max_card_price + print() + + except ValueError as e: + logger.error(f"Error configuring price settings: {e}") + raise + +def get_deck_composition_values(input_handler: InputHandler) -> Dict[str, int]: + """Collect deck composition values from the user. + + Args: + input_handler: InputHandler instance for user input + + Returns: + Dict[str, int]: Mapping of component names to their values + + Raises: + ValueError: If invalid numeric values are provided + """ + try: + composition = {} + for component, prompt in DECK_COMPOSITION_PROMPTS.items(): + if component not in ['max_deck_price', 'max_card_price']: + default_map = { + 'ramp': DEFAULT_RAMP_COUNT, + 'lands': DEFAULT_LAND_COUNT, + 'basic_lands': DEFAULT_BASIC_LAND_COUNT, + 'creatures': DEFAULT_CREATURE_COUNT, + 'removal': DEFAULT_REMOVAL_COUNT, + 'wipes': DEFAULT_WIPES_COUNT, + 'card_advantage': DEFAULT_CARD_ADVANTAGE_COUNT, + 'protection': DEFAULT_PROTECTION_COUNT + } + default_value = default_map.get(component, 0) + + print(prompt) + composition[component] = int(input_handler.questionnaire('Number', message='Default', default_value=default_value)) + print() + + return composition + + except ValueError as e: + logger.error(f"Error getting deck composition values: {e}") + raise + +def assign_sort_order(df: pd.DataFrame) -> pd.DataFrame: + """Assign sort order to cards based on their types. + + This function adds a 'Sort Order' column to the DataFrame based on the + CARD_TYPE_SORT_ORDER constant from settings. Cards are sorted according to + their primary type, with the order specified in CARD_TYPE_SORT_ORDER. + + Args: + df: DataFrame containing card information with a 'Card Type' column + + Returns: + DataFrame with an additional 'Sort Order' column + + Example: + >>> df = pd.DataFrame({ + ... 'Card Type': ['Creature', 'Instant', 'Land'] + ... }) + >>> sorted_df = assign_sort_order(df) + >>> sorted_df['Sort Order'].tolist() + ['Creature', 'Instant', 'Land'] + """ + # Create a copy of the input DataFrame + df = df.copy() + + # Initialize Sort Order column with default value + df['Sort Order'] = 'Other' + + # Assign sort order based on card types + for card_type in CARD_TYPE_SORT_ORDER: + mask = df['Card Type'].str.contains(card_type, case=False, na=False) + df.loc[mask, 'Sort Order'] = card_type + + # Convert Sort Order to categorical for proper sorting + df['Sort Order'] = pd.Categorical( + df['Sort Order'], + categories=CARD_TYPE_SORT_ORDER + ['Other'], + ordered=True + ) + return df + +def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[str]) -> pd.DataFrame: + """Process duplicate cards in the library and consolidate them with updated counts. + + This function identifies duplicate cards that are allowed to have multiple copies + (like basic lands and certain special cards), consolidates them into single entries, + and updates their counts. Card names are formatted using DUPLICATE_CARD_FORMAT. + + Args: + card_library: DataFrame containing the deck's card library + duplicate_lists: List of card names allowed to have multiple copies + + Returns: + DataFrame with processed duplicate cards and updated counts + + Raises: + DuplicateCardError: If there are issues processing duplicate cards + + Example: + >>> card_library = pd.DataFrame({ + ... 'name': ['Forest', 'Forest', 'Mountain', 'Mountain', 'Sol Ring'], + ... 'type': ['Basic Land', 'Basic Land', 'Basic Land', 'Basic Land', 'Artifact'] + ... }) + >>> duplicate_lists = ['Forest', 'Mountain'] + >>> result = process_duplicate_cards(card_library, duplicate_lists) + >>> print(result['name'].tolist()) + ['Forest x 2', 'Mountain x 2', 'Sol Ring'] + """ + try: + # Create a copy of the input DataFrame + processed_library = card_library.copy() + + # Process each allowed duplicate card + for card_name in duplicate_lists: + # Find all instances of the card + card_mask = processed_library['name'] == card_name + card_count = card_mask.sum() + + if card_count > 1: + # Keep only the first instance and update its name with count + first_instance = processed_library[card_mask].iloc[0] + processed_library = processed_library[~card_mask] + + first_instance['name'] = DUPLICATE_CARD_FORMAT.format( + card_name=card_name, + count=card_count + ) + processed_library = pd.concat([processed_library, pd.DataFrame([first_instance])]) + + return processed_library.reset_index(drop=True) + + except Exception as e: + raise DuplicateCardError( + f"Failed to process duplicate cards: {str(e)}", + details={'error': str(e)} + ) + +def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Dict[str, int]: + """Count the number of cards for each specified card type in the library. + + Args: + card_library: DataFrame containing the card library + card_types: List of card types to count + + Returns: + Dictionary mapping card types to their counts + + Raises: + CardTypeCountError: If counting fails for any card type + """ + try: + type_counts = {} + for card_type in card_types: + # Use pandas str.contains() for efficient type matching + # Case-insensitive matching with na=False to handle missing values + type_mask = card_library['type'].str.contains( + card_type, + case=False, + na=False + ) + type_counts[card_type] = int(type_mask.sum()) + + return type_counts + except Exception as e: + logger.error(f"Error counting cards by type: {e}") + raise CardTypeCountError(f"Failed to count cards by type: {str(e)}") + +def calculate_basics_per_color(total_basics: int, num_colors: int) -> Tuple[int, int]: + """Calculate the number of basic lands per color and remaining basics. + + Args: + total_basics: Total number of basic lands to distribute + num_colors: Number of colors in the deck + + Returns: + Tuple containing (basics per color, remaining basics) + + Example: + >>> calculate_basics_per_color(20, 3) + (6, 2) # 6 basics per color with 2 remaining + """ + if num_colors == 0: + return 0, total_basics + + basics_per_color = total_basics // num_colors + remaining_basics = total_basics % num_colors + + return basics_per_color, remaining_basics + +def get_basic_land_mapping(use_snow_covered: bool = False) -> Dict[str, str]: + """Get the appropriate basic land mapping based on snow-covered preference. + + Args: + use_snow_covered: Whether to use snow-covered basic lands + + Returns: + Dictionary mapping colors to their corresponding basic land names + + Example: + >>> get_basic_land_mapping(False) + {'W': 'Plains', 'U': 'Island', ...} + >>> get_basic_land_mapping(True) + {'W': 'Snow-Covered Plains', 'U': 'Snow-Covered Island', ...} + """ + return SNOW_BASIC_LAND_MAPPING if use_snow_covered else COLOR_TO_BASIC_LAND + +def distribute_remaining_basics( + basics_per_color: Dict[str, int], + remaining_basics: int, + colors: List[str] +) -> Dict[str, int]: + """Distribute remaining basic lands across colors. + + This function takes the initial distribution of basic lands and distributes + any remaining basics across the colors. The distribution prioritizes colors + based on their position in the color list (typically WUBRG order). + + Args: + basics_per_color: Initial distribution of basics per color + remaining_basics: Number of remaining basics to distribute + colors: List of colors to distribute basics across + + Returns: + Updated dictionary with final basic land counts per color + + Example: + >>> distribute_remaining_basics( + ... {'W': 6, 'U': 6, 'B': 6}, + ... 2, + ... ['W', 'U', 'B'] + ... ) + {'W': 7, 'U': 7, 'B': 6} + """ + if not colors: + return basics_per_color + + # Create a copy to avoid modifying the input dictionary + final_distribution = basics_per_color.copy() + + # Distribute remaining basics + color_index = 0 + while remaining_basics > 0 and color_index < len(colors): + color = colors[color_index] + if color in final_distribution: + final_distribution[color] += 1 + remaining_basics -= 1 + color_index = (color_index + 1) % len(colors) + + return final_distribution + +def validate_staple_land_conditions( + land_name: str, + conditions: dict, + commander_tags: List[str], + colors: List[str], + commander_power: int +) -> bool: + """Validate if a staple land meets its inclusion conditions. + + Args: + land_name: Name of the staple land to validate + conditions: Dictionary mapping land names to their condition functions + commander_tags: List of tags associated with the commander + colors: List of colors in the deck + commander_power: Power level of the commander + + Returns: + bool: True if the land meets its conditions, False otherwise + + Example: + >>> conditions = {'Command Tower': lambda tags, colors, power: len(colors) > 1} + >>> validate_staple_land_conditions('Command Tower', conditions, [], ['W', 'U'], 7) + True + """ + condition = conditions.get(land_name) + if not condition: + return False + return condition(commander_tags, colors, commander_power) + +def process_staple_lands( + lands_to_add: List[str], + card_library: pd.DataFrame, + land_df: pd.DataFrame +) -> pd.DataFrame: + """Update the land DataFrame by removing added staple lands. + + Args: + lands_to_add: List of staple land names to be added + card_library: DataFrame containing all available cards + land_df: DataFrame containing available lands + + Returns: + Updated land DataFrame with staple lands removed + + Example: + >>> process_staple_lands(['Command Tower'], card_library, land_df) + DataFrame without 'Command Tower' in the available lands + """ + updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] + return updated_land_df + +def validate_fetch_land_count(count: int, min_count: int = 0, max_count: int = 9) -> int: + """Validate the requested number of fetch lands. + + Args: + count: Number of fetch lands requested + min_count: Minimum allowed fetch lands (default: 0) + max_count: Maximum allowed fetch lands (default: 9) + + Returns: + Validated fetch land count + + Raises: + FetchLandValidationError: If count is invalid + + Example: + >>> validate_fetch_land_count(5) + 5 + >>> validate_fetch_land_count(-1) # raises FetchLandValidationError + """ + try: + fetch_count = int(count) + if fetch_count < min_count or fetch_count > max_count: + raise FetchLandValidationError( + f"Fetch land count must be between {min_count} and {max_count}", + {"requested": fetch_count, "min": min_count, "max": max_count} + ) + return fetch_count + except ValueError: + raise FetchLandValidationError( + f"Invalid fetch land count: {count}", + {"value": count} + ) + +def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] = None, + max_price: Optional[float] = None) -> List[str]: + """Get list of fetch lands available for the deck's colors and budget. + + Args: + colors: List of deck colors + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of available fetch land names + + Example: + >>> get_available_fetch_lands(['U', 'R']) + ['Scalding Tarn', 'Flooded Strand', ...] + """ + from settings import GENERIC_FETCH_LANDS, COLOR_TO_FETCH_LANDS + + # Start with generic fetches that work in any deck + available_fetches = GENERIC_FETCH_LANDS.copy() + + # Add color-specific fetches + for color in colors: + if color in COLOR_TO_FETCH_LANDS: + available_fetches.extend(COLOR_TO_FETCH_LANDS[color]) + + # Remove duplicates while preserving order + available_fetches = list(dict.fromkeys(available_fetches)) + + # Filter by price if price checking is enabled + if price_checker and max_price: + available_fetches = [ + fetch for fetch in available_fetches + if price_checker.get_card_price(fetch) <= max_price * 1.1 + ] + + return available_fetches + +def select_fetch_lands(available_fetches: List[str], count: int, + allow_duplicates: bool = False) -> List[str]: + """Randomly select fetch lands from the available pool. + + Args: + available_fetches: List of available fetch lands + count: Number of fetch lands to select + allow_duplicates: Whether to allow duplicate selections + + Returns: + List of selected fetch land names + + Raises: + FetchLandSelectionError: If unable to select required number of fetches + + Example: + >>> select_fetch_lands(['Flooded Strand', 'Polluted Delta'], 2) + ['Polluted Delta', 'Flooded Strand'] + """ + import random + + if not available_fetches: + raise FetchLandSelectionError( + "No fetch lands available to select from", + {"requested": count} + ) + + if not allow_duplicates and count > len(available_fetches): + raise FetchLandSelectionError( + f"Not enough unique fetch lands available (requested {count}, have {len(available_fetches)})", + {"requested": count, "available": len(available_fetches)} + ) + + if allow_duplicates: + return random.choices(available_fetches, k=count) + else: + return random.sample(available_fetches, k=count) + +def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: List[str]) -> bool: + """Validate if a Kindred land meets inclusion criteria. + + Args: + land_name: Name of the Kindred land to validate + commander_tags: List of tags associated with the commander + colors: List of colors in the deck + + Returns: + bool: True if the land meets criteria, False otherwise + + Raises: + KindredLandValidationError: If validation fails + + Example: + >>> validate_kindred_lands('Cavern of Souls', ['Elf Kindred'], ['G']) + True + """ + try: + # Check if any commander tags are Kindred-related + has_kindred_theme = any('Kindred' in tag for tag in commander_tags) + if not has_kindred_theme: + return False + + # Validate color requirements + if land_name in KINDRED_STAPLE_LANDS: + return True + + # Additional validation logic can be added here + return True + + except Exception as e: + raise KindredLandValidationError( + f"Failed to validate Kindred land {land_name}", + {"error": str(e), "tags": commander_tags, "colors": colors} + ) + +def get_available_kindred_lands(colors: List[str], commander_tags: List[str], + price_checker: Optional[Any] = None, + max_price: Optional[float] = None) -> List[str]: + """Get list of Kindred lands available for the deck's colors and themes. + + Args: + colors: List of deck colors + commander_tags: List of commander theme tags + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of available Kindred land names + + Example: + >>> get_available_kindred_lands(['G'], ['Elf Kindred']) + ['Cavern of Souls', 'Path of Ancestry', ...] + """ + # Start with staple Kindred lands + available_lands = [land['name'] for land in KINDRED_STAPLE_LANDS] + + # Filter by price if price checking is enabled + if price_checker and max_price: + available_lands = [ + land for land in available_lands + if price_checker.get_card_price(land) <= max_price * 1.1 + ] + + return available_lands + +def select_kindred_lands(available_lands: List[str], count: int, + allow_duplicates: bool = False) -> List[str]: + """Select Kindred lands from the available pool. + + Args: + available_lands: List of available Kindred lands + count: Number of Kindred lands to select + allow_duplicates: Whether to allow duplicate selections + + Returns: + List of selected Kindred land names + + Raises: + KindredLandSelectionError: If unable to select required number of lands + + Example: + >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'], 2) + ['Path of Ancestry', 'Cavern of Souls'] + """ + import random + + if not available_lands: + raise KindredLandSelectionError( + "No Kindred lands available to select from", + {"requested": count} + ) + + if not allow_duplicates and count > len(available_lands): + raise KindredLandSelectionError( + f"Not enough unique Kindred lands available (requested {count}, have {len(available_lands)})", + {"requested": count, "available": len(available_lands)} + ) + + if allow_duplicates: + return random.choices(available_lands, k=count) + else: + return random.sample(available_lands, k=count) + +def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame, + land_df: pd.DataFrame) -> pd.DataFrame: + """Update the land DataFrame by removing added Kindred lands. + + Args: + lands_to_add: List of Kindred land names to be added + card_library: DataFrame containing all available cards + land_df: DataFrame containing available lands + + Returns: + Updated land DataFrame with Kindred lands removed + + Example: + >>> process_kindred_lands(['Cavern of Souls'], card_library, land_df) + DataFrame without 'Cavern of Souls' in the available lands + """ + updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] + return updated_land_df \ No newline at end of file diff --git a/deck_builder.py b/deck_builder.py index 0912ade..a9551c6 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -13,21 +13,27 @@ import keyboard # type: ignore import pandas as pd # type: ignore import pprint # type: ignore from fuzzywuzzy import process # type: ignore +from tqdm import tqdm # type: ignore from settings import ( - BASIC_LANDS, CARD_TYPES, CSV_DIRECTORY, multiple_copy_cards, - COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, + BASIC_LANDS, CARD_TYPES, CSV_DIRECTORY, multiple_copy_cards, DEFAULT_NON_BASIC_LAND_SLOTS, + COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT, COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, - CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS + CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, + STAPLE_LAND_CONDITIONS ) import builder_utils import setup_utils from setup import determine_commanders from input_handler import InputHandler from exceptions import ( + BasicLandCountError, + BasicLandError, + CommanderMoveError, + CardTypeCountError, CommanderColorError, CommanderLoadError, CommanderSelectionError, @@ -37,16 +43,27 @@ from exceptions import ( CSVTimeoutError, CSVValidationError, DataFrameValidationError, + DuplicateCardError, DeckBuilderError, EmptyDataFrameError, - EmptyInputError, + EmptyInputError, + FetchLandSelectionError, + FetchLandValidationError, + IdealDeterminationError, InvalidNumberError, InvalidQuestionTypeError, + LibraryOrganizationError, + LibrarySortError, MaxAttemptsError, PriceAPIError, + PriceConfigurationError, PriceLimitError, PriceTimeoutError, - PriceValidationError + PriceValidationError, + ThemeSelectionError, + ThemeWeightError, + StapleLandError, + StapleLandError ) from type_definitions import ( CardDict, @@ -129,6 +146,7 @@ class DeckBuilder: # Initialize other attributes with type hints self.commander_info: Dict = {} + self.commander_dict: CommanderDict = {} self.commander: str = '' self.commander_type: str = '' self.commander_text: str = '' @@ -428,7 +446,7 @@ class DeckBuilder: def _initialize_commander_dict(self) -> None: """Initialize the commander dictionary with validated data.""" - self.commander_dict = { + self.commander_dict: CommanderDict = { 'Commander Name': self.commander, 'Mana Cost': self.commander_mana_cost, 'Mana Value': self.commander_mana_value, @@ -439,7 +457,8 @@ class DeckBuilder: 'Text': self.commander_text, 'Power': self.commander_power, 'Toughness': self.commander_toughness, - 'Themes': self.themes + 'Themes': self.themes, + 'CMC': 0.0 } self.add_card(self.commander, self.commander_type, self.commander_mana_cost, self.commander_mana_value, True) @@ -651,15 +670,6 @@ class DeckBuilder: if missing_cols: raise CSVValidationError(f"Missing required columns: {missing_cols}") - # Process in batches - processed_dfs = [] - for i in range(0, len(df), CSV_PROCESSING_BATCH_SIZE): - batch = df.iloc[i:i + CSV_PROCESSING_BATCH_SIZE] - processed_batch = setup_utils.process_card_dataframe(batch, skip_availability_checks=True) - processed_dfs.append(processed_batch) - - df = pd.concat(processed_dfs, ignore_index=True) - # Validate data rules for col, rules in CSV_VALIDATION_RULES.items(): if rules.get('required', False) and df[col].isnull().any(): @@ -667,11 +677,10 @@ class DeckBuilder: if 'type' in rules: expected_type = rules['type'] actual_type = df[col].dtype.name - if expected_type == 'str' and not actual_type in ['object', 'string']: + if expected_type == 'str' and actual_type not in ['object', 'string']: raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") elif expected_type != 'str' and not actual_type.startswith(expected_type): raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") - logger.debug(f"Successfully read and validated {filename}_cards.csv") #print(df.columns) return df @@ -704,6 +713,7 @@ class DeckBuilder: logger.debug(f"Successfully wrote {filename}.csv") except Exception as e: logger.error(f"Error writing {filename}.csv: {e}") + def _load_and_combine_data(self) -> pd.DataFrame: """Load and combine data from multiple CSV files. @@ -718,7 +728,8 @@ class DeckBuilder: all_df = [] try: - for file in self.files_to_load: + # Wrap files_to_load with tqdm for progress bar + for file in tqdm(self.files_to_load, desc="Loading card data files", leave=False): df = self.read_csv(file) if df.empty: raise EmptyDataFrameError(f"Empty DataFrame from {file}") @@ -746,6 +757,7 @@ class DeckBuilder: # Remove lands from main DataFrame df = df[~df['type'].str.contains('Land')] + df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv') # Create specialized frames self.artifact_df = df[df['type'].str.contains('Artifact')].copy() @@ -756,6 +768,8 @@ class DeckBuilder: self.instant_df = df[df['type'].str.contains('Instant')].copy() self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy() self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy() + + self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv') # Sort all frames for frame in [self.artifact_df, self.battle_df, self.creature_df, @@ -839,10 +853,10 @@ class DeckBuilder: # Load and combine data self.full_df = self._load_and_combine_data() self.full_df.sort_values(by='edhrecRank', inplace=True) + self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv') # Split into specialized frames self._split_into_specialized_frames(self.full_df) - # Validate all frames self._validate_dataframes() @@ -854,315 +868,239 @@ class DeckBuilder: except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e: logger.error(f"Error in DataFrame setup: {e}") raise - def determine_themes(self): - themes = self.commander_tags - print('Your commander deck will likely have a number of viable themes, but you\'ll want to narrow it down for focus.\n' - 'This will go through the process of choosing up to three themes for the deck.\n') - while True: - # Choose a primary theme - print('Choose a primary theme for your commander deck.\n' - 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.') - choice = self.input_handler.questionnaire('Choice', choices_list=themes) - self.primary_theme = choice - weights_default = { - 'primary': 1.0, - 'secondary': 0.0, - 'tertiary': 0.0, - 'hidden': 0.0 - } - weights = weights_default.copy() - themes.remove(choice) - themes.append('Stop Here') - self.primary_weight = weights['primary'] - - secondary_theme_chosen = False - tertiary_theme_chosen = False - self.hidden_theme = False - - while not secondary_theme_chosen: - # Secondary theme - print('Choose a secondary theme for your commander deck.\n' - 'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.') - choice = self.input_handler.questionnaire('Choice', choices_list=themes) - while True: - if choice == 'Stop Here': - logger.warning('You\'ve only selected one theme, are you sure you want to stop?\n') - confirm_done = self.input_handler.questionnaire('Confirm', False) - if confirm_done: - secondary_theme_chosen = True - self.secondary_theme = False - tertiary_theme_chosen = True - self.tertiary_theme = False - themes.remove(choice) - break - else: - pass - - else: - weights = weights_default.copy() # primary = 1.0, secondary = 0.0, tertiary = 0.0 - self.secondary_theme = choice - themes.remove(choice) - secondary_theme_chosen = True - # Set weights for primary/secondary themes - if 'Kindred' in self.primary_theme and 'Kindred' not in self.secondary_theme: - weights['primary'] -= 0.1 # 0.8 - weights['secondary'] += 0.1 # 0.1 - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme: - weights['primary'] -= 0.7 # 0.7 - weights['secondary'] += 0.3 # 0.3 - else: - weights['primary'] -= 0.4 # 0.6 - weights['secondary'] += 0.4 # 0.4 - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - break - - while not tertiary_theme_chosen: - # Tertiary theme - print('Choose a tertiary theme for your commander deck.\n' - 'This will typically be a tertiary focus, or just something else to do that your commander is good at.') - choice = self.input_handler.questionnaire('Choice', choices_list=themes) - while True: - if choice == 'Stop Here': - logger.warning('You\'ve only selected two themes, are you sure you want to stop?\n') - confirm_done = self.input_handler.questionnaire('Confirm', False) - if confirm_done: - tertiary_theme_chosen = True - self.tertiary_theme = False - themes.remove(choice) - break - else: - pass - - else: - weights = weights_default.copy() # primary = 1.0, secondary = 0.0, tertiary = 0.0 - self.tertiary_theme = choice - tertiary_theme_chosen = True - - # Set weights for themes: - if 'Kindred' in self.primary_theme and 'Kindred' not in self.secondary_theme and 'Kindred' not in self.tertiary_theme: - weights['primary'] -= 0.2 # 0.8 - weights['secondary'] += 0.1 # 0.1 - weights['tertiary'] += 0.1 # 0.1 - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme and 'Kindred' not in self.tertiary_theme: - weights['primary'] -= 0.3 # 0.7 - weights['secondary'] += 0.2 # 0.2 - weights['tertiary'] += 0.1 # 0.1 - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme and 'Kindred' in self.tertiary_theme: - weights['primary'] -= 0.5 # 0.5 - weights['secondary'] += 0.3 # 0.3 - weights['tertiary'] += 0.2 # 0.2 - else: - weights['primary'] -= 0.6 # 0.4 - weights['secondary'] += 0.3 # 0.3 - weights['tertiary'] += 0.3 # 0.3 - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - break - - self.themes = [self.primary_theme] - if not self.secondary_theme: - pass - else: - self.themes.append(self.secondary_theme) - if not self.tertiary_theme: - pass - else: - self.themes.append(self.tertiary_theme) - - """ - Setting 'Hidden' themes for multiple-copy cards, such as 'Hare Apparent' or 'Shadowborn Apostle'. - These are themes that will be prompted for under specific conditions, such as a matching Kindred theme or a matching color combination and Spellslinger theme for example. - Typically a hidden theme won't come up, but if it does, it will take priority with theme weights to ensure a decent number of the specialty cards are added. - """ - # Setting hidden theme for Kindred-specific themes - hidden_themes = ['Advisor Kindred', 'Demon Kindred', 'Dwarf Kindred', 'Rabbit Kindred', 'Rat Kindred', 'Wraith Kindred'] - theme_cards = ['Persistent Petitioners', 'Shadowborn Apostle', 'Seven Dwarves', 'Hare Apparent', ['Rat Colony', 'Relentless Rats'], 'Nazgûl'] - color = ['B', 'B', 'R', 'W', 'B', 'B'] - for i in range(min(len(hidden_themes), len(theme_cards), len(color))): - if (hidden_themes[i] in self.themes - and hidden_themes[i] != 'Rat Kindred' - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - weights['primary'] = round(weights['primary'] / 3, 2) - weights['secondary'] = round(weights['secondary'] / 2, 2) - weights['tertiary'] = weights['tertiary'] - weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2) - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - self.hidden_weight = weights['hidden'] - else: - continue - - elif (hidden_themes[i] in self.themes - and hidden_themes[i] == 'Rat Kindred' - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - print('Which one?') - choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i]) - if choice: - self.hidden_theme = choice - self.themes.append(self.hidden_theme) - weights['primary'] = round(weights['primary'] / 3, 2) - weights['secondary'] = round(weights['secondary'] / 2, 2) - weights['tertiary'] = weights['tertiary'] - weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2) - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - self.hidden_weight = weights['hidden'] - else: - continue - - # Setting the hidden theme for non-Kindred themes - hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spells Matter', 'Spellslinger', 'Spells Matter',] - theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Dragon\'s Approach', 'Slime Against Humanity', 'Slime Against Humanity'] - color = ['W', 'B', 'R', 'R', 'G', 'G'] - for i in range(min(len(hidden_themes), len(theme_cards), len(color))): - if (hidden_themes[i] in self.themes - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - weights['primary'] = round(weights['primary'] / 3, 2) - weights['secondary'] = round(weights['secondary'] / 2, 2) - weights['tertiary'] = weights['tertiary'] - weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2) - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - self.hidden_weight = weights['hidden'] - else: - continue - - break - def determine_ideals(self): - # "Free" slots that can be used for anything that isn't the ideals - self.free_slots = 99 + def determine_themes(self) -> None: + """Determine and set up themes for the deck building process. - if use_scrython: - print('Would you like to set an intended max price of the deck?\n' - 'There will be some leeway of ~10%, with a couple alternative options provided.') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - print('What would you like the max price to be?') - max_deck_price = float(self.input_handler.questionnaire('Number', 400)) - self.price_checker.max_deck_price = max_deck_price - new_line() - else: - new_line() + This method handles: + 1. Theme selection (primary, secondary, tertiary) + 2. Theme weight calculations + 3. Hidden theme detection and setup + + Raises: + ThemeSelectionError: If theme selection fails + ThemeWeightError: If weight calculation fails + """ + try: + # Get available themes from commander tags + themes = self.commander_tags.copy() - print('Would you like to set a max price per card?\n' - 'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - print('What would you like the max price to be?') - answer = float(self.input_handler.questionnaire('Number', 20)) - self.price_checker.max_card_price = answer - new_line() - else: - new_line() - - # Determine ramp - print('How many pieces of ramp would you like to include?\n' - 'This includes mana rocks, mana dorks, and land ramp spells.\n' - 'A good baseline is 8-12 pieces, scaling up with higher average CMC\n' - 'Default: 8') - answer = self.input_handler.questionnaire('Number', 8) - self.ideal_ramp = int(answer) - self.free_slots -= self.ideal_ramp - new_line() - - # Determine ideal land count - print('How many total lands would you like to include?\n' - 'Before ramp is considered, 38-40 lands is typical for most decks.\n' - "For landfall decks, consider starting at 40 lands before ramp.\n" - 'As a guideline, each mana source from ramp can reduce land count by ~1.\n' - 'Default: 35') - answer = self.input_handler.questionnaire('Number', 35) - self.ideal_land_count = int(answer) - self.free_slots -= self.ideal_land_count - new_line() - - # Determine minimum basics to have - print('How many basic lands would you like to have at minimum?\n' - 'This can vary widely depending on your commander, colors in color identity, and what you want to do.\n' - 'Some decks may be fine with as low as 10, others may want 25.\n' - 'Default: 20') - answer = self.input_handler.questionnaire('Number', 20) - self.min_basics = int(answer) - new_line() - - # Determine ideal creature count - print('How many creatures would you like to include?\n' - 'Something like 25-30 would be a good starting point.\n' - "If you're going for a kindred theme, going past 30 is likely normal.\n" - "Also be sure to take into account token generation, but remember you'll want enough to stay safe\n" - 'Default: 25') - answer = self.input_handler.questionnaire('Number', 25) - self.ideal_creature_count = int(answer) - self.free_slots -= self.ideal_creature_count - new_line() - - # Determine spot/targetted removal - print('How many spot removal pieces would you like to include?\n' - 'A good starting point is about 8-12 pieces of spot removal.\n' - 'Counterspells can be considered proactive removal and protection.\n' - 'If you\'re going spellslinger, more would be a good idea as you might have less cretaures.\n' - 'Default: 10') - answer = self.input_handler.questionnaire('Number', 10) - self.ideal_removal = int(answer) - self.free_slots -= self.ideal_removal - new_line() + # Get available themes from commander tags + themes = self.commander_tags.copy() + + # Initialize theme flags + self.hidden_theme = False + self.secondary_theme = False + self.tertiary_theme = False + + # Select primary theme (required) + self.primary_theme = builder_utils.select_theme( + themes, + 'Choose a primary theme for your commander deck.\n' + 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.' + ) + themes.remove(self.primary_theme) + + # Initialize self.weights from settings + from settings import THEME_WEIGHTS_DEFAULT + self.weights = THEME_WEIGHTS_DEFAULT.copy() + # Set initial weights for primary-only case + self.weights['primary'] = 1.0 + self.weights['secondary'] = 0.0 + self.weights['tertiary'] = 0.0 + self.primary_weight = 1.0 + + # Select secondary theme if desired + if themes: + self.secondary_theme = builder_utils.select_theme( + themes, + 'Choose a secondary theme for your commander deck.\n' + 'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.', + optional=True + ) + + # Check for Stop Here before modifying themes list + if self.secondary_theme == 'Stop Here': + self.secondary_theme = False + elif self.secondary_theme: + themes.remove(self.secondary_theme) + self.weights['secondary'] = 0.6 + self.weights = builder_utils.adjust_theme_weights( + self.primary_theme, + self.secondary_theme, + None, # No tertiary theme yet + self.weights + ) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + + # Select tertiary theme if desired + if themes and self.secondary_theme and self.secondary_theme != 'Stop Here': + self.tertiary_theme = builder_utils.select_theme( + themes, + 'Choose a tertiary theme for your commander deck.\n' + 'This will typically be a tertiary focus, or just something else to do that your commander is good at.', + optional=True + ) + + # Check for Stop Here before modifying themes list + if self.tertiary_theme == 'Stop Here': + self.tertiary_theme = False + elif self.tertiary_theme: + self.weights['tertiary'] = 0.3 + self.weights = builder_utils.adjust_theme_weights( + self.primary_theme, + self.secondary_theme, + self.tertiary_theme, + self.weights + ) + print(self.weights) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + + # Build final themes list + self.themes = [self.primary_theme] + if self.secondary_theme: + self.themes.append(self.secondary_theme) + if self.tertiary_theme: + self.themes.append(self.tertiary_theme) + print(self.weights) + self.determine_hidden_themes() - # Determine board wipes - print('How many board wipes would you like to include?\n' - 'Somewhere around 2-3 is good to help eliminate threats, but also prevent the game from running long\n.' - 'This can include damaging wipes like "Blasphemous Act" or toughness reduction like "Meathook Massacre".\n' - 'Default: 2') - answer = self.input_handler.questionnaire('Number', 2) - self.ideal_wipes = int(answer) - self.free_slots -= self.ideal_wipes - new_line() + except (ThemeSelectionError, ThemeWeightError) as e: + logger.error(f"Error in theme determination: {e}") + raise + + def determine_hidden_themes(self) -> None: + """ + Setting 'Hidden' themes for multiple-copy cards, such as 'Hare Apparent' or 'Shadowborn Apostle'. + These are themes that will be prompted for under specific conditions, such as a matching Kindred theme or a matching color combination and Spellslinger theme for example. + Typically a hidden theme won't come up, but if it does, it will take priority with theme self.weights to ensure a decent number of the specialty cards are added. + """ + # Setting hidden theme for Kindred-specific themes + hidden_themes = ['Advisor Kindred', 'Demon Kindred', 'Dwarf Kindred', 'Rabbit Kindred', 'Rat Kindred', 'Wraith Kindred'] + theme_cards = ['Persistent Petitioners', 'Shadowborn Apostle', 'Seven Dwarves', 'Hare Apparent', ['Rat Colony', 'Relentless Rats'], 'Nazgûl'] + color = ['B', 'B', 'R', 'W', 'B', 'B'] + for i in range(min(len(hidden_themes), len(theme_cards), len(color))): + if (hidden_themes[i] in self.themes + and hidden_themes[i] != 'Rat Kindred' + and color[i] in self.colors): + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) + if choice: + self.hidden_theme = theme_cards[i] + self.themes.append(self.hidden_theme) + self.weights['primary'] = round(self.weights['primary'] / 3, 2) + self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) + self.weights['tertiary'] = self.weights['tertiary'] + self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + self.hidden_weight = self.weights['hidden'] + else: + continue + + elif (hidden_themes[i] in self.themes + and hidden_themes[i] == 'Rat Kindred' + and color[i] in self.colors): + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') + choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) + if choice: + print('Which one?') + choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i], message='') + if choice: + self.hidden_theme = choice + self.themes.append(self.hidden_theme) + self.weights['primary'] = round(self.weights['primary'] / 3, 2) + self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) + self.weights['tertiary'] = self.weights['tertiary'] + self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + self.hidden_weight = self.weights['hidden'] + else: + continue - # Determine card advantage - print('How many pieces of card advantage would you like to include?\n' - '10 pieces of card advantage is good, up to 14 is better.\n' - 'Try to have a majority of it be non-conditional, and only have a couple of "Rhystic Study" style effects.\n' - 'Default: 10') - answer = self.input_handler.questionnaire('Number', 10) - self.ideal_card_advantage = int(answer) - self.free_slots -= self.ideal_card_advantage - new_line() + # Setting the hidden theme for non-Kindred themes + hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spells Matter', 'Spellslinger', 'Spells Matter',] + theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Dragon\'s Approach', 'Slime Against Humanity', 'Slime Against Humanity'] + color = ['W', 'B', 'R', 'R', 'G', 'G'] + for i in range(min(len(hidden_themes), len(theme_cards), len(color))): + if (hidden_themes[i] in self.themes + and color[i] in self.colors): + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) + if choice: + self.hidden_theme = theme_cards[i] + self.themes.append(self.hidden_theme) + self.weights['primary'] = round(self.weights['primary'] / 3, 2) + self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) + self.weights['tertiary'] = self.weights['tertiary'] + self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + self.hidden_weight = self.weights['hidden'] + else: + continue - # Determine how many protection spells - print('How many protection spells would you like to include?\n' - 'This can be individual protection, board protection, fogs, or similar effects.\n' - 'Things that grant indestructible, hexproof, phase out, or even just counterspells.\n' - 'It\'s recommended to have 5 to 15, depending on your commander and preferred strategy.\n' - 'Default: 8') - answer = self.input_handler.questionnaire('Number', 8) - self.ideal_protection = int(answer) - self.free_slots -= self.ideal_protection - new_line() - - print(f'Free slots that aren\'t part of the ideals: {self.free_slots}') - print('Keep in mind that many of the ideals can also cover multiple roles, but this will give a baseline POV.') + def determine_ideals(self): + """Determine ideal card counts and price settings for the deck. + + This method handles: + 1. Price configuration (if price checking is enabled) + 2. Setting ideal counts for different card types + 3. Calculating remaining free slots + + Raises: + PriceConfigurationError: If there are issues configuring price settings + IdealDeterminationError: If there are issues determining ideal counts + """ + try: + # Initialize free slots + self.free_slots = 99 + + # Configure price settings if enabled + if use_scrython: + try: + builder_utils.configure_price_settings(self.price_checker, self.input_handler) + except ValueError as e: + raise PriceConfigurationError(f"Failed to configure price settings: {str(e)}") + + # Get deck composition values + try: + composition = builder_utils.get_deck_composition_values(self.input_handler) + except ValueError as e: + raise IdealDeterminationError(f"Failed to determine deck composition: {str(e)}") + + # Update class attributes with composition values + self.ideal_ramp = composition['ramp'] + self.ideal_land_count = composition['lands'] + self.min_basics = composition['basic_lands'] + self.ideal_creature_count = composition['creatures'] + self.ideal_removal = composition['removal'] + self.ideal_wipes = composition['wipes'] + self.ideal_card_advantage = composition['card_advantage'] + self.ideal_protection = composition['protection'] + + # Update free slots + for value in [self.ideal_ramp, self.ideal_land_count, self.ideal_creature_count, + self.ideal_removal, self.ideal_wipes, self.ideal_card_advantage, + self.ideal_protection]: + self.free_slots -= value + + print(f'\nFree slots that aren\'t part of the ideals: {self.free_slots}') + print('Keep in mind that many of the ideals can also cover multiple roles, but this will give a baseline POV.') + + except (PriceConfigurationError, IdealDeterminationError) as e: + logger.error(f"Error in determine_ideals: {e}") + raise def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, is_commander: bool = False) -> None: """Add a card to the deck library with price checking if enabled. - Args: card (str): Name of the card to add card_type (str): Type of the card (e.g., 'Creature', 'Instant') @@ -1205,109 +1143,159 @@ class DeckBuilder: logger.debug(f"Added {card} to deck library") def organize_library(self): - # Initialize counters dictionary dynamically from CARD_TYPES including Kindred - all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES - card_counters = {card_type: 0 for card_type in all_types} + """Organize and count cards in the library by their types. - # Count cards by type - for card_type in CARD_TYPES: - type_df = self.card_library[self.card_library['Card Type'].apply(lambda x: card_type in x)] - card_counters[card_type] = len(type_df) + This method counts the number of cards for each card type in the library + and updates the corresponding instance variables. It uses the count_cards_by_type + helper function from builder_utils for efficient counting. - # Assign counts to instance variables - self.artifact_cards = card_counters['Artifact'] - self.battle_cards = card_counters['Battle'] - self.creature_cards = card_counters['Creature'] - self.enchantment_cards = card_counters['Enchantment'] - self.instant_cards = card_counters['Instant'] - self.kindred_cards = card_counters.get('Kindred', 0) # Use get() with default value - self.land_cards = card_counters['Land'] - self.planeswalker_cards = card_counters['Planeswalker'] - self.sorcery_cards = card_counters['Sorcery'] - - def sort_library(self): - self.card_library['Sort Order'] = pd.Series(dtype='str') - for index, row in self.card_library.iterrows(): - for card_type in CARD_TYPES: - if card_type in row['Card Type']: - if row['Sort Order'] == 'Creature': - continue - if row['Sort Order'] != 'Creature': - self.card_library.loc[index, 'Sort Order'] = card_type + The method handles the following card types: + - Artifacts + - Battles + - Creatures + - Enchantments + - Instants + - Kindred (if applicable) + - Lands + - Planeswalkers + - Sorceries - custom_order = ['Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land'] - self.card_library['Sort Order'] = pd.Categorical( - self.card_library['Sort Order'], - categories=custom_order, - ordered=True - ) - self.card_library = (self.card_library - .sort_values(by=['Sort Order', 'Card Name'], ascending=[True, True]) - .drop(columns=['Sort Order']) - .reset_index(drop=True) - ) - - def commander_to_top(self) -> None: - """Move commander card to the top of the library while preserving commander status.""" - try: - commander_row = self.card_library[self.card_library['Commander']].copy() - if commander_row.empty: - logger.warning("No commander found in library") - return - - self.card_library = self.card_library[~self.card_library['Commander']] - - self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) - - commander_name = commander_row['Card Name'].iloc[0] - logger.info(f"Successfully moved commander '{commander_name}' to top") - except Exception as e: - logger.error(f"Error moving commander to top: {str(e)}") - def concatenate_duplicates(self): - """Handle duplicate cards in the library while maintaining data integrity.""" - duplicate_lists = BASIC_LANDS + multiple_copy_cards - - # Create a count column for duplicates - self.card_library['Card Count'] = 1 - - for duplicate in duplicate_lists: - mask = self.card_library['Card Name'] == duplicate - count = mask.sum() - - if count > 0: - logger.info(f'Found {count} copies of {duplicate}') - - # Keep first occurrence with updated count - first_idx = mask.idxmax() - self.card_library.loc[first_idx, 'Card Count'] = count - - # Drop other occurrences - self.card_library = self.card_library.drop( - self.card_library[mask & (self.card_library.index != first_idx)].index - ) - - # Update card names with counts where applicable - mask = self.card_library['Card Count'] > 1 - self.card_library.loc[mask, 'Card Name'] = ( - self.card_library.loc[mask, 'Card Name'] + - ' x ' + - self.card_library.loc[mask, 'Card Count'].astype(str) - ) - - # Clean up - self.card_library = self.card_library.drop(columns=['Card Count']) - self.card_library = self.card_library.reset_index(drop=True) - def drop_card(self, dataframe: pd.DataFrame, index: int) -> None: - """Safely drop a card from the dataframe by index. - - Args: - dataframe: DataFrame to modify - index: Index to drop + Raises: + CardTypeCountError: If there are issues counting cards by type + LibraryOrganizationError: If library organization fails """ try: - dataframe.drop(index, inplace=True) - except KeyError: - logger.warning(f"Attempted to drop non-existent index {index}") + # Get all card types to count, including Kindred if not already present + all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES + + # Use helper function to count cards by type + card_counters = builder_utils.count_cards_by_type(self.card_library, all_types) + + # Update instance variables with counts + self.artifact_cards = card_counters['Artifact'] + self.battle_cards = card_counters['Battle'] + self.creature_cards = card_counters['Creature'] + self.enchantment_cards = card_counters['Enchantment'] + self.instant_cards = card_counters['Instant'] + self.kindred_cards = card_counters.get('Kindred', 0) + self.land_cards = card_counters['Land'] + self.planeswalker_cards = card_counters['Planeswalker'] + self.sorcery_cards = card_counters['Sorcery'] + + logger.debug(f"Library organized successfully with {len(self.card_library)} total cards") + + except (CardTypeCountError, Exception) as e: + logger.error(f"Error organizing library: {e}") + raise LibraryOrganizationError(f"Failed to organize library: {str(e)}") + + def sort_library(self) -> None: + """Sort the card library by card type and name. + + This method sorts the card library first by card type according to the + CARD_TYPE_SORT_ORDER constant, and then alphabetically by card name. + It uses the assign_sort_order() helper function to ensure consistent + type-based sorting across the application. + + The sorting order is: + 1. Card type (Planeswalker -> Battle -> Creature -> Instant -> Sorcery -> + Artifact -> Enchantment -> Land) + 2. Card name (alphabetically) + + Raises: + LibrarySortError: If there are issues during the sorting process + """ + try: + # Use the assign_sort_order helper function to add sort order + sorted_library = builder_utils.assign_sort_order(self.card_library) + + # Sort by Sort Order and Card Name + sorted_library = sorted_library.sort_values( + by=['Sort Order', 'Card Name'], + ascending=[True, True] + ) + + # Clean up and reset index + self.card_library = ( + sorted_library + .drop(columns=['Sort Order']) + .reset_index(drop=True) + ) + + logger.debug("Card library sorted successfully") + + except Exception as e: + logger.error(f"Error sorting library: {e}") + raise LibrarySortError( + "Failed to sort card library", + {"error": str(e)} + ) + + def commander_to_top(self) -> None: + """Move commander card to the top of the library while preserving commander status. + + This method identifies the commander card in the library using a boolean mask, + removes it from its current position, and prepends it to the top of the library. + The commander's status and attributes are preserved during the move. + + Raises: + CommanderMoveError: If the commander cannot be found in the library or + if there are issues with the move operation. + """ + try: + # Create boolean mask to identify commander + commander_mask = self.card_library['Commander'] + + # Check if commander exists in library + if not commander_mask.any(): + error_msg = "Commander not found in library" + logger.warning(error_msg) + raise CommanderMoveError(error_msg) + + # Get commander row and name for logging + commander_row = self.card_library[commander_mask].copy() + commander_name = commander_row['Card Name'].iloc[0] + + # Remove commander from current position + self.card_library = self.card_library[~commander_mask] + + # Prepend commander to top of library + self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) + + logger.info(f"Successfully moved commander '{commander_name}' to top of library") + + except CommanderMoveError: + raise + except Exception as e: + error_msg = f"Error moving commander to top: {str(e)}" + logger.error(error_msg) + raise CommanderMoveError(error_msg) + + def concatenate_duplicates(self): + """Process duplicate cards in the library using the helper function. + + This method consolidates duplicate cards (like basic lands and special cards + that can have multiple copies) into single entries with updated counts. + It uses the process_duplicate_cards helper function from builder_utils. + + Raises: + DuplicateCardError: If there are issues processing duplicate cards + """ + try: + # Get list of cards that can have duplicates + duplicate_lists = BASIC_LANDS + multiple_copy_cards + + # Process duplicates using helper function + self.card_library = builder_utils.process_duplicate_cards( + self.card_library, + duplicate_lists + ) + + logger.info("Successfully processed duplicate cards") + + except DuplicateCardError as e: + logger.error(f"Error processing duplicate cards: {e}") + raise + def add_lands(self): """ Add lands to the deck based on ideal count and deck requirements. @@ -1368,231 +1356,230 @@ class DeckBuilder: raise def add_basics(self): - base_basics = self.ideal_land_count - 10 # Reserve 10 slots for non-basic lands - basics_per_color = base_basics // len(self.colors) - remaining_basics = base_basics % len(self.colors) + """Add basic lands to the deck based on color identity and commander tags. - color_to_basic = { - 'W': 'Plains', - 'U': 'Island', - 'B': 'Swamp', - 'R': 'Mountain', - 'G': 'Forest', - 'COLORLESS': 'Wastes' - } + This method: + 1. Calculates total basics needed based on ideal land count + 2. Gets appropriate basic land mapping (normal or snow-covered) + 3. Distributes basics across colors + 4. Updates the land database - if 'Snow' in self.commander_tags: - color_to_basic = { - 'W': 'Snow-Covered Plains', - 'U': 'Snow-Covered Island', - 'B': 'Snow-Covered Swamp', - 'R': 'Snow-Covered Mountain', - 'G': 'Snow-Covered Forest', - 'COLORLESS': 'Snow-Covered Wastes' - } + Raises: + BasicLandError: If there are issues with basic land addition + LandDistributionError: If land distribution fails + """ + try: + # Calculate total basics needed + total_basics = self.ideal_land_count - DEFAULT_NON_BASIC_LAND_SLOTS + if total_basics <= 0: + raise BasicLandError("Invalid basic land count calculation") - print(f'Adding {base_basics} basic lands distributed across {len(self.colors)} colors') + # Get appropriate basic land mapping + use_snow = 'Snow' in self.commander_tags + color_to_basic = builder_utils.get_basic_land_mapping(use_snow) - # Add equal distribution first - for color in self.colors: - basic = color_to_basic.get(color) - if basic: - # Add basics with explicit commander flag and track count - for _ in range(basics_per_color): - self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + # Calculate distribution + basics_per_color, remaining = builder_utils.calculate_basics_per_color( + total_basics, + len(self.colors) + ) - # Distribute remaining basics based on color requirements - if remaining_basics > 0: - for color in self.colors[:remaining_basics]: + print() + logger.info( + f'Adding {total_basics} basic lands distributed across ' + f'{len(self.colors)} colors' + ) + + # Initialize distribution dictionary + distribution = {color: basics_per_color for color in self.colors} + + # Distribute remaining basics + if remaining > 0: + distribution = builder_utils.distribute_remaining_basics( + distribution, + remaining, + self.colors + ) + + # Add basics according to distribution + lands_to_remove = [] + for color, count in distribution.items(): basic = color_to_basic.get(color) if basic: - self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + for _ in range(count): + self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + lands_to_remove.append(basic) - lands_to_remove = [] - for key in color_to_basic: - basic = color_to_basic.get(key) - lands_to_remove.append(basic) - - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + # Update land database + self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + except Exception as e: + logger.error(f"Error adding basic lands: {e}") + raise BasicLandError(f"Failed to add basic lands: {str(e)}") def add_standard_non_basics(self): - """Add staple utility lands based on deck requirements.""" + """Add staple utility lands to the deck based on predefined conditions and requirements. + + This method processes the STAPLE_LAND_CONDITIONS from settings to add appropriate + utility lands to the deck. For each potential staple land, it: + + 1. Validates the land against deck requirements using: + - Commander tags + - Color identity + - Commander power level + - Other predefined conditions + + 2. Adds validated lands to the deck and tracks them in self.staples + + 3. Updates the land database to remove added lands + + The method ensures no duplicate lands are added and maintains proper logging + of all additions. + + Raises: + StapleLandError: If there are issues adding staple lands, such as + validation failures or database update errors. + """ + print() logger.info('Adding staple non-basic lands') - - # Define staple lands and their conditions - staple_lands = { - 'Reliquary Tower': lambda: True, # Always include - 'Ash Barrens': lambda: 'Landfall' not in self.commander_tags, - 'Command Tower': lambda: len(self.colors) > 1, - 'Exotic Orchard': lambda: len(self.colors) > 1, - 'War Room': lambda: len(self.colors) <= 2, - 'Rogue\'s Passage': lambda: self.commander_power >= 5 - } - self.staples = [] + try: - # Add lands that meet their conditions - for land, condition in staple_lands.items(): - if condition(): + for land in STAPLE_LAND_CONDITIONS: + if builder_utils.validate_staple_land_conditions( + land, + STAPLE_LAND_CONDITIONS, + self.commander_tags, + self.colors, + self.commander_power + ): if land not in self.card_library['Card Name'].values: self.add_card(land, 'Land', None, 0) self.staples.append(land) logger.debug(f"Added staple land: {land}") - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(self.staples)] + + self.land_df = builder_utils.process_staple_lands( + self.staples, self.card_library, self.land_df + ) self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(self.staples)} staple lands') - + logger.info(f'Added {len(self.staples)} staple lands:') + print(*self.staples, sep='\n') except Exception as e: logger.error(f"Error adding staple lands: {e}") - raise + raise StapleLandError(f"Failed to add staple lands: {str(e)}") + def add_fetches(self): - # Determine how many fetches in total - print('How many fetch lands would you like to include?\n' - 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' - 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') - answer = self.input_handler.questionnaire('Number', 2) - MAX_ATTEMPTS = 50 # Maximum attempts to prevent infinite loops - attempt_count = 0 - desired_fetches = int(answer) - chosen_fetches = [] - - generic_fetches = [ - 'Evolving Wilds', 'Terramorphic Expanse', 'Shire Terrace', - 'Escape Tunnel', 'Promising Vein', 'Myriad Landscape', - 'Fabled Passage', 'Terminal Moraine' - ] - fetches = generic_fetches.copy() - lands_to_remove = generic_fetches.copy() - - # Adding in expensive fetches - if (use_scrython and self.set_max_card_price): - if self.price_checker.get_card_price('Prismatic Vista') <= self.max_card_price * 1.1: - lands_to_remove.append('Prismatic Vista') - fetches.append('Prismatic Vista') - else: - lands_to_remove.append('Prismatic Vista') - pass - else: - lands_to_remove.append('Prismatic Vista') - fetches.append('Prismatic Vista') - - color_to_fetch = { - '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'] - } - - for color in self.colors: - fetch = color_to_fetch.get(color) - if fetch not in fetches: - fetches.extend(fetch) - if fetch not in lands_to_remove: - lands_to_remove.extend(fetch) - for color in color_to_fetch: - fetch = color_to_fetch.get(color) - if fetch not in fetches: - fetches.extend(fetch) - if fetch not in lands_to_remove: - lands_to_remove.extend(fetch) - - # Randomly choose fetches up to the desired number - while len(chosen_fetches) < desired_fetches + 3 and attempt_count < MAX_ATTEMPTS: - if not fetches: # If we run out of fetches to choose from - break - - fetch_choice = random.choice(fetches) - if use_scrython and self.set_max_card_price: - if self.price_checker.get_card_price(fetch_choice) <= self.max_card_price * 1.1: - chosen_fetches.append(fetch_choice) - fetches.remove(fetch_choice) - else: - chosen_fetches.append(fetch_choice) - fetches.remove(fetch_choice) - - attempt_count += 1 + """Add fetch lands to the deck based on user input and deck colors. - # Select final fetches to add - fetches_to_add = [] - available_fetches = chosen_fetches[:desired_fetches] - for fetch in available_fetches: - if fetch not in fetches_to_add: - fetches_to_add.append(fetch) + This method handles: + 1. Getting user input for desired number of fetch lands + 2. Validating the input + 3. Getting available fetch lands based on deck colors + 4. Selecting and adding appropriate fetch lands + 5. Updating the land database - if attempt_count >= MAX_ATTEMPTS: - logger.warning(f"Reached maximum attempts ({MAX_ATTEMPTS}) while selecting fetch lands") - - for card in fetches_to_add: - self.add_card(card, 'Land', None, 0) - - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - def add_kindred_lands(self): - """Add lands that support tribal/kindred themes.""" - logger.info('Adding Kindred-themed lands') - - # Standard Kindred support lands - KINDRED_STAPLES = [ - {'name': 'Path of Ancestry', 'type': 'Land'}, - {'name': 'Three Tree City', 'type': 'Legendary Land'}, - {'name': 'Cavern of Souls', 'type': 'Land'} - ] - - kindred_lands = KINDRED_STAPLES.copy() - lands_to_remove = set() - + Raises: + FetchLandValidationError: If fetch land count is invalid + FetchLandSelectionError: If unable to select required fetch lands + PriceLimitError: If fetch lands exceed price limits + """ try: - # Process each Kindred theme - for theme in self.themes: - if 'Kindred' in theme: - creature_type = theme.replace(' Kindred', '') - logger.info(f'Searching for {creature_type}-specific lands') - - # Filter lands by creature type - type_specific = self.land_df[ - self.land_df['text'].notna() & - (self.land_df['text'].str.contains(creature_type, case=False) | - self.land_df['type'].str.contains(creature_type, case=False)) - ] - - # Add matching lands to pool - for _, row in type_specific.iterrows(): - kindred_lands.append({ - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - }) - lands_to_remove.add(row['name']) + # Get user input for fetch lands + print() + logger.info('Adding fetch lands') + print('How many fetch lands would you like to include?\n' + 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' + 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') - # Add lands to deck - for card in kindred_lands: - if card['name'] not in self.card_library['Card Name'].values: - self.add_card(card['name'], card['type'], - None, 0) - lands_to_remove.add(card['name']) + # Get and validate fetch count + fetch_count = self.input_handler.questionnaire('Number', default_value=FETCH_LAND_DEFAULT_COUNT, message='Default') + validated_count = builder_utils.validate_fetch_land_count(fetch_count) + + # Get available fetch lands based on colors and budget + max_price = self.max_card_price if hasattr(self, 'max_card_price') else None + available_fetches = builder_utils.get_available_fetch_lands( + self.colors, + self.price_checker if use_scrython else None, + max_price + ) + + # Select fetch lands + selected_fetches = builder_utils.select_fetch_lands( + available_fetches, + validated_count + ) + + # Add selected fetch lands to deck + lands_to_remove = set() + for fetch in selected_fetches: + self.add_card(fetch, 'Land', None, 0) + lands_to_remove.add(fetch) # Update land database self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - logger.info(f'Added {len(lands_to_remove)} Kindred-themed lands') + logger.info(f'Added {len(selected_fetches)} fetch lands:') + print(*selected_fetches, sep='\n') + + except (FetchLandValidationError, FetchLandSelectionError, PriceLimitError) as e: + logger.error(f"Error adding fetch lands: {e}") + raise + + def add_kindred_lands(self): + """Add Kindred-themed lands to the deck based on commander themes. + + This method handles: + 1. Getting available Kindred lands based on deck themes + 2. Selecting and adding appropriate Kindred lands + 3. Updating the land database + + Raises: + KindredLandSelectionError: If unable to select required Kindred lands + PriceLimitError: If Kindred lands exceed price limits + """ + try: + print() + logger.info('Adding Kindred-themed lands') + + # Get available Kindred lands based on themes and budget + max_price = self.max_card_price if hasattr(self, 'max_card_price') else None + available_lands = builder_utils.get_available_kindred_lands( + self.colors, + self.commander_tags, + self.price_checker if use_scrython else None, + max_price + ) + + # Select Kindred lands + selected_lands = builder_utils.select_kindred_lands( + available_lands + ) + + # Add selected Kindred lands to deck + lands_to_remove = set() + for land in selected_lands: + self.add_card(land, 'Land', None, 0) + lands_to_remove.add(land) + + # Update land database + self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} Kindred-themed lands:') + print(*selected_lands, sep='\n') except Exception as e: logger.error(f"Error adding Kindred lands: {e}") raise - def add_dual_lands(self): - # Determine dual-color lands available + def add_dual_lands(self): + # Determine dual-color lands available + # Determine if using the dual-type lands print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') - choice = self.input_handler.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) color_filter = [] color_dict = { 'azorius': 'Plains Island', @@ -1642,7 +1629,7 @@ class DeckBuilder: def add_triple_lands(self): # Determine if using Triome lands print('Would you like to include triome lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') - choice = self.input_handler.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) color_filter = [] color_dict = { @@ -1753,8 +1740,24 @@ class DeckBuilder: except Exception as e: logger.error(f"Error adding misc lands: {e}") raise + def check_basics(self): - """Check and display counts of each basic land type.""" + """Check and display counts of each basic land type in the deck. + + This method analyzes the deck's basic land composition by: + 1. Counting each type of basic land (including snow-covered) + 2. Displaying the counts for each basic land type + 3. Calculating and storing the total number of basic lands + + The method uses helper functions from builder_utils for consistent + counting and display formatting. + + Raises: + BasicLandCountError: If there are issues counting basic lands + + Note: + Updates self.total_basics with the sum of all basic lands + """ basic_lands = { 'Plains': 0, 'Island': 0, @@ -1769,16 +1772,22 @@ class DeckBuilder: } self.total_basics = 0 - for land in basic_lands: - count = len(self.card_library[self.card_library['Card Name'] == land]) - basic_lands[land] = count - self.total_basics += count - logger.info("\nBasic Land Counts:") - for land, count in basic_lands.items(): - if count > 0: - logger.info(f"{land}: {count}") - logger.info(f"Total basic lands: {self.total_basics}\n") + try: + for land in basic_lands: + count = len(self.card_library[self.card_library['Card Name'] == land]) + basic_lands[land] = count + self.total_basics += count + + logger.info("Basic Land Counts:") + for land, count in basic_lands.items(): + if count > 0: + print(f"{land}: {count}") + logger.info(f"Total basic lands: {self.total_basics}") + except BasicLandCountError as e: + logger.error(f"Error counting basic lands: {e}") + self.total_basics = 0 + raise def remove_basic(self, max_attempts: int = 3): """ @@ -2051,10 +2060,10 @@ class DeckBuilder: def add_creatures(self): """ - Add creatures to the deck based on themes and weights. + Add creatures to the deck based on themes and self.weights. This method processes the primary, secondary, and tertiary themes to add - creatures proportionally according to their weights. The total number of + creatures proportionally according to their self.weights. The total number of creatures added will approximate the ideal_creature_count. Themes are processed in order of importance (primary -> secondary -> tertiary) @@ -2146,11 +2155,11 @@ class DeckBuilder: initial_count = len(self.card_library) remaining = 100 - len(self.card_library) - # Adjust weights based on remaining cards needed + # Adjust self.weights based on remaining cards needed weight_multiplier = remaining / cards_needed try: - # Add cards from each theme with adjusted weights + # Add cards from each theme with adjusted self.weights if self.tertiary_theme: self.add_by_tags(self.tertiary_theme, math.ceil(self.tertiary_weight * 10 * weight_multiplier), diff --git a/exceptions.py b/exceptions.py index 876256e..7b8aa57 100644 --- a/exceptions.py +++ b/exceptions.py @@ -583,4 +583,472 @@ class CommanderThemeError(CommanderValidationError): message: Description of the theme validation failure details: Additional context about the error """ - super().__init__(message, code="CMD_THEME_ERR", details=details) \ No newline at end of file + super().__init__(message, code="CMD_THEME_ERR", details=details) + +class CommanderMoveError(DeckBuilderError): + """Raised when there are issues moving the commander to the top of the library. + + This exception is used when the commander_to_top() method encounters problems + such as commander not found in library, invalid deck state, or other issues + preventing the commander from being moved to the top position. + + Examples: + >>> raise CommanderMoveError( + ... "Commander not found in library", + ... {"commander_name": "Atraxa, Praetors' Voice"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander move error. + + Args: + message: Description of the move operation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_MOVE_ERR", details=details) + +class LibraryOrganizationError(DeckBuilderError): + """Base exception class for library organization errors. + + This exception serves as the base for all errors related to organizing + and managing the card library, including card type counting and validation. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "LIB_ORG_ERR", details: dict | None = None): + """Initialize the library organization error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class LibrarySortError(LibraryOrganizationError): + """Raised when there are issues sorting the card library. + + This exception is used when the sort_library() method encounters problems + organizing cards by type and name, such as invalid sort orders or + card type categorization errors. + + Examples: + >>> raise LibrarySortError( + ... "Invalid card type sort order", + ... "Card type 'Unknown' not in sort order list" + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize library sort error. + + Args: + message: Description of the sorting failure + details: Additional context about the error + """ + if details: + details = details or {} + details['sort_error'] = True + super().__init__(message, code="LIB_SORT_ERR", details=details) + +class DuplicateCardError(LibraryOrganizationError): + """Raised when there are issues processing duplicate cards in the library. + + This exception is used when the concatenate_duplicates() method encounters problems + processing duplicate cards, such as invalid card names, missing data, or + inconsistencies in duplicate card information. + + Examples: + >>> raise DuplicateCardError( + ... "Failed to process duplicate cards", + ... "Sol Ring", + ... {"duplicate_count": 3} + ... ) + """ + + def __init__(self, message: str, card_name: str | None = None, details: dict | None = None): + """Initialize duplicate card error. + + Args: + message: Description of the duplicate processing failure + card_name: Name of the card causing the duplication error + details: Additional context about the error + """ + if card_name: + details = details or {} + details['card_name'] = card_name + super().__init__(message, code="DUPLICATE_CARD", details=details) + +class CardTypeCountError(LibraryOrganizationError): + """Raised when there are issues counting cards of specific types. + + This exception is used when card type counting operations fail or + produce invalid results during library organization. + + Examples: + >>> raise CardTypeCountError( + ... "Invalid creature count", + ... "creature", + ... {"expected": 30, "actual": 15} + ... ) + """ + + def __init__(self, message: str, card_type: str, details: dict | None = None): + """Initialize card type count error. + + Args: + message: Description of the counting failure + card_type: The type of card that caused the counting error + details: Additional context about the error + """ + if card_type: + details = details or {} + details['card_type'] = card_type + super().__init__(message, code="CARD_TYPE_COUNT", details=details) + +class ThemeError(DeckBuilderError): + """Base exception class for theme-related errors. + + This exception serves as the base for all theme-related errors in the deck builder, + including theme selection, validation, and weight calculation issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "THEME_ERR", details: dict | None = None): + """Initialize the base theme error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class ThemeSelectionError(ThemeError): + """Raised when theme selection fails or is invalid. + + This exception is used when an invalid theme is selected or when + the theme selection process is canceled by the user. + + Examples: + >>> raise ThemeSelectionError( + ... "Invalid theme selected", + ... "artifacts", + ... {"available_themes": ["tokens", "lifegain", "counters"]} + ... ) + """ + + def __init__(self, message: str, selected_theme: str | None = None, details: dict | None = None): + """Initialize theme selection error. + + Args: + message: Description of the selection failure + selected_theme: The invalid theme that was selected (if any) + details: Additional context about the error + """ + if selected_theme: + details = details or {} + details['selected_theme'] = selected_theme + super().__init__(message, code="THEME_SELECT", details=details) + +class ThemeWeightError(ThemeError): + """Raised when theme weight calculation fails. + + This exception is used when there are errors in calculating or validating + theme weights during the theme selection process. + """ + + def __init__(self, message: str, theme: str | None = None, details: dict | None = None): + """Initialize theme weight error. + + Args: + message: Description of the weight calculation failure + theme: The theme that caused the weight calculation error + details: Additional context about the error + """ + if theme: + details = details or {} + details['theme'] = theme + super().__init__(message, code="THEME_WEIGHT", details=details) + +class IdealDeterminationError(DeckBuilderError): + """Raised when there are issues determining deck composition ideals. + + This exception is used when the determine_ideals() method encounters problems + calculating or validating deck composition ratios and requirements. + + Examples: + >>> raise IdealDeterminationError( + ... "Invalid land ratio calculation", + ... {"calculated_ratio": 0.1, "min_allowed": 0.3} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize ideal determination error. + + Args: + message: Description of the ideal calculation failure + details: Additional context about the error + """ + super().__init__(message, code="IDEAL_ERR", details=details) + +class PriceConfigurationError(DeckBuilderError): + """Raised when there are issues configuring price settings. + + This exception is used when price-related configuration in determine_ideals() + is invalid or cannot be properly applied. + + Examples: + >>> raise PriceConfigurationError( + ... "Invalid budget allocation", + ... {"total_budget": 100, "min_card_price": 200} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize price configuration error. + + Args: + message: Description of the price configuration failure + details: Additional context about the error + """ + super().__init__(message, code="PRICE_CONFIG_ERR", details=details) + +class BasicLandError(DeckBuilderError): + """Base exception class for basic land related errors. + + This exception serves as the base for all basic land related errors in the deck builder, + including land distribution, snow-covered lands, and colorless deck handling. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "BASIC_LAND_ERR", details: dict | None = None): + """Initialize the basic land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + """ + super().__init__(message, code=code, details=details) + +class BasicLandCountError(BasicLandError): + """Raised when there are issues with counting basic lands. + + This exception is used when basic land counting operations fail or + produce unexpected results during deck validation or analysis. + + Examples: + >>> raise BasicLandCountError( + ... "Failed to count basic lands in deck", + ... {"expected_count": 35, "actual_count": 0} + ... ) + + >>> raise BasicLandCountError( + ... "Invalid basic land count for color distribution", + ... {"color": "U", "count": -1} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize basic land count error. + + Args: + message: Description of the counting operation failure + details: Additional context about the error + """ + super().__init__(message, code="BASIC_LAND_COUNT_ERR", details=details) + +class StapleLandError(DeckBuilderError): + """Raised when there are issues adding staple lands. + ``` + This exception is used when there are problems adding staple lands + to the deck, such as invalid land types, missing lands, or + incompatible color requirements. + + Examples: + >>> raise StapleLandError( + ... "Failed to add required shock lands", + ... {"missing_lands": ["Steam Vents", "Breeding Pool"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize staple land error. + + Args: + message: Description of the staple land operation failure + details: Additional context about the error + """ + super().__init__( + message, + code="STAPLE_LAND_ERR", + details=details + ) + +class LandDistributionError(BasicLandError): + """Raised when there are issues with basic land distribution. + + This exception is used when there are problems distributing basic lands + across colors, such as invalid color ratios or unsupported color combinations. + + Examples: + >>> raise LandDistributionError( + ... "Invalid land distribution for colorless deck", + ... {"colors": [], "requested_lands": 40} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize land distribution error. + + Args: + message: Description of the land distribution failure + details: Additional context about the error + """ + super().__init__(message, code="LAND_DIST_ERR", details=details) + +class FetchLandError(DeckBuilderError): + """Base exception class for fetch land-related errors. + + This exception serves as the base for all fetch land-related errors in the deck builder, + including validation errors, selection errors, and fetch land processing issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "FETCH_ERR", details: dict | None = None): + """Initialize the base fetch land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class KindredLandError(DeckBuilderError): + """Base exception class for Kindred land-related errors. + + This exception serves as the base for all Kindred land-related errors in the deck builder, + including validation errors, selection errors, and Kindred land processing issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "KINDRED_ERR", details: dict | None = None): + """Initialize the base Kindred land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class KindredLandValidationError(KindredLandError): + """Raised when Kindred land validation fails. + + This exception is used when there are issues validating Kindred land inputs, + such as invalid land types, unsupported creature types, or color identity mismatches. + + Examples: + >>> raise KindredLandValidationError( + ... "Invalid Kindred land type", + ... {"land_type": "Non-Kindred Land", "creature_type": "Elf"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize Kindred land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="KINDRED_VALID_ERR", details=details) + +class KindredLandSelectionError(KindredLandError): + """Raised when Kindred land selection fails. + + This exception is used when there are issues selecting appropriate Kindred lands, + such as no valid lands found, creature type mismatches, or price constraints. + + Examples: + >>> raise KindredLandSelectionError( + ... "No valid Kindred lands found for creature type", + ... {"creature_type": "Dragon", "attempted_lands": ["Cavern of Souls"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize Kindred land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="KINDRED_SELECT_ERR", details=details) + +class FetchLandValidationError(FetchLandError): + """Raised when fetch land validation fails. + + This exception is used when there are issues validating fetch land inputs, + such as invalid fetch count, unsupported colors, or invalid fetch land types. + + Examples: + >>> raise FetchLandValidationError( + ... "Invalid fetch land count", + ... {"requested": 10, "maximum": 9} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize fetch land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="FETCH_VALID_ERR", details=details) + +class FetchLandSelectionError(FetchLandError): + """Raised when fetch land selection fails. + + This exception is used when there are issues selecting appropriate fetch lands, + such as no valid fetches found, color identity mismatches, or price constraints. + + Examples: + >>> raise FetchLandSelectionError( + ... "No valid fetch lands found for color identity", + ... {"colors": ["W", "U"], "attempted_fetches": ["Flooded Strand", "Polluted Delta"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize fetch land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="FETCH_SELECT_ERR", details=details) \ No newline at end of file diff --git a/input_handler.py b/input_handler.py index ee9c083..47fbbb6 100644 --- a/input_handler.py +++ b/input_handler.py @@ -190,19 +190,19 @@ class InputHandler: question = [ inquirer.Text( 'text', - message=message or 'Enter 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 result + return str(result) elif question_type == 'Price': question = [ inquirer.Text( 'price', - message=message or 'Enter price (or "unlimited")', + message=f'{message}' or 'Enter price (or "unlimited")', default=str(default_value or DEFAULT_MAX_CARD_PRICE) ) ] @@ -210,12 +210,13 @@ class InputHandler: price, is_unlimited = self.validate_price(result) if not is_unlimited: self.validate_price_threshold(price) - return price + return float(price) + elif question_type == 'Number': question = [ inquirer.Text( 'number', - message=message or 'Enter number', + message=f'{message}' or 'Enter number', default=str(default_value or self.default_number) ) ] @@ -226,7 +227,7 @@ class InputHandler: question = [ inquirer.Confirm( 'confirm', - message=message or 'Confirm?', + message=f'{message}' or 'Confirm?', default=default_value if default_value is not None else self.default_confirm ) ] @@ -239,7 +240,7 @@ class InputHandler: question = [ inquirer.List( 'selection', - message=message or 'Select an option', + message=f'{message}' or 'Select an option', choices=choices_list, carousel=True ) diff --git a/settings.py b/settings.py index 2ef5348..8db80d7 100644 --- a/settings.py +++ b/settings.py @@ -1,12 +1,14 @@ -from typing import Dict, List, Optional, Final, Tuple, Pattern, Union +from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable import ast # Commander selection configuration +# Format string for displaying duplicate cards in deck lists +DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}' + COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv' 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 @@ -27,6 +29,148 @@ 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 + +# 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' +} + +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 @@ -555,11 +699,31 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [ 'target player\'s library', 'that player\'s library' ] - - CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', '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 TYPE_TAG_MAPPING = { 'Artifact': ['Artifacts Matter'], @@ -810,6 +974,21 @@ REQUIRED_COLUMNS: List[str] = [ 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side' ] +# Constants for theme weight management +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 = [ 'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink', 'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones', @@ -976,9 +1155,9 @@ DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, boo # Card category validation rules CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { - 'power': {'type': ('str', 'int', 'float'), 'required': True}, - 'toughness': {'type': ('str', 'int', 'float'), 'required': True}, - 'creatureTypes': {'type': 'list', 'required': True} + '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]]]] = { From 8936fa347f6e0a3753ac2a11d7df972140e321b4 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Thu, 16 Jan 2025 11:55:12 -0800 Subject: [PATCH 5/6] Finshed refactoring land addtiions, all that's left is adding cards by theme and other tags --- builder_utils.py | 497 ++++++++++++++++++++++++++++++++++++++++++-- deck_builder.py | 526 ++++++++++++++++++++++++++++------------------- exceptions.py | 229 ++++++++++++++++++++- settings.py | 59 ++++++ 4 files changed, 1089 insertions(+), 222 deletions(-) diff --git a/builder_utils.py b/builder_utils.py index ea33dd3..7cdf45a 100644 --- a/builder_utils.py +++ b/builder_utils.py @@ -34,6 +34,9 @@ from settings import ( COLOR_TO_BASIC_LAND, SNOW_BASIC_LAND_MAPPING, KINDRED_STAPLE_LANDS, + DUAL_LAND_TYPE_MAP, + MANA_COLORS, + MANA_PIP_PATTERNS ) from exceptions import ( DeckBuilderError, @@ -46,6 +49,7 @@ from exceptions import ( FetchLandValidationError, KindredLandSelectionError, KindredLandValidationError, + LandRemovalError, ThemeSelectionError, ThemeWeightError, CardTypeCountError @@ -584,7 +588,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st # Process each allowed duplicate card for card_name in duplicate_lists: # Find all instances of the card - card_mask = processed_library['name'] == card_name + card_mask = processed_library['Card Name'] == card_name card_count = card_mask.sum() if card_count > 1: @@ -592,7 +596,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st first_instance = processed_library[card_mask].iloc[0] processed_library = processed_library[~card_mask] - first_instance['name'] = DUPLICATE_CARD_FORMAT.format( + first_instance['Card Name'] = DUPLICATE_CARD_FORMAT.format( card_name=card_name, count=card_count ) @@ -624,7 +628,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di for card_type in card_types: # Use pandas str.contains() for efficient type matching # Case-insensitive matching with na=False to handle missing values - type_mask = card_library['type'].str.contains( + type_mask = card_library['Card Type'].str.contains( card_type, case=False, na=False @@ -633,6 +637,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di return type_counts except Exception as e: + print(card_type) logger.error(f"Error counting cards by type: {e}") raise CardTypeCountError(f"Failed to count cards by type: {str(e)}") @@ -838,7 +843,6 @@ def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] = fetch for fetch in available_fetches if price_checker.get_card_price(fetch) <= max_price * 1.1 ] - return available_fetches def select_fetch_lands(available_fetches: List[str], count: int, @@ -915,8 +919,7 @@ def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: Li f"Failed to validate Kindred land {land_name}", {"error": str(e), "tags": commander_tags, "colors": colors} ) - -def get_available_kindred_lands(colors: List[str], commander_tags: List[str], +def get_available_kindred_lands(land_df: pd.DataFrame, colors: List[str], commander_tags: List[str], price_checker: Optional[Any] = None, max_price: Optional[float] = None) -> List[str]: """Get list of Kindred lands available for the deck's colors and themes. @@ -934,8 +937,35 @@ def get_available_kindred_lands(colors: List[str], commander_tags: List[str], >>> get_available_kindred_lands(['G'], ['Elf Kindred']) ['Cavern of Souls', 'Path of Ancestry', ...] """ - # Start with staple Kindred lands - available_lands = [land['name'] for land in KINDRED_STAPLE_LANDS] + # Only proceed if deck has tribal themes + if not any('Kindred' in tag for tag in commander_tags): + return [] + + available_lands = [] + + # Add staple Kindred lands first + available_lands.extend([land['name'] for land in KINDRED_STAPLE_LANDS + if validate_kindred_lands(land['name'], commander_tags, colors)]) + + # Extract creature types from Kindred themes + creature_types = [tag.replace(' Kindred', '') + for tag in commander_tags + if 'Kindred' in tag] + + # Find lands specific to each creature type + for creature_type in creature_types: + logging.info(f'Searching for {creature_type}-specific lands') + + # Filter lands by creature type mentions in text or type + type_specific = land_df[ + land_df['text'].notna() & + (land_df['text'].str.contains(creature_type, case=False) | + land_df['type'].str.contains(creature_type, case=False)) + ] + + # Add any found type-specific lands + if not type_specific.empty: + available_lands.extend(type_specific['name'].tolist()) # Filter by price if price checking is enabled if price_checker and max_price: @@ -946,14 +976,12 @@ def get_available_kindred_lands(colors: List[str], commander_tags: List[str], return available_lands -def select_kindred_lands(available_lands: List[str], count: int, +def select_kindred_lands(available_lands: List[str], count: int = None, allow_duplicates: bool = False) -> List[str]: """Select Kindred lands from the available pool. Args: available_lands: List of available Kindred lands - count: Number of Kindred lands to select - allow_duplicates: Whether to allow duplicate selections Returns: List of selected Kindred land names @@ -962,11 +990,10 @@ def select_kindred_lands(available_lands: List[str], count: int, KindredLandSelectionError: If unable to select required number of lands Example: - >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'], 2) - ['Path of Ancestry', 'Cavern of Souls'] + >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry']) + ['Cavern of Souls', 'Path of Ancestry'] """ import random - if not available_lands: raise KindredLandSelectionError( "No Kindred lands available to select from", @@ -1001,4 +1028,444 @@ def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame, DataFrame without 'Cavern of Souls' in the available lands """ updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] - return updated_land_df \ No newline at end of file + return updated_land_df + +def validate_dual_lands(color_pairs: List[str], use_snow: bool = False) -> bool: + """Validate if dual lands should be added based on deck configuration. + + Args: + color_pairs: List of color pair combinations (e.g., ['azorius', 'orzhov']) + use_snow: Whether to use snow-covered lands + + Returns: + bool: True if dual lands should be added, False otherwise + + Example: + >>> validate_dual_lands(['azorius', 'orzhov'], False) + True + """ + if not color_pairs: + return False + + # Validate color pairs against DUAL_LAND_TYPE_MAP + return len(color_pairs) > 0 + +def get_available_dual_lands(land_df: pd.DataFrame, color_pairs: List[str], + use_snow: bool = False) -> pd.DataFrame: + """Get available dual lands based on color pairs and snow preference. + + Args: + land_df: DataFrame containing available lands + color_pairs: List of color pair combinations + use_snow: Whether to use snow-covered lands + + Returns: + DataFrame containing available dual lands + + Example: + >>> get_available_dual_lands(land_df, ['azorius'], False) + DataFrame with azorius dual lands + """ + # Create type filters based on color pairs + type_filters = color_pairs + + # Filter lands + if type_filters: + return land_df[land_df['type'].isin(type_filters)].copy() + return pd.DataFrame() + +def select_dual_lands(dual_df: pd.DataFrame, price_checker: Optional[Any] = None, + max_price: Optional[float] = None) -> List[Dict[str, Any]]: + """Select appropriate dual lands from available pool. + + Args: + dual_df: DataFrame of available dual lands + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected dual land dictionaries + + Example: + >>> select_dual_lands(dual_df, price_checker, 20.0) + [{'name': 'Hallowed Fountain', 'type': 'Land — Plains Island', ...}] + """ + if dual_df.empty: + return [] + + # Sort by EDHREC rank + dual_df.sort_values(by='edhrecRank', inplace=True) + + # Convert to list of card dictionaries + selected_lands = [] + for _, row in dual_df.iterrows(): + card = { + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'] + } + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(card['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {card['name']}: {e}") + continue + + selected_lands.append(card) + + return selected_lands + +def process_dual_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, + land_df: pd.DataFrame) -> pd.DataFrame: + """Update land DataFrame after adding dual lands. + + Args: + lands_to_add: List of dual lands to be added + card_library: Current deck library + land_df: DataFrame of available lands + + Returns: + Updated land DataFrame + + Example: + >>> process_dual_lands(dual_lands, card_library, land_df) + Updated DataFrame without added dual lands + """ + lands_to_remove = set(land['name'] for land in lands_to_add) + return land_df[~land_df['name'].isin(lands_to_remove)] + +def validate_triple_lands(color_triplets: List[str], use_snow: bool = False) -> bool: + """Validate if triple lands should be added based on deck configuration. + + Args: + color_triplets: List of color triplet combinations (e.g., ['esper', 'bant']) + use_snow: Whether to use snow-covered lands + + Returns: + bool: True if triple lands should be added, False otherwise + + Example: + >>> validate_triple_lands(['esper', 'bant'], False) + True + """ + if not color_triplets: + return False + + # Validate color triplets + return len(color_triplets) > 0 + +def get_available_triple_lands(land_df: pd.DataFrame, color_triplets: List[str], + use_snow: bool = False) -> pd.DataFrame: + """Get available triple lands based on color triplets and snow preference. + + Args: + land_df: DataFrame containing available lands + color_triplets: List of color triplet combinations + use_snow: Whether to use snow-covered lands + + Returns: + DataFrame containing available triple lands + + Example: + >>> get_available_triple_lands(land_df, ['esper'], False) + DataFrame with esper triple lands + """ + # Create type filters based on color triplets + type_filters = color_triplets + + # Filter lands + if type_filters: + return land_df[land_df['type'].isin(type_filters)].copy() + return pd.DataFrame() + +def select_triple_lands(triple_df: pd.DataFrame, price_checker: Optional[Any] = None, + max_price: Optional[float] = None) -> List[Dict[str, Any]]: + """Select appropriate triple lands from available pool. + + Args: + triple_df: DataFrame of available triple lands + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected triple land dictionaries + + Example: + >>> select_triple_lands(triple_df, price_checker, 20.0) + [{'name': 'Raffine's Tower', 'type': 'Land — Plains Island Swamp', ...}] + """ + if triple_df.empty: + return [] + + # Sort by EDHREC rank + triple_df.sort_values(by='edhrecRank', inplace=True) + + # Convert to list of card dictionaries + selected_lands = [] + for _, row in triple_df.iterrows(): + card = { + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'] + } + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(card['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {card['name']}: {e}") + continue + + selected_lands.append(card) + + return selected_lands + +def process_triple_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, + land_df: pd.DataFrame) -> pd.DataFrame: + """Update land DataFrame after adding triple lands. + + Args: + lands_to_add: List of triple lands to be added + card_library: Current deck library + land_df: DataFrame of available lands + + Returns: + Updated land DataFrame + + Example: + >>> process_triple_lands(triple_lands, card_library, land_df) + Updated DataFrame without added triple lands + """ + lands_to_remove = set(land['name'] for land in lands_to_add) + return land_df[~land_df['name'].isin(lands_to_remove)] + +def get_available_misc_lands(land_df: pd.DataFrame, max_pool_size: int) -> List[Dict[str, Any]]: + """Retrieve the top N lands from land_df for miscellaneous land selection. + + Args: + land_df: DataFrame containing available lands + max_pool_size: Maximum number of lands to include in the pool + + Returns: + List of dictionaries containing land information + + Example: + >>> get_available_misc_lands(land_df, 100) + [{'name': 'Command Tower', 'type': 'Land', ...}, ...] + """ + try: + # Take top N lands by EDHREC rank + top_lands = land_df.head(max_pool_size).copy() + + # Convert to list of dictionaries + available_lands = [ + { + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'] + } + for _, row in top_lands.iterrows() + ] + + return available_lands + + except Exception as e: + logger.error(f"Error getting available misc lands: {e}") + return [] + +def select_misc_lands(available_lands: List[Dict[str, Any]], min_count: int, max_count: int, + price_checker: Optional[PriceChecker] = None, + max_price: Optional[float] = None) -> List[Dict[str, Any]]: + """Randomly select a number of lands between min_count and max_count. + + Args: + available_lands: List of available lands to select from + min_count: Minimum number of lands to select + max_count: Maximum number of lands to select + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected land dictionaries + + Example: + >>> select_misc_lands(available_lands, 5, 10) + [{'name': 'Command Tower', 'type': 'Land', ...}, ...] + """ + import random + + if not available_lands: + return [] + + # Randomly determine number of lands to select + target_count = random.randint(min_count, max_count) + selected_lands = [] + + # Create a copy of available lands to avoid modifying the original + land_pool = available_lands.copy() + + while land_pool and len(selected_lands) < target_count: + # Randomly select a land + land = random.choice(land_pool) + land_pool.remove(land) + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(land['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {land['name']}: {e}") + continue + + selected_lands.append(land) + + return selected_lands + + +def filter_removable_lands(card_library: pd.DataFrame, protected_lands: List[str]) -> pd.DataFrame: + """Filter the card library to get lands that can be removed. + + Args: + card_library: DataFrame containing all cards in the deck + protected_lands: List of land names that cannot be removed + + Returns: + DataFrame containing only removable lands + + Raises: + LandRemovalError: If no removable lands are found + DataFrameValidationError: If card_library validation fails + """ + try: + # Validate input DataFrame + if card_library.empty: + raise EmptyDataFrameError("filter_removable_lands") + + # Filter for lands only + lands_df = card_library[card_library['Card Type'].str.contains('Land', case=False, na=False)].copy() + + # Remove protected lands + removable_lands = lands_df[~lands_df['Card Name'].isin(protected_lands)] + + if removable_lands.empty: + raise LandRemovalError( + "No removable lands found in deck", + {"protected_lands": protected_lands} + ) + + logger.debug(f"Found {len(removable_lands)} removable lands") + return removable_lands + + except Exception as e: + logger.error(f"Error filtering removable lands: {e}") + raise + +def select_land_for_removal(filtered_lands: pd.DataFrame) -> Tuple[int, str]: + """Randomly select a land for removal from filtered lands. + + Args: + filtered_lands: DataFrame containing only removable lands + + Returns: + Tuple containing (index in original DataFrame, name of selected land) + + Raises: + LandRemovalError: If filtered_lands is empty + DataFrameValidationError: If filtered_lands validation fails + """ + try: + if filtered_lands.empty: + raise LandRemovalError( + "No lands available for removal", + {"filtered_lands_size": len(filtered_lands)} + ) + + # Randomly select a land + selected_land = filtered_lands.sample(n=1).iloc[0] + index = selected_land.name + land_name = selected_land['Card Name'] + + logger.info(f"Selected land for removal: {land_name}") + return index, land_name + + except Exception as e: + logger.error(f"Error selecting land for removal: {e}") + raise + +def count_color_pips(mana_costs: pd.Series, color: str) -> int: + """Count the number of colored mana pips of a specific color in mana costs. + + Args: + mana_costs: Series of mana cost strings to analyze + color: Color to count pips for (W, U, B, R, or G) + + Returns: + Total number of pips of the specified color + + Example: + >>> mana_costs = pd.Series(['{2}{W}{W}', '{W}{U}', '{B}{R}']) + >>> count_color_pips(mana_costs, 'W') + 3 + """ + if not isinstance(mana_costs, pd.Series): + raise TypeError("mana_costs must be a pandas Series") + + if color not in MANA_COLORS: + raise ValueError(f"Invalid color: {color}. Must be one of {MANA_COLORS}") + + pattern = MANA_PIP_PATTERNS[color] + + # Count occurrences of the pattern in non-null mana costs + pip_counts = mana_costs.fillna('').str.count(pattern) + + return int(pip_counts.sum()) + +def calculate_pip_percentages(pip_counts: Dict[str, int]) -> Dict[str, float]: + """Calculate the percentage distribution of mana pips for each color. + + Args: + pip_counts: Dictionary mapping colors to their pip counts + + Returns: + Dictionary mapping colors to their percentage of total pips (0-100) + + Example: + >>> pip_counts = {'W': 10, 'U': 5, 'B': 5, 'R': 0, 'G': 0} + >>> calculate_pip_percentages(pip_counts) + {'W': 50.0, 'U': 25.0, 'B': 25.0, 'R': 0.0, 'G': 0.0} + + Note: + If total pip count is 0, returns 0% for all colors to avoid division by zero. + """ + if not isinstance(pip_counts, dict): + raise TypeError("pip_counts must be a dictionary") + + # Validate colors + invalid_colors = set(pip_counts.keys()) - set(MANA_COLORS) + if invalid_colors: + raise ValueError(f"Invalid colors in pip_counts: {invalid_colors}") + + total_pips = sum(pip_counts.values()) + + if total_pips == 0: + return {color: 0.0 for color in MANA_COLORS} + + percentages = {} + for color in MANA_COLORS: + count = pip_counts.get(color, 0) + percentage = (count / total_pips) * 100 + percentages[color] = round(percentage, 1) + + return percentages diff --git a/deck_builder.py b/deck_builder.py index a9551c6..6dbeb15 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -21,11 +21,13 @@ from settings import ( COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, - COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, + COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP, CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, - STAPLE_LAND_CONDITIONS + STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT, + MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS, + MANA_COLORS, MANA_PIP_PATTERNS ) -import builder_utils +import builder_utils import setup_utils from setup import determine_commanders from input_handler import InputHandler @@ -52,6 +54,7 @@ from exceptions import ( IdealDeterminationError, InvalidNumberError, InvalidQuestionTypeError, + LandRemovalError, LibraryOrganizationError, LibrarySortError, MaxAttemptsError, @@ -63,7 +66,8 @@ from exceptions import ( ThemeSelectionError, ThemeWeightError, StapleLandError, - StapleLandError + StapleLandError, + ManaPipError ) from type_definitions import ( CardDict, @@ -134,7 +138,9 @@ class DeckBuilder: 'Card Type': pd.Series(dtype='str'), 'Mana Cost': pd.Series(dtype='str'), 'Mana Value': pd.Series(dtype='int'), - 'Commander': pd.Series(dtype='bool') + 'Creature Types': pd.Series(dtype='object'), + 'Themes': pd.Series(dtype='object'), + 'Commander': pd.Series(dtype='bool'), }) # Initialize component dataframes @@ -461,7 +467,8 @@ class DeckBuilder: 'CMC': 0.0 } self.add_card(self.commander, self.commander_type, - self.commander_mana_cost, self.commander_mana_value, True) + self.commander_mana_cost, self.commander_mana_value, + self.creature_types, self.commander_tags, True) def _initialize_deck_building(self) -> None: """Initialize deck building process. @@ -869,6 +876,7 @@ class DeckBuilder: logger.error(f"Error in DataFrame setup: {e}") raise + # Theme selection def determine_themes(self) -> None: """Determine and set up themes for the deck building process. @@ -1046,7 +1054,8 @@ class DeckBuilder: self.hidden_weight = self.weights['hidden'] else: continue - + + # Setting ideals def determine_ideals(self): """Determine ideal card counts and price settings for the deck. @@ -1099,13 +1108,16 @@ class DeckBuilder: logger.error(f"Error in determine_ideals: {e}") raise - def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, is_commander: bool = False) -> None: + # Adding card to library + def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None: """Add a card to the deck library with price checking if enabled. Args: card (str): Name of the card to add card_type (str): Type of the card (e.g., 'Creature', 'Instant') mana_cost (str): Mana cost string representation mana_value (int): Converted mana cost/mana value + creature_types (list): List of creature types in the card (if any) + themes (list): List of themes the card has is_commander (bool, optional): Whether this card is the commander. Defaults to False. Returns: @@ -1135,13 +1147,14 @@ class DeckBuilder: return # Create card entry - card_entry = [card, card_type, mana_cost, mana_value, is_commander] + card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander] # Add to library self.card_library.loc[len(self.card_library)] = card_entry logger.debug(f"Added {card} to deck library") + # Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry def organize_library(self): """Organize and count cards in the library by their types. @@ -1296,6 +1309,7 @@ class DeckBuilder: logger.error(f"Error processing duplicate cards: {e}") raise + # Land Management def add_lands(self): """ Add lands to the deck based on ideal count and deck requirements. @@ -1336,6 +1350,7 @@ class DeckBuilder: # Adjust to ideal land count self.check_basics() + print() logger.info('Adjusting total land count to match ideal count...') self.organize_library() @@ -1546,6 +1561,7 @@ class DeckBuilder: # Get available Kindred lands based on themes and budget max_price = self.max_card_price if hasattr(self, 'max_card_price') else None available_lands = builder_utils.get_available_kindred_lands( + self.land_df, self.colors, self.commander_tags, self.price_checker if use_scrython else None, @@ -1554,7 +1570,8 @@ class DeckBuilder: # Select Kindred lands selected_lands = builder_utils.select_kindred_lands( - available_lands + available_lands, + len(available_lands) ) # Add selected Kindred lands to deck @@ -1575,158 +1592,181 @@ class DeckBuilder: raise def add_dual_lands(self): - # Determine dual-color lands available + """Add dual lands to the deck based on color identity and user preference. - # Determine if using the dual-type lands - print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) - color_filter = [] - color_dict = { - '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' - } - - if choice: - for key in color_dict: - if key in self.files_to_load: - color_filter.extend([f'Land — {color_dict[key]}', f'Snow Land — {color_dict[key]}']) - - dual_df = self.land_df[self.land_df['type'].isin(color_filter)].copy() - - # Convert to list of card dictionaries - card_pool = [] - for _, row in dual_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - card_pool.append(card) - - lands_to_remove = [] - for card in card_pool: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) - lands_to_remove.append(card['name']) + This method handles the addition of dual lands by: + 1. Validating if dual lands should be added + 2. Getting available dual lands based on deck colors + 3. Selecting appropriate dual lands + 4. Adding selected lands to the deck + 5. Updating the land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(card_pool)} Dual-type land cards.') - - if not choice: - logger.info('Skipping adding Dual-type land cards.') - - def add_triple_lands(self): - # Determine if using Triome lands - print('Would you like to include triome lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) - - color_filter = [] - color_dict = { - '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' - } - - if choice: - for key in color_dict: - if key in self.files_to_load: - color_filter.extend([f'Land — {color_dict[key]}']) - - triome_df = self.land_df[self.land_df['type'].isin(color_filter)].copy() - - # Convert to list of card dictionaries - card_pool = [] - for _, row in triome_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - card_pool.append(card) - - lands_to_remove = [] - for card in card_pool: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) - lands_to_remove.append(card['name']) - - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(card_pool)} Triome land cards.') - - if not choice: - logger.info('Skipping adding Triome land cards.') - - def add_misc_lands(self): - """Add additional utility lands that fit the deck's color identity.""" - logger.info('Adding miscellaneous utility lands') - - MIN_MISC_LANDS = 5 - MAX_MISC_LANDS = 15 - MAX_POOL_SIZE = 100 - + The process uses helper functions from builder_utils for modular operation. + """ try: - # Create filtered pool of candidate lands - land_pool = (self.land_df - .head(MAX_POOL_SIZE) - .copy() - .reset_index(drop=True)) + # Check if we should add dual lands + print() + print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') + use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True) - # Convert to card dictionaries - card_pool = [ - { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - for _, row in land_pool.iterrows() - if row['name'] not in self.card_library['Card Name'].values - ] - - if not card_pool: - logger.warning("No eligible misc lands found") + if not use_duals: + logger.info('Skipping adding Dual-type land cards.') return - # Randomly select lands within constraints - target_count = random.randint(MIN_MISC_LANDS, MAX_MISC_LANDS) - cards_to_add = [] + logger.info('Adding Dual-type lands') + # Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load + color_pairs = [] + for key in DUAL_LAND_TYPE_MAP: + if key in self.files_to_load: + color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}']) - while card_pool and len(cards_to_add) < target_count: - card = random.choice(card_pool) - card_pool.remove(card) - - # Check price if enabled - if use_scrython and self.set_max_card_price: - price = self.price_checker.get_card_price(card['name']) - if price > self.max_card_price * 1.1: - continue - - cards_to_add.append(card) + # Validate dual lands for these color pairs + if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags): + logger.info('No valid dual lands available for this color combination.') + return + + # Get available dual lands + dual_df = builder_utils.get_available_dual_lands( + self.land_df, + color_pairs, + 'Snow' in self.commander_tags + ) + + # Select appropriate dual lands + selected_lands = builder_utils.select_dual_lands( + dual_df, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands to deck + for land in selected_lands: + self.add_card(land['name'], land['type'], + land['manaCost'], land['manaValue']) + + # Update land database + self.land_df = builder_utils.process_dual_lands( + selected_lands, + self.card_library, + self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} Dual-type land cards:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding dual lands: {e}") + raise + + def add_triple_lands(self): + """Add triple lands to the deck based on color identity and user preference. + + This method handles the addition of triple lands by: + 1. Validating if triple lands should be added + 2. Getting available triple lands based on deck colors + 3. Selecting appropriate triple lands + 4. Adding selected lands to the deck + 5. Updating the land database + + The process uses helper functions from builder_utils for modular operation. + """ + try: + # Check if we should add triple lands + print() + print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') + use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True) + + if not use_triples: + logger.info('Skipping adding triple lands.') + return + + logger.info('Adding triple lands') + # Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load + color_triplets = [] + for key in TRIPLE_LAND_TYPE_MAP: + if key in self.files_to_load: + color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}']) + + # Validate triple lands for these color triplets + if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags): + logger.info('No valid triple lands available for this color combination.') + return + + # Get available triple lands + triple_df = builder_utils.get_available_triple_lands( + self.land_df, + color_triplets, + 'Snow' in self.commander_tags + ) + + # Select appropriate triple lands + selected_lands = builder_utils.select_triple_lands( + triple_df, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands to deck + for land in selected_lands: + self.add_card(land['name'], land['type'], + land['manaCost'], land['manaValue']) + + # Update land database + self.land_df = builder_utils.process_triple_lands( + selected_lands, + self.card_library, + self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} triple lands:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding triple lands: {e}") + + def add_misc_lands(self): + """Add additional utility lands that fit the deck's color identity. + + This method randomly selects a number of miscellaneous utility lands to add to the deck. + The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT. + Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank. + + The method handles price constraints if price checking is enabled and updates the land + database after adding lands to prevent duplicates. + + Raises: + MiscLandSelectionError: If there are issues selecting appropriate misc lands + """ + print() + logger.info('Adding miscellaneous utility lands') + + try: + # Get available misc lands + available_lands = builder_utils.get_available_misc_lands( + self.land_df, + MISC_LAND_POOL_SIZE + ) + + if not available_lands: + logger.warning("No eligible miscellaneous lands found") + return + + # Select random number of lands + selected_lands = builder_utils.select_misc_lands( + available_lands, + MISC_LAND_MIN_COUNT, + MISC_LAND_MAX_COUNT, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) # Add selected lands lands_to_remove = set() - for card in cards_to_add: + for card in selected_lands: self.add_card(card['name'], card['type'], card['manaCost'], card['manaValue']) lands_to_remove.add(card['name']) @@ -1735,7 +1775,9 @@ class DeckBuilder: self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - logger.info(f'Added {len(cards_to_add)} miscellaneous lands') + logger.info(f'Added {len(selected_lands)} miscellaneous lands:') + for card in selected_lands: + print(card['name']) except Exception as e: logger.error(f"Error adding misc lands: {e}") @@ -1778,7 +1820,7 @@ class DeckBuilder: count = len(self.card_library[self.card_library['Card Name'] == land]) basic_lands[land] = count self.total_basics += count - + print() logger.info("Basic Land Counts:") for land, count in basic_lands.items(): if count > 0: @@ -1797,6 +1839,7 @@ class DeckBuilder: Args: max_attempts: Maximum number of removal attempts before falling back to non-basics """ + print() logger.info('Land count over ideal count, removing a basic land.') color_to_basic = { @@ -1843,62 +1886,127 @@ class DeckBuilder: self.remove_land() def remove_land(self): - """Remove a random non-basic, non-staple land from the deck.""" - logger.info('Removing a random nonbasic land.') + """Remove a random non-basic, non-staple land from the deck. - # Define basic lands including snow-covered variants - basic_lands = [ - 'Plains', 'Island', 'Swamp', 'Mountain', 'Forest', - 'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp', - 'Snow-Covered Mountain', 'Snow-Covered Forest' - ] + This method attempts to remove a non-protected land from the deck up to + LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable + lands and select a land for removal. - try: - # Filter for non-basic, non-staple lands - library_filter = self.card_library[ - (self.card_library['Card Type'].str.contains('Land')) & - (~self.card_library['Card Name'].isin(basic_lands + self.staples)) - ].copy() + Raises: + LandRemovalError: If no removable lands are found or removal fails + """ + print() + logger.info('Attempting to remove a non-protected land') + attempts = 0 - if len(library_filter) == 0: - logger.warning("No suitable non-basic lands found to remove.") + while attempts < LAND_REMOVAL_MAX_ATTEMPTS: + try: + # Get removable lands + removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples) + + # Select a land for removal + card_index, card_name = builder_utils.select_land_for_removal(removable_lands) + + # Remove the selected land + logger.info(f"Removing {card_name}") + self.card_library.drop(card_index, inplace=True) + self.card_library.reset_index(drop=True, inplace=True) + logger.info("Land removed successfully") return - # Select random land to remove - card_index = np.random.choice(library_filter.index) - card_name = self.card_library.loc[card_index, 'Card Name'] + except LandRemovalError as e: + logger.warning(f"Attempt {attempts + 1} failed: {e}") + attempts += 1 + continue + except Exception as e: + logger.error(f"Unexpected error removing land: {e}") + raise LandRemovalError(f"Failed to remove land: {str(e)}") - logger.info(f"Removing {card_name}") - self.card_library.drop(card_index, inplace=True) - self.card_library.reset_index(drop=True, inplace=True) - logger.info("Card removed successfully.") - - except Exception as e: - logger.error(f"Error removing land: {e}") - logger.warning("Failed to remove land card.") - + # If we reach here, we've exceeded max attempts + raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts") + # Count pips and get average CMC def count_pips(self): - """Count and display the number of colored mana symbols in casting costs using vectorized operations.""" + """Analyze and display the distribution of colored mana symbols (pips) in card casting costs. + + This method processes the mana costs of all cards in the deck to: + 1. Count the number of colored mana symbols for each color + 2. Calculate the percentage distribution of colors + 3. Log detailed pip distribution information + + The analysis uses helper functions from builder_utils for consistent counting + and percentage calculations. Results are logged with detailed breakdowns + of pip counts and distributions. + + Dependencies: + - MANA_COLORS from settings.py for color iteration + - builder_utils.count_color_pips() for counting pips + - builder_utils.calculate_pip_percentages() for distribution calculation + + Returns: + None + + Raises: + ManaPipError: If there are issues with: + - Counting pips for specific colors + - Calculating pip percentages + - Unexpected errors during analysis + + Logs: + - Warning if no colored mana symbols are found + - Info with detailed pip distribution and percentages + - Error details if analysis fails + """ + print() logger.info('Analyzing color pip distribution...') - # Define colors to check - colors = ['W', 'U', 'B', 'R', 'G'] - - # Use vectorized string operations - mana_costs = self.card_library['Mana Cost'].dropna() - pip_counts = {color: mana_costs.str.count(color).sum() for color in colors} - - total_pips = sum(pip_counts.values()) - if total_pips == 0: - logger.error("No colored mana symbols found in casting costs.") - return - - logger.info("\nColor Pip Distribution:") - for color, count in pip_counts.items(): - if count > 0: - percentage = (count / total_pips) * 100 - print(f"{color}: {count} pips ({percentage:.1f}%)") - logger.info(f"Total colored pips: {total_pips}\n") + try: + # Get mana costs from card library + mana_costs = self.card_library['Mana Cost'].dropna() + + # Count pips for each color using helper function + pip_counts = {} + for color in MANA_COLORS: + try: + pip_counts[color] = builder_utils.count_color_pips(mana_costs, color) + except (TypeError, ValueError) as e: + raise ManaPipError( + f"Error counting {color} pips", + {"color": color, "error": str(e)} + ) + + # Calculate percentages using helper function + try: + percentages = builder_utils.calculate_pip_percentages(pip_counts) + except (TypeError, ValueError) as e: + raise ManaPipError( + "Error calculating pip percentages", + {"error": str(e)} + ) + + # Log detailed pip distribution + total_pips = sum(pip_counts.values()) + if total_pips == 0: + logger.warning("No colored mana symbols found in casting costs") + return + + logger.info("Color Pip Distribution:") + for color in MANA_COLORS: + count = pip_counts[color] + if count > 0: + percentage = percentages[color] + print(f"{color}: {count} pips ({percentage:.1f}%)") + print() + logger.info(f"Total colored pips: {total_pips}") + # Filter out zero percentages + non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0} + logger.info(f"Distribution ratios: {non_zero_percentages}\n") + + except ManaPipError as e: + logger.error(f"Mana pip analysis failed: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in pip analysis: {e}") + raise ManaPipError("Failed to analyze mana pips", {"error": str(e)}) def get_cmc(self): """Calculate average converted mana cost of non-land cards.""" @@ -1947,7 +2055,9 @@ class DeckBuilder: 'name': row['name'], 'type': row['type'], 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] + 'manaValue': row['manaValue'], + 'creatureTypes': row['creatureTypes'], + 'themeTags': row['themeTags'] } for _, row in tag_df.iterrows() ] @@ -1990,7 +2100,8 @@ class DeckBuilder: # Add selected cards to library for card in cards_to_add: self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) + card['manaCost'], card['manaValue'], + card['creatureTypes'], card['themeTags']) card_pool_names = [item['name'] for item in card_pool] self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] @@ -2017,7 +2128,9 @@ class DeckBuilder: 'name': row['name'], 'type': row['type'], 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] + 'manaValue': row['manaValue'], + 'creatureTypes': row['creatureTypes'], + 'themeTags': row['themeTags'] } for _, row in tag_df.iterrows() ] @@ -2047,8 +2160,9 @@ class DeckBuilder: # Add selected cards to library for card in cards_to_add: if len(self.card_library) < 100: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) + self.add_card(card['name'], card['type'], + card['manaCost'], card['manaValue'], + card['creatureTypes'], card['themeTags']) else: continue diff --git a/exceptions.py b/exceptions.py index 7b8aa57..77b7a60 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1051,4 +1051,231 @@ class FetchLandSelectionError(FetchLandError): message: Description of the selection failure details: Additional context about the error """ - super().__init__(message, code="FETCH_SELECT_ERR", details=details) \ No newline at end of file + super().__init__(message, code="FETCH_SELECT_ERR", details=details) + +class DualLandError(DeckBuilderError): + """Base exception class for dual land-related errors. + + This exception serves as the base for all dual land-related errors in the deck builder, + including validation errors, selection errors, and dual land processing issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "DUAL_ERR", details: dict | None = None): + """Initialize the base dual land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class DualLandValidationError(DualLandError): + """Raised when dual land validation fails. + + This exception is used when there are issues validating dual land inputs, + such as invalid dual land types, color identity mismatches, or budget constraints. + + Examples: + >>> raise DualLandValidationError( + ... "Invalid dual land type", + ... {"land_type": "Not a dual land", "colors": ["W", "U"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize dual land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="DUAL_VALID_ERR", details=details) + +class DualLandSelectionError(DualLandError): + """Raised when dual land selection fails. + + This exception is used when there are issues selecting appropriate dual lands, + such as no valid duals found, color identity mismatches, or price constraints. + + Examples: + >>> raise DualLandSelectionError( + ... "No valid dual lands found for color identity", + ... {"colors": ["W", "U"], "attempted_duals": ["Tundra", "Hallowed Fountain"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize dual land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="DUAL_SELECT_ERR", details=details) + +class TripleLandError(DeckBuilderError): + """Base exception class for triple land-related errors. + + This exception serves as the base for all triple land-related errors in the deck builder, + including validation errors, selection errors, and triple land processing issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "TRIPLE_ERR", details: dict | None = None): + """Initialize the base triple land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class TripleLandValidationError(TripleLandError): + """Raised when triple land validation fails. + + This exception is used when there are issues validating triple land inputs, + such as invalid triple land types, color identity mismatches, or budget constraints. + + Examples: + >>> raise TripleLandValidationError( + ... "Invalid triple land type", + ... {"land_type": "Not a triple land", "colors": ["W", "U", "B"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize triple land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="TRIPLE_VALID_ERR", details=details) + +class TripleLandSelectionError(TripleLandError): + """Raised when triple land selection fails. + + This exception is used when there are issues selecting appropriate triple lands, + such as no valid triples found, color identity mismatches, or price constraints. + + Examples: + >>> raise TripleLandSelectionError( + ... "No valid triple lands found for color identity", + ... {"colors": ["W", "U", "B"], "attempted_triples": ["Arcane Sanctum", "Seaside Citadel"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize triple land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="TRIPLE_SELECT_ERR", details=details) + +class MiscLandSelectionError(DeckBuilderError): + """Raised when miscellaneous land selection fails. + + This exception is used when there are issues selecting appropriate miscellaneous lands, + such as insufficient lands in the pool, invalid land types, or selection criteria failures. + + Examples: + >>> raise MiscLandSelectionError( + ... "Insufficient lands in pool for selection", + ... {"available_count": 50, "required_count": 100} + ... ) + + >>> raise MiscLandSelectionError( + ... "Invalid land type in selection pool", + ... {"invalid_lands": ["Not a Land", "Also Not a Land"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize miscellaneous land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__( + message, + code="MISC_LAND_ERR", + details=details + ) + +class LandRemovalError(DeckBuilderError): + """Raised when there are issues removing lands from the deck. + + This exception is used when the land removal process encounters problems, + such as no removable lands available, invalid land selection criteria, + or when removing lands would violate deck construction rules. + + Examples: + >>> raise LandRemovalError( + ... "No removable lands found in deck", + ... {"deck_size": 100, "current_lands": 36, "minimum_lands": 36} + ... ) + + >>> raise LandRemovalError( + ... "Cannot remove required basic lands", + ... {"land_type": "Basic Forest", "current_count": 5, "minimum_required": 5} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize land removal error. + + Args: + message: Description of the land removal failure + details: Additional context about the error + """ + super().__init__( + message, + code="LAND_REMOVE_ERR", + details=details + ) + +class ManaPipError(DeckBuilderError): + """Raised when there are issues analyzing mana pips in the deck. + + This exception is used when there are problems analyzing or calculating + mana pips in the deck, such as invalid mana costs, calculation errors, + or inconsistencies in pip distribution analysis. + + Examples: + >>> raise ManaPipError( + ... "Invalid mana cost format", + ... {"card_name": "Invalid Card", "mana_cost": "Not Valid"} + ... ) + + >>> raise ManaPipError( + ... "Error calculating color pip distribution", + ... {"colors": ["W", "U"], "pip_counts": "invalid"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize mana pip error. + + Args: + message: Description of the mana pip analysis failure + details: Additional context about the error + """ + super().__init__( + message, + code="MANA_PIP_ERR", + details=details + ) \ No newline at end of file diff --git a/settings.py b/settings.py index 8db80d7..3c8d745 100644 --- a/settings.py +++ b/settings.py @@ -37,8 +37,14 @@ 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', @@ -49,6 +55,41 @@ COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { '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', @@ -226,6 +267,12 @@ banned_cards = [# in commander 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 LANDS_MATTER_PATTERNS: Dict[str, List[str]] = { 'land_play': [ @@ -427,6 +474,7 @@ ARISTOCRAT_EXCLUSION_PATTERNS = [ 'from your library', 'into your hand' ] + STAX_TEXT_PATTERNS = [ 'an opponent controls' 'can\'t attack', @@ -699,6 +747,7 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [ 'target player\'s library', 'that player\'s library' ] + CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', 'Kindred', 'Dungeon', 'Battle'] @@ -741,6 +790,15 @@ TYPE_TAG_MAPPING = { CSV_DIRECTORY = 'csv_files' # 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']), @@ -1033,6 +1091,7 @@ AURA_SPECIFIC_CARDS = [ 'Ivy, Gleeful Spellthief', # Copies spells that have single target 'Killian, Ink Duelist', # Targetted spell cost reduction ] + # Equipment-related constants EQUIPMENT_EXCLUSIONS = [ 'Bruenor Battlehammer', # Equipment cost reduction From c628b054ea02bcb7e653aaad57ec937343b9cd57 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Fri, 17 Jan 2025 11:39:27 -0800 Subject: [PATCH 6/6] Finished refactoring and adding docstrings functions. Added module-level docstrings to modules and cleaned up imports --- builder_utils.py | 253 +++++++++++++++---- deck_builder.py | 574 +++++++++++++++++++++++++++++--------------- exceptions.py | 107 +++++++++ settings.py | 14 +- setup.py | 39 ++- setup_utils.py | 2 +- tagger.py | 46 +++- type_definitions.py | 1 + 8 files changed, 784 insertions(+), 252 deletions(-) diff --git a/builder_utils.py b/builder_utils.py index 7cdf45a..6d0151f 100644 --- a/builder_utils.py +++ b/builder_utils.py @@ -1,49 +1,48 @@ -from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union, cast -import pandas as pd -from price_check import PriceChecker -from input_handler import InputHandler -import logging +"""Utility module for MTG deck building operations. + +This module provides utility functions for various deck building operations including: +- DataFrame validation and processing +- Card type counting and validation +- Land selection and management +- Theme processing and weighting +- Price checking integration +- Mana pip analysis + +The module serves as a central collection of helper functions used throughout the +deck building process, handling data validation, card selection, and various +deck composition calculations. + +Key Features: +- DataFrame validation with timeout handling +- Card type counting and categorization +- Land type validation and selection (basic, fetch, dual, etc.) +- Theme tag processing and weighting calculations +- Mana pip counting and color distribution analysis + +Typical usage example: + >>> df = load_commander_data() + >>> validate_dataframe(df, DATAFRAME_VALIDATION_RULES) + >>> process_dataframe_batch(df) + >>> count_cards_by_type(df, ['Creature', 'Instant', 'Sorcery']) +""" + +# Standard library imports import functools +import logging import time +from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast + +# Third-party imports import pandas as pd from fuzzywuzzy import process -from settings import ( - COMMANDER_CSV_PATH, - FUZZY_MATCH_THRESHOLD, - MAX_FUZZY_CHOICES, - COMMANDER_CONVERTERS, - DATAFRAME_VALIDATION_RULES, - DATAFRAME_VALIDATION_TIMEOUT, - DATAFRAME_BATCH_SIZE, - DATAFRAME_TRANSFORM_TIMEOUT, - DATAFRAME_REQUIRED_COLUMNS, - WEIGHT_ADJUSTMENT_FACTORS, - DEFAULT_MAX_DECK_PRICE, - DEFAULT_MAX_CARD_PRICE, - DECK_COMPOSITION_PROMPTS, - DEFAULT_RAMP_COUNT, - DEFAULT_LAND_COUNT, - DEFAULT_BASIC_LAND_COUNT, - DEFAULT_CREATURE_COUNT, - DEFAULT_REMOVAL_COUNT, - DEFAULT_CARD_ADVANTAGE_COUNT, - DEFAULT_PROTECTION_COUNT, - DEFAULT_WIPES_COUNT, - CARD_TYPE_SORT_ORDER, - DUPLICATE_CARD_FORMAT, - COLOR_TO_BASIC_LAND, - SNOW_BASIC_LAND_MAPPING, - KINDRED_STAPLE_LANDS, - DUAL_LAND_TYPE_MAP, - MANA_COLORS, - MANA_PIP_PATTERNS -) + +# Local application imports from exceptions import ( + CSVValidationError, + DataFrameTimeoutError, + DataFrameValidationError, DeckBuilderError, DuplicateCardError, - CSVValidationError, - DataFrameValidationError, - DataFrameTimeoutError, EmptyDataFrameError, FetchLandSelectionError, FetchLandValidationError, @@ -54,6 +53,24 @@ from exceptions import ( ThemeWeightError, CardTypeCountError ) +from input_handler import InputHandler +from price_check import PriceChecker +from settings import ( + CARD_TYPE_SORT_ORDER, COLOR_TO_BASIC_LAND, COMMANDER_CONVERTERS, + COMMANDER_CSV_PATH, DATAFRAME_BATCH_SIZE, + DATAFRAME_REQUIRED_COLUMNS, DATAFRAME_TRANSFORM_TIMEOUT, + DATAFRAME_VALIDATION_RULES, DATAFRAME_VALIDATION_TIMEOUT, + DECK_COMPOSITION_PROMPTS, DEFAULT_BASIC_LAND_COUNT, + DEFAULT_CARD_ADVANTAGE_COUNT, DEFAULT_CREATURE_COUNT, + DEFAULT_LAND_COUNT, DEFAULT_MAX_CARD_PRICE, DEFAULT_MAX_DECK_PRICE, + DEFAULT_PROTECTION_COUNT, DEFAULT_RAMP_COUNT, + DEFAULT_REMOVAL_COUNT, DEFAULT_WIPES_COUNT, DUAL_LAND_TYPE_MAP, + DUPLICATE_CARD_FORMAT, FUZZY_MATCH_THRESHOLD, KINDRED_STAPLE_LANDS, + MANA_COLORS, MANA_PIP_PATTERNS, MAX_FUZZY_CHOICES, + SNOW_BASIC_LAND_MAPPING, THEME_POOL_SIZE_MULTIPLIER, + WEIGHT_ADJUSTMENT_FACTORS +) +from type_definitions import CardLibraryDF, CommanderDF, LandDF logging.basicConfig( level=logging.INFO, @@ -61,7 +78,6 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) - # Type variables for generic functions T = TypeVar('T') DataFrame = TypeVar('DataFrame', bound=pd.DataFrame) @@ -431,7 +447,6 @@ def adjust_theme_weights(primary_theme: str, if total_weight > 0: adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()} - print(adjusted_weights) return adjusted_weights except Exception as e: @@ -1404,6 +1419,162 @@ def select_land_for_removal(filtered_lands: pd.DataFrame) -> Tuple[int, str]: logger.error(f"Error selecting land for removal: {e}") raise +def get_card_theme_overlap(card_tags: List[str], deck_themes: List[str]) -> int: + """Count how many deck themes a given card matches. + + Args: + card_tags: List of tags associated with the card + deck_themes: List of themes in the deck + + Returns: + Number of deck themes that match the card's tags + + Example: + >>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice'] + >>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters'] + >>> get_card_theme_overlap(card_tags, deck_themes) + 2 + """ + if not card_tags or not deck_themes: + return 0 + + # Convert to sets for efficient intersection + card_tag_set = set(card_tags) + deck_theme_set = set(deck_themes) + + # Count overlapping themes + return len(card_tag_set.intersection(deck_theme_set)) + +def calculate_theme_priority(card_tags: List[str], deck_themes: List[str], THEME_PRIORITY_BONUS: float) -> float: + """Calculate priority score for a card based on theme overlap. + + Args: + card_tags: List of tags associated with the card + deck_themes: List of themes in the deck + THEME_PRIORITY_BONUS: Bonus multiplier for each additional theme match + + Returns: + Priority score for the card (higher means more theme overlap) + + Example: + >>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice'] + >>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters'] + >>> calculate_theme_priority(card_tags, deck_themes, 1.2) + 1.44 # Base score of 1.0 * (1.2 ^ 2) for two theme matches + """ + overlap_count = get_card_theme_overlap(card_tags, deck_themes) + if overlap_count == 0: + return 0.0 + + # Calculate priority score with exponential bonus for multiple matches + return pow(THEME_PRIORITY_BONUS, overlap_count) + +def calculate_weighted_pool_size(ideal_count: int, weight: float, multiplier: float = THEME_POOL_SIZE_MULTIPLIER) -> int: + """Calculate the size of the initial card pool based on ideal count and weight. + + Args: + ideal_count: Target number of cards to select + weight: Theme weight factor (0.0-1.0) + multiplier: Pool size multiplier (default from settings) + + Returns: + Calculated pool size + + Example: + >>> calculate_weighted_pool_size(10, 0.8, 2.0) + 16 + """ + return int(ideal_count * weight * multiplier) + +def filter_theme_cards(df: pd.DataFrame, themes: List[str], pool_size: int) -> pd.DataFrame: + """Filter cards by theme and return top cards by EDHREC rank. + + Args: + df: Source DataFrame to filter + themes: List of theme tags to filter by + pool_size: Number of cards to return + + Returns: + Filtered DataFrame with top cards + + Raises: + ValueError: If themes is None or contains invalid values + TypeError: If themes is not a list + + Example: + >>> filtered_df = filter_theme_cards(cards_df, ['Artifacts Matter', 'Token Creation'], 20) + """ + # Input validation + if themes is None: + raise ValueError("themes parameter cannot be None") + + if not isinstance(themes, list): + raise TypeError("themes must be a list of strings") + + if not all(isinstance(theme, str) for theme in themes): + raise ValueError("all themes must be strings") + + if not themes: + return pd.DataFrame() # Return empty DataFrame for empty themes list + + # Create copy to avoid modifying original + filtered_df = df.copy() + + # Filter by theme + filtered_df = filtered_df[filtered_df['themeTags'].apply( + lambda x: any(theme in x for theme in themes) if isinstance(x, list) else False + )] + + # Sort by EDHREC rank and take top cards + filtered_df.sort_values('edhrecRank', inplace=True) + return filtered_df.head(pool_size) + +def select_weighted_cards( + card_pool: pd.DataFrame, + target_count: int, + price_checker: Optional[Any] = None, + max_price: Optional[float] = None +) -> List[Dict[str, Any]]: + """Select cards from pool considering price constraints. + + Args: + card_pool: DataFrame of candidate cards + target_count: Number of cards to select + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected card dictionaries + + Example: + >>> selected = select_weighted_cards(pool_df, 5, price_checker, 10.0) + """ + selected_cards = [] + + for _, card in card_pool.iterrows(): + if len(selected_cards) >= target_count: + break + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(card['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {card['name']}: {e}") + continue + + selected_cards.append({ + 'name': card['name'], + 'type': card['type'], + 'manaCost': card['manaCost'], + 'manaValue': card['manaValue'], + 'themeTags': card['themeTags'] + }) + + return selected_cards + def count_color_pips(mana_costs: pd.Series, color: str) -> int: """Count the number of colored mana pips of a specific color in mana costs. diff --git a/deck_builder.py b/deck_builder.py index 6dbeb15..d5111c3 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -20,16 +20,16 @@ from settings import ( COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT, COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, + THEME_PRIORITY_BONUS, THEME_POOL_SIZE_MULTIPLIER, COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP, CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT, MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS, - MANA_COLORS, MANA_PIP_PATTERNS + MANA_COLORS, MANA_PIP_PATTERNS, THEME_WEIGHT_MULTIPLIER ) import builder_utils import setup_utils -from setup import determine_commanders from input_handler import InputHandler from exceptions import ( BasicLandCountError, @@ -37,7 +37,6 @@ from exceptions import ( CommanderMoveError, CardTypeCountError, CommanderColorError, - CommanderLoadError, CommanderSelectionError, CommanderValidationError, CSVError, @@ -48,16 +47,12 @@ from exceptions import ( DuplicateCardError, DeckBuilderError, EmptyDataFrameError, - EmptyInputError, FetchLandSelectionError, FetchLandValidationError, IdealDeterminationError, - InvalidNumberError, - InvalidQuestionTypeError, LandRemovalError, LibraryOrganizationError, LibrarySortError, - MaxAttemptsError, PriceAPIError, PriceConfigurationError, PriceLimitError, @@ -66,18 +61,21 @@ from exceptions import ( ThemeSelectionError, ThemeWeightError, StapleLandError, - StapleLandError, - ManaPipError + ManaPipError, + ThemeTagError, + ThemeWeightingError, + ThemePoolError ) from type_definitions import ( - CardDict, CommanderDict, CardLibraryDF, CommanderDF, LandDF, ArtifactDF, CreatureDF, - NonCreatureDF) + NonCreatureDF, + PlaneswalkerDF, + NonPlaneswalkerDF) # Try to import scrython and price_checker try: @@ -102,19 +100,6 @@ pd.set_option('display.max_columns', None) pd.set_option('display.max_rows', None) pd.set_option('display.max_colwidth', 50) -""" -Basic deck builder, primarily intended for building Kindred decks. -Logic for other themes (such as Spellslinger or Wheels), is added. -I plan to also implement having it recommend a commander or themes. - -Currently, the script will ask questions to determine number of -creatures, lands, interaction, ramp, etc... then add cards and -adjust from there. - -Land spread will ideally be handled based on pips and some adjustment -is planned based on mana curve and ramp added. -""" - def new_line(num_lines: int = 1) -> None: """Print specified number of newlines for formatting output. @@ -149,9 +134,10 @@ class DeckBuilder: self.artifact_df: ArtifactDF = pd.DataFrame() self.creature_df: CreatureDF = pd.DataFrame() self.noncreature_df: NonCreatureDF = pd.DataFrame() - + self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame() # Initialize other attributes with type hints self.commander_info: Dict = {} + self.max_card_price: Optional[float] = None self.commander_dict: CommanderDict = {} self.commander: str = '' self.commander_type: str = '' @@ -764,7 +750,7 @@ class DeckBuilder: # Remove lands from main DataFrame df = df[~df['type'].str.contains('Land')] - df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv') + df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv', index=False) # Create specialized frames self.artifact_df = df[df['type'].str.contains('Artifact')].copy() @@ -774,9 +760,10 @@ class DeckBuilder: self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy() self.instant_df = df[df['type'].str.contains('Instant')].copy() self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy() + self.nonplaneswalker_df = df[~df['type'].str.contains('Planeswalker')].copy() self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy() - self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv') + self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv', index=False) # Sort all frames for frame in [self.artifact_df, self.battle_df, self.creature_df, @@ -859,8 +846,9 @@ class DeckBuilder: try: # Load and combine data self.full_df = self._load_and_combine_data() + self.full_df = self.full_df[~self.full_df['name'].str.contains(self.commander)] self.full_df.sort_values(by='edhrecRank', inplace=True) - self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv') + self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv', index=False) # Split into specialized frames self._split_into_specialized_frames(self.full_df) @@ -962,7 +950,6 @@ class DeckBuilder: self.tertiary_theme, self.weights ) - print(self.weights) self.primary_weight = self.weights['primary'] self.secondary_weight = self.weights['secondary'] self.tertiary_weight = self.weights['tertiary'] @@ -972,8 +959,7 @@ class DeckBuilder: if self.secondary_theme: self.themes.append(self.secondary_theme) if self.tertiary_theme: - self.themes.append(self.tertiary_theme) - print(self.weights) + self.themes.append self.determine_hidden_themes() except (ThemeSelectionError, ThemeWeightError) as e: @@ -1323,7 +1309,7 @@ class DeckBuilder: 6. Add miscellaneous utility lands 7. Adjust total land count to match ideal count """ - MAX_ADJUSTMENT_ATTEMPTS = 10 + MAX_ADJUSTMENT_ATTEMPTS = (self.ideal_land_count - self.min_basics) * 1.5 self.total_basics = 0 try: @@ -2032,222 +2018,395 @@ class DeckBuilder: logger.error(f"Error calculating CMC: {e}") self.cmc = 0.0 - def weight_by_theme(self, tag, ideal=1, weight=1, df=None): - # First grab the first 50/30/20 cards that match each theme - """Add cards with specific tag up to ideal_value count""" - ideal_value = math.ceil(ideal * weight * 0.9) - print(f'Finding {ideal_value} cards with the "{tag}" tag...') - if 'Kindred' in tag: - tags = [tag, 'Kindred Support'] - else: - tags = [tag] - # Filter cards with the given tag - tag_df = df.copy() - tag_df.sort_values(by='edhrecRank', inplace=True) - tag_df = tag_df[tag_df['themeTags'].apply(lambda x: any(tag in x for tag in tags))] - # Take top cards based on ideal value - pool_size = int(ideal_value * random.randint(15, 20) /10) - tag_df = tag_df.head(pool_size) - - # Convert to list of card dictionaries - card_pool = [ - { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'], - 'creatureTypes': row['creatureTypes'], - 'themeTags': row['themeTags'] - } - for _, row in tag_df.iterrows() - ] + def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None: + """Add cards with specific tag up to weighted ideal count. - # Randomly select cards up to ideal value - cards_to_add = [] - while len(cards_to_add) < ideal_value and card_pool: - card = random.choice(card_pool) - card_pool.remove(card) + Args: + tag: Theme tag to filter cards by + ideal: Target number of cards to add + weight: Theme weight factor (0.0-1.0) + df: Source DataFrame to filter cards from + + Raises: + ThemeWeightingError: If weight calculation fails + ThemePoolError: If card pool is empty or insufficient + """ + try: + # Calculate target card count using weight and safety multiplier + target_count = math.ceil(ideal * weight * THEME_WEIGHT_MULTIPLIER) + logger.info(f'Finding {target_count} cards with the "{tag}" tag...') + + # Handle Kindred theme special case + tags = [tag, 'Kindred Support'] if 'Kindred' in tag else [tag] + + # Calculate initial pool size + pool_size = builder_utils.calculate_weighted_pool_size(target_count, weight) + + # Filter cards by theme + if df is None: + raise ThemePoolError(f"No source DataFrame provided for theme {tag}") - # Check price constraints if enabled - if use_scrython and self.set_max_card_price: - price = self.price_checker.get_card_price(card['name']) - if price > self.max_card_price * 1.1: - continue + tag_df = builder_utils.filter_theme_cards(df, tags, pool_size) + if tag_df.empty: + raise ThemePoolError(f"No cards found for theme {tag}") + + # Select cards considering price and duplicates + selected_cards = builder_utils.select_weighted_cards( + tag_df, + target_count, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Process selected cards + cards_added = [] + for card in selected_cards: + # Handle multiple copy cards + if card['name'] in multiple_copy_cards: + copies = { + 'Nazgûl': 9, + 'Seven Dwarves': 7 + }.get(card['name'], target_count - len(cards_added)) - # Add card if not already in library - - if card['name'] in multiple_copy_cards: - if card['name'] == 'Nazgûl': - for _ in range(9): - cards_to_add.append(card) - elif card['name'] == 'Seven Dwarves': - for _ in range(7): - cards_to_add.append(card) + for _ in range(copies): + cards_added.append(card) + + # Handle regular cards + elif card['name'] not in self.card_library['Card Name'].values: + cards_added.append(card) else: - num_to_add = ideal_value - len(cards_to_add) - for _ in range(num_to_add): - cards_to_add.append(card) - - elif (card['name'] not in multiple_copy_cards - and card['name'] not in self.card_library['Card Name'].values): - cards_to_add.append(card) - - elif (card['name'] not in multiple_copy_cards - and card['name'] in self.card_library['Card Name'].values): - logger.warning(f"{card['name']} already in Library, skipping it.") - continue - - # Add selected cards to library - for card in cards_to_add: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue'], - card['creatureTypes'], card['themeTags']) - - card_pool_names = [item['name'] for item in card_pool] - self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] - self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] - logger.info(f'Added {len(cards_to_add)} {tag} cards') - #tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False) + logger.warning(f"{card['name']} already in Library, skipping it.") + + # Add selected cards to library + for card in cards_added: + self.add_card( + card['name'], + card['type'], + card['manaCost'], + card['manaValue'], + card.get('creatureTypes'), + card['themeTags'] + ) + + # Update DataFrames + used_cards = {card['name'] for card in selected_cards} + self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)] + + logger.info(f'Added {len(cards_added)} {tag} cards') + for card in cards_added: + print(card['name']) + + except (ThemeWeightingError, ThemePoolError) as e: + logger.error(f"Error in weight_by_theme: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in weight_by_theme: {e}") + raise ThemeWeightingError(f"Failed to process theme {tag}: {str(e)}") - def add_by_tags(self, tag, ideal_value=1, df=None): - """Add cards with specific tag up to ideal_value count""" - print(f'Finding {ideal_value} cards with the "{tag}" tag...') + def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False): + """Add cards with specific tag up to ideal_value count. + Args: + tag: The theme tag to filter cards by + ideal_value: Target number of cards to add + df: DataFrame containing candidate cards - # Filter cards with the given tag - skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 - tag_df = df.copy() - tag_df.sort_values(by='edhrecRank', inplace=True) - tag_df = tag_df[tag_df['themeTags'].apply(lambda x: tag in x)] - # Take top cards based on ideal value - pool_size = int(ideal_value * random.randint(2, 3)) - tag_df = tag_df.head(pool_size) - - # Convert to list of card dictionaries - card_pool = [ - { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'], - 'creatureTypes': row['creatureTypes'], - 'themeTags': row['themeTags'] - } - for _, row in tag_df.iterrows() - ] - - # Randomly select cards up to ideal value - cards_to_add = [] - while len(cards_to_add) < ideal_value and card_pool: - card = random.choice(card_pool) - card_pool.remove(card) - - # Check price constraints if enabled - if use_scrython and self.set_max_card_price: - price = self.price_checker.get_card_price(card['name']) - if price > self.max_card_price * 1.1: - continue - - # Add card if not already in library - if card['name'] not in self.card_library['Card Name'].values: - if 'Creature' in card['type'] and skip_creatures: - continue - else: - if 'Creature' in card['type']: - self.creature_cards += 1 - skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 - cards_to_add.append(card) - - # Add selected cards to library - for card in cards_to_add: - if len(self.card_library) < 100: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue'], - card['creatureTypes'], card['themeTags']) + Raises: + ThemeTagError: If there are issues with tag processing or card selection + """ + try: + # Count existing cards with target tag + print() + if not ignore_existing: + existing_count = len(self.card_library[self.card_library['Themes'].apply(lambda x: x is not None and tag in x)]) + remaining_slots = max(0, ideal_value - existing_count + 1) else: - continue + existing_count = 0 + remaining_slots = max(0, ideal_value - existing_count + 1) - card_pool_names = [item['name'] for item in card_pool] - self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] - self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] - logger.info(f'Added {len(cards_to_add)} {tag} cards') - #tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False) + if remaining_slots == 0: + if not ignore_existing: + logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed') + return + else: + logger.info(f'Already have {ideal_value} cards with tag "{tag}" - no additional cards needed') + return + + logger.info(f'Finding {remaining_slots} additional cards with the "{tag}" tag...') + + # Filter cards with the given tag + skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 + tag_df = df.copy() + tag_df.sort_values(by='edhrecRank', inplace=True) + tag_df = tag_df[tag_df['themeTags'].apply(lambda x: x is not None and tag in x)] + + # Calculate initial pool size using THEME_POOL_SIZE_MULTIPLIER + pool_size = int(remaining_slots * THEME_POOL_SIZE_MULTIPLIER) + tag_df = tag_df.head(pool_size) + + # Convert to list of card dictionaries with priority scores + card_pool = [] + for _, row in tag_df.iterrows(): + theme_tags = row['themeTags'] if row['themeTags'] is not None else [] + priority = builder_utils.calculate_theme_priority(theme_tags, self.themes, THEME_PRIORITY_BONUS) + card_pool.append({ + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'], + 'creatureTypes': row['creatureTypes'], + 'themeTags': theme_tags, + 'priority': priority + }) + + # Sort card pool by priority score + card_pool.sort(key=lambda x: x['priority'], reverse=True) + + # Select cards up to remaining slots + cards_to_add = [] + for card in card_pool: + if len(cards_to_add) >= remaining_slots: + break + + # Check price constraints if enabled + if use_scrython and hasattr(self, 'max_card_price') and self.max_card_price: + price = self.price_checker.get_card_price(card['name']) + if price > self.max_card_price * 1.1: + continue + + # Handle multiple-copy cards + if card['name'] in multiple_copy_cards: + existing_copies = len(self.card_library[self.card_library['Card Name'] == card['name']]) + if existing_copies < ideal_value: + cards_to_add.append(card) + continue + + # Add new cards if not already in library + if card['name'] not in self.card_library['Card Name'].values: + if 'Creature' in card['type'] and skip_creatures: + continue + else: + if 'Creature' in card['type']: + self.creature_cards += 1 + skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 + cards_to_add.append(card) + + # Add selected cards to library + for card in cards_to_add: + if len(self.card_library) < 100: + self.add_card(card['name'], card['type'], + card['manaCost'], card['manaValue'], + card['creatureTypes'], card['themeTags']) + else: + break + + # Update DataFrames + card_pool_names = [item['name'] for item in card_pool] + self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] + + logger.info(f'Added {len(cards_to_add)} {tag} cards (total with tag: {existing_count + len(cards_to_add)})') + for card in cards_to_add: + print(card['name']) + + except Exception as e: + raise ThemeTagError(f"Error processing tag '{tag}'", {"error": str(e)}) def add_creatures(self): """ - Add creatures to the deck based on themes and self.weights. + Add creatures to the deck based on themes and weights. This method processes the primary, secondary, and tertiary themes to add - creatures proportionally according to their self.weights. The total number of + creatures proportionally according to their weights. The total number of creatures added will approximate the ideal_creature_count. - Themes are processed in order of importance (primary -> secondary -> tertiary) - with error handling to ensure the deck building process continues even if - a particular theme encounters issues. + The method follows this process: + 1. Process hidden theme if present + 2. Process primary theme + 3. Process secondary theme if present + 4. Process tertiary theme if present + + Each theme is weighted according to its importance: + - Hidden theme: Highest priority if present + - Primary theme: Main focus + - Secondary theme: Supporting focus + - Tertiary theme: Minor focus + + Args: + None + + Returns: + None + + Raises: + ThemeWeightingError: If there are issues with theme weight calculations + ThemePoolError: If the card pool for a theme is insufficient + Exception: For any other unexpected errors during creature addition + + Note: + The method uses error handling to ensure the deck building process + continues even if a particular theme encounters issues. """ - print(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...') + print() + logger.info(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...') try: if self.hidden_theme: - print(f'Processing Hidden theme: {self.hidden_theme}') + print() + logger.info(f'Processing Hidden theme: {self.hidden_theme}') self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight, self.creature_df) - print(f'Processing primary theme: {self.primary_theme}') + logger.info(f'Processing primary theme: {self.primary_theme}') self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df) if self.secondary_theme: - print(f'Processing secondary theme: {self.secondary_theme}') + print() + logger.info(f'Processing secondary theme: {self.secondary_theme}') self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df) if self.tertiary_theme: - print(f'Processing tertiary theme: {self.tertiary_theme}') + print() + logger.info(f'Processing tertiary theme: {self.tertiary_theme}') self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df) except Exception as e: logger.error(f"Error while adding creatures: {e}") finally: self.organize_library() - logger.info(f'Creature addition complete. Total creatures (including commander): {self.creature_cards}') def add_ramp(self): + """Add ramp cards to the deck based on ideal ramp count. + + This method adds three categories of ramp cards: + 1. Mana rocks (artifacts that produce mana) - ~1/3 of ideal ramp count + 2. Mana dorks (creatures that produce mana) - ~1/4 of ideal ramp count + 3. General ramp spells - remaining portion of ideal ramp count + + The method uses the add_by_tags() helper to add cards from each category + while respecting the deck's themes and color identity. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with ramp-related tags + """ try: self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df) self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df) - self.add_by_tags('Ramp', math.ceil(self.ideal_ramp / 2), self.noncreature_df) + self.add_by_tags('Ramp', self.ideal_ramp, self.noncreature_df) except Exception as e: logger.error(f"Error while adding Ramp: {e}") - finally: - logger.info('Adding Ramp complete.') - + def add_interaction(self): + """Add interaction cards to the deck for removal and protection. + + This method adds two categories of interaction cards: + 1. Removal spells based on ideal_removal count + 2. Protection spells based on ideal_protection count + + Cards are selected from non-planeswalker cards to ensure appropriate + interaction types are added. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with interaction-related tags + """ try: - self.add_by_tags('Removal', self.ideal_removal, self.noncreature_nonplaneswaker_df) - self.add_by_tags('Protection', self.ideal_protection, self.noncreature_nonplaneswaker_df) + self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df) + self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df) except Exception as e: logger.error(f"Error while adding Interaction: {e}") - finally: - logger.info('Adding Interaction complete.') def add_board_wipes(self): + """Add board wipe cards to the deck. + + This method adds board wipe cards based on the ideal_wipes count. + Board wipes are selected from the full card pool to include all possible + options across different card types. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with the 'Board Wipes' tag + """ try: self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df) except Exception as e: logger.error(f"Error while adding Board Wipes: {e}") - finally: - logger.info('Adding Board Wipes complete.') def add_card_advantage(self): + """Add card advantage effects to the deck. + + This method adds two categories of card draw effects: + 1. Conditional draw effects (20% of ideal_card_advantage) + - Cards that draw based on specific conditions or triggers + 2. Unconditional draw effects (80% of ideal_card_advantage) + - Cards that provide straightforward card draw + + Cards are selected from appropriate pools while avoiding planeswalkers + for unconditional draw effects. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with draw-related tags + """ try: self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df) - self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.noncreature_nonplaneswaker_df) + self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.nonplaneswalker_df) except Exception as e: logger.error(f"Error while adding Card Draw: {e}") - finally: - logger.info('Adding Card Draw complete.') def fill_out_deck(self): - """Fill out the deck to 100 cards with theme-appropriate cards.""" + """Fill out the deck to 100 cards with theme-appropriate cards. + + This method completes the deck by adding remaining cards up to the 100-card + requirement, prioritizing cards that match the deck's themes. The process + follows these steps: + + 1. Calculate how many cards are needed to reach 100 + 2. Add cards from each theme with weighted distribution: + - Hidden theme (if present) + - Tertiary theme (20% weight if present) + - Secondary theme (30% weight if present) + - Primary theme (50% weight) + + The method includes safeguards: + - Maximum attempts limit to prevent infinite loops + - Timeout to prevent excessive runtime + - Progress tracking to break early if insufficient progress + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with specific theme tags + TimeoutError: If the process exceeds the maximum allowed time + + Note: + If the deck cannot be filled to 100 cards, a warning message is logged + indicating manual additions may be needed. + """ + print() logger.info('Filling out the Library to 100 with cards fitting the themes.') - cards_needed = 100 - len(self.card_library) if cards_needed <= 0: return @@ -2274,38 +2433,57 @@ class DeckBuilder: try: # Add cards from each theme with adjusted self.weights - if self.tertiary_theme: + if self.hidden_theme and remaining > 0: + self.add_by_tags(self.hidden_theme, + math.ceil(weight_multiplier), + self.full_df, + True) + + # Adjust self.weights based on remaining cards needed + remaining = 100 - len(self.card_library) + weight_multiplier = remaining / cards_needed + if self.tertiary_theme and remaining > 0: self.add_by_tags(self.tertiary_theme, - math.ceil(self.tertiary_weight * 10 * weight_multiplier), - self.noncreature_df) - if self.secondary_theme: + math.ceil(weight_multiplier * 0.2), + self.noncreature_df, + True) + + if self.secondary_theme and remaining > 0: self.add_by_tags(self.secondary_theme, - math.ceil(self.secondary_weight * 3 * weight_multiplier), - self.noncreature_df) - self.add_by_tags(self.primary_theme, - math.ceil(self.primary_weight * 2 * weight_multiplier), - self.noncreature_df) + math.ceil(weight_multiplier * 0.3), + self.noncreature_df, + True) + if remaining > 0: + self.add_by_tags(self.primary_theme, + math.ceil(weight_multiplier * 0.5), + self.noncreature_df, + True) # Check if we made progress if len(self.card_library) == initial_count: attempts += 1 if attempts % 5 == 0: + print() logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") # Break early if we're stuck if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4): + print() logger.warning("Insufficient progress being made, breaking early") break except Exception as e: + print() logger.error(f"Error while adding cards: {e}") attempts += 1 final_count = len(self.card_library) if final_count < 100: message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed." + print() logger.warning(message) else: + print() logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") def main(): """Main entry point for deck builder application.""" diff --git a/exceptions.py b/exceptions.py index 77b7a60..16737ba 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1278,4 +1278,111 @@ class ManaPipError(DeckBuilderError): message, code="MANA_PIP_ERR", details=details + ) + +class ThemeTagError(DeckBuilderError): + """Raised when there are issues processing theme tags. + + This exception is used when there are problems processing or validating theme tags, + such as invalid tag formats, missing required tags, or tag validation failures. + + Examples: + >>> raise ThemeTagError( + ... "Invalid theme tag format", + ... {"tag": "invalid#tag", "expected_format": "theme:subtheme"} + ... ) + + >>> raise ThemeTagError( + ... "Missing required theme tags", + ... {"card_name": "Example Card", "required_tags": ["theme:tribal", "theme:synergy"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize theme tag error. + + Args: + message: Description of the theme tag processing failure + details: Additional context about the error + """ + super().__init__( + message, + code="THEME_TAG_ERR", + details=details + ) + +class ThemeWeightingError(DeckBuilderError): + """Raised when there are issues with theme-based card weighting. + + This exception is used when there are problems calculating or validating + theme weights, such as invalid weight values, calculation errors, or + inconsistencies in theme weight distribution. + + Examples: + >>> raise ThemeWeightingError( + ... "Invalid theme weight value", + ... {"theme": "tribal", "weight": -1, "valid_range": "0-100"} + ... ) + + >>> raise ThemeWeightingError( + ... "Theme weight calculation error", + ... {"theme": "artifacts", "error": "Division by zero in weight normalization"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize theme weighting error. + + Args: + message: Description of the theme weighting failure + details: Additional context about the error + """ + super().__init__( + message, + code="THEME_WEIGHT_ERR", + details=details + ) + +class ThemePoolError(DeckBuilderError): + """Raised when there are issues with the theme card pool. + + This exception is used when there are problems creating or managing the theme + card pool, such as empty pools, insufficient cards, or invalid pool configurations. + + Examples: + >>> raise ThemePoolError( + ... "Empty theme card pool", + ... {"theme": "spellslinger", "required_cards": 30} + ... ) + + >>> raise ThemePoolError( + ... "Insufficient cards in theme pool", + ... { + ... "theme": "artifacts", + ... "available_cards": 15, + ... "required_cards": 25 + ... } + ... ) + + >>> raise ThemePoolError( + ... "Invalid card pool configuration", + ... { + ... "theme": "tribal", + ... "creature_type": "Dragon", + ... "error": "No cards match creature type" + ... } + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize theme pool error. + + Args: + message: Description of the theme pool failure + details: Additional context about the error + """ + super().__init__( + message, + code="THEME_POOL_ERR", + details=details ) \ No newline at end of file diff --git a/settings.py b/settings.py index 3c8d745..e3f9084 100644 --- a/settings.py +++ b/settings.py @@ -544,7 +544,7 @@ REMOVAL_TEXT_PATTERNS = [ 'returns target.*to.*hand' ] -REMOVAL_SPECIFIC_CARDS = [] # type: list +REMOVAL_SPECIFIC_CARDS = ['from.*graveyard.*hand'] # type: list REMOVAL_EXCLUSION_PATTERNS = [] # type: list @@ -1032,7 +1032,17 @@ REQUIRED_COLUMNS: List[str] = [ 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side' ] -# Constants for theme weight management +# 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, diff --git a/setup.py b/setup.py index 7640374..e533821 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,53 @@ 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 import logging from enum import Enum -from pathlib import Path import os +from pathlib import Path from typing import Union, List, Dict, Any # Third-party imports -import pandas as pd import inquirer +import pandas as pd # Local application imports 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 ( - 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 ( - CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError, - ColorFilterError, CommanderValidationError + CSVFileNotFoundError, + ColorFilterError, + CommanderValidationError, + DataFrameProcessingError, + MTGJSONDownloadError ) # Create logs directory if it doesn't exist if not os.path.exists('logs'): diff --git a/setup_utils.py b/setup_utils.py index a0b2ed4..125233c 100644 --- a/setup_utils.py +++ b/setup_utils.py @@ -347,7 +347,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: ) 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) -> pd.DataFrame: + include_commander_cols: bool = False, skip_availability_checks: bool = False) -> CardLibraryDF: """Process DataFrame with common operations in batches. Args: diff --git a/tagger.py b/tagger.py index 021dbbe..40751e1 100644 --- a/tagger.py +++ b/tagger.py @@ -293,7 +293,7 @@ def create_theme_tags(df: pd.DataFrame, color: str) -> None: raise TypeError("df must be a pandas DataFrame") if not isinstance(color, str): raise TypeError("color must be a string") - if color not in settings.colors: + if color not in settings.COLORS: raise ValueError(f"Invalid color: {color}") try: @@ -2633,6 +2633,24 @@ def create_token_modifier_mask(df: pd.DataFrame) -> pd.Series: return has_modifier & has_effect & ~name_exclusions +def create_tokens_matter_mask(df: pd.DataFrame) -> pd.Series: + """Create a boolean mask for cards that care about tokens. + + Args: + df: DataFrame to search + + Returns: + Boolean Series indicating which cards care about tokens + """ + # Create patterns for token matters + text_patterns = [ + 'tokens.*you.*control', + 'that\'s a token', + ] + text_mask = tag_utils.create_text_mask(df, text_patterns) + + return text_mask + def tag_for_tokens(df: pd.DataFrame, color: str) -> None: """Tag cards that create or modify tokens using vectorized operations. @@ -2670,6 +2688,13 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None: tag_utils.apply_tag_vectorized(df, modifier_mask, ['Token Modification', 'Token Creation', 'Tokens Matter']) logger.info('Tagged %d cards that modify token creation', modifier_mask.sum()) + + # Create tokens matter mask + matters_mask = create_tokens_matter_mask(df) + if matters_mask.any(): + tag_utils.apply_tag_vectorized(df, matters_mask, + ['Tokens Matter']) + logger.info('Tagged %d cards that care about tokens', modifier_mask.sum()) duration = (pd.Timestamp.now() - start_time).total_seconds() logger.info('Completed token tagging in %.2fs', duration) @@ -3638,7 +3663,7 @@ def tag_for_cantrips(df: pd.DataFrame, color: str) -> None: logger.error('Error tagging Cantrips in %s_cards.csv: %s', color, str(e)) raise - +## Magecraft def create_magecraft_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with magecraft effects. @@ -6369,6 +6394,18 @@ def create_removal_text_mask(df: pd.DataFrame) -> pd.Series: """ return tag_utils.create_text_mask(df, settings.REMOVAL_TEXT_PATTERNS) +def create_removal_exclusion_mask(df: pd.DataFrame) -> pd.Series: + """Create a boolean mask for cards that should be excluded from removal effects. + + Args: + df: DataFrame to search + + Returns: + Boolean Series indicating which cards should be excluded + """ + return tag_utils.create_text_mask(df, settings.REMOVAL_EXCLUSION_PATTERNS) + + def tag_for_removal(df: pd.DataFrame, color: str) -> None: """Tag cards that provide spot removal using vectorized operations. @@ -6422,7 +6459,8 @@ def tag_for_removal(df: pd.DataFrame, color: str) -> None: def run_tagging(): start_time = pd.Timestamp.now() - for color in settings.colors: + for color in settings.COLORS: load_dataframe(color) duration = (pd.Timestamp.now() - start_time).total_seconds() - logger.info(f'Tagged cards in {duration:.2f}s') \ No newline at end of file + logger.info(f'Tagged cards in {duration:.2f}s') + diff --git a/type_definitions.py b/type_definitions.py index b4ca8fb..4b3812b 100644 --- a/type_definitions.py +++ b/type_definitions.py @@ -46,4 +46,5 @@ NonCreatureDF = pd.DataFrame EnchantmentDF = pd.DataFrame InstantDF = pd.DataFrame PlaneswalkerDF = pd.DataFrame +NonPlaneswalkerDF = pd.DataFrame SorceryDF = pd.DataFrame \ No newline at end of file