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:
matt 2025-08-21 17:01:21 -07:00
parent 07605990a1
commit cb710d37ed
12 changed files with 307 additions and 386 deletions

View file

@ -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()

View file

@ -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 = [

View file

@ -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()

View file

@ -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.