Merge pull request #50 from mwisnowski/maintenance/web-unification

Web UI Architecture Improvements: Modern Stack & Quality Enhancements
This commit is contained in:
mwisnowski 2025-11-07 09:24:25 -08:00 committed by GitHub
commit c5774a04f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
183 changed files with 19742 additions and 4714 deletions

View file

@ -106,6 +106,9 @@ WEB_TAG_PARALLEL=1 # dockerhub: WEB_TAG_PARALLEL="1"
WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4" WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4"
WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0" WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0"
# Card Image Caching (optional, uses Scryfall bulk data API)
CACHE_CARD_IMAGES=1 # dockerhub: CACHE_CARD_IMAGES="1" (1=download images to card_files/images/, 0=fetch from Scryfall API on demand)
# Build Stage Ordering # Build Stage Ordering
WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill

13
.gitignore vendored
View file

@ -9,6 +9,7 @@
RELEASE_NOTES.md RELEASE_NOTES.md
test.py test.py
test_*.py
!test_exclude_cards.txt !test_exclude_cards.txt
!test_include_exclude_config.json !test_include_exclude_config.json
@ -40,4 +41,14 @@ logs/
logs/* logs/*
!logs/perf/ !logs/perf/
logs/perf/* logs/perf/*
!logs/perf/theme_preview_warm_baseline.json !logs/perf/theme_preview_warm_baseline.json
# Node.js and build artifacts
node_modules/
code/web/static/js/
code/web/static/styles.css
*.js.map
# Keep TypeScript sources and Tailwind CSS input
!code/web/static/ts/
!code/web/static/tailwind.css

View file

@ -9,14 +9,56 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Build X and Compare** feature: Build multiple decks with same configuration and compare results side-by-side - **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates
- Build 1-10 decks in parallel to see variance from card selection randomness - Validates Jinja2 syntax across all templates
- Real-time progress tracking with dynamic time estimates based on color count - Checks HTML structure (balanced tags, unique IDs, proper attributes)
- Comparison view with card overlap statistics and individual build summaries - Basic accessibility validation (alt text, form labels, button types)
- Smart filtering excludes guaranteed cards (basics, staples) from "Most Common Cards" - Regression prevention thresholds to maintain code quality
- Card hover support throughout comparison interface - **Code Quality Tools**: Enhanced development tooling for maintainability
- Rebuild button to rerun same configuration - Automated utilities for code cleanup
- Export all decks as ZIP archive - Improved type checking configuration
- **Card Image Caching**: Optional local image cache for faster card display
- Downloads card images from Scryfall bulk data (respects API guidelines)
- Graceful fallback to Scryfall API for uncached images
- Enabled via `CACHE_CARD_IMAGES=1` environment variable
- Integrated with setup/tagging process
- Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness)
- **Component Library**: Living documentation of reusable UI components at `/docs/components`
- Interactive examples of all buttons, modals, forms, cards, and panels
- Jinja2 macros for consistent component usage
- Component partial templates for reuse across pages
- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety
- Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript
- Created shared type definitions for state management, telemetry, HTMX, and UI components
- Integrated TypeScript compilation into build process (`npm run build:ts`)
- Compiled JavaScript output in `code/web/static/js/` directory
- Docker build automatically compiles TypeScript during image creation
### Changed
- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code)
- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules
- Created `cardHover.ts` for unified hover panel functionality
- Created `cardImages.ts` for card image loading with automatic retry fallbacks
- Reduced inline script size in base template for better maintainability
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
- Tailwind CSS v3 with custom MTG color palette
- PostCSS build pipeline with autoprefixer
- Reduced inline styles in templates (moved to shared CSS classes)
- Organized CSS into functional sections with clear documentation
- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes
- Light mode: Darker text for improved readability, warm earth tone color palette
- Dark mode: Refined contrast for better visual hierarchy
- High-contrast mode: Optimized for maximum accessibility
- Consistent hover states across all interactive elements
- Improved visibility of form inputs and controls
- **JavaScript Modernization**: Updated to modern JavaScript patterns
- Converted `var` declarations to `const`/`let`
- Added TypeScript type annotations for better IDE support and error catching
- Consolidated event handlers and utility functions
- **Docker Build Optimization**: Improved developer experience
- Hot reload enabled for templates and static files
- Volume mounts for rapid iteration without rebuilds
- **Template Modernization**: Migrated templates to use component system
- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck - **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck
- Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%)
- 10% bonus for cards appearing in 80%+ of builds - 10% bonus for cards appearing in 80%+ of builds
@ -27,18 +69,46 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) - `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled)
- Detailed progress logging for multi-build orchestration - Detailed progress logging for multi-build orchestration
- User guide: `docs/user_guides/batch_build_compare.md` - User guide: `docs/user_guides/batch_build_compare.md`
- **Web UI Component Library**: Standardized UI components for consistent design across all pages
- 5 component partial template files (buttons, modals, forms, cards, panels)
- ~900 lines of component CSS styles
- Interactive JavaScript utilities (components.js)
- Living component library page at `/docs/components`
- 1600+ lines developer documentation (component_catalog.md)
- **Custom UI Enhancements**:
- Darker gray styling for home page buttons
- Visual highlighting for selected theme chips in deck builder
### Changed ### Changed
_None_ - Migrated 5 templates to new component system (home, 404, 500, setup, commanders)
- **Type Checking Configuration**: Improved Python code quality tooling
- Configured type checker for better error detection
- Optimized linting rules for development workflow
### Fixed
- **Template Quality**: Resolved HTML structure issues found by validation tests
- Fixed duplicate ID attributes in build wizard and theme picker templates
- Removed erroneous block tags from component documentation
- Corrected template structure for HTMX fragments
- **Code Quality**: Resolved type checking warnings and improved code maintainability
- Fixed type annotation inconsistencies
- Cleaned up redundant code quality suppressions
- Corrected configuration conflicts
### Removed ### Removed
_None_ _None_
### Fixed
_None_
### Performance ### Performance
_None_ - Hot reload for CSS/template changes (no Docker rebuild needed)
- Optional image caching reduces Scryfall API calls
- Faster page loads with optimized CSS
- TypeScript compilation produces optimized JavaScript
### For Users
- Faster card image loading with optional caching
- Cleaner, more consistent web UI design
- Improved page load performance
- More reliable JavaScript behavior
### Deprecated ### Deprecated
_None_ _None_

View file

@ -283,6 +283,7 @@ See `.env.example` for the full catalog. Common knobs:
| `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. |
| `WEB_TAG_PARALLEL` | `1` | Use parallel workers during tagging. | | `WEB_TAG_PARALLEL` | `1` | Use parallel workers during tagging. |
| `WEB_TAG_WORKERS` | `4` | Worker count for parallel tagging. | | `WEB_TAG_WORKERS` | `4` | Worker count for parallel tagging. |
| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). See [Image Caching](docs/IMAGE_CACHING.md). |
| `WEB_AUTO_ENFORCE` | `0` | Re-export decks after auto-applying compliance fixes. | | `WEB_AUTO_ENFORCE` | `0` | Re-export decks after auto-applying compliance fixes. |
| `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. |

View file

@ -10,21 +10,42 @@ ENV PYTHONUNBUFFERED=1
ARG APP_VERSION=dev ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION=${APP_VERSION}
# Install system dependencies if needed # Install system dependencies including Node.js
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching # Copy package files for Node.js dependencies
COPY package.json package-lock.json* ./
# Install Node.js dependencies
RUN npm install
# Copy Tailwind/TypeScript config files
COPY tailwind.config.js postcss.config.js tsconfig.json ./
# Copy requirements for Python dependencies (for better caching)
COPY requirements.txt . COPY requirements.txt .
# Install Python dependencies # Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy Python application code (includes templates needed for Tailwind)
COPY code/ ./code/ COPY code/ ./code/
COPY mypy.ini . COPY mypy.ini .
# Tailwind source is already in code/web/static/tailwind.css from COPY code/
# TypeScript sources are in code/web/static/ts/ from COPY code/
# Force fresh CSS build by removing any copied styles.css
RUN rm -f ./code/web/static/styles.css
# Build CSS and TypeScript
RUN npm run build
# Copy default configs in two locations: # Copy default configs in two locations:
# 1) /app/config is the live path (may be overlaid by a volume) # 1) /app/config is the live path (may be overlaid by a volume)
# 2) /app/.defaults/config is preserved in the image for first-run seeding when a volume is mounted # 2) /app/.defaults/config is preserved in the image for first-run seeding when a volume is mounted
@ -36,7 +57,9 @@ RUN mkdir -p owned_cards
# Store in /.defaults/card_files so it persists after volume mount # Store in /.defaults/card_files so it persists after volume mount
RUN mkdir -p /.defaults/card_files RUN mkdir -p /.defaults/card_files
# Copy entire card_files directory (will include cache if present, empty if not) # Copy entire card_files directory (will include cache if present, empty if not)
COPY card_files/ /.defaults/card_files/ # COMMENTED OUT FOR LOCAL DEV: card_files is mounted as volume anyway
# Uncomment for production builds or CI/CD
# COPY card_files/ /.defaults/card_files/
# Create necessary directories as mount points # Create necessary directories as mount points
RUN mkdir -p deck_files logs csv_files card_files config /.defaults RUN mkdir -p deck_files logs csv_files card_files config /.defaults

View file

@ -309,6 +309,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. |
| `WEB_TAG_PARALLEL` | `1` | Enable parallel tagging workers. | | `WEB_TAG_PARALLEL` | `1` | Enable parallel tagging workers. |
| `WEB_TAG_WORKERS` | `4` | Worker count for tagging (compose default). | | `WEB_TAG_WORKERS` | `4` | Worker count for tagging (compose default). |
| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). Requires ~3-6 GB. See [Image Caching](docs/IMAGE_CACHING.md). |
| `WEB_AUTO_ENFORCE` | `0` | Auto-apply bracket enforcement after builds. | | `WEB_AUTO_ENFORCE` | `0` | Auto-apply bracket enforcement after builds. |
| `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. |

View file

@ -3,36 +3,106 @@
## [Unreleased] ## [Unreleased]
### Summary ### Summary
Major new feature: Build X and Compare with Intelligent Synergy Builder. Run the same deck configuration multiple times to see variance, compare results side-by-side, and create optimized "best-of" decks. Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability.
### Added ### Added
- **Build X and Compare**: Build 1-10 decks in parallel with same configuration - **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality
- Side-by-side comparison with card overlap statistics - Validates Jinja2 syntax and structure
- Smart filtering of guaranteed cards - Checks for common HTML issues (duplicate IDs, balanced tags)
- Rebuild button for quick iterations - Basic accessibility validation
- ZIP export of all builds - Prevents regression in template quality
- **Synergy Builder**: Create optimized deck from multiple builds - **Code Quality Tools**: Enhanced development tooling for maintainability
- Intelligent scoring (frequency + EDHREC + themes) - Automated utilities for code cleanup
- Color-coded synergy preview - Improved type checking configuration
- Full metadata export (CSV/TXT/JSON) - **Card Image Caching**: Optional local image cache for faster card display
- Partner commander support - Downloads card images from Scryfall bulk data (respects API guidelines)
- Feature flag: `ENABLE_BATCH_BUILD` (default: on) - Graceful fallback to Scryfall API for uncached images
- User guide: `docs/user_guides/batch_build_compare.md` - Enabled via `CACHE_CARD_IMAGES=1` environment variable
- Integrated with setup/tagging process
- Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness)
- **Component Library**: Living documentation of reusable UI components at `/docs/components`
- Interactive examples of all buttons, modals, forms, cards, and panels
- Jinja2 macros for consistent component usage
- Component partial templates for reuse across pages
- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety
- Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript
- Created shared type definitions for state management, telemetry, HTMX, and UI components
- Integrated TypeScript compilation into build process (`npm run build:ts`)
- Compiled JavaScript output in `code/web/static/js/` directory
- Docker build automatically compiles TypeScript during image creation
### Changed ### Changed
_None_ - **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code)
- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules
- Created `cardHover.ts` for unified hover panel functionality
- Created `cardImages.ts` for card image loading with automatic retry fallbacks
- Reduced inline script size in base template for better maintainability
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
- Tailwind CSS v3 with custom MTG color palette
- PostCSS build pipeline with autoprefixer
- Reduced inline styles in templates (moved to shared CSS classes)
- Organized CSS into functional sections with clear documentation
- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes
- Light mode: Darker text for improved readability, warm earth tone color palette
- Dark mode: Refined contrast for better visual hierarchy
- High-contrast mode: Optimized for maximum accessibility
- Consistent hover states across all interactive elements
- Improved visibility of form inputs and controls
- **JavaScript Modernization**: Updated to modern JavaScript patterns
- Converted `var` declarations to `const`/`let`
- Added TypeScript type annotations for better IDE support and error catching
- Consolidated event handlers and utility functions
- **Docker Build Optimization**: Improved developer experience
- Hot reload enabled for templates and static files
- Volume mounts for rapid iteration without rebuilds
- **Template Modernization**: Migrated templates to use component system
- **Type Checking Configuration**: Improved Python code quality tooling
- Configured type checker for better error detection
- Optimized linting rules for development workflow
- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck
- Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%)
- 10% bonus for cards appearing in 80%+ of builds
- Color-coded synergy scores in preview (green=high, red=low)
- Partner commander support with combined color identity
- Multi-copy card tracking (e.g., 8 Mountains, 7 Islands)
- Export synergy deck with full metadata (CSV, TXT, JSON files)
- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled)
- Detailed progress logging for multi-build orchestration
- User guide: `docs/user_guides/batch_build_compare.md`
- **Web UI Component Library**: Standardized UI components for consistent design across all pages
- 5 component partial template files (buttons, modals, forms, cards, panels)
- ~900 lines of component CSS styles
- Interactive JavaScript utilities (components.js)
- Living component library page at `/docs/components`
- 1600+ lines developer documentation (component_catalog.md)
- **Custom UI Enhancements**:
- Darker gray styling for home page buttons
- Visual highlighting for selected theme chips in deck builder
### Removed ### Removed
_None_ _None_
### Fixed ### Fixed
_None_ - **Template Quality**: Resolved HTML structure issues
- Fixed duplicate ID attributes in templates
- Removed erroneous template block tags
- Corrected structure for HTMX fragments
- **Code Quality**: Resolved type checking warnings and improved code maintainability
- Fixed type annotation inconsistencies
- Cleaned up redundant code quality suppressions
- Corrected configuration conflicts
### Performance ### Performance
_None_ - Hot reload for CSS/template changes (no Docker rebuild needed)
- Optional image caching reduces Scryfall API calls
- Faster page loads with optimized CSS
- TypeScript compilation produces optimized JavaScript
### For Users ### For Users
_No changes yet_ - Faster card image loading with optional caching
- Cleaner, more consistent web UI design
- Improved page load performance
- More reliable JavaScript behavior
### Deprecated ### Deprecated
_None_ _None_

View file

@ -4,6 +4,6 @@ __all__ = ['DeckBuilder']
def __getattr__(name): def __getattr__(name):
# Lazy-load DeckBuilder to avoid side effects during import of submodules # Lazy-load DeckBuilder to avoid side effects during import of submodules
if name == 'DeckBuilder': if name == 'DeckBuilder':
from .builder import DeckBuilder # type: ignore from .builder import DeckBuilder
return DeckBuilder return DeckBuilder
raise AttributeError(name) raise AttributeError(name)

View file

@ -1,22 +1,18 @@
"""Loader for background cards derived from `background_cards.csv`.""" """Loader for background cards derived from all_cards.parquet."""
from __future__ import annotations from __future__ import annotations
import ast import ast
import csv import re
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
import re from typing import Any, Mapping, Tuple
from typing import Mapping, Tuple
from logging_util import get_logger from logging_util import get_logger
from deck_builder.partner_background_utils import analyze_partner_background from deck_builder.partner_background_utils import analyze_partner_background
from path_util import csv_dir
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
BACKGROUND_FILENAME = "background_cards.csv"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class BackgroundCard: class BackgroundCard:
@ -57,7 +53,7 @@ class BackgroundCatalog:
def load_background_cards( def load_background_cards(
source_path: str | Path | None = None, source_path: str | Path | None = None,
) -> BackgroundCatalog: ) -> BackgroundCatalog:
"""Load and cache background card data.""" """Load and cache background card data from all_cards.parquet."""
resolved = _resolve_background_path(source_path) resolved = _resolve_background_path(source_path)
try: try:
@ -65,7 +61,7 @@ def load_background_cards(
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
size = stat.st_size size = stat.st_size
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError(f"Background CSV not found at {resolved}") from None raise FileNotFoundError(f"Background data not found at {resolved}") from None
entries, version = _load_background_cards_cached(str(resolved), mtime_ns) entries, version = _load_background_cards_cached(str(resolved), mtime_ns)
etag = f"{size}-{mtime_ns}-{len(entries)}" etag = f"{size}-{mtime_ns}-{len(entries)}"
@ -88,46 +84,49 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B
if not path.exists(): if not path.exists():
return tuple(), "unknown" return tuple(), "unknown"
with path.open("r", encoding="utf-8", newline="") as handle: try:
first_line = handle.readline() import pandas as pd
version = "unknown" df = pd.read_parquet(path, engine="pyarrow")
if first_line.startswith("#"):
version = _parse_version(first_line) # Filter for background cards
else: if 'isBackground' not in df.columns:
handle.seek(0) LOGGER.warning("isBackground column not found in %s", path)
reader = csv.DictReader(handle) return tuple(), "unknown"
if reader.fieldnames is None:
return tuple(), version df_backgrounds = df[df['isBackground']].copy()
entries = _rows_to_cards(reader)
if len(df_backgrounds) == 0:
LOGGER.warning("No background cards found in %s", path)
return tuple(), "unknown"
entries = _rows_to_cards(df_backgrounds)
version = "parquet"
except Exception as e:
LOGGER.error("Failed to load backgrounds from %s: %s", path, e)
return tuple(), "unknown"
frozen = tuple(entries) frozen = tuple(entries)
return frozen, version return frozen, version
def _resolve_background_path(override: str | Path | None) -> Path: def _resolve_background_path(override: str | Path | None) -> Path:
"""Resolve path to all_cards.parquet."""
if override: if override:
return Path(override).resolve() return Path(override).resolve()
return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve() # Use card_files/processed/all_cards.parquet
return Path("card_files/processed/all_cards.parquet").resolve()
def _parse_version(line: str) -> str: def _rows_to_cards(df) -> list[BackgroundCard]:
tokens = line.lstrip("# ").strip().split() """Convert DataFrame rows to BackgroundCard objects."""
for token in tokens:
if "=" not in token:
continue
key, value = token.split("=", 1)
if key == "version":
return value
return "unknown"
def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
entries: list[BackgroundCard] = [] entries: list[BackgroundCard] = []
seen: set[str] = set() seen: set[str] = set()
for raw in reader:
if not raw: for _, row in df.iterrows():
if row.empty:
continue continue
card = _row_to_card(raw) card = _row_to_card(row)
if card is None: if card is None:
continue continue
key = card.display_name.lower() key = card.display_name.lower()
@ -135,20 +134,35 @@ def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
continue continue
seen.add(key) seen.add(key)
entries.append(card) entries.append(card)
entries.sort(key=lambda card: card.display_name) entries.sort(key=lambda card: card.display_name)
return entries return entries
def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None: def _row_to_card(row) -> BackgroundCard | None:
name = _clean_str(row.get("name")) """Convert a DataFrame row to a BackgroundCard."""
face_name = _clean_str(row.get("faceName")) or None # Helper to safely get values from DataFrame row
def get_val(key: str):
try:
if hasattr(row, key):
val = getattr(row, key)
# Handle pandas NA/None
if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__):
return None
return val
return None
except Exception:
return None
name = _clean_str(get_val("name"))
face_name = _clean_str(get_val("faceName")) or None
display = face_name or name display = face_name or name
if not display: if not display:
return None return None
type_line = _clean_str(row.get("type")) type_line = _clean_str(get_val("type"))
oracle_text = _clean_multiline(row.get("text")) oracle_text = _clean_multiline(get_val("text"))
raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags"))) raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags")))
detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags) detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags)
if not detection.is_background: if not detection.is_background:
return None return None
@ -158,18 +172,18 @@ def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
face_name=face_name, face_name=face_name,
display_name=display, display_name=display,
slug=_slugify(display), slug=_slugify(display),
color_identity=_parse_color_list(row.get("colorIdentity")), color_identity=_parse_color_list(get_val("colorIdentity")),
colors=_parse_color_list(row.get("colors")), colors=_parse_color_list(get_val("colors")),
mana_cost=_clean_str(row.get("manaCost")), mana_cost=_clean_str(get_val("manaCost")),
mana_value=_parse_float(row.get("manaValue")), mana_value=_parse_float(get_val("manaValue")),
type_line=type_line, type_line=type_line,
oracle_text=oracle_text, oracle_text=oracle_text,
keywords=tuple(_split_list(row.get("keywords"))), keywords=tuple(_split_list(get_val("keywords"))),
theme_tags=tuple(tag for tag in raw_theme_tags if tag), theme_tags=tuple(tag for tag in raw_theme_tags if tag),
raw_theme_tags=raw_theme_tags, raw_theme_tags=raw_theme_tags,
edhrec_rank=_parse_int(row.get("edhrecRank")), edhrec_rank=_parse_int(get_val("edhrecRank")),
layout=_clean_str(row.get("layout")) or "normal", layout=_clean_str(get_val("layout")) or "normal",
side=_clean_str(row.get("side")) or None, side=_clean_str(get_val("side")) or None,
) )
@ -189,8 +203,19 @@ def _clean_multiline(value: object) -> str:
def _parse_literal_list(value: object) -> list[str]: def _parse_literal_list(value: object) -> list[str]:
if value is None: if value is None:
return [] return []
if isinstance(value, (list, tuple, set)):
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
# Handle lists, tuples, sets, and numpy arrays
if isinstance(value, (list, tuple, set)) or is_numpy:
return [str(item).strip() for item in value if str(item).strip()] return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip() text = str(value).strip()
if not text: if not text:
return [] return []
@ -205,6 +230,17 @@ def _parse_literal_list(value: object) -> list[str]:
def _split_list(value: object) -> list[str]: def _split_list(value: object) -> list[str]:
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
if isinstance(value, (list, tuple, set)) or is_numpy:
return [str(item).strip() for item in value if str(item).strip()]
text = _clean_str(value) text = _clean_str(value)
if not text: if not text:
return [] return []
@ -213,6 +249,18 @@ def _split_list(value: object) -> list[str]:
def _parse_color_list(value: object) -> Tuple[str, ...]: def _parse_color_list(value: object) -> Tuple[str, ...]:
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
if isinstance(value, (list, tuple, set)) or is_numpy:
parts = [str(item).strip().upper() for item in value if str(item).strip()]
return tuple(parts)
text = _clean_str(value) text = _clean_str(value)
if not text: if not text:
return tuple() return tuple()

View file

@ -95,7 +95,7 @@ class DeckBuilder(
# If a seed was assigned pre-init, use it # If a seed was assigned pre-init, use it
if self.seed is not None: if self.seed is not None:
# Import here to avoid any heavy import cycles at module import time # Import here to avoid any heavy import cycles at module import time
from random_util import set_seed as _set_seed # type: ignore from random_util import set_seed as _set_seed
self._rng = _set_seed(int(self.seed)) self._rng = _set_seed(int(self.seed))
else: else:
self._rng = random.Random() self._rng = random.Random()
@ -107,7 +107,7 @@ class DeckBuilder(
def set_seed(self, seed: int | str) -> None: def set_seed(self, seed: int | str) -> None:
"""Set deterministic seed for this builder and reset its RNG instance.""" """Set deterministic seed for this builder and reset its RNG instance."""
try: try:
from random_util import derive_seed_from_string as _derive, set_seed as _set_seed # type: ignore from random_util import derive_seed_from_string as _derive, set_seed as _set_seed
s = _derive(seed) s = _derive(seed)
self.seed = int(s) self.seed = int(s)
self._rng = _set_seed(s) self._rng = _set_seed(s)
@ -215,7 +215,7 @@ class DeckBuilder(
try: try:
# Compute a quick compliance snapshot here to hint at upcoming enforcement # Compute a quick compliance snapshot here to hint at upcoming enforcement
if hasattr(self, 'compute_and_print_compliance') and not getattr(self, 'headless', False): if hasattr(self, 'compute_and_print_compliance') and not getattr(self, 'headless', False):
from deck_builder.brackets_compliance import evaluate_deck as _eval # type: ignore from deck_builder.brackets_compliance import evaluate_deck as _eval
bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower() bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower()
commander = getattr(self, 'commander_name', None) commander = getattr(self, 'commander_name', None)
snap = _eval(self.card_library, commander_name=commander, bracket=bracket_key) snap = _eval(self.card_library, commander_name=commander, bracket=bracket_key)
@ -240,15 +240,15 @@ class DeckBuilder(
csv_path = self.export_decklist_csv() csv_path = self.export_decklist_csv()
# Persist CSV path immediately (before any later potential exceptions) # Persist CSV path immediately (before any later potential exceptions)
try: try:
self.last_csv_path = csv_path # type: ignore[attr-defined] self.last_csv_path = csv_path
except Exception: except Exception:
pass pass
try: try:
import os as _os import os as _os
base, _ext = _os.path.splitext(_os.path.basename(csv_path)) base, _ext = _os.path.splitext(_os.path.basename(csv_path))
txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] txt_path = self.export_decklist_text(filename=base + '.txt')
try: try:
self.last_txt_path = txt_path # type: ignore[attr-defined] self.last_txt_path = txt_path
except Exception: except Exception:
pass pass
# Display the text file contents for easy copy/paste to online deck builders # Display the text file contents for easy copy/paste to online deck builders
@ -256,18 +256,18 @@ class DeckBuilder(
# Compute bracket compliance and save a JSON report alongside exports # Compute bracket compliance and save a JSON report alongside exports
try: try:
if hasattr(self, 'compute_and_print_compliance'): if hasattr(self, 'compute_and_print_compliance'):
report0 = self.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] report0 = self.compute_and_print_compliance(base_stem=base)
# If non-compliant and interactive, offer enforcement now # If non-compliant and interactive, offer enforcement now
try: try:
if isinstance(report0, dict) and report0.get('overall') == 'FAIL' and not getattr(self, 'headless', False): if isinstance(report0, dict) and report0.get('overall') == 'FAIL' and not getattr(self, 'headless', False):
from deck_builder.phases.phase6_reporting import ReportingMixin as _RM # type: ignore from deck_builder.phases.phase6_reporting import ReportingMixin as _RM
if isinstance(self, _RM) and hasattr(self, 'enforce_and_reexport'): if isinstance(self, _RM) and hasattr(self, 'enforce_and_reexport'):
self.output_func("One or more bracket limits exceeded. Enter to auto-resolve, or Ctrl+C to skip.") self.output_func("One or more bracket limits exceeded. Enter to auto-resolve, or Ctrl+C to skip.")
try: try:
_ = self.input_func("") _ = self.input_func("")
except Exception: except Exception:
pass pass
self.enforce_and_reexport(base_stem=base, mode='prompt') # type: ignore[attr-defined] self.enforce_and_reexport(base_stem=base, mode='prompt')
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -295,12 +295,12 @@ class DeckBuilder(
cfg_dir = 'config' cfg_dir = 'config'
if cfg_dir: if cfg_dir:
_os.makedirs(cfg_dir, exist_ok=True) _os.makedirs(cfg_dir, exist_ok=True)
self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined] self.export_run_config_json(directory=cfg_dir, filename=base + '.json')
if cfg_path_env: if cfg_path_env:
cfg_dir2 = _os.path.dirname(cfg_path_env) or '.' cfg_dir2 = _os.path.dirname(cfg_path_env) or '.'
cfg_name2 = _os.path.basename(cfg_path_env) cfg_name2 = _os.path.basename(cfg_path_env)
_os.makedirs(cfg_dir2, exist_ok=True) _os.makedirs(cfg_dir2, exist_ok=True)
self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined] self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2)
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -308,8 +308,8 @@ class DeckBuilder(
else: else:
# Mark suppression so random flow knows nothing was exported yet # Mark suppression so random flow knows nothing was exported yet
try: try:
self.last_csv_path = None # type: ignore[attr-defined] self.last_csv_path = None
self.last_txt_path = None # type: ignore[attr-defined] self.last_txt_path = None
except Exception: except Exception:
pass pass
# If owned-only and deck not complete, print a note # If owned-only and deck not complete, print a note
@ -624,8 +624,8 @@ class DeckBuilder(
try: try:
rec.card_library = rec_subset rec.card_library = rec_subset
# Export CSV and TXT with suffix # Export CSV and TXT with suffix
rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True) # type: ignore[attr-defined] rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True)
rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) # type: ignore[attr-defined] rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True)
finally: finally:
rec.card_library = original_lib rec.card_library = original_lib
# Notify user succinctly # Notify user succinctly
@ -1843,7 +1843,7 @@ class DeckBuilder(
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from settings import MULTIPLE_COPY_CARDS from settings import MULTIPLE_COPY_CARDS
except Exception: except Exception:
MULTIPLE_COPY_CARDS = [] # type: ignore MULTIPLE_COPY_CARDS = []
is_land = 'land' in str(card_type or entry.get('Card Type','')).lower() is_land = 'land' in str(card_type or entry.get('Card Type','')).lower()
is_basic = False is_basic = False
try: try:
@ -2353,7 +2353,7 @@ class DeckBuilder(
rng = getattr(self, 'rng', None) rng = getattr(self, 'rng', None)
try: try:
if rng: if rng:
rng.shuffle(bucket_keys) # type: ignore rng.shuffle(bucket_keys)
else: else:
random.shuffle(bucket_keys) random.shuffle(bucket_keys)
except Exception: except Exception:

View file

@ -1,4 +1,4 @@
from typing import Dict, List, Final, Tuple, Union, Callable, Any as _Any from typing import Dict, List, Final, Tuple, Union, Callable, Any
from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified
from path_util import csv_dir from path_util import csv_dir
import pandas as pd import pandas as pd
@ -21,7 +21,7 @@ DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv" COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv"
DECK_DIRECTORY = '../deck_files' DECK_DIRECTORY = '../deck_files'
# M4: Deprecated - Parquet handles types natively (no converters needed) # M4: Deprecated - Parquet handles types natively (no converters needed)
COMMANDER_CONVERTERS: Final[Dict[str, str]] = { COMMANDER_CONVERTERS: Final[Dict[str, Any]] = {
'themeTags': ast.literal_eval, 'themeTags': ast.literal_eval,
'creatureTypes': ast.literal_eval, 'creatureTypes': ast.literal_eval,
'roleTags': ast.literal_eval, 'roleTags': ast.literal_eval,
@ -140,18 +140,18 @@ OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
} }
# Card category validation rules # Card category validation rules
CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'power': {'type': ('str', 'int', 'float'), 'required': True}, 'power': {'type': ('str', 'int', 'float'), 'required': True},
'toughness': {'type': ('str', 'int', 'float'), 'required': True}, 'toughness': {'type': ('str', 'int', 'float'), 'required': True},
'creatureTypes': {'type': 'list', 'required': True} 'creatureTypes': {'type': 'list', 'required': True}
} }
SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'manaCost': {'type': 'str', 'required': True}, 'manaCost': {'type': 'str', 'required': True},
'text': {'type': 'str', 'required': True} 'text': {'type': 'str', 'required': True}
} }
LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'type': {'type': ('str', 'object'), 'required': True}, 'type': {'type': ('str', 'object'), 'required': True},
'text': {'type': ('str', 'object'), 'required': False} 'text': {'type': ('str', 'object'), 'required': False}
} }
@ -526,7 +526,7 @@ CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations
CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch
# CSV validation configuration # CSV validation configuration
CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = { CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'name': {'type': ('str', 'object'), 'required': True, 'unique': True}, 'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000}, 'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20}, 'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
@ -602,12 +602,12 @@ GAME_CHANGERS: Final[List[str]] = [
# - color_identity: list[str] of required color letters (subset must be in commander CI) # - color_identity: list[str] of required color letters (subset must be in commander CI)
# - printed_cap: int | None (None means no printed cap) # - printed_cap: int | None (None means no printed cap)
# - exclusive_group: str | None (at most one from the same group) # - exclusive_group: str | None (at most one from the same group)
# - triggers: { tags_any: list[str], tags_all: list[str] } # - triggers: { tagsAny: list[str], tags_all: list[str] }
# - default_count: int (default 25) # - default_count: int (default 25)
# - rec_window: tuple[int,int] (recommendation window) # - rec_window: tuple[int,int] (recommendation window)
# - thrumming_stone_synergy: bool # - thrumming_stone_synergy: bool
# - type_hint: 'creature' | 'noncreature' # - type_hint: 'creature' | 'noncreature'
MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = {
'cid_timeless_artificer': { 'cid_timeless_artificer': {
'id': 'cid_timeless_artificer', 'id': 'cid_timeless_artificer',
'name': 'Cid, Timeless Artificer', 'name': 'Cid, Timeless Artificer',
@ -615,7 +615,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['artificer kindred', 'hero kindred', 'artifacts matter'], 'tagsAny': ['artificer kindred', 'hero kindred', 'artifacts matter'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -630,7 +630,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'], 'tagsAny': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -645,7 +645,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['rabbit kindred','tokens matter','aggro'], 'tagsAny': ['rabbit kindred','tokens matter','aggro'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -660,7 +660,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'], 'tagsAny': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -675,7 +675,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': 'rats', 'exclusive_group': 'rats',
'triggers': { 'triggers': {
'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -690,7 +690,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': 'rats', 'exclusive_group': 'rats',
'triggers': { 'triggers': {
'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -705,7 +705,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': 7, 'printed_cap': 7,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'], 'tagsAny': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 7, 'default_count': 7,
@ -720,7 +720,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['mill','advisor kindred','control','defenders','walls','draw-go'], 'tagsAny': ['mill','advisor kindred','control','defenders','walls','draw-go'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -735,7 +735,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'], 'tagsAny': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -750,7 +750,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': 9, 'printed_cap': 9,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'], 'tagsAny': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 9, 'default_count': 9,
@ -765,7 +765,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['bird kindred','aggro'], 'tagsAny': ['bird kindred','aggro'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -780,7 +780,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None, 'printed_cap': None,
'exclusive_group': None, 'exclusive_group': None,
'triggers': { 'triggers': {
'tags_any': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], 'tagsAny': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'],
'tags_all': [] 'tags_all': []
}, },
'default_count': 25, 'default_count': 25,
@ -956,3 +956,4 @@ def get_backgrounds(df: pd.DataFrame) -> pd.DataFrame:
if 'isBackground' not in df.columns: if 'isBackground' not in df.columns:
return pd.DataFrame() return pd.DataFrame()
return df[df['isBackground'] == True].copy() # noqa: E712 return df[df['isBackground'] == True].copy() # noqa: E712

View file

@ -62,6 +62,32 @@ def _detect_produces_mana(text: str) -> bool:
return False return False
def _extract_colors_from_land_type(type_line: str) -> List[str]:
"""Extract mana colors from basic land types in a type line.
Args:
type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains")
Returns:
List of color letters (e.g., ['R'], ['G', 'W'])
"""
if not isinstance(type_line, str):
return []
type_lower = type_line.lower()
colors = []
basic_land_colors = {
'plains': 'W',
'island': 'U',
'swamp': 'B',
'mountain': 'R',
'forest': 'G',
}
for land_type, color in basic_land_colors.items():
if land_type in type_lower:
colors.append(color)
return colors
def _resolved_csv_dir(base_dir: str | None = None) -> str: def _resolved_csv_dir(base_dir: str | None = None) -> str:
try: try:
if base_dir: if base_dir:
@ -144,7 +170,9 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
return {} return {}
# Select only needed columns # Select only needed columns
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName'] # M9: Added backType to detect MDFC lands where land is on back face
# M9: Added colorIdentity to extract mana colors for MDFC lands
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity']
available_cols = [col for col in usecols if col in df.columns] available_cols = [col for col in usecols if col in df.columns]
if not available_cols: if not available_cols:
return {} return {}
@ -160,7 +188,16 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
multi_df['type'] = multi_df['type'].fillna('').astype(str) multi_df['type'] = multi_df['type'].fillna('').astype(str)
multi_df['side'] = multi_df['side'].fillna('').astype(str) multi_df['side'] = multi_df['side'].fillna('').astype(str)
multi_df['text'] = multi_df['text'].fillna('').astype(str) multi_df['text'] = multi_df['text'].fillna('').astype(str)
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] # M9: Check both type and backType for land faces
if 'backType' in multi_df.columns:
multi_df['backType'] = multi_df['backType'].fillna('').astype(str)
land_mask = (
multi_df['type'].str.contains('land', case=False, na=False) |
multi_df['backType'].str.contains('land', case=False, na=False)
)
land_rows = multi_df[land_mask]
else:
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)]
if land_rows.empty: if land_rows.empty:
return {} return {}
mapping: Dict[str, Dict[str, Any]] = {} mapping: Dict[str, Dict[str, Any]] = {}
@ -169,6 +206,78 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
seen: set[tuple[str, str, str]] = set() seen: set[tuple[str, str, str]] = set()
front_is_land = False front_is_land = False
layout_val = '' layout_val = ''
# M9: Handle merged rows with backType
if len(group) == 1 and 'backType' in group.columns:
row = group.iloc[0]
back_type_val = str(row.get('backType', '') or '')
if back_type_val and 'land' in back_type_val.lower():
# Construct synthetic faces from merged row
front_type = str(row.get('type', '') or '')
front_text = str(row.get('text', '') or '')
mana_cost_val = str(row.get('manaCost', '') or '')
mana_value_raw = row.get('manaValue', '')
mana_value_val = None
try:
if mana_value_raw not in (None, ''):
mana_value_val = float(mana_value_raw)
if math.isnan(mana_value_val):
mana_value_val = None
except Exception:
mana_value_val = None
# Front face
faces.append({
'face': str(row.get('faceName', '') or name),
'side': 'a',
'type': front_type,
'text': front_text,
'mana_cost': mana_cost_val,
'mana_value': mana_value_val,
'produces_mana': _detect_produces_mana(front_text),
'is_land': 'land' in front_type.lower(),
'layout': str(row.get('layout', '') or ''),
})
# Back face (synthesized)
# M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line)
color_identity_raw = row.get('colorIdentity', [])
if isinstance(color_identity_raw, str):
# Handle string format like "['G']" or "G"
try:
import ast
color_identity_raw = ast.literal_eval(color_identity_raw)
except Exception:
color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()]
back_face_colors = list(color_identity_raw) if color_identity_raw else []
# Fallback to parsing land type if colorIdentity not available
if not back_face_colors:
back_face_colors = _extract_colors_from_land_type(back_type_val)
faces.append({
'face': name.split(' // ')[1] if ' // ' in name else 'Back',
'side': 'b',
'type': back_type_val,
'text': '', # Not available in merged row
'mana_cost': '',
'mana_value': None,
'produces_mana': True, # Assume land produces mana
'is_land': True,
'layout': str(row.get('layout', '') or ''),
'colors': back_face_colors, # M9: Color information for mana sources
})
front_is_land = 'land' in front_type.lower()
layout_val = str(row.get('layout', '') or '')
mapping[name] = {
'faces': faces,
'front_is_land': front_is_land,
'layout': layout_val,
'colors': back_face_colors, # M9: Store colors at top level for easy access
}
continue
# Original logic for multi-row format
for _, row in group.iterrows(): for _, row in group.iterrows():
side_raw = str(row.get('side', '') or '').strip() side_raw = str(row.get('side', '') or '').strip()
side_key = side_raw.lower() side_key = side_raw.lower()
@ -316,7 +425,7 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
matrix: Dict[str, Dict[str, int]] = {} matrix: Dict[str, Dict[str, int]] = {}
lookup = {} lookup = {}
if full_df is not None and not getattr(full_df, 'empty', True) and 'name' in full_df.columns: if full_df is not None and not getattr(full_df, 'empty', True) and 'name' in full_df.columns:
for _, r in full_df.iterrows(): # type: ignore[attr-defined] for _, r in full_df.iterrows():
nm = str(r.get('name', '')) nm = str(r.get('name', ''))
if nm and nm not in lookup: if nm and nm not in lookup:
lookup[nm] = r lookup[nm] = r
@ -332,8 +441,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if hasattr(row, 'get'): if hasattr(row, 'get'):
row_type_raw = row.get('type', row.get('type_line', '')) or '' row_type_raw = row.get('type', row.get('type_line', '')) or ''
tline_full = str(row_type_raw).lower() tline_full = str(row_type_raw).lower()
# M9: Check backType for MDFC land detection
back_type_raw = ''
if hasattr(row, 'get'):
back_type_raw = row.get('backType', '') or ''
back_type = str(back_type_raw).lower()
# Land or permanent that could produce mana via text # Land or permanent that could produce mana via text
is_land = ('land' in entry_type) or ('land' in tline_full) is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type)
base_is_land = is_land base_is_land = is_land
text_field_raw = '' text_field_raw = ''
if hasattr(row, 'get'): if hasattr(row, 'get'):
@ -363,7 +477,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if face_types or face_texts: if face_types or face_texts:
is_land = True is_land = True
text_field = text_field_raw.lower().replace('\n', ' ') text_field = text_field_raw.lower().replace('\n', ' ')
# Skip obvious non-permanents (rituals etc.) # Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land
# M9: If is_land is True (from backType check), we keep it regardless of front face type
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full): if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
continue continue
# Keep only candidates that are lands OR whose text indicates mana production # Keep only candidates that are lands OR whose text indicates mana production
@ -437,6 +552,12 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
colors['_dfc_land'] = True colors['_dfc_land'] = True
if not (base_is_land or dfc_entry.get('front_is_land')): if not (base_is_land or dfc_entry.get('front_is_land')):
colors['_dfc_counts_as_extra'] = True colors['_dfc_counts_as_extra'] = True
# M9: Extract colors from DFC face metadata (back face land colors)
dfc_colors = dfc_entry.get('colors', [])
if dfc_colors:
for color in dfc_colors:
if color in colors:
colors[color] = 1
produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C')) produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C'))
if produces_any_color or colors.get('_dfc_land'): if produces_any_color or colors.get('_dfc_land'):
matrix[name] = colors matrix[name] = colors
@ -729,7 +850,7 @@ def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: i
out: list[tuple[int,str,str,str]] = [] out: list[tuple[int,str,str,str]] = []
if df is None or getattr(df, 'empty', True): if df is None or getattr(df, 'empty', True):
return out return out
for _, row in df.iterrows(): # type: ignore[attr-defined] for _, row in df.iterrows():
try: try:
name = str(row.get('name','')) name = str(row.get('name',''))
if not name or name in already or name in basics: if not name or name in already or name in basics:
@ -993,7 +1114,7 @@ def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tag-driven land suggestion helpers # Tag-driven land suggestion helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_tag_driven_suggestions(builder) -> list[dict]: # type: ignore[override] def build_tag_driven_suggestions(builder) -> list[dict]:
"""Return a list of suggestion dicts based on selected commander tags. """Return a list of suggestion dicts based on selected commander tags.
Each dict fields: Each dict fields:
@ -1081,7 +1202,7 @@ def color_balance_addition_candidates(builder, target_color: str, combined_df) -
return [] return []
existing = set(builder.card_library.keys()) existing = set(builder.card_library.keys())
out: list[tuple[str, int]] = [] out: list[tuple[str, int]] = []
for _, row in combined_df.iterrows(): # type: ignore[attr-defined] for _, row in combined_df.iterrows():
name = str(row.get('name', '')) name = str(row.get('name', ''))
if not name or name in existing or any(name == o[0] for o in out): if not name or name in existing or any(name == o[0] for o in out):
continue continue

View file

@ -88,12 +88,12 @@ def _candidate_pool_for_role(builder, role: str) -> List[Tuple[str, dict]]:
# Sort by edhrecRank then manaValue # Sort by edhrecRank then manaValue
try: try:
from . import builder_utils as bu from . import builder_utils as bu
sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined] sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"])
# Prefer-owned bias # Prefer-owned bias
if getattr(builder, "prefer_owned", False): if getattr(builder, "prefer_owned", False):
owned = getattr(builder, "owned_card_names", None) owned = getattr(builder, "owned_card_names", None)
if owned: if owned:
sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined] sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned})
except Exception: except Exception:
sorted_df = pool sorted_df = pool
@ -363,7 +363,7 @@ def enforce_bracket_compliance(builder, mode: str = "prompt") -> Dict:
break break
# Rank candidates: break the most combos first; break ties by worst desirability # Rank candidates: break the most combos first; break ties by worst desirability
cand_names = list(freq.keys()) cand_names = list(freq.keys())
cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type] cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False)
removed_any = False removed_any = False
for nm in cand_names: for nm in cand_names:
if nm in blocked: if nm in blocked:

View file

@ -17,7 +17,7 @@ from logging_util import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
try: # Optional pandas import for type checking without heavy dependency at runtime. try: # Optional pandas import for type checking without heavy dependency at runtime.
import pandas as _pd # type: ignore import pandas as _pd
except Exception: # pragma: no cover - tests provide DataFrame-like objects. except Exception: # pragma: no cover - tests provide DataFrame-like objects.
_pd = None # type: ignore _pd = None # type: ignore
@ -267,7 +267,7 @@ def _find_commander_row(df: Any, name: str | None):
if not target: if not target:
return None return None
if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore if _pd is not None and isinstance(df, _pd.DataFrame):
columns = [col for col in ("name", "faceName") if col in df.columns] columns = [col for col in ("name", "faceName") if col in df.columns]
for col in columns: for col in columns:
series = df[col].astype(str).str.casefold() series = df[col].astype(str).str.casefold()
@ -363,7 +363,14 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]:
def _normalize_string_sequence(value: Any) -> tuple[str, ...]: def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
if value is None: if value is None:
return tuple() return tuple()
if isinstance(value, (list, tuple, set)): # Handle numpy arrays, lists, tuples, sets, and other sequences
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
is_numpy = False
if isinstance(value, (list, tuple, set)) or is_numpy:
items = list(value) items = list(value)
else: else:
text = _safe_str(value) text = _safe_str(value)

View file

@ -25,11 +25,11 @@ No behavior change intended.
# Attempt to use a fast fuzzy library; fall back gracefully # Attempt to use a fast fuzzy library; fall back gracefully
try: try:
from rapidfuzz import process as rf_process, fuzz as rf_fuzz # type: ignore from rapidfuzz import process as rf_process, fuzz as rf_fuzz
_FUZZ_BACKEND = "rapidfuzz" _FUZZ_BACKEND = "rapidfuzz"
except ImportError: # pragma: no cover - environment dependent except ImportError: # pragma: no cover - environment dependent
try: try:
from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz # type: ignore from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz
_FUZZ_BACKEND = "fuzzywuzzy" _FUZZ_BACKEND = "fuzzywuzzy"
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
_FUZZ_BACKEND = "difflib" _FUZZ_BACKEND = "difflib"

View file

@ -68,7 +68,7 @@ class CommanderSelectionMixin:
out_words[0] = out_words[0][:1].upper() + out_words[0][1:] out_words[0] = out_words[0][:1].upper() + out_words[0][1:]
return ' '.join(out_words) return ' '.join(out_words)
def choose_commander(self) -> str: # type: ignore[override] def choose_commander(self) -> str:
df = self.load_commander_data() df = self.load_commander_data()
names = df["name"].tolist() names = df["name"].tolist()
while True: while True:
@ -113,7 +113,7 @@ class CommanderSelectionMixin:
continue continue
query = self._normalize_commander_query(choice) # treat as new (normalized) query query = self._normalize_commander_query(choice) # treat as new (normalized) query
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override] def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool:
row = df[df["name"] == name].iloc[0] row = df[df["name"] == name].iloc[0]
pretty = self._format_commander_pretty(row) pretty = self._format_commander_pretty(row)
self.output_func("\n" + pretty) self.output_func("\n" + pretty)
@ -126,7 +126,7 @@ class CommanderSelectionMixin:
return False return False
self.output_func("Please enter y or n.") self.output_func("Please enter y or n.")
def _apply_commander_selection(self, row: pd.Series): # type: ignore[override] def _apply_commander_selection(self, row: pd.Series):
self.commander_name = row["name"] self.commander_name = row["name"]
self.commander_row = row self.commander_row = row
tags_value = row.get("themeTags", []) tags_value = row.get("themeTags", [])
@ -136,7 +136,7 @@ class CommanderSelectionMixin:
# --------------------------- # ---------------------------
# Tag Prioritization # Tag Prioritization
# --------------------------- # ---------------------------
def select_commander_tags(self) -> List[str]: # type: ignore[override] def select_commander_tags(self) -> List[str]:
if not self.commander_name: if not self.commander_name:
self.output_func("No commander chosen yet. Selecting commander first...") self.output_func("No commander chosen yet. Selecting commander first...")
self.choose_commander() self.choose_commander()
@ -173,7 +173,7 @@ class CommanderSelectionMixin:
self._update_commander_dict_with_selected_tags() self._update_commander_dict_with_selected_tags()
return self.selected_tags return self.selected_tags
def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: # type: ignore[override] def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]:
while True: while True:
self.output_func("\nCurrent options:") self.output_func("\nCurrent options:")
for i, t in enumerate(available, 1): for i, t in enumerate(available, 1):
@ -192,7 +192,7 @@ class CommanderSelectionMixin:
return matches[0] return matches[0]
self.output_func("Invalid selection. Try again.") self.output_func("Invalid selection. Try again.")
def _update_commander_dict_with_selected_tags(self): # type: ignore[override] def _update_commander_dict_with_selected_tags(self):
if not self.commander_dict and self.commander_row is not None: if not self.commander_dict and self.commander_row is not None:
self._initialize_commander_dict(self.commander_row) self._initialize_commander_dict(self.commander_row)
if not self.commander_dict: if not self.commander_dict:
@ -205,7 +205,7 @@ class CommanderSelectionMixin:
# --------------------------- # ---------------------------
# Power Bracket Selection # Power Bracket Selection
# --------------------------- # ---------------------------
def select_power_bracket(self) -> BracketDefinition: # type: ignore[override] def select_power_bracket(self) -> BracketDefinition:
if self.bracket_definition: if self.bracket_definition:
return self.bracket_definition return self.bracket_definition
self.output_func("\nChoose Deck Power Bracket:") self.output_func("\nChoose Deck Power Bracket:")
@ -229,14 +229,14 @@ class CommanderSelectionMixin:
return match return match
self.output_func("Invalid input. Type 1-5 or 'info'.") self.output_func("Invalid input. Type 1-5 or 'info'.")
def _print_bracket_details(self): # type: ignore[override] def _print_bracket_details(self):
self.output_func("\nBracket Details:") self.output_func("\nBracket Details:")
for bd in BRACKET_DEFINITIONS: for bd in BRACKET_DEFINITIONS:
self.output_func(f"\n[{bd.level}] {bd.name}") self.output_func(f"\n[{bd.level}] {bd.name}")
self.output_func(bd.long_desc) self.output_func(bd.long_desc)
self.output_func(self._format_limits(bd.limits)) self.output_func(self._format_limits(bd.limits))
def _print_selected_bracket_summary(self): # type: ignore[override] def _print_selected_bracket_summary(self):
self.output_func("\nBracket Constraints:") self.output_func("\nBracket Constraints:")
if self.bracket_limits: if self.bracket_limits:
self.output_func(self._format_limits(self.bracket_limits)) self.output_func(self._format_limits(self.bracket_limits))

View file

@ -22,7 +22,7 @@ Expected attributes / methods on the host DeckBuilder:
class LandBasicsMixin: class LandBasicsMixin:
def add_basic_lands(self): # type: ignore[override] def add_basic_lands(self):
"""Add basic (or snow basic) lands based on color identity. """Add basic (or snow basic) lands based on color identity.
Logic: Logic:
@ -71,8 +71,8 @@ class LandBasicsMixin:
basic_min: Optional[int] = None basic_min: Optional[int] = None
land_total: Optional[int] = None land_total: Optional[int] = None
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
basic_min = self.ideal_counts.get('basic_lands') # type: ignore[attr-defined] basic_min = self.ideal_counts.get('basic_lands')
land_total = self.ideal_counts.get('lands') # type: ignore[attr-defined] land_total = self.ideal_counts.get('lands')
if basic_min is None: if basic_min is None:
basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if land_total is None: if land_total is None:
@ -136,7 +136,7 @@ class LandBasicsMixin:
self.output_func(f" {name.ljust(width)} : {cnt}") self.output_func(f" {name.ljust(width)} : {cnt}")
self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})") self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})")
def run_land_step1(self): # type: ignore[override] def run_land_step1(self):
"""Public wrapper to execute land building step 1 (basics).""" """Public wrapper to execute land building step 1 (basics)."""
self.add_basic_lands() self.add_basic_lands()
try: try:

View file

@ -21,7 +21,7 @@ Host DeckBuilder must provide:
""" """
class LandDualsMixin: class LandDualsMixin:
def add_dual_lands(self, requested_count: int | None = None): # type: ignore[override] def add_dual_lands(self, requested_count: int | None = None):
"""Add two-color 'typed' dual lands based on color identity.""" """Add two-color 'typed' dual lands based on color identity."""
if not getattr(self, 'files_to_load', []): if not getattr(self, 'files_to_load', []):
try: try:
@ -117,10 +117,10 @@ class LandDualsMixin:
pair_buckets[key] = names pair_buckets[key] = names
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if getattr(self, 'ideal_counts', None): if getattr(self, 'ideal_counts', None):
min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] basic_floor = self._basic_floor(min_basic_cfg)
default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6) default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6)
remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] remaining_capacity = max(0, land_target - self._current_land_count())
effective_default = min(default_dual_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool)) effective_default = min(default_dual_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool))
desired = effective_default if requested_count is None else max(0, int(requested_count)) desired = effective_default if requested_count is None else max(0, int(requested_count))
if desired == 0: if desired == 0:
@ -129,14 +129,14 @@ class LandDualsMixin:
if remaining_capacity == 0 and desired > 0: if remaining_capacity == 0 and desired > 0:
slots_needed = desired slots_needed = desired
freed_slots = 0 freed_slots = 0
while freed_slots < slots_needed and self._count_basic_lands() > basic_floor: # type: ignore[attr-defined] while freed_slots < slots_needed and self._count_basic_lands() > basic_floor:
target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] target_basic = self._choose_basic_to_trim()
if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined] if not target_basic or not self._decrement_card(target_basic):
break break
freed_slots += 1 freed_slots += 1
if freed_slots == 0: if freed_slots == 0:
desired = 0 desired = 0
remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] remaining_capacity = max(0, land_target - self._current_land_count())
desired = min(desired, remaining_capacity, len(pool)) desired = min(desired, remaining_capacity, len(pool))
if desired <= 0: if desired <= 0:
self.output_func("Dual Lands: No capacity after trimming; skipping.") self.output_func("Dual Lands: No capacity after trimming; skipping.")
@ -146,7 +146,7 @@ class LandDualsMixin:
rng = getattr(self, 'rng', None) rng = getattr(self, 'rng', None)
try: try:
if rng: if rng:
rng.shuffle(bucket_keys) # type: ignore rng.shuffle(bucket_keys)
else: else:
random.shuffle(bucket_keys) random.shuffle(bucket_keys)
except Exception: except Exception:
@ -171,7 +171,7 @@ class LandDualsMixin:
break break
added: List[str] = [] added: List[str] = []
for name in chosen: for name in chosen:
if self._current_land_count() >= land_target: # type: ignore[attr-defined] if self._current_land_count() >= land_target:
break break
# Determine sub_role as concatenated color pair for traceability # Determine sub_role as concatenated color pair for traceability
try: try:
@ -198,7 +198,7 @@ class LandDualsMixin:
role='dual', role='dual',
sub_role=sub_role, sub_role=sub_role,
added_by='lands_step5' added_by='lands_step5'
) # type: ignore[attr-defined] )
added.append(name) added.append(name)
self.output_func("\nDual Lands Added (Step 5):") self.output_func("\nDual Lands Added (Step 5):")
if not added: if not added:
@ -207,11 +207,11 @@ class LandDualsMixin:
width = max(len(n) for n in added) width = max(len(n) for n in added)
for n in added: for n in added:
self.output_func(f" {n.ljust(width)} : 1") self.output_func(f" {n.ljust(width)} : 1")
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
def run_land_step5(self, requested_count: int | None = None): # type: ignore[override] def run_land_step5(self, requested_count: int | None = None):
self.add_dual_lands(requested_count=requested_count) self.add_dual_lands(requested_count=requested_count)
self._enforce_land_cap(step_label="Duals (Step 5)") # type: ignore[attr-defined] self._enforce_land_cap(step_label="Duals (Step 5)")
try: try:
from .. import builder_utils as _bu from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '5') _bu.export_current_land_pool(self, '5')

View file

@ -19,7 +19,7 @@ Host DeckBuilder must supply:
""" """
class LandFetchMixin: class LandFetchMixin:
def add_fetch_lands(self, requested_count: int | None = None): # type: ignore[override] def add_fetch_lands(self, requested_count: int | None = None):
"""Add fetch lands (color-specific + generic) respecting land target.""" """Add fetch lands (color-specific + generic) respecting land target."""
if not getattr(self, 'files_to_load', []): if not getattr(self, 'files_to_load', []):
try: try:
@ -28,8 +28,8 @@ class LandFetchMixin:
except Exception as e: # pragma: no cover - defensive except Exception as e: # pragma: no cover - defensive
self.output_func(f"Cannot add fetch lands until color identity resolved: {e}") self.output_func(f"Cannot add fetch lands until color identity resolved: {e}")
return return
land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) # type: ignore[attr-defined] land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35)
current = self._current_land_count() # type: ignore[attr-defined] current = self._current_land_count()
color_order = [c for c in getattr(self, 'color_identity', []) if c in ['W','U','B','R','G']] color_order = [c for c in getattr(self, 'color_identity', []) if c in ['W','U','B','R','G']]
color_map = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) color_map = getattr(bc, 'COLOR_TO_FETCH_LANDS', {})
candidates: List[str] = [] candidates: List[str] = []
@ -56,7 +56,7 @@ class LandFetchMixin:
self.output_func("\nAdd Fetch Lands (Step 4):") self.output_func("\nAdd Fetch Lands (Step 4):")
self.output_func("Fetch lands help fix colors & enable landfall / graveyard synergies.") self.output_func("Fetch lands help fix colors & enable landfall / graveyard synergies.")
prompt = f"Enter desired number of fetch lands (default: {effective_default}):" prompt = f"Enter desired number of fetch lands (default: {effective_default}):"
desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) # type: ignore[attr-defined] desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20)
else: else:
desired = max(0, int(requested_count)) desired = max(0, int(requested_count))
if desired > remaining_fetch_slots: if desired > remaining_fetch_slots:
@ -70,20 +70,20 @@ class LandFetchMixin:
if remaining_capacity == 0 and desired > 0: if remaining_capacity == 0 and desired > 0:
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if getattr(self, 'ideal_counts', None): if getattr(self, 'ideal_counts', None):
min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
floor_basics = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] floor_basics = self._basic_floor(min_basic_cfg)
slots_needed = desired slots_needed = desired
while slots_needed > 0 and self._count_basic_lands() > floor_basics: # type: ignore[attr-defined] while slots_needed > 0 and self._count_basic_lands() > floor_basics:
target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] target_basic = self._choose_basic_to_trim()
if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined] if not target_basic or not self._decrement_card(target_basic):
break break
slots_needed -= 1 slots_needed -= 1
remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] remaining_capacity = max(0, land_target - self._current_land_count())
if remaining_capacity > 0 and slots_needed == 0: if remaining_capacity > 0 and slots_needed == 0:
break break
if slots_needed > 0 and remaining_capacity == 0: if slots_needed > 0 and remaining_capacity == 0:
desired -= slots_needed desired -= slots_needed
remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] remaining_capacity = max(0, land_target - self._current_land_count())
desired = min(desired, remaining_capacity, len(candidates), remaining_fetch_slots) desired = min(desired, remaining_capacity, len(candidates), remaining_fetch_slots)
if desired <= 0: if desired <= 0:
self.output_func("Fetch Lands: No capacity (after trimming) or desired reduced to 0; skipping.") self.output_func("Fetch Lands: No capacity (after trimming) or desired reduced to 0; skipping.")
@ -101,7 +101,7 @@ class LandFetchMixin:
if k >= len(pool): if k >= len(pool):
return pool.copy() return pool.copy()
try: try:
return (rng.sample if rng else random.sample)(pool, k) # type: ignore return (rng.sample if rng else random.sample)(pool, k)
except Exception: except Exception:
return pool[:k] return pool[:k]
need = desired need = desired
@ -117,7 +117,7 @@ class LandFetchMixin:
added: List[str] = [] added: List[str] = []
for nm in chosen: for nm in chosen:
if self._current_land_count() >= land_target: # type: ignore[attr-defined] if self._current_land_count() >= land_target:
break break
note = 'generic' if nm in generic_list else 'color-specific' note = 'generic' if nm in generic_list else 'color-specific'
self.add_card( self.add_card(
@ -126,11 +126,11 @@ class LandFetchMixin:
role='fetch', role='fetch',
sub_role=note, sub_role=note,
added_by='lands_step4' added_by='lands_step4'
) # type: ignore[attr-defined] )
added.append(nm) added.append(nm)
# Record actual number of fetch lands added for export/replay context # Record actual number of fetch lands added for export/replay context
try: try:
setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined] setattr(self, 'fetch_count', len(added))
except Exception: except Exception:
pass pass
self.output_func("\nFetch Lands Added (Step 4):") self.output_func("\nFetch Lands Added (Step 4):")
@ -141,9 +141,9 @@ class LandFetchMixin:
for n in added: for n in added:
note = 'generic' if n in generic_list else 'color-specific' note = 'generic' if n in generic_list else 'color-specific'
self.output_func(f" {n.ljust(width)} : 1 ({note})") self.output_func(f" {n.ljust(width)} : 1 ({note})")
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
def run_land_step4(self, requested_count: int | None = None): # type: ignore[override] def run_land_step4(self, requested_count: int | None = None):
"""Public wrapper to add fetch lands. """Public wrapper to add fetch lands.
If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds. If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds.
@ -155,7 +155,7 @@ class LandFetchMixin:
except Exception: except Exception:
desired = requested_count desired = requested_count
self.add_fetch_lands(requested_count=desired) self.add_fetch_lands(requested_count=desired)
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined] self._enforce_land_cap(step_label="Fetch (Step 4)")
try: try:
from .. import builder_utils as _bu from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '4') _bu.export_current_land_pool(self, '4')

View file

@ -20,7 +20,7 @@ Host DeckBuilder must provide:
""" """
class LandKindredMixin: class LandKindredMixin:
def add_kindred_lands(self): # type: ignore[override] def add_kindred_lands(self):
"""Add kindred-oriented lands ONLY if a selected tag includes 'Kindred' or 'Tribal'. """Add kindred-oriented lands ONLY if a selected tag includes 'Kindred' or 'Tribal'.
Baseline inclusions on kindred focus: Baseline inclusions on kindred focus:
@ -41,32 +41,32 @@ class LandKindredMixin:
self.output_func("Kindred Lands: No selected kindred/tribal tag; skipping.") self.output_func("Kindred Lands: No selected kindred/tribal tag; skipping.")
return return
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) # type: ignore[attr-defined] land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35))
else: else:
land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35)
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] basic_floor = self._basic_floor(min_basic_cfg)
def ensure_capacity() -> bool: def ensure_capacity() -> bool:
if self._current_land_count() < land_target: # type: ignore[attr-defined] if self._current_land_count() < land_target:
return True return True
if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] if self._count_basic_lands() <= basic_floor:
return False return False
target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] target_basic = self._choose_basic_to_trim()
if not target_basic: if not target_basic:
return False return False
if not self._decrement_card(target_basic): # type: ignore[attr-defined] if not self._decrement_card(target_basic):
return False return False
return self._current_land_count() < land_target # type: ignore[attr-defined] return self._current_land_count() < land_target
colors = getattr(self, 'color_identity', []) or [] colors = getattr(self, 'color_identity', []) or []
added: List[str] = [] added: List[str] = []
reasons: Dict[str, str] = {} reasons: Dict[str, str] = {}
def try_add(name: str, reason: str): def try_add(name: str, reason: str):
if name in self.card_library: # type: ignore[attr-defined] if name in self.card_library:
return return
if not ensure_capacity(): if not ensure_capacity():
return return
@ -77,7 +77,7 @@ class LandKindredMixin:
sub_role='baseline' if reason.startswith('kindred focus') else 'tribe-specific', sub_role='baseline' if reason.startswith('kindred focus') else 'tribe-specific',
added_by='lands_step3', added_by='lands_step3',
trigger_tag='Kindred/Tribal' trigger_tag='Kindred/Tribal'
) # type: ignore[attr-defined] )
added.append(name) added.append(name)
reasons[name] = reason reasons[name] = reason
@ -105,14 +105,14 @@ class LandKindredMixin:
if snapshot is not None and not snapshot.empty and tribe_terms: if snapshot is not None and not snapshot.empty and tribe_terms:
dynamic_limit = 5 dynamic_limit = 5
for tribe in sorted(tribe_terms): for tribe in sorted(tribe_terms):
if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] if self._current_land_count() >= land_target or dynamic_limit <= 0:
break break
tribe_lower = tribe.lower() tribe_lower = tribe.lower()
matches: List[str] = [] matches: List[str] = []
for _, row in snapshot.iterrows(): for _, row in snapshot.iterrows():
try: try:
nm = str(row.get('name', '')) nm = str(row.get('name', ''))
if not nm or nm in self.card_library: # type: ignore[attr-defined] if not nm or nm in self.card_library:
continue continue
tline = str(row.get('type', row.get('type_line', ''))).lower() tline = str(row.get('type', row.get('type_line', ''))).lower()
if 'land' not in tline: if 'land' not in tline:
@ -125,7 +125,7 @@ class LandKindredMixin:
except Exception: except Exception:
continue continue
for nm in matches[:2]: for nm in matches[:2]:
if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] if self._current_land_count() >= land_target or dynamic_limit <= 0:
break break
if nm in added or nm in getattr(bc, 'BASIC_LANDS', []): if nm in added or nm in getattr(bc, 'BASIC_LANDS', []):
continue continue
@ -139,12 +139,12 @@ class LandKindredMixin:
width = max(len(n) for n in added) width = max(len(n) for n in added)
for n in added: for n in added:
self.output_func(f" {n.ljust(width)} : 1 ({reasons.get(n,'')})") self.output_func(f" {n.ljust(width)} : 1 ({reasons.get(n,'')})")
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
def run_land_step3(self): # type: ignore[override] def run_land_step3(self):
"""Public wrapper to add kindred-focused lands.""" """Public wrapper to add kindred-focused lands."""
self.add_kindred_lands() self.add_kindred_lands()
self._enforce_land_cap(step_label="Kindred (Step 3)") # type: ignore[attr-defined] self._enforce_land_cap(step_label="Kindred (Step 3)")
try: try:
from .. import builder_utils as _bu from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '3') _bu.export_current_land_pool(self, '3')

View file

@ -19,7 +19,7 @@ class LandMiscUtilityMixin:
- Diagnostics & CSV exports - Diagnostics & CSV exports
""" """
def add_misc_utility_lands(self, requested_count: Optional[int] = None): # type: ignore[override] def add_misc_utility_lands(self, requested_count: Optional[int] = None):
# --- Initialization & candidate collection --- # --- Initialization & candidate collection ---
if not getattr(self, 'files_to_load', None): if not getattr(self, 'files_to_load', None):
try: try:
@ -293,7 +293,7 @@ class LandMiscUtilityMixin:
if getattr(self, 'show_diagnostics', False) and filtered_out: if getattr(self, 'show_diagnostics', False) and filtered_out:
self.output_func(f" (Mono-color excluded candidates: {', '.join(filtered_out)})") self.output_func(f" (Mono-color excluded candidates: {', '.join(filtered_out)})")
def run_land_step7(self, requested_count: Optional[int] = None): # type: ignore[override] def run_land_step7(self, requested_count: Optional[int] = None):
self.add_misc_utility_lands(requested_count=requested_count) self.add_misc_utility_lands(requested_count=requested_count)
self._enforce_land_cap(step_label="Utility (Step 7)") self._enforce_land_cap(step_label="Utility (Step 7)")
self._build_tag_driven_land_suggestions() self._build_tag_driven_land_suggestions()
@ -305,12 +305,12 @@ class LandMiscUtilityMixin:
pass pass
# ---- Tag-driven suggestion helpers (used after Step 7) ---- # ---- Tag-driven suggestion helpers (used after Step 7) ----
def _build_tag_driven_land_suggestions(self): # type: ignore[override] def _build_tag_driven_land_suggestions(self):
suggestions = bu.build_tag_driven_suggestions(self) suggestions = bu.build_tag_driven_suggestions(self)
if suggestions: if suggestions:
self.suggested_lands_queue.extend(suggestions) self.suggested_lands_queue.extend(suggestions)
def _apply_land_suggestions_if_room(self): # type: ignore[override] def _apply_land_suggestions_if_room(self):
if not self.suggested_lands_queue: if not self.suggested_lands_queue:
return return
land_target = getattr(self, 'ideal_counts', {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) if getattr(self, 'ideal_counts', None) else getattr(bc, 'DEFAULT_LAND_COUNT', 35) land_target = getattr(self, 'ideal_counts', {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) if getattr(self, 'ideal_counts', None) else getattr(bc, 'DEFAULT_LAND_COUNT', 35)

View file

@ -12,7 +12,7 @@ class LandOptimizationMixin:
Provides optimize_tapped_lands and run_land_step8 (moved from monolithic builder). Provides optimize_tapped_lands and run_land_step8 (moved from monolithic builder).
""" """
def optimize_tapped_lands(self): # type: ignore[override] def optimize_tapped_lands(self):
df = getattr(self, '_combined_cards_df', None) df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty: if df is None or df.empty:
return return
@ -146,7 +146,7 @@ class LandOptimizationMixin:
new_tapped += 1 new_tapped += 1
self.output_func(f" Tapped Lands After : {new_tapped} (threshold {threshold})") self.output_func(f" Tapped Lands After : {new_tapped} (threshold {threshold})")
def run_land_step8(self): # type: ignore[override] def run_land_step8(self):
self.optimize_tapped_lands() self.optimize_tapped_lands()
self._enforce_land_cap(step_label="Tapped Opt (Step 8)") self._enforce_land_cap(step_label="Tapped Opt (Step 8)")
if self.color_source_matrix_baseline is None: if self.color_source_matrix_baseline is None:

View file

@ -27,10 +27,10 @@ class LandStaplesMixin:
# --------------------------- # ---------------------------
# Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet) # Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet)
# --------------------------- # ---------------------------
def _current_land_count(self) -> int: # type: ignore[override] def _current_land_count(self) -> int:
"""Return total number of land cards currently in the library (counts duplicates).""" """Return total number of land cards currently in the library (counts duplicates)."""
total = 0 total = 0
for name, entry in self.card_library.items(): # type: ignore[attr-defined] for name, entry in self.card_library.items():
ctype = entry.get('Card Type', '') ctype = entry.get('Card Type', '')
if ctype and 'land' in ctype.lower(): if ctype and 'land' in ctype.lower():
total += entry.get('Count', 1) total += entry.get('Count', 1)
@ -47,7 +47,7 @@ class LandStaplesMixin:
continue continue
return total return total
def add_staple_lands(self): # type: ignore[override] def add_staple_lands(self):
"""Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands). """Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands).
Respects total land target (ideal_counts['lands']). Skips additions once target reached. Respects total land target (ideal_counts['lands']). Skips additions once target reached.
@ -62,25 +62,25 @@ class LandStaplesMixin:
return return
land_target = None land_target = None
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
land_target = self.ideal_counts.get('lands') # type: ignore[attr-defined] land_target = self.ideal_counts.get('lands')
if land_target is None: if land_target is None:
land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35)
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] basic_floor = self._basic_floor(min_basic_cfg)
def ensure_capacity() -> bool: def ensure_capacity() -> bool:
if self._current_land_count() < land_target: # type: ignore[attr-defined] if self._current_land_count() < land_target:
return True return True
if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] if self._count_basic_lands() <= basic_floor:
return False return False
target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] target_basic = self._choose_basic_to_trim()
if not target_basic: if not target_basic:
return False return False
if not self._decrement_card(target_basic): # type: ignore[attr-defined] if not self._decrement_card(target_basic):
return False return False
return self._current_land_count() < land_target # type: ignore[attr-defined] return self._current_land_count() < land_target
commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or []) commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or [])
colors = getattr(self, 'color_identity', []) or [] colors = getattr(self, 'color_identity', []) or []
@ -102,7 +102,7 @@ class LandStaplesMixin:
if not ensure_capacity(): if not ensure_capacity():
self.output_func("Staple Lands: Cannot free capacity without violating basic floor; stopping additions.") self.output_func("Staple Lands: Cannot free capacity without violating basic floor; stopping additions.")
break break
if land_name in self.card_library: # type: ignore[attr-defined] if land_name in self.card_library:
continue continue
try: try:
include = cond(list(commander_tags_all), colors, commander_power) include = cond(list(commander_tags_all), colors, commander_power)
@ -115,7 +115,7 @@ class LandStaplesMixin:
role='staple', role='staple',
sub_role='generic-staple', sub_role='generic-staple',
added_by='lands_step2' added_by='lands_step2'
) # type: ignore[attr-defined] )
added.append(land_name) added.append(land_name)
if land_name == 'Command Tower': if land_name == 'Command Tower':
reasons[land_name] = f"multi-color ({len(colors)} colors)" reasons[land_name] = f"multi-color ({len(colors)} colors)"
@ -137,12 +137,12 @@ class LandStaplesMixin:
for n in added: for n in added:
reason = reasons.get(n, '') reason = reasons.get(n, '')
self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}") self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}")
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
def run_land_step2(self): # type: ignore[override] def run_land_step2(self):
"""Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" """Public wrapper for adding generic staple nonbasic lands (excluding kindred)."""
self.add_staple_lands() self.add_staple_lands()
self._enforce_land_cap(step_label="Staples (Step 2)") # type: ignore[attr-defined] self._enforce_land_cap(step_label="Staples (Step 2)")
try: try:
from .. import builder_utils as _bu from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '2') _bu.export_current_land_pool(self, '2')

View file

@ -59,7 +59,7 @@ class LandTripleMixin:
'forest': 'G', 'forest': 'G',
} }
for _, row in df.iterrows(): # type: ignore for _, row in df.iterrows():
try: try:
name = str(row.get('name','')) name = str(row.get('name',''))
if not name or name in self.card_library: if not name or name in self.card_library:

View file

@ -33,7 +33,7 @@ class CreatureAdditionMixin:
self.output_func("Card pool missing 'type' column; cannot add creatures.") self.output_func("Card pool missing 'type' column; cannot add creatures.")
return return
try: try:
context = self.get_theme_context() # type: ignore[attr-defined] context = self.get_theme_context()
except Exception: except Exception:
context = None context = None
if context is None or not getattr(context, 'ordered_targets', []): if context is None or not getattr(context, 'ordered_targets', []):
@ -480,7 +480,7 @@ class CreatureAdditionMixin:
drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst)) drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst))
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try: try:
import pandas as _pd # type: ignore import pandas as _pd
mask_keep = _pd.Series(mask_keep, index=df.index) mask_keep = _pd.Series(mask_keep, index=df.index)
except Exception: except Exception:
pass pass

View file

@ -78,7 +78,7 @@ class SpellAdditionMixin:
# Combine into keep mask # Combine into keep mask
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try: try:
import pandas as _pd # type: ignore import pandas as _pd
mask_keep = _pd.Series(mask_keep, index=df.index) mask_keep = _pd.Series(mask_keep, index=df.index)
except Exception: except Exception:
pass pass
@ -742,7 +742,7 @@ class SpellAdditionMixin:
if df is None or df.empty or 'type' not in df.columns: if df is None or df.empty or 'type' not in df.columns:
return return
try: try:
context = self.get_theme_context() # type: ignore[attr-defined] context = self.get_theme_context()
except Exception: except Exception:
context = None context = None
if context is None or not getattr(context, 'ordered_targets', []): if context is None or not getattr(context, 'ordered_targets', []):

View file

@ -14,7 +14,7 @@ from ..shared_copy import build_land_headline, dfc_card_note
logger = logging_util.logging.getLogger(__name__) logger = logging_util.logging.getLogger(__name__)
try: try:
from prettytable import PrettyTable # type: ignore from prettytable import PrettyTable
except Exception: # pragma: no cover except Exception: # pragma: no cover
PrettyTable = None # type: ignore PrettyTable = None # type: ignore
@ -176,7 +176,7 @@ class ReportingMixin:
""" """
try: try:
# Lazy import to avoid cycles # Lazy import to avoid cycles
from deck_builder.enforcement import enforce_bracket_compliance # type: ignore from deck_builder.enforcement import enforce_bracket_compliance
except Exception: except Exception:
self.output_func("Enforcement module unavailable.") self.output_func("Enforcement module unavailable.")
return {} return {}
@ -194,7 +194,7 @@ class ReportingMixin:
if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'): if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'):
before = int(total_cards) before = int(total_cards)
try: try:
self.fill_remaining_theme_spells() # type: ignore[attr-defined] self.fill_remaining_theme_spells()
except Exception: except Exception:
pass pass
# Recompute after filler # Recompute after filler
@ -239,13 +239,13 @@ class ReportingMixin:
csv_name = base_stem + ".csv" csv_name = base_stem + ".csv"
txt_name = base_stem + ".txt" txt_name = base_stem + ".txt"
# Overwrite exports with updated library # Overwrite exports with updated library
self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) # type: ignore[attr-defined] self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True)
self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # type: ignore[attr-defined] self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True)
# Re-export the JSON config to reflect any changes from enforcement # Re-export the JSON config to reflect any changes from enforcement
json_name = base_stem + ".json" json_name = base_stem + ".json"
self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # type: ignore[attr-defined] self.export_run_config_json(directory='config', filename=json_name, suppress_output=True)
# Recompute and write compliance next to them # Recompute and write compliance next to them
self.compute_and_print_compliance(base_stem=base_stem) # type: ignore[attr-defined] self.compute_and_print_compliance(base_stem=base_stem)
# Inject enforcement details into the saved compliance JSON for UI transparency # Inject enforcement details into the saved compliance JSON for UI transparency
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
try: try:
@ -259,18 +259,18 @@ class ReportingMixin:
pass pass
else: else:
# Fall back to default export flow # Fall back to default export flow
csv_path = self.export_decklist_csv() # type: ignore[attr-defined] csv_path = self.export_decklist_csv()
try: try:
base, _ = _os.path.splitext(csv_path) base, _ = _os.path.splitext(csv_path)
base_only = _os.path.basename(base) base_only = _os.path.basename(base)
except Exception: except Exception:
base_only = None base_only = None
self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # type: ignore[attr-defined] self.export_decklist_text(filename=(base_only + '.txt') if base_only else None)
# Re-export JSON config after enforcement changes # Re-export JSON config after enforcement changes
if base_only: if base_only:
self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) # type: ignore[attr-defined] self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True)
if base_only: if base_only:
self.compute_and_print_compliance(base_stem=base_only) # type: ignore[attr-defined] self.compute_and_print_compliance(base_stem=base_only)
# Inject enforcement into written JSON as above # Inject enforcement into written JSON as above
try: try:
comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json") comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json")
@ -294,7 +294,7 @@ class ReportingMixin:
""" """
try: try:
# Late import to avoid circulars in some environments # Late import to avoid circulars in some environments
from deck_builder.brackets_compliance import evaluate_deck # type: ignore from deck_builder.brackets_compliance import evaluate_deck
except Exception: except Exception:
self.output_func("Bracket compliance module unavailable.") self.output_func("Bracket compliance module unavailable.")
return {} return {}
@ -373,7 +373,7 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None) full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df snapshot = full_df if full_df is not None else combined_df
row_lookup: Dict[str, any] = {} row_lookup: Dict[str, Any] = {}
if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns: if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns:
for _, r in snapshot.iterrows(): for _, r in snapshot.iterrows():
nm = str(r.get('name')) nm = str(r.get('name'))
@ -429,7 +429,7 @@ class ReportingMixin:
# Surface land vs. MDFC counts for CLI users to mirror web summary copy # Surface land vs. MDFC counts for CLI users to mirror web summary copy
try: try:
summary = self.build_deck_summary() # type: ignore[attr-defined] summary = self.build_deck_summary()
except Exception: except Exception:
summary = None summary = None
if isinstance(summary, dict): if isinstance(summary, dict):
@ -483,9 +483,9 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None) full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df snapshot = full_df if full_df is not None else combined_df
row_lookup: Dict[str, any] = {} row_lookup: Dict[str, Any] = {}
if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns: if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns:
for _, r in snapshot.iterrows(): # type: ignore[attr-defined] for _, r in snapshot.iterrows():
nm = str(r.get('name')) nm = str(r.get('name'))
if nm and nm not in row_lookup: if nm and nm not in row_lookup:
row_lookup[nm] = r row_lookup[nm] = r
@ -521,7 +521,7 @@ class ReportingMixin:
builder_utils_module = None builder_utils_module = None
try: try:
from deck_builder import builder_utils as _builder_utils # type: ignore from deck_builder import builder_utils as _builder_utils
builder_utils_module = _builder_utils builder_utils_module = _builder_utils
color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df) color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df)
except Exception: except Exception:
@ -543,6 +543,9 @@ class ReportingMixin:
mf_info = {} mf_info = {}
faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else [] faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else []
layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None
# M9: If no colors found from mana production, try extracting from face metadata
if not card_colors and isinstance(mf_info, dict):
card_colors = list(mf_info.get('colors', []))
dfc_land_lookup[name] = { dfc_land_lookup[name] = {
'adds_extra_land': counts_as_extra, 'adds_extra_land': counts_as_extra,
'counts_as_land': not counts_as_extra, 'counts_as_land': not counts_as_extra,
@ -681,13 +684,14 @@ class ReportingMixin:
'faces': faces_meta, 'faces': faces_meta,
'layout': layout_val, 'layout': layout_val,
}) })
if adds_extra: # M9: Count ALL MDFC lands for land summary
dfc_extra_total += copies dfc_extra_total += copies
total_sources = sum(source_counts.values()) total_sources = sum(source_counts.values())
traditional_lands = type_counts.get('Land', 0) traditional_lands = type_counts.get('Land', 0)
# M9: dfc_extra_total now contains ALL MDFC lands, not just extras
land_summary = { land_summary = {
'traditional': traditional_lands, 'traditional': traditional_lands,
'dfc_lands': dfc_extra_total, 'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands
'with_dfc': traditional_lands + dfc_extra_total, 'with_dfc': traditional_lands + dfc_extra_total,
'dfc_cards': dfc_details, 'dfc_cards': dfc_details,
'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total), 'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total),
@ -852,7 +856,7 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None) full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df snapshot = full_df if full_df is not None else combined_df
row_lookup: Dict[str, any] = {} row_lookup: Dict[str, Any] = {}
if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns:
for _, r in snapshot.iterrows(): for _, r in snapshot.iterrows():
nm = str(r.get('name')) nm = str(r.get('name'))
@ -1124,7 +1128,7 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None) full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df snapshot = full_df if full_df is not None else combined_df
row_lookup: Dict[str, any] = {} row_lookup: Dict[str, Any] = {}
if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns:
for _, r in snapshot.iterrows(): for _, r in snapshot.iterrows():
nm = str(r.get('name')) nm = str(r.get('name'))
@ -1132,7 +1136,7 @@ class ReportingMixin:
row_lookup[nm] = r row_lookup[nm] = r
try: try:
from deck_builder import builder_utils as _builder_utils # type: ignore from deck_builder import builder_utils as _builder_utils
color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df) color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df)
except Exception: except Exception:
color_matrix = {} color_matrix = {}
@ -1383,3 +1387,4 @@ class ReportingMixin:
""" """
# Card library printout suppressed; use CSV and text export for card list. # Card library printout suppressed; use CSV and text export for card list.
pass pass

View file

@ -885,7 +885,7 @@ def _filter_multi(df: pd.DataFrame, primary: Optional[str], secondary: Optional[
if index_map is None: if index_map is None:
_ensure_theme_tag_index(current_df) _ensure_theme_tag_index(current_df)
index_map = current_df.attrs.get("_ltag_index") or {} index_map = current_df.attrs.get("_ltag_index") or {}
return index_map # type: ignore[return-value] return index_map
index_map_all = _get_index_map(df) index_map_all = _get_index_map(df)
@ -1047,7 +1047,7 @@ def _check_constraints(candidate_count: int, constraints: Optional[Dict[str, Any
if not constraints: if not constraints:
return return
try: try:
req_min = constraints.get("require_min_candidates") # type: ignore[attr-defined] req_min = constraints.get("require_min_candidates")
except Exception: except Exception:
req_min = None req_min = None
if req_min is None: if req_min is None:
@ -1436,7 +1436,7 @@ def build_random_full_deck(
primary_choice_idx, secondary_choice_idx, tertiary_choice_idx = _resolve_theme_choices_for_headless(base.commander, base) primary_choice_idx, secondary_choice_idx, tertiary_choice_idx = _resolve_theme_choices_for_headless(base.commander, base)
try: try:
from headless_runner import run as _run # type: ignore from headless_runner import run as _run
except Exception as e: except Exception as e:
return RandomFullBuildResult( return RandomFullBuildResult(
seed=base.seed, seed=base.seed,
@ -1482,7 +1482,7 @@ def build_random_full_deck(
summary: Dict[str, Any] | None = None summary: Dict[str, Any] | None = None
try: try:
if hasattr(builder, 'build_deck_summary'): if hasattr(builder, 'build_deck_summary'):
summary = builder.build_deck_summary() # type: ignore[attr-defined] summary = builder.build_deck_summary()
except Exception: except Exception:
summary = None summary = None
@ -1559,7 +1559,7 @@ def build_random_full_deck(
if isinstance(custom_base, str) and custom_base.strip(): if isinstance(custom_base, str) and custom_base.strip():
meta_payload["name"] = custom_base.strip() meta_payload["name"] = custom_base.strip()
try: try:
commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined] commander_meta = builder.get_commander_export_metadata()
except Exception: except Exception:
commander_meta = {} commander_meta = {}
names = commander_meta.get("commander_names") or [] names = commander_meta.get("commander_names") or []
@ -1589,8 +1589,8 @@ def build_random_full_deck(
try: try:
import os as _os import os as _os
import json as _json import json as _json
csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] csv_path = getattr(builder, 'last_csv_path', None)
txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] txt_path = getattr(builder, 'last_txt_path', None)
if csv_path and isinstance(csv_path, str): if csv_path and isinstance(csv_path, str):
base_path, _ = _os.path.splitext(csv_path) base_path, _ = _os.path.splitext(csv_path)
# If txt missing but expected, look for sibling # If txt missing but expected, look for sibling
@ -1608,7 +1608,7 @@ def build_random_full_deck(
# Compute compliance if not already saved # Compute compliance if not already saved
try: try:
if hasattr(builder, 'compute_and_print_compliance'): if hasattr(builder, 'compute_and_print_compliance'):
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path))
except Exception: except Exception:
compliance = None compliance = None
# Write summary sidecar if missing # Write summary sidecar if missing
@ -1646,7 +1646,7 @@ def build_random_full_deck(
csv_path = existing_base csv_path = existing_base
base_path, _ = _os.path.splitext(csv_path) base_path, _ = _os.path.splitext(csv_path)
else: else:
tmp_csv = builder.export_decklist_csv() # type: ignore[attr-defined] tmp_csv = builder.export_decklist_csv()
stem_base, ext = _os.path.splitext(tmp_csv) stem_base, ext = _os.path.splitext(tmp_csv)
if stem_base.endswith('_1'): if stem_base.endswith('_1'):
original = stem_base[:-2] + ext original = stem_base[:-2] + ext
@ -1662,13 +1662,13 @@ def build_random_full_deck(
if _os.path.isfile(target_txt): if _os.path.isfile(target_txt):
txt_path = target_txt txt_path = target_txt
else: else:
tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') # type: ignore[attr-defined] tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt')
if tmp_txt.endswith('_1.txt') and _os.path.isfile(target_txt): if tmp_txt.endswith('_1.txt') and _os.path.isfile(target_txt):
txt_path = target_txt txt_path = target_txt
else: else:
txt_path = tmp_txt txt_path = tmp_txt
if hasattr(builder, 'compute_and_print_compliance'): if hasattr(builder, 'compute_and_print_compliance'):
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path))
if summary: if summary:
sidecar = base_path + '.summary.json' sidecar = base_path + '.summary.json'
if not _os.path.isfile(sidecar): if not _os.path.isfile(sidecar):

View file

@ -167,7 +167,7 @@ def _reset_metrics_for_test() -> None:
def _sanitize_theme_list(values: Iterable[Any]) -> list[str]: def _sanitize_theme_list(values: Iterable[Any]) -> list[str]:
sanitized: list[str] = [] sanitized: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for raw in values or []: # type: ignore[arg-type] for raw in values or []:
text = str(raw or "").strip() text = str(raw or "").strip()
if not text: if not text:
continue continue

View file

@ -183,7 +183,7 @@ def _iter_json_themes(payload: object) -> Iterable[ThemeCatalogEntry]:
try: try:
from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path
except ImportError: # pragma: no cover - fallback when running as package except ImportError: # pragma: no cover - fallback when running as package
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore from code.type_definitions_theme_catalog import ThemeCatalog
try: try:
catalog = ThemeCatalog.model_validate(payload) catalog = ThemeCatalog.model_validate(payload)

View file

@ -0,0 +1,567 @@
"""
Card image caching system.
Downloads and manages local cache of Magic: The Gathering card images
from Scryfall, with graceful fallback to API when images are missing.
Features:
- Optional caching (disabled by default for open source users)
- Uses Scryfall bulk data API (respects rate limits and guidelines)
- Downloads from Scryfall CDN (no rate limits on image files)
- Progress tracking for long downloads
- Resume capability if interrupted
- Graceful fallback to API if images missing
Environment Variables:
CACHE_CARD_IMAGES: 1=enable caching, 0=disable (default: 0)
Image Sizes:
- small: 160px width (for list views)
- normal: 488px width (for prominent displays, hover previews)
Directory Structure:
card_files/images/small/ - Small thumbnails (~900 MB - 1.5 GB)
card_files/images/normal/ - Normal images (~2.4 GB - 4.5 GB)
See: https://scryfall.com/docs/api
"""
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Optional
from urllib.request import Request, urlopen
from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient
logger = logging.getLogger(__name__)
# Scryfall CDN has no rate limits, but we'll be conservative
DOWNLOAD_DELAY = 0.05 # 50ms between image downloads (20 req/sec)
# Image sizes to cache
IMAGE_SIZES = ["small", "normal"]
# Card name sanitization (filesystem-safe)
INVALID_CHARS = r'[<>:"/\\|?*]'
def sanitize_filename(card_name: str) -> str:
"""
Sanitize card name for use as filename.
Args:
card_name: Original card name
Returns:
Filesystem-safe filename
"""
# Replace invalid characters with underscore
safe_name = re.sub(INVALID_CHARS, "_", card_name)
# Remove multiple consecutive underscores
safe_name = re.sub(r"_+", "_", safe_name)
# Trim leading/trailing underscores
safe_name = safe_name.strip("_")
return safe_name
class ImageCache:
"""Manages local card image cache."""
def __init__(
self,
base_dir: str = "card_files/images",
bulk_data_path: str = "card_files/raw/scryfall_bulk_data.json",
):
"""
Initialize image cache.
Args:
base_dir: Base directory for cached images
bulk_data_path: Path to Scryfall bulk data JSON
"""
self.base_dir = Path(base_dir)
self.bulk_data_path = Path(bulk_data_path)
self.client = ScryfallBulkDataClient()
self._last_download_time: float = 0.0
def is_enabled(self) -> bool:
"""Check if image caching is enabled via environment variable."""
return os.getenv("CACHE_CARD_IMAGES", "0") == "1"
def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]:
"""
Get local path to cached image if it exists.
Args:
card_name: Card name
size: Image size ('small' or 'normal')
Returns:
Path to cached image, or None if not cached
"""
if not self.is_enabled():
return None
safe_name = sanitize_filename(card_name)
image_path = self.base_dir / size / f"{safe_name}.jpg"
if image_path.exists():
return image_path
return None
def get_image_url(self, card_name: str, size: str = "normal") -> str:
"""
Get image URL (local path if cached, Scryfall API otherwise).
Args:
card_name: Card name
size: Image size ('small' or 'normal')
Returns:
URL or local path to image
"""
# Check local cache first
local_path = self.get_image_path(card_name, size)
if local_path:
# Return as static file path for web serving
return f"/static/card_images/{size}/{sanitize_filename(card_name)}.jpg"
# Fallback to Scryfall API
from urllib.parse import quote
card_query = quote(card_name)
return f"https://api.scryfall.com/cards/named?fuzzy={card_query}&format=image&version={size}"
def _rate_limit_wait(self) -> None:
"""Wait to respect rate limits between downloads."""
elapsed = time.time() - self._last_download_time
if elapsed < DOWNLOAD_DELAY:
time.sleep(DOWNLOAD_DELAY - elapsed)
self._last_download_time = time.time()
def _download_image(self, image_url: str, output_path: Path) -> bool:
"""
Download single image from Scryfall CDN.
Args:
image_url: Image URL from bulk data
output_path: Local path to save image
Returns:
True if successful, False otherwise
"""
self._rate_limit_wait()
try:
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
req = Request(image_url)
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
with urlopen(req, timeout=30) as response:
image_data = response.read()
with open(output_path, "wb") as f:
f.write(image_data)
return True
except Exception as e:
logger.debug(f"Failed to download {image_url}: {e}")
# Clean up partial download
if output_path.exists():
output_path.unlink()
return False
def _load_bulk_data(self) -> list[dict[str, Any]]:
"""
Load card data from bulk data JSON.
Returns:
List of card objects with image URLs
Raises:
FileNotFoundError: If bulk data file doesn't exist
json.JSONDecodeError: If file is invalid JSON
"""
if not self.bulk_data_path.exists():
raise FileNotFoundError(
f"Bulk data file not found: {self.bulk_data_path}. "
"Run download_bulk_data() first."
)
logger.info(f"Loading bulk data from {self.bulk_data_path}")
with open(self.bulk_data_path, "r", encoding="utf-8") as f:
return json.load(f)
def _filter_to_our_cards(self, bulk_cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Filter bulk data to only cards in our all_cards.parquet file.
Deduplicates by card name (takes first printing only).
Args:
bulk_cards: Full Scryfall bulk data
Returns:
Filtered list of cards matching our dataset (one per unique name)
"""
try:
import pandas as pd
from code.path_util import get_processed_cards_path
# Load our card names
parquet_path = get_processed_cards_path()
df = pd.read_parquet(parquet_path, columns=["name"])
our_card_names = set(df["name"].str.lower())
logger.info(f"Filtering {len(bulk_cards)} Scryfall cards to {len(our_card_names)} cards in our dataset")
# Filter and deduplicate - keep only first printing of each card
seen_names = set()
filtered = []
for card in bulk_cards:
card_name_lower = card.get("name", "").lower()
if card_name_lower in our_card_names and card_name_lower not in seen_names:
filtered.append(card)
seen_names.add(card_name_lower)
logger.info(f"Filtered to {len(filtered)} unique cards with image data")
return filtered
except Exception as e:
logger.warning(f"Could not filter to our cards: {e}. Using all Scryfall cards.")
return bulk_cards
def download_bulk_data(self, progress_callback=None) -> None:
"""
Download latest Scryfall bulk data JSON.
Args:
progress_callback: Optional callback(bytes_downloaded, total_bytes)
Raises:
Exception: If download fails
"""
logger.info("Downloading Scryfall bulk data...")
self.bulk_data_path.parent.mkdir(parents=True, exist_ok=True)
self.client.get_bulk_data(
output_path=str(self.bulk_data_path),
progress_callback=progress_callback,
)
logger.info("Bulk data download complete")
def download_images(
self,
sizes: Optional[list[str]] = None,
progress_callback=None,
max_cards: Optional[int] = None,
) -> dict[str, int]:
"""
Download card images from Scryfall CDN.
Args:
sizes: Image sizes to download (default: ['small', 'normal'])
progress_callback: Optional callback(current, total, card_name)
max_cards: Maximum cards to download (for testing)
Returns:
Dictionary with download statistics
Raises:
FileNotFoundError: If bulk data not available
"""
if not self.is_enabled():
logger.info("Image caching disabled (CACHE_CARD_IMAGES=0)")
return {"skipped": 0}
if sizes is None:
sizes = IMAGE_SIZES
logger.info(f"Starting image download for sizes: {sizes}")
# Load bulk data and filter to our cards
bulk_cards = self._load_bulk_data()
cards = self._filter_to_our_cards(bulk_cards)
total_cards = len(cards) if max_cards is None else min(max_cards, len(cards))
stats = {
"total": total_cards,
"downloaded": 0,
"skipped": 0,
"failed": 0,
}
for i, card in enumerate(cards[:total_cards]):
card_name = card.get("name")
if not card_name:
stats["skipped"] += 1
continue
# Collect all faces to download (single-faced or multi-faced)
faces_to_download = []
# Check if card has direct image_uris (single-faced card)
if card.get("image_uris"):
faces_to_download.append({
"name": card_name,
"image_uris": card["image_uris"],
})
# Handle double-faced cards (get all faces)
elif card.get("card_faces"):
for face_idx, face in enumerate(card["card_faces"]):
if face.get("image_uris"):
# For multi-faced cards, append face name or index
face_name = face.get("name", f"{card_name}_face{face_idx}")
faces_to_download.append({
"name": face_name,
"image_uris": face["image_uris"],
})
# Skip if no faces found
if not faces_to_download:
logger.debug(f"No image URIs for {card_name}")
stats["skipped"] += 1
continue
# Download each face in each requested size
for face in faces_to_download:
face_name = face["name"]
image_uris = face["image_uris"]
for size in sizes:
image_url = image_uris.get(size)
if not image_url:
continue
# Check if already cached
safe_name = sanitize_filename(face_name)
output_path = self.base_dir / size / f"{safe_name}.jpg"
if output_path.exists():
stats["skipped"] += 1
continue
# Download image
if self._download_image(image_url, output_path):
stats["downloaded"] += 1
else:
stats["failed"] += 1
# Progress callback
if progress_callback:
progress_callback(i + 1, total_cards, card_name)
# Invalidate cached summary since we just downloaded new images
self.invalidate_summary_cache()
logger.info(f"Image download complete: {stats}")
return stats
def cache_statistics(self) -> dict[str, Any]:
"""
Get statistics about cached images.
Uses a cached summary.json file to avoid scanning thousands of files.
Regenerates summary if it doesn't exist or is stale (based on WEB_AUTO_REFRESH_DAYS,
default 7 days, matching the main card data staleness check).
Returns:
Dictionary with cache stats (count, size, etc.)
"""
stats = {"enabled": self.is_enabled()}
if not self.is_enabled():
return stats
summary_file = self.base_dir / "summary.json"
# Get staleness threshold from environment (same as card data check)
try:
refresh_days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
except Exception:
refresh_days = 7
if refresh_days <= 0:
# Never consider stale
refresh_seconds = float('inf')
else:
refresh_seconds = refresh_days * 24 * 60 * 60 # Convert days to seconds
# Check if summary exists and is recent (less than refresh_seconds old)
use_cached = False
if summary_file.exists():
try:
import time
file_age = time.time() - summary_file.stat().st_mtime
if file_age < refresh_seconds:
use_cached = True
except Exception:
pass
# Try to use cached summary
if use_cached:
try:
import json
with summary_file.open('r', encoding='utf-8') as f:
cached_stats = json.load(f)
stats.update(cached_stats)
return stats
except Exception as e:
logger.warning(f"Could not read cache summary: {e}")
# Regenerate summary (fast - just count files and estimate size)
for size in IMAGE_SIZES:
size_dir = self.base_dir / size
if size_dir.exists():
# Fast count: count .jpg files without statting each one
count = sum(1 for _ in size_dir.glob("*.jpg"))
# Estimate total size based on typical averages to avoid stat() calls
# Small images: ~40 KB avg, Normal images: ~100 KB avg
avg_size_kb = 40 if size == "small" else 100
estimated_size_mb = (count * avg_size_kb) / 1024
stats[size] = {
"count": count,
"size_mb": round(estimated_size_mb, 1),
}
else:
stats[size] = {"count": 0, "size_mb": 0.0}
# Save summary for next time
try:
import json
with summary_file.open('w', encoding='utf-8') as f:
json.dump({k: v for k, v in stats.items() if k != "enabled"}, f)
except Exception as e:
logger.warning(f"Could not write cache summary: {e}")
return stats
def invalidate_summary_cache(self) -> None:
"""Delete the cached summary file to force regeneration on next call."""
if not self.is_enabled():
return
summary_file = self.base_dir / "summary.json"
if summary_file.exists():
try:
summary_file.unlink()
logger.debug("Invalidated cache summary file")
except Exception as e:
logger.warning(f"Could not delete cache summary: {e}")
def main():
"""CLI entry point for image caching."""
import argparse
parser = argparse.ArgumentParser(description="Card image cache management")
parser.add_argument(
"--download",
action="store_true",
help="Download images from Scryfall",
)
parser.add_argument(
"--stats",
action="store_true",
help="Show cache statistics",
)
parser.add_argument(
"--max-cards",
type=int,
help="Maximum cards to download (for testing)",
)
parser.add_argument(
"--sizes",
nargs="+",
default=IMAGE_SIZES,
choices=IMAGE_SIZES,
help="Image sizes to download",
)
parser.add_argument(
"--force",
action="store_true",
help="Force re-download of bulk data even if recent",
)
args = parser.parse_args()
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
cache = ImageCache()
if args.stats:
stats = cache.cache_statistics()
print("\nCache Statistics:")
print(f" Enabled: {stats['enabled']}")
if stats["enabled"]:
for size in IMAGE_SIZES:
if size in stats:
print(
f" {size.capitalize()}: {stats[size]['count']} images "
f"({stats[size]['size_mb']:.1f} MB)"
)
elif args.download:
if not cache.is_enabled():
print("Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable.")
return
# Check if bulk data already exists and is recent (within 24 hours)
bulk_data_exists = cache.bulk_data_path.exists()
bulk_data_age_hours = None
if bulk_data_exists:
import time
age_seconds = time.time() - cache.bulk_data_path.stat().st_mtime
bulk_data_age_hours = age_seconds / 3600
print(f"Bulk data file exists (age: {bulk_data_age_hours:.1f} hours)")
# Download bulk data if missing, old, or forced
if not bulk_data_exists or bulk_data_age_hours > 24 or args.force:
print("Downloading Scryfall bulk data...")
def bulk_progress(downloaded, total):
if total > 0:
pct = (downloaded / total) * 100
print(f" Progress: {downloaded / 1024 / 1024:.1f} MB / "
f"{total / 1024 / 1024:.1f} MB ({pct:.1f}%)", end="\r")
cache.download_bulk_data(progress_callback=bulk_progress)
print("\nBulk data downloaded successfully")
else:
print("Bulk data is recent, skipping download (use --force to re-download)")
# Download images
print(f"\nDownloading card images (sizes: {', '.join(args.sizes)})...")
def image_progress(current, total, card_name):
pct = (current / total) * 100
print(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}", end="\r")
stats = cache.download_images(
sizes=args.sizes,
progress_callback=image_progress,
max_cards=args.max_cards,
)
print("\n\nDownload complete:")
print(f" Total: {stats['total']}")
print(f" Downloaded: {stats['downloaded']}")
print(f" Skipped: {stats['skipped']}")
print(f" Failed: {stats['failed']}")
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -40,7 +40,7 @@ from typing import List, Dict, Any
# Third-party imports (optional) # Third-party imports (optional)
try: try:
import inquirer # type: ignore import inquirer
except Exception: except Exception:
inquirer = None # Fallback to simple input-based menu when unavailable inquirer = None # Fallback to simple input-based menu when unavailable
import pandas as pd import pandas as pd

View file

@ -40,7 +40,7 @@ from typing import List, Dict, Any
# Third-party imports (optional) # Third-party imports (optional)
try: try:
import inquirer # type: ignore import inquirer
except Exception: except Exception:
inquirer = None # Fallback to simple input-based menu when unavailable inquirer = None # Fallback to simple input-based menu when unavailable
import pandas as pd import pandas as pd

View file

@ -0,0 +1,169 @@
"""
Scryfall Bulk Data API client.
Fetches bulk data JSON files from Scryfall's bulk data API, which provides
all card information including image URLs without hitting rate limits.
See: https://scryfall.com/docs/api/bulk-data
"""
import logging
import os
import time
from typing import Any
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
BULK_DATA_API_URL = "https://api.scryfall.com/bulk-data"
DEFAULT_BULK_TYPE = "default_cards" # All cards in Scryfall's database
RATE_LIMIT_DELAY = 0.1 # 100ms between requests (50-100ms per Scryfall guidelines)
class ScryfallBulkDataClient:
"""Client for fetching Scryfall bulk data."""
def __init__(self, rate_limit_delay: float = RATE_LIMIT_DELAY):
"""
Initialize Scryfall bulk data client.
Args:
rate_limit_delay: Seconds to wait between API requests (default 100ms)
"""
self.rate_limit_delay = rate_limit_delay
self._last_request_time: float = 0.0
def _rate_limit_wait(self) -> None:
"""Wait to respect rate limits between API calls."""
elapsed = time.time() - self._last_request_time
if elapsed < self.rate_limit_delay:
time.sleep(self.rate_limit_delay - elapsed)
self._last_request_time = time.time()
def _make_request(self, url: str) -> Any:
"""
Make HTTP request with rate limiting and error handling.
Args:
url: URL to fetch
Returns:
Parsed JSON response
Raises:
Exception: If request fails after retries
"""
self._rate_limit_wait()
try:
req = Request(url)
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
with urlopen(req, timeout=30) as response:
import json
return json.loads(response.read().decode("utf-8"))
except Exception as e:
logger.error(f"Failed to fetch {url}: {e}")
raise
def get_bulk_data_info(self, bulk_type: str = DEFAULT_BULK_TYPE) -> dict[str, Any]:
"""
Get bulk data metadata (download URL, size, last updated).
Args:
bulk_type: Type of bulk data to fetch (default: default_cards)
Returns:
Dictionary with bulk data info including 'download_uri'
Raises:
ValueError: If bulk_type not found
Exception: If API request fails
"""
logger.info(f"Fetching bulk data info for type: {bulk_type}")
response = self._make_request(BULK_DATA_API_URL)
# Find the requested bulk data type
for item in response.get("data", []):
if item.get("type") == bulk_type:
logger.info(
f"Found bulk data: {item.get('name')} "
f"(size: {item.get('size', 0) / 1024 / 1024:.1f} MB, "
f"updated: {item.get('updated_at', 'unknown')})"
)
return item
raise ValueError(f"Bulk data type '{bulk_type}' not found")
def download_bulk_data(
self, download_uri: str, output_path: str, progress_callback=None
) -> None:
"""
Download bulk data JSON file.
Args:
download_uri: Direct download URL from get_bulk_data_info()
output_path: Local path to save the JSON file
progress_callback: Optional callback(bytes_downloaded, total_bytes)
Raises:
Exception: If download fails
"""
logger.info(f"Downloading bulk data from: {download_uri}")
logger.info(f"Saving to: {output_path}")
# No rate limit on bulk data downloads per Scryfall docs
try:
req = Request(download_uri)
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
with urlopen(req, timeout=60) as response:
total_size = int(response.headers.get("Content-Length", 0))
downloaded = 0
chunk_size = 1024 * 1024 # 1MB chunks
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "wb") as f:
while True:
chunk = response.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback:
progress_callback(downloaded, total_size)
logger.info(f"Downloaded {downloaded / 1024 / 1024:.1f} MB successfully")
except Exception as e:
logger.error(f"Failed to download bulk data: {e}")
# Clean up partial download
if os.path.exists(output_path):
os.remove(output_path)
raise
def get_bulk_data(
self,
bulk_type: str = DEFAULT_BULK_TYPE,
output_path: str = "card_files/raw/scryfall_bulk_data.json",
progress_callback=None,
) -> str:
"""
Fetch bulk data info and download the JSON file.
Args:
bulk_type: Type of bulk data to fetch
output_path: Where to save the JSON file
progress_callback: Optional progress callback
Returns:
Path to downloaded file
Raises:
Exception: If fetch or download fails
"""
info = self.get_bulk_data_info(bulk_type)
download_uri = info["download_uri"]
self.download_bulk_data(download_uri, output_path, progress_callback)
return output_path

View file

@ -349,6 +349,44 @@ def initial_setup() -> None:
logger.info(f" Raw: {raw_path}") logger.info(f" Raw: {raw_path}")
logger.info(f" Processed: {processed_path}") logger.info(f" Processed: {processed_path}")
logger.info("=" * 80) logger.info("=" * 80)
# Step 3: Optional image caching (if enabled)
try:
from code.file_setup.image_cache import ImageCache
cache = ImageCache()
if cache.is_enabled():
logger.info("=" * 80)
logger.info("Card image caching enabled - starting download")
logger.info("=" * 80)
# Download bulk data
logger.info("Downloading Scryfall bulk data...")
cache.download_bulk_data()
# Download images
logger.info("Downloading card images (this may take 1-2 hours)...")
def progress(current, total, card_name):
if current % 100 == 0: # Log every 100 cards
pct = (current / total) * 100
logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}")
stats = cache.download_images(progress_callback=progress)
logger.info("=" * 80)
logger.info("✓ Image cache complete")
logger.info(f" Downloaded: {stats['downloaded']}")
logger.info(f" Skipped: {stats['skipped']}")
logger.info(f" Failed: {stats['failed']}")
logger.info("=" * 80)
else:
logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)")
logger.info("Images will be fetched from Scryfall API on demand")
except Exception as e:
logger.error(f"Failed to cache images (continuing anyway): {e}")
logger.error("Images will be fetched from Scryfall API on demand")
def regenerate_processed_parquet() -> None: def regenerate_processed_parquet() -> None:

View file

@ -139,7 +139,7 @@ def _validate_commander_available(command_name: str) -> None:
return return
try: try:
from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found] from commander_exclusions import lookup_commander_detail as _lookup_commander_detail
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
_lookup_commander_detail = None _lookup_commander_detail = None
@ -281,12 +281,12 @@ def run(
# Optional deterministic seed for Random Modes (does not affect core when unset) # Optional deterministic seed for Random Modes (does not affect core when unset)
try: try:
if seed is not None: if seed is not None:
builder.set_seed(seed) # type: ignore[attr-defined] builder.set_seed(seed)
except Exception: except Exception:
pass pass
# Mark this run as headless so builder can adjust exports and logging # Mark this run as headless so builder can adjust exports and logging
try: try:
builder.headless = True # type: ignore[attr-defined] builder.headless = True
except Exception: except Exception:
pass pass
@ -294,9 +294,9 @@ def run(
secondary_clean = (secondary_commander or "").strip() secondary_clean = (secondary_commander or "").strip()
background_clean = (background or "").strip() background_clean = (background or "").strip()
try: try:
builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined] builder.partner_feature_enabled = partner_feature_enabled
builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined] builder.requested_secondary_commander = secondary_clean or None
builder.requested_background = background_clean or None # type: ignore[attr-defined] builder.requested_background = background_clean or None
except Exception: except Exception:
pass pass
@ -313,11 +313,11 @@ def run(
# Configure include/exclude settings (M1: Config + Validation + Persistence) # Configure include/exclude settings (M1: Config + Validation + Persistence)
try: try:
builder.include_cards = list(include_cards or []) # type: ignore[attr-defined] builder.include_cards = list(include_cards or [])
builder.exclude_cards = list(exclude_cards or []) # type: ignore[attr-defined] builder.exclude_cards = list(exclude_cards or [])
builder.enforcement_mode = enforcement_mode # type: ignore[attr-defined] builder.enforcement_mode = enforcement_mode
builder.allow_illegal = allow_illegal # type: ignore[attr-defined] builder.allow_illegal = allow_illegal
builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined] builder.fuzzy_matching = fuzzy_matching
except Exception: except Exception:
pass pass
@ -336,16 +336,16 @@ def run(
) )
try: try:
builder.theme_match_mode = theme_resolution.mode # type: ignore[attr-defined] builder.theme_match_mode = theme_resolution.mode
builder.theme_catalog_version = theme_resolution.catalog_version # type: ignore[attr-defined] builder.theme_catalog_version = theme_resolution.catalog_version
builder.user_theme_requested = list(theme_resolution.requested) # type: ignore[attr-defined] builder.user_theme_requested = list(theme_resolution.requested)
builder.user_theme_resolved = list(theme_resolution.resolved) # type: ignore[attr-defined] builder.user_theme_resolved = list(theme_resolution.resolved)
builder.user_theme_matches = list(theme_resolution.matches) # type: ignore[attr-defined] builder.user_theme_matches = list(theme_resolution.matches)
builder.user_theme_unresolved = list(theme_resolution.unresolved) # type: ignore[attr-defined] builder.user_theme_unresolved = list(theme_resolution.unresolved)
builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) # type: ignore[attr-defined] builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections)
builder.user_theme_resolution = theme_resolution # type: ignore[attr-defined] builder.user_theme_resolution = theme_resolution
if user_theme_weight is not None: if user_theme_weight is not None:
builder.user_theme_weight = float(user_theme_weight) # type: ignore[attr-defined] builder.user_theme_weight = float(user_theme_weight)
except Exception: except Exception:
pass pass
@ -356,7 +356,7 @@ def run(
ic: Dict[str, int] = {} ic: Dict[str, int] = {}
for k, v in ideal_counts.items(): for k, v in ideal_counts.items():
try: try:
iv = int(v) if v is not None else None # type: ignore iv = int(v) if v is not None else None
except Exception: except Exception:
continue continue
if iv is None: if iv is None:
@ -365,7 +365,7 @@ def run(
if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}: if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}:
ic[k] = iv ic[k] = iv
if ic: if ic:
builder.ideal_counts.update(ic) # type: ignore[attr-defined] builder.ideal_counts.update(ic)
except Exception: except Exception:
pass pass
builder.run_initial_setup() builder.run_initial_setup()
@ -518,24 +518,24 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commande
"""Attach combined commander metadata to the builder for downstream use.""" """Attach combined commander metadata to the builder for downstream use."""
try: try:
builder.combined_commander = combined_commander # type: ignore[attr-defined] builder.combined_commander = combined_commander
except Exception: except Exception:
pass pass
try: try:
builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined] builder.partner_mode = combined_commander.partner_mode
except Exception: except Exception:
pass pass
try: try:
builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined] builder.secondary_commander = combined_commander.secondary_name
except Exception: except Exception:
pass pass
try: try:
builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined] builder.combined_color_identity = combined_commander.color_identity
builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined] builder.combined_theme_tags = combined_commander.theme_tags
builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined] builder.partner_warnings = combined_commander.warnings
except Exception: except Exception:
pass pass
@ -557,7 +557,7 @@ def _export_outputs(builder: DeckBuilder) -> None:
# Persist for downstream reuse (e.g., random_entrypoint / reroll flows) so they don't re-export # Persist for downstream reuse (e.g., random_entrypoint / reroll flows) so they don't re-export
if csv_path: if csv_path:
try: try:
builder.last_csv_path = csv_path # type: ignore[attr-defined] builder.last_csv_path = csv_path
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -572,7 +572,7 @@ def _export_outputs(builder: DeckBuilder) -> None:
finally: finally:
if txt_generated: if txt_generated:
try: try:
builder.last_txt_path = txt_generated # type: ignore[attr-defined] builder.last_txt_path = txt_generated
except Exception: except Exception:
pass pass
else: else:
@ -582,7 +582,7 @@ def _export_outputs(builder: DeckBuilder) -> None:
finally: finally:
if txt_generated: if txt_generated:
try: try:
builder.last_txt_path = txt_generated # type: ignore[attr-defined] builder.last_txt_path = txt_generated
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -1196,7 +1196,7 @@ def _run_random_mode(config: RandomRunConfig) -> int:
RandomConstraintsImpossibleError, RandomConstraintsImpossibleError,
RandomThemeNoMatchError, RandomThemeNoMatchError,
build_random_full_deck, build_random_full_deck,
) # type: ignore )
except Exception as exc: except Exception as exc:
print(f"Random mode unavailable: {exc}") print(f"Random mode unavailable: {exc}")
return 1 return 1

View file

@ -36,7 +36,7 @@ except Exception: # pragma: no cover
try: try:
# Support running as `python code/scripts/build_theme_catalog.py` when 'code' already on path # Support running as `python code/scripts/build_theme_catalog.py` when 'code' already on path
from scripts.extract_themes import ( # type: ignore from scripts.extract_themes import (
BASE_COLORS, BASE_COLORS,
collect_theme_tags_from_constants, collect_theme_tags_from_constants,
collect_theme_tags_from_tagger_source, collect_theme_tags_from_tagger_source,
@ -51,7 +51,7 @@ try:
) )
except ModuleNotFoundError: except ModuleNotFoundError:
# Fallback: direct relative import when running within scripts package context # Fallback: direct relative import when running within scripts package context
from extract_themes import ( # type: ignore from extract_themes import (
BASE_COLORS, BASE_COLORS,
collect_theme_tags_from_constants, collect_theme_tags_from_constants,
collect_theme_tags_from_tagger_source, collect_theme_tags_from_tagger_source,
@ -66,7 +66,7 @@ except ModuleNotFoundError:
) )
try: try:
from scripts.export_themes_to_yaml import slugify as slugify_theme # type: ignore from scripts.export_themes_to_yaml import slugify as slugify_theme
except Exception: except Exception:
_SLUG_RE = re.compile(r'[^a-z0-9-]') _SLUG_RE = re.compile(r'[^a-z0-9-]')
@ -951,7 +951,7 @@ def main(): # pragma: no cover
if args.schema: if args.schema:
# Lazy import to avoid circular dependency: replicate minimal schema inline from models file if present # Lazy import to avoid circular dependency: replicate minimal schema inline from models file if present
try: try:
from type_definitions_theme_catalog import ThemeCatalog # type: ignore from type_definitions_theme_catalog import ThemeCatalog
import json as _json import json as _json
print(_json.dumps(ThemeCatalog.model_json_schema(), indent=2)) print(_json.dumps(ThemeCatalog.model_json_schema(), indent=2))
return return
@ -990,8 +990,8 @@ def main(): # pragma: no cover
# Safeguard: if catalog dir missing, attempt to auto-export Phase A YAML first # Safeguard: if catalog dir missing, attempt to auto-export Phase A YAML first
if not CATALOG_DIR.exists(): # pragma: no cover (environmental) if not CATALOG_DIR.exists(): # pragma: no cover (environmental)
try: try:
from scripts.export_themes_to_yaml import main as export_main # type: ignore from scripts.export_themes_to_yaml import main as export_main
export_main(['--force']) # type: ignore[arg-type] export_main(['--force'])
except Exception as _e: except Exception as _e:
print(f"[build_theme_catalog] WARNING: catalog dir missing and auto export failed: {_e}", file=sys.stderr) print(f"[build_theme_catalog] WARNING: catalog dir missing and auto export failed: {_e}", file=sys.stderr)
if yaml is None: if yaml is None:
@ -1013,7 +1013,7 @@ def main(): # pragma: no cover
meta_block = raw.get('metadata_info') if isinstance(raw.get('metadata_info'), dict) else {} meta_block = raw.get('metadata_info') if isinstance(raw.get('metadata_info'), dict) else {}
# Legacy migration: if no metadata_info but legacy provenance present, adopt it # Legacy migration: if no metadata_info but legacy provenance present, adopt it
if not meta_block and isinstance(raw.get('provenance'), dict): if not meta_block and isinstance(raw.get('provenance'), dict):
meta_block = raw.get('provenance') # type: ignore meta_block = raw.get('provenance')
changed = True changed = True
if force or not meta_block.get('last_backfill'): if force or not meta_block.get('last_backfill'):
meta_block['last_backfill'] = time.strftime('%Y-%m-%dT%H:%M:%S') meta_block['last_backfill'] = time.strftime('%Y-%m-%dT%H:%M:%S')

View file

@ -41,7 +41,7 @@ SCRIPT_ROOT = Path(__file__).resolve().parent
CODE_ROOT = SCRIPT_ROOT.parent CODE_ROOT = SCRIPT_ROOT.parent
if str(CODE_ROOT) not in sys.path: if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT)) sys.path.insert(0, str(CODE_ROOT))
from scripts.extract_themes import derive_synergies_for_tags # type: ignore from scripts.extract_themes import derive_synergies_for_tags
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
THEME_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' THEME_JSON = ROOT / 'config' / 'themes' / 'theme_list.json'

View file

@ -18,8 +18,8 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if ROOT not in sys.path: if ROOT not in sys.path:
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
from code.settings import CSV_DIRECTORY # type: ignore from code.settings import CSV_DIRECTORY
from code.tagging import tag_constants # type: ignore from code.tagging import tag_constants
BASE_COLORS = { BASE_COLORS = {
'white': 'W', 'white': 'W',

View file

@ -32,7 +32,7 @@ if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT)) sys.path.insert(0, str(CODE_ROOT))
try: try:
from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY
except Exception: # pragma: no cover - fallback for adhoc execution except Exception: # pragma: no cover - fallback for adhoc execution
DEFAULT_CSV_DIRECTORY = "csv_files" DEFAULT_CSV_DIRECTORY = "csv_files"

View file

@ -42,7 +42,7 @@ def _sample_combinations(tags: List[str], iterations: int) -> List[Tuple[str | N
def _collect_tag_pool(df: pd.DataFrame) -> List[str]: def _collect_tag_pool(df: pd.DataFrame) -> List[str]:
tag_pool: set[str] = set() tag_pool: set[str] = set()
for tags in df.get("_ltags", []): # type: ignore[assignment] for tags in df.get("_ltags", []):
if not tags: if not tags:
continue continue
for token in tags: for token in tags:

View file

@ -37,7 +37,7 @@ def _refresh_setup() -> None:
def _refresh_tags() -> None: def _refresh_tags() -> None:
tagger = importlib.import_module("code.tagging.tagger") tagger = importlib.import_module("code.tagging.tagger")
tagger = importlib.reload(tagger) # type: ignore[assignment] tagger = importlib.reload(tagger)
for color in SUPPORTED_COLORS: for color in SUPPORTED_COLORS:
tagger.load_dataframe(color) tagger.load_dataframe(color)

View file

@ -21,7 +21,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path: if str(PROJECT_ROOT) not in sys.path:
sys.path.append(str(PROJECT_ROOT)) sys.path.append(str(PROJECT_ROOT))
from deck_builder.random_entrypoint import ( # type: ignore # noqa: E402 from deck_builder.random_entrypoint import ( # noqa: E402
_build_random_theme_pool, _build_random_theme_pool,
_ensure_theme_tag_cache, _ensure_theme_tag_cache,
_load_commanders_df, _load_commanders_df,

View file

@ -731,7 +731,7 @@ def main(): # pragma: no cover (script orchestration)
if cand: if cand:
theme_card_hits[display] = cand theme_card_hits[display] = cand
# Build global duplicate frequency map ONCE (baseline prior to this run) if threshold active # Build global duplicate frequency map ONCE (baseline prior to this run) if threshold active
if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): # type: ignore if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals():
freq: Dict[str, int] = {} freq: Dict[str, int] = {}
total_themes = 0 total_themes = 0
for fp0 in CATALOG_DIR.glob('*.yml'): for fp0 in CATALOG_DIR.glob('*.yml'):
@ -748,10 +748,10 @@ def main(): # pragma: no cover (script orchestration)
continue continue
seen_local.add(c) seen_local.add(c)
freq[c] = freq.get(c, 0) + 1 freq[c] = freq.get(c, 0) + 1
globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) # type: ignore globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes)
# Apply duplicate filtering to candidate lists (do NOT mutate existing example_cards) # Apply duplicate filtering to candidate lists (do NOT mutate existing example_cards)
if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals():
freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore freq_map, total_prev = globals()['GLOBAL_CARD_FREQ']
if total_prev > 0: # avoid div-by-zero if total_prev > 0: # avoid div-by-zero
cutoff = args.common_card_threshold cutoff = args.common_card_threshold
def _filter(lst: List[Tuple[float, str, Set[str]]]) -> List[Tuple[float, str, Set[str]]]: def _filter(lst: List[Tuple[float, str, Set[str]]]) -> List[Tuple[float, str, Set[str]]]:
@ -803,8 +803,8 @@ def main(): # pragma: no cover (script orchestration)
print(f"[promote] modified {changed_count} themes") print(f"[promote] modified {changed_count} themes")
if args.fill_example_cards: if args.fill_example_cards:
print(f"[cards] modified {cards_changed} themes (target {args.cards_target})") print(f"[cards] modified {cards_changed} themes (target {args.cards_target})")
if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals():
freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore freq_map, total_prev = globals()['GLOBAL_CARD_FREQ']
if total_prev: if total_prev:
items = sorted(freq_map.items(), key=lambda x: (-x[1], x[0]))[:30] items = sorted(freq_map.items(), key=lambda x: (-x[1], x[0]))[:30]
print('[dup-metrics] Top shared example_cards (baseline before this run):') print('[dup-metrics] Top shared example_cards (baseline before this run):')

View file

@ -31,9 +31,9 @@ CODE_ROOT = ROOT / 'code'
if str(CODE_ROOT) not in sys.path: if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT)) sys.path.insert(0, str(CODE_ROOT))
from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile # type: ignore from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile
from scripts.extract_themes import load_whitelist_config # type: ignore from scripts.extract_themes import load_whitelist_config
from scripts.build_theme_catalog import build_catalog # type: ignore from scripts.build_theme_catalog import build_catalog
CATALOG_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' CATALOG_JSON = ROOT / 'config' / 'themes' / 'theme_list.json'

View file

@ -89,11 +89,8 @@ COLUMN_ORDER = CARD_COLUMN_ORDER
TAGGED_COLUMN_ORDER = CARD_COLUMN_ORDER TAGGED_COLUMN_ORDER = CARD_COLUMN_ORDER
REQUIRED_COLUMNS = REQUIRED_CARD_COLUMNS REQUIRED_COLUMNS = REQUIRED_CARD_COLUMNS
MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit'] # MAIN_MENU_ITEMS, SETUP_MENU_ITEMS, CSV_DIRECTORY already defined above (lines 67-70)
SETUP_MENU_ITEMS: List[str] = ['Initial Setup', 'Regenerate CSV', 'Main Menu']
CSV_DIRECTORY: str = 'csv_files'
CARD_FILES_DIRECTORY: str = 'card_files' # Parquet files for consolidated card data CARD_FILES_DIRECTORY: str = 'card_files' # Parquet files for consolidated card data
# ---------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------
@ -111,11 +108,7 @@ CARD_FILES_PROCESSED_DIR = os.getenv('CARD_FILES_PROCESSED_DIR', os.path.join(CA
# Set to '1' or 'true' to enable CSV fallback when Parquet loading fails # Set to '1' or 'true' to enable CSV fallback when Parquet loading fails
LEGACY_CSV_COMPAT = os.getenv('LEGACY_CSV_COMPAT', '0').lower() in ('1', 'true', 'on', 'enabled') LEGACY_CSV_COMPAT = os.getenv('LEGACY_CSV_COMPAT', '0').lower() in ('1', 'true', 'on', 'enabled')
# Configuration for handling null/NA values in DataFrame columns # FILL_NA_COLUMNS already defined above (lines 75-78)
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
}
# ---------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------
# ALL CARDS CONSOLIDATION FEATURE FLAG # ALL CARDS CONSOLIDATION FEATURE FLAG

View file

@ -30,14 +30,14 @@ try:
import logging_util import logging_util
except Exception: except Exception:
# Fallback for direct module loading # Fallback for direct module loading
import importlib.util # type: ignore import importlib.util
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
lu_path = root / 'logging_util.py' lu_path = root / 'logging_util.py'
spec = importlib.util.spec_from_file_location('logging_util', str(lu_path)) spec = importlib.util.spec_from_file_location('logging_util', str(lu_path))
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(mod) # type: ignore[assignment] spec.loader.exec_module(mod)
logging_util = mod # type: ignore logging_util = mod
logger = logging_util.logging.getLogger(__name__) logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL) logger.setLevel(logging_util.LOG_LEVEL)

View file

@ -240,6 +240,13 @@ def merge_multi_face_rows(
faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()] faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()]
# M9: Capture back face type for MDFC land detection
if len(group_sorted) >= 2 and "type" in group_sorted.columns:
back_face_row = group_sorted.iloc[1]
back_type = str(back_face_row.get("type", "") or "")
if back_type:
work_df.at[primary_idx, "backType"] = back_type
drop_indices.extend(group_sorted.index[1:]) drop_indices.extend(group_sorted.index[1:])
merged_count += 1 merged_count += 1

View file

@ -173,7 +173,7 @@ def _merge_summary_recorder(color: str):
def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None: def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None:
try: # type: ignore[name-defined] try:
_DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True) _DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True)
path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv" path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv"
df.to_csv(path, index=False) df.to_csv(path, index=False)

View file

@ -173,7 +173,7 @@ def _merge_summary_recorder(color: str):
def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None: def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None:
"""Write DFC compatibility snapshot (diagnostic output, kept as CSV for now).""" """Write DFC compatibility snapshot (diagnostic output, kept as CSV for now)."""
try: # type: ignore[name-defined] try:
_DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True) _DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True)
path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv" path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv"
df.to_csv(path, index=False) # M3: Kept as CSV (diagnostic only, not main data flow) df.to_csv(path, index=False) # M3: Kept as CSV (diagnostic only, not main data flow)

View file

@ -11,9 +11,9 @@ def _load_applier():
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
mod_path = root / 'code' / 'tagging' / 'bracket_policy_applier.py' mod_path = root / 'code' / 'tagging' / 'bracket_policy_applier.py'
spec = importlib.util.spec_from_file_location('bracket_policy_applier', str(mod_path)) spec = importlib.util.spec_from_file_location('bracket_policy_applier', str(mod_path))
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(mod) # type: ignore[assignment] spec.loader.exec_module(mod)
return mod return mod

View file

@ -30,8 +30,8 @@ def test_card_index_color_identity_list_handles_edge_cases(tmp_path, monkeypatch
csv_path = write_csv(tmp_path) csv_path = write_csv(tmp_path)
monkeypatch.setenv("CARD_INDEX_EXTRA_CSV", str(csv_path)) monkeypatch.setenv("CARD_INDEX_EXTRA_CSV", str(csv_path))
# Force rebuild # Force rebuild
card_index._CARD_INDEX.clear() # type: ignore card_index._CARD_INDEX.clear()
card_index._CARD_INDEX_MTIME = None # type: ignore card_index._CARD_INDEX_MTIME = None
card_index.maybe_build_index() card_index.maybe_build_index()
pool = card_index.get_tag_pool("Blink") pool = card_index.get_tag_pool("Blink")

View file

@ -8,7 +8,7 @@ from urllib.parse import parse_qs, urlparse
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache from code.web.services.commander_catalog_loader import clear_commander_catalog_cache

View file

@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
from code.web.services import telemetry from code.web.services import telemetry
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache from code.web.services.commander_catalog_loader import clear_commander_catalog_cache

View file

@ -7,7 +7,7 @@ from types import SimpleNamespace
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
from code.web.routes import commanders from code.web.routes import commanders
from code.web.services import commander_catalog_loader from code.web.services import commander_catalog_loader
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog

View file

@ -24,7 +24,7 @@ def load_app_with_env(**env: str) -> types.ModuleType:
os.environ.pop(key, None) os.environ.pop(key, None)
for k, v in env.items(): for k, v in env.items():
os.environ[k] = v os.environ[k] = v
import code.web.app as app_module # type: ignore import code.web.app as app_module
importlib.reload(app_module) importlib.reload(app_module)
return app_module return app_module

View file

@ -50,7 +50,7 @@ def _load_catalog() -> Dict[str, Any]:
def test_deterministic_build_under_seed(): def test_deterministic_build_under_seed():
# Import build after setting seed env # Import build after setting seed env
os.environ['EDITORIAL_SEED'] = '999' os.environ['EDITORIAL_SEED'] = '999'
from scripts.build_theme_catalog import build_catalog # type: ignore from scripts.build_theme_catalog import build_catalog
first = build_catalog(limit=0, verbose=False) first = build_catalog(limit=0, verbose=False)
second = build_catalog(limit=0, verbose=False) second = build_catalog(limit=0, verbose=False)
# Drop volatile metadata_info/timestamp fields before comparison # Drop volatile metadata_info/timestamp fields before comparison
@ -106,7 +106,7 @@ def test_metadata_info_block_coverage():
def test_synergy_commanders_exclusion_of_examples(): def test_synergy_commanders_exclusion_of_examples():
import yaml # type: ignore import yaml
pattern = re.compile(r" - Synergy \(.*\)$") pattern = re.compile(r" - Synergy \(.*\)$")
violations: List[str] = [] violations: List[str] = []
for p in CATALOG_DIR.glob('*.yml'): for p in CATALOG_DIR.glob('*.yml'):
@ -128,7 +128,7 @@ def test_synergy_commanders_exclusion_of_examples():
def test_mapping_trigger_specialization_guard(): def test_mapping_trigger_specialization_guard():
import yaml # type: ignore import yaml
assert MAPPING.exists(), "description_mapping.yml missing" assert MAPPING.exists(), "description_mapping.yml missing"
mapping_yaml = yaml.safe_load(MAPPING.read_text(encoding='utf-8')) or [] mapping_yaml = yaml.safe_load(MAPPING.read_text(encoding='utf-8')) or []
triggers: Set[str] = set() triggers: Set[str] = set()

View file

@ -20,7 +20,7 @@ def load_app_with_env(**env: str) -> types.ModuleType:
os.environ.pop(key, None) os.environ.pop(key, None)
for k, v in env.items(): for k, v in env.items():
os.environ[k] = v os.environ[k] = v
import code.web.app as app_module # type: ignore import code.web.app as app_module
importlib.reload(app_module) importlib.reload(app_module)
return app_module return app_module

View file

@ -14,7 +14,7 @@ class DummyBuilder(ReportingMixin):
self.card_library = card_library self.card_library = card_library
self.color_identity = colors self.color_identity = colors
self.output_lines: List[str] = [] self.output_lines: List[str] = []
self.output_func = self.output_lines.append # type: ignore[assignment] self.output_func = self.output_lines.append
self._full_cards_df = None self._full_cards_df = None
self._combined_cards_df = None self._combined_cards_df = None
self.include_exclude_diagnostics = None self.include_exclude_diagnostics = None

View file

@ -20,7 +20,7 @@ def _stub_modal_matrix(builder: DeckBuilder) -> None:
"Forest": {"G": 1}, "Forest": {"G": 1},
} }
builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined] builder._compute_color_source_matrix = MethodType(fake_matrix, builder)
def test_modal_dfc_swaps_basic_when_enabled(): def test_modal_dfc_swaps_basic_when_enabled():

View file

@ -18,7 +18,7 @@ def test_multicopy_clamp_trims_current_stage_additions_only():
# Preseed 95 cards in the library # Preseed 95 cards in the library
b.card_library = {"Filler": {"Count": 95, "Role": "Test", "SubRole": "", "AddedBy": "Test"}} b.card_library = {"Filler": {"Count": 95, "Role": "Test", "SubRole": "", "AddedBy": "Test"}}
# Set a multi-copy selection that would exceed 100 by 15 # Set a multi-copy selection that would exceed 100 by 15
b._web_multi_copy = { # type: ignore[attr-defined] b._web_multi_copy = {
"id": "persistent_petitioners", "id": "persistent_petitioners",
"name": "Persistent Petitioners", "name": "Persistent Petitioners",
"count": 20, "count": 20,

View file

@ -23,7 +23,7 @@ def test_petitioners_clamp_to_100_and_reduce_creature_slots():
"card_advantage": 8, "protection": 4, "card_advantage": 8, "protection": 4,
} }
# Thread multi-copy selection for Petitioners as a creature archetype # Thread multi-copy selection for Petitioners as a creature archetype
b._web_multi_copy = { # type: ignore[attr-defined] b._web_multi_copy = {
"id": "persistent_petitioners", "id": "persistent_petitioners",
"name": "Persistent Petitioners", "name": "Persistent Petitioners",
"count": 40, # intentionally large to trigger clamp/adjustments "count": 40, # intentionally large to trigger clamp/adjustments

View file

@ -17,7 +17,7 @@ def _minimal_ctx(selection: dict):
b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True) b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True)
# Thread selection and ensure empty library # Thread selection and ensure empty library
b._web_multi_copy = selection # type: ignore[attr-defined] b._web_multi_copy = selection
b.card_library = {} b.card_library = {}
ctx = { ctx = {

View file

@ -1,7 +1,7 @@
import importlib import importlib
import pytest import pytest
try: try:
from starlette.testclient import TestClient # type: ignore from starlette.testclient import TestClient
except Exception: # pragma: no cover - optional dep in CI except Exception: # pragma: no cover - optional dep in CI
TestClient = None # type: ignore TestClient = None # type: ignore

View file

@ -128,7 +128,7 @@ def _make_request(path: str = "/api/partner/suggestions", query_string: str = ""
"client": ("203.0.113.5", 52345), "client": ("203.0.113.5", 52345),
"server": ("testserver", 80), "server": ("testserver", 80),
} }
request = Request(scope, receive=_receive) # type: ignore[arg-type] request = Request(scope, receive=_receive)
request.state.request_id = "req-telemetry" request.state.request_id = "req-telemetry"
return request return request
@ -197,21 +197,21 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey
from code.web.services import orchestrator as orchestrator_service from code.web.services import orchestrator as orchestrator_service
original_default = partner_service.DEFAULT_DATASET_PATH original_default = partner_service.DEFAULT_DATASET_PATH
original_path = partner_service._DATASET_PATH # type: ignore[attr-defined] original_path = partner_service._DATASET_PATH
original_cache = partner_service._DATASET_CACHE # type: ignore[attr-defined] original_cache = partner_service._DATASET_CACHE
original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED # type: ignore[attr-defined] original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED
partner_service.DEFAULT_DATASET_PATH = dataset_path partner_service.DEFAULT_DATASET_PATH = dataset_path
partner_service._DATASET_PATH = dataset_path # type: ignore[attr-defined] partner_service._DATASET_PATH = dataset_path
partner_service._DATASET_CACHE = None # type: ignore[attr-defined] partner_service._DATASET_CACHE = None
partner_service._DATASET_REFRESH_ATTEMPTED = True # type: ignore[attr-defined] partner_service._DATASET_REFRESH_ATTEMPTED = True
calls = {"count": 0} calls = {"count": 0}
payload_path = tmp_path / "seed_dataset.json" payload_path = tmp_path / "seed_dataset.json"
_write_dataset(payload_path) _write_dataset(payload_path)
def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override] def seeded_refresh(out_func=None, *, force=False, root=None):
calls["count"] += 1 calls["count"] += 1
dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8") dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8")
@ -227,9 +227,9 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey
assert calls["count"] == 1 assert calls["count"] == 1
finally: finally:
partner_service.DEFAULT_DATASET_PATH = original_default partner_service.DEFAULT_DATASET_PATH = original_default
partner_service._DATASET_PATH = original_path # type: ignore[attr-defined] partner_service._DATASET_PATH = original_path
partner_service._DATASET_CACHE = original_cache # type: ignore[attr-defined] partner_service._DATASET_CACHE = original_cache
partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted # type: ignore[attr-defined] partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted
try: try:
dataset_path.unlink() dataset_path.unlink()
except FileNotFoundError: except FileNotFoundError:

View file

@ -33,7 +33,7 @@ def _invoke_helper(
) -> list[tuple[list[str], str]]: ) -> list[tuple[list[str], str]]:
calls: list[tuple[list[str], str]] = [] calls: list[tuple[list[str], str]] = []
def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def] def _fake_run(cmd, check=False, cwd=None):
calls.append((list(cmd), cwd)) calls.append((list(cmd), cwd))
class _Completed: class _Completed:
returncode = 0 returncode = 0

View file

@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi")
def load_app_with_env(**env: str) -> types.ModuleType: def load_app_with_env(**env: str) -> types.ModuleType:
for k,v in env.items(): for k,v in env.items():
os.environ[k] = v os.environ[k] = v
import code.web.app as app_module # type: ignore import code.web.app as app_module
importlib.reload(app_module) importlib.reload(app_module)
return app_module return app_module

View file

@ -1,7 +1,7 @@
import json import json
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
def test_preview_includes_curated_examples_regression(): def test_preview_includes_curated_examples_regression():

View file

@ -1,8 +1,8 @@
import os import os
from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore from code.web.services.theme_preview import get_theme_preview, bust_preview_cache
from code.web.services import preview_cache as pc # type: ignore from code.web.services import preview_cache as pc
from code.web.services.preview_metrics import preview_metrics # type: ignore from code.web.services.preview_metrics import preview_metrics
def _prime(slug: str, limit: int = 12, hits: int = 0, *, colors=None): def _prime(slug: str, limit: int = 12, hits: int = 0, *, colors=None):
@ -89,7 +89,7 @@ def test_env_weight_override(monkeypatch):
bust_preview_cache() bust_preview_cache()
# Clear module-level caches for weights # Clear module-level caches for weights
if hasattr(pc, '_EVICT_WEIGHTS_CACHE'): if hasattr(pc, '_EVICT_WEIGHTS_CACHE'):
pc._EVICT_WEIGHTS_CACHE = None # type: ignore pc._EVICT_WEIGHTS_CACHE = None
# Create two entries: one older with many hits, one fresh with none. # Create two entries: one older with many hits, one fresh with none.
_prime('Blink', limit=6, hits=6, colors=None) # older hot entry _prime('Blink', limit=6, hits=6, colors=None) # older hot entry
old_key = next(iter(pc.PREVIEW_CACHE.keys())) old_key = next(iter(pc.PREVIEW_CACHE.keys()))

View file

@ -1,6 +1,6 @@
import os import os
from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore from code.web.services.theme_preview import get_theme_preview, bust_preview_cache
from code.web.services import preview_cache as pc # type: ignore from code.web.services import preview_cache as pc
def test_basic_low_score_eviction(monkeypatch): def test_basic_low_score_eviction(monkeypatch):
@ -17,7 +17,7 @@ def test_basic_low_score_eviction(monkeypatch):
get_theme_preview('Blink', limit=6, colors=c) get_theme_preview('Blink', limit=6, colors=c)
# Cache limit 5, inserted 6 distinct -> eviction should have occurred # Cache limit 5, inserted 6 distinct -> eviction should have occurred
assert len(pc.PREVIEW_CACHE) <= 5 assert len(pc.PREVIEW_CACHE) <= 5
from code.web.services.preview_metrics import preview_metrics # type: ignore from code.web.services.preview_metrics import preview_metrics
m = preview_metrics() m = preview_metrics()
assert m['preview_cache_evictions'] >= 1, 'Expected at least one eviction' assert m['preview_cache_evictions'] >= 1, 'Expected at least one eviction'
assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1 assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1

View file

@ -1,5 +1,5 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
def test_minimal_variant_hides_controls_and_headers(): def test_minimal_variant_hides_controls_and_headers():

View file

@ -8,7 +8,7 @@ pytestmark = pytest.mark.skip(reason="M4: preview_perf_benchmark module removed
def test_fetch_all_theme_slugs_retries(monkeypatch): def test_fetch_all_theme_slugs_retries(monkeypatch):
calls = {"count": 0} calls = {"count": 0}
def fake_fetch(url): # type: ignore[override] def fake_fetch(url):
calls["count"] += 1 calls["count"] += 1
if calls["count"] == 1: if calls["count"] == 1:
raise RuntimeError("transient 500") raise RuntimeError("transient 500")
@ -27,7 +27,7 @@ def test_fetch_all_theme_slugs_retries(monkeypatch):
def test_fetch_all_theme_slugs_page_level_retry(monkeypatch): def test_fetch_all_theme_slugs_page_level_retry(monkeypatch):
calls = {"count": 0} calls = {"count": 0}
def fake_fetch_with_retry(url, attempts=3, delay=0.6): # type: ignore[override] def fake_fetch_with_retry(url, attempts=3, delay=0.6):
calls["count"] += 1 calls["count"] += 1
if calls["count"] < 3: if calls["count"] < 3:
raise RuntimeError("service warming up") raise RuntimeError("service warming up")

View file

@ -1,5 +1,5 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
def test_preview_fragment_suppress_curated_removes_examples(): def test_preview_fragment_suppress_curated_removes_examples():

View file

@ -3,16 +3,16 @@ from code.web.services import preview_cache as pc
def _force_interval_elapsed(): def _force_interval_elapsed():
# Ensure adaptation interval guard passes # Ensure adaptation interval guard passes
if pc._LAST_ADAPT_AT is not None: # type: ignore[attr-defined] if pc._LAST_ADAPT_AT is not None:
pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) # type: ignore[attr-defined] pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1)
def test_ttl_adapts_down_and_up(capsys): def test_ttl_adapts_down_and_up(capsys):
# Enable adaptation regardless of env # Enable adaptation regardless of env
pc._ADAPTATION_ENABLED = True # type: ignore[attr-defined] pc._ADAPTATION_ENABLED = True
pc.TTL_SECONDS = pc._TTL_BASE # type: ignore[attr-defined] pc.TTL_SECONDS = pc._TTL_BASE
pc._RECENT_HITS.clear() # type: ignore[attr-defined] pc._RECENT_HITS.clear()
pc._LAST_ADAPT_AT = None # type: ignore[attr-defined] pc._LAST_ADAPT_AT = None
# Low hit ratio pattern (~0.1) # Low hit ratio pattern (~0.1)
for _ in range(72): for _ in range(72):
@ -23,11 +23,11 @@ def test_ttl_adapts_down_and_up(capsys):
out1 = capsys.readouterr().out out1 = capsys.readouterr().out
assert "theme_preview_ttl_adapt" in out1, "expected adaptation log for low hit ratio" assert "theme_preview_ttl_adapt" in out1, "expected adaptation log for low hit ratio"
ttl_after_down = pc.TTL_SECONDS ttl_after_down = pc.TTL_SECONDS
assert ttl_after_down <= pc._TTL_BASE # type: ignore[attr-defined] assert ttl_after_down <= pc._TTL_BASE
# Force interval elapsed & high hit ratio pattern (~0.9) # Force interval elapsed & high hit ratio pattern (~0.9)
_force_interval_elapsed() _force_interval_elapsed()
pc._RECENT_HITS.clear() # type: ignore[attr-defined] pc._RECENT_HITS.clear()
for _ in range(72): for _ in range(72):
pc.record_request_hit(True) pc.record_request_hit(True)
for _ in range(8): for _ in range(8):

View file

@ -19,17 +19,17 @@ def _client_with_flags(window_s: int = 2, limit_random: int = 2, limit_build: in
# Force fresh import so RATE_LIMIT_* constants reflect env # Force fresh import so RATE_LIMIT_* constants reflect env
sys.modules.pop('code.web.app', None) sys.modules.pop('code.web.app', None)
from code.web import app as app_module # type: ignore from code.web import app as app_module
# Force override constants for deterministic test # Force override constants for deterministic test
try: try:
app_module.RATE_LIMIT_ENABLED = True # type: ignore[attr-defined] app_module.RATE_LIMIT_ENABLED = True
app_module.RATE_LIMIT_WINDOW_S = window_s # type: ignore[attr-defined] app_module.RATE_LIMIT_WINDOW_S = window_s
app_module.RATE_LIMIT_RANDOM = limit_random # type: ignore[attr-defined] app_module.RATE_LIMIT_RANDOM = limit_random
app_module.RATE_LIMIT_BUILD = limit_build # type: ignore[attr-defined] app_module.RATE_LIMIT_BUILD = limit_build
app_module.RATE_LIMIT_SUGGEST = limit_suggest # type: ignore[attr-defined] app_module.RATE_LIMIT_SUGGEST = limit_suggest
# Reset in-memory counters # Reset in-memory counters
if hasattr(app_module, '_RL_COUNTS'): if hasattr(app_module, '_RL_COUNTS'):
app_module._RL_COUNTS.clear() # type: ignore[attr-defined] app_module._RL_COUNTS.clear()
except Exception: except Exception:
pass pass
return TestClient(app_module.app) return TestClient(app_module.app)

View file

@ -3,8 +3,8 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web import app as web_app # type: ignore from code.web import app as web_app
from code.web.app import app # type: ignore from code.web.app import app
# Ensure project root on sys.path for absolute imports # Ensure project root on sys.path for absolute imports
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]

View file

@ -9,17 +9,17 @@ def setup_module(module): # ensure deterministic env weights
def test_rarity_diminishing(): def test_rarity_diminishing():
# Monkeypatch internal index # Monkeypatch internal index
card_index._CARD_INDEX.clear() # type: ignore card_index._CARD_INDEX.clear()
theme = "Test Theme" theme = "Test Theme"
card_index._CARD_INDEX[theme] = [ # type: ignore card_index._CARD_INDEX[theme] = [
{"name": "Mythic One", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"}, {"name": "Mythic One", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"},
{"name": "Mythic Two", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"}, {"name": "Mythic Two", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"},
] ]
def no_build(): def no_build():
return None return None
sampling.maybe_build_index = no_build # type: ignore sampling.maybe_build_index = no_build
cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander=None) cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander=None)
rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] # type: ignore rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")]
assert len(rarity_weights) >= 2 assert len(rarity_weights) >= 2
v1 = float(rarity_weights[0].split(":")[-1]) v1 = float(rarity_weights[0].split(":")[-1])
v2 = float(rarity_weights[1].split(":")[-1]) v2 = float(rarity_weights[1].split(":")[-1])
@ -40,15 +40,15 @@ def test_commander_overlap_monotonic_diminishing():
def test_splash_off_color_penalty_applied(): def test_splash_off_color_penalty_applied():
card_index._CARD_INDEX.clear() # type: ignore card_index._CARD_INDEX.clear()
theme = "Splash Theme" theme = "Splash Theme"
# Commander W U B R (4 colors) # Commander W U B R (4 colors)
commander = {"name": "CommanderTest", "tags": [theme], "color_identity": "WUBR", "mana_cost": "", "rarity": "mythic"} commander = {"name": "CommanderTest", "tags": [theme], "color_identity": "WUBR", "mana_cost": "", "rarity": "mythic"}
# Card with single off-color G (W U B R G) # Card with single off-color G (W U B R G)
splash_card = {"name": "CardSplash", "tags": [theme], "color_identity": "WUBRG", "mana_cost": "G", "rarity": "rare"} splash_card = {"name": "CardSplash", "tags": [theme], "color_identity": "WUBRG", "mana_cost": "G", "rarity": "rare"}
card_index._CARD_INDEX[theme] = [commander, splash_card] # type: ignore card_index._CARD_INDEX[theme] = [commander, splash_card]
sampling.maybe_build_index = lambda: None # type: ignore sampling.maybe_build_index = lambda: None
cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander="CommanderTest") cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander="CommanderTest")
splash = next((c for c in cards if c["name"] == "CardSplash"), None) splash = next((c for c in cards if c["name"] == "CardSplash"), None)
assert splash is not None assert splash is not None
assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) # type: ignore assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"])

View file

@ -1,5 +1,5 @@
import re import re
from code.web.services.theme_preview import get_theme_preview # type: ignore from code.web.services.theme_preview import get_theme_preview
# We can't easily execute the JS normalizeCardName in Python, but we can ensure # We can't easily execute the JS normalizeCardName in Python, but we can ensure
# server-delivered sample names that include appended synergy annotations are not # server-delivered sample names that include appended synergy annotations are not

View file

@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi") # skip if FastAPI missing
def load_app_with_env(**env: str) -> types.ModuleType: def load_app_with_env(**env: str) -> types.ModuleType:
for k, v in env.items(): for k, v in env.items():
os.environ[k] = v os.environ[k] = v
import code.web.app as app_module # type: ignore import code.web.app as app_module
importlib.reload(app_module) importlib.reload(app_module)
return app_module return app_module

View file

@ -2,7 +2,7 @@ import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from code.web.app import app # type: ignore from code.web.app import app
# Ensure project root on sys.path for absolute imports # Ensure project root on sys.path for absolute imports
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]

View file

@ -146,7 +146,7 @@ def test_generate_theme_catalog_basic(tmp_path: Path, fixed_now: datetime) -> No
assert all(row['last_generated_at'] == result.generated_at for row in rows) assert all(row['last_generated_at'] == result.generated_at for row in rows)
assert all(row['version'] == result.version for row in rows) assert all(row['version'] == result.version for row in rows)
expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined] expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows])
assert result.version == expected_hash assert result.version == expected_hash

View file

@ -4,7 +4,7 @@ import os
import importlib import importlib
from pathlib import Path from pathlib import Path
from starlette.testclient import TestClient from starlette.testclient import TestClient
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore from code.type_definitions_theme_catalog import ThemeCatalog
CATALOG_PATH = Path('config/themes/theme_list.json') CATALOG_PATH = Path('config/themes/theme_list.json')

View file

@ -8,7 +8,7 @@ def test_theme_list_json_validates_against_pydantic_and_fast_path():
raw = json.loads(p.read_text(encoding='utf-8')) raw = json.loads(p.read_text(encoding='utf-8'))
# Pydantic validation # Pydantic validation
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore from code.type_definitions_theme_catalog import ThemeCatalog
catalog = ThemeCatalog(**raw) catalog = ThemeCatalog(**raw)
assert isinstance(catalog.themes, list) and len(catalog.themes) > 0 assert isinstance(catalog.themes, list) and len(catalog.themes) > 0
# Basic fields exist on entries # Basic fields exist on entries

View file

@ -36,7 +36,7 @@ from fastapi.testclient import TestClient
def _get_app(): # local import to avoid heavy import cost if file unused def _get_app(): # local import to avoid heavy import cost if file unused
from code.web.app import app # type: ignore from code.web.app import app
return app return app
@ -115,13 +115,13 @@ def test_preview_cache_hit_timing(monkeypatch, client):
r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12") r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert r1.status_code == 200 assert r1.status_code == 200
# Monkeypatch theme_preview._now to freeze time so second call counts as hit # Monkeypatch theme_preview._now to freeze time so second call counts as hit
import code.web.services.theme_preview as tp # type: ignore import code.web.services.theme_preview as tp
orig_now = tp._now orig_now = tp._now
monkeypatch.setattr(tp, "_now", lambda: orig_now()) monkeypatch.setattr(tp, "_now", lambda: orig_now())
r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12") r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert r2.status_code == 200 assert r2.status_code == 200
# Deterministic service-level verification: second direct function call should short-circuit via cache # Deterministic service-level verification: second direct function call should short-circuit via cache
import code.web.services.theme_preview as tp # type: ignore import code.web.services.theme_preview as tp
# Snapshot counters # Snapshot counters
pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0) pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0)
first_payload = tp.get_theme_preview(theme_id, limit=12) first_payload = tp.get_theme_preview(theme_id, limit=12)

View file

@ -16,7 +16,7 @@ def _new_client(prewarm: bool = False) -> TestClient:
# Remove existing module (if any) so lifespan runs again # Remove existing module (if any) so lifespan runs again
if 'code.web.app' in list(importlib.sys.modules.keys()): if 'code.web.app' in list(importlib.sys.modules.keys()):
importlib.sys.modules.pop('code.web.app') importlib.sys.modules.pop('code.web.app')
from code.web.app import app # type: ignore from code.web.app import app
return TestClient(app) return TestClient(app)

View file

@ -2,8 +2,8 @@ from __future__ import annotations
import pytest import pytest
from code.web.services.theme_preview import get_theme_preview # type: ignore from code.web.services.theme_preview import get_theme_preview
from code.web.services.theme_catalog_loader import load_index, slugify, project_detail # type: ignore from code.web.services.theme_catalog_loader import load_index, slugify, project_detail
@pytest.mark.parametrize("limit", [8, 12]) @pytest.mark.parametrize("limit", [8, 12])

View file

@ -1,7 +1,7 @@
import os import os
import time import time
import json import json
from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache
def test_colors_filter_constraint_green_subset(): def test_colors_filter_constraint_green_subset():

View file

@ -47,10 +47,10 @@ class DummySpellBuilder(SpellAdditionMixin):
def rng(self) -> DummyRNG: def rng(self) -> DummyRNG:
return self._rng return self._rng
def get_theme_context(self) -> ThemeContext: # type: ignore[override] def get_theme_context(self) -> ThemeContext:
return self._theme_context return self._theme_context
def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override] def add_card(self, name: str, **kwargs: Any) -> None:
self.card_library[name] = {"Count": kwargs.get("count", 1)} self.card_library[name] = {"Count": kwargs.get("count", 1)}
self.added_cards.append(name) self.added_cards.append(name)

View file

@ -20,7 +20,7 @@ def _fresh_client() -> TestClient:
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
clear_commander_catalog_cache() clear_commander_catalog_cache()
from code.web.app import app # type: ignore from code.web.app import app
client = TestClient(app) client = TestClient(app)
from code.web.services import tasks from code.web.services import tasks

View file

@ -87,7 +87,7 @@ class ThemeCatalog(BaseModel):
def theme_names(self) -> List[str]: # convenience def theme_names(self) -> List[str]: # convenience
return [t.theme for t in self.themes] return [t.theme for t in self.themes]
def model_post_init(self, __context: Any) -> None: # type: ignore[override] def model_post_init(self, __context: Any) -> None:
# If only legacy 'provenance' provided, alias to metadata_info # If only legacy 'provenance' provided, alias to metadata_info
if self.metadata_info is None and self.provenance is not None: if self.metadata_info is None and self.provenance is not None:
object.__setattr__(self, 'metadata_info', self.provenance) object.__setattr__(self, 'metadata_info', self.provenance)
@ -135,7 +135,7 @@ class ThemeYAMLFile(BaseModel):
model_config = ConfigDict(extra='forbid') model_config = ConfigDict(extra='forbid')
def model_post_init(self, __context: Any) -> None: # type: ignore[override] def model_post_init(self, __context: Any) -> None:
if not self.metadata_info and self.provenance: if not self.metadata_info and self.provenance:
object.__setattr__(self, 'metadata_info', self.provenance) object.__setattr__(self, 'metadata_info', self.provenance)
if self.metadata_info and self.provenance: if self.metadata_info and self.provenance:

View file

@ -19,9 +19,12 @@ from contextlib import asynccontextmanager
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics
from tagging.multi_face_merger import load_merge_summary from tagging.multi_face_merger import load_merge_summary
from .services.combo_utils import detect_all as _detect_all from .services.combo_utils import detect_all as _detect_all
from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore from .services.theme_catalog_loader import prewarm_common_filters, load_index
from .services.commander_catalog_loader import load_commander_catalog # type: ignore from .services.commander_catalog_loader import load_commander_catalog
from .services.tasks import get_session, new_sid, set_session_value # type: ignore from .services.tasks import get_session, new_sid, set_session_value
# Logger for app-level logging
logger = logging.getLogger(__name__)
# Resolve template/static dirs relative to this file # Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent _THIS_DIR = Path(__file__).resolve().parent
@ -53,18 +56,18 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
except Exception: except Exception:
pass pass
try: try:
commanders_routes.prewarm_default_page() # type: ignore[attr-defined] commanders_routes.prewarm_default_page()
except Exception: except Exception:
pass pass
# Warm preview card index once (updated Phase A: moved to card_index module) # Warm preview card index once (updated Phase A: moved to card_index module)
try: # local import to avoid cost if preview unused try: # local import to avoid cost if preview unused
from .services.card_index import maybe_build_index # type: ignore from .services.card_index import maybe_build_index
maybe_build_index() maybe_build_index()
except Exception: except Exception:
pass pass
# Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing) # Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing)
try: try:
from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore from .routes.card_browser import get_theme_catalog, get_theme_index
get_theme_catalog() # Fast: just reads CSV get_theme_catalog() # Fast: just reads CSV
get_theme_index() # Slower: parses cards for theme-to-card mapping get_theme_index() # Slower: parses cards for theme-to-card mapping
except Exception: except Exception:
@ -73,7 +76,7 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
try: try:
from code.settings import ENABLE_CARD_DETAILS from code.settings import ENABLE_CARD_DETAILS
if ENABLE_CARD_DETAILS: if ENABLE_CARD_DETAILS:
from .routes.card_browser import get_similarity # type: ignore from .routes.card_browser import get_similarity
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
except Exception: except Exception:
pass pass
@ -86,7 +89,7 @@ app.add_middleware(GZipMiddleware, minimum_size=500)
# Mount static if present # Mount static if present
if _STATIC_DIR.exists(): if _STATIC_DIR.exists():
class CacheStatic(StaticFiles): class CacheStatic(StaticFiles):
async def get_response(self, path, scope): # type: ignore[override] async def get_response(self, path, scope):
resp = await super().get_response(path, scope) resp = await super().get_response(path, scope)
try: try:
# Add basic cache headers for static assets # Add basic cache headers for static assets
@ -99,12 +102,38 @@ if _STATIC_DIR.exists():
# Jinja templates # Jinja templates
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
# Add custom Jinja2 filter for card image URLs
def card_image_url(card_name: str, size: str = "normal") -> str:
"""
Generate card image URL (uses local cache if available, falls back to Scryfall).
For DFC cards (containing ' // '), extracts the front face name.
Args:
card_name: Name of the card (may be "Front // Back" for DFCs)
size: Image size ('small' or 'normal')
Returns:
URL for the card image
"""
from urllib.parse import quote
# Extract front face name for DFCs (thumbnails always show front face)
display_name = card_name
if ' // ' in card_name:
display_name = card_name.split(' // ')[0].strip()
# Use our API endpoint which handles cache lookup and fallback
return f"/api/images/{size}/{quote(display_name)}"
templates.env.filters["card_image"] = card_image_url
# Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...}) # Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...})
# and reorder to the new signature TemplateResponse(request, name, {...}). # and reorder to the new signature TemplateResponse(request, name, {...}).
# Prevents DeprecationWarning noise in tests without touching all call sites. # Prevents DeprecationWarning noise in tests without touching all call sites.
_orig_template_response = templates.TemplateResponse _orig_template_response = templates.TemplateResponse
def _compat_template_response(*args, **kwargs): # type: ignore[override] def _compat_template_response(*args, **kwargs):
try: try:
if args and isinstance(args[0], str): if args and isinstance(args[0], str):
name = args[0] name = args[0]
@ -122,7 +151,7 @@ def _compat_template_response(*args, **kwargs): # type: ignore[override]
pass pass
return _orig_template_response(*args, **kwargs) return _orig_template_response(*args, **kwargs)
templates.TemplateResponse = _compat_template_response # type: ignore[assignment] templates.TemplateResponse = _compat_template_response
# (Startup prewarm moved to lifespan handler _lifespan) # (Startup prewarm moved to lifespan handler _lifespan)
@ -298,7 +327,7 @@ templates.env.globals.update({
# Expose catalog hash (for cache versioning / service worker) best-effort, fallback to 'dev' # Expose catalog hash (for cache versioning / service worker) best-effort, fallback to 'dev'
def _load_catalog_hash() -> str: def _load_catalog_hash() -> str:
try: # local import to avoid circular on early load try: # local import to avoid circular on early load
from .services.theme_catalog_loader import CATALOG_JSON # type: ignore from .services.theme_catalog_loader import CATALOG_JSON
if CATALOG_JSON.exists(): if CATALOG_JSON.exists():
raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}") raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}")
meta = raw.get("metadata_info") or {} meta = raw.get("metadata_info") or {}
@ -840,6 +869,12 @@ async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")}) return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
@app.get("/docs/components", response_class=HTMLResponse)
async def components_library(request: Request) -> HTMLResponse:
"""M2 Component Library - showcase of standardized UI components"""
return templates.TemplateResponse("docs/components.html", {"request": request})
# Simple health check (hardened) # Simple health check (hardened)
@app.get("/healthz") @app.get("/healthz")
async def healthz(): async def healthz():
@ -916,7 +951,7 @@ async def status_random_theme_stats():
if not SHOW_DIAGNOSTICS: if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found") raise HTTPException(status_code=404, detail="Not Found")
try: try:
from deck_builder.random_entrypoint import get_theme_tag_stats # type: ignore from deck_builder.random_entrypoint import get_theme_tag_stats
stats = get_theme_tag_stats() stats = get_theme_tag_stats()
return JSONResponse({"ok": True, "stats": stats}) return JSONResponse({"ok": True, "stats": stats})
@ -1003,8 +1038,8 @@ async def api_random_build(request: Request):
except Exception: except Exception:
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
# Import on-demand to avoid heavy costs at module import time # Import on-demand to avoid heavy costs at module import time
from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError
from deck_builder.random_entrypoint import RandomThemeNoMatchError # type: ignore from deck_builder.random_entrypoint import RandomThemeNoMatchError
res = build_random_deck( res = build_random_deck(
theme=theme, theme=theme,
@ -1135,7 +1170,7 @@ async def api_random_full_build(request: Request):
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
# Build a full deck deterministically # Build a full deck deterministically
from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError
res = build_random_full_deck( res = build_random_full_deck(
theme=theme, theme=theme,
constraints=constraints, constraints=constraints,
@ -1359,7 +1394,7 @@ async def api_random_reroll(request: Request):
except Exception: except Exception:
new_seed = None new_seed = None
if new_seed is None: if new_seed is None:
from random_util import generate_seed # type: ignore from random_util import generate_seed
new_seed = int(generate_seed()) new_seed = int(generate_seed())
# Build with the new seed # Build with the new seed
@ -1370,7 +1405,7 @@ async def api_random_reroll(request: Request):
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS)) attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore from deck_builder.random_entrypoint import build_random_full_deck
res = build_random_full_deck( res = build_random_full_deck(
theme=theme, theme=theme,
constraints=constraints, constraints=constraints,
@ -1751,10 +1786,10 @@ async def hx_random_reroll(request: Request):
except Exception: except Exception:
new_seed = None new_seed = None
if new_seed is None: if new_seed is None:
from random_util import generate_seed # type: ignore from random_util import generate_seed
new_seed = int(generate_seed()) new_seed = int(generate_seed())
# Import outside conditional to avoid UnboundLocalError when branch not taken # Import outside conditional to avoid UnboundLocalError when branch not taken
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore from deck_builder.random_entrypoint import build_random_full_deck
try: try:
t0 = time.time() t0 = time.time()
_attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS) _attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS)
@ -1765,7 +1800,7 @@ async def hx_random_reroll(request: Request):
_timeout_s = max(0.1, float(_timeout_ms) / 1000.0) _timeout_s = max(0.1, float(_timeout_ms) / 1000.0)
if is_reroll_same: if is_reroll_same:
build_t0 = time.time() build_t0 = time.time()
from headless_runner import run as _run # type: ignore from headless_runner import run as _run
# Suppress builder's internal initial export to control artifact generation (matches full random path logic) # Suppress builder's internal initial export to control artifact generation (matches full random path logic)
try: try:
import os as _os import os as _os
@ -1778,18 +1813,18 @@ async def hx_random_reroll(request: Request):
summary = None summary = None
try: try:
if hasattr(builder, 'build_deck_summary'): if hasattr(builder, 'build_deck_summary'):
summary = builder.build_deck_summary() # type: ignore[attr-defined] summary = builder.build_deck_summary()
except Exception: except Exception:
summary = None summary = None
decklist = [] decklist = []
try: try:
if hasattr(builder, 'deck_list_final'): if hasattr(builder, 'deck_list_final'):
decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined] decklist = getattr(builder, 'deck_list_final')
except Exception: except Exception:
decklist = [] decklist = []
# Controlled artifact export (single pass) # Controlled artifact export (single pass)
csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] csv_path = getattr(builder, 'last_csv_path', None)
txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] txt_path = getattr(builder, 'last_txt_path', None)
compliance = None compliance = None
try: try:
import os as _os import os as _os
@ -1797,7 +1832,7 @@ async def hx_random_reroll(request: Request):
# Perform exactly one export sequence now # Perform exactly one export sequence now
if not csv_path and hasattr(builder, 'export_decklist_csv'): if not csv_path and hasattr(builder, 'export_decklist_csv'):
try: try:
csv_path = builder.export_decklist_csv() # type: ignore[attr-defined] csv_path = builder.export_decklist_csv()
except Exception: except Exception:
csv_path = None csv_path = None
if csv_path and isinstance(csv_path, str): if csv_path and isinstance(csv_path, str):
@ -1807,7 +1842,7 @@ async def hx_random_reroll(request: Request):
try: try:
base_name = _os.path.basename(base_path) + '.txt' base_name = _os.path.basename(base_path) + '.txt'
if hasattr(builder, 'export_decklist_text'): if hasattr(builder, 'export_decklist_text'):
txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined] txt_path = builder.export_decklist_text(filename=base_name)
except Exception: except Exception:
# Fallback: if a txt already exists from a prior build reuse it # Fallback: if a txt already exists from a prior build reuse it
if _os.path.isfile(base_path + '.txt'): if _os.path.isfile(base_path + '.txt'):
@ -1822,7 +1857,7 @@ async def hx_random_reroll(request: Request):
else: else:
try: try:
if hasattr(builder, 'compute_and_print_compliance'): if hasattr(builder, 'compute_and_print_compliance'):
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path))
except Exception: except Exception:
compliance = None compliance = None
if summary: if summary:
@ -2016,7 +2051,7 @@ async def hx_random_reroll(request: Request):
except Exception: except Exception:
_permalink = None _permalink = None
resp = templates.TemplateResponse( resp = templates.TemplateResponse(
"partials/random_result.html", # type: ignore "partials/random_result.html",
{ {
"request": request, "request": request,
"seed": int(res.seed), "seed": int(res.seed),
@ -2212,6 +2247,13 @@ async def setup_status():
return JSONResponse({"running": False, "phase": "error"}) return JSONResponse({"running": False, "phase": "error"})
# ============================================================================
# Card Image Serving Endpoint - MOVED TO /routes/api.py
# ============================================================================
# Image serving logic has been moved to code/web/routes/api.py
# The router is included below via: app.include_router(api_routes.router)
# Routers # Routers
from .routes import build as build_routes # noqa: E402 from .routes import build as build_routes # noqa: E402
from .routes import configs as config_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402
@ -2225,6 +2267,7 @@ from .routes import telemetry as telemetry_routes # noqa: E402
from .routes import cards as cards_routes # noqa: E402 from .routes import cards as cards_routes # noqa: E402
from .routes import card_browser as card_browser_routes # noqa: E402 from .routes import card_browser as card_browser_routes # noqa: E402
from .routes import compare as compare_routes # noqa: E402 from .routes import compare as compare_routes # noqa: E402
from .routes import api as api_routes # noqa: E402
app.include_router(build_routes.router) app.include_router(build_routes.router)
app.include_router(config_routes.router) app.include_router(config_routes.router)
app.include_router(decks_routes.router) app.include_router(decks_routes.router)
@ -2237,6 +2280,7 @@ app.include_router(telemetry_routes.router)
app.include_router(cards_routes.router) app.include_router(cards_routes.router)
app.include_router(card_browser_routes.router) app.include_router(card_browser_routes.router)
app.include_router(compare_routes.router) app.include_router(compare_routes.router)
app.include_router(api_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev # Warm validation cache early to reduce first-call latency in tests and dev
try: try:
@ -2423,7 +2467,7 @@ async def logs_page(
# Respect feature flag # Respect feature flag
raise HTTPException(status_code=404, detail="Not Found") raise HTTPException(status_code=404, detail="Not Found")
# Reuse status_logs logic # Reuse status_logs logic
data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type] data = await status_logs(tail=tail, q=q, level=level)
lines: list[str] lines: list[str]
if isinstance(data, JSONResponse): if isinstance(data, JSONResponse):
payload = data.body payload = data.body

299
code/web/routes/api.py Normal file
View file

@ -0,0 +1,299 @@
"""API endpoints for web services."""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from urllib.parse import quote_plus
from fastapi import APIRouter, Query
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from code.file_setup.image_cache import ImageCache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Global image cache instance
_image_cache = ImageCache()
@router.get("/images/status")
async def get_download_status():
"""
Get current image download status.
Returns:
JSON response with download status
"""
import json
status_file = Path("card_files/images/.download_status.json")
if not status_file.exists():
# Check cache statistics if no download in progress
stats = _image_cache.cache_statistics()
return JSONResponse({
"running": False,
"stats": stats
})
try:
with status_file.open('r', encoding='utf-8') as f:
status = json.load(f)
return JSONResponse(status)
except Exception as e:
logger.warning(f"Could not read status file: {e}")
return JSONResponse({
"running": False,
"error": str(e)
})
@router.get("/images/debug")
async def get_image_debug():
"""
Debug endpoint to check image cache configuration.
Returns:
JSON with debug information
"""
import os
from pathlib import Path
base_dir = Path(_image_cache.base_dir)
debug_info = {
"cache_enabled": _image_cache.is_enabled(),
"env_var": os.getenv("CACHE_CARD_IMAGES", "not set"),
"base_dir": str(base_dir),
"base_dir_exists": base_dir.exists(),
"small_dir": str(base_dir / "small"),
"small_dir_exists": (base_dir / "small").exists(),
"normal_dir": str(base_dir / "normal"),
"normal_dir_exists": (base_dir / "normal").exists(),
}
# Count files if directories exist
if (base_dir / "small").exists():
debug_info["small_count"] = len(list((base_dir / "small").glob("*.jpg")))
if (base_dir / "normal").exists():
debug_info["normal_count"] = len(list((base_dir / "normal").glob("*.jpg")))
# Test with a sample card name
test_card = "Lightning Bolt"
debug_info["test_card"] = test_card
test_path_small = _image_cache.get_image_path(test_card, "small")
test_path_normal = _image_cache.get_image_path(test_card, "normal")
debug_info["test_path_small"] = str(test_path_small) if test_path_small else None
debug_info["test_path_normal"] = str(test_path_normal) if test_path_normal else None
debug_info["test_exists_small"] = test_path_small.exists() if test_path_small else False
debug_info["test_exists_normal"] = test_path_normal.exists() if test_path_normal else False
return JSONResponse(debug_info)
@router.get("/images/{size}/{card_name}")
async def get_card_image(size: str, card_name: str, face: str = Query(default="front")):
"""
Serve card image from cache or redirect to Scryfall API.
Args:
size: Image size ('small' or 'normal')
card_name: Name of the card
face: Which face to show ('front' or 'back') for DFC cards
Returns:
FileResponse if cached locally, RedirectResponse to Scryfall API otherwise
"""
# Validate size parameter
if size not in ["small", "normal"]:
size = "normal"
# Check if caching is enabled
cache_enabled = _image_cache.is_enabled()
# Check if image exists in cache
if cache_enabled:
image_path = None
# For DFC cards, handle front/back faces differently
if " // " in card_name:
if face == "back":
# For back face, ONLY try the back face name
back_face = card_name.split(" // ")[1].strip()
logger.debug(f"DFC back face requested: {back_face}")
image_path = _image_cache.get_image_path(back_face, size)
else:
# For front face (or unspecified), try front face name
front_face = card_name.split(" // ")[0].strip()
logger.debug(f"DFC front face requested: {front_face}")
image_path = _image_cache.get_image_path(front_face, size)
else:
# Single-faced card, try exact name
image_path = _image_cache.get_image_path(card_name, size)
if image_path and image_path.exists():
logger.info(f"Serving cached image: {card_name} ({size}, {face})")
return FileResponse(
image_path,
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=31536000", # 1 year
}
)
else:
logger.debug(f"No cached image found for: {card_name} (face: {face})")
# Fallback to Scryfall API
# For back face requests of DFC cards, we need the full card name
scryfall_card_name = card_name
scryfall_params = f"fuzzy={quote_plus(scryfall_card_name)}&format=image&version={size}"
# If this is a back face request, try to find the full DFC name
if face == "back":
try:
from code.services.all_cards_loader import AllCardsLoader
loader = AllCardsLoader()
df = loader.load()
# Look for cards where this face name appears in the card_faces
# The card name format is "Front // Back"
matching = df[df['name'].str.contains(card_name, case=False, na=False, regex=False)]
if not matching.empty:
# Find DFC cards (containing ' // ')
dfc_matches = matching[matching['name'].str.contains(' // ', na=False, regex=False)]
if not dfc_matches.empty:
# Use the first matching DFC card's full name
full_name = dfc_matches.iloc[0]['name']
scryfall_card_name = full_name
# Add face parameter to Scryfall request
scryfall_params = f"exact={quote_plus(full_name)}&format=image&version={size}&face=back"
except Exception as e:
logger.warning(f"Could not lookup full card name for back face '{card_name}': {e}")
scryfall_url = f"https://api.scryfall.com/cards/named?{scryfall_params}"
return RedirectResponse(scryfall_url)
@router.post("/images/download")
async def download_images():
"""
Start downloading card images in background.
Returns:
JSON response with status
"""
if not _image_cache.is_enabled():
return JSONResponse({
"ok": False,
"message": "Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable."
}, status_code=400)
# Write initial status
try:
status_dir = Path("card_files/images")
status_dir.mkdir(parents=True, exist_ok=True)
status_file = status_dir / ".download_status.json"
import json
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": True,
"phase": "bulk_data",
"message": "Downloading Scryfall bulk data...",
"current": 0,
"total": 0,
"percentage": 0
}, f)
except Exception as e:
logger.warning(f"Could not write initial status: {e}")
# Start download in background thread
def _download_task():
import json
status_file = Path("card_files/images/.download_status.json")
try:
# Download bulk data first
logger.info("[IMAGE DOWNLOAD] Starting bulk data download...")
def bulk_progress(downloaded: int, total: int):
"""Progress callback for bulk data download."""
try:
percentage = int(downloaded / total * 100) if total > 0 else 0
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": True,
"phase": "bulk_data",
"message": f"Downloading bulk data: {percentage}%",
"current": downloaded,
"total": total,
"percentage": percentage
}, f)
except Exception as e:
logger.warning(f"Could not update bulk progress: {e}")
_image_cache.download_bulk_data(progress_callback=bulk_progress)
# Download images
logger.info("[IMAGE DOWNLOAD] Starting image downloads...")
def image_progress(current: int, total: int, card_name: str):
"""Progress callback for image downloads."""
try:
percentage = int(current / total * 100) if total > 0 else 0
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": True,
"phase": "images",
"message": f"Downloading images: {card_name}",
"current": current,
"total": total,
"percentage": percentage
}, f)
# Log progress every 100 cards
if current % 100 == 0:
logger.info(f"[IMAGE DOWNLOAD] Progress: {current}/{total} ({percentage}%)")
except Exception as e:
logger.warning(f"Could not update image progress: {e}")
stats = _image_cache.download_images(progress_callback=image_progress)
# Write completion status
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": False,
"phase": "complete",
"message": f"Download complete: {stats.get('downloaded', 0)} new images",
"stats": stats,
"percentage": 100
}, f)
logger.info(f"[IMAGE DOWNLOAD] Complete: {stats}")
except Exception as e:
logger.error(f"[IMAGE DOWNLOAD] Failed: {e}", exc_info=True)
try:
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": False,
"phase": "error",
"message": f"Download failed: {str(e)}",
"percentage": 0
}, f)
except Exception:
pass
# Start background thread
thread = threading.Thread(target=_download_task, daemon=True)
thread.start()
return JSONResponse({
"ok": True,
"message": "Image download started in background"
}, status_code=202)

View file

@ -25,11 +25,12 @@ from ..services.build_utils import (
owned_set as owned_set_helper, owned_set as owned_set_helper,
builder_present_names, builder_present_names,
builder_display_map, builder_display_map,
commander_hover_context,
) )
from ..app import templates from ..app import templates
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from ..services import orchestrator as orch from ..services import orchestrator as orch
from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale
from ..services.build_utils import owned_names as owned_names_helper from ..services.build_utils import owned_names as owned_names_helper
from ..services.tasks import get_session, new_sid from ..services.tasks import get_session, new_sid
from html import escape as _esc from html import escape as _esc
@ -118,7 +119,7 @@ def _available_cards_normalized() -> tuple[set[str], dict[str, str]]:
from deck_builder.include_exclude_utils import normalize_punctuation from deck_builder.include_exclude_utils import normalize_punctuation
except Exception: except Exception:
# Fallback: identity normalization # Fallback: identity normalization
def normalize_punctuation(x: str) -> str: # type: ignore def normalize_punctuation(x: str) -> str:
return str(x).strip().casefold() return str(x).strip().casefold()
norm_map: dict[str, str] = {} norm_map: dict[str, str] = {}
for name in names: for name in names:
@ -469,7 +470,7 @@ def _background_options_from_commander_catalog() -> list[dict[str, Any]]:
seen: set[str] = set() seen: set[str] = set()
options: list[dict[str, Any]] = [] options: list[dict[str, Any]] = []
for record in getattr(catalog, "entries", ()): # type: ignore[attr-defined] for record in getattr(catalog, "entries", ()):
if not getattr(record, "is_background", False): if not getattr(record, "is_background", False):
continue continue
name = getattr(record, "display_name", None) name = getattr(record, "display_name", None)
@ -1107,6 +1108,8 @@ async def build_index(request: Request) -> HTMLResponse:
if q_commander: if q_commander:
# Persist a human-friendly commander name into session for the wizard # Persist a human-friendly commander name into session for the wizard
sess["commander"] = str(q_commander) sess["commander"] = str(q_commander)
# Set flag to indicate this is a quick-build scenario
sess["quick_build"] = True
except Exception: except Exception:
pass pass
return_url = None return_url = None
@ -1146,12 +1149,17 @@ async def build_index(request: Request) -> HTMLResponse:
last_step = 2 last_step = 2
else: else:
last_step = 1 last_step = 1
# Only pass commander to template if coming from commander browser (?commander= query param)
# This prevents stale commander from being pre-filled on subsequent builds
# The query param only exists on initial navigation from commander browser
should_auto_fill = q_commander is not None
resp = templates.TemplateResponse( resp = templates.TemplateResponse(
request, request,
"build/index.html", "build/index.html",
{ {
"sid": sid, "sid": sid,
"commander": sess.get("commander"), "commander": sess.get("commander") if should_auto_fill else None,
"tags": sess.get("tags", []), "tags": sess.get("tags", []),
"name": sess.get("custom_export_base"), "name": sess.get("custom_export_base"),
"last_step": last_step, "last_step": last_step,
@ -1349,6 +1357,19 @@ async def build_new_modal(request: Request) -> HTMLResponse:
for key in skip_keys: for key in skip_keys:
sess.pop(key, None) sess.pop(key, None)
# M2: Check if this is a quick-build scenario (from commander browser)
# Use the quick_build flag set by /build route when ?commander= param present
is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
# M2: Clear commander and form selections for fresh start (unless quick build)
if not is_quick_build:
commander_keys = [
"commander", "partner", "background", "commander_mode",
"themes", "bracket"
]
for key in commander_keys:
sess.pop(key, None)
theme_context = _custom_theme_context(request, sess) theme_context = _custom_theme_context(request, sess)
ctx = { ctx = {
"request": request, "request": request,
@ -1361,6 +1382,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"enable_batch_build": ENABLE_BATCH_BUILD, "enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"form": { "form": {
"commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")), "prefer_combos": bool(sess.get("prefer_combos")),
"combo_count": sess.get("combo_target_count"), "combo_count": sess.get("combo_target_count"),
"combo_balance": sess.get("combo_balance"), "combo_balance": sess.get("combo_balance"),
@ -1483,20 +1505,14 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
merged_tags.append(token) merged_tags.append(token)
ctx["tags"] = merged_tags ctx["tags"] = merged_tags
# Deduplicate recommended: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
existing_recommended = ctx.get("recommended") or [] existing_recommended = ctx.get("recommended") or []
merged_recommended: list[str] = [] deduplicated_recommended = [
rec_seen: set[str] = set() tag for tag in existing_recommended
for source in (partner_tags, existing_recommended): if str(tag).strip().casefold() not in partner_tags_lower
for tag in source: ]
token = str(tag).strip() ctx["recommended"] = deduplicated_recommended
if not token:
continue
key = token.casefold()
if key in rec_seen:
continue
rec_seen.add(key)
merged_recommended.append(token)
ctx["recommended"] = merged_recommended
reason_map = dict(ctx.get("recommended_reasons") or {}) reason_map = dict(ctx.get("recommended_reasons") or {})
for tag in partner_tags: for tag in partner_tags:
@ -2849,7 +2865,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
snap = h.get("snapshot") snap = h.get("snapshot")
break break
if snap is not None: if snap is not None:
orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined] orch._restore_builder(ctx["builder"], snap)
ctx["idx"] = int(target_i) - 1 ctx["idx"] = int(target_i) - 1
ctx["last_visible_idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1
except Exception: except Exception:
@ -2907,6 +2923,11 @@ async def build_step2_get(request: Request) -> HTMLResponse:
if is_gc and (sel_br is None or int(sel_br) < 3): if is_gc and (sel_br is None or int(sel_br) < 3):
sel_br = 3 sel_br = 3
partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS) partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS)
import logging
logger = logging.getLogger(__name__)
logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
context = { context = {
"request": request, "request": request,
"commander": {"name": commander}, "commander": {"name": commander},
@ -2940,7 +2961,22 @@ async def build_step2_get(request: Request) -> HTMLResponse:
) )
partner_tags = context.pop("partner_theme_tags", None) partner_tags = context.pop("partner_theme_tags", None)
if partner_tags: if partner_tags:
import logging
logger = logging.getLogger(__name__)
context["tags"] = partner_tags context["tags"] = partner_tags
# Deduplicate recommended tags: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
original_recommended = context.get("recommended", [])
deduplicated_recommended = [
tag for tag in original_recommended
if str(tag).strip().casefold() not in partner_tags_lower
]
logger.info(
f"Step2: partner_tags={len(partner_tags)}, "
f"original_recommended={len(original_recommended)}, "
f"deduplicated_recommended={len(deduplicated_recommended)}"
)
context["recommended"] = deduplicated_recommended
resp = templates.TemplateResponse("build/_step2.html", context) resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -3266,6 +3302,57 @@ async def build_step3_get(request: Request) -> HTMLResponse:
sess["last_step"] = 3 sess["last_step"] = 3
defaults = orch.ideal_defaults() defaults = orch.ideal_defaults()
values = sess.get("ideals") or defaults values = sess.get("ideals") or defaults
# Check if any skip flags are enabled to show skeleton automation page
skip_flags = {
"skip_lands": "land selection",
"skip_to_misc": "land selection",
"skip_basics": "basic lands",
"skip_staples": "staple lands",
"skip_kindred": "kindred lands",
"skip_fetches": "fetch lands",
"skip_duals": "dual lands",
"skip_triomes": "triome lands",
"skip_all_creatures": "creature selection",
"skip_creature_primary": "primary creatures",
"skip_creature_secondary": "secondary creatures",
"skip_creature_fill": "creature fills",
"skip_all_spells": "spell selection",
"skip_ramp": "ramp spells",
"skip_removal": "removal spells",
"skip_wipes": "board wipes",
"skip_card_advantage": "card advantage spells",
"skip_protection": "protection spells",
"skip_spell_fill": "spell fills",
}
active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)]
if active_skips:
# Show skeleton automation page with auto-submit
automation_parts = []
if any("land" in s for s in active_skips):
automation_parts.append("lands")
if any("creature" in s for s in active_skips):
automation_parts.append("creatures")
if any("spell" in s for s in active_skips):
automation_parts.append("spells")
automation_message = f"Applying default values for {', '.join(automation_parts)}..."
resp = templates.TemplateResponse(
"build/_step3_skeleton.html",
{
"request": request,
"defaults": defaults,
"commander": sess.get("commander"),
"automation_message": automation_message,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# No skips enabled, show normal form
resp = templates.TemplateResponse( resp = templates.TemplateResponse(
"build/_step3.html", "build/_step3.html",
{ {
@ -3782,7 +3869,7 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
if not ctx or not ctx.get("snapshot"): if not ctx or not ctx.get("snapshot"):
return await build_step5_get(request) return await build_step5_get(request)
try: try:
orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] orch._restore_builder(ctx["builder"], ctx["snapshot"])
except Exception: except Exception:
return await build_step5_get(request) return await build_step5_get(request)
# Re-render step 5 with cleared added list # Re-render step 5 with cleared added list
@ -3844,6 +3931,16 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe
ctx["synergies"] = synergies ctx["synergies"] = synergies
ctx["summary_ready"] = True ctx["summary_ready"] = True
ctx["summary_token"] = active_token ctx["summary_token"] = active_token
# Add commander hover context for color identity and theme tags
hover_meta = commander_hover_context(
commander_name=ctx.get("commander"),
deck_tags=sess.get("tags"),
summary=summary_data,
combined=ctx.get("combined_commander"),
)
ctx.update(hover_meta)
response = templates.TemplateResponse("partials/deck_summary.html", ctx) response = templates.TemplateResponse("partials/deck_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax") response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response return response
@ -4196,7 +4293,7 @@ async def build_alternatives(
try: try:
if rng is not None: if rng is not None:
return rng.sample(seq, limit) if len(seq) >= limit else list(seq) return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
import random as _rnd # type: ignore import random as _rnd
return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq) return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
except Exception: except Exception:
return list(seq[:limit]) return list(seq[:limit])
@ -4247,7 +4344,7 @@ async def build_alternatives(
# Helper: map display names # Helper: map display names
def _display_map_for(lower_pool: set[str]) -> dict[str, str]: def _display_map_for(lower_pool: set[str]) -> dict[str, str]:
try: try:
return builder_display_map(b, lower_pool) # type: ignore[arg-type] return builder_display_map(b, lower_pool)
except Exception: except Exception:
return {nm: nm for nm in lower_pool} return {nm: nm for nm in lower_pool}
@ -4425,7 +4522,7 @@ async def build_alternatives(
pass pass
# Sort by priority like the builder # Sort by priority like the builder
try: try:
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"])
except Exception: except Exception:
pass pass
# Exclusions and ownership (for non-random roles this stays before slicing) # Exclusions and ownership (for non-random roles this stays before slicing)
@ -4923,13 +5020,13 @@ async def build_compliance_panel(request: Request) -> HTMLResponse:
comp = None comp = None
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] comp = b.compute_and_print_compliance(base_stem=None)
except Exception: except Exception:
comp = None comp = None
try: try:
if comp: if comp:
from ..services import orchestrator as orch from ..services import orchestrator as orch
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] comp = orch._attach_enforcement_plan(b, comp)
except Exception: except Exception:
pass pass
if not comp: if not comp:
@ -5054,11 +5151,11 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
# If missing, export once to establish base # If missing, export once to establish base
if not base_stem: if not base_stem:
try: try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] ctx["csv_path"] = b.export_decklist_csv()
import os as _os import os as _os
base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0]
# Also produce a text export for completeness # Also produce a text export for completeness
ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt')
except Exception: except Exception:
base_stem = None base_stem = None
# Add lock placeholders into the library before enforcement so user choices are present # Add lock placeholders into the library before enforcement so user choices are present
@ -5103,7 +5200,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
pass pass
# Run enforcement + re-exports (tops up to 100 internally) # Run enforcement + re-exports (tops up to 100 internally)
try: try:
rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto')
except Exception as e: except Exception as e:
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp = templates.TemplateResponse("build/_step5.html", err_ctx)
@ -5177,13 +5274,13 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
comp = None comp = None
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] comp = b.compute_and_print_compliance(base_stem=None)
except Exception: except Exception:
comp = None comp = None
try: try:
if comp: if comp:
from ..services import orchestrator as orch from ..services import orchestrator as orch
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] comp = orch._attach_enforcement_plan(b, comp)
except Exception: except Exception:
pass pass
try: try:

View file

@ -425,7 +425,7 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s
mt_val = str(int(mt)) mt_val = str(int(mt))
except Exception: except Exception:
mt_val = "0" mt_val = "0"
options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type] options.append({"name": it.get("name"), "label": label, "mtime": mt_val})
diffs = None diffs = None
metaA: Dict[str, str] = {} metaA: Dict[str, str] = {}

View file

@ -7,7 +7,7 @@ from pathlib import Path
import json as _json import json as _json
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from ..app import templates from ..app import templates
from ..services.orchestrator import _ensure_setup_ready # type: ignore from ..services.orchestrator import _ensure_setup_ready
router = APIRouter(prefix="/setup") router = APIRouter(prefix="/setup")
@ -21,7 +21,7 @@ def _kickoff_setup_async(force: bool = False):
def runner(): def runner():
try: try:
print(f"[SETUP THREAD] Starting setup/tagging (force={force})...") print(f"[SETUP THREAD] Starting setup/tagging (force={force})...")
_ensure_setup_ready(print, force=force) # type: ignore[arg-type] _ensure_setup_ready(print, force=force)
print("[SETUP THREAD] Setup/tagging completed successfully") print("[SETUP THREAD] Setup/tagging completed successfully")
except Exception as e: # pragma: no cover - background best effort except Exception as e: # pragma: no cover - background best effort
try: try:
@ -36,7 +36,7 @@ def _kickoff_setup_async(force: bool = False):
@router.get("/running", response_class=HTMLResponse) @router.get("/running", response_class=HTMLResponse)
async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override] async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse:
# Optionally start the setup/tagging in the background if requested # Optionally start the setup/tagging in the background if requested
try: try:
if start and int(start) != 0: if start and int(start) != 0:
@ -195,7 +195,11 @@ async def download_github():
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def setup_index(request: Request) -> HTMLResponse: async def setup_index(request: Request) -> HTMLResponse:
import code.settings as settings import code.settings as settings
from code.file_setup.image_cache import ImageCache
image_cache = ImageCache()
return templates.TemplateResponse("setup/index.html", { return templates.TemplateResponse("setup/index.html", {
"request": request, "request": request,
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
"image_cache_enabled": image_cache.is_enabled()
}) })

View file

@ -7,7 +7,7 @@ from typing import Optional, Dict, Any
from fastapi import APIRouter, Request, HTTPException, Query from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment
from fastapi.responses import JSONResponse, HTMLResponse from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from ..services.theme_catalog_loader import ( from ..services.theme_catalog_loader import (
@ -17,10 +17,10 @@ from ..services.theme_catalog_loader import (
filter_slugs_fast, filter_slugs_fast,
summaries_for_slugs, summaries_for_slugs,
) )
from ..services.theme_preview import get_theme_preview # type: ignore from ..services.theme_preview import get_theme_preview
from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters # type: ignore from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters
from ..services.theme_preview import preview_metrics # type: ignore from ..services.theme_preview import preview_metrics
from ..services import theme_preview as _theme_preview_mod # type: ignore # for error counters from ..services import theme_preview as _theme_preview_mod # for error counters
import os import os
from fastapi import Body from fastapi import Body
@ -36,7 +36,7 @@ router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status
# Reuse the main app's template environment so nav globals stay consistent. # Reuse the main app's template environment so nav globals stay consistent.
try: # circular-safe import: app defines templates before importing this router try: # circular-safe import: app defines templates before importing this router
from ..app import templates as _templates # type: ignore from ..app import templates as _templates
except Exception: # Fallback (tests/minimal contexts) except Exception: # Fallback (tests/minimal contexts)
_templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates')) _templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates'))
@ -131,7 +131,7 @@ async def theme_suggest(
# Optional rate limit using app helper if available # Optional rate limit using app helper if available
rl_result = None rl_result = None
try: try:
from ..app import rate_limit_check # type: ignore from ..app import rate_limit_check
rl_result = rate_limit_check(request, "suggest") rl_result = rate_limit_check(request, "suggest")
except HTTPException as http_ex: # propagate 429 with headers except HTTPException as http_ex: # propagate 429 with headers
raise http_ex raise http_ex
@ -231,7 +231,7 @@ async def theme_status():
yaml_file_count = 0 yaml_file_count = 0
if yaml_catalog_exists: if yaml_catalog_exists:
try: try:
yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type] yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"])
except Exception: except Exception:
yaml_file_count = -1 yaml_file_count = -1
tagged_time = _load_tag_flag_time() tagged_time = _load_tag_flag_time()
@ -291,28 +291,6 @@ def _diag_enabled() -> bool:
return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"} return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"}
@router.get("/picker", response_class=HTMLResponse)
async def theme_picker_page(request: Request):
"""Render the theme picker shell.
Dynamic data (list, detail) loads via fragment endpoints. We still inject
known archetype list for the filter select so it is populated on initial load.
"""
archetypes: list[str] = []
try:
idx = load_index()
archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type]
except Exception:
archetypes = []
return _templates.TemplateResponse(
"themes/picker.html",
{
"request": request,
"archetypes": archetypes,
"theme_picker_diagnostics": _diag_enabled(),
},
)
@router.get("/metrics") @router.get("/metrics")
async def theme_metrics(): async def theme_metrics():
if not _diag_enabled(): if not _diag_enabled():
@ -569,7 +547,7 @@ async def theme_yaml(theme_id: str):
raise HTTPException(status_code=404, detail="yaml_not_found") raise HTTPException(status_code=404, detail="yaml_not_found")
# Reconstruct minimal YAML (we have dict already) # Reconstruct minimal YAML (we have dict already)
import yaml as _yaml # local import to keep top-level lean import yaml as _yaml # local import to keep top-level lean
text = _yaml.safe_dump(y, sort_keys=False) # type: ignore text = _yaml.safe_dump(y, sort_keys=False)
headers = {"Content-Type": "text/plain; charset=utf-8"} headers = {"Content-Type": "text/plain; charset=utf-8"}
return HTMLResponse(text, headers=headers) return HTMLResponse(text, headers=headers)
@ -653,7 +631,7 @@ async def api_theme_search(
prefix: list[dict[str, Any]] = [] prefix: list[dict[str, Any]] = []
substr: list[dict[str, Any]] = [] substr: list[dict[str, Any]] = []
seen: set[str] = set() seen: set[str] = set()
themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined] themes_iter = list(idx.catalog.themes)
# Phase 1 + 2: exact / prefix # Phase 1 + 2: exact / prefix
for t in themes_iter: for t in themes_iter:
name = t.theme name = t.theme
@ -746,89 +724,9 @@ async def api_theme_preview(
return JSONResponse({"ok": True, "preview": payload}) return JSONResponse({"ok": True, "preview": payload})
@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse)
async def theme_preview_fragment(
theme_id: str,
limit: int = Query(12, ge=1, le=30),
colors: str | None = None,
commander: str | None = None,
suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"),
minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale used in detail page collapsible preview)"),
request: Request = None,
):
"""Return HTML fragment for theme preview with caching headers.
Adds ETag and Last-Modified headers (no strong caching enables conditional GET / 304).
ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander). @router.get("/fragment/list", response_class=HTMLResponse)
"""
try:
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
except KeyError:
return HTMLResponse("<div class='error'>Theme not found.</div>", status_code=404)
# Load example commanders (authoritative list) from catalog detail for legality instead of inferring
example_commanders: list[str] = []
synergy_commanders: list[str] = []
try:
idx = load_index()
slug = slugify(theme_id)
entry = idx.slug_to_entry.get(slug)
if entry:
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False)
example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)]
synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)]
# De-duplicate any overlap with example commanders while preserving order
seen = set(example_commanders)
for c in synergy_commanders_raw:
if c not in seen:
synergy_commanders.append(c)
seen.add(c)
except Exception:
example_commanders = []
synergy_commanders = []
# Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift)
import hashlib
import json as _json
import time as _time
try:
idx = load_index()
catalog_tag = idx.etag
except Exception:
catalog_tag = "unknown"
hash_src = _json.dumps({
"theme": theme_id,
"limit": limit,
"commander": commander,
"sample": payload.get("sample", [])[:3], # small slice for stability & speed
"v": 1,
}, sort_keys=True).encode("utf-8")
etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}"
# Conditional request support
if request is not None:
inm = request.headers.get("if-none-match")
if inm and inm == etag:
# 304 Not Modified FastAPI HTMLResponse with empty body & headers
resp = HTMLResponse(status_code=304, content="")
resp.headers["ETag"] = etag
from email.utils import formatdate as _fmtdate
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
resp.headers["Cache-Control"] = "no-cache"
return resp
ctx = {
"request": request,
"preview": payload,
"example_commanders": example_commanders,
"synergy_commanders": synergy_commanders,
"theme_id": theme_id,
"etag": etag,
"suppress_curated": suppress_curated,
"minimal": minimal,
}
resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx)
resp.headers["ETag"] = etag
from email.utils import formatdate as _fmtdate
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
resp.headers["Cache-Control"] = "no-cache"
return resp
# --- Preview Export Endpoints (CSV / JSON) --- # --- Preview Export Endpoints (CSV / JSON) ---

View file

@ -202,7 +202,7 @@ def commander_hover_context(
from .summary_utils import format_theme_label, format_theme_list from .summary_utils import format_theme_label, format_theme_list
except Exception: except Exception:
# Fallbacks in the unlikely event of circular import issues # Fallbacks in the unlikely event of circular import issues
def format_theme_label(value: Any) -> str: # type: ignore[redef] def format_theme_label(value: Any) -> str:
text = str(value or "").strip().replace("_", " ") text = str(value or "").strip().replace("_", " ")
if not text: if not text:
return "" return ""
@ -214,10 +214,10 @@ def commander_hover_context(
parts.append(chunk[:1].upper() + chunk[1:].lower()) parts.append(chunk[:1].upper() + chunk[1:].lower())
return " ".join(parts) return " ".join(parts)
def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef] def format_theme_list(values: Iterable[Any]) -> list[str]:
seen: set[str] = set() seen: set[str] = set()
result: list[str] = [] result: list[str] = []
for raw in values or []: # type: ignore[arg-type] for raw in values or []:
label = format_theme_label(raw) label = format_theme_label(raw)
if not label or len(label) <= 1: if not label or len(label) <= 1:
continue continue
@ -310,13 +310,30 @@ def commander_hover_context(
raw_color_identity = combined_info.get("color_identity") if combined_info else None raw_color_identity = combined_info.get("color_identity") if combined_info else None
commander_color_identity: list[str] = [] commander_color_identity: list[str] = []
# If we have a combined commander (partner/background), use its color identity
if isinstance(raw_color_identity, (list, tuple, set)): if isinstance(raw_color_identity, (list, tuple, set)):
for item in raw_color_identity: for item in raw_color_identity:
token = str(item).strip().upper() token = str(item).strip().upper()
if token: if token:
commander_color_identity.append(token) commander_color_identity.append(token)
# M7: For non-partner commanders, also check summary.colors for color identity # For regular commanders (no partner/background), look up from commander catalog first
if not commander_color_identity and not has_combined and commander_name:
try:
from .commander_catalog_loader import find_commander_record
record = find_commander_record(commander_name)
if record and hasattr(record, 'color_identity'):
raw_ci = record.color_identity
if isinstance(raw_ci, (list, tuple, set)):
for item in raw_ci:
token = str(item).strip().upper()
if token:
commander_color_identity.append(token)
except Exception:
pass
# Fallback: check summary.colors if we still don't have color identity
if not commander_color_identity and not has_combined and isinstance(summary, dict): if not commander_color_identity and not has_combined and isinstance(summary, dict):
summary_colors = summary.get("colors") summary_colors = summary.get("colors")
if isinstance(summary_colors, (list, tuple, set)): if isinstance(summary_colors, (list, tuple, set)):
@ -403,7 +420,7 @@ def step5_ctx_from_result(
else: else:
entry = {} entry = {}
try: try:
entry.update(vars(item)) # type: ignore[arg-type] entry.update(vars(item))
except Exception: except Exception:
pass pass
# Preserve common attributes when vars() empty # Preserve common attributes when vars() empty

View file

@ -359,7 +359,7 @@ def _global_prune_disallowed_pool(b: DeckBuilder) -> None:
drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd)) drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd))
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try: try:
import pandas as _pd # type: ignore import pandas as _pd
mask_keep = _pd.Series(mask_keep, index=work.index) mask_keep = _pd.Series(mask_keep, index=work.index)
except Exception: except Exception:
pass pass
@ -480,7 +480,7 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li
tmp = DeckBuilder() tmp = DeckBuilder()
try: try:
if hasattr(tmp, '_normalize_commander_query'): if hasattr(tmp, '_normalize_commander_query'):
query = tmp._normalize_commander_query(query) # type: ignore[attr-defined] query = tmp._normalize_commander_query(query)
else: else:
# Light fallback: basic title case # Light fallback: basic title case
query = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(query).split(' ')]) query = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(query).split(' ')])
@ -653,7 +653,7 @@ def commander_select(name: str) -> Dict[str, Any]:
if row.empty: if row.empty:
try: try:
if hasattr(tmp, '_normalize_commander_query'): if hasattr(tmp, '_normalize_commander_query'):
name2 = tmp._normalize_commander_query(name) # type: ignore[attr-defined] name2 = tmp._normalize_commander_query(name)
else: else:
name2 = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(name).split(' ')]) name2 = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(name).split(' ')])
row = df[df["name"] == name2] row = df[df["name"] == name2]
@ -1288,8 +1288,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
pass pass
# Bust theme-related in-memory caches so new catalog reflects immediately # Bust theme-related in-memory caches so new catalog reflects immediately
try: try:
from .theme_catalog_loader import bust_filter_cache # type: ignore from .theme_catalog_loader import bust_filter_cache
from .theme_preview import bust_preview_cache # type: ignore from .theme_preview import bust_preview_cache
bust_filter_cache("catalog_refresh") bust_filter_cache("catalog_refresh")
bust_preview_cache("catalog_refresh") bust_preview_cache("catalog_refresh")
try: try:
@ -1327,7 +1327,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
try: try:
# M4 (Parquet Migration): Check for processed Parquet file instead of CSV # M4 (Parquet Migration): Check for processed Parquet file instead of CSV
from path_util import get_processed_cards_path # type: ignore from path_util import get_processed_cards_path
cards_path = get_processed_cards_path() cards_path = get_processed_cards_path()
flag_path = os.path.join('csv_files', '.tagging_complete.json') flag_path = os.path.join('csv_files', '.tagging_complete.json')
auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1') auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1')
@ -1416,7 +1416,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
_write_status({"running": True, "phase": "setup", "message": "GitHub download failed, running local setup...", "percent": 0}) _write_status({"running": True, "phase": "setup", "message": "GitHub download failed, running local setup...", "percent": 0})
try: try:
from file_setup.setup import initial_setup # type: ignore from file_setup.setup import initial_setup
# Always run initial_setup when forced or when cards are missing/stale # Always run initial_setup when forced or when cards are missing/stale
initial_setup() initial_setup()
except Exception as e: except Exception as e:
@ -1425,7 +1425,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
return return
# M4 (Parquet Migration): Use unified run_tagging with parallel support # M4 (Parquet Migration): Use unified run_tagging with parallel support
try: try:
from tagging import tagger as _tagger # type: ignore from tagging import tagger as _tagger
use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"} use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"}
max_workers_env = os.getenv('WEB_TAG_WORKERS') max_workers_env = os.getenv('WEB_TAG_WORKERS')
try: try:
@ -1466,7 +1466,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
try: try:
_write_status({"running": True, "phase": "aggregating", "message": "Consolidating card data...", "percent": 90}) _write_status({"running": True, "phase": "aggregating", "message": "Consolidating card data...", "percent": 90})
out("Aggregating card CSVs into Parquet files...") out("Aggregating card CSVs into Parquet files...")
from file_setup.card_aggregator import CardAggregator # type: ignore from file_setup.card_aggregator import CardAggregator
aggregator = CardAggregator() aggregator = CardAggregator()
# Aggregate all_cards.parquet # Aggregate all_cards.parquet
@ -1474,7 +1474,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
out(f"Aggregated {stats['total_cards']} cards into all_cards.parquet ({stats['file_size_mb']} MB)") out(f"Aggregated {stats['total_cards']} cards into all_cards.parquet ({stats['file_size_mb']} MB)")
# Convert commander_cards.csv and background_cards.csv to Parquet # Convert commander_cards.csv and background_cards.csv to Parquet
import pandas as pd # type: ignore import pandas as pd
# Convert commander_cards.csv # Convert commander_cards.csv
commander_csv = 'csv_files/commander_cards.csv' commander_csv = 'csv_files/commander_cards.csv'
@ -1524,8 +1524,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
# Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress # Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress
_refresh_theme_catalog(out, force=True, fast_path=False) _refresh_theme_catalog(out, force=True, fast_path=False)
try: try:
from .theme_catalog_loader import bust_filter_cache # type: ignore from .theme_catalog_loader import bust_filter_cache
from .theme_preview import bust_preview_cache # type: ignore from .theme_preview import bust_preview_cache
bust_filter_cache("tagging_complete") bust_filter_cache("tagging_complete")
bust_preview_cache("tagging_complete") bust_preview_cache("tagging_complete")
except Exception: except Exception:
@ -1721,19 +1721,19 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Owned/Prefer-owned integration (optional for headless runs) # Owned/Prefer-owned integration (optional for headless runs)
try: try:
if use_owned_only: if use_owned_only:
b.use_owned_only = True # type: ignore[attr-defined] b.use_owned_only = True
# Prefer explicit owned_names list if provided; else let builder discover from files # Prefer explicit owned_names list if provided; else let builder discover from files
if owned_names: if owned_names:
try: try:
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception: except Exception:
b.owned_card_names = set() # type: ignore[attr-defined] b.owned_card_names = set()
# Soft preference flag does not filter; only biases selection order # Soft preference flag does not filter; only biases selection order
if prefer_owned: if prefer_owned:
try: try:
b.prefer_owned = True # type: ignore[attr-defined] b.prefer_owned = True
if owned_names and not getattr(b, 'owned_card_names', None): if owned_names and not getattr(b, 'owned_card_names', None):
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -1751,13 +1751,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Thread combo preferences (if provided) # Thread combo preferences (if provided)
try: try:
if prefer_combos is not None: if prefer_combos is not None:
b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined] b.prefer_combos = bool(prefer_combos)
if combo_target_count is not None: if combo_target_count is not None:
b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] b.combo_target_count = int(combo_target_count)
if combo_balance: if combo_balance:
bal = str(combo_balance).strip().lower() bal = str(combo_balance).strip().lower()
if bal in ('early','late','mix'): if bal in ('early','late','mix'):
b.combo_balance = bal # type: ignore[attr-defined] b.combo_balance = bal
except Exception: except Exception:
pass pass
@ -1934,7 +1934,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception: except Exception:
pass pass
if hasattr(b, 'export_decklist_csv'): if hasattr(b, 'export_decklist_csv'):
csv_path = b.export_decklist_csv() # type: ignore[attr-defined] csv_path = b.export_decklist_csv()
except Exception as e: except Exception as e:
out(f"CSV export failed: {e}") out(f"CSV export failed: {e}")
try: try:
@ -1942,7 +1942,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Try to mirror build_deck_full behavior by displaying the contents # Try to mirror build_deck_full behavior by displaying the contents
import os as _os import os as _os
base, _ext = _os.path.splitext(_os.path.basename(csv_path)) if csv_path else (f"deck_{b.timestamp}", "") base, _ext = _os.path.splitext(_os.path.basename(csv_path)) if csv_path else (f"deck_{b.timestamp}", "")
txt_path = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] txt_path = b.export_decklist_text(filename=base + '.txt')
try: try:
b._display_txt_contents(txt_path) b._display_txt_contents(txt_path)
except Exception: except Exception:
@ -1950,7 +1950,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Compute bracket compliance and save JSON alongside exports # Compute bracket compliance and save JSON alongside exports
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] rep0 = b.compute_and_print_compliance(base_stem=base)
# Attach planning preview (no mutation) and only auto-enforce if explicitly enabled # Attach planning preview (no mutation) and only auto-enforce if explicitly enabled
rep0 = _attach_enforcement_plan(b, rep0) rep0 = _attach_enforcement_plan(b, rep0)
try: try:
@ -1959,7 +1959,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception: except Exception:
_auto = False _auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] b.enforce_and_reexport(base_stem=base, mode='auto')
except Exception: except Exception:
pass pass
# Load compliance JSON for UI consumption # Load compliance JSON for UI consumption
@ -1981,7 +1981,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Build structured summary for UI # Build structured summary for UI
try: try:
if hasattr(b, 'build_deck_summary'): if hasattr(b, 'build_deck_summary'):
summary = b.build_deck_summary() # type: ignore[attr-defined] summary = b.build_deck_summary()
except Exception: except Exception:
summary = None summary = None
# Write sidecar summary JSON next to CSV (if available) # Write sidecar summary JSON next to CSV (if available)
@ -1999,7 +1999,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
"txt": txt_path, "txt": txt_path,
} }
try: try:
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] commander_meta = b.get_commander_export_metadata()
except Exception: except Exception:
commander_meta = {} commander_meta = {}
names = commander_meta.get("commander_names") or [] names = commander_meta.get("commander_names") or []
@ -2383,21 +2383,21 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) ->
"""Attach combined commander metadata to the builder.""" """Attach combined commander metadata to the builder."""
try: try:
builder.combined_commander = combined # type: ignore[attr-defined] builder.combined_commander = combined
except Exception: except Exception:
pass pass
try: try:
builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined] builder.partner_mode = getattr(combined, "partner_mode", None)
except Exception: except Exception:
pass pass
try: try:
builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined] builder.secondary_commander = getattr(combined, "secondary_name", None)
except Exception: except Exception:
pass pass
try: try:
builder.combined_color_identity = getattr(combined, "color_identity", None) # type: ignore[attr-defined] builder.combined_color_identity = getattr(combined, "color_identity", None)
builder.combined_theme_tags = getattr(combined, "theme_tags", None) # type: ignore[attr-defined] builder.combined_theme_tags = getattr(combined, "theme_tags", None)
builder.partner_warnings = getattr(combined, "warnings", None) # type: ignore[attr-defined] builder.partner_warnings = getattr(combined, "warnings", None)
except Exception: except Exception:
pass pass
commander_dict = getattr(builder, "commander_dict", None) commander_dict = getattr(builder, "commander_dict", None)
@ -2583,17 +2583,17 @@ def start_build_ctx(
# Owned-only / prefer-owned (if requested) # Owned-only / prefer-owned (if requested)
try: try:
if use_owned_only: if use_owned_only:
b.use_owned_only = True # type: ignore[attr-defined] b.use_owned_only = True
if owned_names: if owned_names:
try: try:
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception: except Exception:
b.owned_card_names = set() # type: ignore[attr-defined] b.owned_card_names = set()
if prefer_owned: if prefer_owned:
try: try:
b.prefer_owned = True # type: ignore[attr-defined] b.prefer_owned = True
if owned_names and not getattr(b, 'owned_card_names', None): if owned_names and not getattr(b, 'owned_card_names', None):
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -2646,14 +2646,14 @@ def start_build_ctx(
# Thread combo config # Thread combo config
try: try:
if combo_target_count is not None: if combo_target_count is not None:
b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] b.combo_target_count = int(combo_target_count)
except Exception: except Exception:
pass pass
try: try:
if combo_balance: if combo_balance:
bal = str(combo_balance).strip().lower() bal = str(combo_balance).strip().lower()
if bal in ('early','late','mix'): if bal in ('early','late','mix'):
b.combo_balance = bal # type: ignore[attr-defined] b.combo_balance = bal
except Exception: except Exception:
pass pass
# Stages # Stages
@ -2735,23 +2735,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
pass pass
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try: try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] ctx["csv_path"] = b.export_decklist_csv()
except Exception as e: except Exception as e:
logs.append(f"CSV export failed: {e}") logs.append(f"CSV export failed: {e}")
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try: try:
import os as _os import os as _os
base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv")) base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv"))
ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt')
# Export the run configuration JSON for manual builds # Export the run configuration JSON for manual builds
try: try:
b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] b.export_run_config_json(directory='config', filename=base + '.json')
except Exception: except Exception:
pass pass
# Compute bracket compliance and save JSON alongside exports # Compute bracket compliance and save JSON alongside exports
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] rep0 = b.compute_and_print_compliance(base_stem=base)
rep0 = _attach_enforcement_plan(b, rep0) rep0 = _attach_enforcement_plan(b, rep0)
try: try:
import os as __os import os as __os
@ -2759,7 +2759,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception: except Exception:
_auto = False _auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] b.enforce_and_reexport(base_stem=base, mode='auto')
except Exception: except Exception:
pass pass
# Load compliance JSON for UI consumption # Load compliance JSON for UI consumption
@ -2811,7 +2811,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
summary = None summary = None
try: try:
if hasattr(b, 'build_deck_summary'): if hasattr(b, 'build_deck_summary'):
summary = b.build_deck_summary() # type: ignore[attr-defined] summary = b.build_deck_summary()
except Exception: except Exception:
summary = None summary = None
# Write sidecar summary JSON next to CSV (if available) # Write sidecar summary JSON next to CSV (if available)
@ -2830,7 +2830,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"txt": ctx.get("txt_path"), "txt": ctx.get("txt_path"),
} }
try: try:
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] commander_meta = b.get_commander_export_metadata()
except Exception: except Exception:
commander_meta = {} commander_meta = {}
names = commander_meta.get("commander_names") or [] names = commander_meta.get("commander_names") or []
@ -2890,12 +2890,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp_now = None comp_now = None
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] comp_now = b.compute_and_print_compliance(base_stem=None)
except Exception: except Exception:
comp_now = None comp_now = None
try: try:
if comp_now: if comp_now:
comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined] comp_now = _attach_enforcement_plan(b, comp_now)
except Exception: except Exception:
pass pass
# If still FAIL, return the saved result without advancing or rerunning # If still FAIL, return the saved result without advancing or rerunning
@ -3407,7 +3407,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp = None comp = None
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] comp = b.compute_and_print_compliance(base_stem=None)
except Exception: except Exception:
comp = None comp = None
try: try:
@ -3508,7 +3508,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp = None comp = None
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] comp = b.compute_and_print_compliance(base_stem=None)
except Exception: except Exception:
comp = None comp = None
try: try:
@ -3575,7 +3575,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp = None comp = None
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] comp = b.compute_and_print_compliance(base_stem=None)
except Exception: except Exception:
comp = None comp = None
try: try:
@ -3617,23 +3617,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
pass pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'): if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
try: try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] ctx["csv_path"] = b.export_decklist_csv()
except Exception as e: except Exception as e:
logs.append(f"CSV export failed: {e}") logs.append(f"CSV export failed: {e}")
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try: try:
import os as _os import os as _os
base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv")) base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv"))
ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt')
# Export the run configuration JSON for manual builds # Export the run configuration JSON for manual builds
try: try:
b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] b.export_run_config_json(directory='config', filename=base + '.json')
except Exception: except Exception:
pass pass
# Compute bracket compliance and save JSON alongside exports # Compute bracket compliance and save JSON alongside exports
try: try:
if hasattr(b, 'compute_and_print_compliance'): if hasattr(b, 'compute_and_print_compliance'):
rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] rep0 = b.compute_and_print_compliance(base_stem=base)
rep0 = _attach_enforcement_plan(b, rep0) rep0 = _attach_enforcement_plan(b, rep0)
try: try:
import os as __os import os as __os
@ -3641,7 +3641,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception: except Exception:
_auto = False _auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] b.enforce_and_reexport(base_stem=base, mode='auto')
except Exception: except Exception:
pass pass
# Load compliance JSON for UI consumption # Load compliance JSON for UI consumption
@ -3662,7 +3662,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
summary = None summary = None
try: try:
if hasattr(b, 'build_deck_summary'): if hasattr(b, 'build_deck_summary'):
summary = b.build_deck_summary() # type: ignore[attr-defined] summary = b.build_deck_summary()
except Exception: except Exception:
summary = None summary = None
# Write sidecar summary JSON next to CSV (if available) # Write sidecar summary JSON next to CSV (if available)
@ -3681,7 +3681,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"txt": ctx.get("txt_path"), "txt": ctx.get("txt_path"),
} }
try: try:
commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] commander_meta = b.get_commander_export_metadata()
except Exception: except Exception:
commander_meta = {} commander_meta = {}
names = commander_meta.get("commander_names") or [] names = commander_meta.get("commander_names") or []

View file

@ -362,7 +362,7 @@ def load_dataset(*, force: bool = False, refresh: bool = False) -> Optional[Part
if allow_auto_refresh: if allow_auto_refresh:
_DATASET_REFRESH_ATTEMPTED = True _DATASET_REFRESH_ATTEMPTED = True
try: try:
from .orchestrator import _maybe_refresh_partner_synergy # type: ignore from .orchestrator import _maybe_refresh_partner_synergy
_maybe_refresh_partner_synergy(None, force=True) _maybe_refresh_partner_synergy(None, force=True)
except Exception as refresh_exc: # pragma: no cover - best-effort except Exception as refresh_exc: # pragma: no cover - best-effort

View file

@ -21,7 +21,7 @@ import json
import threading import threading
import math import math
from .preview_metrics import record_eviction # type: ignore from .preview_metrics import record_eviction
# Phase 2 extraction: adaptive TTL band policy moved into preview_policy # Phase 2 extraction: adaptive TTL band policy moved into preview_policy
from .preview_policy import ( from .preview_policy import (
@ -30,7 +30,7 @@ from .preview_policy import (
DEFAULT_TTL_MIN as _POLICY_TTL_MIN, DEFAULT_TTL_MIN as _POLICY_TTL_MIN,
DEFAULT_TTL_MAX as _POLICY_TTL_MAX, DEFAULT_TTL_MAX as _POLICY_TTL_MAX,
) )
from .preview_cache_backend import redis_store # type: ignore from .preview_cache_backend import redis_store
TTL_SECONDS = 600 TTL_SECONDS = 600
# Backward-compat variable names retained (tests may reference) mapping to policy constants # Backward-compat variable names retained (tests may reference) mapping to policy constants

Some files were not shown because too many files have changed in this diff Show more