2 Commits
Author SHA1 Message Date
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
Matteo Benedetto 6f5b224b2f docs: sezione download dei binari statici della release v1.0.0 + SHA256SUMS 2026-09-20 17:31:12 +02:00
43 changed files with 781 additions and 14 deletions
+2 -1
View File
@@ -50,7 +50,8 @@ verify: all
tests/verify_binary.sh $(DIST)/$(NAME) tests/verify_binary.sh $(DIST)/$(NAME)
test: asan test: asan
tests/parity_test.sh tests/parity_test.sh # parita' col client Python (30 casi + artefatti)
tests/progress_test.sh # barra di avanzamento (pipe/PTY/plain/off)
clean: clean:
rm -rf $(DIST) build tests/tmp rm -rf $(DIST) build tests/tmp
+54 -1
View File
@@ -17,6 +17,20 @@ Autore: Matteo Benedetto — progetto derivato da `enne2/cellar` (commit `f5216b
| Build | `x86_64` static, `i686` static, `aarch64` static (cross) | | Build | `x86_64` static, `i686` static, `aarch64` static (cross) |
| Installazione locale | `~/.local/bin/cellar-cli` → symlink a `dist/cellar-cli` | | Installazione locale | `~/.local/bin/cellar-cli` → symlink a `dist/cellar-cli` |
## Download dei binari pronti
Nella release **v1.0.0** sono allegati tre binari statici (nessuna dipendenza a runtime):
| file | architettura | ABI minima |
|---|---|---|
| `cellar-cli-1.0.0-linux-x86_64-static` | x86-64 (amd64) | Linux, ABI 3.2.0 |
| `cellar-cli-1.0.0-linux-i686-static` | i686 (x86 32 bit) | Linux, ABI 3.2.0 |
| `cellar-cli-1.0.0-linux-aarch64-static` | aarch64 (arm64) | Linux, ABI 3.7.0 |
Insieme a `SHA256SUMS.txt` per la verifica: `sha256sum -c SHA256SUMS.txt`.
Pubblicato su Gitea: <https://git.enne2.net/enne2/cellar-cli-c/releases/tag/v1.0.0>
## Build ## Build
```bash ```bash
@@ -67,6 +81,37 @@ cellar-cli --server http://10.8.0.3:8080 list # override del server
Exit code: `0` ok, `1` errore HTTP/rete/file, `2` argomenti non validi (come argparse). Exit code: `0` ok, `1` errore HTTP/rete/file, `2` argomenti non validi (come argparse).
## Barra di avanzamento
Durante `install`, `wizard-install` e `download` viene mostrata una barra su una riga,
con due fasi (`Scaricamento` ed `Estrazione`):
```
Scaricamento [████████████░░░░░░░░░░░░] 58% 6.7 MB/11.5 MB 3.0 MB/s ETA 00:01
Estrazione [████████████████████████] 100% 11.5 MB/11.5 MB 178 MB/s membri: 20
```
- **attiva solo se stdout è un terminale**: con output rediretto, in pipeline, in cron o
nei log **non stampa nulla** — per questo l'output resta identico al client Python;
- controllo con la variabile d'ambiente **`CELLAR_PROGRESS`**:
- `auto` (default) → barra su terminale, silenzio altrove;
- `bar` → barra anche con output rediretto;
- `plain` → una riga ogni 10% (per log e cron, senza disegno);
- `off` → disattivata;
- **nessun codice ANSI**: solo `\r` e riempimento con spazi, quindi funziona anche su
console vecchie e seriali; glifi ASCII (`#`/`-`) se la locale non è UTF-8, blocchi
Unicode (`█`/`░`) se lo è;
- larghezza adattata al terminale (`TIOCGWINSZ`, fallback 80 colonne, max 200) e misurata
in **colonne**, non in byte (i glifi UTF-8 sono multi-byte);
- ridisegno limitato a ~8 volte al secondo, velocità con media mobile, ETA mostrata solo
quando la stima è affidabile;
- `CELLAR_PROGRESS_DEBUG=1` stampa su stderr modalità/larghezza calcolate (diagnostica).
```bash
CELLAR_PROGRESS=plain cellar-cli install 'Gioco' # output adatto a un log
CELLAR_PROGRESS=off cellar-cli install 'Gioco' # nessuna barra
```
## Retrocompatibilità (le scelte che contano) ## Retrocompatibilità (le scelte che contano)
- **Binario statico** (`-static`): nessuna dipendenza da glibc a runtime, quindi gira su - **Binario statico** (`-static`): nessuna dipendenza da glibc a runtime, quindi gira su
@@ -114,6 +159,7 @@ sei casi di errore di argomenti, più verifiche sugli artefatti:
| Confronto nomi case-insensitive | ASCII (`tolower`) invece di `str.lower()` Unicode | | Confronto nomi case-insensitive | ASCII (`tolower`) invece di `str.lower()` Unicode |
| EOF su una richiesta interattiva | Python solleva `EOFError` (exit 1); qui si stampa `EOFError: EOF when reading a line` e si esce **1**. Nei prompt con default (nome archivio, descrizione…) su EOF si applica il default, così i wizard restano usabili da script | | EOF su una richiesta interattiva | Python solleva `EOFError` (exit 1); qui si stampa `EOFError: EOF when reading a line` e si esce **1**. Nei prompt con default (nome archivio, descrizione…) su EOF si applica il default, così i wizard restano usabili da script |
| Upload multipart | In **streaming** (il client Python legge l'intero archivio in RAM) | | Upload multipart | In **streaming** (il client Python legge l'intero archivio in RAM) |
| Barra di avanzamento | Aggiunta (il CLI Python non stampa nulla durante download/estrazione). Attiva solo su terminale, quindi l'output rediretto resta identico all'originale |
| TLS | Non supportato (niente OpenSSL): serve `http://`, oppure un reverse proxy TLS. Il server Cellar è HTTP | | TLS | Non supportato (niente OpenSSL): serve `http://`, oppure un reverse proxy TLS. Il server Cellar è HTTP |
| Timeout di rete | Connect 15 s, I/O 300 s (urllib non ha timeout) | | Timeout di rete | Connect 15 s, I/O 300 s (urllib non ha timeout) |
| Redirect | Seguiti come `urllib.request`: GET/HEAD su 301/302/303/307/308, POST→GET su 301/302/303 | | Redirect | Seguiti come `urllib.request`: GET/HEAD su 301/302/303/307/308, POST→GET su 301/302/303 |
@@ -121,11 +167,17 @@ sei casi di errore di argomenti, più verifiche sugli artefatti:
## Test e verifica ## Test e verifica
```bash ```bash
make test # parità (usa la build ASan) make test # parità + barra (build ASan)
C_BIN=dist/cellar-cli tests/parity_test.sh # parità con il binario statico C_BIN=dist/cellar-cli tests/parity_test.sh # parità con il binario statico
C_BIN=dist/cellar-cli tests/progress_test.sh # barra: pipe silenziosa, PTY, plain, off
make verify # file/ldd/ABI/simboli GLIBC/syscall/smoke test make verify # file/ldd/ABI/simboli GLIBC/syscall/smoke test
``` ```
`tests/progress_test.sh` usa un server mock con throttling (`--throttle` byte/s) e
`script -qec` per allocare un PTY: verifica che su pipe non ci sia alcun output di
progresso, che su terminale compaiano le due fasi con ETA e riepilogo, che nessuna riga
superi la larghezza del terminale e che non ci siano codici ANSI.
## Struttura ## Struttura
``` ```
@@ -140,6 +192,7 @@ src/config.{c,h} ~/.cellar.conf (INI in stile configparser)
src/bottle.{c,h} record archivi, scansione bottiglie, bottle.yml, backup src/bottle.{c,h} record archivi, scansione bottiglie, bottle.yml, backup
src/ui.{c,h} tabelle e JSON con larghezze in code point (come le f-string) src/ui.{c,h} tabelle e JSON con larghezze in code point (come le f-string)
src/ops.{c,h} list/upload/download/install src/ops.{c,h} list/upload/download/install
src/progress.{c,h} barra di avanzamento (download + estrazione, env CELLAR_PROGRESS)
src/wizard.{c,h} flussi interattivi src/wizard.{c,h} flussi interattivi
src/main.c CLI e messaggi di errore in stile argparse src/main.c CLI e messaggi di errore in stile argparse
third_party/ miniz (unlicense/MIT) + licenza third_party/ miniz (unlicense/MIT) + licenza
+3
View File
@@ -0,0 +1,3 @@
0c454b2c65ac872efcd19888f996f3de17e6d69541cfc8b2657c0a0bfaf9b23c cellar-cli-1.0.0-linux-x86_64-static
16c7db8c6667ff46c4a276bf7985b2ebc1cb5078f079651414472d47ef5f9ad7 cellar-cli-1.0.0-linux-i686-static
11ad5ae2b5916c21d0ed2f5fa7a640e787596da2df4f5d3685b1bb48505d8f7b cellar-cli-1.0.0-linux-aarch64-static
+17
View File
@@ -30,6 +30,7 @@ struct gz_reader {
mz_stream zs; mz_stream zs;
FILE *fp; FILE *fp;
char in[GZ_BUFSZ]; char in[GZ_BUFSZ];
unsigned long long total_in; /* byte compressi consumati */
int stream_end; int stream_end;
unsigned long crc; unsigned long crc;
unsigned long long total_out; unsigned long long total_out;
@@ -247,6 +248,11 @@ gz_reader *gzr_open(const char *path, cerror *e)
free(r); free(r);
return NULL; return NULL;
} }
/* i byte dell'header fanno parte dell'avanzamento */
{
long pos = ftell(r->fp);
r->total_in = (pos > 0) ? (unsigned long long)pos : 0;
}
int rc = mz_inflateInit2(&r->zs, -15); /* deflate raw */ int rc = mz_inflateInit2(&r->zs, -15); /* deflate raw */
if (rc != MZ_OK) { if (rc != MZ_OK) {
err_set(e, ERR_MISC, 0, "inflate init failed (%d)", rc); err_set(e, ERR_MISC, 0, "inflate init failed (%d)", rc);
@@ -286,6 +292,7 @@ long gzr_read(gz_reader *r, void *buf, size_t cap, cerror *e)
} }
break; /* EOF inatteso: lo segnala il chiamante */ break; /* EOF inatteso: lo segnala il chiamante */
} }
r->total_in += (unsigned long long)n;
r->zs.next_in = (unsigned char *)r->in; r->zs.next_in = (unsigned char *)r->in;
r->zs.avail_in = (unsigned int)n; r->zs.avail_in = (unsigned int)n;
} }
@@ -310,6 +317,16 @@ long gzr_read(gz_reader *r, void *buf, size_t cap, cerror *e)
return (long)produced; return (long)produced;
} }
void gzr_progress(gz_reader *r, long long *in_bytes, long long *out_bytes)
{
if (!r)
return;
if (in_bytes)
*in_bytes = (long long)r->total_in;
if (out_bytes)
*out_bytes = (long long)r->total_out;
}
void gzr_close(gz_reader *r) void gzr_close(gz_reader *r)
{ {
if (!r) if (!r)
+2
View File
@@ -19,6 +19,8 @@ int gzw_close(gz_writer *w, cerror *e);
gz_reader *gzr_open(const char *path, cerror *e); gz_reader *gzr_open(const char *path, cerror *e);
/* Ritorna il numero di byte letti, 0 a EOF, -1 su errore. */ /* Ritorna il numero di byte letti, 0 a EOF, -1 su errore. */
long gzr_read(gz_reader *r, void *buf, size_t cap, cerror *e); long gzr_read(gz_reader *r, void *buf, size_t cap, cerror *e);
/* Avanzamento: byte compressi consumati e byte decompressi prodotti. */
void gzr_progress(gz_reader *r, long long *in_bytes, long long *out_bytes);
void gzr_close(gz_reader *r); void gzr_close(gz_reader *r);
#endif /* CELLAR_GZIP_H */ #endif /* CELLAR_GZIP_H */
+38 -4
View File
@@ -4,6 +4,7 @@
#include "fsutil.h" #include "fsutil.h"
#include "http.h" #include "http.h"
#include "json.h" #include "json.h"
#include "progress.h"
#include "tar.h" #include "tar.h"
#include "ui.h" #include "ui.h"
@@ -141,6 +142,7 @@ int ops_upload(const char *server, const char *filepath, const mp_field *fields,
typedef struct { typedef struct {
FILE *fp; FILE *fp;
long long written; long long written;
progress *pr; /* opzionale: barra di avanzamento */
} file_sink; } file_sink;
static int sink_write(void *ctx, const char *buf, size_t len, cerror *e) static int sink_write(void *ctx, const char *buf, size_t len, cerror *e)
@@ -151,6 +153,8 @@ static int sink_write(void *ctx, const char *buf, size_t len, cerror *e)
return -1; return -1;
} }
s->written += (long long)len; s->written += (long long)len;
if (s->pr)
progress_update(s->pr, s->written);
return 0; return 0;
} }
@@ -187,9 +191,12 @@ static int download_to(const char *server, long long archive_id, const char *des
csresponse_free(&meta); csresponse_free(&meta);
return -1; return -1;
} }
progress pr;
progress_init(&pr, "Scaricamento", meta.content_length);
file_sink sink; file_sink sink;
sink.fp = fp; sink.fp = fp;
sink.written = 0; sink.written = 0;
sink.pr = &pr;
int rc = http_read_body(c, sink_write, &sink, NULL, e); int rc = http_read_body(c, sink_write, &sink, NULL, e);
if (fclose(fp) != 0 && rc == 0) { if (fclose(fp) != 0 && rc == 0) {
err_set_errno(e, ERR_FILE, "cannot write %s", dest); err_set_errno(e, ERR_FILE, "cannot write %s", dest);
@@ -197,8 +204,11 @@ static int download_to(const char *server, long long archive_id, const char *des
} }
http_close(c); http_close(c);
csresponse_free(&meta); csresponse_free(&meta);
if (rc != 0) if (rc != 0) {
return -1; progress_abort(&pr);
return -1; /* come il client Python: il file parziale resta */
}
progress_finish(&pr);
if (announce) if (announce)
printf("Downloaded to %s\n", dest); printf("Downloaded to %s\n", dest);
return 0; return 0;
@@ -252,9 +262,12 @@ int ops_download(const char *server, long long archive_id, const char *output, c
csresponse_free(&meta); csresponse_free(&meta);
return -1; return -1;
} }
progress pr;
progress_init(&pr, "Scaricamento", meta.content_length);
file_sink sink; file_sink sink;
sink.fp = fp; sink.fp = fp;
sink.written = 0; sink.written = 0;
sink.pr = &pr;
int rc = http_read_body(c, sink_write, &sink, NULL, e); int rc = http_read_body(c, sink_write, &sink, NULL, e);
if (fclose(fp) != 0 && rc == 0) { if (fclose(fp) != 0 && rc == 0) {
err_set_errno(e, ERR_FILE, "cannot write %s", dest); err_set_errno(e, ERR_FILE, "cannot write %s", dest);
@@ -262,14 +275,25 @@ int ops_download(const char *server, long long archive_id, const char *output, c
} }
http_close(c); http_close(c);
csresponse_free(&meta); csresponse_free(&meta);
if (rc != 0) if (rc != 0) {
progress_abort(&pr);
return -1; return -1;
}
progress_finish(&pr);
printf("Downloaded to %s\n", dest); printf("Downloaded to %s\n", dest);
return 0; return 0;
} }
return download_to(server, archive_id, output, 1, e); return download_to(server, archive_id, output, 1, e);
} }
static int extract_progress_cb(void *ctx, long long consumed, long long members)
{
progress *p = ctx;
progress_set_members(p, members);
progress_update(p, consumed);
return 0;
}
int ops_install_record(const char *server, const archive_rec *rec, const char *bottles_dir, int replace, int ops_install_record(const char *server, const archive_rec *rec, const char *bottles_dir, int replace,
const char *ref, cerror *e) const char *ref, cerror *e)
{ {
@@ -319,8 +343,18 @@ int ops_install_record(const char *server, const archive_rec *rec, const char *b
if (mkdir_p(extract_dir, e) != 0) if (mkdir_p(extract_dir, e) != 0)
goto cleanup; goto cleanup;
if (tar_extract(download_path, extract_dir, e) != 0) {
struct stat arc_st;
long long arc_size = (stat(download_path, &arc_st) == 0) ? (long long)arc_st.st_size : -1;
progress px;
progress_init(&px, "Estrazione", arc_size);
int xrc = tar_extract_cb(download_path, extract_dir, extract_progress_cb, &px, e);
if (xrc != 0) {
progress_abort(&px);
goto cleanup; goto cleanup;
}
progress_finish(&px);
}
strlist subdirs; strlist subdirs;
strlist_init(&subdirs); strlist_init(&subdirs);
+336
View File
@@ -0,0 +1,336 @@
/* progress.c — implementazione della barra (vd. progress.h). */
#include "progress.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <time.h>
#include <unistd.h>
#define PROG_MIN_WIDTH 40
#define PROG_MAX_WIDTH 200
#define PROG_DRAW_INTERVAL 0.12 /* secondi tra due ridisegni */
#define PROG_SAMPLE_INTERVAL 0.25
#define PROG_ASCII_FULL '#'
#define PROG_ASCII_EMPTY '-'
#define PROG_UNI_FULL "\xe2\x96\x88" /* U+2588 */
#define PROG_UNI_EMPTY "\xe2\x96\x91" /* U+2591 */
static double now_monotonic(void)
{
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0)
return 0.0;
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
static int env_is_utf8(void)
{
const char *vars[] = {getenv("LC_ALL"), getenv("LC_CTYPE"), getenv("LANG")};
for (size_t i = 0; i < sizeof(vars) / sizeof(vars[0]); i++) {
const char *v = vars[i];
if (!v || !*v)
continue;
char buf[64];
size_t n = strlen(v);
if (n >= sizeof(buf))
n = sizeof(buf) - 1;
for (size_t k = 0; k < n; k++)
buf[k] = (char)tolower((unsigned char)v[k]);
buf[n] = '\0';
if (strstr(buf, "utf-8") || strstr(buf, "utf8"))
return 1;
/* la prima variabile impostata vince (come fa setlocale) */
return 0;
}
return 0;
}
prog_mode progress_mode_from_env(void)
{
const char *v = getenv("CELLAR_PROGRESS");
if (!v || !*v)
return PROG_AUTO;
if (!strcmp(v, "bar") || !strcmp(v, "1") || !strcmp(v, "on") || !strcmp(v, "yes"))
return PROG_BAR;
if (!strcmp(v, "plain") || !strcmp(v, "line"))
return PROG_PLAIN;
if (!strcmp(v, "off") || !strcmp(v, "0") || !strcmp(v, "none") || !strcmp(v, "no"))
return PROG_OFF;
return PROG_AUTO;
}
static int terminal_width(void)
{
struct winsize ws;
if (isatty(STDOUT_FILENO) && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
int w = ws.ws_col;
if (w < PROG_MIN_WIDTH)
w = PROG_MIN_WIDTH;
if (w > PROG_MAX_WIDTH)
w = PROG_MAX_WIDTH;
return w;
}
return 80;
}
/* Dimensione in stile client Python: MiB etichettati MB. */
static void fmt_size(long long bytes, char *out, size_t outsz)
{
double b = (double)bytes;
if (bytes < 1024)
snprintf(out, outsz, "%lld B", bytes);
else if (bytes < 1024 * 1024)
snprintf(out, outsz, "%.0f kB", b / 1024.0);
else if (bytes < 1024LL * 1024 * 1024)
snprintf(out, outsz, "%.1f MB", b / (1024.0 * 1024.0));
else
snprintf(out, outsz, "%.2f GB", b / (1024.0 * 1024.0 * 1024.0));
}
static void fmt_speed(double bytes_per_sec, char *out, size_t outsz)
{
double b = bytes_per_sec;
if (b < 1024)
snprintf(out, outsz, "%.0f B/s", b);
else if (b < 1024.0 * 1024.0)
snprintf(out, outsz, "%.0f kB/s", b / 1024.0);
else
snprintf(out, outsz, "%.1f MB/s", b / (1024.0 * 1024.0));
}
static void fmt_eta(double seconds, char *out, size_t outsz)
{
if (seconds < 0 || seconds > 99 * 3600) {
snprintf(out, outsz, "--:--");
return;
}
long s = (long)(seconds + 0.5);
snprintf(out, outsz, "%02ld:%02ld", s / 60, s % 60);
}
void progress_init(progress *p, const char *label, long long total)
{
memset(p, 0, sizeof(*p));
p->mode = progress_mode_from_env();
if (p->mode == PROG_AUTO) {
/* auto: barra solo su terminale, silenzio se rediretto/in pipeline */
p->mode = isatty(STDOUT_FILENO) ? PROG_BAR : PROG_OFF;
}
p->label = label ? label : "";
p->total = total;
p->last_percent = -1;
p->plain_step = 0;
p->width = terminal_width();
p->unicode = env_is_utf8();
p->t_start = now_monotonic();
p->t_last_draw = -1.0;
p->t_last_sample = p->t_start;
if (getenv("CELLAR_PROGRESS_DEBUG"))
fprintf(stderr, "[debug progress] mode=%d width=%d unicode=%d isatty=%d\n", (int)p->mode,
p->width, p->unicode, isatty(STDOUT_FILENO));
if (p->mode == PROG_PLAIN)
printf("%s: avvio...\n", p->label);
}
void progress_set_members(progress *p, long long members)
{
p->members = members;
}
static void build_line(progress *p, char *line, size_t linesz, int final)
{
char done_s[32], total_s[32], speed_s[32], eta_s[16];
fmt_size(p->done, done_s, sizeof(done_s));
if (p->total > 0)
fmt_size(p->total, total_s, sizeof(total_s));
else
snprintf(total_s, sizeof(total_s), "?");
int percent = -1;
if (p->total > 0) {
percent = (int)((p->done * 100) / p->total);
if (percent > 100)
percent = 100;
}
fmt_speed(p->rate, speed_s, sizeof(speed_s));
double eta = -1.0;
if (p->total > 0 && p->rate > 4096.0)
eta = (double)(p->total - p->done) / p->rate;
fmt_eta(eta, eta_s, sizeof(eta_s));
char members_s[48];
members_s[0] = '\0';
if (p->members > 0)
snprintf(members_s, sizeof(members_s), " membri: %lld", p->members);
/* suffisso senza barra */
char suffix[192];
if (percent >= 0)
snprintf(suffix, sizeof(suffix), "] %3d%% %s/%s %s%s", percent, done_s, total_s, speed_s,
members_s);
else
snprintf(suffix, sizeof(suffix), "] %s %s%s", done_s, speed_s, members_s);
if (!final && p->rate <= 4096.0) {
/* senza stima affidabile non mostriamo ETA */
} else if (eta >= 0) {
char tmp[224];
snprintf(tmp, sizeof(tmp), "%s ETA %s", suffix, eta_s);
snprintf(suffix, sizeof(suffix), "%s", tmp);
}
if (p->mode == PROG_PLAIN) {
/* log-friendly: niente barra grafica, una riga per soglia */
if (percent >= 0)
snprintf(line, linesz, "%s: %d%% %s/%s %s%s", p->label, percent, done_s, total_s,
speed_s, members_s);
else
snprintf(line, linesz, "%s: %s %s%s", p->label, done_s, speed_s, members_s);
return;
}
char prefix[96];
snprintf(prefix, sizeof(prefix), "%s [", p->label);
size_t avail = 0;
size_t plen = strlen(prefix), slen = strlen(suffix);
if (p->width > (int)(plen + slen))
avail = (size_t)p->width - plen - slen;
if (avail < 8) {
/* troppo stretta per la barra: riduce il suffisso */
snprintf(line, linesz, "%s%s", prefix, suffix);
return;
}
size_t filled = 0;
if (p->total > 0)
filled = (size_t)((p->done * (long long)avail) / p->total);
if (filled > avail)
filled = avail;
size_t used = 0;
int n = snprintf(line, linesz, "%s", prefix);
if (n > 0)
used = (size_t)n;
const char *full = p->unicode ? PROG_UNI_FULL : NULL;
const char *empty = p->unicode ? PROG_UNI_EMPTY : NULL;
for (size_t i = 0; i < avail && used + 8 < linesz; i++) {
if (i < filled) {
if (full)
used += (size_t)snprintf(line + used, linesz - used, "%s", full);
else
line[used++] = PROG_ASCII_FULL;
} else {
if (empty)
used += (size_t)snprintf(line + used, linesz - used, "%s", empty);
else
line[used++] = PROG_ASCII_EMPTY;
}
}
line[used] = '\0';
snprintf(line + used, linesz - used, "%s", suffix);
}
static void draw_line(progress *p, const char *line)
{
/* attenzione: i glifi della barra sono multi-byte in UTF-8, quindi il
* conteggio per il ridisegno e la pulizia va fatto in COLONNE (code point),
* non in byte, altrimenti la riga supera la larghezza del terminale. */
size_t cols = utf8_count(line);
if (getenv("CELLAR_PROGRESS_DEBUG"))
fprintf(stderr, "[debug progress] line bytes=%zu cols=%zu\n", strlen(line), cols);
fputs("\r", stdout);
fputs(line, stdout);
for (size_t i = cols; i < (size_t)p->last_cols; i++)
fputc(' ', stdout);
p->last_cols = (int)cols;
fflush(stdout);
}
void progress_update(progress *p, long long done)
{
if (p->mode == PROG_OFF || p->finished)
return;
p->done = done;
double now = now_monotonic();
if (now - p->t_last_sample >= PROG_SAMPLE_INTERVAL) {
double dt = now - p->t_last_sample;
long long delta = done - p->last_sample_done;
if (dt > 0 && delta >= 0) {
double inst = (double)delta / dt;
p->rate = (p->rate > 0.0) ? (0.7 * p->rate + 0.3 * inst) : inst;
}
p->last_sample_done = done;
p->t_last_sample = now;
}
if (p->mode == PROG_PLAIN) {
int percent = (p->total > 0) ? (int)((done * 100) / p->total) : 0;
if (p->total > 0) {
if (percent >= p->plain_step) {
char line[512];
build_line(p, line, sizeof(line), 0);
printf("%s\n", line);
p->plain_step += 10;
}
} else if (now - p->t_last_draw >= 3.0) {
char line[512];
build_line(p, line, sizeof(line), 0);
printf("%s\n", line);
p->t_last_draw = now;
}
return;
}
/* PROG_BAR */
int percent = (p->total > 0) ? (int)((done * 100) / p->total) : -1;
if (p->t_last_draw > 0.0 && now - p->t_last_draw < PROG_DRAW_INTERVAL)
return;
if (percent == p->last_percent && p->total > 0 && done != p->total)
return;
p->last_percent = percent;
p->t_last_draw = now;
char line[512];
build_line(p, line, sizeof(line), 0);
draw_line(p, line);
}
void progress_finish(progress *p)
{
if (p->mode == PROG_OFF || p->finished)
return;
p->finished = 1;
if (p->total > 0)
p->done = p->total;
double elapsed = now_monotonic() - p->t_start;
if (elapsed <= 0.0)
elapsed = 0.001;
double avg = (double)p->done / elapsed;
char done_s[32], speed_s[32];
fmt_size(p->done, done_s, sizeof(done_s));
fmt_speed(avg, speed_s, sizeof(speed_s));
if (p->mode == PROG_PLAIN) {
printf("%s: completato (%s in %.1f s, %s)\n", p->label, done_s, elapsed, speed_s);
return;
}
char line[512];
build_line(p, line, sizeof(line), 1);
draw_line(p, line);
printf("\n%s: %s in %.1f s (%s)\n", p->label, done_s, elapsed, speed_s);
}
void progress_abort(progress *p)
{
if (p->mode == PROG_OFF || p->finished)
return;
p->finished = 1;
if (p->mode == PROG_BAR && p->last_cols > 0)
printf("\n");
}
+52
View File
@@ -0,0 +1,52 @@
/* progress.h — barra di avanzamento per download ed estrazione.
*
* Attiva solo quando ha senso: con CELLAR_PROGRESS=auto (default) disegna la
* barra se stdout e' un terminale, e non stampa nulla se l'output e' rediretto
* o in pipeline (cosi' l'output resta identico al client Python e i log
* restano puliti). Modalita' forzabili: bar, plain, off.
*
* Nessun codice ANSI: solo '\r' e riempimento con spazi, quindi funziona anche
* su console vecchie/seriali. Glyph ASCII di default, Unicode solo se la
* locale e' UTF-8.
*/
#ifndef CELLAR_PROGRESS_H
#define CELLAR_PROGRESS_H
#include "common.h"
#include <stddef.h>
typedef enum { PROG_AUTO = 0, PROG_BAR, PROG_PLAIN, PROG_OFF } prog_mode;
typedef struct {
prog_mode mode;
const char *label;
long long total; /* -1 se sconosciuto */
long long done;
long long members; /* informazione opzionale (estrazione) */
double t_start;
double t_last_draw;
double t_last_sample;
double rate; /* byte/s, media mobile */
long long last_sample_done;
int last_percent;
int last_cols; /* larghezza (colonne) dell'ultima riga disegnata, per ripulire */
int plain_step; /* prossima soglia in modalita' plain */
int width;
int unicode;
int finished;
} progress;
/* Modalita' da CELLAR_PROGRESS (auto|bar|plain|off); default auto. */
prog_mode progress_mode_from_env(void);
/* Inizializza: in modalita' AUTO decide in base a isatty(stdout). */
void progress_init(progress *p, const char *label, long long total);
void progress_set_members(progress *p, long long members);
/* Aggiorna con i byte completati (throttled: non ridisegna piu' di ~8 volte/s). */
void progress_update(progress *p, long long done);
/* Chiude la riga (newline) e stampa il riepilogo finale. */
void progress_finish(progress *p);
/* Percorso d'errore: chiude solo la riga, senza riepilogo. */
void progress_abort(progress *p);
#endif /* CELLAR_PROGRESS_H */
+22 -1
View File
@@ -374,6 +374,9 @@ int tar_create(const char *archive_path, const char *rootpath, const char *arcna
typedef struct { typedef struct {
gz_reader *gz; gz_reader *gz;
long long remaining; /* byte non ancora letti del membro corrente */ long long remaining; /* byte non ancora letti del membro corrente */
tar_progress_fn cb; /* opzionale: avanzamento */
void *cb_ctx;
long long members;
} tr; } tr;
static int tr_read_exact(tr *t, void *buf, size_t len, cerror *e) static int tr_read_exact(tr *t, void *buf, size_t len, cerror *e)
@@ -389,6 +392,14 @@ static int tr_read_exact(tr *t, void *buf, size_t len, cerror *e)
return -1; return -1;
} }
off += (size_t)n; off += (size_t)n;
if (t->cb) {
long long in_bytes = 0;
gzr_progress(t->gz, &in_bytes, NULL);
if (t->cb(t->cb_ctx, in_bytes, t->members) != 0) {
err_set(e, ERR_MISC, 0, "operazione annullata");
return -1;
}
}
} }
return 0; return 0;
} }
@@ -542,7 +553,7 @@ static int apply_times(const char *path, time_t sec, long nsec, int symlink_ok)
return utimensat(AT_FDCWD, path, times, 0); return utimensat(AT_FDCWD, path, times, 0);
} }
int tar_extract(const char *archive_path, const char *destdir, cerror *e) int tar_extract_cb(const char *archive_path, const char *destdir, tar_progress_fn cb, void *ctx, cerror *e)
{ {
gz_reader *gz = gzr_open(archive_path, e); gz_reader *gz = gzr_open(archive_path, e);
if (!gz) if (!gz)
@@ -550,6 +561,9 @@ int tar_extract(const char *archive_path, const char *destdir, cerror *e)
tr t; tr t;
t.gz = gz; t.gz = gz;
t.remaining = 0; t.remaining = 0;
t.cb = cb;
t.cb_ctx = ctx;
t.members = 0;
dirfixup *dirs = NULL; dirfixup *dirs = NULL;
size_t ndirs = 0, cdirs = 0; size_t ndirs = 0, cdirs = 0;
@@ -854,6 +868,8 @@ int tar_extract(const char *archive_path, const char *destdir, cerror *e)
} }
} }
t.members++;
/* gli override valgono per un solo membro */ /* gli override valgono per un solo membro */
pax_free(&next); pax_free(&next);
free(gnu_longname); free(gnu_longname);
@@ -879,3 +895,8 @@ int tar_extract(const char *archive_path, const char *destdir, cerror *e)
gzr_close(gz); gzr_close(gz);
return rc; return rc;
} }
int tar_extract(const char *archive_path, const char *destdir, cerror *e)
{
return tar_extract_cb(archive_path, destdir, NULL, NULL, e);
}
+5
View File
@@ -16,4 +16,9 @@ int tar_create(const char *archive_path, const char *rootpath, const char *arcna
* (symlink/hardlink preservati, permessi e mtime ripristinati). */ * (symlink/hardlink preservati, permessi e mtime ripristinati). */
int tar_extract(const char *archive_path, const char *destdir, cerror *e); int tar_extract(const char *archive_path, const char *destdir, cerror *e);
/* Come tar_extract, ma notifica l'avanzamento: byte compressi consumati e
* membri gia' processati. Il callback puo' ritornare -1 per interrompere. */
typedef int (*tar_progress_fn)(void *ctx, long long consumed, long long members);
int tar_extract_cb(const char *archive_path, const char *destdir, tar_progress_fn cb, void *ctx, cerror *e);
#endif /* CELLAR_TAR_H */ #endif /* CELLAR_TAR_H */
+32 -4
View File
@@ -19,6 +19,7 @@ import json
import os import os
import re import re
import threading import threading
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@@ -75,8 +76,14 @@ def parse_multipart(body: bytes, content_type: str):
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
state: State state: State
throttle = 0 # byte/s, 0 = nessun limite (per testare la barra di avanzamento)
protocol_version = "HTTP/1.1" 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 def log_message(self, *args): # silenzioso
pass pass
@@ -90,7 +97,20 @@ class Handler(BaseHTTPRequestHandler):
def _body(self) -> bytes: def _body(self) -> bytes:
length = int(self.headers.get("Content-Length") or 0) 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): def do_GET(self):
path = self.path.split("?")[0] path = self.path.split("?")[0]
@@ -113,13 +133,19 @@ class Handler(BaseHTTPRequestHandler):
f = self.state.storage / r["stored_name"] f = self.state.storage / r["stored_name"]
if not f.exists(): if not f.exists():
return self._json(404, {"detail": "Stored file not found."}) return self._json(404, {"detail": "Stored file not found."})
data = f.read_bytes()
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", r.get("content_type") or "application/octet-stream") 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-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() 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."}) return self._json(404, {"detail": "Archive not found."})
if path == "/openapi.json": if path == "/openapi.json":
return self._json(200, {"info": {"title": "Bottle Archive Server", "version": "0.1.0"}}) return self._json(200, {"info": {"title": "Bottle Archive Server", "version": "0.1.0"}})
@@ -181,8 +207,10 @@ def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=18099) ap.add_argument("--port", type=int, default=18099)
ap.add_argument("--state", required=True) 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() args = ap.parse_args()
Handler.state = State(Path(args.state)) Handler.state = State(Path(args.state))
Handler.throttle = args.throttle
srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
srv.serve_forever() srv.serve_forever()
+165
View File
@@ -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
@@ -0,0 +1 @@
/nonexistent/target
@@ -0,0 +1 @@
config
@@ -0,0 +1 @@
config.ini
@@ -0,0 +1 @@
unicode
+1
View File
@@ -0,0 +1 @@
binario
@@ -0,0 +1 @@
deep
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -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
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
View File
@@ -0,0 +1 @@
binario
@@ -0,0 +1 @@
deep
+18
View File
@@ -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"
}
]