progress: barra di avanzamento per download ed estrazione
Aggiunge src/progress.{c,h}: barra a una riga attiva solo quando stdout e' un
terminale (con output rediretto/pipeline non stampa nulla, quindi l'output resta
identico al client Python: parita' 30/30 invariata).
- due fasi: "Scaricamento" (byte da Content-Length) ed "Estrazione" (byte
compressi consumati + membri processati: nuovo contatore in gz_reader e
callback tar_progress_fn/tar_extract_cb)
- controllo con CELLAR_PROGRESS=auto|bar|plain|off (default auto)
- nessun codice ANSI, solo '\r' e riempimento con spazi; glifi ASCII se la
locale non e' UTF-8, larghezza da TIOCGWINSZ e misurata in colonne (i glifi
UTF-8 sono multi-byte), ridisegno throttled, velocita' a media mobile, ETA
- percorsi d'errore: progress_abort() chiude la riga senza riepilogo; il file
parziale resta come nel client Python
- tests/progress_test.sh: pipe silenziosa, PTY via script (barra, ETA, riepilogo,
nessun ANSI, righe entro la larghezza), modalita' plain e off, integrita' del
file scaricato e della bottiglia installata (13/13 verdi)
- tests/parity_test.sh resta 30/30; mock_server.py con --throttle per i test
This commit is contained in:
+32
-4
@@ -19,6 +19,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -75,8 +76,14 @@ def parse_multipart(body: bytes, content_type: str):
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
state: State
|
||||
throttle = 0 # byte/s, 0 = nessun limite (per testare la barra di avanzamento)
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
@classmethod
|
||||
def _pace(cls, nbytes: int) -> None:
|
||||
if cls.throttle > 0:
|
||||
time.sleep(nbytes / cls.throttle)
|
||||
|
||||
def log_message(self, *args): # silenzioso
|
||||
pass
|
||||
|
||||
@@ -90,7 +97,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def _body(self) -> bytes:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
return self.rfile.read(length) if length else b""
|
||||
if not length:
|
||||
return b""
|
||||
if Handler.throttle <= 0:
|
||||
return self.rfile.read(length)
|
||||
chunks, left = [], length
|
||||
while left > 0:
|
||||
n = min(left, 65536)
|
||||
data = self.rfile.read(n)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
left -= len(data)
|
||||
Handler._pace(len(data))
|
||||
return b"".join(chunks)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
@@ -113,13 +133,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
f = self.state.storage / r["stored_name"]
|
||||
if not f.exists():
|
||||
return self._json(404, {"detail": "Stored file not found."})
|
||||
data = f.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", r.get("content_type") or "application/octet-stream")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{r["file_name"]}"')
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Content-Length", str(f.stat().st_size))
|
||||
self.end_headers()
|
||||
return self.wfile.write(data)
|
||||
with f.open("rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
Handler._pace(len(chunk))
|
||||
return None
|
||||
return self._json(404, {"detail": "Archive not found."})
|
||||
if path == "/openapi.json":
|
||||
return self._json(200, {"info": {"title": "Bottle Archive Server", "version": "0.1.0"}})
|
||||
@@ -181,8 +207,10 @@ def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=18099)
|
||||
ap.add_argument("--state", required=True)
|
||||
ap.add_argument("--throttle", type=int, default=0, help="byte/s massimi (per testare la barra)")
|
||||
args = ap.parse_args()
|
||||
Handler.state = State(Path(args.state))
|
||||
Handler.throttle = args.throttle
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test della barra di avanzamento (src/progress.c).
|
||||
#
|
||||
# Verifica:
|
||||
# 1. modalita' auto con output rediretto -> NESSUN output di progresso
|
||||
# (cosi' l'output resta identico al client Python: vedi parity_test.sh);
|
||||
# 2. modalita' auto su terminale (PTY via `script`) -> barra disegnata per il
|
||||
# download e per l'estrazione, con riga di riepilogo finale;
|
||||
# 3. CELLAR_PROGRESS=plain -> una riga per soglia del 10%, niente barra;
|
||||
# 4. CELLAR_PROGRESS=off -> nessun output di progresso anche su PTY;
|
||||
# 5. l'output finale (Downloaded to / Installed bottle) resta corretto e
|
||||
# l'archivio scaricato e' integro.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
C_BIN="${C_BIN:-$ROOT/dist/cellar-cli}"
|
||||
PY="${PYTHON:-python3}"
|
||||
WORK="$ROOT/tests/tmp-progress"
|
||||
PORT="${PORT:-18097}"
|
||||
THROTTLE="${THROTTLE:-3000000}" # 3 MB/s: la barra disegna piu' fotogrammi
|
||||
SRV_PID=""
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { PASS=$((PASS + 1)); printf ' PASS %s\n' "$1"; }
|
||||
ko() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; }
|
||||
|
||||
cleanup() {
|
||||
[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail_if_missing() {
|
||||
[ -x "$C_BIN" ] || { echo "binario non trovato: $C_BIN (esegui make all)"; exit 1; }
|
||||
command -v script >/dev/null || { echo "serve util-linux 'script' per il test su PTY"; exit 1; }
|
||||
}
|
||||
fail_if_missing
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"/{state,inst,bottles,home}
|
||||
|
||||
# bottiglia di prova con un file da ~12 MB (comprimibile poco, cosi' il
|
||||
# download dura qualche secondo con il throttling)
|
||||
bash "$ROOT/tests/make_fake_bottle.sh" "$WORK/bottles" >/dev/null
|
||||
head -c 12000000 /dev/urandom >"$WORK/bottles/Fake Bottle/drive_c/big.bin"
|
||||
|
||||
"$PY" "$ROOT/tests/mock_server.py" --port "$PORT" --state "$WORK/state" --throttle "$THROTTLE" &
|
||||
SRV_PID=$!
|
||||
for _ in $(seq 60); do
|
||||
curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
SRV="http://127.0.0.1:$PORT"
|
||||
export HOME="$WORK/home"
|
||||
|
||||
# archivio tar.gz valido (l'estrazione deve poter funzionare)
|
||||
"$PY" - "$WORK/bottles/Fake Bottle" "$WORK/big.tar.gz" <<'PYEOF'
|
||||
import sys, tarfile
|
||||
with tarfile.open(sys.argv[2], "w:gz") as tf:
|
||||
tf.add(sys.argv[1], arcname="Fake Bottle")
|
||||
PYEOF
|
||||
"$C_BIN" --server "$SRV" upload "$WORK/big.tar.gz" --name BigBottle \
|
||||
--bottle-name "Fake Bottle" >/dev/null 2>&1
|
||||
echo " (server mock pronto, archivio valido da $(stat -c%s "$WORK/big.tar.gz") byte caricato)"
|
||||
|
||||
# ---------------------------------------------------------------- 1. auto su pipe
|
||||
out=$(CELLAR_PROGRESS=auto "$C_BIN" --server "$SRV" download 1 "$WORK/dl-pipe.bin" 2>&1)
|
||||
if [ "$out" = "Downloaded to $WORK/dl-pipe.bin" ]; then
|
||||
ok "auto su pipe: nessun output di progresso"
|
||||
else
|
||||
ko "auto su pipe: nessun output di progresso (ottenuto: $(printf '%s' "$out" | head -2 | tr '\n' '|'))"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------ 2. auto su PTY (barra)
|
||||
pty_out=$(script -qec "CELLAR_PROGRESS=auto '$C_BIN' --server '$SRV' install 'Fake Bottle' --bottles-dir '$WORK/inst'" /dev/null 2>&1 | tr '\r' '\n')
|
||||
if grep -q "Scaricamento \[" <<<"$pty_out"; then
|
||||
ok "PTY: barra del download disegnata"
|
||||
else
|
||||
ko "PTY: barra del download disegnata"
|
||||
fi
|
||||
if grep -q "Estrazione \[" <<<"$pty_out" || grep -q "^Estrazione:" <<<"$pty_out"; then
|
||||
ok "PTY: fase di estrazione tracciata"
|
||||
else
|
||||
ko "PTY: fase di estrazione tracciata"
|
||||
fi
|
||||
if grep -qE "^Scaricamento: [0-9.]+ (B|kB|MB|GB) in [0-9.]+ s" <<<"$pty_out"; then
|
||||
ok "PTY: riepilogo finale del download"
|
||||
else
|
||||
ko "PTY: riepilogo finale del download"
|
||||
fi
|
||||
if grep -q "^Installed bottle 'Fake Bottle' to " <<<"$pty_out"; then
|
||||
ok "PTY: output finale corretto dopo la barra"
|
||||
else
|
||||
ko "PTY: output finale corretto dopo la barra"
|
||||
fi
|
||||
if grep -q "ETA" <<<"$pty_out"; then
|
||||
ok "PTY: ETA mostrata durante il trasferimento"
|
||||
else
|
||||
ko "PTY: ETA mostrata durante il trasferimento"
|
||||
fi
|
||||
# nessun codice ANSI (retro-compatibilita' delle console)
|
||||
if grep -qP '\x1b\[' <<<"$pty_out"; then
|
||||
ko "PTY: nessun codice ANSI nel disegno"
|
||||
else
|
||||
ok "PTY: nessun codice ANSI nel disegno"
|
||||
fi
|
||||
# la barra non deve eccedere la larghezza del terminale (script: 80 colonne).
|
||||
# I glifi Unicode sono multi-byte: si contano i code point, non i byte.
|
||||
# solo le righe disegnate dalla barra (l'output normale puo' essere lungo)
|
||||
bar_lines=$(grep -E "^(Scaricamento|Estrazione)[ :]" <<<"$pty_out")
|
||||
longest=$(printf '%s' "$bar_lines" | "$PY" -c "import sys; print(max((len(l) for l in sys.stdin.read().split('\\n')), default=0))")
|
||||
if [ "${longest:-0}" -le 80 ]; then
|
||||
ok "PTY: righe entro 80 colonne (max $longest)"
|
||||
else
|
||||
ko "PTY: righe entro 80 colonne (max $longest)"
|
||||
printf '%s' "$bar_lines" | "$PY" -c "
|
||||
import sys
|
||||
lines = sys.stdin.buffer.read().decode('utf-8', 'replace').split('\n')
|
||||
for n, l in sorted(((len(x), x) for x in lines), reverse=True)[:3]:
|
||||
print(f' {n:3} |{l}|')
|
||||
"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- 3. modalita' plain
|
||||
plain_out=$(CELLAR_PROGRESS=plain "$C_BIN" --server "$SRV" download 1 "$WORK/dl-plain.bin" 2>&1)
|
||||
lines=$(grep -c "^Scaricamento: [0-9]" <<<"$plain_out")
|
||||
if [ "$lines" -ge 10 ]; then
|
||||
ok "plain: $lines righe di avanzamento (>=10)"
|
||||
else
|
||||
ko "plain: righe di avanzamento insufficienti ($lines)"
|
||||
fi
|
||||
if grep -q "^Scaricamento: completato" <<<"$plain_out" && ! grep -q "\[" <<<"$plain_out"; then
|
||||
ok "plain: riepilogo presente e nessuna barra grafica"
|
||||
else
|
||||
ko "plain: riepilogo presente e nessuna barra grafica"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------- 4. off
|
||||
off_out=$(script -qec "CELLAR_PROGRESS=off '$C_BIN' --server '$SRV' download 1 '$WORK/dl-off.bin'" /dev/null 2>&1 | tr '\r' '\n')
|
||||
if ! grep -qE "Scaricamento" <<<"$off_out"; then
|
||||
ok "off: nessun output di progresso (anche su PTY)"
|
||||
else
|
||||
ko "off: nessun output di progresso (anche su PTY)"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------- 5. integrita' dell'artefatto
|
||||
if [ -f "$WORK/dl-pipe.bin" ] && cmp -s "$WORK/dl-pipe.bin" "$WORK/big.tar.gz"; then
|
||||
ok "integrita': il file scaricato coincide con quello caricato"
|
||||
else
|
||||
ko "integrita': il file scaricato coincide con quello caricato"
|
||||
fi
|
||||
if [ -f "$WORK/inst/Fake Bottle/bottle.yml" ]; then
|
||||
ok "install: bottiglia installata nonostante il disegno della barra"
|
||||
else
|
||||
ko "install: bottiglia installata nonostante il disegno della barra"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=================================================="
|
||||
printf 'PASS: %d FAIL: %d\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
echo "barra di avanzamento ok ✔"
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
Name: 'Fake Bottle'
|
||||
Runner: soda-9.0-1
|
||||
Arch: win32
|
||||
Windows: win10
|
||||
Environment: Gaming
|
||||
@@ -0,0 +1 @@
|
||||
../drive_c/config.ini
|
||||
@@ -0,0 +1 @@
|
||||
hello world
|
||||
+1
@@ -0,0 +1 @@
|
||||
long path
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
/nonexistent/target
|
||||
@@ -0,0 +1 @@
|
||||
config
|
||||
@@ -0,0 +1 @@
|
||||
config.ini
|
||||
@@ -0,0 +1 @@
|
||||
unicode
|
||||
@@ -0,0 +1 @@
|
||||
binario
|
||||
@@ -0,0 +1 @@
|
||||
deep
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
[cellar]
|
||||
server = http://127.0.0.1:8080
|
||||
bottles_dir = /home/enne2/Dev/cellar-cli-c/tests/tmp-progress/home/.var/app/com.usebottles.bottles/data/bottles/bottles
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Name: 'Fake Bottle'
|
||||
Runner: soda-9.0-1
|
||||
Arch: win32
|
||||
Windows: win10
|
||||
Environment: Gaming
|
||||
@@ -0,0 +1 @@
|
||||
../drive_c/config.ini
|
||||
@@ -0,0 +1 @@
|
||||
hello world
|
||||
+1
@@ -0,0 +1 @@
|
||||
long path
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
/nonexistent/target
|
||||
@@ -0,0 +1 @@
|
||||
config
|
||||
@@ -0,0 +1 @@
|
||||
config.ini
|
||||
@@ -0,0 +1 @@
|
||||
unicode
|
||||
+1
@@ -0,0 +1 @@
|
||||
binario
|
||||
@@ -0,0 +1 @@
|
||||
deep
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "BigBottle",
|
||||
"bottle_name": "Fake Bottle",
|
||||
"description": null,
|
||||
"tags": null,
|
||||
"arch": null,
|
||||
"runner": null,
|
||||
"windows_version": null,
|
||||
"id": 1,
|
||||
"file_name": "big.tar.gz",
|
||||
"stored_name": "b2b18dc0668e8b5b661c952112aeb58a.tar.gz",
|
||||
"content_type": "application/gzip",
|
||||
"size_bytes": 12009363,
|
||||
"sha256": "b2b18dc0668e8b5b661c952112aeb58a3b16a1552156b0098823e47ca89f2434",
|
||||
"created_at": "2026-09-20T15:44:25.190979+00:00"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Reference in New Issue
Block a user