Began work on refactoring deck_builder

Fixed logging for the other files such that they actually log to the file instead of just creating it
This commit is contained in:
mwisnowski 2025-01-14 10:10:30 -08:00
parent 503068b20c
commit e0dd09adee
8 changed files with 1228 additions and 807 deletions

View file

@ -119,4 +119,78 @@ class CommanderValidationError(MTGSetupError):
self.validation_type = validation_type
self.details = details
error_info = f" - {details}" if details else ""
super().__init__(f"{message} [{validation_type}]{error_info}")
super().__init__(f"{message} [{validation_type}]{error_info}")
class InputValidationError(MTGSetupError):
"""Exception raised when input validation fails.
This exception is raised when there are issues validating user input,
such as invalid text formats, number ranges, or confirmation responses.
Args:
message: Explanation of the error
input_type: Type of input validation that failed (e.g., 'text', 'number', 'confirm')
details: Additional error details
Examples:
>>> raise InputValidationError(
... "Invalid number input",
... "number",
... "Value must be between 1 and 100"
... )
>>> raise InputValidationError(
... "Invalid confirmation response",
... "confirm",
... "Please enter 'y' or 'n'"
... )
>>> raise InputValidationError(
... "Invalid text format",
... "text",
... "Input contains invalid characters"
... )
"""
def __init__(self, message: str, input_type: str, details: str = None) -> None:
self.input_type = input_type
self.details = details
error_info = f" - {details}" if details else ""
super().__init__(f"{message} [{input_type}]{error_info}")
class PriceCheckError(MTGSetupError):
"""Exception raised when price checking operations fail.
This exception is raised when there are issues retrieving or processing
card prices, such as API failures, invalid responses, or parsing errors.
Args:
message: Explanation of the error
card_name: Name of the card that caused the error
details: Additional error details
Examples:
>>> raise PriceCheckError(
... "Failed to retrieve price",
... "Black Lotus",
... "API request timeout"
... )
>>> raise PriceCheckError(
... "Invalid price data format",
... "Lightning Bolt",
... "Unexpected response structure"
... )
>>> raise PriceCheckError(
... "Price data unavailable",
... "Underground Sea",
... "No price information found"
... )
"""
def __init__(self, message: str, card_name: str, details: str = None) -> None:
self.card_name = card_name
self.details = details
error_info = f" - {details}" if details else ""
super().__init__(f"{message} for card '{card_name}'{error_info}")

212
input_handler.py Normal file
View file

@ -0,0 +1,212 @@
"""Input validation and handling for MTG Python Deckbuilder.
This module provides the InputHandler class which encapsulates all input validation
and handling logic. It supports different types of input validation including text,
numbers, confirmations, and multiple choice questions.
"""
from typing import Any, List, Optional, Union
import inquirer
import logging
import os
from exceptions import InputValidationError
from settings import INPUT_VALIDATION, QUESTION_TYPES
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):
os.makedirs('logs')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('logs/input_handlers.log', mode='a', encoding='utf-8')
]
)
logger = logging.getLogger(__name__)
class InputHandler:
"""Handles input validation and user interaction.
This class provides methods for validating different types of user input
and handling user interaction through questionnaires. It uses constants
from settings.py for validation messages and configuration.
"""
def validate_text(self, result: str) -> bool:
"""Validate text input is not empty.
Args:
result: Text input to validate
Returns:
bool: True if text is not empty after stripping whitespace
Raises:
InputValidationError: If text validation fails
"""
try:
if not result or not result.strip():
raise InputValidationError(
INPUT_VALIDATION['default_text_message'],
'text',
'Input cannot be empty'
)
return True
except Exception as e:
raise InputValidationError(
str(e),
'text',
'Unexpected error during text validation'
)
def validate_number(self, result: str) -> Optional[float]:
"""Validate and convert string input to float.
Args:
result: Number input to validate
Returns:
float | None: Converted float value or None if invalid
Raises:
InputValidationError: If number validation fails
"""
try:
if not result:
raise InputValidationError(
INPUT_VALIDATION['default_number_message'],
'number',
'Input cannot be empty'
)
return float(result)
except ValueError:
raise InputValidationError(
INPUT_VALIDATION['default_number_message'],
'number',
'Input must be a valid number'
)
except Exception as e:
raise InputValidationError(
str(e),
'number',
'Unexpected error during number validation'
)
def validate_confirm(self, result: Any) -> bool:
"""Validate confirmation input.
Args:
result: Confirmation input to validate
Returns:
bool: True for positive confirmation, False otherwise
Raises:
InputValidationError: If confirmation validation fails
"""
try:
if isinstance(result, bool):
return result
if isinstance(result, str):
result = result.lower().strip()
if result in ('y', 'yes', 'true', '1'):
return True
if result in ('n', 'no', 'false', '0'):
return False
raise InputValidationError(
INPUT_VALIDATION['default_confirm_message'],
'confirm',
'Invalid confirmation response'
)
except InputValidationError:
raise
except Exception as e:
raise InputValidationError(
str(e),
'confirm',
'Unexpected error during confirmation validation'
)
def questionnaire(
self,
question_type: str,
default_value: Union[str, bool, float] = '',
choices_list: List[str] = []
) -> Union[str, bool, float]:
"""Present questions to user and validate input.
Args:
question_type: Type of question ('Text', 'Number', 'Confirm', 'Choice')
default_value: Default value for the question
choices_list: List of choices for Choice type questions
Returns:
Union[str, bool, float]: Validated user input
Raises:
InputValidationError: If input validation fails
ValueError: If question type is not supported
"""
if question_type not in QUESTION_TYPES:
raise ValueError(f"Unsupported question type: {question_type}")
attempts = 0
while attempts < INPUT_VALIDATION['max_attempts']:
try:
if question_type == 'Text':
question = [inquirer.Text('text')]
result = inquirer.prompt(question)['text']
if self.validate_text(result):
return result
elif question_type == 'Number':
question = [inquirer.Text('number', default=str(default_value))]
result = inquirer.prompt(question)['number']
validated = self.validate_number(result)
if validated is not None:
return validated
elif question_type == 'Confirm':
question = [inquirer.Confirm('confirm', default=default_value)]
result = inquirer.prompt(question)['confirm']
return self.validate_confirm(result)
elif question_type == 'Choice':
if not choices_list:
raise InputValidationError(
INPUT_VALIDATION['default_choice_message'],
'choice',
'No choices provided'
)
question = [
inquirer.List('selection',
choices=choices_list,
carousel=True)
]
return inquirer.prompt(question)['selection']
except InputValidationError as e:
attempts += 1
if attempts >= INPUT_VALIDATION['max_attempts']:
raise InputValidationError(
"Maximum input attempts reached",
question_type,
str(e)
)
logger.warning(f"Invalid input ({attempts}/{INPUT_VALIDATION['max_attempts']}): {str(e)}")
except Exception as e:
raise InputValidationError(
str(e),
question_type,
'Unexpected error during questionnaire'
)
raise InputValidationError(
"Maximum input attempts reached",
question_type,
"Failed to get valid input"
)

34
main.py
View file

@ -3,6 +3,7 @@ from __future__ import annotations
# Standard library imports
import sys
import logging
import os
from pathlib import Path
from typing import NoReturn, Optional
@ -21,15 +22,19 @@ MTG Python Deckbuilder. It handles menu display, user input processing, and
routing to different application features like setup, deck building, card info
lookup and CSV file tagging.
"""
# Configure logging
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):
os.makedirs('logs')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('main.log', mode='w')
logging.FileHandler('logs/main.log', mode='a', encoding='utf-8')
]
)
logger = logging.getLogger(__name__)
# Menu constants
MENU_SETUP = 'Setup'
@ -62,8 +67,9 @@ def get_menu_choice() -> Optional[str]:
answer = inquirer.prompt(question) # type: ignore
return answer['menu'] if answer else None
except (KeyError, TypeError) as e:
logging.error(f"Error getting menu choice: {e}")
logger.error(f"Error getting menu choice: {e}")
return None
def handle_card_info() -> None:
"""Handle the card info menu option with proper error handling.
@ -91,12 +97,13 @@ def handle_card_info() -> None:
if not answer or not answer['continue']:
break
except (KeyError, TypeError) as e:
logging.error(f"Error in card info continuation prompt: {e}")
logger.error(f"Error in card info continuation prompt: {e}")
break
except Exception as e:
logging.error(f"Error in card info handling: {e}")
logger.error(f"Error in card info handling: {e}")
def run_menu() -> NoReturn:
"""Main menu loop with improved error handling and logging.
"""Main menu loop with improved error handling and logger.
Provides the main application loop that displays the menu and handles user selections.
Creates required directories, processes menu choices, and handles errors gracefully.
@ -117,7 +124,7 @@ def run_menu() -> NoReturn:
4. Tag CSV Files
5. Quit
"""
logging.info("Starting MTG Python Deckbuilder")
logger.info("Starting MTG Python Deckbuilder")
Path('csv_files').mkdir(parents=True, exist_ok=True)
while True:
@ -126,29 +133,30 @@ def run_menu() -> NoReturn:
choice = get_menu_choice()
if choice is None:
logging.info("Menu operation cancelled")
logger.info("Menu operation cancelled")
continue
logging.info(f"User selected: {choice}")
logger.info(f"User selected: {choice}")
match choice:
case 'Setup':
setup.setup()
tagger.run_tagging()
case 'Build a Deck':
logging.info("Deck building not yet implemented")
logger.info("Deck building not yet implemented")
print('Deck building not yet implemented')
case 'Get Card Info':
handle_card_info()
case 'Tag CSV Files':
tagger.run_tagging()
case 'Quit':
logging.info("Exiting application")
logger.info("Exiting application")
sys.exit(0)
case _:
logging.warning(f"Invalid menu choice: {choice}")
logger.warning(f"Invalid menu choice: {choice}")
except Exception as e:
logging.error(f"Unexpected error in main menu: {e}")
logger.error(f"Unexpected error in main menu: {e}")
if __name__ == "__main__":
run_menu()

60
price_check.py Normal file
View file

@ -0,0 +1,60 @@
"""Price checking functionality for MTG Python Deckbuilder.
This module provides functionality to check card prices using the Scryfall API
through the scrython library. It includes caching and error handling for reliable
price lookups.
"""
import time
from functools import lru_cache
from typing import Optional
import scrython
from scrython.cards import Named
from exceptions import PriceCheckError
from settings import PRICE_CHECK_CONFIG
@lru_cache(maxsize=PRICE_CHECK_CONFIG['cache_size'])
def check_price(card_name: str) -> float:
"""Retrieve the current price of a Magic: The Gathering card.
Args:
card_name: The name of the card to check.
Returns:
float: The current price of the card in USD.
Raises:
PriceCheckError: If there are any issues retrieving the price.
"""
retries = 0
last_error = None
while retries < PRICE_CHECK_CONFIG['max_retries']:
try:
card = Named(fuzzy=card_name)
price = card.prices('usd')
print(price)
if price is None:
raise PriceCheckError(
"No price data available",
card_name,
"Card may be too new or not available in USD"
)
return float(price)
except (scrython.ScryfallError, ValueError) as e:
last_error = str(e)
retries += 1
if retries < PRICE_CHECK_CONFIG['max_retries']:
time.sleep(0.1) # Brief delay before retry
continue
raise PriceCheckError(
"Failed to retrieve price after multiple attempts",
card_name,
f"Last error: {last_error}"
)

View file

@ -10,6 +10,23 @@ and enable static type checking with mypy.
from typing import Dict, List, Optional
# Constants for input validation
INPUT_VALIDATION = {
'max_attempts': 3,
'default_text_message': 'Please enter a valid text response.',
'default_number_message': 'Please enter a valid number.',
'default_confirm_message': 'Please enter Y/N or Yes/No.',
'default_choice_message': 'Please select a valid option from the list.'
}
QUESTION_TYPES = [
'Text',
'Number',
'Confirm',
'Choice'
]
# Card type constants
artifact_tokens: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
'Junk','Map','Powerstone', 'Treasure']
@ -777,6 +794,20 @@ VOLTRON_PATTERNS = [
'reconfigure'
]
# Constants for price checking functionality
PRICE_CHECK_CONFIG: Dict[str, float] = {
# Maximum number of retry attempts for price checking requests
'max_retries': 3,
# Timeout in seconds for price checking requests
'timeout': 0.1,
# Maximum size of the price check cache
'cache_size': 128,
# Price tolerance factor (e.g., 1.1 means accept prices within 10% difference)
'price_tolerance': 1.1
}
# Constants for setup and CSV processing
MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv'

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from enum import Enum
from pathlib import Path
import os
from typing import Union, List, Dict, Any
# Third-party imports
@ -21,11 +22,17 @@ from exceptions import (
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError,
ColorFilterError, CommanderValidationError
)
# Configure logging
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):
os.makedirs('logs')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
handlers=[
logging.StreamHandler(),
logging.FileHandler('logs/setup.log', mode='w', encoding='utf-8')
]
)
logger = logging.getLogger(__name__)
@ -288,23 +295,23 @@ def setup() -> bool:
choice = _display_setup_menu()
if choice == SetupOption.INITIAL_SETUP:
logging.info('Starting initial setup')
logger.info('Starting initial setup')
initial_setup()
logging.info('Initial setup completed successfully')
logger.info('Initial setup completed successfully')
return True
elif choice == SetupOption.REGENERATE_CSV:
logging.info('Starting CSV regeneration')
logger.info('Starting CSV regeneration')
regenerate_csvs_all()
logging.info('CSV regeneration completed successfully')
logger.info('CSV regeneration completed successfully')
return True
elif choice == SetupOption.BACK:
logging.info('Setup cancelled by user')
logger.info('Setup cancelled by user')
return False
except Exception as e:
logging.error(f'Error during setup: {e}')
logger.error(f'Error during setup: {e}')
raise
return False
return False

View file

@ -1,7 +1,24 @@
"""MTG Python Deckbuilder setup utilities.
This module provides utility functions for setting up and managing the MTG Python Deckbuilder
application. It handles tasks such as downloading card data, filtering cards by various criteria,
and processing legendary creatures for commander format.
Key Features:
- Card data download from MTGJSON
- DataFrame filtering and processing
- Color identity filtering
- Commander validation
- CSV file management
The module integrates with settings.py for configuration and exceptions.py for error handling.
"""
from __future__ import annotations
# Standard library imports
import logging
import os
import requests
from pathlib import Path
from typing import List, Optional, Union, TypedDict
@ -27,21 +44,19 @@ from exceptions import (
CommanderValidationError
)
"""MTG Python Deckbuilder setup utilities.
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):
os.makedirs('logs')
This module provides utility functions for setting up and managing the MTG Python Deckbuilder
application. It handles tasks such as downloading card data, filtering cards by various criteria,
and processing legendary creatures for commander format.
Key Features:
- Card data download from MTGJSON
- DataFrame filtering and processing
- Color identity filtering
- Commander validation
- CSV file management
The module integrates with settings.py for configuration and exceptions.py for error handling.
"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('logs/setup_utils.log', mode='a', encoding='utf-8')
]
)
logger = logging.getLogger(__name__)
# Type definitions
class FilterRule(TypedDict):
@ -83,7 +98,7 @@ def download_cards_csv(url: str, output_path: Union[str, Path]) -> None:
pbar.update(size)
except requests.RequestException as e:
logging.error(f'Failed to download cards data from {url}')
logger.error(f'Failed to download cards data from {url}')
raise MTGJSONDownloadError(
"Failed to download cards data",
url,
@ -128,14 +143,14 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
"""
try:
logging.info('Starting standard DataFrame filtering')
logger.info('Starting standard DataFrame filtering')
# Fill null values according to configuration
for col, fill_value in FILL_NA_COLUMNS.items():
if col == 'faceName':
fill_value = df['name']
df[col] = df[col].fillna(fill_value)
logging.debug(f'Filled NA values in {col} with {fill_value}')
logger.debug(f'Filled NA values in {col} with {fill_value}')
# Apply basic filters from configuration
filtered_df = df.copy()
@ -148,22 +163,22 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
elif rule_type == 'require':
for value in values:
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
logging.debug(f'Applied {rule_type} filter for {field}: {values}')
logger.debug(f'Applied {rule_type} filter for {field}: {values}')
# Remove illegal sets
for set_code in NON_LEGAL_SETS:
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
logging.debug('Removed illegal sets')
logger.debug('Removed illegal sets')
# Remove banned cards
for card in banned_cards:
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
logging.debug('Removed banned cards')
logger.debug('Removed banned cards')
# Remove special card types
for card_type in CARD_TYPES_TO_EXCLUDE:
filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)]
logging.debug('Removed special card types')
logger.debug('Removed special card types')
# Select columns, sort, and drop duplicates
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
@ -172,12 +187,12 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col
)
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
logging.info('Completed standard DataFrame filtering')
logger.info('Completed standard DataFrame filtering')
return filtered_df
except Exception as e:
logging.error(f'Failed to filter DataFrame: {str(e)}')
logger.error(f'Failed to filter DataFrame: {str(e)}')
raise DataFrameProcessingError(
"Failed to filter DataFrame",
"standard_filtering",
@ -202,7 +217,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
DataFrameProcessingError: If general filtering operations fail
"""
try:
logging.info(f'Filtering cards for color identity: {color_identity}')
logger.info(f'Filtering cards for color identity: {color_identity}')
# Validate color identity
with tqdm(total=1, desc='Validating color identity') as pbar:
@ -222,14 +237,14 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
# Filter by color identity
with tqdm(total=1, desc='Filtering by color identity') as pbar:
filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity]
logging.debug(f'Applied color identity filter: {color_identity}')
logger.debug(f'Applied color identity filter: {color_identity}')
pbar.update(1)
# Additional color-specific processing
with tqdm(total=1, desc='Performing color-specific processing') as pbar:
# Placeholder for future color-specific processing
pbar.update(1)
logging.info(f'Completed color identity filtering for {color_identity}')
logger.info(f'Completed color identity filtering for {color_identity}')
return filtered_df
except DataFrameProcessingError as e:
@ -259,7 +274,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
DataFrameProcessingError: If general processing fails
"""
try:
logging.info('Starting commander validation process')
logger.info('Starting commander validation process')
filtered_df = df.copy()
# Step 1: Check legendary status
@ -273,7 +288,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
"DataFrame contains no cards matching legendary criteria"
)
filtered_df = filtered_df[mask].copy()
logging.debug(f'Found {len(filtered_df)} legendary cards')
logger.debug(f'Found {len(filtered_df)} legendary cards')
pbar.update(1)
except Exception as e:
raise CommanderValidationError(
@ -288,7 +303,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
special_cases = df['text'].str.contains('can be your commander', na=False)
special_commanders = df[special_cases].copy()
filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates()
logging.debug(f'Added {len(special_commanders)} special commander cards')
logger.debug(f'Added {len(special_commanders)} special commander cards')
pbar.update(1)
except Exception as e:
raise CommanderValidationError(
@ -306,7 +321,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
~filtered_df['printings'].str.contains(set_code, na=False)
]
removed_count = initial_count - len(filtered_df)
logging.debug(f'Removed {removed_count} cards from illegal sets')
logger.debug(f'Removed {removed_count} cards from illegal sets')
pbar.update(1)
except Exception as e:
raise CommanderValidationError(
@ -314,7 +329,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
"set_legality",
str(e)
) from e
logging.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
logger.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
return filtered_df
except CommanderValidationError:

1520
tagger.py

File diff suppressed because it is too large Load diff