mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
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:
parent
503068b20c
commit
e0dd09adee
8 changed files with 1228 additions and 807 deletions
|
@ -119,4 +119,78 @@ class CommanderValidationError(MTGSetupError):
|
||||||
self.validation_type = validation_type
|
self.validation_type = validation_type
|
||||||
self.details = details
|
self.details = details
|
||||||
error_info = f" - {details}" if details else ""
|
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
212
input_handler.py
Normal 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
34
main.py
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NoReturn, Optional
|
from typing import NoReturn, Optional
|
||||||
|
|
||||||
|
@ -21,15 +22,19 @@ MTG Python Deckbuilder. It handles menu display, user input processing, and
|
||||||
routing to different application features like setup, deck building, card info
|
routing to different application features like setup, deck building, card info
|
||||||
lookup and CSV file tagging.
|
lookup and CSV file tagging.
|
||||||
"""
|
"""
|
||||||
# Configure logging
|
# Create logs directory if it doesn't exist
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.makedirs('logs')
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.StreamHandler(),
|
logging.StreamHandler(),
|
||||||
logging.FileHandler('main.log', mode='w')
|
logging.FileHandler('logs/main.log', mode='a', encoding='utf-8')
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Menu constants
|
# Menu constants
|
||||||
MENU_SETUP = 'Setup'
|
MENU_SETUP = 'Setup'
|
||||||
|
@ -62,8 +67,9 @@ def get_menu_choice() -> Optional[str]:
|
||||||
answer = inquirer.prompt(question) # type: ignore
|
answer = inquirer.prompt(question) # type: ignore
|
||||||
return answer['menu'] if answer else None
|
return answer['menu'] if answer else None
|
||||||
except (KeyError, TypeError) as e:
|
except (KeyError, TypeError) as e:
|
||||||
logging.error(f"Error getting menu choice: {e}")
|
logger.error(f"Error getting menu choice: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def handle_card_info() -> None:
|
def handle_card_info() -> None:
|
||||||
"""Handle the card info menu option with proper error handling.
|
"""Handle the card info menu option with proper error handling.
|
||||||
|
|
||||||
|
@ -91,12 +97,13 @@ def handle_card_info() -> None:
|
||||||
if not answer or not answer['continue']:
|
if not answer or not answer['continue']:
|
||||||
break
|
break
|
||||||
except (KeyError, TypeError) as e:
|
except (KeyError, TypeError) as e:
|
||||||
logging.error(f"Error in card info continuation prompt: {e}")
|
logger.error(f"Error in card info continuation prompt: {e}")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error in card info handling: {e}")
|
logger.error(f"Error in card info handling: {e}")
|
||||||
|
|
||||||
def run_menu() -> NoReturn:
|
def run_menu() -> NoReturn:
|
||||||
"""Main menu loop with improved error handling and logging.
|
"""Main menu loop with improved error handling and logger.
|
||||||
|
|
||||||
Provides the main application loop that displays the menu and handles user selections.
|
Provides the main application loop that displays the menu and handles user selections.
|
||||||
Creates required directories, processes menu choices, and handles errors gracefully.
|
Creates required directories, processes menu choices, and handles errors gracefully.
|
||||||
|
@ -117,7 +124,7 @@ def run_menu() -> NoReturn:
|
||||||
4. Tag CSV Files
|
4. Tag CSV Files
|
||||||
5. Quit
|
5. Quit
|
||||||
"""
|
"""
|
||||||
logging.info("Starting MTG Python Deckbuilder")
|
logger.info("Starting MTG Python Deckbuilder")
|
||||||
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
@ -126,29 +133,30 @@ def run_menu() -> NoReturn:
|
||||||
choice = get_menu_choice()
|
choice = get_menu_choice()
|
||||||
|
|
||||||
if choice is None:
|
if choice is None:
|
||||||
logging.info("Menu operation cancelled")
|
logger.info("Menu operation cancelled")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logging.info(f"User selected: {choice}")
|
logger.info(f"User selected: {choice}")
|
||||||
|
|
||||||
match choice:
|
match choice:
|
||||||
case 'Setup':
|
case 'Setup':
|
||||||
setup.setup()
|
setup.setup()
|
||||||
tagger.run_tagging()
|
tagger.run_tagging()
|
||||||
case 'Build a Deck':
|
case 'Build a Deck':
|
||||||
logging.info("Deck building not yet implemented")
|
logger.info("Deck building not yet implemented")
|
||||||
print('Deck building not yet implemented')
|
print('Deck building not yet implemented')
|
||||||
case 'Get Card Info':
|
case 'Get Card Info':
|
||||||
handle_card_info()
|
handle_card_info()
|
||||||
case 'Tag CSV Files':
|
case 'Tag CSV Files':
|
||||||
tagger.run_tagging()
|
tagger.run_tagging()
|
||||||
case 'Quit':
|
case 'Quit':
|
||||||
logging.info("Exiting application")
|
logger.info("Exiting application")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
case _:
|
case _:
|
||||||
logging.warning(f"Invalid menu choice: {choice}")
|
logger.warning(f"Invalid menu choice: {choice}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Unexpected error in main menu: {e}")
|
logger.error(f"Unexpected error in main menu: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_menu()
|
run_menu()
|
60
price_check.py
Normal file
60
price_check.py
Normal 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}"
|
||||||
|
)
|
31
settings.py
31
settings.py
|
@ -10,6 +10,23 @@ and enable static type checking with mypy.
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
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',
|
artifact_tokens: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||||
'Junk','Map','Powerstone', 'Treasure']
|
'Junk','Map','Powerstone', 'Treasure']
|
||||||
|
|
||||||
|
@ -777,6 +794,20 @@ VOLTRON_PATTERNS = [
|
||||||
'reconfigure'
|
'reconfigure'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Constants for price checking functionality
|
||||||
|
PRICE_CHECK_CONFIG: Dict[str, float] = {
|
||||||
|
# Maximum number of retry attempts for price checking requests
|
||||||
|
'max_retries': 3,
|
||||||
|
|
||||||
|
# Timeout in seconds for price checking requests
|
||||||
|
'timeout': 0.1,
|
||||||
|
|
||||||
|
# Maximum size of the price check cache
|
||||||
|
'cache_size': 128,
|
||||||
|
|
||||||
|
# Price tolerance factor (e.g., 1.1 means accept prices within 10% difference)
|
||||||
|
'price_tolerance': 1.1
|
||||||
|
}
|
||||||
# Constants for setup and CSV processing
|
# Constants for setup and CSV processing
|
||||||
MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv'
|
MTGJSON_API_URL = 'https://mtgjson.com/api/v5/csv/cards.csv'
|
||||||
|
|
||||||
|
|
25
setup.py
25
setup.py
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import os
|
||||||
from typing import Union, List, Dict, Any
|
from typing import Union, List, Dict, Any
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
|
@ -21,11 +22,17 @@ from exceptions import (
|
||||||
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError,
|
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError,
|
||||||
ColorFilterError, CommanderValidationError
|
ColorFilterError, CommanderValidationError
|
||||||
)
|
)
|
||||||
# Configure logging
|
# Create logs directory if it doesn't exist
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.makedirs('logs')
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler('logs/setup.log', mode='w', encoding='utf-8')
|
||||||
|
]
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -288,23 +295,23 @@ def setup() -> bool:
|
||||||
choice = _display_setup_menu()
|
choice = _display_setup_menu()
|
||||||
|
|
||||||
if choice == SetupOption.INITIAL_SETUP:
|
if choice == SetupOption.INITIAL_SETUP:
|
||||||
logging.info('Starting initial setup')
|
logger.info('Starting initial setup')
|
||||||
initial_setup()
|
initial_setup()
|
||||||
logging.info('Initial setup completed successfully')
|
logger.info('Initial setup completed successfully')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif choice == SetupOption.REGENERATE_CSV:
|
elif choice == SetupOption.REGENERATE_CSV:
|
||||||
logging.info('Starting CSV regeneration')
|
logger.info('Starting CSV regeneration')
|
||||||
regenerate_csvs_all()
|
regenerate_csvs_all()
|
||||||
logging.info('CSV regeneration completed successfully')
|
logger.info('CSV regeneration completed successfully')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif choice == SetupOption.BACK:
|
elif choice == SetupOption.BACK:
|
||||||
logging.info('Setup cancelled by user')
|
logger.info('Setup cancelled by user')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f'Error during setup: {e}')
|
logger.error(f'Error during setup: {e}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -1,7 +1,24 @@
|
||||||
|
"""MTG Python Deckbuilder setup utilities.
|
||||||
|
|
||||||
|
This module provides utility functions for setting up and managing the MTG Python Deckbuilder
|
||||||
|
application. It handles tasks such as downloading card data, filtering cards by various criteria,
|
||||||
|
and processing legendary creatures for commander format.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Card data download from MTGJSON
|
||||||
|
- DataFrame filtering and processing
|
||||||
|
- Color identity filtering
|
||||||
|
- Commander validation
|
||||||
|
- CSV file management
|
||||||
|
|
||||||
|
The module integrates with settings.py for configuration and exceptions.py for error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import requests
|
import requests
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Union, TypedDict
|
from typing import List, Optional, Union, TypedDict
|
||||||
|
@ -27,21 +44,19 @@ from exceptions import (
|
||||||
CommanderValidationError
|
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
|
logging.basicConfig(
|
||||||
application. It handles tasks such as downloading card data, filtering cards by various criteria,
|
level=logging.INFO,
|
||||||
and processing legendary creatures for commander format.
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
Key Features:
|
logging.StreamHandler(),
|
||||||
- Card data download from MTGJSON
|
logging.FileHandler('logs/setup_utils.log', mode='a', encoding='utf-8')
|
||||||
- DataFrame filtering and processing
|
]
|
||||||
- Color identity filtering
|
)
|
||||||
- Commander validation
|
logger = logging.getLogger(__name__)
|
||||||
- CSV file management
|
|
||||||
|
|
||||||
The module integrates with settings.py for configuration and exceptions.py for error handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Type definitions
|
# Type definitions
|
||||||
class FilterRule(TypedDict):
|
class FilterRule(TypedDict):
|
||||||
|
@ -83,7 +98,7 @@ def download_cards_csv(url: str, output_path: Union[str, Path]) -> None:
|
||||||
pbar.update(size)
|
pbar.update(size)
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logging.error(f'Failed to download cards data from {url}')
|
logger.error(f'Failed to download cards data from {url}')
|
||||||
raise MTGJSONDownloadError(
|
raise MTGJSONDownloadError(
|
||||||
"Failed to download cards data",
|
"Failed to download cards data",
|
||||||
url,
|
url,
|
||||||
|
@ -128,14 +143,14 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
||||||
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
|
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logging.info('Starting standard DataFrame filtering')
|
logger.info('Starting standard DataFrame filtering')
|
||||||
|
|
||||||
# Fill null values according to configuration
|
# Fill null values according to configuration
|
||||||
for col, fill_value in FILL_NA_COLUMNS.items():
|
for col, fill_value in FILL_NA_COLUMNS.items():
|
||||||
if col == 'faceName':
|
if col == 'faceName':
|
||||||
fill_value = df['name']
|
fill_value = df['name']
|
||||||
df[col] = df[col].fillna(fill_value)
|
df[col] = df[col].fillna(fill_value)
|
||||||
logging.debug(f'Filled NA values in {col} with {fill_value}')
|
logger.debug(f'Filled NA values in {col} with {fill_value}')
|
||||||
|
|
||||||
# Apply basic filters from configuration
|
# Apply basic filters from configuration
|
||||||
filtered_df = df.copy()
|
filtered_df = df.copy()
|
||||||
|
@ -148,22 +163,22 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
||||||
elif rule_type == 'require':
|
elif rule_type == 'require':
|
||||||
for value in values:
|
for value in values:
|
||||||
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
|
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
|
||||||
logging.debug(f'Applied {rule_type} filter for {field}: {values}')
|
logger.debug(f'Applied {rule_type} filter for {field}: {values}')
|
||||||
|
|
||||||
# Remove illegal sets
|
# Remove illegal sets
|
||||||
for set_code in NON_LEGAL_SETS:
|
for set_code in NON_LEGAL_SETS:
|
||||||
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
||||||
logging.debug('Removed illegal sets')
|
logger.debug('Removed illegal sets')
|
||||||
|
|
||||||
# Remove banned cards
|
# Remove banned cards
|
||||||
for card in banned_cards:
|
for card in banned_cards:
|
||||||
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
||||||
logging.debug('Removed banned cards')
|
logger.debug('Removed banned cards')
|
||||||
|
|
||||||
# Remove special card types
|
# Remove special card types
|
||||||
for card_type in CARD_TYPES_TO_EXCLUDE:
|
for card_type in CARD_TYPES_TO_EXCLUDE:
|
||||||
filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)]
|
filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)]
|
||||||
logging.debug('Removed special card types')
|
logger.debug('Removed special card types')
|
||||||
|
|
||||||
# Select columns, sort, and drop duplicates
|
# Select columns, sort, and drop duplicates
|
||||||
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
|
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
|
||||||
|
@ -172,12 +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
|
key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col
|
||||||
)
|
)
|
||||||
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
|
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
|
||||||
logging.info('Completed standard DataFrame filtering')
|
logger.info('Completed standard DataFrame filtering')
|
||||||
|
|
||||||
return filtered_df
|
return filtered_df
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f'Failed to filter DataFrame: {str(e)}')
|
logger.error(f'Failed to filter DataFrame: {str(e)}')
|
||||||
raise DataFrameProcessingError(
|
raise DataFrameProcessingError(
|
||||||
"Failed to filter DataFrame",
|
"Failed to filter DataFrame",
|
||||||
"standard_filtering",
|
"standard_filtering",
|
||||||
|
@ -202,7 +217,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
|
||||||
DataFrameProcessingError: If general filtering operations fail
|
DataFrameProcessingError: If general filtering operations fail
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logging.info(f'Filtering cards for color identity: {color_identity}')
|
logger.info(f'Filtering cards for color identity: {color_identity}')
|
||||||
|
|
||||||
# Validate color identity
|
# Validate color identity
|
||||||
with tqdm(total=1, desc='Validating color identity') as pbar:
|
with tqdm(total=1, desc='Validating color identity') as pbar:
|
||||||
|
@ -222,14 +237,14 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
|
||||||
# Filter by color identity
|
# Filter by color identity
|
||||||
with tqdm(total=1, desc='Filtering by color identity') as pbar:
|
with tqdm(total=1, desc='Filtering by color identity') as pbar:
|
||||||
filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity]
|
filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity]
|
||||||
logging.debug(f'Applied color identity filter: {color_identity}')
|
logger.debug(f'Applied color identity filter: {color_identity}')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
|
|
||||||
# Additional color-specific processing
|
# Additional color-specific processing
|
||||||
with tqdm(total=1, desc='Performing color-specific processing') as pbar:
|
with tqdm(total=1, desc='Performing color-specific processing') as pbar:
|
||||||
# Placeholder for future color-specific processing
|
# Placeholder for future color-specific processing
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
logging.info(f'Completed color identity filtering for {color_identity}')
|
logger.info(f'Completed color identity filtering for {color_identity}')
|
||||||
return filtered_df
|
return filtered_df
|
||||||
|
|
||||||
except DataFrameProcessingError as e:
|
except DataFrameProcessingError as e:
|
||||||
|
@ -259,7 +274,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
DataFrameProcessingError: If general processing fails
|
DataFrameProcessingError: If general processing fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logging.info('Starting commander validation process')
|
logger.info('Starting commander validation process')
|
||||||
|
|
||||||
filtered_df = df.copy()
|
filtered_df = df.copy()
|
||||||
# Step 1: Check legendary status
|
# Step 1: Check legendary status
|
||||||
|
@ -273,7 +288,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"DataFrame contains no cards matching legendary criteria"
|
"DataFrame contains no cards matching legendary criteria"
|
||||||
)
|
)
|
||||||
filtered_df = filtered_df[mask].copy()
|
filtered_df = filtered_df[mask].copy()
|
||||||
logging.debug(f'Found {len(filtered_df)} legendary cards')
|
logger.debug(f'Found {len(filtered_df)} legendary cards')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommanderValidationError(
|
raise CommanderValidationError(
|
||||||
|
@ -288,7 +303,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
special_cases = df['text'].str.contains('can be your commander', na=False)
|
special_cases = df['text'].str.contains('can be your commander', na=False)
|
||||||
special_commanders = df[special_cases].copy()
|
special_commanders = df[special_cases].copy()
|
||||||
filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates()
|
filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates()
|
||||||
logging.debug(f'Added {len(special_commanders)} special commander cards')
|
logger.debug(f'Added {len(special_commanders)} special commander cards')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommanderValidationError(
|
raise CommanderValidationError(
|
||||||
|
@ -306,7 +321,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
~filtered_df['printings'].str.contains(set_code, na=False)
|
~filtered_df['printings'].str.contains(set_code, na=False)
|
||||||
]
|
]
|
||||||
removed_count = initial_count - len(filtered_df)
|
removed_count = initial_count - len(filtered_df)
|
||||||
logging.debug(f'Removed {removed_count} cards from illegal sets')
|
logger.debug(f'Removed {removed_count} cards from illegal sets')
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommanderValidationError(
|
raise CommanderValidationError(
|
||||||
|
@ -314,7 +329,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"set_legality",
|
"set_legality",
|
||||||
str(e)
|
str(e)
|
||||||
) from e
|
) from e
|
||||||
logging.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
|
logger.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
|
||||||
return filtered_df
|
return filtered_df
|
||||||
|
|
||||||
except CommanderValidationError:
|
except CommanderValidationError:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue