diff --git a/.gitea/workflows/binarka.yml b/.gitea/workflows/binarka.yml index ae0b920..32fa422 100644 --- a/.gitea/workflows/binarka.yml +++ b/.gitea/workflows/binarka.yml @@ -53,10 +53,16 @@ jobs: uv --version - name: Testy - run: uv run --with-requirements requirements.txt --no-project pytest -q + run: uv run --with-requirements requirements-dev.txt --no-project pytest -q - name: Binárka - run: ./build.sh + run: | + # Runner klonuje jeden commit bez značek, takže si git describe + # nemá o co opřít — na tagu mu verzi řekneme rovnou. + if [ "$GITHUB_REF_TYPE" = tag ]; then + export VERSION="$GITHUB_REF_NAME" + fi + ./build.sh - name: Zkouška run: | @@ -74,6 +80,7 @@ jobs: 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 + ./dist/kostky --version cat /tmp/server.log - name: Na release diff --git a/.gitea/workflows/testy.yml b/.gitea/workflows/testy.yml index 10d723c..eb7231e 100644 --- a/.gitea/workflows/testy.yml +++ b/.gitea/workflows/testy.yml @@ -49,7 +49,7 @@ jobs: - name: Testy run: | uv run --managed-python --python "${{ matrix.python }}" \ - --with-requirements requirements.txt --no-project \ + --with-requirements requirements-dev.txt --no-project \ pytest -q - name: Terminálová verze diff --git a/.github/workflows/binarky.yml b/.github/workflows/binarky.yml index f1a9aef..32ac091 100644 --- a/.github/workflows/binarky.yml +++ b/.github/workflows/binarky.yml @@ -1,4 +1,4 @@ -# Binárky pro Windows a macOS. +# Binárky pro Windows, macOS a Linux na ARM. # # PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit # ten systém. Kupovat kvůli tomu Mac nebo krmit Windows VM by bylo přehnané, @@ -8,8 +8,10 @@ # z Gitey zrcadlí a hotové soubory se posílají zpátky na tamní release přes # Gitea API. Jedna stránka s releasy, ta původní. # -# Linux se tu schválně nestaví — o ten se stará vlastní runner -# (.gitea/workflows/binarka.yml) a dělá to líp, proti glibc 2.17. +# Linux na x86-64 se tu schválně nestaví — o ten se stará vlastní runner +# (.gitea/workflows/binarka.yml). Když GitHub vypadne, release má pořád tu +# platformu, na které to reálně poběží. ARM doma není na čem postavit, takže +# ten jediný linuxový sem patří. name: binárky on: @@ -37,6 +39,9 @@ jobs: - os: macos-latest # Apple Silicon asset: kostky-macos-arm64 built: dist/kostky + - os: ubuntu-24.04-arm # pro Raspberry Pi a spol. + asset: kostky-linux-arm64 + built: dist/kostky runs-on: ${{ matrix.os }} defaults: @@ -52,10 +57,16 @@ jobs: - uses: astral-sh/setup-uv@v10.0.1 - name: Testy - run: uv run --with-requirements requirements.txt --no-project pytest -q + run: uv run --with-requirements requirements-dev.txt --no-project pytest -q - name: Binárka - run: ./build.sh + run: | + # Na tagu je verze jistá. Jinde je repozitář naklonovaný mělce + # a build.sh si vystačí s tím, co z gitu vyčte. + if [ "$GITHUB_REF_TYPE" = tag ]; then + export VERSION="$GITHUB_REF_NAME" + fi + ./build.sh - name: Zkouška run: | @@ -75,6 +86,10 @@ jobs: curl -sf "http://127.0.0.1:8123/board" | grep -q "127.0.0.1:8123" test -f zkouska/dice.db + # Verze se musí do binárky opravdu dostat, ne se hlásit jako neznámá. + "${{ matrix.built }}" --version + ! "${{ matrix.built }}" --version | grep -q "neznámá" + echo "Binárka jede." - name: Na release diff --git a/.gitignore b/.gitignore index a9551e5..4e2bc71 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ dist/ build/ .build/ +verze.txt diff --git a/README.md b/README.md index 4ad0c60..a9b4069 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Stáhni binárku pro svůj systém z | Systém | Soubor | |---|---| | Linux (x86-64) | `kostky-linux-x86_64` | +| Linux (ARM64) | `kostky-linux-arm64` | | macOS (Apple Silicon) | `kostky-macos-arm64` | | macOS (Intel) | `kostky-macos-x86_64` | | Windows (x86-64) | `kostky-windows-x86_64.exe` | @@ -59,12 +60,17 @@ 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ý: ``` +Kostky v1.1.0 + Počitadlo je dostupné na: → http://10.186.234.182:8000 místní síť http://100.91.0.24:8000 přes Tailscale http://172.17.0.1:8000 virtuální síť ``` +Kterou verzi máš, řekne i `kostky --version`; visí taky v patičce každé +stránky. Když něco nefunguje, je to první věc, na kterou se zeptám. + Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se přepne na ni. Zastavuje se `Ctrl+C`. @@ -93,7 +99,19 @@ napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mí sad vedle sebe. Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho -najde kdekoli. +najde kdekoli. Kdo ho hledat nechce — a z binárky na Windows je zalezlý — +si ho stáhne ze síně slávy odkazem **Stáhnout databázi** (`/export`). Kopie +se dělá přes `VACUUM INTO`, takže je použitelná i uprostřed rozehrané hry. + +Schéma má verzi uloženou v `PRAGMA user_version` a `storage.migrate()` si +databázi při každém připojení dorovná. Starší soubory z verzí, kdy se verze +schématu ještě nepsala, se poznají podle nuly a orazítkují se. Databázi +založenou novější verzí počitadla appka odmítne otevřít, místo aby ji tiše +rozbila. + +Obsluhuje to `waitress` — čistě pythonní WSGI server, který se zabalí do +binárky stejně snadno jako zbytek. Vestavěný server Flasku by pod úvodní +hlášku vysypal varování, že takhle se to nemá, a měl by pravdu. Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi může zapisovat. @@ -132,6 +150,7 @@ při remíze na prvním místě se hraje dál. | `web.py` | Flask aplikace | | `dice.py` | totéž v terminálu, bez ukládání | | `console.py` | aby čeština prošla i windowsovou konzolí | +| `version.py` | která verze to je — z gitu, nebo z binárky | | `build.sh`, `kostky.spec` | stavba binárky | | `.gitea/workflows/` | testy a linuxová binárka doma | | `.github/workflows/` | binárky pro Windows a macOS | @@ -155,16 +174,45 @@ python -m venv .venv Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic: ```bash -sudo dnf install python3-flask python3-qrcode python3-tabulate +sudo dnf install python3-flask python3-qrcode python3-tabulate python3-waitress python3 web.py ``` +## Závislosti + +Verze jsou zamčené, aby binárka postavená dnes a za půl roku obsahovala +totéž. Volné seznamy jsou v `.in`, zamčené v `.txt`: + +| Soubor | K čemu | +|---|---| +| `requirements.in` → `.txt` | běh aplikace | +| `requirements-dev.in` → `.txt` | + pytest, pro testy | +| `requirements-build.in` → `.txt` | + PyInstaller, pro stavbu binárky | + +Aktualizace je vědomý krok, ne vedlejší efekt toho, že něco vyšlo na PyPI: + +```bash +for f in requirements requirements-dev requirements-build; do + uv pip compile "$f.in" -o "$f.txt" +done +``` + +Verze se píšou ještě jednou v hlavičce PEP 723 na začátku `web.py` a +`dice.py`, aby fungovalo `uv run web.py`. Že se ty dva zápisy shodují, hlídá +`tests/test_zavislosti.py` — po každém přegenerování je potřeba hlavičky +srovnat. + ## Vlastní binárka ```bash ./build.sh ``` +Verzi si `build.sh` vezme z `git describe`, nebo se dá vnutit přes +`VERSION=v1.2.3 ./build.sh`. Zapíše ji do `verze.txt`, `kostky.spec` ji +přibalí a binárka ji pak umí ohlásit i na cizím počítači, kde žádný git není. +Verzi Pythonu, proti kterému se staví, přebíjí `PYTHON_VERSION`. + 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, @@ -184,8 +232,8 @@ Otagovaný commit spustí stavbu na obou stranách: | Kde | Co staví | Workflow | |---|---|---| -| vlastní runner u Gitey | Linux | `.gitea/workflows/binarka.yml` | -| GitHub Actions | Windows, macOS ×2 | `.github/workflows/binarky.yml` | +| vlastní runner u Gitey | Linux x86-64 | `.gitea/workflows/binarka.yml` | +| GitHub Actions | Windows, macOS ×2, Linux ARM64 | `.github/workflows/binarky.yml` | ```bash git tag v1.0.0 && git push origin v1.0.0 @@ -194,9 +242,11 @@ git tag v1.0.0 && git push origin v1.0.0 GitHub je tu jen půjčená dílna. Repozitář se tam z Gitey zrcadlí ([mirror](https://github.com/david-spacil/dice-counter)), postavené soubory se posílají zpátky na zdejší release přes Gitea API a projekt má pořád jednu -stránku s releasy — tuhle. Linux se na GitHubu schválně nestaví; doma to jde -proti staré glibc, a když GitHub vypadne, release má aspoň tu platformu, -na které to reálně poběží. +stránku s releasy — tuhle. Linux na x86-64 se na GitHubu schválně nestaví; +doma to jde proti staré glibc, a když GitHub vypadne, release má aspoň tu +platformu, na které to reálně poběží. ARM64 je výjimka — doma na něm není +na čem stavět, a hodí se pro Raspberry Pi nebo jinou malou pořád zapnutou +krabičku. Každá stavba nejdřív projede testy a zkusí hotovou binárku nastartovat, než ji kamkoli pověsí. Vedle každé visí i `.sha256`, takže se stažený soubor dá @@ -217,5 +267,5 @@ 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` — -102 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie +118 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 index ee0cf48..e41ba97 100755 --- a/build.sh +++ b/build.sh @@ -12,11 +12,20 @@ # se do ní nedostane, takže kontejner k tomu není potřeba. set -euo pipefail -VERSION=${VERSION:-3.11} +PYTHON_VERSION=${PYTHON_VERSION:-3.11} BUILD=${BUILD:-.build} -uv python install "$VERSION" -uv venv --quiet --managed-python --python "$VERSION" "$BUILD" +# Verze, kterou binárka ohlásí přes --version. V CI ji na tagu vnutíme +# proměnnou, protože tam je repozitář naklonovaný na jeden commit a git +# describe nemá o co se opřít. +VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo neznámá)} +printf '%s\n' "$VERSION" > verze.txt +echo "Verze: $VERSION" + +uv python install "$PYTHON_VERSION" +# --clear, protože runner si pracovní adresář mezi běhy drží a uv nad +# existujícím prostředím jinak skončí chybou. Stavíme načisto. +uv venv --quiet --clear --managed-python --python "$PYTHON_VERSION" "$BUILD" # Windows dává spustitelné soubory venvu do Scripts/, zbytek světa do bin/. python="$BUILD/bin/python" @@ -26,7 +35,7 @@ if [ -d "$BUILD/Scripts" ]; then pyinstaller="$BUILD/Scripts/pyinstaller.exe" fi -uv pip install --quiet --python "$python" -r requirements.txt pyinstaller +uv pip install --quiet --python "$python" -r requirements-build.txt "$pyinstaller" --clean --noconfirm \ --distpath dist --workpath "$BUILD/work" kostky.spec diff --git a/console.py b/console.py index cf6a35d..3ab13ab 100644 --- a/console.py +++ b/console.py @@ -1,10 +1,10 @@ -"""Aby čeština prošla i tam, kde konzole čeká něco jiného.""" +"""Aby výstup prošel i tam, kde ho nikdo nečeká na konzoli.""" import sys def utf8() -> None: - """Přepne standardní výstup na UTF-8. + """Přepne standardní výstup na UTF-8 a na řádkové bufferování. Windows sahá po historické kódové stránce (cp1252, cp852) všude, kde výstup není konzole — přesměrovaný do souboru, puštěný ze skriptu, ve @@ -14,7 +14,14 @@ def utf8() -> None: Na konzoli i na ostatních systémech je UTF-8 dávno výchozí a tohle je tam prázdná operace. `errors="replace"` je pojistka pro zbylé případy: rozsypaný háček je pořád lepší než spadlý server. + + Řádkové bufferování řeší druhou půlku téhož problému. Mimo konzoli + Python drží výstup v bloku a pouští ho, až se naplní nebo až program + skončí — jenže server neskončí, ten běží. Kdo si ho pustí na pozadí + s výpisem do souboru, našel by tam prázdno; přesně to dělá zkouška + binárky v CI. Pár řádek hlášky za to bufferovat nemá cenu. """ for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): - stream.reconfigure(encoding="utf-8", errors="replace") + stream.reconfigure(encoding="utf-8", errors="replace", + line_buffering=True) diff --git a/dice.py b/dice.py index d05bf15..433eb89 100644 --- a/dice.py +++ b/dice.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["tabulate"] +# dependencies = ["tabulate==0.10.0"] # /// """Počitadlo skóre v terminálu. @@ -8,11 +8,13 @@ Pravidla žijí v `core.Game`, tenhle modul je jen vstup a výstup. Hraje se v paměti, nic se neukládá — na historii je webová aplikace (`web.py`). """ +import sys from os import name, system from tabulate import tabulate import console +import version from core import FINAL_SCORE, Game table_header = ["Hráč", "Skóre"] @@ -130,4 +132,9 @@ def main() -> None: if __name__ == "__main__": console.utf8() + + if {"--version", "-V"} & set(sys.argv[1:]): + print(f"kostky {version.current()}") + sys.exit(0) + main() diff --git a/kostky.spec b/kostky.spec index 68164e8..cffe88e 100644 --- a/kostky.spec +++ b/kostky.spec @@ -5,12 +5,19 @@ Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek je i na starších systémech, než je ten tvůj. """ +import os import sys +# Verzi zapisuje build.sh těsně před stavbou. Když se pyinstaller pustí +# ručně bez ní, binárka se postaví taky — jen se pak hlásí jako neznámá. +datas = [("templates", "templates"), ("static", "static")] +if os.path.exists("verze.txt"): + datas.append(("verze.txt", ".")) + analysis = Analysis( ["web.py"], pathex=["."], - datas=[("templates", "templates"), ("static", "static")], + datas=datas, noarchive=False, ) diff --git a/requirements-build.in b/requirements-build.in new file mode 100644 index 0000000..fffb682 --- /dev/null +++ b/requirements-build.in @@ -0,0 +1,4 @@ +# Co je potřeba ke stavbě binárky. Zamyká se do requirements-build.txt: +# uv pip compile requirements-build.in -o requirements-build.txt +-r requirements.in +pyinstaller diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..903e786 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,37 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-build.in -o requirements-build.txt --python-version 3.11 +altgraph==0.17.5 + # via pyinstaller +blinker==1.9.0 + # via flask +click==8.4.2 + # via flask +flask==3.1.3 + # via -r requirements.in +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +markupsafe==3.0.3 + # via + # flask + # jinja2 + # werkzeug +packaging==26.3 + # via + # pyinstaller + # pyinstaller-hooks-contrib +pyinstaller==6.22.2 + # via -r requirements-build.in +pyinstaller-hooks-contrib==2026.6 + # via pyinstaller +qrcode==8.2 + # via -r requirements.in +setuptools==84.0.0 + # via pyinstaller +tabulate==0.10.0 + # via -r requirements.in +waitress==3.0.2 + # via -r requirements.in +werkzeug==3.1.8 + # via flask diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..1e2d1dc --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,4 @@ +# Co je potřeba k vývoji a testům. Zamyká se do requirements-dev.txt: +# uv pip compile requirements-dev.in -o requirements-dev.txt +-r requirements.in +pytest diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..49187f9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,35 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11 +blinker==1.9.0 + # via flask +click==8.4.2 + # via flask +flask==3.1.3 + # via -r requirements.in +iniconfig==2.3.0 + # via pytest +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +markupsafe==3.0.3 + # via + # flask + # jinja2 + # werkzeug +packaging==26.3 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.21.0 + # via pytest +pytest==9.1.1 + # via -r requirements-dev.in +qrcode==8.2 + # via -r requirements.in +tabulate==0.10.0 + # via -r requirements.in +waitress==3.0.2 + # via -r requirements.in +werkzeug==3.1.8 + # via flask diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..90f9743 --- /dev/null +++ b/requirements.in @@ -0,0 +1,6 @@ +# Běhové závislosti. Verze se zamykají do requirements.txt přes: +# uv pip compile requirements.in -o requirements.txt +flask +qrcode +tabulate +waitress diff --git a/requirements.txt b/requirements.txt index f017b4d..68ed760 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,25 @@ -flask -qrcode -tabulate -pytest +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt --python-version 3.11 +blinker==1.9.0 + # via flask +click==8.4.2 + # via flask +flask==3.1.3 + # via -r requirements.in +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +markupsafe==3.0.3 + # via + # flask + # jinja2 + # werkzeug +qrcode==8.2 + # via -r requirements.in +tabulate==0.10.0 + # via -r requirements.in +waitress==3.0.2 + # via -r requirements.in +werkzeug==3.1.8 + # via flask diff --git a/static/style.css b/static/style.css index 9284f3e..4331551 100644 --- a/static/style.css +++ b/static/style.css @@ -513,3 +513,11 @@ button { font-size: 0.85em; color: var(--ink); } + +/* Verze v patičce. Má být k nalezení, když ji někdo hledá, a jinak neplést. */ +.verze { + margin: 2rem 0 0.75rem; + text-align: center; + font-size: 0.75rem; + opacity: 0.35; +} diff --git a/storage.py b/storage.py index 80eca90..db29428 100644 --- a/storage.py +++ b/storage.py @@ -86,6 +86,45 @@ CREATE TABLE IF NOT EXISTS turns ( CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id); """ +# Každý krok posouvá schéma o jedno dál; krok na indexu 0 vede z verze 1 na 2. +# Přidávat jen na konec a nikdy nesahat na ten, který už někomu proběhl — +# databáze leží lidem na discích a zpátky se nedají stáhnout. +MIGRATIONS: list[str] = [] + +SCHEMA_VERSION = 1 + len(MIGRATIONS) + + +def migrate(conn: sqlite3.Connection) -> int: + """Dorovná databázi na aktuální schéma a vrátí její verzi. + + Verze se drží v `PRAGMA user_version`, což je celé číslo, které si SQLite + veze v hlavičce souboru a jinak si ho nevšímá. + + Nula znamená dvojí: buď je soubor prázdný, nebo pochází z doby, kdy se + verze ještě nezapisovala. Obojí se vyřeší stejně — `SCHEMA` je samé + `IF NOT EXISTS`, takže na hotové databázi neudělá nic a jen se orazítkuje. + """ + version = conn.execute("PRAGMA user_version").fetchone()[0] + + if version > SCHEMA_VERSION: + raise RuntimeError( + f"Databáze je ve verzi {version}, tahle aplikace umí " + f"{SCHEMA_VERSION}. Nejspíš ji založila novější verze počitadla; " + f"aktualizuj, ať o data nepřijdeš.") + + if version == 0: + conn.executescript(SCHEMA) + version = 1 + + for step in MIGRATIONS[version - 1:]: + conn.executescript(step) + version += 1 + + # PRAGMA neumí zástupné otazníky. Číslo sem teče z kódu, ne od uživatele. + conn.execute(f"PRAGMA user_version = {version}") + conn.commit() + return version + def connect(path: Path | str | None = None) -> sqlite3.Connection: target = Path(path or DB_PATH) @@ -93,7 +132,7 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection: conn = sqlite3.connect(target) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") - conn.executescript(SCHEMA) + migrate(conn) return conn diff --git a/templates/base.html b/templates/base.html index 5d8cdb6..a2ef8c5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,5 +8,6 @@
{% block body %}{% endblock %} +