Files
cellar-cli-c/tests/mock_server.py
T
Matteo Benedetto 5f82187c67 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
2026-09-20 17:46:29 +02:00

220 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Server Cellar minimale per i test di parita' (solo stdlib).
Riproduce i comportamenti del server FastAPI di Cellar:
GET /health -> {"status":"ok"}
GET /archives -> [ArchiveRead, ...] (per created_at desc)
POST /archives -> 201 ArchiveRead (multipart/form-data)
GET /archives/{id} -> ArchiveRead | 404
GET /archives/{id}/download -> FileResponse con Content-Disposition
DELETE /archives/{id} -> 204
Uso: mock_server.py --port 18099 --state /tmp/cellar-state
"""
from __future__ import annotations
import argparse
import hashlib
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
class State:
def __init__(self, root: Path):
self.root = root
self.storage = root / "storage"
self.db = root / "db.json"
self.lock = threading.Lock()
self.storage.mkdir(parents=True, exist_ok=True)
if not self.db.exists():
self.db.write_text("[]")
def load(self):
with self.lock:
return json.loads(self.db.read_text())
def save(self, rows):
with self.lock:
self.db.write_text(json.dumps(rows, indent=2))
def next_id(self, rows):
return max([r["id"] for r in rows], default=0) + 1
def parse_multipart(body: bytes, content_type: str):
m = re.search(r'boundary="?([^";]+)"?', content_type or "")
if not m:
return {}, None
boundary = ("--" + m.group(1)).encode()
fields, file_part = {}, None
for chunk in body.split(boundary):
if not chunk or chunk in (b"--", b"--\r\n", b"\r\n"):
continue
chunk = chunk.strip(b"\r\n")
if not chunk:
continue
head, _, data = chunk.partition(b"\r\n\r\n")
headers = head.decode("utf-8", "replace")
nm = re.search(r'name="([^"]*)"', headers)
if not nm:
continue
name = nm.group(1)
fn = re.search(r'filename="([^"]*)"', headers)
ct = re.search(r"Content-Type:\s*([^\r\n]+)", headers, re.I)
if fn:
file_part = (fn.group(1), data, (ct.group(1).strip() if ct else None))
else:
fields[name] = data.decode("utf-8", "replace")
return fields, file_part
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
def _json(self, code: int, payload):
data = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _body(self) -> bytes:
length = int(self.headers.get("Content-Length") or 0)
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]
if path == "/health":
return self._json(200, {"status": "ok"})
if path == "/archives":
return self._json(200, self.state.load())
m = re.fullmatch(r"/archives/(\d+)", path)
if m:
rows = self.state.load()
for r in rows:
if r["id"] == int(m.group(1)):
return self._json(200, r)
return self._json(404, {"detail": "Archive not found."})
m = re.fullmatch(r"/archives/(\d+)/download", path)
if m:
rows = self.state.load()
for r in rows:
if r["id"] == int(m.group(1)):
f = self.state.storage / r["stored_name"]
if not f.exists():
return self._json(404, {"detail": "Stored file not found."})
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(f.stat().st_size))
self.end_headers()
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"}})
return self._json(404, {"detail": "Not Found"})
def do_DELETE(self):
m = re.fullmatch(r"/archives/(\d+)", self.path)
if not m:
return self._json(404, {"detail": "Not Found"})
rows = self.state.load()
keep = [r for r in rows if r["id"] != int(m.group(1))]
if len(keep) == len(rows):
return self._json(404, {"detail": "Archive not found."})
self.state.save(keep)
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
def do_POST(self):
if self.path != "/archives":
return self._json(404, {"detail": "Not Found"})
body = self._body()
fields, file_part = parse_multipart(body, self.headers.get("Content-Type", ""))
if not file_part:
return self._json(422, {"detail": [{"loc": ["body", "file"], "msg": "field required"}]})
if "name" not in fields:
return self._json(422, {"detail": [{"loc": ["body", "name"], "msg": "field required"}]})
filename, data, ctype = file_part
if not filename:
return self._json(400, {"detail": "Uploaded file must have a filename."})
digest = hashlib.sha256(data).hexdigest()
stored = digest[:32] + ".tar.gz"
(self.state.storage / stored).write_bytes(data)
rows = self.state.load()
rec = {
"name": fields["name"],
"bottle_name": fields.get("bottle_name"),
"description": fields.get("description"),
"tags": fields.get("tags"),
"arch": fields.get("arch"),
"runner": fields.get("runner"),
"windows_version": fields.get("windows_version"),
"id": self.state.next_id(rows),
"file_name": filename,
"stored_name": stored,
"content_type": ctype,
"size_bytes": len(data),
"sha256": digest,
"created_at": datetime.now(timezone.utc).isoformat(),
}
rows.append(rec)
# come il server reale: ordinamento per created_at desc
rows.sort(key=lambda r: r["created_at"], reverse=True)
self.state.save(rows)
return self._json(201, rec)
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()
if __name__ == "__main__":
main()