diff --git a/.gitea/workflows/binarka.yml b/.gitea/workflows/binarka.yml new file mode 100644 index 0000000..fa4bebc --- /dev/null +++ b/.gitea/workflows/binarka.yml @@ -0,0 +1,108 @@ +# Postaví binárku a pověsí ji na release. +# +# Runner běží v host módu, takže tu není kontejner ani hotové akce +# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno. +# O starou glibc se stará build.sh přes samostatný CPython od uv. +name: binárka + +on: + push: + tags: ["v*"] + pull_request: + workflow_dispatch: + +jobs: + linux: + runs-on: homelab + + steps: + - name: objdump + run: | + # Jediné, co musí být na hostiteli — PyInstaller jím hledá, na + # kterých knihovnách binárka visí. Zbytek si obstará uv. + command -v objdump >/dev/null && exit 0 + sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo + if command -v apt-get >/dev/null; then + $sudo apt-get update -qq + $sudo apt-get install -y -qq binutils + elif command -v dnf >/dev/null; then + $sudo dnf install -y -q binutils + else + echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2 + exit 1 + fi + + - name: Zdrojáky + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git config http.extraheader "Authorization: token ${TOKEN}" + git fetch -q --depth 1 origin "${GITHUB_REF}" + git config --unset http.extraheader + git checkout -qf FETCH_HEAD + git clean -qfd + + - name: uv + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh + uv --version + + - name: Testy + run: uv run --with-requirements requirements.txt --no-project pytest -q + + - name: Binárka + run: ./build.sh + + - name: Zkouška + run: | + XDG_DATA_HOME=/tmp/zkouska DICE_PORT=8321 DICE_HOST=127.0.0.1 \ + ./dist/kostky > /tmp/server.log 2>&1 & + server=$! + trap 'kill $server 2>/dev/null || true' EXIT + + for _ in $(seq 30); do + curl -sf -o /dev/null http://127.0.0.1:8321/ && break + sleep 1 + done + + curl -sf -o /dev/null http://127.0.0.1:8321/ + curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css + curl -sf -o /dev/null http://127.0.0.1:8321/board + test -f /tmp/zkouska/kostky/dice.db + cat /tmp/server.log + + - name: Na release + if: startsWith(github.ref, 'refs/tags/') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET: kostky-linux-x86_64 + run: | + api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + auth="Authorization: token ${TOKEN}" + + # Release u tagu být může, ale nemusí — dá se založit i ručně. + id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true) + + if [ -z "$id" ]; then + id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \ + "$api/releases" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + fi + + # Aby se dal build zopakovat, stará příloha stejného jména jde pryč. + curl -s -H "$auth" "$api/releases/$id/assets" \ + | python3 -c 'import json,os,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == os.environ["ASSET"]]' \ + | while read -r old; do + curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old" + done + + curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@dist/kostky" \ + "$api/releases/$id/assets?name=${ASSET}" + echo "$ASSET pověšen na ${GITHUB_REF_NAME}" diff --git a/.gitea/workflows/testy.yml b/.gitea/workflows/testy.yml new file mode 100644 index 0000000..10d723c --- /dev/null +++ b/.gitea/workflows/testy.yml @@ -0,0 +1,69 @@ +# Testy nad každým pull requestem a nad main. +# +# Runner běží v host módu, takže tu není kontejner ani hotové akce +# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný. +name: testy + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: testy-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + runs-on: homelab + + strategy: + fail-fast: false + # Runner sdílí pracovní adresář mezi variantami, takže po jedné. + max-parallel: 1 + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Zdrojáky + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git config http.extraheader "Authorization: token ${TOKEN}" + git fetch -q --depth 1 origin "${GITHUB_REF}" + git config --unset http.extraheader + git checkout -qf FETCH_HEAD + git clean -qfd + + - name: uv + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh + uv python install "${{ matrix.python }}" + + - name: Testy + run: | + uv run --managed-python --python "${{ matrix.python }}" \ + --with-requirements requirements.txt --no-project \ + pytest -q + + - name: Terminálová verze + run: | + # Pytest sahá na core.py, ne na prompty. Tohle projde celou partii + # tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra. + out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \ + | TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py) + + for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do + printf '%s' "$out" | grep -qF "$expected" || { + echo "chybí: $expected" + printf '%s\n' "$out" + exit 1 + } + done + echo "terminálová verze v pořádku" diff --git a/.gitignore b/.gitignore index 5351aa3..a9551e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__/ .venv/ *.db .pytest_cache/ +dist/ +build/ +.build/ diff --git a/README.md b/README.md index 3803366..069a63b 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,24 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla. ## Spuštění +Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases) +a spusť ji: + +```bash +chmod +x kostky +./kostky +``` + +Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux +na x86-64; postavené proti glibc 2.17, takže jede prakticky všude. + +Ze zdrojáků to je stejně krátké: + ```bash uv run web.py ``` -To je celé. Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará -sám — není co instalovat ani aktivovat. +Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará sám. Server vypíše všechny adresy, na kterých je dostupný: @@ -25,14 +37,33 @@ Počitadlo je dostupné na: ``` Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule -zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. +zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se +`Ctrl+C`. | Proměnná | K čemu je | Výchozí | |---|---|---| -| `DICE_DB` | soubor s databází | `dice.db` | +| `DICE_DB` | soubor s databází | podle způsobu spuštění, viz níž | | `DICE_PORT` | port | `8000` | | `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu | +## Kde jsou data + +Jeden soubor SQLite. Založí se sám při prvním načtení stránky, cestu k němu +server vypíše při startu. + +| Spuštěno | Databáze | +|---|---| +| binárkou | `~/.local/share/kostky/dice.db` | +| ze zdrojáků | `dice.db` v pracovním adresáři | + +Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se +odkudkoli, takže relativní cesta by databázi rozsypala po disku — proto +napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mít víc +sad vedle sebe. + +Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho +najde kdekoli. + Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi může zapisovat. @@ -69,6 +100,7 @@ při remíze na prvním místě se hraje dál. | `stats.py` | síň slávy a kariérní statistiky | | `web.py` | Flask aplikace | | `dice.py` | totéž v terminálu, bez ukládání | +| `build.sh`, `kostky.spec` | stavba binárky | Terminálová verze zůstává funkční jako záloha: @@ -93,6 +125,30 @@ sudo dnf install python3-flask python3-qrcode python3-tabulate python3 web.py ``` +## Vlastní binárka + +```bash +./build.sh +``` + +Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je +slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo +glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu, +ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu +potřeba není. Výsledek je v `dist/kostky`. + +Terminálová verze součástí binárky není; ta se pouští ze zdrojáků. + +Releasy si tohle dělají samy. Otagovaný commit spustí +`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí +ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`: + +```bash +git tag v1.0.0 && git push origin v1.0.0 +``` + +Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší. + ## Testy ```bash @@ -100,3 +156,7 @@ uv run --with-requirements requirements.txt --no-project pytest ``` Nebo z připraveného prostředí prostě `pytest`. + +Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` — +84 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie +v terminálové verzi, na kterou pytest nesahá. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ea0f553 --- /dev/null +++ b/build.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Postaví jednosouborovou binárku pro Linux do dist/kostky. +# +# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten je +# slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech. +# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by +# spustit skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou. +# Nic z hostitele se do ní nedostane, takže kontejner k tomu není potřeba. +set -euo pipefail + +VERSION=${VERSION:-3.11} +BUILD=${BUILD:-.build} + +uv python install "$VERSION" +uv venv --quiet --managed-python --python "$VERSION" "$BUILD" +uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller + +"$BUILD/bin/pyinstaller" --clean --noconfirm \ + --distpath dist --workpath "$BUILD/work" kostky.spec + +echo +ls -lh dist/kostky diff --git a/kostky.spec b/kostky.spec new file mode 100644 index 0000000..857bd78 --- /dev/null +++ b/kostky.spec @@ -0,0 +1,26 @@ +# -*- mode: python ; coding: utf-8 -*- +"""Jedna binárka se vším: Python, Flask, šablony i styly. + +Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek jel +i na starších systémech, než je ten tvůj. +""" + +analysis = Analysis( + ["web.py"], + pathex=["."], + datas=[("templates", "templates"), ("static", "static")], + noarchive=False, +) + +pyz = PYZ(analysis.pure) + +exe = EXE( + pyz, + analysis.scripts, + analysis.binaries, + analysis.datas, + name="kostky", + console=True, + strip=True, + upx=False, +) diff --git a/storage.py b/storage.py index 12723da..35cce6e 100644 --- a/storage.py +++ b/storage.py @@ -10,12 +10,30 @@ Součty se nikde neukládají; stav hry se rekonstruuje načtením tahů do import os import re import sqlite3 +import sys from datetime import datetime from pathlib import Path from core import FINAL_SCORE, Game, Turn -DB_PATH = Path(os.environ.get("DICE_DB", "dice.db")) +def default_db() -> Path: + """Kam se ukládá databáze. + + Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných + sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového + adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do + dočasného adresáře, takže relativní cesta by databázi rozsypala po disku. + """ + chosen = os.environ.get("DICE_DB") + if chosen: + return Path(chosen) + if getattr(sys, "frozen", False): + share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share" + return Path(share) / "kostky" / "dice.db" + return Path("dice.db") + + +DB_PATH = default_db() STATUS_RUNNING = "probíhá" STATUS_FINISHED = "dohráno" @@ -60,7 +78,9 @@ CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id); def connect(path: Path | str | None = None) -> sqlite3.Connection: - conn = sqlite3.connect(path or DB_PATH) + target = Path(path or DB_PATH) + target.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(target) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") conn.executescript(SCHEMA) diff --git a/tests/test_storage.py b/tests/test_storage.py index af390b6..603d8df 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest import storage @@ -132,3 +134,34 @@ def test_hrac_nesmi_sedet_u_stolu_dvakrat(conn): def test_prazdne_jmeno(conn): with pytest.raises(ValueError): storage.get_or_create_player(conn, " ") + + +def test_umisteni_databaze_ze_zdrojaku(monkeypatch): + """Ze zdrojáků se ukládá do pracovního adresáře.""" + monkeypatch.delenv("DICE_DB", raising=False) + monkeypatch.delattr(storage.sys, "frozen", raising=False) + + assert storage.default_db() == Path("dice.db") + + +def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path): + """Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí.""" + monkeypatch.delenv("DICE_DB", raising=False) + monkeypatch.setattr(storage.sys, "frozen", True, raising=False) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + + assert storage.default_db() == tmp_path / "kostky" / "dice.db" + + +def test_dice_db_prebije_oboje(monkeypatch, tmp_path): + monkeypatch.setattr(storage.sys, "frozen", True, raising=False) + monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db")) + + assert storage.default_db() == tmp_path / "jinde.db" + + +def test_connect_zalozi_chybejici_adresar(tmp_path): + conn = storage.connect(tmp_path / "novy" / "adresar" / "dice.db") + conn.close() + + assert (tmp_path / "novy" / "adresar" / "dice.db").exists() diff --git a/web.py b/web.py index 93bba45..2d66572 100644 --- a/web.py +++ b/web.py @@ -10,6 +10,8 @@ Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step. import os import socket +import sys +from pathlib import Path import qrcode from flask import (Flask, abort, g, redirect, render_template, request, @@ -21,7 +23,19 @@ import stats import storage from core import FINAL_SCORE, Game, GameOver -app = Flask(__name__) +def bundled(folder: str) -> str: + """Cesta k šablonám a stylům. + + Ze zdrojáků leží vedle tohoto souboru. V binárce je PyInstaller rozbalí + do dočasného adresáře, na který ukazuje `sys._MEIPASS`. + """ + root = Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) + return str(root / folder) + + +app = Flask(__name__, + template_folder=bundled("templates"), + static_folder=bundled("static")) PORT = int(os.environ.get("DICE_PORT", 8000)) @@ -298,4 +312,6 @@ if __name__ == "__main__": else: print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=.") + print(f"\nDatabáze: {storage.DB_PATH.resolve()}") + app.run(host="0.0.0.0", port=PORT, threaded=True)