genro-asgi · 0.33.0 · Beta
Anatomia di genro-asgi: tredici blocchi logici, i loro sotto-blocchi, e il grado di completezza di ogni funzionalità progettata — misurato sul codice, non dichiarato.
genro-asgi non è un wrapper su Starlette: è un server ASGI scritto da zero a partire da una specifica ratificata — SPECIFICATION.md, 858 righe di decision log con 28 decisioni numerate. Questo documento ne mappa la struttura in blocchi navigabili e misura quanto di ciò che è stato progettato esiste davvero.
Ogni blocco si apre. Il primo livello dà il perimetro e il peso; dentro trovi i moduli reali con la loro copertura di test, così che «completo» sia un numero e non un'opinione.
Una sola regola di smistamento governa ogni server: primo segmento del path → l'applicazione montata lì; altrimenti quella sulla radice del sito; altrimenti, per / con un default dichiarato, un 307; altrimenti 404. Un server mono-applicazione è un uso di questa regola, non un meccanismo diverso.
Il middleware è ordinato da un numero, non da un tratto di classe: più piccolo è più esterno. Solo errors è attivo di default; session e auth vengono armati dai rispettivi mixin quando li configuri.
Apri un blocco per scendere ai sotto-blocchi: moduli reali, con le loro istruzioni misurate e la copertura dei test. La percentuale è quella di pytest --cov, eseguito sul codice di questa versione.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| server.py | BaseServer: le app servite, il dispatch ASGI, l'avvio uvicorn, il socket websocket vuoto | 118 | 99% |
| asgi_server.py | AsgiServer: la composizione spedita, tutti i mixin di capacità in un solo MRO | 93 | 97% |
| request_registry.py | la richiesta corrente (un solo ContextVar, posseduto dall'istanza) e il quadro delle richieste in volo | 65 | 98% |
| pool.py | l'unico thread pool per il lavoro bloccante, provvisto pigramente sul loop vivo | 41 | 100% |
| lifespan.py | avvio ordinato, spegnimento inverso, isolamento degli errori | 35 | 100% |
| types.py | alias di tipo ASGI | 8 | 100% |
Decisioni di riferimento: D2 (cosa possiede il server base), D3 (una sola regola di demux), D5 (un solo registro delle richieste).
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| application.py | BaseApplication: code (identità), mount (prefisso URL), server assegnato una volta sola | 45 | 100% |
| routed_application.py | innesta genro-routes: handler @route risolti dal router dell'app | 94 | 96% |
| applications/openapi.py | OpenApiApplication: REST + schema OpenAPI + pagine di documentazione | 71 | 100% |
| applications/mcp.py | McpApplication e McpOpenApiApplication: trasporto Streamable HTTP stateless | 175 | 96% |
| applications/server_app.py | ServerApplication: l'app _server automatica, mai configurata | 112 | 100% |
| applications/spa_app.py | SpaApplication: il front montabile che possiede un pool user-sticky, demux a due stadi | 96 | 100% |
Una sola categoria di applicazione, non due: lo stesso albero di rotte serve REST e MCP insieme. mount="" è la radice del sito — un valore come un altro, non un'assenza.
| Sezione | Superficie | Stmt | Cov |
|---|---|---|---|
| auth_section.py | il contenitore /_server/auth: monta solo i metodi che possiedono rotte proprie | 24 | 100% |
| users_section.py | gestione utenti, cancello SUPERADMIN | 87 | 100% |
| tokens_section.py | credenziali emesse e revocabili | 64 | 100% |
| tasks_section.py | endpoint della spina dorsale dei task | 139 | 100% |
Invariante 10 applicata nel codice: un metodo di login senza rotte vive nel registro ordinato, non nell'albero di routing. Il routing non è mai usato come registro.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| request.py | una classe piatta sullo scope ASGI, parsing del corpo eager | 138 | 96% |
| response.py | una classe piatta, bufferizzata, consapevole di TYTX | 111 | 99% |
| sse.py | framing Server-Sent Events sopra StreamingResponse | 52 | 100% |
| streaming.py | risposta a chunk, sorella ASGI di Response | 34 | 97% |
| exceptions.py | eccezioni HTTP sollevabili ovunque, risposte dal middleware errors | 24 | 100% |
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| errors.py | il try/except più esterno e la giuntura del login | 79 | 99% |
| cors.py | Cross-Origin Resource Sharing | 73 | 96% |
| base.py | classe base e meccanismo della catena | 66 | 98% |
| session.py | ciclo di vita della sessione guidato dal cookie | 44 | 100% |
| logging.py | log di accesso HTTP | 41 | 85% |
| wellknown.py | filtro dei path di probe e well-known | 17 | 100% |
| authentication.py | pubblica l'identità della richiesta sullo scope | 10 | 100% |
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| auth/oidc_method.py | authorization-code + PKCE S256, discovery pigra e cachata, un'istanza per provider | 108 | 97% |
| auth/core.py | configurazione credenziali e verifica degli header (basic, bearer, JWT) | 96 | 95% |
| auth/api_key_store.py | registro delle credenziali bearer revocabili: contratto + backend su file | 93 | 95% |
| auth/user_store.py | sorgente locale di identità: contratto + backend su file, contatore di lockout | 81 | 94% |
| auth/mixin.py | risoluzione dell'identità da header o sessione, come capacità | 60 | 100% |
| auth/auth_method.py | metodi di login auto-descrittivi montati sotto _server/auth | 31 | 100% |
Il lockout (D28) vive sul record utente — failed_attempts, last_failed_at — quindi sopravvive ai riavvii ed è condiviso tra processi. I tentativi rifiutati durante la finestra non incrementano il contatore: un attaccante non può prolungare il blocco di un utente legittimo.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| session/store.py | il Protocol dello store e il default in memoria | 77 | 100% |
| session/session.py | id, meta, dati come Bag, collezione di Avatar per chiave | 51 | 100% |
| session/mixin.py | le sessioni HTTP come capacità sul server base | 39 | 100% |
| session/avatar.py | l'unico tipo di identità autenticata del pacchetto | 18 | 94% |
D24: attach_avatar muta la sessione esistente — quello che un visitatore anonimo ha accumulato sopravvive al login, e il cookie che il client già possiede resta valido. Nessun cookie viene emesso al login.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| config/handler.py | la porta di lettura: server.config("server.host") su valore scritto → default della firma → default della chiamata → KeyError rumoroso | 124 | 100% |
| config/elements.py | AsgiServerGrammar: la grammatica delle sezioni | 52 | 100% |
| config/default_config.py | il livello di default che una ricetta dichiara per sé | 48 | 98% |
| config/builder.py | il dialetto asgiconfig su genro-builders contrib/config | 26 | 100% |
I valori che vengono da fuori sono resolver al loro posto: metti un EnvResolver dove andrebbe il valore e si risolve alla lettura. Il vecchio modello a puntatori risolveva una volta sola alla materializzazione e perdeva interi livelli in silenzio.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| tasks/spool.py | lo spool su file, modello a spostamento di cartella, atomico su un mount | 148 | 100% |
| tasks/scheduler.py | il ciclo che esegue le pianificazioni scadute | 140 | 91% |
| tasks/schedule.py | parsing delle pianificazioni e calcolo della prossima esecuzione, tre tipi di task | 99 | 98% |
| tasks/store.py | la metà persistente della spina dorsale (nessuno SQLite) | 94 | 94% |
| tasks/manager.py | spool + executor + hub, posseduti dal server | 67 | 97% |
| tasks/mixin.py | la spina dorsale come capacità, aperta pigramente sullo storage | 49 | 100% |
| tasks/executor.py | esegue un task dello spool in-process sul server vivo | 42 | 100% |
| tasks/hub.py | fan-out degli eventi per sessione, in memoria | 25 | 100% |
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| channel/hub.py | il lato padre, con buste tipizzate (CALL, REPLY, EVENT) | 202 | 93% |
| channel/local.py | il filo in-process: una coppia di code al posto del socket, stessa codifica | 129 | 92% |
| channel/client.py | il lato figlio — sapere essere figlio fa parte di ciò che un server è | 116 | 91% |
| channel/frame.py | il protocollo di frame: il prefisso di lunghezza sostituisce il framing websocket | 71 | 94% |
Il ruolo «single» è configurazione, non una sottoclasse: local_worker=True costruisce un worker in questo stesso processo e lo attacca all'hub tramite LocalChannel — stesso REGISTER, stessa codifica su ogni frame. Se il protocollo funziona nel ruolo singolo, passare a multi cambia solo il filo.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| spa/commander.py | il pool di worker e il quadro di routing sopra di esso: otto registri piatti, supervisione, sonda, battito del pool | 1209 | 95% |
| spa/worker.py | il runtime di esecuzione: registri, quattro famiglie di op, outbox, due vie di segnale | 782 | 95% |
| spa/register_registry.py | il registro dei registri, vocabolario di ciclo di vita utenti/pagine | 146 | 100% |
| spa/evaluator.py | trasforma la finestra di metriche di un worker in un'occupazione in [0, 1] | 124 | 100% |
| spa/global_store.py | un Bag master sopra, una replica per worker sotto | 75 | 100% |
| spa/worker_entry.py | il punto d'ingresso del processo figlio: un canale, nient'altro | 69 | 72% |
| spa/register.py | un dataset in-process con indici secondari | 66 | 100% |
| spa/environ.py | il sintetizzatore di environ: fatti HTTP dentro, risposta WSGI fuori | 60 | 100% |
| spa/subscription_index.py | sottoscrizioni a tabelle, entrambe le direzioni, un solo mutatore per lato | 44 | 100% |
Chiavi e posizioni sopra, contenuti sotto: i registri di superficie del commander sono dizionari piatti, deliberatamente non la macchina Register del worker. Vedi la sezione dedicata più sotto.
| Modulo | Ruolo | Stmt | Cov |
|---|---|---|---|
| plugins/openapi/translator.py | traduce l'output di nodes() di genro-routes in OpenAPI | 157 | 86% |
| plugin_mixin.py | i plugin del router come capacità del server | 46 | 100% |
| storage_mixin.py | genro-storage come capacità | 29 | 100% |
| plugins/openapi/plugin.py | configurazione OpenAPI per singolo handler | 26 | 77% |
| db.py | il contratto minimo per un database montato | 17 | 100% |
config.py più genro-asgi serve sono un'unità di deployment completa.
| Comando | Comportamento |
|---|---|
| serve <sorgente> | risolve in tre passi: assegnazione application=<target>, percorso .py esistente, altrimenti nome registrato. Opzioni --host, --port, --reload, --name |
| apps | elenca i server registrati e il loro stato reale |
| stop <nome> | ferma un server avviato per nome |
| remove <nome> | rimuove la registrazione |
Il registro è un archivio di puntatori, mai una copia: ~/.genroasgi/apps/<nome>.json tiene la stringa sorgente, quindi rilanciare per nome esegue sempre il codice corrente. Un pidfile non è mai creduto — mancante, illeggibile o riferito a un processo morto si leggono tutti come «non in esecuzione», così un server crashato risulta fermo e non fantasma.
È il blocco che non ha controparte in Starlette o FastAPI, e da solo vale 2.580 istruzioni — un quarto abbondante del progetto. Serve una cosa sola: tenere viva la sessione applicativa di un utente attraverso più processi, con l'utente come chiave di partizione del carico e la pagina come unità di stato vivo.
Il commander non apre mai il contenuto che instrada: un datachange viaggia codificato TYTX e l'indirizzo è tutto ciò che legge. Un indirizzo che non sa risolvere viene scartato con un log — non esiste coda di ritenta.
page_id è il ciclo richiesta/risposta di quella pagina, e la REPLY vi innesta ciò che era in attesa. Per una pagina che non chiama, il cambiamento resta nel suo collettore.La scala non è un giudizio: è derivata da tre fatti verificabili — se esiste il codice, se esistono test dedicati che lo esercitano, e se la specifica dichiara ancora punti aperti su quella feature.
| Feature | Stato | Evidenza |
|---|---|---|
| Server base e regola unica di demux | Consolidato | D2/D3 · test_demux.py (12), test_contract.py (21) · 99% |
| Contratto lato applicazione | Consolidato | D7 · app di prova dedicata (throwaway_app.py) · 100% |
| Capacità come mixin cooperativi | Consolidato | D16/D17 · sette mixin in un MRO · 97% |
| Thread pool e affinità al loop | Consolidato | invariante 1 · test_pool.py (12) · 100% |
| Registro delle richieste | Consolidato | D5 · test_request_registry.py (11) · 98% |
| Configurazione: ricetta, porta di lettura, resolver | Consolidato | D-config · test_config.py (72) + test_config_env.py (16) · 100% |
| Catena middleware | Consolidato | test_middleware.py + test_middleware_std.py (39) · 96% |
| Sessioni HTTP e continuum del login | Consolidato | D24 · test_session.py (50), test_login_flow.py (28) · 99% |
| Login password con lockout a backoff | Consolidato | D28 · contatore sul record utente · 94–95% |
| Login OIDC (authorization-code + PKCE) | Consolidato | D27 · test_oidc.py (27) · 97% |
| Credenziali su header: basic, bearer, JWT | Consolidato | invariante 5 · test_auth.py (46) · 95% |
| Archivi utenti e chiavi API | Consolidato | contratto + backend su file · 42 test · 94–95% |
| App _server automatica e sue sezioni | Consolidato | D4/D26 · quattro sezioni · 100% |
| OpenAPI: schema, documentazione, plugin per handler | Consolidato | test_plugins.py (32), test_openapi_application.py (11) · 77–100% |
| MCP: engine JSON-RPC e trasporto Streamable HTTP | Consolidato | 68 test su tre file · 96–98% |
| Streaming e Server-Sent Events | Consolidato | test_sse.py (13), test_streaming.py (9) · 97–100% |
| Storage e handler di database | Consolidato | sezioni trasversali di D15 · 100% |
| Spool dei task: lo stato è la posizione | Consolidato | invariante 7 · test_task_spool.py (28) · 100% |
| Esecuzione task in-process e pianificazione | Consolidato | D22 · 5 file di test, 73 casi · 91–100% |
| Protocollo di canale e lato figlio | Consolidato | ◆D10 · test_channel*.py (58) · 91–94% |
| Hub del canale (lato padre) | Consolidato | test_channel_hub.py (22) · 93% |
| Canale locale in-process, identico al socket | Consolidato | ◆D13 · test_channel_local.py (12), test_spa_single.py (28) · 92% |
| Riga di comando e registro dei server | Consolidato | D-cli · test_cli.py (47) · 98% |
| Front SPA montabile con demux a due stadi | Consolidato | issue #3 · 47 test su tre file · 100% |
| Registri del worker e indice delle sottoscrizioni | Consolidato | test_register_registry.py (43), test_subscription_index.py (15) · 100% |
| Spostamento di un utente tra worker | Consolidato | test_spa_move.py — 119 casi, il file più esercitato del progetto · 95% |
| Store globale: un master, repliche per worker | Consolidato | lock FIFO tutto-o-niente · test_spa_global_store.py (17) · 100% |
| Datachange a tre livelli e fan-out dei dbevent | Consolidato | esclusione dell'origine · test_spa_dbevents.py (16), test_spa_collect.py (11) · 95% |
| Sensore di occupazione e valutatore | Consolidato | issue #4 · test_spa_evaluator.py (47) · 100% |
| Politiche del pool: piazzamento, crescita, compattazione, ribilanciamento | Consolidato | issue #5 · test_spa_commander.py (60) · 95% |
| Riciclo dei processi su tempo-al-limite | Consolidato | issue #8 · serie di pavimenti di memoria per worker · 95% |
| Seam WSGI per il sito ospitato | Consolidato | test_spa_http_form.py (20) · 100% |
| Dump e ripristino del registro al riavvio | Attivo con riserve | D21 livello 1 completo e coperto — il commander sfratta ogni utente, scrive un pickle solo, e al riavvio lo ripiazza rinominandolo _loaded perché un restart morto a metà non lo installi due volte (4 test dedicati, compresi evict fallito e pacchetto rifiutato). Il livello 2 — i registri di ogni livello e il ri-aggancio per page_id — non esiste ancora |
| Gruppi di worker | Solo progettato | D11/D12 · esiste solo l'etichetta: un kwarg group di default "default" ricopiato in ogni riga del roster (commander.py:445, 990) e riletto solo dal log di sepoltura e dal monitor. L'instradamento è cieco al gruppo — decide_worker sceglie per saturazione — e nessun test passa mai un valore diverso |
| Scadenze e sweep delle pagine | Attivo con riserve | implementato e testato, ma disarmato se non passi sweep_interval: finché il canale del browser non porta un segnale di presenza, una pagina silenziosa e una morta si somigliano |
| Ricarica selettiva in sviluppo | Attivo con riserve | la primitiva ritiro+respawn esiste e --reload copre il processo intero; il watcher per gruppo di ◆D14 no |
| Soglie di occupazione come configurazione | Attivo con riserve | costanti PROVISIONAL in evaluator.py:95 e worker.py:366, in attesa della configurazione per gruppo |
| WebSocket / protocollo WSX | Questione aperta | Q1 · server.py:253 accetta nulla e chiude con codice 1000: la presa esiste, il motore no |
| Manager di gruppo remoto (forma C) | Solo progettato | D11 · zero occorrenze di sotto-commander nel codice; il manager locale è l'unica implementazione |
| Gerarchia multi-macchina | Solo progettato | D11/D12 · le tre proiezioni di registro esistono su due livelli, non tre |
| Esecuzione distribuita dei batch | Solo progettato | D22 · il modello (spool, stati) è completo in-process; l'app commander dei batch e i worker dedicati no |
| Configurazione viva a due stadi | Solo progettato | D23 · parcheggiata esplicitamente come macro futura |
| Server interno come sottoclasse | Solo progettato | D26 · il flag di profilo è stato rimosso in favore di una sottoclasse che non ha ancora un consumatore |
| Limite di frequenza per IP | Solo progettato | D28 lo dichiara materia di un middleware futuro, fuori dal gestore di login |
| Spec del runtime di gruppo (venv, pyz, OCI) | Questione aperta | Q9 · rinviata alla discussione sul server completo |
| Modalità «pooled» per app senza stato | Questione aperta | Q7 · emersa dalla tabella di piazzamento, non ancora necessaria |
Le voci «solo progettato» hanno tutte la stessa forma: sono la fase 2+ della specifica — la topologia dei processi oltre la singola macchina. Il taglio è dichiarato in D22 e ha una prova pratica: ciò che deve sopravvivere alla morte di un worker vive nel nucleo (sessioni, login, stato dello spool), ciò che muore e viene ricostruito col suo processo vive nell'orchestrazione (pagine, pendenze, sottoscrizioni).
Due assenze meritano una nota, perché non sono rinvii ma dipendenze da qualcosa che ancora non esiste.
Il motore WebSocket. La presa è aperta e vuota fin dalla fase 0, per decisione: quando il motore arriverà dovrà essere un solo motore di dispatch con due trasporti (HTTP e WSX). Nel repository precedente i due erano divergenti perché nulla li obbligava a restare uguali — la lezione è registrata come invariante 9, «test di contratto su ogni interfaccia con più implementazioni».
Lo sweep delle scadenze disarmato. Non è codice incompleto: è codice che aspetta un'informazione. Senza un segnale di presenza sul canale del browser, una pagina silenziosa e una pagina morta sono indistinguibili dal server — e scaduta per errore, una pagina viva perde il suo stato. Finché il segnale non c'è, la scelta è non spazzare.
src/, separando codice, docstring, commenti e righe vuote. Il codice è 9.405 righe su 20.211 totali; le docstring sono 6.477, perché in questo progetto la docstring di modulo è la fonte di verità del design.pytest tests/ --cov=genro_asgi eseguito su questa versione — 1.567 test passati, 2 saltati (richiedono glibc, la macchina è macOS), 7.474 istruzioni con 275 non coperte.SPECIFICATION.md (28 decisioni ratificate, 9 questioni aperte), le 12 issue chiuse su GitHub e la presenza effettiva del codice, verificata per simbolo.Due discrepanze rilevate durante la verifica, entrambe cosmetiche: __version__ in src/genro_asgi/__init__.py è fermo a 0.23.0 mentre pyproject.toml dichiara 0.33.0; e SPECIFICATION.md, insieme alle pagine di architettura e concetti, porta ancora il marcatore DA REVISIONARE.