Infrastrukturní úklid: zamčené závislosti, verze schématu i binárky #12
No files matched your search
@@ -53,10 +53,16 @@ jobs:
|
|||||||
uv --version
|
uv --version
|
||||||
|
|
||||||
- name: Testy
|
- 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
|
- 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
|
- name: Zkouška
|
||||||
run: |
|
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/static/style.css
|
||||||
curl -sf -o /dev/null http://127.0.0.1:8321/board
|
curl -sf -o /dev/null http://127.0.0.1:8321/board
|
||||||
test -f /tmp/zkouska/kostky/dice.db
|
test -f /tmp/zkouska/kostky/dice.db
|
||||||
|
./dist/kostky --version
|
||||||
cat /tmp/server.log
|
cat /tmp/server.log
|
||||||
|
|
||||||
- name: Na release
|
- name: Na release
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
- name: Testy
|
- name: Testy
|
||||||
run: |
|
run: |
|
||||||
uv run --managed-python --python "${{ matrix.python }}" \
|
uv run --managed-python --python "${{ matrix.python }}" \
|
||||||
--with-requirements requirements.txt --no-project \
|
--with-requirements requirements-dev.txt --no-project \
|
||||||
pytest -q
|
pytest -q
|
||||||
|
|
||||||
- name: Terminálová verze
|
- 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
|
# 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é,
|
# 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
|
# 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í.
|
# Gitea API. Jedna stránka s releasy, ta původní.
|
||||||
#
|
#
|
||||||
# Linux se tu schválně nestaví — o ten se stará vlastní runner
|
# Linux na x86-64 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.
|
# (.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
|
name: binárky
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -37,6 +39,9 @@ jobs:
|
|||||||
- os: macos-latest # Apple Silicon
|
- os: macos-latest # Apple Silicon
|
||||||
asset: kostky-macos-arm64
|
asset: kostky-macos-arm64
|
||||||
built: dist/kostky
|
built: dist/kostky
|
||||||
|
- os: ubuntu-24.04-arm # pro Raspberry Pi a spol.
|
||||||
|
asset: kostky-linux-arm64
|
||||||
|
built: dist/kostky
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
defaults:
|
defaults:
|
||||||
@@ -52,10 +57,16 @@ jobs:
|
|||||||
- uses: astral-sh/setup-uv@v10.0.1
|
- uses: astral-sh/setup-uv@v10.0.1
|
||||||
|
|
||||||
- name: Testy
|
- 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
|
- 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
|
- name: Zkouška
|
||||||
run: |
|
run: |
|
||||||
@@ -75,6 +86,10 @@ jobs:
|
|||||||
curl -sf "http://127.0.0.1:8123/board" | grep -q "127.0.0.1:8123"
|
curl -sf "http://127.0.0.1:8123/board" | grep -q "127.0.0.1:8123"
|
||||||
test -f zkouska/dice.db
|
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."
|
echo "Binárka jede."
|
||||||
|
|
||||||
- name: Na release
|
- name: Na release
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.build/
|
.build/
|
||||||
|
verze.txt
|
||||||
@@ -14,6 +14,7 @@ Stáhni binárku pro svůj systém z
|
|||||||
| Systém | Soubor |
|
| Systém | Soubor |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Linux (x86-64) | `kostky-linux-x86_64` |
|
| Linux (x86-64) | `kostky-linux-x86_64` |
|
||||||
|
| Linux (ARM64) | `kostky-linux-arm64` |
|
||||||
| macOS (Apple Silicon) | `kostky-macos-arm64` |
|
| macOS (Apple Silicon) | `kostky-macos-arm64` |
|
||||||
| macOS (Intel) | `kostky-macos-x86_64` |
|
| macOS (Intel) | `kostky-macos-x86_64` |
|
||||||
| Windows (x86-64) | `kostky-windows-x86_64.exe` |
|
| 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ý:
|
Server vypíše všechny adresy, na kterých je dostupný:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Kostky v1.1.0
|
||||||
|
|
||||||
Počitadlo je dostupné na:
|
Počitadlo je dostupné na:
|
||||||
→ http://10.186.234.182:8000 místní síť
|
→ http://10.186.234.182:8000 místní síť
|
||||||
http://100.91.0.24:8000 přes Tailscale
|
http://100.91.0.24:8000 přes Tailscale
|
||||||
http://172.17.0.1:8000 virtuální síť
|
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
|
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
|
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se
|
||||||
přepne na ni. Zastavuje se `Ctrl+C`.
|
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.
|
sad vedle sebe.
|
||||||
|
|
||||||
Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho
|
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
|
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
|
||||||
může zapisovat.
|
může zapisovat.
|
||||||
@@ -132,6 +150,7 @@ při remíze na prvním místě se hraje dál.
|
|||||||
| `web.py` | Flask aplikace |
|
| `web.py` | Flask aplikace |
|
||||||
| `dice.py` | totéž v terminálu, bez ukládání |
|
| `dice.py` | totéž v terminálu, bez ukládání |
|
||||||
| `console.py` | aby čeština prošla i windowsovou konzolí |
|
| `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 |
|
| `build.sh`, `kostky.spec` | stavba binárky |
|
||||||
| `.gitea/workflows/` | testy a linuxová binárka doma |
|
| `.gitea/workflows/` | testy a linuxová binárka doma |
|
||||||
| `.github/workflows/` | binárky pro Windows a macOS |
|
| `.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:
|
Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dnf install python3-flask python3-qrcode python3-tabulate
|
sudo dnf install python3-flask python3-qrcode python3-tabulate python3-waitress
|
||||||
python3 web.py
|
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
|
## Vlastní binárka
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build.sh
|
./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
|
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
|
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,
|
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 |
|
| Kde | Co staví | Workflow |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| vlastní runner u Gitey | Linux | `.gitea/workflows/binarka.yml` |
|
| vlastní runner u Gitey | Linux x86-64 | `.gitea/workflows/binarka.yml` |
|
||||||
| GitHub Actions | Windows, macOS ×2 | `.github/workflows/binarky.yml` |
|
| GitHub Actions | Windows, macOS ×2, Linux ARM64 | `.github/workflows/binarky.yml` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.0 && git push origin v1.0.0
|
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í
|
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
|
([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
|
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
|
stránku s releasy — tuhle. Linux na x86-64 se na GitHubu schválně nestaví;
|
||||||
proti staré glibc, a když GitHub vypadne, release má aspoň tu platformu,
|
doma to jde proti staré glibc, a když GitHub vypadne, release má aspoň tu
|
||||||
na které to reálně poběží.
|
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ž
|
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á
|
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`.
|
Nebo z připraveného prostředí prostě `pytest`.
|
||||||
|
|
||||||
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
|
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á.
|
v terminálové verzi, na kterou pytest nesahá.
|
||||||
@@ -12,11 +12,20 @@
|
|||||||
# se do ní nedostane, takže kontejner k tomu není potřeba.
|
# se do ní nedostane, takže kontejner k tomu není potřeba.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION=${VERSION:-3.11}
|
PYTHON_VERSION=${PYTHON_VERSION:-3.11}
|
||||||
BUILD=${BUILD:-.build}
|
BUILD=${BUILD:-.build}
|
||||||
|
|
||||||
uv python install "$VERSION"
|
# Verze, kterou binárka ohlásí přes --version. V CI ji na tagu vnutíme
|
||||||
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
|
# 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/.
|
# Windows dává spustitelné soubory venvu do Scripts/, zbytek světa do bin/.
|
||||||
python="$BUILD/bin/python"
|
python="$BUILD/bin/python"
|
||||||
@@ -26,7 +35,7 @@ if [ -d "$BUILD/Scripts" ]; then
|
|||||||
pyinstaller="$BUILD/Scripts/pyinstaller.exe"
|
pyinstaller="$BUILD/Scripts/pyinstaller.exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
uv pip install --quiet --python "$python" -r requirements.txt pyinstaller
|
uv pip install --quiet --python "$python" -r requirements-build.txt
|
||||||
|
|
||||||
"$pyinstaller" --clean --noconfirm \
|
"$pyinstaller" --clean --noconfirm \
|
||||||
--distpath dist --workpath "$BUILD/work" kostky.spec
|
--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
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def utf8() -> None:
|
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
|
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
|
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
|
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:
|
tam prázdná operace. `errors="replace"` je pojistka pro zbylé případy:
|
||||||
rozsypaný háček je pořád lepší než spadlý server.
|
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):
|
for stream in (sys.stdout, sys.stderr):
|
||||||
if hasattr(stream, "reconfigure"):
|
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
|
# /// script
|
||||||
# requires-python = ">=3.11"
|
# requires-python = ">=3.11"
|
||||||
# dependencies = ["tabulate"]
|
# dependencies = ["tabulate==0.10.0"]
|
||||||
# ///
|
# ///
|
||||||
"""Počitadlo skóre v terminálu.
|
"""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`).
|
v paměti, nic se neukládá — na historii je webová aplikace (`web.py`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
from os import name, system
|
from os import name, system
|
||||||
|
|
||||||
from tabulate import tabulate
|
from tabulate import tabulate
|
||||||
|
|
||||||
import console
|
import console
|
||||||
|
import version
|
||||||
from core import FINAL_SCORE, Game
|
from core import FINAL_SCORE, Game
|
||||||
|
|
||||||
table_header = ["Hráč", "Skóre"]
|
table_header = ["Hráč", "Skóre"]
|
||||||
@@ -130,4 +132,9 @@ def main() -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
console.utf8()
|
console.utf8()
|
||||||
|
|
||||||
|
if {"--version", "-V"} & set(sys.argv[1:]):
|
||||||
|
print(f"kostky {version.current()}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
main()
|
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.
|
i na starších systémech, než je ten tvůj.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
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(
|
analysis = Analysis(
|
||||||
["web.py"],
|
["web.py"],
|
||||||
pathex=["."],
|
pathex=["."],
|
||||||
datas=[("templates", "templates"), ("static", "static")],
|
datas=datas,
|
||||||
noarchive=False,
|
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
|
# This file was autogenerated by uv via the following command:
|
||||||
qrcode
|
# uv pip compile requirements.in -o requirements.txt --python-version 3.11
|
||||||
tabulate
|
blinker==1.9.0
|
||||||
pytest
|
# 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;
|
font-size: 0.85em;
|
||||||
color: var(--ink);
|
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);
|
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:
|
def connect(path: Path | str | None = None) -> sqlite3.Connection:
|
||||||
target = Path(path or DB_PATH)
|
target = Path(path or DB_PATH)
|
||||||
@@ -93,7 +132,7 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
|
|||||||
conn = sqlite3.connect(target)
|
conn = sqlite3.connect(target)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
conn.executescript(SCHEMA)
|
migrate(conn)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
|
<footer class="verze">kostky {{ verze }}</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
<p class="eyebrow">Počitadlo</p>
|
<p class="eyebrow">Počitadlo</p>
|
||||||
<h1>Síň slávy</h1>
|
<h1>Síň slávy</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{% if records.best_turn %}
|
{% if records.best_turn %}
|
||||||
|
|||||||
+18
-1
@@ -9,10 +9,12 @@ class Proud:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.encoding = "cp1252"
|
self.encoding = "cp1252"
|
||||||
self.errors = "strict"
|
self.errors = "strict"
|
||||||
|
self.line_buffering = False
|
||||||
|
|
||||||
def reconfigure(self, encoding, errors):
|
def reconfigure(self, encoding, errors, line_buffering=False):
|
||||||
self.encoding = encoding
|
self.encoding = encoding
|
||||||
self.errors = errors
|
self.errors = errors
|
||||||
|
self.line_buffering = line_buffering
|
||||||
|
|
||||||
|
|
||||||
def test_vystup_se_prepne_na_utf8(monkeypatch):
|
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 (out.encoding, out.errors) == ("utf-8", "replace")
|
||||||
assert (err.encoding, err.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):
|
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())
|
monkeypatch.setattr(console.sys, "stderr", object())
|
||||||
|
|
||||||
console.utf8()
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -182,3 +183,69 @@ def test_connect_zalozi_chybejici_adresar(tmp_path):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
assert (tmp_path / "novy" / "adresar" / "dice.db").exists()
|
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 painted == expected
|
||||||
assert svg.count("<rect") < len(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
|
# /// script
|
||||||
# requires-python = ">=3.11"
|
# 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íť.
|
"""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.
|
Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import qrcode
|
import qrcode
|
||||||
from flask import (Flask, abort, g, redirect, render_template, request,
|
from flask import (Flask, abort, g, redirect, render_template, request,
|
||||||
url_for)
|
send_file, url_for)
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup, escape
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
import console
|
import console
|
||||||
import net
|
import net
|
||||||
import stats
|
import stats
|
||||||
import storage
|
import storage
|
||||||
|
import version
|
||||||
from core import FINAL_SCORE, Game, GameOver
|
from core import FINAL_SCORE, Game, GameOver
|
||||||
|
|
||||||
def bundled(folder: str) -> str:
|
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")
|
@app.template_filter("datum")
|
||||||
def datum(value: str) -> str:
|
def datum(value: str) -> str:
|
||||||
"""Z ISO textu udělá 12. 8. 2026."""
|
"""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))
|
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")
|
@app.route("/stats")
|
||||||
def hall():
|
def hall():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -320,8 +362,9 @@ def hall():
|
|||||||
games=storage.recent_games(conn))
|
games=storage.recent_games(conn))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def announce() -> None:
|
||||||
console.utf8()
|
"""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()
|
found = net.addresses()
|
||||||
|
|
||||||
@@ -336,4 +379,18 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
|
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