mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
feat!: auto-setup/tagging; direct builder + rerun prompt; fix(type-summary, .txt dup); refactor(export filenames); ci(DockerHub+GH releases); docs(minimal Windows guide, release notes template)
This commit is contained in:
parent
07605990a1
commit
cb710d37ed
12 changed files with 307 additions and 386 deletions
|
@ -34,6 +34,9 @@ from .phases.phase6_reporting import ReportingMixin
|
|||
# Local application imports
|
||||
from . import builder_constants as bc
|
||||
from . import builder_utils as bu
|
||||
import os
|
||||
from settings import CSV_DIRECTORY
|
||||
from file_setup.setup import initial_setup
|
||||
|
||||
# Create logger consistent with existing pattern (mirrors tagging/tagger.py usage)
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
@ -69,6 +72,16 @@ class DeckBuilder(
|
|||
start_ts = datetime.datetime.now()
|
||||
logger.info("=== Deck Build: BEGIN ===")
|
||||
try:
|
||||
# Ensure CSVs exist and are tagged before starting any deck build logic
|
||||
try:
|
||||
cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv')
|
||||
if not os.path.exists(cards_path):
|
||||
logger.info("cards.csv not found. Running initial setup and tagging before deck build...")
|
||||
initial_setup()
|
||||
from tagging import tagger
|
||||
tagger.run_tagging()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed ensuring CSVs before deck build: {e}")
|
||||
self.run_initial_setup()
|
||||
self.run_deck_build_step1()
|
||||
self.run_deck_build_step2()
|
||||
|
|
|
@ -47,18 +47,67 @@ class ReportingMixin:
|
|||
return '\n'.join(lines)
|
||||
|
||||
def print_type_summary(self):
|
||||
"""Prints a summary of card types and their counts in the current deck library.
|
||||
Displays type distribution and percentage breakdown.
|
||||
"""Print a type/category distribution for the current deck library.
|
||||
Uses the stored 'Card Type' when available; otherwise enriches from the
|
||||
loaded card snapshot. Categories mirror export classification.
|
||||
"""
|
||||
type_counts: Dict[str,int] = {}
|
||||
# Build a quick lookup from the loaded dataset to enrich type lines
|
||||
full_df = getattr(self, '_full_cards_df', None)
|
||||
combined_df = getattr(self, '_combined_cards_df', None)
|
||||
snapshot = full_df if full_df is not None else combined_df
|
||||
row_lookup: Dict[str, any] = {}
|
||||
if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns:
|
||||
for _, r in snapshot.iterrows():
|
||||
nm = str(r.get('name'))
|
||||
if nm not in row_lookup:
|
||||
row_lookup[nm] = r
|
||||
|
||||
# Category precedence (purely for stable sorted output)
|
||||
precedence_order = [
|
||||
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||
]
|
||||
precedence_index = {k: i for i, k in enumerate(precedence_order)}
|
||||
commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
|
||||
def classify(primary_type_line: str, card_name: str) -> str:
|
||||
if commander_name and card_name == commander_name:
|
||||
return 'Commander'
|
||||
tl = (primary_type_line or '').lower()
|
||||
if 'battle' in tl:
|
||||
return 'Battle'
|
||||
if 'planeswalker' in tl:
|
||||
return 'Planeswalker'
|
||||
if 'creature' in tl:
|
||||
return 'Creature'
|
||||
if 'instant' in tl:
|
||||
return 'Instant'
|
||||
if 'sorcery' in tl:
|
||||
return 'Sorcery'
|
||||
if 'artifact' in tl:
|
||||
return 'Artifact'
|
||||
if 'enchantment' in tl:
|
||||
return 'Enchantment'
|
||||
if 'land' in tl:
|
||||
return 'Land'
|
||||
return 'Other'
|
||||
|
||||
# Count by classified category
|
||||
cat_counts: Dict[str, int] = {}
|
||||
for name, info in self.card_library.items():
|
||||
ctype = info.get('Type', 'Unknown')
|
||||
cnt = info.get('Count',1)
|
||||
type_counts[ctype] = type_counts.get(ctype,0) + cnt
|
||||
total_cards = sum(type_counts.values())
|
||||
base_type = info.get('Card Type') or info.get('Type', '')
|
||||
if not base_type:
|
||||
row = row_lookup.get(name)
|
||||
if row is not None:
|
||||
base_type = row.get('type', row.get('type_line', '')) or ''
|
||||
category = classify(base_type, name)
|
||||
cnt = int(info.get('Count', 1))
|
||||
cat_counts[category] = cat_counts.get(category, 0) + cnt
|
||||
|
||||
total_cards = sum(cat_counts.values())
|
||||
self.output_func("\nType Summary:")
|
||||
for t, c in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||
self.output_func(f" {t:<15} {c:>3} ({(c/total_cards*100 if total_cards else 0):5.1f}%)")
|
||||
for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])):
|
||||
pct = (c / total_cards * 100) if total_cards else 0.0
|
||||
self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)")
|
||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||
"""Export current decklist to CSV (enriched).
|
||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||
|
@ -73,25 +122,37 @@ class ReportingMixin:
|
|||
Falls back gracefully if snapshot rows missing.
|
||||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
def _slug(s: str) -> str:
|
||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||
return s2 or 'x'
|
||||
def _unique_path(path: str) -> str:
|
||||
if not os.path.exists(path):
|
||||
return path
|
||||
base, ext = os.path.splitext(path)
|
||||
i = 1
|
||||
while True:
|
||||
candidate = f"{base}_{i}{ext}"
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
i += 1
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
if isinstance(cmdr, str) and cmdr:
|
||||
cmdr_first = cmdr.split()[0]
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
# Collect themes in order
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
cmdr_first = 'deck'
|
||||
theme = getattr(self, 'primary_tag', None) or (self.selected_tags[0] if getattr(self, 'selected_tags', []) else None)
|
||||
if isinstance(theme, str) and theme:
|
||||
theme_first = theme.split()[0]
|
||||
else:
|
||||
theme_first = 'notheme'
|
||||
def _slug(s: str) -> str:
|
||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||
return s2 or 'x'
|
||||
cmdr_slug = _slug(cmdr_first)
|
||||
theme_slug = _slug(theme_first)
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv"
|
||||
fname = os.path.join(directory, filename)
|
||||
fname = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
full_df = getattr(self, '_full_cards_df', None)
|
||||
combined_df = getattr(self, '_combined_cards_df', None)
|
||||
|
@ -217,12 +278,6 @@ class ReportingMixin:
|
|||
|
||||
self.output_func(f"Deck exported to {fname}")
|
||||
# Auto-generate matching plaintext list (best-effort; ignore failures)
|
||||
try: # pragma: no cover - sidecar convenience
|
||||
stem = os.path.splitext(os.path.basename(fname))[0]
|
||||
# Always overwrite sidecar to reflect latest deck state
|
||||
self.export_decklist_text(directory=directory, filename=stem + '.txt', suppress_output=True) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
logger.warning("Plaintext sidecar export failed (non-fatal)")
|
||||
return fname
|
||||
|
||||
def export_decklist_text(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||
|
@ -236,27 +291,38 @@ class ReportingMixin:
|
|||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
# Derive base filename logic (shared with CSV exporter) – intentionally duplicated to avoid refactor risk.
|
||||
def _slug(s: str) -> str:
|
||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||
return s2 or 'x'
|
||||
def _unique_path(path: str) -> str:
|
||||
if not os.path.exists(path):
|
||||
return path
|
||||
base, ext = os.path.splitext(path)
|
||||
i = 1
|
||||
while True:
|
||||
candidate = f"{base}_{i}{ext}"
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
i += 1
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
if isinstance(cmdr, str) and cmdr:
|
||||
cmdr_first = cmdr.split()[0]
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
cmdr_first = 'deck'
|
||||
theme = getattr(self, 'primary_tag', None) or (self.selected_tags[0] if getattr(self, 'selected_tags', []) else None)
|
||||
if isinstance(theme, str) and theme:
|
||||
theme_first = theme.split()[0]
|
||||
else:
|
||||
theme_first = 'notheme'
|
||||
def _slug(s: str) -> str:
|
||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||
return s2 or 'x'
|
||||
cmdr_slug = _slug(cmdr_first)
|
||||
theme_slug = _slug(theme_first)
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt"
|
||||
if not filename.lower().endswith('.txt'):
|
||||
filename = filename + '.txt'
|
||||
path = os.path.join(directory, filename)
|
||||
path = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
# Sorting reproduction
|
||||
precedence_order = [
|
||||
|
|
118
code/main.py
118
code/main.py
|
@ -1,25 +1,22 @@
|
|||
"""Command-line interface for the MTG Python Deckbuilder application.
|
||||
"""Command-line entrypoint for the MTG Python Deckbuilder.
|
||||
|
||||
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.
|
||||
Launches directly into the interactive deck builder. On first run (or if the
|
||||
card database is missing), it automatically performs initial setup and 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
|
||||
from typing import NoReturn
|
||||
|
||||
# Local imports
|
||||
from deck_builder import DeckBuilder
|
||||
from file_setup import setup
|
||||
from file_setup.setup import initial_setup
|
||||
from tagging import tagger
|
||||
import logging_util
|
||||
import os
|
||||
from settings import CSV_DIRECTORY
|
||||
|
||||
# Create logger for this module
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
@ -27,94 +24,45 @@ 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.
|
||||
"""Launch directly into the deck builder after ensuring data files exist.
|
||||
|
||||
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
|
||||
Creates required directories, ensures card CSVs are present (running setup
|
||||
and tagging if needed), then starts the full deck build flow. Exits when done.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Ensure required CSVs exist and are tagged before proceeding
|
||||
try:
|
||||
cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv')
|
||||
if not os.path.exists(cards_path):
|
||||
logger.info("cards.csv not found. Running initial setup and tagging...")
|
||||
initial_setup()
|
||||
tagger.run_tagging()
|
||||
logger.info("Initial setup and tagging completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed ensuring CSVs are ready: {e}")
|
||||
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.build_deck_full()
|
||||
case 'Quit':
|
||||
logger.info("Exiting application")
|
||||
sys.exit(0)
|
||||
case _:
|
||||
logger.warning(f"Invalid menu choice: {choice}")
|
||||
|
||||
# Fresh builder instance for each deck to avoid state carryover
|
||||
DeckBuilder().build_deck_full()
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in main menu: {e}")
|
||||
logger.error(f"Unexpected error in deck builder: {e}")
|
||||
|
||||
# Prompt to build another deck or quit
|
||||
try:
|
||||
resp = input("\nBuild another deck? (y/n): ").strip().lower()
|
||||
except KeyboardInterrupt:
|
||||
resp = 'n'
|
||||
print("")
|
||||
if resp not in ('y', 'yes'):
|
||||
logger.info("Exiting application")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_menu()
|
|
@ -2235,7 +2235,7 @@ def tag_for_cascade(df: pd.DataFrame, color: str) -> None:
|
|||
logger.error('Error tagging Cascade cards: %s', str(e))
|
||||
raise
|
||||
|
||||
## Dsicover cards
|
||||
## Discover cards
|
||||
def tag_for_discover(df: pd.DataFrame, color: str) -> None:
|
||||
"""Tag cards with Discover using vectorized operations.
|
||||
|
||||
|
@ -2416,6 +2416,7 @@ def tag_for_impulse(df: pd.DataFrame, color: str) -> None:
|
|||
raise
|
||||
|
||||
logger.info('Completed tagging Impulse effects')
|
||||
|
||||
## Cards that have or care about plotting
|
||||
def tag_for_plot(df: pd.DataFrame, color: str) -> None:
|
||||
"""Tag cards with Plot using vectorized operations.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue