Moved the builder, tagger, and setup modules into their own folders, along with constants to help provide better clarity and readability. Additionally added a missing call for the tag_for_artifcact_triggers() function

This commit is contained in:
mwisnowski 2025-01-28 10:19:44 -08:00
parent 3a5beebfe2
commit dbbc8bc66e
20 changed files with 1525 additions and 1737 deletions

View file

@ -0,0 +1,7 @@
from .builder import DeckBuilder
from .builder_utils import *
from .builder_constants import *
__all__ = [
'DeckBuilder',
]

2497
code/deck_builder/builder.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,437 @@
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
FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching
MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
# Commander-related constants
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv'
DECK_DIRECTORY = '../deck_files'
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
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]] = []
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
'Kindred', 'Dungeon', 'Battle']
# Basic mana colors
MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G']
# Mana pip patterns for each color
MANA_PIP_PATTERNS: Final[Dict[str, str]] = {
color: f'{{{color}}}' for color in MANA_COLORS
}
MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = {
'COLORLESS': ('Colorless', ['colorless']),
'W': ('White', ['colorless', 'white']),
'U': ('Blue', ['colorless', 'blue']),
'B': ('Black', ['colorless', 'black']),
'R': ('Red', ['colorless', 'red']),
'G': ('Green', ['colorless', 'green'])
}
DUAL_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
'B, G': ('Golgari: Black/Green', ['B', 'G', 'B, G'], ['colorless', 'black', 'green', 'golgari']),
'B, R': ('Rakdos: Black/Red', ['B', 'R', 'B, R'], ['colorless', 'black', 'red', 'rakdos']),
'B, U': ('Dimir: Black/Blue', ['B', 'U', 'B, U'], ['colorless', 'black', 'blue', 'dimir']),
'B, W': ('Orzhov: Black/White', ['B', 'W', 'B, W'], ['colorless', 'black', 'white', 'orzhov']),
'G, R': ('Gruul: Green/Red', ['G', 'R', 'G, R'], ['colorless', 'green', 'red', 'gruul']),
'G, U': ('Simic: Green/Blue', ['G', 'U', 'G, U'], ['colorless', 'green', 'blue', 'simic']),
'G, W': ('Selesnya: Green/White', ['G', 'W', 'G, W'], ['colorless', 'green', 'white', 'selesnya']),
'R, U': ('Izzet: Blue/Red', ['U', 'R', 'U, R'], ['colorless', 'blue', 'red', 'izzet']),
'U, W': ('Azorius: Blue/White', ['U', 'W', 'U, W'], ['colorless', 'blue', 'white', 'azorius']),
'R, W': ('Boros: Red/White', ['R', 'W', 'R, W'], ['colorless', 'red', 'white', 'boros'])
}
TRI_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
'B, G, U': ('Sultai: Black/Blue/Green', ['B', 'G', 'U', 'B, G', 'B, U', 'G, U', 'B, G, U'],
['colorless', 'black', 'blue', 'green', 'dimir', 'golgari', 'simic', 'sultai']),
'B, G, R': ('Jund: Black/Red/Green', ['B', 'G', 'R', 'B, G', 'B, R', 'G, R', 'B, G, R'],
['colorless', 'black', 'green', 'red', 'golgari', 'rakdos', 'gruul', 'jund']),
'B, G, W': ('Abzan: Black/Green/White', ['B', 'G', 'W', 'B, G', 'B, W', 'G, W', 'B, G, W'],
['colorless', 'black', 'green', 'white', 'golgari', 'orzhov', 'selesnya', 'abzan']),
'B, R, U': ('Grixis: Black/Blue/Red', ['B', 'R', 'U', 'B, R', 'B, U', 'R, U', 'B, R, U'],
['colorless', 'black', 'blue', 'red', 'dimir', 'rakdos', 'izzet', 'grixis']),
'B, R, W': ('Mardu: Black/Red/White', ['B', 'R', 'W', 'B, R', 'B, W', 'R, W', 'B, R, W'],
['colorless', 'black', 'red', 'white', 'rakdos', 'orzhov', 'boros', 'mardu']),
'B, U, W': ('Esper: Black/Blue/White', ['B', 'U', 'W', 'B, U', 'B, W', 'U, W', 'B, U, W'],
['colorless', 'black', 'blue', 'white', 'dimir', 'orzhov', 'azorius', 'esper']),
'G, R, U': ('Temur: Blue/Green/Red', ['G', 'R', 'U', 'G, R', 'G, U', 'R, U', 'G, R, U'],
['colorless', 'green', 'red', 'blue', 'simic', 'izzet', 'gruul', 'temur']),
'G, R, W': ('Naya: Green/Red/White', ['G', 'R', 'W', 'G, R', 'G, W', 'R, W', 'G, R, W'],
['colorless', 'green', 'red', 'white', 'gruul', 'selesnya', 'boros', 'naya']),
'G, U, W': ('Bant: Blue/Green/White', ['G', 'U', 'W', 'G, U', 'G, W', 'U, W', 'G, U, W'],
['colorless', 'green', 'blue', 'white', 'simic', 'azorius', 'selesnya', 'bant']),
'R, U, W': ('Jeskai: Blue/Red/White', ['R', 'U', 'W', 'R, U', 'U, W', 'R, W', 'R, U, W'],
['colorless', 'blue', 'red', 'white', 'izzet', 'azorius', 'boros', 'jeskai'])
}
OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
'B, G, R, U': ('Glint: Black/Blue/Green/Red',
['B', 'G', 'R', 'U', 'B, G', 'B, R', 'B, U', 'G, R', 'G, U', 'R, U', 'B, G, R',
'B, G, U', 'B, R, U', 'G, R, U', 'B, G, R, U'],
['colorless', 'black', 'blue', 'green', 'red', 'golgari', 'rakdos', 'dimir',
'gruul', 'simic', 'izzet', 'jund', 'sultai', 'grixis', 'temur', 'glint']),
'B, G, R, W': ('Dune: Black/Green/Red/White',
['B', 'G', 'R', 'W', 'B, G', 'B, R', 'B, W', 'G, R', 'G, W', 'R, W', 'B, G, R',
'B, G, W', 'B, R, W', 'G, R, W', 'B, G, R, W'],
['colorless', 'black', 'green', 'red', 'white', 'golgari', 'rakdos', 'orzhov',
'gruul', 'selesnya', 'boros', 'jund', 'abzan', 'mardu', 'naya', 'dune']),
'B, G, U, W': ('Witch: Black/Blue/Green/White',
['B', 'G', 'U', 'W', 'B, G', 'B, U', 'B, W', 'G, U', 'G, W', 'U, W', 'B, G, U',
'B, G, W', 'B, U, W', 'G, U, W', 'B, G, U, W'],
['colorless', 'black', 'blue', 'green', 'white', 'golgari', 'dimir', 'orzhov',
'simic', 'selesnya', 'azorius', 'sultai', 'abzan', 'esper', 'bant', 'witch']),
'B, R, U, W': ('Yore: Black/Blue/Red/White',
['B', 'R', 'U', 'W', 'B, R', 'B, U', 'B, W', 'R, U', 'R, W', 'U, W', 'B, R, U',
'B, R, W', 'B, U, W', 'R, U, W', 'B, R, U, W'],
['colorless', 'black', 'blue', 'red', 'white', 'rakdos', 'dimir', 'orzhov',
'izzet', 'boros', 'azorius', 'grixis', 'mardu', 'esper', 'jeskai', 'yore']),
'G, R, U, W': ('Ink: Blue/Green/Red/White',
['G', 'R', 'U', 'W', 'G, R', 'G, U', 'G, W', 'R, U', 'R, W', 'U, W', 'G, R, U',
'G, R, W', 'G, U, W', 'R, U, W', 'G, R, U, W'],
['colorless', 'blue', 'green', 'red', 'white', 'gruul', 'simic', 'selesnya',
'izzet', 'boros', 'azorius', 'temur', 'naya', 'bant', 'jeskai', 'ink']),
'B, G, R, U, W': ('WUBRG: All colors',
['B', 'G', 'R', 'U', 'W', 'B, G', 'B, R', 'B, U', 'B, W', 'G, R', 'G, U',
'G, W', 'R, U', 'R, W', 'U, W', 'B, G, R', 'B, G, U', 'B, G, W', 'B, R, U',
'B, R, W', 'B, U, W', 'G, R, U', 'G, R, W', 'G, U, W', 'R, U, W',
'B, G, R, U', 'B, G, R, W', 'B, G, U, W', 'B, R, U, W', 'G, R, U, W',
'B, G, R, U, W'],
['colorless', 'black', 'green', 'red', 'blue', 'white', 'golgari', 'rakdos',
'dimir', 'orzhov', 'gruul', 'simic', 'selesnya', 'izzet', 'boros', 'azorius',
'jund', 'sultai', 'abzan', 'grixis', 'mardu', 'esper', 'temur', 'naya',
'bant', 'jeskai', 'glint', 'dune', 'witch', 'yore', 'ink', 'wubrg'])
}
# Price checking configuration
DEFAULT_PRICE_DELAY: Final[float] = 0.1 # Delay between price checks in seconds
MAX_PRICE_CHECK_ATTEMPTS: Final[int] = 3 # Maximum attempts for price checking
PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache
PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
# Deck composition defaults
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count
DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color
# Miscellaneous land configuration
MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add
MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add
MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from
# Default fetch land count
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
# Basic Lands
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
# Basic land mappings
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
'W': 'Plains',
'U': 'Island',
'B': 'Swamp',
'R': 'Mountain',
'G': 'Forest',
'C': 'Wastes'
}
# Dual land type mappings
DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = {
'azorius': 'Plains Island',
'dimir': 'Island Swamp',
'rakdos': 'Swamp Mountain',
'gruul': 'Mountain Forest',
'selesnya': 'Forest Plains',
'orzhov': 'Plains Swamp',
'golgari': 'Swamp Forest',
'simic': 'Forest Island',
'izzet': 'Island Mountain',
'boros': 'Mountain Plains'
}
# Triple land type mappings
TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = {
'bant': 'Forest Plains Island',
'esper': 'Plains Island Swamp',
'grixis': 'Island Swamp Mountain',
'jund': 'Swamp Mountain Forest',
'naya': 'Mountain Forest Plains',
'mardu': 'Mountain Plains Swamp',
'abzan': 'Plains Swamp Forest',
'sultai': 'Swamp Forest Island',
'temur': 'Forest Island Mountain',
'jeskai': 'Island Mountain Plains'
}
# Default preference for including dual lands
DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True
# Default preference for including triple lands
DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True
SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island',
'B': 'Snow-Covered Swamp',
'G': 'Snow-Covered Forest'
}
SNOW_BASIC_LAND_MAPPING: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island',
'B': 'Snow-Covered Swamp',
'R': 'Snow-Covered Mountain',
'G': 'Snow-Covered Forest',
'C': 'Wastes' # Note: No snow-covered version exists for Wastes
}
# Generic fetch lands list
GENERIC_FETCH_LANDS: Final[List[str]] = [
'Evolving Wilds',
'Terramorphic Expanse',
'Shire Terrace',
'Escape Tunnel',
'Promising Vein',
'Myriad Landscape',
'Fabled Passage',
'Terminal Moraine',
'Prismatic Vista'
]
# Kindred land constants
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
{
'name': 'Path of Ancestry',
'type': 'Land'
},
{
'name': 'Three Tree City',
'type': 'Legendary Land'
},
{'name': 'Cavern of Souls', 'type': 'Land'}
]
# Color-specific fetch land mappings
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
'W': [
'Flooded Strand',
'Windswept Heath',
'Marsh Flats',
'Arid Mesa',
'Brokers Hideout',
'Obscura Storefront',
'Cabaretti Courtyard'
],
'U': [
'Flooded Strand',
'Polluted Delta',
'Scalding Tarn',
'Misty Rainforest',
'Brokers Hideout',
'Obscura Storefront',
'Maestros Theater'
],
'B': [
'Polluted Delta',
'Bloodstained Mire',
'Marsh Flats',
'Verdant Catacombs',
'Obscura Storefront',
'Maestros Theater',
'Riveteers Overlook'
],
'R': [
'Bloodstained Mire',
'Wooded Foothills',
'Scalding Tarn',
'Arid Mesa',
'Maestros Theater',
'Riveteers Overlook',
'Cabaretti Courtyard'
],
'G': [
'Wooded Foothills',
'Windswept Heath',
'Verdant Catacombs',
'Misty Rainforest',
'Brokers Hideout',
'Riveteers Overlook',
'Cabaretti Courtyard'
]
}
# 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
}
# 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]
# Other defaults
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
DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells
# Deck composition prompts
DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
'ramp': 'Enter desired number of ramp pieces (default: 8):',
'lands': 'Enter desired number of total lands (default: 35):',
'basic_lands': 'Enter minimum number of basic lands (default: 20):',
'creatures': 'Enter desired number of creatures (default: 25):',
'removal': 'Enter desired number of spot removal spells (default: 10):',
'wipes': 'Enter desired number of board wipes (default: 2):',
'card_advantage': 'Enter desired number of card advantage pieces (default: 10):',
'protection': 'Enter desired number of protection spells (default: 8):',
'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):',
'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):'
}
DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price
BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch
# Constants for input validation
# Type aliases
CardName = str
CardType = str
ThemeTag = str
ColorIdentity = str
ColorList = List[str]
ColorInfo = Tuple[str, List[str], List[str]]
INPUT_VALIDATION = {
'max_attempts': 3,
'default_text_message': 'Please enter a valid text response.',
'default_number_message': 'Please enter a valid number.',
'default_confirm_message': 'Please enter Y/N or Yes/No.',
'default_choice_message': 'Please select a valid option from the list.'
}
QUESTION_TYPES = [
'Text',
'Number',
'Confirm',
'Choice'
]
# Constants for theme weight management and selection
# Multiplier for initial card pool size during theme-based selection
THEME_POOL_SIZE_MULTIPLIER: Final[float] = 2.0
# Bonus multiplier for cards that match multiple deck themes
THEME_PRIORITY_BONUS: Final[float] = 1.2
# Safety multiplier to avoid overshooting target counts
THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
'primary': 1.0,
'secondary': 0.6,
'tertiary': 0.3,
'hidden': 0.0
}
WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = {
'kindred_primary': 1.5, # Boost for Kindred themes as primary
'kindred_secondary': 1.3, # Boost for Kindred themes as secondary
'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary
'theme_synergy': 1.2 # Boost for themes that work well together
}
DEFAULT_THEME_TAGS = [
'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink',
'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones',
'Combat Matters', 'Control', 'Counters Matter', 'Energy',
'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect',
'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter',
'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate',
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Super Friends',
'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells'
]
# 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'
]
# 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
# Required DataFrame columns
DATAFRAME_REQUIRED_COLUMNS: Final[List[str]] = [
'name', 'type', 'colorIdentity', 'manaValue', 'text',
'edhrecRank', 'themeTags', 'keywords'
]
# 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 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'
]

File diff suppressed because it is too large Load diff

1388
code/exceptions.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
"""Initialize the file_setup package."""
from .setup import setup, regenerate_csv_by_color
__all__ = [
'setup',
'regenerate_csv_by_color'
]

337
code/file_setup/setup.py Normal file
View file

@ -0,0 +1,337 @@
"""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.
"""
from __future__ import annotations
# Standard library imports
import logging
from enum import Enum
import os
from pathlib import Path
from typing import Union, List, Dict, Any
# Third-party imports
import inquirer
import pandas as pd
# Local imports
import logging_util
from settings import CSV_DIRECTORY
from .setup_constants import BANNED_CARDS, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL
from .setup_utils import (
download_cards_csv,
filter_by_color_identity,
filter_dataframe,
process_legendary_cards
)
from exceptions import (
CSVFileNotFoundError,
ColorFilterError,
CommanderValidationError,
DataFrameProcessingError,
MTGJSONDownloadError
)
# Create logger for this module
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
# Create CSV directory if it doesn't exist
if not os.path.exists(CSV_DIRECTORY):
os.makedirs(CSV_DIRECTORY)
def check_csv_exists(file_path: Union[str, Path]) -> bool:
"""Check if a CSV file exists at the specified path.
Args:
file_path: Path to the CSV file to check
Returns:
bool: True if file exists, False otherwise
Raises:
CSVFileNotFoundError: If there are issues accessing the file path
"""
try:
with open(file_path, 'r', encoding='utf-8'):
return True
except FileNotFoundError:
return False
except Exception as e:
raise CSVFileNotFoundError(f'Error checking CSV file: {str(e)}')
def initial_setup() -> None:
"""Perform initial setup by downloading card data and creating filtered CSV files.
Downloads the latest card data from MTGJSON if needed, creates color-filtered CSV files,
and generates commander-eligible cards list. Uses utility functions from setup_utils.py
for file operations and data processing.
Raises:
CSVFileNotFoundError: If required CSV files cannot be found
MTGJSONDownloadError: If card data download fails
DataFrameProcessingError: If data processing fails
ColorFilterError: If color filtering fails
"""
logger.info('Checking for cards.csv file')
try:
cards_file = f'{CSV_DIRECTORY}/cards.csv'
try:
with open(cards_file, 'r', encoding='utf-8'):
logger.info('cards.csv exists')
except FileNotFoundError:
logger.info('cards.csv not found, downloading from mtgjson')
download_cards_csv(MTGJSON_API_URL, cards_file)
df = pd.read_csv(cards_file, low_memory=False)
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
logger.info('Checking for color identity sorted files')
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'):
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')
# Generate commander list
determine_commanders()
except Exception as e:
logger.error(f'Error during initial setup: {str(e)}')
raise
def filter_by_color(df: pd.DataFrame, column_name: str, value: str, new_csv_name: Union[str, Path]) -> None:
"""Filter DataFrame by color identity and save to CSV.
Args:
df: DataFrame to filter
column_name: Column to filter on (should be 'colorIdentity')
value: Color identity value to filter for
new_csv_name: Path to save filtered CSV
Raises:
ColorFilterError: If filtering fails
DataFrameProcessingError: If DataFrame processing fails
CSVFileNotFoundError: If CSV file operations fail
"""
try:
# Check if target CSV already exists
if check_csv_exists(new_csv_name):
logger.info(f'{new_csv_name} already exists, will be overwritten')
filtered_df = filter_by_color_identity(df, value)
filtered_df.to_csv(new_csv_name, index=False)
logger.info(f'Successfully created {new_csv_name}')
except (ColorFilterError, DataFrameProcessingError, CSVFileNotFoundError) as e:
logger.error(f'Failed to filter by color {value}: {str(e)}')
raise
def determine_commanders() -> None:
"""Generate commander_cards.csv containing all cards eligible to be commanders.
This function processes the card database to identify and validate commander-eligible cards,
applying comprehensive validation steps and filtering criteria.
Raises:
CSVFileNotFoundError: If cards.csv is missing and cannot be downloaded
MTGJSONDownloadError: If downloading cards data fails
CommanderValidationError: If commander validation fails
DataFrameProcessingError: If data processing operations fail
"""
logger.info('Starting commander card generation process')
try:
# Check for cards.csv with progress tracking
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)
else:
logger.info('cards.csv found, proceeding with processing')
# Load and process cards data
logger.info('Loading card data from CSV')
df = pd.read_csv(cards_file, low_memory=False)
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
# Process legendary cards with validation
logger.info('Processing and validating legendary cards')
try:
filtered_df = process_legendary_cards(df)
except CommanderValidationError as e:
logger.error(f'Commander validation failed: {str(e)}')
raise
# Apply standard filters
logger.info('Applying standard card filters')
filtered_df = filter_dataframe(filtered_df, BANNED_CARDS)
# Save commander cards
logger.info('Saving validated commander cards')
filtered_df.to_csv(f'{CSV_DIRECTORY}/commander_cards.csv', index=False)
logger.info('Commander card generation completed successfully')
except (CSVFileNotFoundError, MTGJSONDownloadError) as e:
logger.error(f'File operation error: {str(e)}')
raise
except CommanderValidationError as e:
logger.error(f'Commander validation error: {str(e)}')
raise
except Exception as e:
logger.error(f'Unexpected error during commander generation: {str(e)}')
raise
def regenerate_csvs_all() -> None:
"""Regenerate all color-filtered CSV files from latest card data.
Downloads fresh card data and recreates all color-filtered CSV files.
Useful for updating the card database when new sets are released.
Raises:
MTGJSONDownloadError: If card data download fails
DataFrameProcessingError: If data processing fails
ColorFilterError: If color filtering fails
"""
try:
logger.info('Downloading latest card data from MTGJSON')
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['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
logger.info('Regenerating color identity sorted files')
for i in range(min(len(SETUP_COLORS), len(COLOR_ABRV))):
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')
logger.info('Regenerating commander cards')
determine_commanders()
logger.info('Card database regeneration complete')
except Exception as e:
logger.error(f'Failed to regenerate card database: {str(e)}')
raise
# Once files are regenerated, create a new legendary list
determine_commanders()
def regenerate_csv_by_color(color: str) -> None:
"""Regenerate CSV file for a specific color identity.
Args:
color: Color name to regenerate CSV for (e.g. 'white', 'blue')
Raises:
ValueError: If color is not valid
MTGJSONDownloadError: If card data download fails
DataFrameProcessingError: If data processing fails
ColorFilterError: If color filtering fails
"""
try:
if color not in SETUP_COLORS:
raise ValueError(f'Invalid color: {color}')
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')
logger.info('Loading and processing card data')
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')
logger.info(f'Successfully regenerated {color} cards database')
except Exception as e:
logger.error(f'Failed to regenerate {color} cards: {str(e)}')
raise
class SetupOption(Enum):
"""Enum for setup menu options."""
INITIAL_SETUP = 'Initial Setup'
REGENERATE_CSV = 'Regenerate CSV Files'
BACK = 'Back'
def _display_setup_menu() -> SetupOption:
"""Display the setup menu and return the selected option.
Returns:
SetupOption: The selected menu option
"""
question: List[Dict[str, Any]] = [
inquirer.List(
'menu',
choices=[option.value for option in SetupOption],
carousel=True)]
answer = inquirer.prompt(question)
return SetupOption(answer['menu'])
def setup() -> bool:
"""Run the setup process for the MTG Python Deckbuilder.
This function provides a menu-driven interface to:
1. Perform initial setup by downloading and processing card data
2. Regenerate CSV files with updated card data
3. Perform all tagging processes on the color-sorted csv files
The function handles errors gracefully and provides feedback through logging.
Returns:
bool: True if setup completed successfully, False otherwise
"""
try:
print('Which setup operation would you like to perform?\n'
'If this is your first time setting up, do the initial setup.\n'
'If you\'ve done the basic setup before, you can regenerate the CSV files\n')
choice = _display_setup_menu()
if choice == SetupOption.INITIAL_SETUP:
logger.info('Starting initial setup')
initial_setup()
logger.info('Initial setup completed successfully')
return True
elif choice == SetupOption.REGENERATE_CSV:
logger.info('Starting CSV regeneration')
regenerate_csvs_all()
logger.info('CSV regeneration completed successfully')
return True
elif choice == SetupOption.BACK:
logger.info('Setup cancelled by user')
return False
except Exception as e:
logger.error(f'Error during setup: {e}')
raise
return False

View file

@ -0,0 +1,118 @@
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
BANNED_CARDS: List[str] = [# in commander
'Ancestral Recall', 'Balance', 'Biorhythm', 'Black Lotus',
'Braids, Cabal Minion', 'Chaos Orb', 'Coalition Victory',
'Channel', 'Dockside Extortionist', 'Emrakul, the Aeons Torn',
'Erayo, Soratami Ascendant', 'Falling Star', 'Fastbond',
'Flash', 'Gifts Ungiven', 'Golos, Tireless Pilgrim',
'Griselbrand', 'Hullbreacher', 'Iona, Shield of Emeria',
'Karakas', 'Jeweled Lotus', 'Leovold, Emissary of Trest',
'Library of Alexandria', 'Limited Resources', 'Lutri, the Spellchaser',
'Mana Crypt', 'Mox Emerald', 'Mox Jet', 'Mox Pearl', 'Mox Ruby',
'Mox Sapphire', 'Nadu, Winged Wisdom', 'Panoptic Mirror',
'Paradox Engine', 'Primeval Titan', 'Prophet of Kruphix',
'Recurring Nightmare', 'Rofellos, Llanowar Emissary', 'Shahrazad',
'Sundering Titan', 'Sway of the Stars', 'Sylvan Primordial',
'Time Vault', 'Time Walk', 'Tinker', 'Tolarian Academy',
'Trade Secrets', 'Upheaval', 'Yawgmoth\'s Bargain',
# In constructed
'Invoke Prejudice', 'Cleanse', 'Stone-Throwing Devils', 'Pradesh Gypsies',
'Jihad', 'Imprison', 'Crusade'
]
SETUP_COLORS: List[str] = ['colorless', 'white', 'blue', 'black', 'green', 'red',
'azorius', 'orzhov', 'selesnya', 'boros', 'dimir',
'simic', 'izzet', 'golgari', 'rakdos', 'gruul',
'bant', 'esper', 'grixis', 'jund', 'naya',
'abzan', 'jeskai', 'mardu', 'sultai', 'temur',
'dune', 'glint', 'ink', 'witch', 'yore', 'wubrg']
COLOR_ABRV: List[str] = ['Colorless', 'W', 'U', 'B', 'G', 'R',
'U, W', 'B, W', 'G, W', 'R, W', 'B, U',
'G, U', 'R, U', 'B, G', 'B, R', 'G, R',
'G, U, W', 'B, U, W', 'B, R, U', 'B, G, R', 'G, R, W',
'B, G, W', 'R, U, W', 'B, R, W', 'B, G, U', 'G, R, U',
'B, G, R, W', 'B, G, R, U', 'G, R, U, W', 'B, G, U, W',
'B, R, U, W', 'B, G, R, U, W']
# Constants for setup and CSV processing
MTGJSON_API_URL: str = 'https://mtgjson.com/api/v5/csv/cards.csv'
LEGENDARY_OPTIONS: List[str] = [
'Legendary Creature',
'Legendary Artifact',
'Legendary Artifact Creature',
'Legendary Enchantment Creature',
'Legendary Planeswalker'
]
NON_LEGAL_SETS: List[str] = [
'PHTR', 'PH17', 'PH18', 'PH19', 'PH20', 'PH21',
'UGL', 'UND', 'UNH', 'UST'
]
CARD_TYPES_TO_EXCLUDE: List[str] = [
'Plane —',
'Conspiracy',
'Vanguard',
'Scheme',
'Phenomenon',
'Stickers',
'Attraction',
'Hero',
'Contraption'
]
# Columns to keep when processing CSV files
CSV_PROCESSING_COLUMNS: List[str] = [
'name', # Card name
'faceName', # Name of specific face for multi-faced cards
'edhrecRank', # Card's rank on EDHREC
'colorIdentity', # Color identity for Commander format
'colors', # Actual colors in card's mana cost
'manaCost', # Mana cost string
'manaValue', # Converted mana cost
'type', # Card type line
'layout', # Card layout (normal, split, etc)
'text', # Card text/rules
'power', # Power (for creatures)
'toughness', # Toughness (for creatures)
'keywords', # Card's keywords
'side' # Side identifier for multi-faced cards
]
# Configuration for DataFrame sorting operations
SORT_CONFIG = {
'columns': ['name', 'side'], # Columns to sort by
'case_sensitive': False # Ignore case when sorting
}
# Configuration for DataFrame filtering operations
FILTER_CONFIG: Dict[str, Dict[str, List[str]]] = {
'layout': {
'exclude': ['reversible_card']
},
'availability': {
'require': ['paper']
},
'promoTypes': {
'exclude': ['playtest']
},
'securityStamp': {
'exclude': ['Heart', 'Acorn']
}
}
COLUMN_ORDER: List[str] = [
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
]
TAGGED_COLUMN_ORDER: List[str] = [
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
]

View file

@ -0,0 +1,408 @@
"""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
# Third-party imports
import pandas as pd
from tqdm import tqdm
# Local application imports
from .setup_constants import (
CSV_PROCESSING_COLUMNS,
CARD_TYPES_TO_EXCLUDE,
NON_LEGAL_SETS,
LEGENDARY_OPTIONS,
SORT_CONFIG,
FILTER_CONFIG,
COLUMN_ORDER,
TAGGED_COLUMN_ORDER
)
from exceptions import (
MTGJSONDownloadError,
DataFrameProcessingError,
ColorFilterError,
CommanderValidationError
)
from type_definitions import CardLibraryDF
from settings import FILL_NA_COLUMNS
import logging_util
# Create logger for this module
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
# Type definitions
class FilterRule(TypedDict):
"""Type definition for filter rules configuration."""
exclude: Optional[List[str]]
require: Optional[List[str]]
class FilterConfig(TypedDict):
"""Type definition for complete filter configuration."""
layout: FilterRule
availability: FilterRule
promoTypes: FilterRule
securityStamp: FilterRule
def download_cards_csv(url: str, output_path: Union[str, Path]) -> None:
"""Download cards data from MTGJSON and save to CSV.
Downloads card data from the specified MTGJSON URL and saves it to a local CSV file.
Shows a progress bar during download using tqdm.
Args:
url: URL to download cards data from (typically MTGJSON API endpoint)
output_path: Path where the downloaded CSV file will be saved
Raises:
MTGJSONDownloadError: If download fails due to network issues or invalid response
Example:
>>> download_cards_csv('https://mtgjson.com/api/v5/cards.csv', 'cards.csv')
"""
try:
response = requests.get(url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
with open(output_path, 'wb') as f:
with tqdm(total=total_size, unit='iB', unit_scale=True, desc='Downloading cards data') as pbar:
for chunk in response.iter_content(chunk_size=8192):
size = f.write(chunk)
pbar.update(size)
except requests.RequestException as e:
logger.error(f'Failed to download cards data from {url}')
raise MTGJSONDownloadError(
"Failed to download cards data",
url,
getattr(e.response, 'status_code', None) if hasattr(e, 'response') else None
) from e
def check_csv_exists(filepath: Union[str, Path]) -> bool:
"""Check if a CSV file exists at the specified path.
Verifies the existence of a CSV file at the given path. This function is used
to determine if card data needs to be downloaded or if it already exists locally.
Args:
filepath: Path to the CSV file to check
Returns:
bool: True if the file exists, False otherwise
Example:
>>> if not check_csv_exists('cards.csv'):
... download_cards_csv(MTGJSON_API_URL, 'cards.csv')
"""
return Path(filepath).is_file()
def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
"""Apply standard filters to the cards DataFrame using configuration from settings.
Applies a series of filters to the cards DataFrame based on configuration from settings.py.
This includes handling null values, applying basic filters, removing illegal sets and banned cards,
and processing special card types.
Args:
df: pandas DataFrame containing card data to filter
banned_cards: List of card names that are banned and should be excluded
Returns:
pd.DataFrame: A new DataFrame containing only the cards that pass all filters
Raises:
DataFrameProcessingError: If any filtering operation fails
Example:
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
"""
try:
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)
logger.debug(f'Filled NA values in {col} with {fill_value}')
# Apply basic filters from configuration
filtered_df = df.copy()
filter_config: FilterConfig = FILTER_CONFIG # Type hint for configuration
for field, rules in filter_config.items():
for rule_type, values in rules.items():
if rule_type == 'exclude':
for value in values:
filtered_df = filtered_df[~filtered_df[field].str.contains(value, na=False)]
elif rule_type == 'require':
for value in values:
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
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)]
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)]
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)]
logger.debug('Removed special card types')
# Select columns, sort, and drop duplicates
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
filtered_df = filtered_df.sort_values(
by=SORT_CONFIG['columns'],
key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col
)
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
logger.info('Completed standard DataFrame filtering')
return filtered_df
except Exception as e:
logger.error(f'Failed to filter DataFrame: {str(e)}')
raise DataFrameProcessingError(
"Failed to filter DataFrame",
"standard_filtering",
str(e)
) from e
def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFrame:
"""Filter DataFrame by color identity with additional color-specific processing.
This function extends the base filter_dataframe functionality with color-specific
filtering logic. It is used by setup.py's filter_by_color function but provides
a more robust and configurable implementation.
Args:
df: DataFrame to filter
color_identity: Color identity to filter by (e.g., 'W', 'U,B', 'Colorless')
Returns:
DataFrame filtered by color identity
Raises:
ColorFilterError: If color identity is invalid or filtering fails
DataFrameProcessingError: If general filtering operations fail
"""
try:
logger.info(f'Filtering cards for color identity: {color_identity}')
# Validate color identity
with tqdm(total=1, desc='Validating color identity') as pbar:
if not isinstance(color_identity, str):
raise ColorFilterError(
"Invalid color identity type",
str(color_identity),
"Color identity must be a string"
)
pbar.update(1)
# Apply base filtering
with tqdm(total=1, desc='Applying base filtering') as pbar:
filtered_df = filter_dataframe(df, [])
pbar.update(1)
# Filter by color identity
with tqdm(total=1, desc='Filtering by color identity') as pbar:
filtered_df = filtered_df[filtered_df['colorIdentity'] == 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)
logger.info(f'Completed color identity filtering for {color_identity}')
return filtered_df
except DataFrameProcessingError as e:
raise ColorFilterError(
"Color filtering failed",
color_identity,
str(e)
) from e
except Exception as e:
raise ColorFilterError(
"Unexpected error during color filtering",
color_identity,
str(e)
) from e
def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
"""Process and filter legendary cards for commander eligibility with comprehensive validation.
Args:
df: DataFrame containing all cards
Returns:
DataFrame containing only commander-eligible cards
Raises:
CommanderValidationError: If validation fails for legendary status, special cases, or set legality
DataFrameProcessingError: If general processing fails
"""
try:
logger.info('Starting commander validation process')
filtered_df = df.copy()
# Step 1: Check legendary status
try:
with tqdm(total=1, desc='Checking legendary status') as pbar:
mask = filtered_df['type'].str.contains('|'.join(LEGENDARY_OPTIONS), na=False)
if not mask.any():
raise CommanderValidationError(
"No legendary creatures found",
"legendary_check",
"DataFrame contains no cards matching legendary criteria"
)
filtered_df = filtered_df[mask].copy()
logger.debug(f'Found {len(filtered_df)} legendary cards')
pbar.update(1)
except Exception as e:
raise CommanderValidationError(
"Legendary status check failed",
"legendary_check",
str(e)
) from e
# Step 2: Validate special cases
try:
with tqdm(total=1, desc='Validating special cases') as pbar:
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()
logger.debug(f'Added {len(special_commanders)} special commander cards')
pbar.update(1)
except Exception as e:
raise CommanderValidationError(
"Special case validation failed",
"special_cases",
str(e)
) from e
# Step 3: Verify set legality
try:
with tqdm(total=1, desc='Verifying set legality') as pbar:
initial_count = len(filtered_df)
for set_code in NON_LEGAL_SETS:
filtered_df = filtered_df[
~filtered_df['printings'].str.contains(set_code, na=False)
]
removed_count = initial_count - len(filtered_df)
logger.debug(f'Removed {removed_count} cards from illegal sets')
pbar.update(1)
except Exception as e:
raise CommanderValidationError(
"Set legality verification failed",
"set_legality",
str(e)
) from e
logger.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
return filtered_df
except CommanderValidationError:
raise
except Exception as e:
raise DataFrameProcessingError(
"Failed to process legendary cards",
"commander_processing",
str(e)
) from e
def process_card_dataframe(df: CardLibraryDF, batch_size: int = 1000, columns_to_keep: Optional[List[str]] = None,
include_commander_cols: bool = False, skip_availability_checks: bool = False) -> CardLibraryDF:
"""Process DataFrame with common operations in batches.
Args:
df: DataFrame to process
batch_size: Size of batches for processing
columns_to_keep: List of columns to keep (default: COLUMN_ORDER)
include_commander_cols: Whether to include commander-specific columns
skip_availability_checks: Whether to skip availability and security checks (default: False)
Args:
df: DataFrame to process
batch_size: Size of batches for processing
columns_to_keep: List of columns to keep (default: COLUMN_ORDER)
include_commander_cols: Whether to include commander-specific columns
Returns:
CardLibraryDF: Processed DataFrame with standardized structure
"""
logger.info("Processing card DataFrame...")
if columns_to_keep is None:
columns_to_keep = TAGGED_COLUMN_ORDER.copy()
if include_commander_cols:
commander_cols = ['printings', 'text', 'power', 'toughness', 'keywords']
columns_to_keep.extend(col for col in commander_cols if col not in columns_to_keep)
# Fill NA values
df.loc[:, 'colorIdentity'] = df['colorIdentity'].fillna('Colorless')
df.loc[:, 'faceName'] = df['faceName'].fillna(df['name'])
# Process in batches
total_batches = len(df) // batch_size + 1
processed_dfs = []
for i in tqdm(range(total_batches), desc="Processing batches"):
start_idx = i * batch_size
end_idx = min((i + 1) * batch_size, len(df))
batch = df.iloc[start_idx:end_idx].copy()
if not skip_availability_checks:
columns_to_keep = COLUMN_ORDER.copy()
logger.debug("Performing column checks...")
# Common processing steps
batch = batch[batch['availability'].str.contains('paper', na=False)]
batch = batch.loc[batch['layout'] != 'reversible_card']
batch = batch.loc[batch['promoTypes'] != 'playtest']
batch = batch.loc[batch['securityStamp'] != 'heart']
batch = batch.loc[batch['securityStamp'] != 'acorn']
# Keep only specified columns
batch = batch[columns_to_keep]
processed_dfs.append(batch)
else:
logger.debug("Skipping column checks...")
# Keep only specified columns
batch = batch[columns_to_keep]
processed_dfs.append(batch)
# Combine processed batches
result = pd.concat(processed_dfs, ignore_index=True)
# Final processing
result.drop_duplicates(subset='faceName', keep='first', inplace=True)
result.sort_values(by=['name', 'side'], key=lambda col: col.str.lower(), inplace=True)
logger.info("DataFrame processing completed")
return result

445
code/input_handler.py Normal file
View file

@ -0,0 +1,445 @@
"""Input handling and validation module for MTG Python Deckbuilder."""
from __future__ import annotations
import logging
import os
from typing import Any, List, Optional, Tuple, Union
import inquirer.prompt
from settings import (
COLORS, COLOR_ABRV
)
from deck_builder.builder_constants import (DEFAULT_MAX_CARD_PRICE,
DEFAULT_MAX_DECK_PRICE, DEFAULT_THEME_TAGS, MONO_COLOR_MAP,
DUAL_COLOR_MAP, TRI_COLOR_MAP, OTHER_COLOR_MAP
)
from exceptions import (
CommanderColorError,
CommanderStatsError,
CommanderTagError,
CommanderThemeError,
CommanderTypeError,
DeckBuilderError,
EmptyInputError,
InvalidNumberError,
InvalidQuestionTypeError,
MaxAttemptsError,
PriceError,
PriceLimitError,
PriceValidationError
)
import logging_util
# Create logger for this module
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
class InputHandler:
"""Handles user input operations with validation and error handling.
This class provides methods for collecting and validating different types
of user input including text, numbers, confirmations, and choices.
Attributes:
max_attempts (int): Maximum number of retry attempts for invalid input
default_text (str): Default value for text input
default_number (float): Default value for number input
default_confirm (bool): Default value for confirmation input
"""
def __init__(
self,
max_attempts: int = 3,
default_text: str = '',
default_number: float = 0.0,
default_confirm: bool = True
):
"""Initialize input handler with configuration.
Args:
max_attempts: Maximum number of retry attempts
default_text: Default value for text input
default_number: Default value for number input
default_confirm: Default value for confirmation input
"""
self.max_attempts = max_attempts
self.default_text = default_text
self.default_number = default_number
self.default_confirm = default_confirm
def validate_text(self, result: str) -> bool:
"""Validate text input is not empty.
Args:
result: Text input to validate
Returns:
True if text is not empty after stripping whitespace
Raises:
EmptyInputError: If input is empty or whitespace only
"""
if not result or not result.strip():
raise EmptyInputError()
return True
def validate_number(self, result: str) -> float:
"""Validate and convert string input to float.
Args:
result: Number input to validate
Returns:
Converted float value
Raises:
InvalidNumberError: If input cannot be converted to float
"""
try:
return float(result)
except (ValueError, TypeError):
raise InvalidNumberError(result)
def validate_price(self, result: str) -> Tuple[float, bool]:
"""Validate and convert price input to float with format checking.
Args:
result: Price input to validate
Returns:
Tuple of (price value, is_unlimited flag)
Raises:
PriceValidationError: If price format is invalid
"""
result = result.strip().lower()
# Check for unlimited budget
if result in ['unlimited', 'any']:
return (float('inf'), True)
# Remove currency symbol if present
if result.startswith('$'):
result = result[1:]
try:
price = float(result)
if price < 0:
raise PriceValidationError('Price cannot be negative')
return (price, False)
except ValueError:
raise PriceValidationError(f"Invalid price format: '{result}'")
def validate_price_threshold(self, price: float, threshold: float = DEFAULT_MAX_CARD_PRICE) -> bool:
"""Validate price against maximum threshold.
Args:
price: Price value to check
threshold: Maximum allowed price (default from settings)
Returns:
True if price is within threshold
Raises:
PriceLimitError: If price exceeds threshold
"""
if price > threshold and price != float('inf'):
raise PriceLimitError('Card', price, threshold)
return True
def validate_confirm(self, result: bool) -> bool:
"""Validate confirmation input.
Args:
result: Boolean confirmation input
Returns:
The boolean input value
"""
return bool(result)
def questionnaire(
self,
question_type: str,
message: str = '',
default_value: Any = None,
choices_list: List[str] = None
) -> Union[str, float, bool]:
"""Present questions to user and handle input validation.
Args:
question_type: Type of question ('Text', 'Number', 'Confirm', 'Choice')
message: Question message to display
default_value: Default value for the question
choices_list: List of choices for Choice type questions
Returns:
Validated user input of appropriate type
Raises:
InvalidQuestionTypeError: If question_type is not supported
MaxAttemptsError: If maximum retry attempts are exceeded
"""
attempts = 0
while attempts < self.max_attempts:
try:
if question_type == 'Text':
question = [
inquirer.Text(
'text',
message=f'{message}' or 'Enter text',
default=default_value or self.default_text
)
]
result = inquirer.prompt(question)['text']
if self.validate_text(result):
return str(result)
elif question_type == 'Price':
question = [
inquirer.Text(
'price',
message=f'{message}' or 'Enter price (or "unlimited")',
default=str(default_value or DEFAULT_MAX_CARD_PRICE)
)
]
result = inquirer.prompt(question)['price']
price, is_unlimited = self.validate_price(result)
if not is_unlimited:
self.validate_price_threshold(price)
return float(price)
elif question_type == 'Number':
question = [
inquirer.Text(
'number',
message=f'{message}' or 'Enter number',
default=str(default_value or self.default_number)
)
]
result = inquirer.prompt(question)['number']
return self.validate_number(result)
elif question_type == 'Confirm':
question = [
inquirer.Confirm(
'confirm',
message=f'{message}' or 'Confirm?',
default=default_value if default_value is not None else self.default_confirm
)
]
result = inquirer.prompt(question)['confirm']
return self.validate_confirm(result)
elif question_type == 'Choice':
if not choices_list:
raise ValueError("Choices list cannot be empty for Choice type")
question = [
inquirer.List(
'selection',
message=f'{message}' or 'Select an option',
choices=choices_list,
carousel=True
)
]
return inquirer.prompt(question)['selection']
else:
raise InvalidQuestionTypeError(question_type)
except DeckBuilderError as e:
logger.warning(f"Input validation failed: {e}")
attempts += 1
if attempts >= self.max_attempts:
raise MaxAttemptsError(
self.max_attempts,
question_type.lower(),
{"last_error": str(e)}
)
except Exception as e:
logger.error(f"Unexpected error in questionnaire: {e}")
raise
raise MaxAttemptsError(self.max_attempts, question_type.lower())
def validate_commander_type(self, type_line: str) -> str:
"""Validate commander type line requirements.
Args:
type_line: Commander's type line to validate
Returns:
Validated type line
Raises:
CommanderTypeError: If type line validation fails
"""
if not type_line:
raise CommanderTypeError("Type line cannot be empty")
type_line = type_line.strip()
# Check for legendary creature requirement
if not ('Legendary' in type_line and 'Creature' in type_line):
# Check for 'can be your commander' text
if 'can be your commander' not in type_line.lower():
raise CommanderTypeError(
"Commander must be a legendary creature or have 'can be your commander' text"
)
return type_line
def validate_commander_stats(self, stat_name: str, value: str) -> int:
"""Validate commander numerical statistics.
Args:
stat_name: Name of the stat (power, toughness, mana value)
value: Value to validate
Returns:
Validated integer value
Raises:
CommanderStatsError: If stat validation fails
"""
try:
stat_value = int(value)
if stat_value < 0 and stat_name != 'power':
raise CommanderStatsError(f"{stat_name} cannot be negative")
return stat_value
except ValueError:
raise CommanderStatsError(
f"Invalid {stat_name} value: '{value}'. Must be a number."
)
def _normalize_color_string(self, colors: str) -> str:
"""Helper method to standardize color string format.
Args:
colors: Raw color string to normalize
Returns:
Normalized color string
"""
if not colors:
return 'colorless'
# Remove whitespace and sort color symbols
colors = colors.strip().upper()
color_symbols = [c for c in colors if c in 'WUBRG']
return ', '.join(sorted(color_symbols))
def _validate_color_combination(self, colors: str) -> bool:
"""Helper method to validate color combinations.
Args:
colors: Normalized color string to validate
Returns:
True if valid, False otherwise
"""
if colors == 'colorless':
return True
# Check against valid combinations from settings
return (colors in COLOR_ABRV or
any(colors in combo for combo in [MONO_COLOR_MAP, DUAL_COLOR_MAP,
TRI_COLOR_MAP, OTHER_COLOR_MAP]))
def validate_color_identity(self, colors: str) -> str:
"""Validate commander color identity using settings constants.
Args:
colors: Color identity string to validate
Returns:
Validated color identity string
Raises:
CommanderColorError: If color validation fails
"""
# Normalize the color string
normalized = self._normalize_color_string(colors)
# Validate the combination
if not self._validate_color_combination(normalized):
raise CommanderColorError(
f"Invalid color identity: '{colors}'. Must be a valid color combination."
)
return normalized
def validate_commander_colors(self, colors: str) -> str:
"""Validate commander color identity.
Args:
colors: Color identity string to validate
Returns:
Validated color identity string
Raises:
CommanderColorError: If color validation fails
"""
try:
return self.validate_color_identity(colors)
except CommanderColorError as e:
logger.error(f"Color validation failed: {e}")
raise
def validate_commander_tags(self, tags: List[str]) -> List[str]:
"""Validate commander theme tags.
Args:
tags: List of theme tags to validate
Returns:
Validated list of theme tags
Raises:
CommanderTagError: If tag validation fails
"""
if not isinstance(tags, list):
raise CommanderTagError("Tags must be provided as a list")
validated_tags = []
for tag in tags:
if not isinstance(tag, str):
raise CommanderTagError(f"Invalid tag type: {type(tag)}. Must be string.")
tag = tag.strip()
if tag:
validated_tags.append(tag)
return validated_tags
def validate_commander_themes(self, themes: List[str]) -> List[str]:
"""Validate commander themes.
Args:
themes: List of themes to validate
Returns:
Validated list of themes
Raises:
CommanderThemeError: If theme validation fails
"""
if not isinstance(themes, list):
raise CommanderThemeError("Themes must be provided as a list")
validated_themes = []
for theme in themes:
if not isinstance(theme, str):
raise CommanderThemeError(f"Invalid theme type: {type(theme)}. Must be string.")
theme = theme.strip()
if theme and theme in DEFAULT_THEME_TAGS:
validated_themes.append(theme)
else:
raise CommanderThemeError(f"Invalid theme: '{theme}'")
return validated_themes

29
code/logging_util.py Normal file
View file

@ -0,0 +1,29 @@
from __future__ import annotations
from settings import os
import logging
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):
os.makedirs('logs')
# Logging configuration
LOG_DIR = 'logs'
LOG_FILE = os.path.join(LOG_DIR, 'deck_builder.log')
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
LOG_LEVEL = logging.INFO
# Create formatters and handlers
# Create a formatter that removes double underscores
class NoDunderFormatter(logging.Formatter):
def format(self, record):
record.name = record.name.replace("__", "")
return super().format(record)
# File handler
file_handler = logging.FileHandler(LOG_FILE, mode='w', encoding='utf-8')
file_handler.setFormatter(NoDunderFormatter(LOG_FORMAT))
# Stream handler
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(NoDunderFormatter(LOG_FORMAT))

120
code/main.py Normal file
View file

@ -0,0 +1,120 @@
"""Command-line interface for the MTG Python Deckbuilder application.
This module provides the main menu and user interaction functionality for the
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.
"""
from __future__ import annotations
# Standard library imports
import sys
from pathlib import Path
from typing import NoReturn, Optional
# Third-party imports
import inquirer.prompt
# Local imports
from deck_builder import DeckBuilder
from file_setup import setup
from tagging import tagger
import logging_util
# Create logger for this module
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
# Menu constants
MENU_SETUP = 'Setup'
MAIN_TAG = 'Tag CSV Files'
MENU_BUILD_DECK = 'Build a Deck'
MENU_QUIT = 'Quit'
MENU_CHOICES = [MENU_SETUP, MAIN_TAG, MENU_BUILD_DECK, MENU_QUIT]
builder = DeckBuilder()
def get_menu_choice() -> Optional[str]:
"""Display the main menu and get user choice.
Presents a menu of options to the user using inquirer and returns their selection.
Handles potential errors from inquirer gracefully.
Returns:
Optional[str]: The selected menu option or None if cancelled/error occurs
Example:
>>> choice = get_menu_choice()
>>> if choice == MENU_SETUP:
... setup.setup()
"""
question = [
inquirer.List('menu',
choices=MENU_CHOICES,
carousel=True)
]
try:
answer = inquirer.prompt(question)
return answer['menu'] if answer else None
except (KeyError, TypeError) as e:
logger.error(f"Error getting menu choice: {e}")
return None
def run_menu() -> NoReturn:
"""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.
Never returns normally - exits via sys.exit().
Returns:
NoReturn: Function never returns normally
Raises:
SystemExit: When user selects Quit option
Example:
>>> run_menu()
What would you like to do?
1. Setup
2. Build a Deck
3. Get Card Info
4. Tag CSV Files
5. Quit
"""
logger.info("Starting MTG Python Deckbuilder")
Path('csv_files').mkdir(parents=True, exist_ok=True)
Path('deck_files').mkdir(parents=True, exist_ok=True)
Path('logs').mkdir(parents=True, exist_ok=True)
while True:
try:
print('What would you like to do?')
choice = get_menu_choice()
if choice is None:
logger.info("Menu operation cancelled")
continue
logger.info(f"User selected: {choice}")
match choice:
case 'Setup':
setup()
case 'Tag CSV Files':
tagger.run_tagging()
case 'Build a Deck':
builder.determine_commander()
case 'Quit':
logger.info("Exiting application")
sys.exit(0)
case _:
logger.warning(f"Invalid menu choice: {choice}")
except Exception as e:
logger.error(f"Unexpected error in main menu: {e}")
if __name__ == "__main__":
run_menu()

207
code/price_check.py Normal file
View file

@ -0,0 +1,207 @@
"""Price checking functionality for MTG Python Deckbuilder.
This module provides functionality to check card prices using the Scryfall API
through the scrython library. It includes caching and error handling for reliable
price lookups.
"""
from __future__ import annotations
# Standard library imports
import time
from functools import lru_cache
from typing import Dict, List, Optional, Tuple, Union
# Third-party imports
import scrython
from scrython.cards import Named as ScryfallCard
# Local imports
from exceptions import (
PriceAPIError,
PriceLimitError,
PriceTimeoutError,
PriceValidationError
)
from deck_builder.builder_constants 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
import logging_util
# Create logger for this module
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
class PriceChecker:
"""Class for handling MTG card price checking and validation.
This class provides functionality for checking card prices via the Scryfall API,
validating prices against thresholds, and managing price caching.
Attributes:
price_cache (Dict[str, float]): Cache of card prices
max_card_price (float): Maximum allowed price per card
max_deck_price (float): Maximum allowed total deck price
current_deck_price (float): Current total price of the deck
"""
def __init__(
self,
max_card_price: float = DEFAULT_MAX_CARD_PRICE,
max_deck_price: float = DEFAULT_MAX_DECK_PRICE
) -> None:
"""Initialize the PriceChecker.
Args:
max_card_price: Maximum allowed price per card
max_deck_price: Maximum allowed total deck price
"""
self.price_cache: PriceCache = {}
self.max_card_price: float = max_card_price
self.max_deck_price: float = max_deck_price
self.current_deck_price: float = 0.0
@lru_cache(maxsize=PRICE_CACHE_SIZE)
def get_card_price(self, card_name: str, attempts: int = 0) -> float:
"""Get the price of a card with caching and retry logic.
Args:
card_name: Name of the card to check
attempts: Current number of retry attempts
Returns:
Float price of the card in USD
Raises:
PriceAPIError: If price lookup fails after max attempts
PriceTimeoutError: If request times out
PriceValidationError: If received price data is invalid
"""
# Check cache first
if card_name in self.price_cache:
return self.price_cache[card_name]
try:
# Add delay between API calls
time.sleep(DEFAULT_PRICE_DELAY)
# Make API request with type hints
card: ScryfallCard = scrython.cards.Named(fuzzy=card_name, timeout=PRICE_CHECK_TIMEOUT)
price: Optional[str] = card.prices('usd')
# Handle None or empty string cases
if price is None or price == "":
return 0.0
# Validate and cache price
if isinstance(price, (int, float, str)):
try:
# Convert string or numeric price to float
price_float = float(price)
self.price_cache[card_name] = price_float
return price_float
except ValueError:
raise PriceValidationError(card_name, str(price))
return 0.0
except scrython.foundation.ScryfallError as e:
if attempts < MAX_PRICE_CHECK_ATTEMPTS:
logger.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:
logger.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))
logger.error(f"Error checking price for {card_name}: {e}")
if errors:
logger.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
logger.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()
logger.info("Price cache cleared")

40
code/settings.py Normal file
View file

@ -0,0 +1,40 @@
from __future__ import annotations
# Standard library imports
import os
from sys import exit
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
# Third-party imports
COLORS = ['colorless', 'white', 'blue', 'black', 'red', 'green',
'azorius', 'orzhov', 'selesnya', 'boros', 'dimir',
'simic', 'izzet', 'golgari', 'rakdos', 'gruul',
'bant', 'esper', 'grixis', 'jund', 'naya',
'abzan', 'jeskai', 'mardu', 'sultai', 'temur',
'dune', 'glint', 'ink', 'witch', 'yore', 'wubrg',
'commander']
COLOR_ABRV: List[str] = ['Colorless', 'W', 'U', 'B', 'G', 'R',
'U, W', 'B, W', 'G, W', 'R, W', 'B, U',
'G, U', 'R, U', 'B, G', 'B, R', 'G, R',
'G, U, W', 'B, U, W', 'B, R, U', 'B, G, R', 'G, R, W',
'B, G, W', 'R, U, W', 'B, R, W', 'B, G, U', 'G, R, U',
'B, G, R, W', 'B, G, R, U', 'G, R, U, W', 'B, G, U, W',
'B, R, U, W', 'B, G, R, U, W']
MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit']
SETUP_MENU_ITEMS: List[str] = ['Initial Setup', 'Regenerate CSV', 'Main Menu']
CSV_DIRECTORY: str = 'csv_files'
# Configuration for handling null/NA values in DataFrame columns
FILL_NA_COLUMNS: Dict[str, Optional[str]] = {
'colorIdentity': 'Colorless', # Default color identity for cards without one
'faceName': None # Use card's name column value when face name is not available
}
MULTIPLE_COPY_CARDS = ['Dragon\'s Approach', 'Hare Apparent', 'Nazgûl', 'Persistent Petitioners',
'Rat Colony', 'Relentless Rats', 'Seven Dwarves', 'Shadowborn Apostle',
'Slime Against Humanity', 'Templar Knight']

4
code/tagging/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""
This module initializes the tagging package, which contains functionality for
handling card tagging and related operations in the MTG deck builder application.
"""

View file

@ -0,0 +1,718 @@
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
TRIGGERS: List[str] = ['when', 'whenever', 'at']
NUM_TO_SEARCH: List[str] = ['a', 'an', 'one', '1', 'two', '2', 'three', '3', 'four','4', 'five', '5',
'six', '6', 'seven', '7', 'eight', '8', 'nine', '9', 'ten', '10',
'x','one or more']
# Constants for common tag groupings
TAG_GROUPS: Dict[str, List[str]] = {
"Cantrips": ["Cantrips", "Card Draw", "Spellslinger", "Spells Matter"],
"Tokens": ["Token Creation", "Tokens Matter"],
"Counters": ["Counters Matter"],
"Combat": ["Combat Matters", "Combat Tricks"],
"Artifacts": ["Artifacts Matter", "Artifact Tokens"],
"Enchantments": ["Enchantments Matter", "Enchantment Tokens"],
"Lands": ["Lands Matter"],
"Spells": ["Spellslinger", "Spells Matter"]
}
# Common regex patterns
PATTERN_GROUPS: Dict[str, Optional[str]] = {
"draw": r"draw[s]? a card|draw[s]? one card",
"combat": r"attack[s]?|block[s]?|combat damage",
"tokens": r"create[s]? .* token|put[s]? .* token",
"counters": r"\+1/\+1 counter|\-1/\-1 counter|loyalty counter",
"sacrifice": r"sacrifice[s]? .*|sacrificed",
"exile": r"exile[s]? .*|exiled",
"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"
}
# Creature/Counter types
COUNTER_TYPES: List[str] = [r'\+0/\+1', r'\+0/\+2', r'\+1/\+0', r'\+1/\+2', r'\+2/\+0', r'\+2/\+2',
'-0/-1', '-0/-2', '-1/-0', '-1/-2', '-2/-0', '-2/-2',
'Acorn', 'Aegis', 'Age', 'Aim', 'Arrow', 'Arrowhead','Awakening',
'Bait', 'Blaze', 'Blessing', 'Blight',' Blood', 'Bloddline',
'Bloodstain', 'Book', 'Bounty', 'Brain', 'Bribery', 'Brick',
'Burden', 'Cage', 'Carrion', 'Charge', 'Coin', 'Collection',
'Component', 'Contested', 'Corruption', 'CRANK!', 'Credit',
'Croak', 'Corpse', 'Crystal', 'Cube', 'Currency', 'Death',
'Defense', 'Delay', 'Depletion', 'Descent', 'Despair', 'Devotion',
'Divinity', 'Doom', 'Dream', 'Duty', 'Echo', 'Egg', 'Elixir',
'Ember', 'Energy', 'Enlightened', 'Eon', 'Eruption', 'Everything',
'Experience', 'Eyeball', 'Eyestalk', 'Fade', 'Fate', 'Feather',
'Feeding', 'Fellowship', 'Fetch', 'Filibuster', 'Finality', 'Flame',
'Flood', 'Foreshadow', 'Fungus', 'Fury', 'Fuse', 'Gem', 'Ghostform',
'Glpyh', 'Gold', 'Growth', 'Hack', 'Harmony', 'Hatching', 'Hatchling',
'Healing', 'Hit', 'Hope',' Hone', 'Hoofprint', 'Hour', 'Hourglass',
'Hunger', 'Ice', 'Imposter', 'Incarnation', 'Incubation', 'Infection',
'Influence', 'Ingenuity', 'Intel', 'Intervention', 'Invitation',
'Isolation', 'Javelin', 'Judgment', 'Keyword', 'Ki', 'Kick',
'Knickknack', 'Knowledge', 'Landmark', 'Level', 'Loot', 'Lore',
'Loyalty', 'Luck', 'Magnet', 'Manabond', 'Manifestation', 'Mannequin',
'Mask', 'Matrix', 'Memory', 'Midway', 'Mine', 'Mining', 'Mire',
'Music', 'Muster', 'Necrodermis', 'Nest', 'Net', 'Night', 'Oil',
'Omen', 'Ore', 'Page', 'Pain', 'Palliation', 'Paralyzing', 'Pause',
'Petal', 'Petrification', 'Phyresis', 'Phylatery', 'Pin', 'Plague',
'Plot', 'Point', 'Poison', 'Polyp', 'Possession', 'Pressure', 'Prey',
'Pupa', 'Quest', 'Rad', 'Rejection', 'Reprieve', 'Rev', 'Revival',
'Ribbon', 'Ritual', 'Rope', 'Rust', 'Scream', 'Scroll', 'Shell',
'Shield', 'Silver', 'Shred', 'Sleep', 'Sleight', 'Slime', 'Slumber',
'Soot', 'Soul', 'Spark', 'Spite', 'Spore', 'Stash', 'Storage',
'Story', 'Strife', 'Study', 'Stun', 'Supply', 'Suspect', 'Takeover',
'Task', 'Ticket', 'Tide', 'Time', 'Tower', 'Training', 'Trap',
'Treasure', 'Unity', 'Unlock', 'Valor', 'Velocity', 'Verse',
'Vitality', 'Void', 'Volatile', 'Vortex', 'Vow', 'Voyage', 'Wage',
'Winch', 'Wind', 'Wish']
CREATURE_TYPES: List[str] = ['Advisor', 'Aetherborn', 'Alien', 'Ally', 'Angel', 'Antelope', 'Ape', 'Archer', 'Archon', 'Armadillo',
'Army', 'Artificer', 'Assassin', 'Assembly-Worker', 'Astartes', 'Atog', 'Aurochs', 'Automaton',
'Avatar', 'Azra', 'Badger', 'Balloon', 'Barbarian', 'Bard', 'Basilisk', 'Bat', 'Bear', 'Beast', 'Beaver',
'Beeble', 'Beholder', 'Berserker', 'Bird', 'Blinkmoth', 'Boar', 'Brainiac', 'Bringer', 'Brushwagg',
'C\'tan', 'Camarid', 'Camel', 'Capybara', 'Caribou', 'Carrier', 'Cat', 'Centaur', 'Chicken', 'Child',
'Chimera', 'Citizen', 'Cleric', 'Clown', 'Cockatrice', 'Construct', 'Coward', 'Coyote', 'Crab', 'Crocodile',
'Custodes', 'Cyberman', 'Cyclops', 'Dalek', 'Dauthi', 'Demigod', 'Demon', 'Deserter', 'Detective', 'Devil',
'Dinosaur', 'Djinn', 'Doctor', 'Dog', 'Dragon', 'Drake', 'Dreadnought', 'Drone', 'Druid', 'Dryad', 'Dwarf',
'Efreet', 'Egg', 'Elder', 'Eldrazi', 'Elemental', 'Elephant', 'Elf', 'Elk', 'Employee', 'Eye', 'Faerie',
'Ferret', 'Fish', 'Flagbearer', 'Fox', 'Fractal', 'Frog', 'Fungus', 'Gamer', 'Gargoyle', 'Germ', 'Giant',
'Gith', 'Glimmer', 'Gnoll', 'Gnome', 'Goat', 'Goblin', 'God', 'Golem', 'Gorgon', 'Graveborn', 'Gremlin',
'Griffin', 'Guest', 'Hag', 'Halfling', 'Hamster', 'Harpy', 'Head', 'Hellion', 'Hero', 'Hippo', 'Hippogriff',
'Homarid', 'Homunculus', 'Hornet', 'Horror', 'Horse', 'Human', 'Hydra', 'Hyena', 'Illusion', 'Imp',
'Incarnation', 'Inkling', 'Inquisitor', 'Insect', 'Jackal', 'Jellyfish', 'Juggernaut', 'Kavu', 'Kirin',
'Kithkin', 'Knight', 'Kobold', 'Kor', 'Kraken', 'Lamia', 'Lammasu', 'Leech', 'Leviathan', 'Lhurgoyf',
'Licid', 'Lizard', 'Manticore', 'Masticore', 'Mercenary', 'Merfolk', 'Metathran', 'Minion', 'Minotaur',
'Mite', 'Mole', 'Monger', 'Mongoose', 'Monk', 'Monkey', 'Moonfolk', 'Mount', 'Mouse', 'Mutant', 'Myr',
'Mystic', 'Naga', 'Nautilus', 'Necron', 'Nephilim', 'Nightmare', 'Nightstalker', 'Ninja', 'Noble', 'Noggle',
'Nomad', 'Nymph', 'Octopus', 'Ogre', 'Ooze', 'Orb', 'Orc', 'Orgg', 'Otter', 'Ouphe', 'Ox', 'Oyster', 'Pangolin',
'Peasant', 'Pegasus', 'Pentavite', 'Performer', 'Pest', 'Phelddagrif', 'Phoenix', 'Phyrexian', 'Pilot',
'Pincher', 'Pirate', 'Plant', 'Porcupine', 'Possum', 'Praetor', 'Primarch', 'Prism', 'Processor', 'Rabbit',
'Raccoon', 'Ranger', 'Rat', 'Rebel', 'Reflection', 'Reveler', 'Rhino', 'Rigger', 'Robot', 'Rogue', 'Rukh',
'Sable', 'Salamander', 'Samurai', 'Sand', 'Saproling', 'Satyr', 'Scarecrow', 'Scientist', 'Scion', 'Scorpion',
'Scout', 'Sculpture', 'Serf', 'Serpent', 'Servo', 'Shade', 'Shaman', 'Shapeshifter', 'Shark', 'Sheep', 'Siren',
'Skeleton', 'Skunk', 'Slith', 'Sliver', 'Sloth', 'Slug', 'Snail', 'Snake', 'Soldier', 'Soltari', 'Spawn',
'Specter', 'Spellshaper', 'Sphinx', 'Spider', 'Spike', 'Spirit', 'Splinter', 'Sponge', 'Spy', 'Squid',
'Squirrel', 'Starfish', 'Surrakar', 'Survivor', 'Synth', 'Teddy', 'Tentacle', 'Tetravite', 'Thalakos',
'Thopter', 'Thrull', 'Tiefling', 'Time Lord', 'Toy', 'Treefolk', 'Trilobite', 'Triskelavite', 'Troll',
'Turtle', 'Tyranid', 'Unicorn', 'Urzan', 'Vampire', 'Varmint', 'Vedalken', 'Volver', 'Wall', 'Walrus',
'Warlock', 'Warrior', 'Wasp', 'Weasel', 'Weird', 'Werewolf', 'Whale', 'Wizard', 'Wolf', 'Wolverine', 'Wombat',
'Worm', 'Wraith', 'Wurm', 'Yeti', 'Zombie', 'Zubera']
NON_CREATURE_TYPES: List[str] = ['Legendary', 'Creature', 'Enchantment', 'Artifact',
'Battle', 'Sorcery', 'Instant', 'Land', '-', '',
'Blood', 'Clue', 'Food', 'Gold', 'Incubator',
'Junk', 'Map', 'Powerstone', 'Treasure',
'Equipment', 'Fortification', 'vehicle',
'Bobblehead', 'Attraction', 'Contraption',
'Siege',
'Aura', 'Background', 'Saga', 'Role', 'Shard',
'Cartouche', 'Case', 'Class', 'Curse', 'Rune',
'Shrine',
'Plains', 'Island', 'Swamp', 'Forest', 'Mountain',
'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Mine',
'Power-Plant', 'Sphere', 'Tower', 'Urza\'s']
OUTLAW_TYPES: List[str] = ['Assassin', 'Mercenary', 'Pirate', 'Rogue', 'Warlock']
ENCHANTMENT_TOKENS: List[str] = ['Cursed Role', 'Monster Role', 'Royal Role', 'Sorcerer Role',
'Virtuous Role', 'Wicked Role', 'Young Hero Role', 'Shard']
ARTIFACT_TOKENS: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
'Junk','Map','Powerstone', 'Treasure']
# Constants for DataFrame validation and processing
REQUIRED_COLUMNS: List[str] = [
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
]
# Mapping of card types to their corresponding theme tags
TYPE_TAG_MAPPING: List[str] = {
'Artifact': ['Artifacts Matter'],
'Battle': ['Battles Matter'],
#'Creature': [],
'Enchantment': ['Enchantments Matter'],
'Equipment': ['Equipment', 'Voltron'],
'Aura': ['Auras', 'Voltron'],
'Instant': ['Spells Matter', 'Spellslinger'],
'Land': ['Lands Matter'],
'Planeswalker': ['Superfriends'],
'Sorcery': ['Spells Matter', 'Spellslinger']
}
# Constants for draw-related functionality
DRAW_RELATED_TAGS: List[str] = [
'Card Draw', # General card draw effects
'Conditional Draw', # Draw effects with conditions/triggers
'Cycling', # Cycling and similar discard-to-draw effects
'Life to Draw', # Draw effects that require paying life
'Loot', # Draw + discard effects
'Replacement Draw', # Effects that modify or replace draws
'Sacrifice to Draw', # Draw effects requiring sacrificing permanents
'Unconditional Draw' # Pure card draw without conditions
]
# Text patterns that exclude cards from being tagged as unconditional draw
DRAW_EXCLUSION_PATTERNS: List[str] = [
'annihilator', # Eldrazi mechanic that can match 'draw' patterns
'ravenous', # Keyword that can match 'draw' patterns
]
# Equipment-related constants
EQUIPMENT_EXCLUSIONS: List[str] = [
'Bruenor Battlehammer', # Equipment cost reduction
'Nazahn, Revered Bladesmith', # Equipment tutor
'Stonehewer Giant', # Equipment tutor
]
EQUIPMENT_SPECIFIC_CARDS: List[str] = [
'Ardenn, Intrepid Archaeologist', # Equipment movement
'Armory Automaton', # Mass equip ability
'Brass Squire', # Free equip ability
'Danitha Capashen, Paragon', # Equipment cost reduction
'Halvar, God of Battle', # Equipment movement
'Kemba, Kha Regent', # Equipment payoff
'Kosei, Penitent Warlord', # Wants to be eequipped
'Puresteel Paladin', # Equipment draw engine
'Reyav, Master Smith', # Equipment combat boost
'Sram, Senior Edificer', # Equipment card draw
'Valduk, Keeper of the Flame' # Equipment token creation
]
EQUIPMENT_RELATED_TAGS: List[str] = [
'Equipment', # Base equipment tag
'Equipment Matters', # Cards that care about equipment
'Voltron', # Commander-focused equipment strategy
'Artifacts Matter', # Equipment are artifacts
'Warriors Matter', # Common equipment tribal synergy
'Knights Matter' # Common equipment tribal synergy
]
EQUIPMENT_TEXT_PATTERNS: List[str] = [
'attach', # Equipment attachment
'equip', # Equipment keyword
'equipped', # Equipment state
'equipment', # Equipment type
'unattach', # Equipment removal
'unequip', # Equipment removal
]
# Aura-related constants
AURA_SPECIFIC_CARDS: List[str] = [
'Ardenn, Intrepid Archaeologist', # Aura movement
'Calix, Guided By Fate', # Create duplicate Auras
'Gilwain, Casting Director', # Creates role tokens
'Ivy, Gleeful Spellthief', # Copies spells that have single target
'Killian, Ink Duelist', # Targetted spell cost reduction
]
# Constants for Voltron strategy
VOLTRON_COMMANDER_CARDS: List[str] = [
'Akiri, Line-Slinger',
'Ardenn, Intrepid Archaeologist',
'Bruna, Light of Alabaster',
'Danitha Capashen, Paragon',
'Greven, Predator Captain',
'Halvar, God of Battle',
'Kaldra Compleat',
'Kemba, Kha Regent',
'Light-Paws, Emperor\'s Voice',
'Nahiri, the Lithomancer',
'Rafiq of the Many',
'Reyav, Master Smith',
'Rograkh, Son of Rohgahh',
'Sram, Senior Edificer',
'Syr Gwyn, Hero of Ashvale',
'Tiana, Ship\'s Caretaker',
'Uril, the Miststalker',
'Valduk, Keeper of the Flame',
'Wyleth, Soul of Steel'
]
VOLTRON_PATTERNS: List[str] = [
'attach',
'aura you control',
'enchant creature',
'enchanted creature',
'equipped creature',
'equipment you control',
'fortify',
'living weapon',
'reconfigure'
]
# Constants for lands matter functionality
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
'land_play': [
'play a land',
'play an additional land',
'play two additional lands',
'play lands from',
'put a land card',
'put a basic land card'
],
'land_search': [
'search your library for a basic land card',
'search your library for a land card',
'search your library for up to two basic land',
'search their library for a basic land card'
],
'land_state': [
'land enters',
'land card is put into your graveyard',
'number of lands you control',
'one or more land cards',
'sacrifice a land',
'target land'
]
}
DOMAIN_PATTERNS: List[str] = {
'keyword': ['domain'],
'text': ['basic land types among lands you control']
}
LANDFALL_PATTERNS: List[str] = {
'keyword': ['landfall'],
'triggers': [
'whenever a land enters the battlefield under your control',
'when a land enters the battlefield under your control'
]
}
LANDWALK_PATTERNS: List[str] = {
'basic': [
'plainswalker',
'islandwalk',
'swampwalk',
'mountainwalk',
'forestwalk'
],
'nonbasic': [
'nonbasic landwalk',
'landwalk'
]
}
LAND_TYPES: List[str] = [
# Basic lands
'Plains', 'Island', 'Swamp', 'Mountain', 'Forest',
# Special lands
'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Mine',
'Power-Plant', 'Sphere', 'Tower', 'Urza\'s'
]
LANDS_MATTER_SPECIFIC_CARDS: List[str] = [
'Abundance',
'Archdruid\'s Charm',
'Archelos, Lagoon Mystic',
'Catacylsmic Prospecting',
'Coiling Oracle',
'Disorienting Choice',
'Eerie Ultimatum',
'Gitrog Monster',
'Mana Reflection',
'Nahiri\'s Lithoforming',
'Nine-fingers Keene',
'Open the Way',
'Realms Uncharted',
'Reshape the Earth',
'Scapeshift',
'Yarok, the Desecrated',
'Wonderscape Sage'
]
# Constants for aristocrats functionality
ARISTOCRAT_TEXT_PATTERNS: List[str] = [
'another creature dies',
'creature dies',
'creature dying',
'creature you control dies',
'creature you own dies',
'dies this turn',
'dies, create',
'dies, draw',
'dies, each opponent',
'dies, exile',
'dies, put',
'dies, return',
'dies, sacrifice',
'dies, you',
'has blitz',
'have blitz',
'permanents were sacrificed',
'sacrifice a creature',
'sacrifice another',
'sacrifice another creature',
'sacrifice a nontoken',
'sacrifice a permanent',
'sacrifice another nontoken',
'sacrifice another permanent',
'sacrifice another token',
'sacrifices a creature',
'sacrifices another',
'sacrifices another creature',
'sacrifices another nontoken',
'sacrifices another permanent',
'sacrifices another token',
'sacrifices a nontoken',
'sacrifices a permanent',
'sacrifices a token',
'when this creature dies',
'whenever a food',
'whenever you sacrifice'
]
ARISTOCRAT_SPECIFIC_CARDS: List[str] = [
'Ashnod, Flesh Mechanist',
'Blood Artist',
'Butcher of Malakir',
'Chatterfang, Squirrel General',
'Cruel Celebrant',
'Dictate of Erebos',
'Endrek Sahr, Master Breeder',
'Gisa, Glorious Resurrector',
'Grave Pact',
'Grim Haruspex',
'Judith, the Scourge Diva',
'Korvold, Fae-Cursed King',
'Mayhem Devil',
'Midnight Reaper',
'Mikaeus, the Unhallowed',
'Pitiless Plunderer',
'Poison-Tip Archer',
'Savra, Queen of the Golgari',
'Sheoldred, the Apocalypse',
'Syr Konrad, the Grim',
'Teysa Karlov',
'Viscera Seer',
'Yawgmoth, Thran Physician',
'Zulaport Cutthroat'
]
ARISTOCRAT_EXCLUSION_PATTERNS: List[str] = [
'blocking enchanted',
'blocking it',
'blocked by',
'end the turn',
'from your graveyard',
'from your hand',
'from your library',
'into your hand'
]
# Constants for stax functionality
STAX_TEXT_PATTERNS: List[str] = [
'an opponent controls'
'can\'t attack',
'can\'t be cast',
'can\'t be activated',
'can\'t cast spells',
'can\'t enter',
'can\'t search',
'can\'t untap',
'don\'t untap',
'don\'t cause abilities',
'each other player\'s',
'each player\'s upkeep',
'opponent would search',
'opponents cast cost',
'opponents can\'t',
'opponents control',
'opponents control can\'t',
'opponents control enter tapped',
'spells cost {1} more',
'spells cost {2} more',
'spells cost {3} more',
'spells cost {4} more',
'spells cost {5} more',
'that player doesn\'t',
'unless that player pays',
'you control your opponent',
'you gain protection'
]
STAX_SPECIFIC_CARDS: List[str] = [
'Archon of Emeria',
'Drannith Magistrate',
'Ethersworn Canonist',
'Grand Arbiter Augustin IV',
'Hokori, Dust Drinker',
'Kataki, War\'s Wage',
'Lavinia, Azorius Renegade',
'Leovold, Emissary of Trest',
'Magus of the Moon',
'Narset, Parter of Veils',
'Opposition Agent',
'Rule of Law',
'Sanctum Prelate',
'Thalia, Guardian of Thraben',
'Winter Orb'
]
STAX_EXCLUSION_PATTERNS: List[str] = [
'blocking enchanted',
'blocking it',
'blocked by',
'end the turn',
'from your graveyard',
'from your hand',
'from your library',
'into your hand'
]
# Constants for removal functionality
REMOVAL_TEXT_PATTERNS: List[str] = [
'destroy target',
'destroys target',
'exile target',
'exiles target',
'sacrifices target',
'return target.*to.*hand',
'returns target.*to.*hand'
]
REMOVAL_SPECIFIC_CARDS: List[str] = ['from.*graveyard.*hand']
REMOVAL_EXCLUSION_PATTERNS: List[str] = []
REMOVAL_KEYWORDS: List[str] = []
# Constants for counterspell functionality
COUNTERSPELL_TEXT_PATTERNS: List[str] = [
'control counters a',
'counter target',
'counter that spell',
'counter all',
'counter each',
'counter the next',
'counters a spell',
'counters target',
'return target spell',
'exile target spell',
'counter unless',
'unless its controller pays'
]
COUNTERSPELL_SPECIFIC_CARDS: List[str] = [
'Arcane Denial',
'Counterspell',
"Dovin's Veto",
'Force of Will',
'Mana Drain',
'Mental Misstep',
'Mindbreak Trap',
'Mystic Confluence',
'Pact of Negation',
'Swan Song'
]
COUNTERSPELL_EXCLUSION_PATTERNS: List[str] = [
'counter on',
'counter from',
'remove a counter',
'move a counter',
'distribute counter',
'proliferate'
]
# Constants for theft functionality
THEFT_TEXT_PATTERNS: List[str] = [
'cast a spell you don\'t own',
'cast but don\'t own',
'cost to cast this spell, sacrifice',
'control but don\'t own',
'exile top of target player\'s library',
'exile top of each player\'s library',
'gain control of',
'target opponent\'s library',
'that player\'s library',
'you control enchanted creature'
]
THEFT_SPECIFIC_CARDS: List[str] = [
'Adarkar Valkyrie',
'Captain N\'gathrod',
'Hostage Taker',
'Siphon Insight',
'Thief of Sanity',
'Xanathar, Guild Kingpin',
'Zara, Renegade Recruiter'
]
# Constants for big mana functionality
BIG_MANA_TEXT_PATTERNS: List[str] = [
'add {w}{u}{b}{r}{g}',
'card onto the battlefield',
'control with power [3-5] or greater',
'creature with power [3-5] or greater',
'double the power',
'from among them onto the battlefield',
'from among them without paying',
'hand onto the battlefield',
'mana, add one mana',
'mana, it produces twice',
'mana, it produces three',
'mana, its controller adds',
'pay {w}{u}{b}{r}{g}',
'spell with power 5 or greater',
'value [5-7] or greater',
'you may cast it without paying'
]
BIG_MANA_SPECIFIC_CARDS: List[str] = [
'Akroma\'s Memorial',
'Apex Devastator',
'Apex of Power',
'Brass\'s Bounty',
'Cabal Coffers',
'Caged Sun',
'Doubling Cube',
'Forsaken Monument',
'Guardian Project',
'Mana Reflection',
'Nyxbloom Ancient',
'Omniscience',
'One with the Multiverse',
'Portal to Phyrexia',
'Vorinclex, Voice of Hunger'
]
BIG_MANA_KEYWORDS: List[str] = [
'Cascade',
'Convoke',
'Discover',
'Emerge',
'Improvise',
'Surge'
]
# Constants for board wipe effects
BOARD_WIPE_TEXT_PATTERNS: Dict[str, List[str]] = {
'mass_destruction': [
'destroy all',
'destroy each',
'destroy the rest',
'destroys all',
'destroys each',
'destroys the rest'
],
'mass_exile': [
'exile all',
'exile each',
'exile the rest',
'exiles all',
'exiles each',
'exiles the rest'
],
'mass_bounce': [
'return all',
'return each',
'put all creatures',
'returns all',
'returns each',
'puts all creatures'
],
'mass_sacrifice': [
'sacrifice all',
'sacrifice each',
'sacrifice the rest',
'sacrifices all',
'sacrifices each',
'sacrifices the rest'
],
'mass_damage': [
'deals damage to each',
'deals damage to all',
'deals X damage to each',
'deals X damage to all',
'deals that much damage to each',
'deals that much damage to all'
]
}
BOARD_WIPE_SPECIFIC_CARDS: List[str] = [
'Akroma\'s Vengeance',
'All Is Dust',
'Austere Command',
'Blasphemous Act',
'Cleansing Nova',
'Cyclonic Rift',
'Damnation',
'Day of Judgment',
'Decree of Pain',
'Devastation Tide',
'Evacuation',
'Extinction Event',
'Farewell',
'Hour of Devastation',
'In Garruk\'s Wake',
'Living Death',
'Living End',
'Merciless Eviction',
'Nevinyrral\'s Disk',
'Oblivion Stone',
'Planar Cleansing',
'Ravnica at War',
'Shatter the Sky',
'Supreme Verdict',
'Terminus',
'Time Wipe',
'Toxic Deluge',
'Vanquish the Horde',
'Wrath of God'
]
BOARD_WIPE_EXCLUSION_PATTERNS: List[str] = [
'blocking enchanted',
'blocking it',
'blocked by',
'end the turn',
'from your graveyard',
'from your hand',
'from your library',
'into your hand',
'target player\'s library',
'that player\'s library'
]
# Constants for topdeck manipulation
TOPDECK_TEXT_PATTERNS: List[str] = [
'from the top',
'look at the top',
'reveal the top',
'scries',
'surveils',
'top of your library',
'you scry',
'you surveil'
]
TOPDECK_KEYWORDS: List[str] = [
'Miracle',
'Scry',
'Surveil'
]
TOPDECK_SPECIFIC_CARDS: List[str] = [
'Aminatou, the Fateshifter',
'Brainstorm',
'Counterbalance',
'Delver of Secrets',
'Jace, the Mind Sculptor',
'Lantern of Insight',
'Melek, Izzet Paragon',
'Mystic Forge',
'Sensei\'s Divining Top',
'Soothsaying',
'Temporal Mastery',
'Vampiric Tutor'
]
TOPDECK_EXCLUSION_PATTERNS: List[str] = [
'from the top of target player\'s library',
'from the top of their library',
'look at the top card of target player\'s library',
'reveal the top card of target player\'s library'
]

368
code/tagging/tag_utils.py Normal file
View file

@ -0,0 +1,368 @@
"""Utility module for tag manipulation and pattern matching in card data processing.
This module provides a collection of functions for working with card tags, types, and text patterns
in a card game context. It includes utilities for:
- Creating boolean masks for filtering cards based on various criteria
- Manipulating and extracting card types
- Managing theme tags and card attributes
- Pattern matching in card text and types
- Mass effect detection (damage, removal, etc.)
The module is designed to work with pandas DataFrames containing card data and provides
vectorized operations for efficient processing of large card collections.
"""
from __future__ import annotations
# Standard library imports
import re
from typing import List, Set, Union, Any
# Third-party imports
import pandas as pd
# Local application imports
from . import tag_constants
def pluralize(word: str) -> str:
"""Convert a word to its plural form using basic English pluralization rules.
Args:
word: The singular word to pluralize
Returns:
The pluralized word
"""
if word.endswith('y'):
return word[:-1] + 'ies'
elif word.endswith(('s', 'sh', 'ch', 'x', 'z')):
return word + 'es'
elif word.endswith(('f')):
return word[:-1] + 'ves'
else:
return word + 's'
def sort_list(items: Union[List[Any], pd.Series]) -> Union[List[Any], pd.Series]:
"""Sort a list or pandas Series in ascending order.
Args:
items: List or Series to sort
Returns:
Sorted list or Series
"""
if isinstance(items, (list, pd.Series)):
return sorted(items) if isinstance(items, list) else items.sort_values()
return items
def create_type_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where type matches one or more patterns.
Args:
df: DataFrame to search
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
regex: Whether to treat patterns as regex expressions (default: True)
Returns:
Boolean Series indicating matching rows
Raises:
ValueError: If type_text is empty or None
TypeError: If type_text is not a string or list of strings
"""
if not type_text:
raise ValueError("type_text cannot be empty or None")
if isinstance(type_text, str):
type_text = [type_text]
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return df['type'].str.contains(pattern, case=False, na=False, regex=True)
else:
masks = [df['type'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
return pd.concat(masks, axis=1).any(axis=1)
def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True, combine_with_or: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where text matches one or more patterns.
Args:
df: DataFrame to search
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
regex: Whether to treat patterns as regex expressions (default: True)
combine_with_or: Whether to combine multiple patterns with OR (True) or AND (False)
Returns:
Boolean Series indicating matching rows
Raises:
ValueError: If type_text is empty or None
TypeError: If type_text is not a string or list of strings
"""
if not type_text:
raise ValueError("type_text cannot be empty or None")
if isinstance(type_text, str):
type_text = [type_text]
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return df['text'].str.contains(pattern, case=False, na=False, regex=True)
else:
masks = [df['text'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
if combine_with_or:
return pd.concat(masks, axis=1).any(axis=1)
else:
return pd.concat(masks, axis=1).all(axis=1)
def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where keyword text matches one or more patterns.
Args:
df: DataFrame to search
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
regex: Whether to treat patterns as regex expressions (default: True)
Returns:
Boolean Series indicating matching rows. For rows with empty/null keywords,
returns False.
Raises:
ValueError: If type_text is empty or None
TypeError: If type_text is not a string or list of strings
ValueError: If required 'keywords' column is missing from DataFrame
"""
# Validate required columns
validate_dataframe_columns(df, {'keywords'})
# Handle empty DataFrame case
if len(df) == 0:
return pd.Series([], dtype=bool)
if not type_text:
raise ValueError("type_text cannot be empty or None")
if isinstance(type_text, str):
type_text = [type_text]
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
# Create default mask for null values
# Handle null values and convert to string
keywords = df['keywords'].fillna('')
# Convert non-string values to strings
keywords = keywords.astype(str)
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return keywords.str.contains(pattern, case=False, na=False, regex=True)
else:
masks = [keywords.str.contains(p, case=False, na=False, regex=False) for p in type_text]
return pd.concat(masks, axis=1).any(axis=1)
def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where name matches one or more patterns.
Args:
df: DataFrame to search
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
regex: Whether to treat patterns as regex expressions (default: True)
Returns:
Boolean Series indicating matching rows
Raises:
ValueError: If type_text is empty or None
TypeError: If type_text is not a string or list of strings
"""
if not type_text:
raise ValueError("type_text cannot be empty or None")
if isinstance(type_text, str):
type_text = [type_text]
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return df['name'].str.contains(pattern, case=False, na=False, regex=True)
else:
masks = [df['name'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
return pd.concat(masks, axis=1).any(axis=1)
def extract_creature_types(type_text: str, creature_types: List[str], non_creature_types: List[str]) -> List[str]:
"""Extract creature types from a type text string.
Args:
type_text: The type line text to parse
creature_types: List of valid creature types
non_creature_types: List of non-creature types to exclude
Returns:
List of extracted creature types
"""
types = [t.strip() for t in type_text.split()]
return [t for t in types if t in creature_types and t not in non_creature_types]
def find_types_in_text(text: str, name: str, creature_types: List[str]) -> List[str]:
"""Find creature types mentioned in card text.
Args:
text: Card text to search
name: Card name to exclude from search
creature_types: List of valid creature types
Returns:
List of found creature types
"""
if pd.isna(text):
return []
found_types = []
words = text.split()
for word in words:
clean_word = re.sub(r'[^a-zA-Z-]', '', word)
if clean_word in creature_types:
if clean_word not in name:
found_types.append(clean_word)
return list(set(found_types))
def add_outlaw_type(types: List[str], outlaw_types: List[str]) -> List[str]:
"""Add Outlaw type if card has an outlaw-related type.
Args:
types: List of current types
outlaw_types: List of types that qualify for Outlaw
Returns:
Updated list of types
"""
if any(t in outlaw_types for t in types) and 'Outlaw' not in types:
return types + ['Outlaw']
return types
def create_tag_mask(df: pd.DataFrame, tag_patterns: Union[str, List[str]], column: str = 'themeTags') -> pd.Series[bool]:
"""Create a boolean mask for rows where tags match specified patterns.
Args:
df: DataFrame to search
tag_patterns: String or list of strings to match against tags
column: Column containing tags to search (default: 'themeTags')
Returns:
Boolean Series indicating matching rows
Examples:
# Match cards with draw-related tags
>>> mask = create_tag_mask(df, ['Card Draw', 'Conditional Draw'])
>>> mask = create_tag_mask(df, 'Unconditional Draw')
"""
if isinstance(tag_patterns, str):
tag_patterns = [tag_patterns]
# Handle empty DataFrame case
if len(df) == 0:
return pd.Series([], dtype=bool)
# Create mask for each pattern
masks = [df[column].apply(lambda x: any(pattern in tag for tag in x)) for pattern in tag_patterns]
# Combine masks with OR
return pd.concat(masks, axis=1).any(axis=1)
def validate_dataframe_columns(df: pd.DataFrame, required_columns: Set[str]) -> None:
"""Validate that DataFrame contains all required columns.
Args:
df: DataFrame to validate
required_columns: Set of column names that must be present
Raises:
ValueError: If any required columns are missing
"""
missing = required_columns - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series[bool], tags: Union[str, List[str]]) -> None:
"""Apply tags to rows in a dataframe based on a boolean mask.
Args:
df: The dataframe to modify
mask: Boolean series indicating which rows to tag
tags: List of tags to apply
"""
if not isinstance(tags, list):
tags = [tags]
# Get current tags for masked rows
current_tags = df.loc[mask, 'themeTags']
# Add new tags
df.loc[mask, 'themeTags'] = current_tags.apply(lambda x: sorted(list(set(x + tags))))
def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series[bool]:
"""Create a boolean mask for cards with mass removal effects of a specific type.
Args:
df: DataFrame to search
effect_type: Type of mass effect to match ('destruction', 'exile', 'bounce', 'sacrifice', 'damage')
Returns:
Boolean Series indicating which cards have mass effects of the specified type
Raises:
ValueError: If effect_type is not recognized
"""
if effect_type not in tag_constants.BOARD_WIPE_TEXT_PATTERNS:
raise ValueError(f"Unknown effect type: {effect_type}")
patterns = tag_constants.BOARD_WIPE_TEXT_PATTERNS[effect_type]
return create_text_mask(df, patterns)
def create_damage_pattern(number: Union[int, str]) -> str:
"""Create a pattern for matching X damage effects.
Args:
number: Number or variable (X) for damage amount
Returns:
Pattern string for matching damage effects
"""
return f'deals {number} damage'
def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series[bool]:
"""Create a boolean mask for cards with mass damage effects.
Args:
df: DataFrame to search
Returns:
Boolean Series indicating which cards have mass damage effects
"""
# Create patterns for numeric damage
number_patterns = [create_damage_pattern(i) for i in range(1, 21)]
# Add X damage pattern
number_patterns.append(create_damage_pattern('X'))
# Add patterns for damage targets
target_patterns = [
'to each creature',
'to all creatures',
'to each player',
'to each opponent',
'to everything'
]
# Create masks
damage_mask = create_text_mask(df, number_patterns)
target_mask = create_text_mask(df, target_patterns)
return damage_mask & target_mask

6437
code/tagging/tagger.py Normal file

File diff suppressed because it is too large Load diff

50
code/type_definitions.py Normal file
View file

@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Dict, List, TypedDict, Union
import pandas as pd
class CardDict(TypedDict):
"""Type definition for card dictionary structure used in deck_builder.py.
Contains all the necessary fields to represent a Magic: The Gathering card
in the deck building process.
"""
name: str
type: str
manaCost: Union[str, None]
manaValue: int
class CommanderDict(TypedDict):
"""Type definition for commander dictionary structure used in deck_builder.py.
Contains all the necessary fields to represent a commander card and its
associated metadata.
"""
Commander_Name: str
Mana_Cost: str
Mana_Value: int
Color_Identity: str
Colors: List[str]
Type: str
Creature_Types: str
Text: str
Power: int
Toughness: int
Themes: List[str]
CMC: float
# Type alias for price cache dictionary used in price_checker.py
PriceCache = Dict[str, float]
# DataFrame type aliases for different card categories
CardLibraryDF = pd.DataFrame
CommanderDF = pd.DataFrame
LandDF = pd.DataFrame
ArtifactDF = pd.DataFrame
CreatureDF = pd.DataFrame
NonCreatureDF = pd.DataFrame
EnchantmentDF = pd.DataFrame
InstantDF = pd.DataFrame
PlaneswalkerDF = pd.DataFrame
NonPlaneswalkerDF = pd.DataFrame
SorceryDF = pd.DataFrame