Merge pull request #4 from mwisnowski:docker_test

Docker_test
This commit is contained in:
mwisnowski 2025-08-21 10:27:58 -07:00 committed by GitHub
commit 9950838520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 867 additions and 4609 deletions

24
.dockerignore Normal file
View file

@ -0,0 +1,24 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
.venv/
.env
logs/*.log
*.egg-info/

3
.gitignore vendored
View file

@ -14,4 +14,5 @@ logs/
non_interactive_test.py
test_determinism.py
test.py
deterministic_test.py
deterministic_test.py
build.ps1

221
DOCKER.md Normal file
View file

@ -0,0 +1,221 @@
# Docker Guide for MTG Python Deckbuilder
A comprehensive guide for running the MTG Python Deckbuilder in Docker containers with full file persistence and cross-platform support.
## 🚀 Quick Start
### Linux/macOS/Remote Host
```bash
# Make scripts executable (one time only)
chmod +x quick-start.sh run-docker-linux.sh
# Simplest method - just run this:
./quick-start.sh
# Or use the full script with options:
./run-docker-linux.sh compose
```
### Windows (PowerShell)
```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-linux.sh setup` | Create directories and check Docker installation |
| `./run-docker-linux.sh build` | Build the Docker image |
| `./run-docker-linux.sh compose` | Run with Docker Compose (recommended) |
| `./run-docker-linux.sh run` | Run with manual volume mounting |
| `./run-docker-linux.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`
### `docker-compose.interactive.yml` (Alternative)
- Identical functionality
- Container name: `mtg-deckbuilder-interactive`
- Use with: `docker compose -f docker compose.interactive.yml run --rm mtg-deckbuilder-interactive`
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
docker compose -f docker-compose.interactive.yml down
# Remove image
docker rmi mtg-deckbuilder
# Clean up system
docker system prune -f
# Rebuild
docker compose build
```
## 🔍 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
```
4. **Run again** - your data should still be there!
## 🎯 Best Practices
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-linux.sh help
```

46
Dockerfile Normal file
View file

@ -0,0 +1,46 @@
# Use Python 3.11 slim image for smaller size
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Install system dependencies if needed
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY code/ ./code/
COPY csv_files/ ./csv_files/
COPY mypy.ini .
# Create necessary directories as mount points
RUN mkdir -p deck_files logs csv_files
# Create volumes for persistent data
VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files"]
# Create symbolic links BEFORE changing working directory
RUN cd /app/code && \
ln -sf /app/deck_files ./deck_files && \
ln -sf /app/logs ./logs && \
ln -sf /app/csv_files ./csv_files
# Verify symbolic links were created
RUN cd /app/code && ls -la deck_files logs csv_files
# Set the working directory to code for proper imports
WORKDIR /app/code
# Run the application
CMD ["python", "main.py"]

View file

@ -364,7 +364,7 @@ LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
# Other defaults
DEFAULT_CREATURE_COUNT: Final[int] = 35 # Default number of creatures
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes

View file

@ -1,7 +0,0 @@
from .builder import DeckBuilder
from .builder_utils import *
from .builder_constants import *
__all__ = [
'DeckBuilder',
]

View file

@ -1,437 +0,0 @@
from typing import Dict, List, Final, Tuple, Union, Callable
from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified
__all__ = [
'CSV_REQUIRED_COLUMNS'
]
import ast
# Commander selection configuration
# Format string for displaying duplicate cards in deck lists
FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching
MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
# Commander-related constants
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv'
DECK_DIRECTORY = '../deck_files'
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
COMMANDER_POWER_DEFAULT: Final[int] = 0
COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0
COMMANDER_MANA_VALUE_DEFAULT: Final[int] = 0
COMMANDER_TYPE_DEFAULT: Final[str] = ''
COMMANDER_TEXT_DEFAULT: Final[str] = ''
COMMANDER_MANA_COST_DEFAULT: Final[str] = ''
COMMANDER_COLOR_IDENTITY_DEFAULT: Final[str] = ''
COMMANDER_COLORS_DEFAULT: Final[List[str]] = []
COMMANDER_CREATURE_TYPES_DEFAULT: Final[str] = ''
COMMANDER_TAGS_DEFAULT: Final[List[str]] = []
COMMANDER_THEMES_DEFAULT: Final[List[str]] = []
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
'Kindred', 'Dungeon', 'Battle']
# Basic mana colors
MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G']
# Mana pip patterns for each color
MANA_PIP_PATTERNS: Final[Dict[str, str]] = {
color: f'{{{color}}}' for color in MANA_COLORS
}
MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = {
'COLORLESS': ('Colorless', ['colorless']),
'W': ('White', ['colorless', 'white']),
'U': ('Blue', ['colorless', 'blue']),
'B': ('Black', ['colorless', 'black']),
'R': ('Red', ['colorless', 'red']),
'G': ('Green', ['colorless', 'green'])
}
DUAL_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
'B, G': ('Golgari: Black/Green', ['B', 'G', 'B, G'], ['colorless', 'black', 'green', 'golgari']),
'B, R': ('Rakdos: Black/Red', ['B', 'R', 'B, R'], ['colorless', 'black', 'red', 'rakdos']),
'B, U': ('Dimir: Black/Blue', ['B', 'U', 'B, U'], ['colorless', 'black', 'blue', 'dimir']),
'B, W': ('Orzhov: Black/White', ['B', 'W', 'B, W'], ['colorless', 'black', 'white', 'orzhov']),
'G, R': ('Gruul: Green/Red', ['G', 'R', 'G, R'], ['colorless', 'green', 'red', 'gruul']),
'G, U': ('Simic: Green/Blue', ['G', 'U', 'G, U'], ['colorless', 'green', 'blue', 'simic']),
'G, W': ('Selesnya: Green/White', ['G', 'W', 'G, W'], ['colorless', 'green', 'white', 'selesnya']),
'R, U': ('Izzet: Blue/Red', ['U', 'R', 'U, R'], ['colorless', 'blue', 'red', 'izzet']),
'U, W': ('Azorius: Blue/White', ['U', 'W', 'U, W'], ['colorless', 'blue', 'white', 'azorius']),
'R, W': ('Boros: Red/White', ['R', 'W', 'R, W'], ['colorless', 'red', 'white', 'boros'])
}
TRI_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
'B, G, U': ('Sultai: Black/Blue/Green', ['B', 'G', 'U', 'B, G', 'B, U', 'G, U', 'B, G, U'],
['colorless', 'black', 'blue', 'green', 'dimir', 'golgari', 'simic', 'sultai']),
'B, G, R': ('Jund: Black/Red/Green', ['B', 'G', 'R', 'B, G', 'B, R', 'G, R', 'B, G, R'],
['colorless', 'black', 'green', 'red', 'golgari', 'rakdos', 'gruul', 'jund']),
'B, G, W': ('Abzan: Black/Green/White', ['B', 'G', 'W', 'B, G', 'B, W', 'G, W', 'B, G, W'],
['colorless', 'black', 'green', 'white', 'golgari', 'orzhov', 'selesnya', 'abzan']),
'B, R, U': ('Grixis: Black/Blue/Red', ['B', 'R', 'U', 'B, R', 'B, U', 'R, U', 'B, R, U'],
['colorless', 'black', 'blue', 'red', 'dimir', 'rakdos', 'izzet', 'grixis']),
'B, R, W': ('Mardu: Black/Red/White', ['B', 'R', 'W', 'B, R', 'B, W', 'R, W', 'B, R, W'],
['colorless', 'black', 'red', 'white', 'rakdos', 'orzhov', 'boros', 'mardu']),
'B, U, W': ('Esper: Black/Blue/White', ['B', 'U', 'W', 'B, U', 'B, W', 'U, W', 'B, U, W'],
['colorless', 'black', 'blue', 'white', 'dimir', 'orzhov', 'azorius', 'esper']),
'G, R, U': ('Temur: Blue/Green/Red', ['G', 'R', 'U', 'G, R', 'G, U', 'R, U', 'G, R, U'],
['colorless', 'green', 'red', 'blue', 'simic', 'izzet', 'gruul', 'temur']),
'G, R, W': ('Naya: Green/Red/White', ['G', 'R', 'W', 'G, R', 'G, W', 'R, W', 'G, R, W'],
['colorless', 'green', 'red', 'white', 'gruul', 'selesnya', 'boros', 'naya']),
'G, U, W': ('Bant: Blue/Green/White', ['G', 'U', 'W', 'G, U', 'G, W', 'U, W', 'G, U, W'],
['colorless', 'green', 'blue', 'white', 'simic', 'azorius', 'selesnya', 'bant']),
'R, U, W': ('Jeskai: Blue/Red/White', ['R', 'U', 'W', 'R, U', 'U, W', 'R, W', 'R, U, W'],
['colorless', 'blue', 'red', 'white', 'izzet', 'azorius', 'boros', 'jeskai'])
}
OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
'B, G, R, U': ('Glint: Black/Blue/Green/Red',
['B', 'G', 'R', 'U', 'B, G', 'B, R', 'B, U', 'G, R', 'G, U', 'R, U', 'B, G, R',
'B, G, U', 'B, R, U', 'G, R, U', 'B, G, R, U'],
['colorless', 'black', 'blue', 'green', 'red', 'golgari', 'rakdos', 'dimir',
'gruul', 'simic', 'izzet', 'jund', 'sultai', 'grixis', 'temur', 'glint']),
'B, G, R, W': ('Dune: Black/Green/Red/White',
['B', 'G', 'R', 'W', 'B, G', 'B, R', 'B, W', 'G, R', 'G, W', 'R, W', 'B, G, R',
'B, G, W', 'B, R, W', 'G, R, W', 'B, G, R, W'],
['colorless', 'black', 'green', 'red', 'white', 'golgari', 'rakdos', 'orzhov',
'gruul', 'selesnya', 'boros', 'jund', 'abzan', 'mardu', 'naya', 'dune']),
'B, G, U, W': ('Witch: Black/Blue/Green/White',
['B', 'G', 'U', 'W', 'B, G', 'B, U', 'B, W', 'G, U', 'G, W', 'U, W', 'B, G, U',
'B, G, W', 'B, U, W', 'G, U, W', 'B, G, U, W'],
['colorless', 'black', 'blue', 'green', 'white', 'golgari', 'dimir', 'orzhov',
'simic', 'selesnya', 'azorius', 'sultai', 'abzan', 'esper', 'bant', 'witch']),
'B, R, U, W': ('Yore: Black/Blue/Red/White',
['B', 'R', 'U', 'W', 'B, R', 'B, U', 'B, W', 'R, U', 'R, W', 'U, W', 'B, R, U',
'B, R, W', 'B, U, W', 'R, U, W', 'B, R, U, W'],
['colorless', 'black', 'blue', 'red', 'white', 'rakdos', 'dimir', 'orzhov',
'izzet', 'boros', 'azorius', 'grixis', 'mardu', 'esper', 'jeskai', 'yore']),
'G, R, U, W': ('Ink: Blue/Green/Red/White',
['G', 'R', 'U', 'W', 'G, R', 'G, U', 'G, W', 'R, U', 'R, W', 'U, W', 'G, R, U',
'G, R, W', 'G, U, W', 'R, U, W', 'G, R, U, W'],
['colorless', 'blue', 'green', 'red', 'white', 'gruul', 'simic', 'selesnya',
'izzet', 'boros', 'azorius', 'temur', 'naya', 'bant', 'jeskai', 'ink']),
'B, G, R, U, W': ('WUBRG: All colors',
['B', 'G', 'R', 'U', 'W', 'B, G', 'B, R', 'B, U', 'B, W', 'G, R', 'G, U',
'G, W', 'R, U', 'R, W', 'U, W', 'B, G, R', 'B, G, U', 'B, G, W', 'B, R, U',
'B, R, W', 'B, U, W', 'G, R, U', 'G, R, W', 'G, U, W', 'R, U, W',
'B, G, R, U', 'B, G, R, W', 'B, G, U, W', 'B, R, U, W', 'G, R, U, W',
'B, G, R, U, W'],
['colorless', 'black', 'green', 'red', 'blue', 'white', 'golgari', 'rakdos',
'dimir', 'orzhov', 'gruul', 'simic', 'selesnya', 'izzet', 'boros', 'azorius',
'jund', 'sultai', 'abzan', 'grixis', 'mardu', 'esper', 'temur', 'naya',
'bant', 'jeskai', 'glint', 'dune', 'witch', 'yore', 'ink', 'wubrg'])
}
# Price checking configuration
DEFAULT_PRICE_DELAY: Final[float] = 0.1 # Delay between price checks in seconds
MAX_PRICE_CHECK_ATTEMPTS: Final[int] = 3 # Maximum attempts for price checking
PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache
PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
# Deck composition defaults
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count
DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color
# Miscellaneous land configuration
MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add
MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add
MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from
# Default fetch land count
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
# Basic Lands
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
# Basic land mappings
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
'W': 'Plains',
'U': 'Island',
'B': 'Swamp',
'R': 'Mountain',
'G': 'Forest',
'C': 'Wastes'
}
# Dual land type mappings
DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = {
'azorius': 'Plains Island',
'dimir': 'Island Swamp',
'rakdos': 'Swamp Mountain',
'gruul': 'Mountain Forest',
'selesnya': 'Forest Plains',
'orzhov': 'Plains Swamp',
'golgari': 'Swamp Forest',
'simic': 'Forest Island',
'izzet': 'Island Mountain',
'boros': 'Mountain Plains'
}
# Triple land type mappings
TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = {
'bant': 'Forest Plains Island',
'esper': 'Plains Island Swamp',
'grixis': 'Island Swamp Mountain',
'jund': 'Swamp Mountain Forest',
'naya': 'Mountain Forest Plains',
'mardu': 'Mountain Plains Swamp',
'abzan': 'Plains Swamp Forest',
'sultai': 'Swamp Forest Island',
'temur': 'Forest Island Mountain',
'jeskai': 'Island Mountain Plains'
}
# Default preference for including dual lands
DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True
# Default preference for including triple lands
DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True
SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island',
'B': 'Snow-Covered Swamp',
'G': 'Snow-Covered Forest'
}
SNOW_BASIC_LAND_MAPPING: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island',
'B': 'Snow-Covered Swamp',
'R': 'Snow-Covered Mountain',
'G': 'Snow-Covered Forest',
'C': 'Wastes' # Note: No snow-covered version exists for Wastes
}
# Generic fetch lands list
GENERIC_FETCH_LANDS: Final[List[str]] = [
'Evolving Wilds',
'Terramorphic Expanse',
'Shire Terrace',
'Escape Tunnel',
'Promising Vein',
'Myriad Landscape',
'Fabled Passage',
'Terminal Moraine',
'Prismatic Vista'
]
# Kindred land constants
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
{
'name': 'Path of Ancestry',
'type': 'Land'
},
{
'name': 'Three Tree City',
'type': 'Legendary Land'
},
{'name': 'Cavern of Souls', 'type': 'Land'}
]
# Color-specific fetch land mappings
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
'W': [
'Flooded Strand',
'Windswept Heath',
'Marsh Flats',
'Arid Mesa',
'Brokers Hideout',
'Obscura Storefront',
'Cabaretti Courtyard'
],
'U': [
'Flooded Strand',
'Polluted Delta',
'Scalding Tarn',
'Misty Rainforest',
'Brokers Hideout',
'Obscura Storefront',
'Maestros Theater'
],
'B': [
'Polluted Delta',
'Bloodstained Mire',
'Marsh Flats',
'Verdant Catacombs',
'Obscura Storefront',
'Maestros Theater',
'Riveteers Overlook'
],
'R': [
'Bloodstained Mire',
'Wooded Foothills',
'Scalding Tarn',
'Arid Mesa',
'Maestros Theater',
'Riveteers Overlook',
'Cabaretti Courtyard'
],
'G': [
'Wooded Foothills',
'Windswept Heath',
'Verdant Catacombs',
'Misty Rainforest',
'Brokers Hideout',
'Riveteers Overlook',
'Cabaretti Courtyard'
]
}
# Staple land conditions mapping
STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bool]]] = {
'Reliquary Tower': lambda commander_tags, colors, commander_power: True, # Always include
'Ash Barrens': lambda commander_tags, colors, commander_power: 'Landfall' not in commander_tags,
'Command Tower': lambda commander_tags, colors, commander_power: len(colors) > 1,
'Exotic Orchard': lambda commander_tags, colors, commander_power: len(colors) > 1,
'War Room': lambda commander_tags, colors, commander_power: len(colors) <= 2,
'Rogue\'s Passage': lambda commander_tags, colors, commander_power: commander_power >= 5
}
# Constants for land removal functionality
LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
# Protected lands that cannot be removed during land removal process
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
# Other defaults
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes
DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells
# Deck composition prompts
DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
'ramp': 'Enter desired number of ramp pieces (default: 8):',
'lands': 'Enter desired number of total lands (default: 35):',
'basic_lands': 'Enter minimum number of basic lands (default: 20):',
'creatures': 'Enter desired number of creatures (default: 25):',
'removal': 'Enter desired number of spot removal spells (default: 10):',
'wipes': 'Enter desired number of board wipes (default: 2):',
'card_advantage': 'Enter desired number of card advantage pieces (default: 10):',
'protection': 'Enter desired number of protection spells (default: 8):',
'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):',
'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):'
}
DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price
BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch
# Constants for input validation
# Type aliases
CardName = str
CardType = str
ThemeTag = str
ColorIdentity = str
ColorList = List[str]
ColorInfo = Tuple[str, List[str], List[str]]
INPUT_VALIDATION = {
'max_attempts': 3,
'default_text_message': 'Please enter a valid text response.',
'default_number_message': 'Please enter a valid number.',
'default_confirm_message': 'Please enter Y/N or Yes/No.',
'default_choice_message': 'Please select a valid option from the list.'
}
QUESTION_TYPES = [
'Text',
'Number',
'Confirm',
'Choice'
]
# Constants for theme weight management and selection
# Multiplier for initial card pool size during theme-based selection
THEME_POOL_SIZE_MULTIPLIER: Final[float] = 2.0
# Bonus multiplier for cards that match multiple deck themes
THEME_PRIORITY_BONUS: Final[float] = 1.2
# Safety multiplier to avoid overshooting target counts
THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
'primary': 1.0,
'secondary': 0.6,
'tertiary': 0.3,
'hidden': 0.0
}
WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = {
'kindred_primary': 1.5, # Boost for Kindred themes as primary
'kindred_secondary': 1.3, # Boost for Kindred themes as secondary
'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary
'theme_synergy': 1.2 # Boost for themes that work well together
}
DEFAULT_THEME_TAGS = [
'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink',
'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones',
'Combat Matters', 'Control', 'Counters Matter', 'Energy',
'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect',
'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter',
'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate',
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Super Friends',
'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells'
]
# CSV processing configuration
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 validation configuration
CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = {
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'}
}
# (CSV_REQUIRED_COLUMNS imported from settings to avoid duplication)
# DataFrame processing configuration
BATCH_SIZE: Final[int] = 1000 # Number of records to process at once
DATAFRAME_BATCH_SIZE: Final[int] = 500 # Batch size for DataFrame operations
TRANSFORM_BATCH_SIZE: Final[int] = 250 # Batch size for data transformations
CSV_DOWNLOAD_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV downloads
PROGRESS_UPDATE_INTERVAL: Final[int] = 100 # Number of records between progress updates
# DataFrame operation timeouts
DATAFRAME_READ_TIMEOUT: Final[int] = 30 # Timeout for DataFrame read operations
DATAFRAME_WRITE_TIMEOUT: Final[int] = 30 # Timeout for DataFrame write operations
DATAFRAME_TRANSFORM_TIMEOUT: Final[int] = 45 # Timeout for DataFrame transformations
DATAFRAME_VALIDATION_TIMEOUT: Final[int] = 20 # Timeout for DataFrame validation
# Required DataFrame columns
DATAFRAME_REQUIRED_COLUMNS: Final[List[str]] = [
'name', 'type', 'colorIdentity', 'manaValue', 'text',
'edhrecRank', 'themeTags', 'keywords'
]
# DataFrame validation rules
DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
'colorIdentity': {'type': ('str', 'object'), 'required': True},
'text': {'type': ('str', 'object'), 'required': False}
}
# Card type sorting order for organizing libraries
# This constant defines the order in which different card types should be sorted
# when organizing a deck library. The order is designed to group cards logically,
# starting with Planeswalkers and ending with Lands.
CARD_TYPE_SORT_ORDER: Final[List[str]] = [
'Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery',
'Artifact', 'Enchantment', 'Land'
]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,10 @@ from __future__ import annotations
# Standard library imports
import re
from typing import List, Set, Union, Any
from typing import List, Set, Union, Any, Tuple
from functools import lru_cache
import numpy as np
# Third-party imports
import pandas as pd
@ -24,6 +27,43 @@ import pandas as pd
# Local application imports
from . import tag_constants
# --- Internal helpers for performance -----------------------------------------------------------
@lru_cache(maxsize=2048)
def _build_joined_pattern(parts: Tuple[str, ...]) -> str:
"""Join multiple regex parts with '|'. Cached for reuse across calls."""
return '|'.join(parts)
@lru_cache(maxsize=2048)
def _compile_pattern(pattern: str, ignore_case: bool = True):
"""Compile a regex pattern with optional IGNORECASE. Cached for reuse."""
flags = re.IGNORECASE if ignore_case else 0
return re.compile(pattern, flags)
def _ensure_norm_series(df: pd.DataFrame, source_col: str, norm_col: str) -> pd.Series:
"""Ensure a cached normalized string series exists on df for source_col.
Normalization here means: fillna('') and cast to str once. This avoids
repeating fill/astype work on every mask creation. Extra columns are
later dropped by final reindex in output.
Args:
df: DataFrame containing the column
source_col: Name of the source column (e.g., 'text')
norm_col: Name of the cache column to create/use (e.g., '__text_s')
Returns:
The normalized pandas Series.
"""
if norm_col in df.columns:
return df[norm_col]
# Create normalized string series
series = df[source_col].fillna('') if source_col in df.columns else pd.Series([''] * len(df), index=df.index)
series = series.astype(str)
df[norm_col] = series
return df[norm_col]
def pluralize(word: str) -> str:
"""Convert a word to its plural form using basic English pluralization rules.
@ -78,12 +118,21 @@ def create_type_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
if len(df) == 0:
return pd.Series([], dtype=bool)
# Use normalized cached series
type_series = _ensure_norm_series(df, 'type', '__type_s')
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return df['type'].str.contains(pattern, case=False, na=False, regex=True)
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
compiled = _compile_pattern(pattern, ignore_case=True)
return type_series.str.contains(compiled, na=False, regex=True)
else:
masks = [df['type'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
return pd.concat(masks, axis=1).any(axis=1)
masks = [type_series.str.contains(p, case=False, na=False, regex=False) for p in type_text]
if not masks:
return pd.Series(False, index=df.index)
return pd.Series(np.logical_or.reduce(masks), index=df.index)
def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True, combine_with_or: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where text matches one or more patterns.
@ -109,15 +158,22 @@ def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
if len(df) == 0:
return pd.Series([], dtype=bool)
# Use normalized cached series
text_series = _ensure_norm_series(df, 'text', '__text_s')
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return df['text'].str.contains(pattern, case=False, na=False, regex=True)
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
compiled = _compile_pattern(pattern, ignore_case=True)
return text_series.str.contains(compiled, na=False, regex=True)
else:
masks = [df['text'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
if combine_with_or:
return pd.concat(masks, axis=1).any(axis=1)
else:
return pd.concat(masks, axis=1).all(axis=1)
masks = [text_series.str.contains(p, case=False, na=False, regex=False) for p in type_text]
if not masks:
return pd.Series(False, index=df.index)
reduced = np.logical_or.reduce(masks) if combine_with_or else np.logical_and.reduce(masks)
return pd.Series(reduced, index=df.index)
def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where keyword text matches one or more patterns.
@ -151,18 +207,18 @@ def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], rege
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
# Create default mask for null values
# Handle null values and convert to string
keywords = df['keywords'].fillna('')
# Convert non-string values to strings
keywords = keywords.astype(str)
# Use normalized cached series for keywords
keywords = _ensure_norm_series(df, 'keywords', '__keywords_s')
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return keywords.str.contains(pattern, case=False, na=False, regex=True)
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
compiled = _compile_pattern(pattern, ignore_case=True)
return keywords.str.contains(compiled, na=False, regex=True)
else:
masks = [keywords.str.contains(p, case=False, na=False, regex=False) for p in type_text]
return pd.concat(masks, axis=1).any(axis=1)
if not masks:
return pd.Series(False, index=df.index)
return pd.Series(np.logical_or.reduce(masks), index=df.index)
def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
"""Create a boolean mask for rows where name matches one or more patterns.
@ -187,12 +243,21 @@ def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
elif not isinstance(type_text, list):
raise TypeError("type_text must be a string or list of strings")
if len(df) == 0:
return pd.Series([], dtype=bool)
# Use normalized cached series
name_series = _ensure_norm_series(df, 'name', '__name_s')
if regex:
pattern = '|'.join(f'{p}' for p in type_text)
return df['name'].str.contains(pattern, case=False, na=False, regex=True)
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
compiled = _compile_pattern(pattern, ignore_case=True)
return name_series.str.contains(compiled, na=False, regex=True)
else:
masks = [df['name'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
return pd.concat(masks, axis=1).any(axis=1)
masks = [name_series.str.contains(p, case=False, na=False, regex=False) for p in type_text]
if not masks:
return pd.Series(False, index=df.index)
return pd.Series(np.logical_or.reduce(masks), index=df.index)
def extract_creature_types(type_text: str, creature_types: List[str], non_creature_types: List[str]) -> List[str]:
"""Extract creature types from a type text string.
@ -307,6 +372,31 @@ def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series[bool], tags: Union[st
# Add new tags
df.loc[mask, 'themeTags'] = current_tags.apply(lambda x: sorted(list(set(x + tags))))
def apply_rules(df: pd.DataFrame, rules: List[dict]) -> None:
"""Apply a list of rules to a DataFrame.
Each rule dict supports:
- mask: pd.Series of booleans or a callable df->mask
- tags: str|List[str]
Example:
rules = [
{ 'mask': lambda d: create_text_mask(d, 'lifelink'), 'tags': ['Lifelink'] },
]
Args:
df: DataFrame to update
rules: list of rule dicts
"""
for rule in rules:
mask = rule.get('mask')
if callable(mask):
mask = mask(df)
if mask is None:
continue
tags = rule.get('tags', [])
apply_tag_vectorized(df, mask, tags)
def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series[bool]:
"""Create a boolean mask for cards with mass removal effects of a specific type.
@ -326,6 +416,60 @@ def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series[boo
patterns = tag_constants.BOARD_WIPE_TEXT_PATTERNS[effect_type]
return create_text_mask(df, patterns)
def create_trigger_mask(
df: pd.DataFrame,
subjects: Union[str, List[str]],
include_attacks: bool = False,
) -> pd.Series:
"""Create a mask for text that contains trigger phrases followed by subjects.
Example: with subjects=['a creature','you'] builds patterns:
'when a creature', 'whenever you', 'at you', etc.
Args:
df: DataFrame
subjects: A subject string or list (will be normalized to list)
include_attacks: If True, also include '{trigger} .* attacks'
Returns:
Boolean Series mask
"""
subs = [subjects] if isinstance(subjects, str) else subjects
patterns: List[str] = []
for trig in tag_constants.TRIGGERS:
patterns.extend([f"{trig} {s}" for s in subs])
if include_attacks:
patterns.append(f"{trig} .* attacks")
return create_text_mask(df, patterns)
def create_numbered_phrase_mask(
df: pd.DataFrame,
verb: Union[str, List[str]],
noun: str = '',
numbers: List[str] | None = None,
) -> pd.Series:
"""Create a boolean mask for phrases like 'draw {num} card'.
Args:
df: DataFrame to search
verb: Action verb or list of verbs (e.g., 'draw' or ['gain', 'gains'])
noun: Optional object noun in singular form (e.g., 'card'); if empty, omitted
numbers: Optional list of number words/digits (defaults to tag_constants.NUM_TO_SEARCH)
Returns:
Boolean Series mask
"""
if numbers is None:
numbers = tag_constants.NUM_TO_SEARCH
# Normalize verbs to list
verbs = [verb] if isinstance(verb, str) else verb
# Build patterns
if noun:
patterns = [fr"{v}\s+{num}\s+{noun}" for v in verbs for num in numbers]
else:
patterns = [fr"{v}\s+{num}" for v in verbs for num in numbers]
return create_text_mask(df, patterns)
def create_damage_pattern(number: Union[int, str]) -> str:
"""Create a pattern for matching X damage effects.

16
docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
services:
mtg-deckbuilder:
build: .
container_name: mtg-deckbuilder-main
stdin_open: true # Equivalent to docker run -i
tty: true # Equivalent to docker run -t
volumes:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs
- ${PWD}/csv_files:/app/csv_files
environment:
- PYTHONUNBUFFERED=1
- TERM=xterm-256color
- DEBIAN_FRONTEND=noninteractive
# Ensure proper cleanup
restart: "no"

58
pyproject.toml Normal file
View file

@ -0,0 +1,58 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mtg-deckbuilder"
version = "1.0.0"
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md"
license = {file = "LICENSE"}
authors = [
{name = "Your Name", email = "your.email@example.com"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
requires-python = ">=3.13" # 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",
]
[project.optional-dependencies]
dev = [
"mypy>=1.3.0",
"pandas-stubs>=2.0.0",
"pytest>=8.0.0",
]
[project.scripts]
mtg-deckbuilder = "code.main:run_menu"
[project.urls]
Homepage = "https://github.com/mwisnowski/mtg_python_deckbuilder"
Repository = "https://github.com/mwisnowski/mtg_python_deckbuilder"
Issues = "https://github.com/mwisnowski/mtg_python_deckbuilder/issues"
[tool.setuptools.packages.find]
include = ["code*"]
[tool.setuptools.package-data]
"*" = ["*.csv"]

79
run-docker.ps1 Normal file
View file

@ -0,0 +1,79 @@
# MTG Deckbuilder Docker Runner Script
# This script provides easy commands to run the MTG Deckbuilder in Docker with proper volume mounting
Write-Host "MTG Deckbuilder Docker Helper" -ForegroundColor Green
Write-Host "==============================" -ForegroundColor Green
function Show-Help {
Write-Host ""
Write-Host "Available commands:" -ForegroundColor Yellow
Write-Host " .\run-docker.ps1 build - Build the Docker image"
Write-Host " .\run-docker.ps1 run - Run the application with volume mounting"
Write-Host " .\run-docker.ps1 compose - Use docker-compose (recommended)"
Write-Host " .\run-docker.ps1 clean - Remove containers and images"
Write-Host " .\run-docker.ps1 help - Show this help"
Write-Host ""
}
# Get command line argument
$command = $args[0]
switch ($command) {
"build" {
Write-Host "Building MTG Deckbuilder Docker image..." -ForegroundColor Yellow
docker build -t mtg-deckbuilder .
if ($LASTEXITCODE -eq 0) {
Write-Host "Build successful!" -ForegroundColor Green
} else {
Write-Host "Build failed!" -ForegroundColor Red
}
}
"run" {
Write-Host "Running MTG Deckbuilder with volume mounting..." -ForegroundColor Yellow
# Ensure local directories exist
if (!(Test-Path "deck_files")) { New-Item -ItemType Directory -Path "deck_files" }
if (!(Test-Path "logs")) { New-Item -ItemType Directory -Path "logs" }
if (!(Test-Path "csv_files")) { New-Item -ItemType Directory -Path "csv_files" }
# Run with proper volume mounting
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
}
"compose" {
Write-Host "Running MTG Deckbuilder with Docker Compose..." -ForegroundColor Yellow
# Ensure local directories exist
if (!(Test-Path "deck_files")) { New-Item -ItemType Directory -Path "deck_files" }
if (!(Test-Path "logs")) { New-Item -ItemType Directory -Path "logs" }
if (!(Test-Path "csv_files")) { New-Item -ItemType Directory -Path "csv_files" }
docker-compose up --build
}
"clean" {
Write-Host "Cleaning up Docker containers and images..." -ForegroundColor Yellow
docker-compose down 2>$null
docker rmi mtg-deckbuilder 2>$null
docker system prune -f
Write-Host "Cleanup complete!" -ForegroundColor Green
}
"help" {
Show-Help
}
default {
Write-Host "Invalid command: $command" -ForegroundColor Red
Show-Help
}
}
Write-Host ""
Write-Host "Note: Your deck files, logs, and CSV files will be saved in the local directories" -ForegroundColor Cyan
Write-Host "and will persist between Docker runs." -ForegroundColor Cyan

252
run-docker.sh Normal file
View file

@ -0,0 +1,252 @@
#!/bin/bash
# MTG Deckbuilder Docker Runner Script for Linux/macOS
set -e # Exit on any error
echo "MTG Deckbuilder Docker Helper (Linux)"
echo "====================================="
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_debug() {
echo -e "${BLUE}[DEBUG]${NC} $1"
}
show_help() {
echo ""
echo -e "${YELLOW}Available commands:${NC}"
echo " ./run-docker-linux.sh setup - Initial setup (create directories, check Docker)"
echo " ./run-docker-linux.sh build - Build the Docker image"
echo " ./run-docker-linux.sh run - Run with manual volume mounting"
echo " ./run-docker-linux.sh compose - Use docker-compose run (recommended for interactive)"
echo " ./run-docker-linux.sh compose-build - Build and run with docker-compose"
echo " ./run-docker-linux.sh compose-up - Use docker-compose up (not recommended for interactive)"
echo " ./run-docker-linux.sh debug - Run with debug info and volume verification"
echo " ./run-docker-linux.sh clean - Remove containers and images"
echo " ./run-docker-linux.sh help - Show this help"
echo ""
echo -e "${BLUE}For interactive applications like MTG Deckbuilder:${NC}"
echo -e "${BLUE} - Use 'compose' or 'run' commands${NC}"
echo -e "${BLUE} - Avoid 'compose-up' as it doesn't handle input properly${NC}"
}
setup_directories() {
print_status "Setting up directories..."
# Create directories with proper permissions
mkdir -p deck_files logs csv_files
# Set permissions to ensure Docker can write
chmod 755 deck_files logs csv_files
print_status "Current directory: $(pwd)"
print_status "Directory structure:"
ls -la | grep -E "(deck_files|logs|csv_files|^d)"
echo ""
print_status "Directory setup complete!"
}
check_docker() {
print_status "Checking Docker installation..."
if ! command -v docker &> /dev/null; then
print_error "Docker is not installed or not in PATH"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
print_warning "docker-compose not found, trying docker compose..."
if ! docker compose version &> /dev/null; then
print_error "Neither docker-compose nor 'docker compose' is available"
exit 1
else
COMPOSE_CMD="docker compose"
fi
else
COMPOSE_CMD="docker-compose"
fi
print_status "Docker is available"
print_status "Compose command: $COMPOSE_CMD"
}
case "$1" in
"setup")
check_docker
setup_directories
print_status "Setup complete! You can now run: ./run-docker-linux.sh compose"
;;
"build")
print_status "Building MTG Deckbuilder Docker image..."
docker build -t mtg-deckbuilder .
if [ $? -eq 0 ]; then
print_status "Build successful!"
else
print_error "Build failed!"
exit 1
fi
;;
"run")
print_status "Running MTG Deckbuilder with manual volume mounting..."
# Ensure directories exist
setup_directories
print_debug "Volume mounts:"
print_debug " $(pwd)/deck_files -> /app/deck_files"
print_debug " $(pwd)/logs -> /app/logs"
print_debug " $(pwd)/csv_files -> /app/csv_files"
# Run with proper volume mounting
docker run -it --rm \
-v "$(pwd)/deck_files:/app/deck_files" \
-v "$(pwd)/logs:/app/logs" \
-v "$(pwd)/csv_files:/app/csv_files" \
-e PYTHONUNBUFFERED=1 \
-e TERM=xterm-256color \
mtg-deckbuilder
;;
"compose")
print_status "Running MTG Deckbuilder with Docker Compose..."
# Ensure directories exist
setup_directories
# Check for compose command
check_docker
print_debug "Using compose command: $COMPOSE_CMD"
print_debug "Working directory: $(pwd)"
print_status "Starting interactive session..."
print_warning "Use Ctrl+C to exit when done"
# Run with compose in interactive mode
$COMPOSE_CMD run --rm mtg-deckbuilder
;;
"compose-build")
print_status "Building and running MTG Deckbuilder with Docker Compose..."
# Ensure directories exist
setup_directories
# Check for compose command
check_docker
print_debug "Using compose command: $COMPOSE_CMD"
print_debug "Working directory: $(pwd)"
print_status "Building image and starting interactive session..."
print_warning "Use Ctrl+C to exit when done"
# Build and run with compose in interactive mode
$COMPOSE_CMD build
$COMPOSE_CMD run --rm mtg-deckbuilder
;;
"compose-up")
print_status "Running MTG Deckbuilder with Docker Compose UP (not recommended for interactive apps)..."
# Ensure directories exist
setup_directories
# Check for compose command
check_docker
print_debug "Using compose command: $COMPOSE_CMD"
print_debug "Working directory: $(pwd)"
print_warning "This may not work properly for interactive applications!"
print_warning "Use 'compose' command instead for better interactivity"
# Run with compose
$COMPOSE_CMD up --build
;;
"debug")
print_status "Running in debug mode..."
setup_directories
print_debug "=== DEBUG INFO ==="
print_debug "Current user: $(whoami)"
print_debug "Current directory: $(pwd)"
print_debug "Directory permissions:"
ls -la deck_files logs csv_files 2>/dev/null || print_warning "Some directories don't exist yet"
print_debug "=== DOCKER INFO ==="
docker --version
docker info | grep -E "(Operating System|Architecture)"
print_debug "=== RUNNING CONTAINER ==="
docker run -it --rm \
-v "$(pwd)/deck_files:/app/deck_files" \
-v "$(pwd)/logs:/app/logs" \
-v "$(pwd)/csv_files:/app/csv_files" \
-e PYTHONUNBUFFERED=1 \
-e TERM=xterm-256color \
mtg-deckbuilder /bin/bash -c "
echo 'Container info:'
echo 'Working dir: \$(pwd)'
echo 'Mount points:'
ls -la /app/
echo 'Testing file creation:'
touch /app/deck_files/test_file.txt
echo 'File created: \$(ls -la /app/deck_files/test_file.txt)'
echo 'Starting application...'
python main.py
"
;;
"clean")
print_status "Cleaning up Docker containers and images..."
check_docker
# Stop and remove containers
$COMPOSE_CMD down 2>/dev/null || true
docker stop mtg-deckbuilder 2>/dev/null || true
docker rm mtg-deckbuilder 2>/dev/null || true
# Remove image
docker rmi mtg-deckbuilder 2>/dev/null || true
# Clean up unused resources
docker system prune -f
print_status "Cleanup complete!"
;;
"help"|*)
show_help
;;
esac
echo ""
echo -e "${BLUE}Note: Your deck files, logs, and CSV files will be saved in:${NC}"
echo -e "${BLUE} $(pwd)/deck_files${NC}"
echo -e "${BLUE} $(pwd)/logs${NC}"
echo -e "${BLUE} $(pwd)/csv_files${NC}"