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 %} + diff --git a/templates/stats.html b/templates/stats.html index 0f748e2..3e12ada 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -8,7 +8,8 @@

Počitadlo

Síň slávy

-

Nová hra

+

Nová hra + · Stáhnout databázi

{% if records.best_turn %} diff --git a/tests/test_console.py b/tests/test_console.py index f2fed1b..9c7fed2 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -9,10 +9,12 @@ class Proud: def __init__(self): self.encoding = "cp1252" self.errors = "strict" + self.line_buffering = False - def reconfigure(self, encoding, errors): + def reconfigure(self, encoding, errors, line_buffering=False): self.encoding = encoding self.errors = errors + self.line_buffering = line_buffering def test_vystup_se_prepne_na_utf8(monkeypatch): @@ -24,6 +26,7 @@ def test_vystup_se_prepne_na_utf8(monkeypatch): assert (out.encoding, out.errors) == ("utf-8", "replace") assert (err.encoding, err.errors) == ("utf-8", "replace") + assert out.line_buffering and err.line_buffering def test_cestina_projde_i_pres_cp1252(monkeypatch): @@ -46,3 +49,17 @@ def test_proud_bez_reconfigure_se_prezije(monkeypatch): monkeypatch.setattr(console.sys, "stderr", object()) console.utf8() + + +def test_hlaska_je_v_souboru_hned(tmp_path, monkeypatch): + """Server běží dál, takže na vyprázdnění bufferu při konci se čekat nedá.""" + cesta = tmp_path / "server.log" + with cesta.open("w", encoding="utf-8") as soubor: + monkeypatch.setattr(console.sys, "stdout", soubor) + monkeypatch.setattr(console.sys, "stderr", soubor) + + console.utf8() + print("Počitadlo je dostupné na:", file=console.sys.stdout) + + # Bez zavření souboru — přesně jako u běžícího serveru. + assert "Počitadlo je dostupné" in cesta.read_text(encoding="utf-8") diff --git a/tests/test_storage.py b/tests/test_storage.py index a07e2c6..34af97a 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,3 +1,4 @@ +import sqlite3 from pathlib import Path import pytest @@ -182,3 +183,69 @@ def test_connect_zalozi_chybejici_adresar(tmp_path): conn.close() assert (tmp_path / "novy" / "adresar" / "dice.db").exists() + + +# --- verze schématu --------------------------------------------------------- + +def verze(conn) -> int: + return conn.execute("PRAGMA user_version").fetchone()[0] + + +def test_nova_databaze_je_orazitkovana(tmp_path): + conn = storage.connect(tmp_path / "nova.db") + try: + assert verze(conn) == storage.SCHEMA_VERSION + finally: + conn.close() + + +def test_databaze_z_doby_pred_verzovanim_prezije(tmp_path): + """Přesně to, co lidem leží na disku z verzí 1.0 a 1.1. + + Tabulky má, razítko ne. Nesmí se ani přijít o data, ani spadnout. + """ + path = tmp_path / "stara.db" + stara = sqlite3.connect(path) + stara.executescript(storage.SCHEMA) # bez PRAGMA user_version + stara.execute("INSERT INTO players (name, key, created_at) " + "VALUES ('Adam', 'adam', '2026-01-01T00:00:00')") + stara.commit() + stara.close() + + conn = storage.connect(path) + try: + assert verze(conn) == storage.SCHEMA_VERSION + assert [p["name"] for p in storage.known_players(conn)] == ["Adam"] + finally: + conn.close() + + +def test_migrace_dojede_jen_ty_chybejici(tmp_path, monkeypatch): + """Podruhé už krok proběhnout nesmí — jinak by ALTER TABLE spadl.""" + monkeypatch.setattr(storage, "MIGRATIONS", + ["ALTER TABLE players ADD COLUMN barva TEXT;"]) + monkeypatch.setattr(storage, "SCHEMA_VERSION", 2) + + path = tmp_path / "migrovana.db" + conn = storage.connect(path) + conn.close() + + conn = storage.connect(path) # druhé spuštění téže verze + try: + assert verze(conn) == 2 + sloupce = [r[1] for r in conn.execute("PRAGMA table_info(players)")] + assert "barva" in sloupce + finally: + conn.close() + + +def test_databaze_z_novejsi_verze_se_odmitne(tmp_path): + """Radši srozumitelná hláška než tiše rozbitá data.""" + path = tmp_path / "budouci.db" + conn = storage.connect(path) + conn.execute(f"PRAGMA user_version = {storage.SCHEMA_VERSION + 1}") + conn.commit() + conn.close() + + with pytest.raises(RuntimeError, match="novější verze"): + storage.connect(path) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..06f33a0 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,48 @@ +"""Verze se musí poznat i z binárky, kde žádný git není.""" + +import pytest + +import version + + +@pytest.fixture(autouse=True) +def bez_pameti(): + """`current()` si výsledek pamatuje; mezi testy je potřeba čistý stůl.""" + version.current.cache_clear() + yield + version.current.cache_clear() + + +def test_z_binarky_se_cte_pribaleny_soubor(monkeypatch, tmp_path): + """V binárce leží verze.txt v dočasném adresáři vedle šablon.""" + (tmp_path / version.NAME).write_text("v9.9.9\n", encoding="utf-8") + monkeypatch.setattr(version.sys, "frozen", True, raising=False) + monkeypatch.setattr(version.sys, "_MEIPASS", str(tmp_path), raising=False) + + assert version.current() == "v9.9.9" + + +def test_binarka_se_gitu_nepta(monkeypatch, tmp_path): + """Na cizím počítači žádný repozitář není a ptát se nemá koho.""" + monkeypatch.setattr(version.sys, "frozen", True, raising=False) + monkeypatch.setattr(version.sys, "_MEIPASS", str(tmp_path), raising=False) + monkeypatch.setattr(version, "described", + lambda: pytest.fail("binárka se ptala gitu")) + + assert version.current() == version.UNKNOWN + + +def test_ze_zdrojaku_ma_prednost_git(monkeypatch): + monkeypatch.delattr(version.sys, "frozen", raising=False) + monkeypatch.setattr(version, "described", lambda: "v1.2.3-4-gabcdef") + monkeypatch.setattr(version, "bundled", lambda: "v1.0.0") + + assert version.current() == "v1.2.3-4-gabcdef" + + +def test_bez_gitu_a_bez_souboru_zbyde_neznama(monkeypatch): + monkeypatch.delattr(version.sys, "frozen", raising=False) + monkeypatch.setattr(version, "described", lambda: "") + monkeypatch.setattr(version, "bundled", lambda: "") + + assert version.current() == version.UNKNOWN diff --git a/tests/test_web.py b/tests/test_web.py index e6d28a7..6abb950 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -326,3 +326,36 @@ def test_qr_obdelniky_kryji_stejne_moduly(): assert painted == expected assert svg.count(" dict[str, str]: + """Zamčené verze z compilovaného requirements souboru.""" + text = (ROOT / name).read_text(encoding="utf-8") + return {m.group(1).lower(): m.group(2) + for m in re.finditer(r"^([A-Za-z0-9_.\-]+)==(\S+)", text, re.M)} + + +def header(script: str) -> dict[str, str]: + """Závislosti z hlavičky PEP 723 na začátku skriptu.""" + text = (ROOT / script).read_text(encoding="utf-8") + line = re.search(r"^# dependencies = \[(.*)\]$", text, re.M) + assert line, f"{script} nemá hlavičku PEP 723" + + return {m.group(1).lower(): m.group(2) + for m in re.finditer(r'"([A-Za-z0-9_.\-]+)==(\S+?)"', line.group(1))} + + +@pytest.mark.parametrize("name", ["requirements.txt", "requirements-dev.txt", + "requirements-build.txt"]) +def test_zavislosti_jsou_zamcene(name): + """Bez zamčených verzí se binárka z března liší od binárky z dubna.""" + lines = [line.strip() for line in (ROOT / name).read_text().splitlines()] + volne = [line for line in lines + if line and not line.startswith("#") and "==" not in line] + + assert not volne, f"{name} má nezamčené závislosti: {volne}" + + +@pytest.mark.parametrize("script", sorted(SCRIPTS)) +def test_hlavicka_sedi_s_requirements(script): + """`uv run web.py` musí dostat totéž, co se zabalí do binárky.""" + zamcene = pins("requirements.txt") + v_hlavicce = header(script) + + assert set(v_hlavicce) == SCRIPTS[script] + + for balicek, verze in v_hlavicce.items(): + assert balicek in zamcene, f"{balicek} chybí v requirements.txt" + assert verze == zamcene[balicek], ( + f"{script}: {balicek}=={verze}, ale zamčeno je " + f"{zamcene[balicek]} — přepiš hlavičku") diff --git a/version.py b/version.py new file mode 100644 index 0000000..4e9715f --- /dev/null +++ b/version.py @@ -0,0 +1,49 @@ +"""Která verze počitadla to vlastně je. + +Venku jsou čtyři binárky pro tři systémy a k tomu zdrojáky. Když někdo +napíše, že mu něco nefunguje, je první otázka vždycky stejná — a bez +`--version` na ni nemá jak odpovědět. +""" + +import subprocess +import sys +from functools import lru_cache +from pathlib import Path + +NAME = "verze.txt" +UNKNOWN = "neznámá" + + +def bundled() -> str: + """Verze vypálená do binárky při stavbě. + + `build.sh` ji zapíše do souboru, `kostky.spec` ji přibalí. V binárce + leží vedle šablon v dočasném adresáři, na který ukazuje `sys._MEIPASS`. + """ + root = Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) + try: + return (root / NAME).read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def described() -> str: + """Verze podle gitu. Ze zdrojáků je to přesnější než cokoli zapsaného.""" + try: + done = subprocess.run( + ["git", "describe", "--tags", "--always", "--dirty"], + cwd=Path(__file__).parent, capture_output=True, text=True, + timeout=5, check=True) + except (OSError, subprocess.SubprocessError): + return "" + + return done.stdout.strip() + + +@lru_cache(maxsize=1) +def current() -> str: + """Verze pro výpis. Ptát se gitu má smysl jen mimo binárku.""" + if getattr(sys, "frozen", False): + return bundled() or UNKNOWN + + return described() or bundled() or UNKNOWN diff --git a/web.py b/web.py index 57faac7..97c3475 100644 --- a/web.py +++ b/web.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["flask", "qrcode"] +# dependencies = ["flask==3.1.3", "qrcode==8.2", "waitress==3.0.2"] # /// """Webové počitadlo pro domácí síť. @@ -8,20 +8,26 @@ Telefon zapisuje skóre (`/game/`), notebook ukazuje zápisník (`/board`). Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step. """ +import io import os +import shutil import socket import sys +import tempfile +from datetime import date from pathlib import Path import qrcode from flask import (Flask, abort, g, redirect, render_template, request, - url_for) + send_file, url_for) from markupsafe import Markup, escape +from waitress import serve import console import net import stats import storage +import version from core import FINAL_SCORE, Game, GameOver def bundled(folder: str) -> str: @@ -125,6 +131,12 @@ def qr_svg(data: str) -> Markup: ) +@app.context_processor +def verze(): + """Verze do patičky. Když někdo hlásí chybu, tohle je první otázka.""" + return {"verze": version.current()} + + @app.template_filter("datum") def datum(value: str) -> str: """Z ISO textu udělá 12. 8. 2026.""" @@ -311,6 +323,36 @@ def board_panel(game_id: int): return render_template("_panel.html", **view(game, game_id, row)) +@app.route("/export") +def export(): + """Databáze ke stažení. + + Dokud se hrálo ze zdrojáků, ležel `dice.db` v pracovním adresáři a záloha + byla otázka zkopírování. Z binárky se schovává do systémového datového + adresáře, který na Windows běžný člověk nenajde — tak ať se dá stáhnout + přes prohlížeč. + + `VACUUM INTO` udělá konzistentní kopii i uprostřed zápisu, na rozdíl od + prostého přečtení souboru. Výsledek si přečteme do paměti a kopii hned + zahodíme: databáze počitadla se počítá v kilobajtech a takhle nezůstane + ležet v dočasném adresáři, když si ji někdo nestáhne celou. + """ + conn = db() + conn.commit() # VACUUM nesmí běžet v transakci + + docasny = tempfile.mkdtemp() + try: + kopie = Path(docasny) / "dice.db" + conn.execute("VACUUM INTO ?", (str(kopie),)) + data = kopie.read_bytes() + finally: + shutil.rmtree(docasny, ignore_errors=True) + + return send_file(io.BytesIO(data), mimetype="application/vnd.sqlite3", + as_attachment=True, + download_name=f"kostky-{date.today().isoformat()}.db") + + @app.route("/stats") def hall(): conn = db() @@ -320,8 +362,9 @@ def hall(): games=storage.recent_games(conn)) -if __name__ == "__main__": - console.utf8() +def announce() -> None: + """Vypíše, kde všude je počitadlo k mání, a čím je zrovna spuštěné.""" + print(f"Kostky {version.current()}\n") found = net.addresses() @@ -336,4 +379,18 @@ if __name__ == "__main__": print(f"\nDatabáze: {storage.DB_PATH.resolve()}") - app.run(host="0.0.0.0", port=PORT, threaded=True) + +if __name__ == "__main__": + console.utf8() + + if {"--version", "-V"} & set(sys.argv[1:]): + print(f"kostky {version.current()}") + sys.exit(0) + + announce() + + # Ne vestavěný server Flasku: ten pod úvodní hlášku vysype červené + # varování, že takhle se to nemá, a má pravdu. Waitress je čistě + # pythonní, takže se zabalí do binárky bez řečí, a osm vláken pobere + # telefony u stolu i tabuli, která se doptává každé dvě vteřiny. + serve(app, host="0.0.0.0", port=PORT, threads=8)