Merge pull request 'Infrastrukturní úklid: zamčené závislosti, verze schématu i binárky' (#12) from agents/claude/infrastruktura into main
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
commit
923fc03581
26 files changed
+627
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,3 +5,4 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
.build/
|
||||
verze.txt
|
||||
@@ -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á.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
<footer class="verze">kostky {{ verze }}</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user