Fleshed out docstrings, added typings, cleaned up imports, and added a requirements.txt file

Additionally, renamed utility.ty to tag_utils.py to fit the naming pattern used with setup.py and setup.utils.py
This commit is contained in:
mwisnowski 2025-01-14 09:06:59 -08:00
parent 000d804ba7
commit b8d9958564
8 changed files with 592 additions and 466 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
*.json
*.log
*.txt
!requirements.txt
test.py
.mypy_cache/
__pycache__/

67
main.py
View file

@ -1,15 +1,26 @@
from __future__ import annotations
import inquirer.prompt # type: ignore
# Standard library imports
import sys
import logging
from pathlib import Path
from typing import NoReturn, Optional
# Third-party imports
import inquirer.prompt # type: ignore
# Local imports
import setup
import card_info
import tagger
"""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.
"""
# Configure logging
logging.basicConfig(
level=logging.INFO,
@ -31,8 +42,16 @@ MENU_CHOICES = [MENU_SETUP, MENU_BUILD_DECK, MENU_CARD_INFO, MAIN_TAG, MENU_QUIT
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
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',
@ -40,14 +59,26 @@ def get_menu_choice() -> Optional[str]:
carousel=True)
]
try:
answer = inquirer.prompt(question)
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}")
return 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.
Provides an interface for looking up card information repeatedly until the user
chooses to stop. Handles potential errors from card info lookup and user input.
Returns:
None
Example:
>>> handle_card_info()
Enter card name: Lightning Bolt
[Card info displayed]
Would you like to look up another card? [y/N]: n
"""
try:
while True:
card_info.get_card_info()
@ -56,7 +87,7 @@ def handle_card_info() -> None:
message='Would you like to look up another card?')
]
try:
answer = inquirer.prompt(question)
answer = inquirer.prompt(question) # type: ignore
if not answer or not answer['continue']:
break
except (KeyError, TypeError) as e:
@ -64,9 +95,28 @@ def handle_card_info() -> None:
break
except Exception as e:
logging.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 logging.
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
"""
logging.info("Starting MTG Python Deckbuilder")
Path('csv_files').mkdir(parents=True, exist_ok=True)
@ -100,6 +150,5 @@ def run_menu() -> NoReturn:
except Exception as e:
logging.error(f"Unexpected error in main menu: {e}")
if __name__ == "__main__":
run_menu()

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
pandas>=1.5.0
inquirer>=3.1.3
typing-extensions>=4.5.0
# Development dependencies
mypy>=1.3.0
pandas-stubs>=2.0.0
types-inquirer>=3.1.3

View file

@ -3,8 +3,14 @@
This module contains all the constant values and configuration settings used throughout
the application for card filtering, processing, and analysis. Constants are organized
into logical sections with clear documentation.
All constants are properly typed according to PEP 484 standards to ensure type safety
and enable static type checking with mypy.
"""
artifact_tokens = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
from typing import Dict, List, Optional
artifact_tokens: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
'Junk','Map','Powerstone', 'Treasure']
banned_cards = [# in commander
@ -33,7 +39,7 @@ basic_lands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
basic_lands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
# Constants for lands matter functionality
LANDS_MATTER_PATTERNS = {
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
'land_play': [
'play a land',
'play an additional land',
@ -661,7 +667,7 @@ DRAW_EXCLUSION_PATTERNS = [
]
# Constants for DataFrame validation and processing
REQUIRED_COLUMNS = [
REQUIRED_COLUMNS: List[str] = [
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
@ -833,7 +839,7 @@ COLOR_ABRV = ['Colorless', 'W', 'U', 'B', 'G', 'R',
'B, R, U, W', 'B, G, R, U, W']
# Configuration for handling null/NA values in DataFrame columns
FILL_NA_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
}
@ -844,7 +850,7 @@ SORT_CONFIG = {
}
# Configuration for DataFrame filtering operations
FILTER_CONFIG = {
FILTER_CONFIG: Dict[str, Dict[str, List[str]]] = {
'layout': {
'exclude': ['reversible_card']
},

View file

@ -1,15 +1,26 @@
from __future__ import annotations
from enum import Enum
import pandas as pd # type: ignore
import inquirer.prompt # type: ignore
# Standard library imports
import logging
from enum import Enum
from pathlib import Path
from typing import Union, List, Dict, Any
from settings import banned_cards, csv_directory, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL
from setup_utils import download_cards_csv, filter_dataframe, process_legendary_cards, filter_by_color_identity
from exceptions import CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError, ColorFilterError, CommanderValidationError
# Third-party imports
import pandas as pd
import inquirer
# Local application imports
from settings import (
banned_cards, csv_directory, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL
)
from setup_utils import (
download_cards_csv, filter_dataframe, process_legendary_cards, filter_by_color_identity
)
from exceptions import (
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError,
ColorFilterError, CommanderValidationError
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
@ -18,7 +29,7 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
def check_csv_exists(file_path: str) -> bool:
def check_csv_exists(file_path: Union[str, Path]) -> bool:
"""Check if a CSV file exists at the specified path.
Args:
@ -83,7 +94,7 @@ def initial_setup() -> None:
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: str) -> None:
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:
@ -248,11 +259,11 @@ def _display_setup_menu() -> SetupOption:
Returns:
SetupOption: The selected menu option
"""
question = [
inquirer.List('menu',
choices=[option.value for option in SetupOption],
carousel=True)
]
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'])

View file

@ -1,12 +1,16 @@
from __future__ import annotations
import pandas as pd
import requests
# Standard library imports
import logging
from tqdm import tqdm
import requests
from pathlib import Path
from typing import List, Optional, Union, Dict, Any
from typing import List, Optional, Union, TypedDict
# Third-party imports
import pandas as pd
from tqdm import tqdm
# Local application imports
from settings import (
CSV_PROCESSING_COLUMNS,
CARD_TYPES_TO_EXCLUDE,
@ -14,26 +18,58 @@ from settings import (
LEGENDARY_OPTIONS,
FILL_NA_COLUMNS,
SORT_CONFIG,
FILTER_CONFIG
FILTER_CONFIG,
)
from exceptions import CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError, ColorFilterError, CommanderValidationError
from settings import (
CSV_PROCESSING_COLUMNS,
CARD_TYPES_TO_EXCLUDE,
NON_LEGAL_SETS,
LEGENDARY_OPTIONS
from exceptions import (
MTGJSONDownloadError,
DataFrameProcessingError,
ColorFilterError,
CommanderValidationError
)
from exceptions import CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError
"""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.
"""
# 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
output_path: Path to save the downloaded CSV file
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 or response is invalid
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)
@ -47,35 +83,49 @@ 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}')
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 check for CSV file
filepath: Path to the CSV file to check
Returns:
True if file exists, False otherwise
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: DataFrame to filter
banned_cards: List of banned card names to exclude
df: pandas DataFrame containing card data to filter
banned_cards: List of card names that are banned and should be excluded
Returns:
Filtered DataFrame
pd.DataFrame: A new DataFrame containing only the cards that pass all filters
Raises:
DataFrameProcessingError: If filtering operations fail
DataFrameProcessingError: If any filtering operation fails
Example:
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
"""
try:
logging.info('Starting standard DataFrame filtering')
@ -89,7 +139,8 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
# Apply basic filters from configuration
filtered_df = df.copy()
for field, rules in FILTER_CONFIG.items():
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:
@ -126,12 +177,12 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
return filtered_df
except Exception as e:
logging.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.
@ -153,14 +204,6 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
try:
logging.info(f'Filtering cards for color identity: {color_identity}')
# Define processing steps for progress tracking
steps = [
'Validating color identity',
'Applying base filtering',
'Filtering by color identity',
'Performing color-specific processing'
]
# Validate color identity
with tqdm(total=1, desc='Validating color identity') as pbar:
if not isinstance(color_identity, str):
@ -217,11 +260,6 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
"""
try:
logging.info('Starting commander validation process')
validation_steps = [
'Checking legendary status',
'Validating special cases',
'Verifying set legality'
]
filtered_df = df.copy()
# Step 1: Check legendary status

View file

@ -1,12 +1,28 @@
import pandas as pd
"""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
import logging
from typing import List, Set, Union, Any
from typing import Dict, List, Optional, Set, Union
from time import perf_counter
# Third-party imports
import pandas as pd
# Local application imports
import settings
def pluralize(word: str) -> str:
"""Convert a word to its plural form using basic English pluralization rules.
@ -25,7 +41,7 @@ def pluralize(word: str) -> str:
else:
return word + 's'
def sort_list(items: Union[List, pd.Series]) -> Union[List, pd.Series]:
def sort_list(items: Union[List[Any], pd.Series]) -> Union[List[Any], pd.Series]:
"""Sort a list or pandas Series in ascending order.
Args:
@ -38,7 +54,7 @@ def sort_list(items: Union[List, pd.Series]) -> Union[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:
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:
@ -68,7 +84,7 @@ def create_type_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
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:
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:
@ -102,7 +118,7 @@ def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
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:
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:
@ -146,7 +162,8 @@ def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], rege
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:
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:
@ -229,7 +246,7 @@ def add_outlaw_type(types: List[str], outlaw_types: List[str]) -> List[str]:
return types + ['Outlaw']
return types
def create_tag_mask(df: pd.DataFrame, tag_patterns: Union[str, List[str]], column: str = 'themeTags') -> pd.Series:
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:
@ -272,7 +289,7 @@ def validate_dataframe_columns(df: pd.DataFrame, required_columns: Set[str]) ->
if missing:
raise ValueError(f"Missing required columns: {missing}")
def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series, tags: List[str]) -> None:
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:
@ -288,7 +305,8 @@ def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series, tags: List[str]) ->
# 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:
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:
@ -306,6 +324,7 @@ def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series:
patterns = settings.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.
@ -316,7 +335,8 @@ def create_damage_pattern(number: Union[int, str]) -> str:
Pattern string for matching damage effects
"""
return f'deals {number} damage'
def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series:
def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series[bool]:
"""Create a boolean mask for cards with mass damage effects.
Args:

765
tagger.py

File diff suppressed because it is too large Load diff