Infrastrukturní úklid: zamčené závislosti, verze schématu i binárky #12

Merged
david-spacil merged 8 commits from agents/claude/infrastruktura into main 2026-08-22 18:42:29 +02:00
26 changed files with 627 additions and 37 deletions

No files matched your search

+9 -2
View File
@@ -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
+1 -1
View File
@@ -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
+20 -5
View File
@@ -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
+1
View File
@@ -5,3 +5,4 @@ __pycache__/
dist/
build/
.build/
verze.txt
+58 -8
View File
@@ -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á.
+13 -4
View File
@@ -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
+10 -3
View File
@@ -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)
+8 -1
View File
@@ -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()
+8 -1
View File
@@ -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,
)
+4
View File
@@ -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
+37
View File
@@ -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
+4
View File
@@ -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
+35
View File
@@ -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
+6
View File
@@ -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
+25 -4
View File
@@ -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
+8
View File
@@ -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;
}
+40 -1
View File
@@ -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
+1
View File
@@ -8,5 +8,6 @@
</head>
<body>
{% block body %}{% endblock %}
<footer class="verze">kostky {{ verze }}</footer>
</body>
</html>
+2 -1
View File
@@ -8,7 +8,8 @@
<p class="eyebrow">Počitadlo</p>
<h1>Síň slávy</h1>
</div>
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a></p>
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a>
· <a href="{{ url_for('export') }}">Stáhnout databázi</a></p>
</header>
{% if records.best_turn %}
+18 -1
View File
@@ -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")
+67
View File
@@ -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)
+48
View File
@@ -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
+33
View File
@@ -326,3 +326,36 @@ def test_qr_obdelniky_kryji_stejne_moduly():
assert painted == expected
assert svg.count("<rect") < len(expected)
# --- záloha -----------------------------------------------------------------
def test_export_vrati_pouzitelnou_databazi(client, tmp_path):
"""Ze staženého souboru musí jít data přečíst zpátky, ne jen že se stáhne."""
game_id = start_game(client, "Adam", "Eva")
score(client, game_id, 150)
response = client.get("/export")
assert response.status_code == 200
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Disposition"].endswith(".db")
stazeny = tmp_path / "stazeny.db"
stazeny.write_bytes(response.data)
conn = storage.connect(stazeny)
try:
assert [p["name"] for p in storage.seating(conn, game_id)] == ["Adam", "Eva"]
assert storage.load_game(conn, game_id).totals()["Adam"] == 150
finally:
conn.close()
def test_export_zabere_i_rozehranou_hru(client):
"""Kopie se dělá za běhu, uprostřed nedohrané hry."""
game_id = start_game(client, "Adam")
score(client, game_id, 100)
assert client.get("/export").status_code == 200
assert text(client.get("/stats")).count("Stáhnout databázi") == 1
+60
View File
@@ -0,0 +1,60 @@
"""Závislosti jsou zamčené a všechna místa se shodnou.
Verze se píšou na dvou místech: v `requirements.txt` pro stavbu binárky
a v hlavičce PEP 723 pro `uv run web.py`. Ta dvě místa se dřív nebo později
rozejdou — leda že by to někdo hlídal. Proto tenhle soubor.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parent
# Skript a to, co si podle hlavičky nese s sebou.
SCRIPTS = {"web.py": {"flask", "qrcode", "waitress"},
"dice.py": {"tabulate"}}
def pins(name: str) -> 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")
+49
View File
@@ -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
+62 -5
View File
@@ -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/<id>`), 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)