Release v1.1.0: headless runner + tagging updates (Discard Matters, Freerunning, Craft, Spree, Explore/Map, Rad, Energy/Resource Engine, Spawn/Scion)

This commit is contained in:
matt 2025-08-22 16:32:39 -07:00
parent 36abbaa1dd
commit 99005c19f8
23 changed files with 1330 additions and 420 deletions

28
.env.example Normal file
View file

@ -0,0 +1,28 @@
# Copy this file to `.env` and adjust values to your needs.
# Set to 'headless' to auto-run the non-interactive mode on container start
# DECK_MODE=headless
# Optional JSON config path (inside the container)
# If you mount ./config to /app/config, use:
# DECK_CONFIG=/app/config/deck.json
# Common knobs
# DECK_COMMANDER=Pantlaza
# DECK_PRIMARY_CHOICE=2
# DECK_SECONDARY_CHOICE=2
# DECK_TERTIARY_CHOICE=2
# DECK_ADD_CREATURES=true
# DECK_ADD_NON_CREATURE_SPELLS=true
# DECK_ADD_RAMP=true
# DECK_ADD_REMOVAL=true
# DECK_ADD_WIPES=true
# DECK_ADD_CARD_ADVANTAGE=true
# DECK_ADD_PROTECTION=true
# DECK_USE_MULTI_THEME=true
# DECK_ADD_LANDS=true
# DECK_FETCH_COUNT=3
# DECK_DUAL_COUNT=
# DECK_TRIPLE_COUNT=
# DECK_UTILITY_COUNT=

40
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
# Fallbacks if requirements-dev.txt not present
pip install mypy pytest || true
- name: Type check (mypy)
run: |
mypy code || true
- name: Headless smoke test (dry-run)
run: |
python -m code.headless_runner --config config/deck.json --dry-run
- name: Tests
run: |
pytest -q || true

2
.gitignore vendored
View file

@ -12,7 +12,7 @@ build/
csv_files/
dist/
logs/
non_interactive_test.py
test_determinism.py
test.py
deterministic_test.py
!config/deck.json

259
DOCKER.md
View file

@ -1,215 +1,66 @@
# Docker Guide for MTG Python Deckbuilder
# Docker Guide (concise)
A comprehensive guide for running the MTG Python Deckbuilder in Docker containers with full file persistence and cross-platform support.
Run the MTG Deckbuilder in Docker with persistent volumes and optional headless mode.
## 🚀 Quick Start
## Quick start
### Linux/macOS/Remote Host
```bash
# Make scripts executable (one time only)
chmod +x quick-start.sh run-docker.sh
# Simplest method - just run this:
./quick-start.sh
# Or use the full script with options:
./run-docker.sh compose
```
### Windows (PowerShell)
### PowerShell (recommended)
```powershell
# Run with Docker Compose (recommended)
.\run-docker.ps1 compose
# Or manual Docker run
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
mtg-deckbuilder
```
## 📋 Prerequisites
- **Docker** installed and running
- **Docker Compose** (usually included with Docker)
- Basic terminal/command line knowledge
## 🔧 Available Commands
### Quick Start Scripts
| Script | Platform | Description |
|--------|----------|-------------|
| `./quick-start.sh` | Linux/macOS | Simplest way to run the application |
| `.\run-docker.ps1 compose` | Windows | PowerShell equivalent |
### Full Featured Scripts
| Command | Description |
|---------|-------------|
| `./run-docker.sh setup` | Create directories and check Docker installation |
| `./run-docker.sh build` | Build the Docker image |
| `./run-docker.sh compose` | Run with Docker Compose (recommended) |
| `./run-docker.sh run` | Run with manual volume mounting |
| `./run-docker.sh clean` | Remove containers and images |
## 🗂️ File Persistence
Your files are automatically saved to local directories that persist between runs:
```
mtg_python_deckbuilder/
├── deck_files/ # Your saved decks (CSV and TXT files)
├── logs/ # Application logs and debug info
├── csv_files/ # Card database and color-sorted files
└── ...
```
### How It Works
The Docker container uses **volume mounting** to map container directories to your local filesystem:
- Container path `/app/deck_files` ↔ Host path `./deck_files`
- Container path `/app/logs` ↔ Host path `./logs`
- Container path `/app/csv_files` ↔ Host path `./csv_files`
When the application saves files, they appear in your local directories and remain there after the container stops.
## 🎮 Interactive Application Requirements
The MTG Deckbuilder is an **interactive application** that uses menus and requires keyboard input.
### ✅ Commands That Work
- `docker compose run --rm mtg-deckbuilder`
- `docker run -it --rm mtg-deckbuilder`
- `./quick-start.sh`
- Helper scripts with `compose` command
### ❌ Commands That Don't Work
- `docker compose up` (runs in background, no interaction)
- `docker run` without `-it` flags
- Any command without proper TTY allocation
### Why the Difference?
- **`docker compose run`**: Creates new container with terminal attachment
- **`docker compose up`**: Starts service in background without terminal
## 🔨 Manual Docker Commands
### Build the Image
```bash
docker build -t mtg-deckbuilder .
```
### Run with Full Volume Mounting
**Linux/macOS:**
```bash
docker run -it --rm \
-v "$(pwd)/deck_files:/app/deck_files" \
-v "$(pwd)/logs:/app/logs" \
-v "$(pwd)/csv_files:/app/csv_files" \
mtg-deckbuilder
```
**Windows PowerShell:**
```powershell
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
mtg-deckbuilder
```
## 📁 Docker Compose Files
The project includes two Docker Compose configurations:
### `docker-compose.yml` (Main)
- Standard configuration
- Container name: `mtg-deckbuilder-main`
- Use with: `docker compose run --rm mtg-deckbuilder`
Both files provide the same functionality and file persistence.
## 🐛 Troubleshooting
### Files Not Saving?
1. **Check volume mounts**: Ensure you see `-v` flags in your docker command
2. **Verify directories exist**: Scripts automatically create needed directories
3. **Check permissions**: Ensure you have write access to the project directory
4. **Use correct command**: Use `docker compose run`, not `docker compose up`
### Application Won't Start Interactively?
1. **Use the right command**: `docker compose run --rm mtg-deckbuilder`
2. **Check TTY allocation**: Ensure `-it` flags are present in manual commands
3. **Avoid background mode**: Don't use `docker compose up` for interactive apps
### Permission Issues?
Files created by Docker may be owned by `root`. This is normal on Linux systems.
### Container Build Fails?
1. **Update Docker**: Ensure you have a recent version
2. **Clear cache**: Run `docker system prune -f`
3. **Check network**: Ensure Docker can download dependencies
### Starting Fresh
**Complete cleanup:**
```bash
# Stop all containers
docker compose down
# Remove image
docker rmi mtg-deckbuilder
# Clean up system
docker system prune -f
# Rebuild
docker compose build
docker compose run --rm mtg-deckbuilder
```
## 🔍 Verifying Everything Works
After running the application:
1. **Create or modify some data** (run setup, build a deck, etc.)
2. **Exit the container** (Ctrl+C or select Quit)
3. **Check your local directories**:
```bash
ls -la deck_files/ # Should show any decks you created
ls -la logs/ # Should show log files
ls -la csv_files/ # Should show card database files
### From Docker Hub (PowerShell)
```powershell
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
mwisnowski/mtg-python-deckbuilder:latest
```
4. **Run again** - your data should still be there!
## 🎯 Best Practices
## Volumes
- `/app/deck_files``./deck_files`
- `/app/logs``./logs`
- `/app/csv_files``./csv_files`
- Optional: `/app/config``./config` (JSON configs for headless)
1. **Use the quick-start script** for simplest experience
2. **Always use `docker compose run`** for interactive applications
3. **Keep your project directory organized** - files persist locally
4. **Regularly backup your `deck_files/`** if you create valuable decks
5. **Use `clean` commands** to free up disk space when needed
## 🌟 Benefits of Docker Approach
- ✅ **Consistent environment** across different machines
- ✅ **No Python installation required** on host system
- ✅ **Isolated dependencies** - won't conflict with other projects
- ✅ **Easy sharing** - others can run your setup instantly
- ✅ **Cross-platform** - works on Windows, macOS, and Linux
- ✅ **File persistence** - your work is saved locally
- ✅ **Easy cleanup** - remove everything with one command
---
**Need help?** Check the troubleshooting section above or refer to the helper script help:
```bash
./run-docker.sh help
## Interactive vs headless
- Interactive: attach a TTY (compose run or `docker run -it`)
- Headless auto-run:
```powershell
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
```
- Headless with JSON config:
```powershell
docker compose run --rm `
-e DECK_MODE=headless `
-e DECK_CONFIG=/app/config/deck.json `
mtg-deckbuilder
```
### Common env vars
- DECK_MODE=headless
- DECK_CONFIG=/app/config/deck.json
- DECK_COMMANDER, DECK_PRIMARY_CHOICE
- DECK_ADD_LANDS, DECK_FETCH_COUNT
## Manual build/run
```powershell
docker build -t mtg-deckbuilder .
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
mtg-deckbuilder
```
## Troubleshooting
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
- Files not saving? Verify volume mounts and that folders exist
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
## Tips
- Use `docker compose run`, not `up`, for interactive mode
- Exported decks appear in `deck_files/`
- JSON run-config is exported only in interactive runs; headless skips it

View file

@ -24,20 +24,21 @@ COPY code/ ./code/
COPY mypy.ini .
# Create necessary directories as mount points
RUN mkdir -p deck_files logs csv_files
RUN mkdir -p deck_files logs csv_files config
# Create volumes for persistent data
VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files"]
VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files", "/app/config"]
# Create symbolic links BEFORE changing working directory
# These will point to the mounted volumes
RUN cd /app/code && \
ln -sf /app/deck_files ./deck_files && \
ln -sf /app/logs ./logs && \
ln -sf /app/csv_files ./csv_files
ln -sf /app/csv_files ./csv_files && \
ln -sf /app/config ./config
# Verify symbolic links were created
RUN cd /app/code && ls -la deck_files logs csv_files
RUN cd /app/code && ls -la deck_files logs csv_files config
# Set the working directory to code for proper imports
WORKDIR /app/code

BIN
README.md

Binary file not shown.

View file

@ -1,3 +1,62 @@
# MTG Python Deckbuilder v1.1.0 Release Notes
## Highlights
- Headless mode via submenu in the main menu (auto-runs single config; lists multiple as "Commander - Theme1, Theme2, Theme3"; `deck.json` shows as "Default")
- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON
- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it)
- Docs simplified: concise README and Docker guide; PowerShell examples included
## Docker
- Single service with persistent volumes:
- /app/deck_files
- /app/logs
- /app/csv_files
- Optional: /app/config for JSON configs
### Quick Start (PowerShell)
```powershell
# From Docker Hub
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
mwisnowski/mtg-python-deckbuilder:latest
# From source with Compose
docker compose build
docker compose run --rm mtg-deckbuilder
# Headless (optional)
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
# With JSON config
docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder
```
## Changes
- Added headless runner and headless submenu
- Suppressed JSON run-config export for headless runs
- `ideal_counts` in JSON now honored by prompts; only `fetch_count` tracked for lands
- Documentation trimmed and updated; added sample config with ideal_counts
### Tagging updates
- New: Discard Matters theme detects your discard effects and triggers; includes Madness and Blood creators; Loot/Connive/Cycling/Blood also add Discard Matters.
- New taggers:
- Freerunning → adds Freerunning and Cost Reduction.
- Craft → adds Transform; conditionally Artifacts Matter, Exile Matters, Graveyard Matters.
- Spree → adds Modal and Cost Scaling.
- Explore/Map → adds Card Selection; Explore may add +1/+1 Counters; Map adds Tokens Matter.
- Rad counters → adds Rad Counters.
- Exile Matters expanded to cover Warp and Time Counters/Time Travel/Vanishing.
- Energy enriched to also tag Resource Engine.
- Eldrazi Spawn/Scion creators now tag Aristocrats and Ramp (replacing prior Sacrifice Fodder mapping).
## Known Issues
- First run downloads card data (takes a few minutes)
- Use `docker compose run --rm` (not `up`) for interactive sessions
- Ensure volumes are mounted to persist files outside the container
---
# MTG Python Deckbuilder v1.0.0 Release Notes
## 🎉 Initial Release

View file

@ -1,47 +1,59 @@
# MTG Python Deckbuilder ${VERSION}
## Highlights
- Direct-to-builder launch with automatic initial setup and tagging
- Improved Type Summary (accurate Commander/Creature/etc. counts)
- Smarter export filenames: full commander name + ordered themes + date, with auto-increment
- TXT export duplication fixed
- Post-build prompt to build another deck or quit
- Headless mode with a submenu in the main menu (auto-runs single config; lists multiple as "Commander - Theme1, Theme2, Theme3"; `deck.json` labeled "Default")
- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON
- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it)
- Smarter filenames: commander + ordered themes + date, with auto-increment when exists
## Docker
- Multi-arch image (amd64, arm64) on Docker Hub
- Persistent volumes:
- Single service; persistent volumes:
- /app/deck_files
- /app/logs
- /app/csv_files
- Optional: /app/config (mount `./config` for JSON configs)
### Quick Start
```bash
mkdir mtg-decks && cd mtg-decks
docker run -it --rm \
-v "$(pwd)/deck_files":/app/deck_files \
-v "$(pwd)/logs":/app/logs \
-v "$(pwd)/csv_files":/app/csv_files \
```powershell
# From Docker Hub
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
mwisnowski/mtg-python-deckbuilder:latest
# From source with Compose
docker compose build
docker compose run --rm mtg-deckbuilder
# Headless (optional)
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
# With JSON config
docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder
```
Windows PowerShell users: see WINDOWS_DOCKER_GUIDE.md or run:
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/mwisnowski/mtg_python_deckbuilder/main/run-from-dockerhub.bat" -OutFile "run-from-dockerhub.bat" run-from-dockerhub.bat
## Changes
- Auto-setup/tagging when `csv_files/cards.csv` is missing (both main and builder)
- Main entrypoint now skips menu and launches the deck builder
- Type summary classification matches export categories; uses snapshot fallback
- Export filenames:
- Full commander name (punctuation removed)
- All themes in order
- Date suffix (YYYYMMDD)
- Auto-increment when file exists
- Removed duplicate TXT sidecar creation in CSV export
- Added headless runner and main menu headless submenu
- JSON export is suppressed in headless; interactive runs export replayable JSON to `config/`
- `ideal_counts` supported and honored by prompts; only `fetch_count` tracked for lands
- Documentation simplified and focused; Docker guide trimmed and PowerShell examples updated
### Tagging updates
- New: Discard Matters theme detects your discard effects and triggers; includes Madness and Blood creators; Loot/Connive/Cycling/Blood also add Discard Matters.
- New taggers:
- Freerunning → adds Freerunning and Cost Reduction.
- Craft → adds Transform; conditionally Artifacts Matter, Exile Matters, Graveyard Matters.
- Spree → adds Modal and Cost Scaling.
- Explore/Map → adds Card Selection; Explore may add +1/+1 Counters; Map adds Tokens Matter.
- Rad counters → adds Rad Counters.
- Exile Matters expanded to cover Warp and Time Counters/Time Travel/Vanishing.
- Energy enriched to also tag Resource Engine.
- Eldrazi Spawn/Scion creators now tag Aristocrats and Ramp (replacing prior Sacrifice Fodder mapping).
## Known Issues
- First run downloads card data; may take several minutes
- Ensure volume mounts are present to persist files outside the container
- First run downloads card data (takes a few minutes)
- Use `docker compose run --rm` (not `up`) for interactive sessions
- Ensure volumes are mounted to persist files outside the container
## Links
- Repo: https://github.com/mwisnowski/mtg_python_deckbuilder

View file

@ -1,11 +1,18 @@
"""Root package for the MTG deckbuilder source tree.
Adding this file ensures the directory is treated as a proper package so that
`python -m code.main` resolves to this project instead of the Python stdlib
module named `code` (which is a simple module, not a package).
If you still accidentally import the stdlib module, be sure you are executing
from the project root so the local `code` package is first on sys.path.
Ensures `python -m code.*` resolves to this project and adjusts sys.path so
legacy absolute imports like `import logging_util` (modules living under this
package) work whether you run files directly or as modules.
"""
from __future__ import annotations
import os
import sys
# Make the package directory importable as a top-level for legacy absolute imports
_PKG_DIR = os.path.dirname(__file__)
if _PKG_DIR and _PKG_DIR not in sys.path:
sys.path.insert(0, _PKG_DIR)
__all__ = []

View file

@ -103,6 +103,30 @@ class DeckBuilder(
txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
# Display the text file contents for easy copy/paste to online deck builders
self._display_txt_contents(txt_path)
# Also export a matching JSON config for replay (interactive builds only)
if not getattr(self, 'headless', False):
try:
# Choose config output dir: DECK_CONFIG dir > /app/config > ./config
import os as _os
cfg_path_env = _os.getenv('DECK_CONFIG')
cfg_dir = None
if cfg_path_env:
cfg_dir = _os.path.dirname(cfg_path_env) or '.'
elif _os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
if cfg_dir:
_os.makedirs(cfg_dir, exist_ok=True)
self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined]
# Also, if DECK_CONFIG explicitly points to a file path, write exactly there too
if cfg_path_env:
cfg_dir2 = _os.path.dirname(cfg_path_env) or '.'
cfg_name2 = _os.path.basename(cfg_path_env)
_os.makedirs(cfg_dir2, exist_ok=True)
self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined]
except Exception:
pass
except Exception:
logger.warning("Plaintext export failed (non-fatal)")
end_ts = datetime.datetime.now()
@ -213,14 +237,18 @@ class DeckBuilder(
# IO injection for testing
input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt))
output_func: Callable[[str], None] = field(default=lambda msg: print(msg))
# Deterministic random support
seed: Optional[int] = None
# Random support (no external seeding)
_rng: Any = field(default=None, repr=False)
# Logging / output behavior
log_outputs: bool = True # if True, mirror output_func messages into logger at INFO level
_original_output_func: Optional[Callable[[str], None]] = field(default=None, repr=False)
# Chosen land counts (only fetches are tracked/exported; others vary randomly)
fetch_count: Optional[int] = None
# Whether this build is running in headless mode (suppress some interactive-only exports)
headless: bool = False
def __post_init__(self):
"""Post-init hook to wrap the provided output function so that all user-facing
messages are also captured in the central log (at INFO level) unless disabled.
@ -250,10 +278,10 @@ class DeckBuilder(
# ---------------------------
# RNG Initialization
# ---------------------------
def _get_rng(self): # lazy init to allow seed set post-construction
def _get_rng(self): # lazy init
if self._rng is None:
import random as _r
self._rng = _r.Random(self.seed) if self.seed is not None else _r
self._rng = _r
return self._rng
# ---------------------------

View file

@ -114,6 +114,7 @@ class LandFetchMixin:
if len(chosen) < desired:
leftovers = [n for n in candidates if n not in chosen]
chosen.extend(leftovers[: desired - len(chosen)])
added: List[str] = []
for nm in chosen:
if self._current_land_count() >= land_target: # type: ignore[attr-defined]
@ -127,6 +128,11 @@ class LandFetchMixin:
added_by='lands_step4'
) # type: ignore[attr-defined]
added.append(nm)
# Record actual number of fetch lands added for export/replay context
try:
setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined]
except Exception:
pass
self.output_func("\nFetch Lands Added (Step 4):")
if not added:
self.output_func(" (None added)")

View file

@ -106,6 +106,7 @@ class LandMiscUtilityMixin:
self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7')
added.append(nm)
self.output_func("\nMisc Utility Lands Added (Step 7):")
if not added:
self.output_func(" (None added)")

View file

@ -217,6 +217,7 @@ class LandTripleMixin:
)
added.append(name)
self.output_func("\nTriple Lands Added (Step 6):")
if not added:
self.output_func(" (None added)")

View file

@ -383,6 +383,89 @@ class ReportingMixin:
self.output_func(f"Plaintext deck list exported to {path}")
return path
def export_run_config_json(self, directory: str = 'config', filename: str | None = None, suppress_output: bool = False) -> str:
"""Export a JSON config capturing the key choices for replaying headless.
Filename mirrors CSV/TXT naming (same stem, .json extension).
Fields included:
- commander
- primary_tag / secondary_tag / tertiary_tag
- bracket_level (if chosen)
- use_multi_theme (default True)
- add_lands, add_creatures, add_non_creature_spells (defaults True)
- fetch_count (if determined during run)
- ideal_counts (the actual ideal composition values used)
"""
os.makedirs(directory, exist_ok=True)
def _slug(s: str) -> str:
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
return s2 or 'x'
def _unique_path(path: str) -> str:
if not os.path.exists(path):
return path
base, ext = os.path.splitext(path)
i = 1
while True:
candidate = f"{base}_{i}{ext}"
if not os.path.exists(candidate):
return candidate
i += 1
if filename is None:
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
themes: List[str] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
date_part = _dt.date.today().strftime('%Y%m%d')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
path = _unique_path(os.path.join(directory, filename))
# Capture ideal counts (actual chosen values)
ideal_counts = getattr(self, 'ideal_counts', {}) or {}
# Capture fetch count (others vary run-to-run and are intentionally not recorded)
chosen_fetch = getattr(self, 'fetch_count', None)
payload = {
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
"primary_tag": getattr(self, 'primary_tag', None),
"secondary_tag": getattr(self, 'secondary_tag', None),
"tertiary_tag": getattr(self, 'tertiary_tag', None),
"bracket_level": getattr(self, 'bracket_level', None),
"use_multi_theme": True,
"add_lands": True,
"add_creatures": True,
"add_non_creature_spells": True,
# chosen fetch land count (others intentionally omitted for variance)
"fetch_count": chosen_fetch,
# actual ideal counts used for this run
"ideal_counts": {
k: int(v) for k, v in ideal_counts.items() if isinstance(v, (int, float))
}
# seed intentionally omitted
}
try:
import json as _json
with open(path, 'w', encoding='utf-8') as f:
_json.dump(payload, f, indent=2)
if not suppress_output:
self.output_func(f"Run config exported to {path}")
except Exception as e:
logger.warning(f"Failed to export run config: {e}")
return path
def print_card_library(self, table: bool = True):
"""Prints the current card library in either plain or tabular format.
Uses PrettyTable if available, otherwise prints a simple list.

View file

@ -22,8 +22,11 @@ from enum import Enum
import os
from typing import List, Dict, Any
# Third-party imports
import inquirer
# Third-party imports (optional)
try:
import inquirer # type: ignore
except Exception:
inquirer = None # Fallback to simple input-based menu when unavailable
import pandas as pd
# Local imports
@ -229,6 +232,7 @@ def _display_setup_menu() -> SetupOption:
Returns:
SetupOption: The selected menu option
"""
if inquirer is not None:
question: List[Dict[str, Any]] = [
inquirer.List(
'menu',
@ -237,6 +241,24 @@ def _display_setup_menu() -> SetupOption:
answer = inquirer.prompt(question)
return SetupOption(answer['menu'])
# Simple fallback when inquirer isn't installed (e.g., headless/container)
options = list(SetupOption)
print("\nSetup Menu:")
for idx, opt in enumerate(options, start=1):
print(f" {idx}) {opt.value}")
while True:
try:
sel = input("Select an option [1]: ").strip() or "1"
i = int(sel)
if 1 <= i <= len(options):
return options[i - 1]
except KeyboardInterrupt:
print("")
return SetupOption.BACK
except Exception:
pass
print("Invalid selection. Please try again.")
def setup() -> bool:
"""Run the setup process for the MTG Python Deckbuilder.

404
code/headless_runner.py Normal file
View file

@ -0,0 +1,404 @@
from __future__ import annotations
import argparse
import json
import os
from typing import Any, Dict, List, Optional
from pathlib import Path
from code.deck_builder.builder import DeckBuilder
"""Headless (non-interactive) runner.
Features:
- Script commander selection.
- Script primary / optional secondary / tertiary tags.
- Apply bracket & accept default ideal counts.
- Invoke multi-theme creature addition if available (fallback to primary-only).
Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic.
Indices correspond to the numbered tag list presented during interaction.
"""
def run(
command_name: str = "Pantlaza",
add_creatures: bool = True,
add_non_creature_spells: bool = True,
# Fine-grained toggles (used only if add_non_creature_spells is False)
add_ramp: bool = True,
add_removal: bool = True,
add_wipes: bool = True,
add_card_advantage: bool = True,
add_protection: bool = True,
use_multi_theme: bool = True,
primary_choice: int = 2,
secondary_choice: Optional[int] = 2,
tertiary_choice: Optional[int] = 2,
add_lands: bool = True,
fetch_count: Optional[int] = 3,
dual_count: Optional[int] = None,
triple_count: Optional[int] = None,
utility_count: Optional[int] = None,
ideal_counts: Optional[Dict[str, int]] = None,
) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.
Integer parameters (primary_choice, secondary_choice, tertiary_choice) correspond to the
numeric indices shown during interactive tag selection. Pass None to omit secondary/tertiary.
Optional counts (fetch_count, dual_count, triple_count, utility_count) constrain land steps.
"""
scripted_inputs: List[str] = []
# Commander query & selection
scripted_inputs.append(command_name) # initial query
scripted_inputs.append("1") # choose first search match to inspect
scripted_inputs.append("y") # confirm commander
# Primary tag selection
scripted_inputs.append(str(primary_choice))
# Secondary tag selection or stop (0)
if secondary_choice is not None:
scripted_inputs.append(str(secondary_choice))
# Tertiary tag selection or stop (0)
if tertiary_choice is not None:
scripted_inputs.append(str(tertiary_choice))
else:
scripted_inputs.append("0")
else:
scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value
scripted_inputs.append("3")
# Ideal count prompts (press Enter for defaults)
for _ in range(8):
scripted_inputs.append("")
def scripted_input(prompt: str) -> str:
if scripted_inputs:
return scripted_inputs.pop(0)
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
builder = DeckBuilder(input_func=scripted_input)
# Mark this run as headless so builder can adjust exports and logging
try:
builder.headless = True # type: ignore[attr-defined]
except Exception:
pass
# If ideal_counts are provided (from JSON), use them as the current defaults
# so the step 2 prompts will show these values and our blank entries will accept them.
if isinstance(ideal_counts, dict) and ideal_counts:
try:
ic: Dict[str, int] = {}
for k, v in ideal_counts.items():
try:
iv = int(v) if v is not None else None # type: ignore
except Exception:
continue
if iv is None:
continue
# Only accept known keys
if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}:
ic[k] = iv
if ic:
builder.ideal_counts.update(ic) # type: ignore[attr-defined]
except Exception:
pass
builder.run_initial_setup()
builder.run_deck_build_step1()
builder.run_deck_build_step2()
# Land sequence (optional)
if add_lands:
if hasattr(builder, 'run_land_step1'):
builder.run_land_step1() # Basics / initial
if hasattr(builder, 'run_land_step2'):
builder.run_land_step2() # Utility basics / rebalancing
if hasattr(builder, 'run_land_step3'):
builder.run_land_step3() # Kindred lands if applicable
if hasattr(builder, 'run_land_step4'):
builder.run_land_step4(requested_count=fetch_count)
if hasattr(builder, 'run_land_step5'):
builder.run_land_step5(requested_count=dual_count)
if hasattr(builder, 'run_land_step6'):
builder.run_land_step6(requested_count=triple_count)
if hasattr(builder, 'run_land_step7'):
builder.run_land_step7(requested_count=utility_count)
if hasattr(builder, 'run_land_step8'):
builder.run_land_step8()
if add_creatures:
builder.add_creatures()
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'):
builder.add_non_creature_spells()
else:
# Allow selective invocation if orchestrator not desired
if add_ramp and hasattr(builder, 'add_ramp'):
builder.add_ramp()
if add_removal and hasattr(builder, 'add_removal'):
builder.add_removal()
if add_wipes and hasattr(builder, 'add_board_wipes'):
builder.add_board_wipes()
if add_card_advantage and hasattr(builder, 'add_card_advantage'):
builder.add_card_advantage()
if add_protection and hasattr(builder, 'add_protection'):
builder.add_protection()
# Suppress verbose library print in headless run since CSV export is produced.
# builder.print_card_library()
builder.post_spell_land_adjust()
# Export decklist CSV (commander first word + date)
csv_path: Optional[str] = None
if hasattr(builder, 'export_decklist_csv'):
try:
csv_path = builder.export_decklist_csv()
except Exception:
csv_path = None
if hasattr(builder, 'export_decklist_text'):
try:
if csv_path:
base = os.path.splitext(os.path.basename(csv_path))[0]
builder.export_decklist_text(filename=base + '.txt')
if hasattr(builder, 'export_run_config_json'):
try:
cfg_path_env = os.getenv('DECK_CONFIG')
if cfg_path_env:
cfg_dir = os.path.dirname(cfg_path_env) or '.'
elif os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
os.makedirs(cfg_dir, exist_ok=True)
builder.export_run_config_json(directory=cfg_dir, filename=base + '.json')
# If an explicit DECK_CONFIG path is given, also write to exactly that path
if cfg_path_env:
cfg_dir2 = os.path.dirname(cfg_path_env) or '.'
cfg_name2 = os.path.basename(cfg_path_env)
os.makedirs(cfg_dir2, exist_ok=True)
builder.export_run_config_json(directory=cfg_dir2, filename=cfg_name2)
except Exception:
pass
else:
builder.export_decklist_text()
except Exception:
pass
return builder
def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]:
if val is None:
return None
if isinstance(val, bool):
return val
if isinstance(val, int):
return bool(val)
s = str(val).strip().lower()
if s in {"1", "true", "t", "yes", "y", "on"}:
return True
if s in {"0", "false", "f", "no", "n", "off"}:
return False
return None
def _parse_opt_int(val: Optional[str | int]) -> Optional[int]:
if val is None:
return None
if isinstance(val, int):
return val
s = str(val).strip().lower()
if s in {"", "none", "null", "nan"}:
return None
return int(s)
def _load_json_config(path: Optional[str]) -> Dict[str, Any]:
if not path:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("JSON config must be an object")
return data
except FileNotFoundError:
raise
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Headless deck builder runner")
p.add_argument("--config", default=os.getenv("DECK_CONFIG"), help="Path to JSON config file")
p.add_argument("--commander", default=None)
p.add_argument("--primary-choice", type=int, default=None)
p.add_argument("--secondary-choice", type=_parse_opt_int, default=None)
p.add_argument("--tertiary-choice", type=_parse_opt_int, default=None)
p.add_argument("--add-lands", type=_parse_bool, default=None)
p.add_argument("--fetch-count", type=_parse_opt_int, default=None)
p.add_argument("--dual-count", type=_parse_opt_int, default=None)
p.add_argument("--triple-count", type=_parse_opt_int, default=None)
p.add_argument("--utility-count", type=_parse_opt_int, default=None)
# no seed support
# Booleans
p.add_argument("--add-creatures", type=_parse_bool, default=None)
p.add_argument("--add-non-creature-spells", type=_parse_bool, default=None)
p.add_argument("--add-ramp", type=_parse_bool, default=None)
p.add_argument("--add-removal", type=_parse_bool, default=None)
p.add_argument("--add-wipes", type=_parse_bool, default=None)
p.add_argument("--add-card-advantage", type=_parse_bool, default=None)
p.add_argument("--add-protection", type=_parse_bool, default=None)
p.add_argument("--use-multi-theme", type=_parse_bool, default=None)
p.add_argument("--dry-run", action="store_true", help="Print resolved config and exit")
p.add_argument("--auto-select-config", action="store_true", help="If set, and multiple JSON configs exist, list and prompt to choose one before running.")
return p
def _resolve_value(
cli: Optional[Any], env_name: str, json_data: Dict[str, Any], json_key: str, default: Any
) -> Any:
if cli is not None:
return cli
env_val = os.getenv(env_name)
if env_val is not None:
# Convert types based on default type
if isinstance(default, bool):
b = _parse_bool(env_val)
return default if b is None else b
if isinstance(default, int) or default is None:
# allow optional ints
try:
return _parse_opt_int(env_val)
except ValueError:
return default
return env_val
if json_key in json_data:
return json_data[json_key]
return default
def _main() -> int:
parser = _build_arg_parser()
args = parser.parse_args()
# Optional config auto-discovery/prompting
cfg_path = args.config
json_cfg: Dict[str, Any] = {}
def _discover_json_configs() -> List[str]:
# Determine directory to scan for JSON configs
if cfg_path and os.path.isdir(cfg_path):
cfg_dir = cfg_path
elif os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
try:
p = Path(cfg_dir)
return sorted([str(fp) for fp in p.glob('*.json')]) if p.exists() else []
except Exception:
return []
# If a file path is provided, load it directly
if cfg_path and os.path.isfile(cfg_path):
json_cfg = _load_json_config(cfg_path)
else:
# If auto-select is requested, we may prompt user to choose a config
configs = _discover_json_configs()
if cfg_path and os.path.isdir(cfg_path):
# Directory explicitly provided, prefer auto selection behavior
if len(configs) == 1:
json_cfg = _load_json_config(configs[0])
os.environ['DECK_CONFIG'] = configs[0]
elif len(configs) > 1 and args.auto_select_config:
def _label(p: str) -> str:
try:
with open(p, 'r', encoding='utf-8') as fh:
data = json.load(fh)
cmd = str(data.get('commander') or '').strip() or 'Unknown Commander'
themes = [t for t in [data.get('primary_tag'), data.get('secondary_tag'), data.get('tertiary_tag')] if isinstance(t, str) and t.strip()]
return f"{cmd} - {', '.join(themes)}" if themes else cmd
except Exception:
return p
print("\nAvailable JSON configs:")
for idx, f in enumerate(configs, start=1):
print(f" {idx}) {_label(f)}")
print(" 0) Cancel")
while True:
try:
sel = input("Select a config to run [0]: ").strip() or '0'
except KeyboardInterrupt:
print("")
sel = '0'
if sel == '0':
return 0
try:
i = int(sel)
if 1 <= i <= len(configs):
chosen = configs[i - 1]
json_cfg = _load_json_config(chosen)
os.environ['DECK_CONFIG'] = chosen
break
except ValueError:
pass
print("Invalid selection. Try again.")
else:
# No explicit file; if exactly one config exists, auto use it; else leave empty
if len(configs) == 1:
json_cfg = _load_json_config(configs[0])
os.environ['DECK_CONFIG'] = configs[0]
# Defaults mirror run() signature
defaults = dict(
command_name="Pantlaza",
add_creatures=True,
add_non_creature_spells=True,
add_ramp=True,
add_removal=True,
add_wipes=True,
add_card_advantage=True,
add_protection=True,
use_multi_theme=True,
primary_choice=2,
secondary_choice=2,
tertiary_choice=2,
add_lands=True,
fetch_count=3,
dual_count=None,
triple_count=None,
utility_count=None,
)
# Pull optional ideal_counts from JSON if present
ideal_counts_json = {}
try:
if isinstance(json_cfg.get("ideal_counts"), dict):
ideal_counts_json = json_cfg["ideal_counts"]
except Exception:
ideal_counts_json = {}
resolved = {
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
"add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]),
"add_non_creature_spells": _resolve_value(args.add_non_creature_spells, "DECK_ADD_NON_CREATURE_SPELLS", json_cfg, "add_non_creature_spells", defaults["add_non_creature_spells"]),
"add_ramp": _resolve_value(args.add_ramp, "DECK_ADD_RAMP", json_cfg, "add_ramp", defaults["add_ramp"]),
"add_removal": _resolve_value(args.add_removal, "DECK_ADD_REMOVAL", json_cfg, "add_removal", defaults["add_removal"]),
"add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]),
"add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]),
"add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]),
"use_multi_theme": _resolve_value(args.use_multi_theme, "DECK_USE_MULTI_THEME", json_cfg, "use_multi_theme", defaults["use_multi_theme"]),
"primary_choice": _resolve_value(args.primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]),
"secondary_choice": _resolve_value(args.secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_choice"]),
"tertiary_choice": _resolve_value(args.tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]),
"add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]),
"fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]),
"dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]),
"triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]),
"utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]),
"ideal_counts": ideal_counts_json,
}
if args.dry_run:
print(json.dumps(resolved, indent=2))
return 0
run(**resolved)
return 0
if __name__ == "__main__":
raise SystemExit(_main())

View file

@ -8,6 +8,7 @@ from __future__ import annotations
# Standard library imports
import sys
from pathlib import Path
import json
from typing import NoReturn
# Local imports
@ -26,12 +27,7 @@ logger.addHandler(logging_util.stream_handler)
builder = DeckBuilder()
def run_menu() -> NoReturn:
"""Launch directly into the deck builder after ensuring data files exist.
Creates required directories, ensures card CSVs are present (running setup
and tagging if needed), then starts the full deck build flow. Exits when done.
"""
def _ensure_data_ready() -> None:
logger.info("Starting MTG Python Deckbuilder")
Path('csv_files').mkdir(parents=True, exist_ok=True)
Path('deck_files').mkdir(parents=True, exist_ok=True)
@ -47,6 +43,9 @@ def run_menu() -> NoReturn:
logger.info("Initial setup and tagging completed.")
except Exception as e:
logger.error(f"Failed ensuring CSVs are ready: {e}")
def _interactive_loop() -> None:
while True:
try:
# Fresh builder instance for each deck to avoid state carryover
@ -54,15 +53,157 @@ def run_menu() -> NoReturn:
except Exception as e:
logger.error(f"Unexpected error in deck builder: {e}")
# Prompt to build another deck or quit
# Prompt to build another deck or return to main menu
try:
resp = input("\nBuild another deck? (y/n): ").strip().lower()
except KeyboardInterrupt:
resp = 'n'
print("")
if resp not in ('y', 'yes'):
break
def run_menu() -> NoReturn:
"""Launch directly into the deck builder after ensuring data files exist.
Creates required directories, ensures card CSVs are present (running setup
and tagging if needed), then starts the full deck build flow. Exits when done.
"""
_ensure_data_ready()
# Auto headless mode for container runs (no menu prompt)
auto_mode = os.getenv('DECK_MODE', '').strip().lower()
if auto_mode in ("headless", "noninteractive", "auto"):
try:
from headless_runner import _main as headless_main
headless_main()
except Exception as e:
logger.error(f"Headless run failed: {e}")
logger.info("Exiting application")
sys.exit(0)
# Menu-driven selection
def _run_headless_with_config(selected_config: str | None) -> None:
"""Run headless runner, optionally forcing a specific config path for this invocation."""
try:
from headless_runner import _main as headless_main
# Temporarily override DECK_CONFIG for this run if provided
prev_cfg = os.environ.get('DECK_CONFIG')
try:
if selected_config:
os.environ['DECK_CONFIG'] = selected_config
headless_main()
finally:
if selected_config is not None:
if prev_cfg is not None:
os.environ['DECK_CONFIG'] = prev_cfg
else:
os.environ.pop('DECK_CONFIG', None)
except Exception as e:
logger.error(f"Headless run failed: {e}")
def _headless_submenu() -> None:
"""Submenu to choose a JSON config and run the headless builder.
Behavior:
- If DECK_CONFIG points to a file, run it immediately.
- Else, search for *.json in (DECK_CONFIG as dir) or /app/config or ./config.
- If one file is found, run it immediately.
- If multiple files, list them for selection.
- If none, fall back to running headless using env/CLI/defaults.
"""
cfg_target = os.getenv('DECK_CONFIG')
# Case 1: DECK_CONFIG is an explicit file
if cfg_target and os.path.isfile(cfg_target):
print(f"\nRunning headless with config: {cfg_target}")
_run_headless_with_config(cfg_target)
return
# Determine directory to scan for JSON configs
if cfg_target and os.path.isdir(cfg_target):
cfg_dir = cfg_target
elif os.path.isdir('/app/config'):
cfg_dir = '/app/config'
else:
cfg_dir = 'config'
try:
p = Path(cfg_dir)
files = sorted([str(fp) for fp in p.glob('*.json')]) if p.exists() else []
except Exception:
files = []
# No configs found: run headless with current env/CLI/defaults
if not files:
print("\nNo JSON configs found in '" + cfg_dir + "'. Running headless with env/CLI/defaults...")
_run_headless_with_config(None)
return
# Single config: run automatically
if len(files) == 1:
print(f"\nFound one JSON config: {files[0]}\nRunning it now...")
_run_headless_with_config(files[0])
return
# Multiple configs: list and select
def _config_label(p: str) -> str:
try:
with open(p, 'r', encoding='utf-8') as fh:
data = json.load(fh)
cmd = str(data.get('commander') or '').strip() or 'Unknown Commander'
themes = [t for t in [data.get('primary_tag'), data.get('secondary_tag'), data.get('tertiary_tag')] if isinstance(t, str) and t.strip()]
name = os.path.basename(p).lower()
if name == 'deck.json':
return 'Default'
return f"{cmd} - {', '.join(themes)}" if themes else cmd
except Exception:
return p
print("\nAvailable JSON configs:")
labels = [_config_label(f) for f in files]
for idx, label in enumerate(labels, start=1):
print(f" {idx}) {label}")
print(" 0) Back to main menu")
while True:
try:
sel = input("Select a config to run [0]: ").strip() or '0'
except KeyboardInterrupt:
print("")
sel = '0'
if sel == '0':
return
try:
i = int(sel)
if 1 <= i <= len(files):
_run_headless_with_config(files[i - 1])
return
except ValueError:
pass
print("Invalid selection. Try again.")
while True:
print("\n==== MTG Deckbuilder ====")
print("1) Interactive deck build")
print("2) Headless (env/JSON-configured) run")
print(" - Will auto-run a single config if found, or let you choose from many")
print("q) Quit")
try:
choice = input("Select an option [1]: ").strip().lower() or '1'
except KeyboardInterrupt:
print("")
choice = 'q'
if choice in ('1', 'i', 'interactive'):
_interactive_loop()
# loop returns to main menu
elif choice in ('2', 'h', 'headless', 'noninteractive'):
_headless_submenu()
# after one headless run, return to menu
elif choice in ('q', 'quit', 'exit'):
logger.info("Exiting application")
sys.exit(0)
else:
print("Invalid selection. Please try again.")
if __name__ == "__main__":
run_menu()

View file

@ -1,128 +0,0 @@
from __future__ import annotations
from typing import List, Optional
from deck_builder.builder import DeckBuilder
"""Non-interactive harness.
Features:
- Script commander selection.
- Script primary / optional secondary / tertiary tags.
- Apply bracket & accept default ideal counts.
- Invoke multi-theme creature addition if available (fallback to primary-only).
Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic.
Indices correspond to the numbered tag list presented during interaction.
"""
def run(
command_name: str = "Pantlaza",
add_creatures: bool = True,
add_non_creature_spells: bool = True,
# Fine-grained toggles (used only if add_non_creature_spells is False)
add_ramp: bool = True,
add_removal: bool = True,
add_wipes: bool = True,
add_card_advantage: bool = True,
add_protection: bool = True,
use_multi_theme: bool = True,
primary_choice: int = 2,
secondary_choice: Optional[int] = 2,
tertiary_choice: Optional[int] = 2,
add_lands: bool = True,
fetch_count: Optional[int] = 3,
dual_count: Optional[int] = None,
triple_count: Optional[int] = None,
utility_count: Optional[int] = None,
seed: Optional[int] = None,
) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.
Integer parameters (primary_choice, secondary_choice, tertiary_choice) correspond to the
numeric indices shown during interactive tag selection. Pass None to omit secondary/tertiary.
Optional counts (fetch_count, dual_count, triple_count, utility_count) constrain land steps.
seed: optional deterministic RNG seed for reproducible builds.
"""
scripted_inputs: List[str] = []
# Commander query & selection
scripted_inputs.append(command_name) # initial query
scripted_inputs.append("1") # choose first search match to inspect
scripted_inputs.append("y") # confirm commander
# Primary tag selection
scripted_inputs.append(str(primary_choice))
# Secondary tag selection or stop (0)
if secondary_choice is not None:
scripted_inputs.append(str(secondary_choice))
# Tertiary tag selection or stop (0)
if tertiary_choice is not None:
scripted_inputs.append(str(tertiary_choice))
else:
scripted_inputs.append("0")
else:
scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value
scripted_inputs.append("3")
# Ideal count prompts (press Enter for defaults)
for _ in range(8):
scripted_inputs.append("")
def scripted_input(prompt: str) -> str:
if scripted_inputs:
return scripted_inputs.pop(0)
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
builder = DeckBuilder(input_func=scripted_input, seed=seed)
builder.run_initial_setup()
builder.run_deck_build_step1()
builder.run_deck_build_step2()
# Land sequence (optional)
if add_lands:
if hasattr(builder, 'run_land_step1'):
builder.run_land_step1() # Basics / initial
if hasattr(builder, 'run_land_step2'):
builder.run_land_step2() # Utility basics / rebalancing
if hasattr(builder, 'run_land_step3'):
builder.run_land_step3() # Kindred lands if applicable
if hasattr(builder, 'run_land_step4'):
builder.run_land_step4(requested_count=fetch_count)
if hasattr(builder, 'run_land_step5'):
builder.run_land_step5(requested_count=dual_count)
if hasattr(builder, 'run_land_step6'):
builder.run_land_step6(requested_count=triple_count)
if hasattr(builder, 'run_land_step7'):
builder.run_land_step7(requested_count=utility_count)
if hasattr(builder, 'run_land_step8'):
builder.run_land_step8()
if add_creatures:
builder.add_creatures()
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'):
builder.add_non_creature_spells()
else:
# Allow selective invocation if orchestrator not desired
if add_ramp and hasattr(builder, 'add_ramp'):
builder.add_ramp()
if add_removal and hasattr(builder, 'add_removal'):
builder.add_removal()
if add_wipes and hasattr(builder, 'add_board_wipes'):
builder.add_board_wipes()
if add_card_advantage and hasattr(builder, 'add_card_advantage'):
builder.add_card_advantage()
if add_protection and hasattr(builder, 'add_protection'):
builder.add_protection()
# Suppress verbose library print in non-interactive run since CSV export is produced.
# builder.print_card_library()
builder.post_spell_land_adjust()
# Export decklist CSV (commander first word + date)
if hasattr(builder, 'export_decklist_csv'):
builder.export_decklist_csv()
return builder
if __name__ == "__main__":
run()

View file

@ -115,16 +115,34 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
## Tag for various effects
tag_for_cost_reduction(df, color)
print('\n====================\n')
# Freerunning is a keyworded cost-reduction mechanic
tag_for_freerunning(df, color)
print('\n====================\n')
tag_for_card_draw(df, color)
print('\n====================\n')
# Discard-centric effects and triggers
tag_for_discard_matters(df, color)
print('\n====================\n')
# Explore and Map tokens provide selection and incidental counters
tag_for_explore_and_map(df, color)
print('\n====================\n')
tag_for_artifacts(df, color)
print('\n====================\n')
tag_for_enchantments(df, color)
print('\n====================\n')
# Craft is a transform mechanic that often references artifacts, exile, and graveyards
tag_for_craft(df, color)
print('\n====================\n')
tag_for_exile_matters(df, color)
print('\n====================\n')
# Custom keywords/mechanics
tag_for_bending(df, color)
print('\n====================\n')
tag_for_tokens(df, color)
print('\n====================\n')
# Rad counters are tracked separately to surface the theme
tag_for_rad_counters(df, color)
print('\n====================\n')
tag_for_life_matters(df, color)
print('\n====================\n')
tag_for_counters(df, color)
@ -135,6 +153,9 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
print('\n====================\n')
tag_for_spellslinger(df, color)
print('\n====================\n')
# Spree spells are modal and cost-scale via additional payments
tag_for_spree(df, color)
print('\n====================\n')
tag_for_ramp(df, color)
print('\n====================\n')
tag_for_themes(df, color)
@ -810,19 +831,19 @@ def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None:
# Apply tags based on masks
if loot_mask.any():
tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {loot_mask.sum()} cards with standard loot effects')
if connive_mask.any():
tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {connive_mask.sum()} cards with connive effects')
if cycling_mask.any():
tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {cycling_mask.sum()} cards with cycling effects')
if blood_mask.any():
tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw'])
tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw', 'Discard Matters'])
logger.info(f'Tagged {blood_mask.sum()} cards with blood token effects')
logger.info('Completed tagging loot-like effects')
@ -2136,6 +2157,15 @@ def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None:
logger.info('Completed Suspend tagging')
print('\n==========\n')
tag_for_warp(df, color)
logger.info('Completed Warp tagging')
print('\n==========\n')
# New: Time counters and Time Travel support
tag_for_time_counters(df, color)
logger.info('Completed Time Counters tagging')
print('\n==========\n')
# Log completion and performance metrics
duration = pd.Timestamp.now() - start_time
logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s')
@ -2471,6 +2501,98 @@ def tag_for_suspend(df: pd.DataFrame, color: str) -> None:
logger.info('Completed tagging Suspend cards')
## Cards that have or care about Warp
def tag_for_warp(df: pd.DataFrame, color: str) -> None:
"""Tag cards with Warp using vectorized operations.
Args:
df: DataFrame containing card data
color: Color identifier for logging purposes
"""
logger.info(f'Tagging Warp cards in {color}_cards.csv')
start_time = pd.Timestamp.now()
try:
# Create masks for Warp
keyword_mask = tag_utils.create_keyword_mask(df, 'Warp')
text_mask = tag_utils.create_text_mask(df, 'Warp')
final_mask = keyword_mask | text_mask
tag_utils.apply_rules(df, [{ 'mask': final_mask, 'tags': ['Warp', 'Exile Matters'] }])
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info(f'Tagged {final_mask.sum()} Warp cards in {duration:.2f}s')
except Exception as e:
logger.error(f'Error tagging Warp cards: {str(e)}')
raise
logger.info('Completed tagging Warp cards')
def create_time_counters_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards that mention time counters or Time Travel.
This captures interactions commonly associated with Suspend without
requiring the Suspend keyword (e.g., Time Travel effects, adding/removing
time counters, or Vanishing).
Args:
df: DataFrame to search
Returns:
Boolean Series indicating which cards interact with time counters
"""
# Text patterns around time counters and time travel
text_patterns = [
'time counter',
'time counters',
'remove a time counter',
'add a time counter',
'time travel'
]
text_mask = tag_utils.create_text_mask(df, text_patterns)
# Keyword-based patterns that imply time counters
keyword_mask = tag_utils.create_keyword_mask(df, ['Vanishing'])
return text_mask | keyword_mask
def tag_for_time_counters(df: pd.DataFrame, color: str) -> None:
"""Tag cards that interact with time counters or Time Travel.
Applies a base 'Time Counters' tag. Adds 'Exile Matters' when the card also
mentions exile or Suspend, since those imply interaction with suspended
cards in exile.
Args:
df: DataFrame containing card data
color: Color identifier for logging purposes
"""
logger.info(f'Tagging Time Counters interactions in {color}_cards.csv')
start_time = pd.Timestamp.now()
try:
time_mask = create_time_counters_mask(df)
if not time_mask.any():
logger.info('No Time Counters interactions found')
return
# Always tag Time Counters
tag_utils.apply_rules(df, [{ 'mask': time_mask, 'tags': ['Time Counters'] }])
# Conditionally add Exile Matters if the card references exile or suspend
exile_mask = tag_utils.create_text_mask(df, tag_constants.PATTERN_GROUPS['exile'])
suspend_mask = tag_utils.create_keyword_mask(df, 'Suspend') | tag_utils.create_text_mask(df, 'Suspend')
time_exile_mask = time_mask & (exile_mask | suspend_mask)
if time_exile_mask.any():
tag_utils.apply_rules(df, [{ 'mask': time_exile_mask, 'tags': ['Exile Matters'] }])
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info('Completed Time Counters tagging in %.2fs', duration)
except Exception as e:
logger.error(f'Error tagging Time Counters interactions: {str(e)}')
raise
### Tokens
def create_creature_token_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards that create creature tokens.
@ -2591,6 +2713,19 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
{ 'mask': matters_mask, 'tags': ['Tokens Matter'] },
])
# Eldrazi Spawn/Scion special-casing: add Aristocrats and Ramp synergy tags
spawn_patterns = [
'eldrazi spawn creature token',
'eldrazi scion creature token',
'spawn creature token with "sacrifice',
'scion creature token with "sacrifice'
]
spawn_scion_mask = tag_utils.create_text_mask(df, spawn_patterns)
if spawn_scion_mask.any():
tag_utils.apply_rules(df, [
{ 'mask': spawn_scion_mask, 'tags': ['Aristocrats', 'Ramp'] }
])
# Logging
if creature_mask.any():
logger.info('Tagged %d cards that create creature tokens', creature_mask.sum())
@ -2606,6 +2741,162 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
logger.error('Error tagging token cards: %s', str(e))
raise
### Freerunning (cost reduction variant)
def tag_for_freerunning(df: pd.DataFrame, color: str) -> None:
"""Tag cards that reference the Freerunning mechanic.
Adds Cost Reduction to ensure consistency, and a specific Freerunning tag for filtering.
"""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
mask = tag_utils.create_keyword_mask(df, 'Freerunning') | tag_utils.create_text_mask(df, ['freerunning', 'free running'])
if mask.any():
tag_utils.apply_rules(df, [
{ 'mask': mask, 'tags': ['Cost Reduction', 'Freerunning'] }
])
logger.info('Tagged %d Freerunning cards', mask.sum())
except Exception as e:
logger.error('Error tagging Freerunning: %s', str(e))
raise
### Craft (transform mechanic with exile/graveyard/artifact hooks)
def tag_for_craft(df: pd.DataFrame, color: str) -> None:
"""Tag cards with Craft. Adds Transform; conditionally adds Artifacts Matter, Exile Matters, and Graveyard Matters."""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
craft_mask = tag_utils.create_keyword_mask(df, 'Craft') | tag_utils.create_text_mask(df, ['craft with', 'craft —', ' craft '])
if craft_mask.any():
rules = [{ 'mask': craft_mask, 'tags': ['Transform'] }]
# Conditionals
artifact_cond = craft_mask & tag_utils.create_text_mask(df, ['artifact', 'artifacts'])
exile_cond = craft_mask & tag_utils.create_text_mask(df, ['exile'])
gy_cond = craft_mask & tag_utils.create_text_mask(df, ['graveyard'])
if artifact_cond.any():
rules.append({ 'mask': artifact_cond, 'tags': ['Artifacts Matter'] })
if exile_cond.any():
rules.append({ 'mask': exile_cond, 'tags': ['Exile Matters'] })
if gy_cond.any():
rules.append({ 'mask': gy_cond, 'tags': ['Graveyard Matters'] })
tag_utils.apply_rules(df, rules)
logger.info('Tagged %d Craft cards', craft_mask.sum())
except Exception as e:
logger.error('Error tagging Craft: %s', str(e))
raise
### Spree (modal, cost-scaling spells)
def tag_for_spree(df: pd.DataFrame, color: str) -> None:
"""Tag Spree spells with Modal and Cost Scaling."""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
mask = tag_utils.create_keyword_mask(df, 'Spree') | tag_utils.create_text_mask(df, ['spree'])
if mask.any():
tag_utils.apply_rules(df, [
{ 'mask': mask, 'tags': ['Modal', 'Cost Scaling'] }
])
logger.info('Tagged %d Spree cards', mask.sum())
except Exception as e:
logger.error('Error tagging Spree: %s', str(e))
raise
### Explore and Map tokens
def tag_for_explore_and_map(df: pd.DataFrame, color: str) -> None:
"""Tag Explore and Map token interactions.
- Explore: add Card Selection; if it places +1/+1 counters, add +1/+1 Counters
- Map Tokens: add Card Selection and Tokens Matter
"""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
explore_mask = tag_utils.create_keyword_mask(df, 'Explore') | tag_utils.create_text_mask(df, ['explores', 'explore.'])
map_mask = tag_utils.create_text_mask(df, ['map token', 'map tokens'])
rules = []
if explore_mask.any():
rules.append({ 'mask': explore_mask, 'tags': ['Card Selection'] })
# If the text also references +1/+1 counters, add that theme
explore_counters = explore_mask & tag_utils.create_text_mask(df, ['+1/+1 counter'])
if explore_counters.any():
rules.append({ 'mask': explore_counters, 'tags': ['+1/+1 Counters'] })
if map_mask.any():
rules.append({ 'mask': map_mask, 'tags': ['Card Selection', 'Tokens Matter'] })
if rules:
tag_utils.apply_rules(df, rules)
total = (explore_mask.astype(int) + map_mask.astype(int)).astype(bool).sum()
logger.info('Tagged %d Explore/Map cards', total)
except Exception as e:
logger.error('Error tagging Explore/Map: %s', str(e))
raise
### Rad counters
def tag_for_rad_counters(df: pd.DataFrame, color: str) -> None:
"""Tag Rad counter interactions as a dedicated theme."""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
rad_mask = tag_utils.create_text_mask(df, ['rad counter', 'rad counters'])
if rad_mask.any():
tag_utils.apply_rules(df, [ { 'mask': rad_mask, 'tags': ['Rad Counters'] } ])
logger.info('Tagged %d Rad counter cards', rad_mask.sum())
except Exception as e:
logger.error('Error tagging Rad counters: %s', str(e))
raise
### Discard Matters
def tag_for_discard_matters(df: pd.DataFrame, color: str) -> None:
"""Tag cards that discard or care about discarding.
Adds Discard Matters for:
- Text that makes you discard a card (costs or effects)
- Triggers on discarding
Also adds Loot where applicable is handled elsewhere; this focuses on the theme surface.
"""
try:
required = {'text', 'themeTags'}
tag_utils.validate_dataframe_columns(df, required)
# Events where YOU discard (as part of a cost or effect). Keep generic 'discard a card' but filter out opponent/each-player cases.
discard_action_patterns = [
r'you discard (?:a|one|two|three|x) card',
r'discard (?:a|one|two|three|x) card',
r'discard your hand',
r'as an additional cost to (?:cast this spell|activate this ability),? discard (?:a|one) card',
r'as an additional cost,? discard (?:a|one) card'
]
action_mask = tag_utils.create_text_mask(df, discard_action_patterns)
exclude_opponent_patterns = [
r'target player discards',
r'target opponent discards',
r'each player discards',
r'each opponent discards',
r'that player discards'
]
exclude_mask = tag_utils.create_text_mask(df, exclude_opponent_patterns)
# Triggers/conditions that care when you discard
discard_trigger_patterns = [
r'whenever you discard',
r'if you discarded',
r'for each card you discarded',
r'when you discard'
]
trigger_mask = tag_utils.create_text_mask(df, discard_trigger_patterns)
# Blood tokens enable rummage (discard), and Madness explicitly cares about discarding
blood_patterns = [r'create (?:a|one|two|three|x|\d+) blood token']
blood_mask = tag_utils.create_text_mask(df, blood_patterns)
madness_mask = tag_utils.create_text_mask(df, [r'\bmadness\b'])
final_mask = ((action_mask & ~exclude_mask) | trigger_mask | blood_mask | madness_mask)
if final_mask.any():
tag_utils.apply_rules(df, [ { 'mask': final_mask, 'tags': ['Discard Matters'] } ])
logger.info('Tagged %d cards for Discard Matters', final_mask.sum())
except Exception as e:
logger.error('Error tagging Discard Matters: %s', str(e))
raise
### Life Matters
def tag_for_life_matters(df: pd.DataFrame, color: str) -> None:
"""Tag cards that care about life totals, life gain/loss, and related effects using vectorized operations.
@ -4195,6 +4486,47 @@ def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None:
logger.error(f'Error in tag_for_aristocrats: {str(e)}')
raise
### Bending
def tag_for_bending(df: pd.DataFrame, color: str) -> None:
"""Tag cards for bending-related keywords.
Looks for 'airbend', 'waterbend', 'firebend', 'earthbend' in rules text and
applies tags accordingly.
"""
logger.info(f'Tagging Bending keywords in {color}_cards.csv')
start_time = pd.Timestamp.now()
try:
rules = []
air_mask = tag_utils.create_text_mask(df, 'airbend')
if air_mask.any():
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] })
water_mask = tag_utils.create_text_mask(df, 'waterbend')
if water_mask.any():
rules.append({ 'mask': water_mask, 'tags': ['Waterbending', 'Cost Reduction', 'Big Mana'] })
fire_mask = tag_utils.create_text_mask(df, 'firebend')
if fire_mask.any():
rules.append({ 'mask': fire_mask, 'tags': ['Aggro', 'Combat Matters', 'Firebending', 'Mana Dork', 'Ramp', 'X Spells'] })
earth_mask = tag_utils.create_text_mask(df, 'earthbend')
if earth_mask.any():
rules.append({ 'mask': earth_mask, 'tags': ['Earthbend', 'Lands Matter', 'Landfall'] })
if rules:
tag_utils.apply_rules(df, rules)
total = sum(int(r['mask'].sum()) for r in rules)
logger.info('Tagged %d cards with Bending keywords', total)
else:
logger.info('No Bending keywords found')
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info('Completed Bending tagging in %.2fs', duration)
except Exception as e:
logger.error(f'Error tagging Bending keywords: {str(e)}')
raise
## Big Mana
def create_big_mana_cost_mask(df: pd.DataFrame) -> pd.Series:
@ -4766,11 +5098,11 @@ def tag_for_energy(df: pd.DataFrame, color: str) -> None:
energy_patterns = [r'\{e\}', 'energy counter', 'energy counters']
energy_mask = tag_utils.create_text_mask(df, energy_patterns)
# Apply tags via rules engine
# Apply tags via rules engine (also mark as a Resource Engine per request)
tag_utils.apply_rules(df, rules=[
{
'mask': energy_mask,
'tags': ['Energy']
'tags': ['Energy', 'Resource Engine']
}
])

22
config/deck.json Normal file
View file

@ -0,0 +1,22 @@
{
"commander": "Aang, Airbending Master",
"primary_tag": "Experience Counters",
"secondary_tag": "Token Creation",
"tertiary_tag": null,
"bracket_level": 4,
"use_multi_theme": true,
"add_lands": true,
"add_creatures": true,
"add_non_creature_spells": true,
"fetch_count": 3,
"ideal_counts": {
"ramp": 8,
"lands": 35,
"basic_lands": 15,
"creatures": 25,
"removal": 10,
"wipes": 2,
"card_advantage": 10,
"protection": 8
}
}

View file

@ -8,9 +8,16 @@ services:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs
- ${PWD}/csv_files:/app/csv_files
# Optional: mount a config directory for headless JSON
- ${PWD}/config:/app/config
environment:
- PYTHONUNBUFFERED=1
- TERM=xterm-256color
- DEBIAN_FRONTEND=noninteractive
# Set DECK_MODE=headless to auto-run non-interactive mode on start
# - DECK_MODE=headless
# Optional headless configuration (examples):
# - DECK_CONFIG=/app/config/deck.json
# - DECK_COMMANDER=Pantlaza
# Ensure proper cleanup
restart: "no"

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mtg-deckbuilder"
version = "1.0.0"
version = "1.1.0"
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md"
license = {file = "LICENSE"}
@ -25,15 +25,9 @@ classifiers = [
requires-python = ">=3.11" # This is what it was built with anyway
dependencies = [
"pandas>=1.5.0",
"inquirer>=3.1.3",
"typing_extensions>=4.5.0",
"fuzzywuzzy>=0.18.0",
"python-Levenshtein>=0.12.0",
"tqdm>=4.66.0",
"scrython>=1.10.0",
"numpy>=1.24.0",
"requests>=2.31.0",
"prettytable>=3.9.0",
"tqdm>=4.66.0",
]
[project.optional-dependencies]
@ -42,6 +36,12 @@ dev = [
"pandas-stubs>=2.0.0",
"pytest>=8.0.0",
]
reporting = [
"prettytable>=3.9.0",
]
pricecheck = [
"scrython>=1.10.0",
]
[project.scripts]
mtg-deckbuilder = "code.main:run_menu"

View file

@ -1,15 +1,8 @@
pandas>=1.5.0
inquirer>=3.1.3
typing_extensions>=4.5.0
fuzzywuzzy>=0.18.0
python-Levenshtein>=0.12.0
tqdm>=4.66.0
scrython>=1.10.0
numpy>=1.24.0
requests>=2.31.0
tqdm>=4.66.0
# Optional pretty output in reports; app falls back gracefully if missing
prettytable>=3.9.0
# Development dependencies
mypy>=1.3.0
pandas-stubs>=2.0.0
pytest>=8.0.0
# Development dependencies are in requirements-dev.txt