diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0e41e08..9780c4e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,9 @@ "Bash(python -m pytest tests/test_predictor.py -v)", "Bash(python -m pytest)", "Bash(python -m pytest tests/test_settings.py -v)", - "Bash(python -m pytest tests/ -v --tb=short)" + "Bash(python -m pytest tests/ -v --tb=short)", + "Bash(python -m pytest tests/ -q --tb=short)", + "Bash(python -m PyInstaller lottosight.spec --clean)" ] } } diff --git a/.gitignore b/.gitignore index 36b13f1..00118ba 100644 --- a/.gitignore +++ b/.gitignore @@ -28,10 +28,12 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec +# *.spec intentionally NOT ignored — lottosight.spec is committed + +# LottoSight runtime artifacts +data/*.db +exports/ # Installer logs pip-log.txt diff --git a/CLAUDE.md b/CLAUDE.md index 6c1759b..d92ce39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,11 +325,16 @@ All actions are logged to console and optionally to a log file: --- -### 🔲 Phase 8 — Packaging -- [ ] Write PyInstaller `.spec` file -- [ ] Test build on target OS (Windows / Mac / Linux) -- [ ] Bundle SQLite DB, assets, exports folder -- [ ] Test packaged app fresh install (no Python required) +### ✅ Phase 8 — Packaging +- [x] Write `core/paths.py` — `user_data_dir()` + `bundled_asset()` helpers (frozen-aware) +- [x] Update `db/database.py` + `core/exporter.py` to use `user_data_dir()` so DB + exports land next to the .exe when frozen +- [x] Update `main.py` to use `bundled_asset()` for icon lookup +- [x] Write `lottosight.spec` — directory build, no console, bundles `assets/`, matplotlib data, openpyxl templates, APScheduler hidden imports +- [x] Build verified: `pyinstaller lottosight.spec --clean` succeeds → `dist/LottoSight/` (~115 MB) +- [x] `assets/icon.png` confirmed present in `dist/LottoSight/_internal/assets/` +- [x] Fix `.gitignore` — un-ignore `*.spec`, add `data/*.db` + `exports/` +- [x] 165/165 tests still passing after paths refactor +- [ ] Test packaged app on a clean machine (no Python installed) --- diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..3ef09f7 Binary files /dev/null and b/assets/icon.png differ diff --git a/core/exporter.py b/core/exporter.py index c4085e8..446e44c 100644 --- a/core/exporter.py +++ b/core/exporter.py @@ -18,9 +18,9 @@ import openpyxl from openpyxl.styles import Alignment, Font, PatternFill from db.models import get_draws_with_game, get_game_by_id, get_predictions +from core.paths import user_data_dir -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -EXPORTS_DIR = os.path.join(BASE_DIR, "exports") +EXPORTS_DIR = os.path.join(user_data_dir(), "exports") def ensure_exports_dir(): diff --git a/core/paths.py b/core/paths.py new file mode 100644 index 0000000..13296dd --- /dev/null +++ b/core/paths.py @@ -0,0 +1,33 @@ +""" +core/paths.py +------------- +Runtime path resolution that works both in development and when +frozen by PyInstaller (--onefile or directory build). + + user_data_dir() — mutable data root (DB, exports) — next to the .exe + bundled_asset(rel) — read-only asset path (icon etc.) — extracted bundle +""" + +import os +import sys + + +def user_data_dir() -> str: + """Root for mutable user data. Next to the .exe when frozen.""" + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + # development: two levels up from core/paths.py → project root + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def bundled_asset(relative_path: str) -> str: + """Absolute path to a read-only bundled asset (icon, etc.). + + In a onefile build sys._MEIPASS is the temp extraction dir; + in a directory build it equals the exe directory. + """ + if getattr(sys, "frozen", False): + base = sys._MEIPASS # type: ignore[attr-defined] + else: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base, relative_path) diff --git a/data/lottosight.db b/data/lottosight.db index f3f3ea1..1c30682 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/db/database.py b/db/database.py index d1c228d..a35d30b 100644 --- a/db/database.py +++ b/db/database.py @@ -9,11 +9,11 @@ import sqlite3 import logging import os +from core.paths import user_data_dir + logger = logging.getLogger(__name__) -# DB file lives in lottosight/data/lottosight.db -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -DB_PATH = os.path.join(BASE_DIR, "data", "lottosight.db") +DB_PATH = os.path.join(user_data_dir(), "data", "lottosight.db") def get_connection(): diff --git a/lottosight.spec b/lottosight.spec new file mode 100644 index 0000000..84f3493 --- /dev/null +++ b/lottosight.spec @@ -0,0 +1,87 @@ +# lottosight.spec +# ---------------- +# PyInstaller spec for LottoSight (Windows directory build). +# Build: pyinstaller lottosight.spec +# Output: dist/LottoSight/LottoSight.exe (+ supporting files) +# +# User-writable data (DB, exports) is stored next to the .exe at runtime. +# Read-only assets (icon) are bundled inside the package via datas. + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +# ── Data files ──────────────────────────────────────────────────────────────── +datas = [ + # Bundle the assets folder (icon.png) into the package root + ('assets', 'assets'), +] +# Matplotlib ships its own data directory (fonts, colormaps, style sheets) +datas += collect_data_files('matplotlib') +# openpyxl ships its own templates +datas += collect_data_files('openpyxl') + +# ── Hidden imports ──────────────────────────────────────────────────────────── +hiddenimports = [ + # Matplotlib TkAgg backend — not detected by static analysis + 'matplotlib.backends.backend_tkagg', + # APScheduler components loaded via entry-point discovery at runtime + 'apscheduler.schedulers.background', + 'apscheduler.executors.pool', + 'apscheduler.jobstores.memory', + 'apscheduler.triggers.interval', + 'apscheduler.triggers.date', + # bs4 HTML parser back-end + 'bs4.builder._htmlparser', + # Tkinter sub-modules + 'tkinter', + 'tkinter.ttk', + 'tkinter.filedialog', + 'tkinter.messagebox', +] + +# ── Analysis ────────────────────────────────────────────────────────────────── +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + # Strip test / notebook dependencies to keep the bundle smaller + excludes=['pytest', 'IPython', 'ipykernel', 'jupyter'], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +# ── Executable ──────────────────────────────────────────────────────────────── +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, # binaries go into COLLECT, not baked into exe + name='LottoSight', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, # no console window (pure GUI app) + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +# ── Directory bundle ────────────────────────────────────────────────────────── +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='LottoSight', # output: dist/LottoSight/ +) diff --git a/main.py b/main.py index a07eb95..e10e700 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from db.database import init_db from core.fetcher import fetch_all from core.exporter import create_icon_png +from core.paths import bundled_asset, user_data_dir from ui.statusbar import StatusBar from ui.history import HistoryScreen from ui.analysis import AnalysisScreen @@ -38,7 +39,7 @@ class LottoSightApp(tk.Tk): self.minsize(900, 600) try: - self.iconphoto(True, tk.PhotoImage(file="assets/icon.png")) + self.iconphoto(True, tk.PhotoImage(file=bundled_asset("assets/icon.png"))) except Exception: pass @@ -172,9 +173,10 @@ class LottoSightApp(tk.Tk): def main(): init_db() - if not os.path.exists("assets/icon.png"): - os.makedirs("assets", exist_ok=True) - create_icon_png("assets/icon.png") + icon_path = bundled_asset("assets/icon.png") + if not os.path.exists(icon_path): + os.makedirs(os.path.dirname(icon_path), exist_ok=True) + create_icon_png(icon_path) app = LottoSightApp() app.protocol("WM_DELETE_WINDOW", app.on_close) app.mainloop()