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
220 lines
8.0 KiB
Python
Executable File
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()
|