In questo post vedremo come configurare moduli e strumenti per lanciare, documentare e testare una codebase.
Sources: https://pxhere.com/en/photo/1188160, https://www.publicdomainpictures.net/en/view-image.php?image=424040&picture=rapid-test-corona-test
Nel nostro post precedente abbiamo visto come progettare un’API in Python usando il framework FastAPI. Oggi vedremo come lanciare un server Python, e documentare e testare una codebase.
Esecuzione e Gestione delle Dipendenze
L’entrypoint di sem
è nel modulo modules/main.py
:
# modules/main.py
26 import uvicorn
27
28
29 __version__ = "1.0.0"
30
31
32 def main():
33 "Execute main entrypoint."
34 uvicorn.run("modules.api:app", host="0.0.0.0", reload=True)
35
36
37 if __name__ == "__main__":
38 main()
Il modulo uvicorn
viene usato per avviare applicazioni che restano in attesa di richieste HTTP (come la mia app
FastAPI, specificata come argomento).
- L’applicazione utilizzerà l’indirizzo specificato come host (sulla porta di default, 8000, poiché non ne abbiamo fornita una come argomento).
- L’indirizzo
0.0.0.0
viene utilizzato per futura compatibilità con la containerizzazione (il valore di default sarebbe127.0.0.1
olocalhost
, che funzionano anche con questa scelta perhost
). - Impostando
reload
aTrue
il server verrà rilanciato se uno dei file sorgente viene modificato.
Adesso il server può essere lanciato con il comando
$ python -m modules.main
nella riga di comando del sistema; molto probabilmente, però, questo risulterà in un fallimento, a meno che tutti i pacchetti necessari non siano installati sul sistema.
Per assicurarsi che tutte le dipendenze siano installate, sem
è preconfigurato con
, un gestore di pacchetti Python. Nello specifico, il repository contiene un file poetry
pyproject.toml
, che elenca le dipendenze richieste dal progetto e altre informazioni rilevanti.
# pyproject.toml
1 [tool.poetry]
2 name = "sem"
3 version = "0.1.0"
4 description = "Simple expense manager"
5 authors = ["aangelone2 <adriano.angelone.work@gmail.com>"]
6 license = "GPL v3"
7 readme = "README.md"
8 packages = [{include = "modules"}]
9
10 [tool.poetry.dependencies]
11 python = "^3.11"
12 SQLAlchemy = "^2.0.20"
13 SQLAlchemy-Utils = "^0.41.1"
14 psycopg = "^3.1.14"
15 fastapi = "^0.104.1"
16 uvicorn = "^0.24.0"
17 rich = "^13.7.0"
18
19 [tool.poetry.group.dev.dependencies]
20 pytest = "^7.4.3"
21 httpx = "^0.25.1"
22
23 [tool.poetry.group.docs.dependencies]
24 mkdocs = "^1.5.3"
25 mkdocstrings = {extras = ["python"], version = "^0.24"}
26 mkdocs-material = "^9.4.11"
27
28 [tool.poetry.scripts]
29 sem = "modules.main:main"
30
31 [build-system]
32 requires = ["poetry-core"]
33 build-backend = "poetry.core.masonry.api"
Dopo alcune informazioni sul progetto, le dipendenze vengono elencate in gruppi (alcune sono necessarie per il progetto stesso, alcune per lo sviluppo, altre per la documentazione). Di default, poetry
installerà tutti i gruppi, se non diversamente indicato.
Il gruppo principale tool.poetry.dependencies
contiene il pacchetto python
: poetry
configurerà un ambiente virtuale con questa versione di python
come interprete, dove verranno installati tutti i pacchetti e verrà lanciato il programma (per evitare conflitti con pacchetti di sistema). La sezione tool.poetry.scripts
consente di definire degli entrypoint (con la sintassi modulo:funzione
) che possono essere lanciati con la sintassi
$ poetry run <entrypoint>
Il mettere ^
all’inizio di un numero di versione implica che dovrebbero essere installate versioni recenti almeno quanto quella specificata, dove il numero di versione maggiore è lo stesso (ad esempio, ^1.0
installerà 1.2
se disponibile, ma non 2.0
).
Quando il repository viene clonato, è necessario eseguire (come menzionato nel README) il comando
$ poetry install
Questo installerà le versioni più recenti dei pacchetti specificati che non sono in conflitto con i requisiti degli altri, rispettando i vincoli impostati in pyproject.toml
, e predisporrà gli entrypoint di poetry.
Quando poetry install
viene lanciato per la prima volta (e ogni volta che l’ambiente viene modificato, a causa di nuove versioni dei pacchetti) poetry
genera un file poetry.lock
, che memorizza le versioni effettivamente installate dei pacchetti. Questo di solito è incluso nel repository (come in sem
) e consente un processo di installazione molto più veloce, poiché poetry
lo utilizzerà nelle successive chiamate a poetry install
.
Una volta completata la configurazione di poetry
, il server può essere lanciato nell’ambiente creato da poetry
:
$ poetry run sem
Se lanciato da una directory diversa dalla root del repository, bisognerà lanciare il comando
$ poetry --directory <repository root> run sem
Vedremo in seguito come configurare strumenti di automazione (in pratica, un makefile
) per semplificare l’avvio del server.
Una volta lanciato in questo modo, il server continuerà a girare finché arrestato manualmente, ricevendo e rispondendo a richieste HTTP. Accedere, ad esempio, a http://localhost:8000/query/
su uno web browser eseguirà una di queste richieste, che riceverà in risposta una lista di tutte le spese nel database.
Documentazione
Il nostro progetto offrirà due set di documentazione: uno per i moduli non API (usando le docstring aggiunte nei vari moduli) e uno per l’API (usando le descrizioni fornite nei decoratori FastAPI). La separazione tra questi due può sembrare poco intuitiva, ma alla fine non è troppo scomoda, poiché la documentazione interna è indirizzata a un pubblico diverso da quella dell’API (sviluppatori con focus verso server e client, rispettivamente).
Questi set di documentazione saranno offerti da server di documentazione, servizi a cui un client (come un browser web) può connettersi tramite richieste HTTP, ricevendo in cambio la documentazione da visualizzare.
Documentazione degli Internals
sem
usa il modulo mkdocs
per generare documentazione per gli internals del codice. Questa utility estrae la sua configurazione dal file mkdocs.yml
nella directory root del progetto, che in sem
ha la forma
# mkdocs.yml
1 site_name: sem
2
3 dev_addr: "0.0.0.0:8001"
4
5 theme:
6 name: "material"
7
8 plugins:
9 - mkdocstrings
10
11 nav:
12 - Homepage: index.md
13 - Reference:
14 - models: reference/models.md
15 - schemas: reference/schemas.md
16 - crud_handler: reference/crud_handler.md
17 - session: reference/session.md
dev_addr
imposta l’URL al quale sarà disponibile la documentazione. Il valore di default è127.0.0.1
(localhost
) sulla porta 8000, che ho cambiato in 8001 per poter accedere alla documentazione mentre si utilizza il server (che sarebbe sulla stessa porta).theme
imposta l’aspetto grafico delle pagine della documentazione. Qui abbiamo scelto il temamkdocs-material
, che abbiamo specificato come dipendenza nel filepyproject.toml
.mkdocstrings
è un plugin permkdocs
, che consente di generare automaticamente pagine di documentazione da docstring di moduli, classi e funzioni.nav
è un elenco di pagine di documentazione (scritto in Markdown). La struttura di questo elenco (incluse le directory in cui sono organizzate le pagine) rispecchierà l’organizzazione del sito web della documentazione. Qui impostiamo una pagina principale e una pagina di riferimento per ciascun modulo nella directoryreference/
(quest’ultima sarà generata a partire dalle docstring).
La pagina principale solitamente contiene informazioni generali sul progetto ed eventualmente collegamenti alle altre sezioni della documentazione. Nel nostro caso, si tratta semplicemente di una copia del file README, archiviato in docs/index.md
. La pagina risultante dovrebbe apparire in questo modo:
Il menu sul lato sinistro dà accesso alla pagina principale e alle pagine dei moduli, dove funzioni e classi mostrano le loro descrizioni. Queste sono generati dai file di documentazione di ciascun modulo, che ha la forma (qui, per il modulo crud_handler.py
)
# docs/reference/crud_handler.md
::: modules.crud_handler
options:
docstring_style: numpy
che ordina al plugin mkdocstrings
di usare le docstring nel file specificato (ho usato lo stile numpy per le docstrings, che non è l’impostazione predefinita). Le pagine di documentazione risultanti dovrebbero avere la forma
Una volta completata questa configurazione, la documentazione viene creata e servita utilizzando i comandi
$ poetry run mkdocs build
$ poetry run mkdocs serve
e può essere visualizzata all’indirizzo http://localhost:8001
.
Documentazione per l’API
Il servizio di documentazione API, integrato in FastAPI, usa le docstring solo per i modelli pydantic presenti nelle path functions (da cui la loro assenza in modules/api.py
). Raccoglie invece informazioni sui modelli pydantic e sulle funzioni API tramite le descrizioni negli specificatori Field()
e nei decoratori FastAPI.
Questa documentazione è accessibile quando il server è in esecuzione all’indirizzo <URL>/docs
o <URL>/redoc
, dove <URL>
è l’URL del server (qui, http://localhost:8000
). La pagina esposta visualizza un elenco degli endpoint raggruppati per metodo e URL (di seguito è riportato uno screenshot di esempio della documentazione esposta a /docs/
per l’endpoint /add
).
La documentazione mostrerà i parametri consentiti, esempi di tipo di risposta e altre informazioni, come qui sotto per l’endpoint /add
.
Testing
Concetti e Strumenti Generali
Quando si scrivono applicazioni, è essenziale scrivere una qualche forma di test, che garantisca in modo automatico (o almeno semiautomatico) che il codice si comporti come previsto, coprendo un numero sufficientemente ampio di situazioni e casi limite.
Esistono diversi protocolli per testare un programma: qui eseguiremo principalmente unit test (che testano la funzionalità di singole parti del programma, come le funzioni) e test di integrazione (che testano l’interazione fra i componenti del programma).
Utilizzeremo il modulo pytest
per eseguire test automatici, dato che gli strumenti che offre semplificano notevolmente la gestione dei test. Questo framework cerca file Python il cui nome inizia con test_
; questi dovrebbero contenere funzioni il cui nome inizia con test_
, che verranno eseguite e che conterranno controlli sulla funzionalità del programma. pytest
riporterà informazioni dettagliate sull’esito di questi controlli, consentendo di diagnosticare eventuali problemi qualora si presentassero.
Il primo componente della suite di test è il file tests/common.py
, che contiene strumenti generali utilizzate da tutti i test. TEST_DB_NAME
sarà il nome di un database separato per i dati di test (per evitare di inquinare il database principale del programma), mentre expenses
è una tupla di oggetti ExpenseRead
che verranno utilizzati per riempire il database di test con dati di esempio.
# tests/common.py
9 from contextlib import contextmanager
10
11 from modules.schemas import ExpenseAdd
12 from modules.schemas import ExpenseRead
13 from modules.crud_handler import CRUDHandler
14
15
16 TEST_DB_NAME = "sem-test"
17
18
19 # Example expenses for testing
20 expenses = (
21 ExpenseRead(
22 id=1,
23 date="2023-12-31",
24 type="R",
25 category="gen",
26 amount=-12.0,
27 description="test-1",
28 ),
29 ExpenseRead(
30 id=2,
31 date="2023-12-15",
32 type="C",
33 category="test",
34 amount=-13.0,
35 description="test-2",
36 ),
...
61 )
Per eseguire test realistici del service layer, è utile riempire il database di test con alcuni dati di esempio, che verranno estratti, aggiornati e così via. Per semplificare il test, è opportuno svuotare il database di test prima che il test venga eseguito e dopo averlo completato, per avere un ambiente di test controllato. Questo potrebbe essere fatto manualmente, ma è molto più semplice definire un context manager di test per automatizzare la pulizia.
# tests/common.py
80 @contextmanager
81 def CRUDHandlerTestContext() -> CRUDHandler:
82 """Manage testing context for CRUDHandler.
83
84 Inits and inserts example data, removing all data
from table at closure.
85
86 Yields
87 -----------------------
88 CRUDHandler
89 The context-managed and populated CRUDHandler.
90 """
91 # Easier to define a new context manager,
92 # should call erase() in __enter__() and __exit__().
93 ch = CRUDHandler(TEST_DB_NAME)
94 ch.erase()
95
96 for exp in expenses:
97 # ID field ignored by pydantic constructor
98 ch.add(ExpenseAdd(**exp.model_dump()))
99
100 try:
101 yield ch
102 finally:
103 ch.erase()
104 ch.close()
Questo è molto simile a CRUDHandlerContext()
, con la differenza che 1) i dati vengono inseriti nel database di test prima della chiamata a yield
, in un database di test svuotato, e 2) il database viene ripulito prima di chiudere il contesto.
In teoria, questo potrebbe essere sostituito tramite mocking del database, cioè sostituendo l’accesso al database con una funzione che restituisca dati, bypassando il database. Ciò consentirebbe uno unit-testing più rigoroso del service layer, poiché non avrebbe luogo alcuna interazione con il data layer. Tuttavia, ho ritenuto che le funzioni di test riportate di seguito, che sono essenzialmente test di integrazione di data e service layer, siano sufficienti per diagnosticare problemi anche nel solo service layer.
Test per CRUDHandler
I nostri primi test riguarderanno la classe CRUDHandler
, verificando che i suoi metodi funzionino come previsto in diversi casi. Questi vengono eseguiti nel file tests/test_crud_handler.py
: non mostreremo qui l’intero file, ma solo alcuni test che usano strumenti utili.
Il primo test mostrato qui testa il metodo query()
, eseguendo una ricerca senza filtri (abbiamo omesso per somiglianza i test seguenti, che applicano anche filtri e controllano che vengano restituite solo le spese corrispondenti).
# tests/test_crud_handler.py
6 from fastapi.encoders import jsonable_encoder
7
8 from pytest import raises
...
13 from modules.schemas import QueryParameters
14 from modules.crud_handler import CRUDHandlerError
15
16 from tests.common import expenses
...
18 from tests.common import CRUDHandlerTestContext
19
20
21 def test_global_query():
22 """Tests no-parameter query."""
23 with CRUDHandlerTestContext() as ch:
24 # retrieve all expenses
25 res = ch.query(QueryParameters())
26 expected = [
27 expenses[4],
28 expenses[3],
29 expenses[2],
30 expenses[1],
31 expenses[0],
32 ]
33
34 assert jsonable_encoder(res) == jsonable_encoder(expected)
...
La funzione di test viene eseguita all’interno del contesto CRUDHandlerTestContext()
e dovrebbe restituire i dati inseriti. L’output diretto della query viene quindi confrontato con un elenco delle voci inserite, ordinate per data (per corrispondere all’output del metodo query()
).
Come in tutte le funzioni di test pytest
, una direttiva assert
consente di confrontare l’output previsto con quello effettivo. jsonable_encoder()
serializza le espressioni confrontate ed è necessario per evitare problemi di precisione quando si confrontano i campi a virgola mobile degli oggetti ottenuti con quelli previsti.
Un test più complesso, mostrato di seguito, è quello per la funzione remove()
. Vengono rimosse due spese esistenti e interrogato il database, per verificare che le spese rimosse siano effettivamente scomparse e che le altre siano rimaste intatte; quindi si tenta la cancellazione di una spesa inesistente.
352 def test_remove():
353 """Tests removal function."""
354 with CRUDHandlerTestContext() as ch:
355 # Selective removal
356 ch.remove([3, 1])
357
358 res = ch.query(QueryParameters())
359 expected = [
360 expenses[4],
361 expenses[3],
362 expenses[1],
363 ]
364
365 assert jsonable_encoder(res) == jsonable_encoder(expected)
366
367 # Nonexistent ID
368 with raises(CRUDHandlerError) as err:
369 res = ch.remove([19])
370 assert str(err.value) == "ID 19 not found"
371
372 # Checking that no changes are committed in case of error
373 res = ch.query(QueryParameters())
374 assert jsonable_encoder(res) == jsonable_encoder(expected)
...
Alla riga 369, il tentativo di rimozione di un QueryParameters
inesistente dovrebbe risultare in un’eccezione
. La funzione CRUDHandlerError
pytest.raises()
crea un contesto in cui le istruzioni che generano eccezioni possono essere racchiuse senza causare un crash del programma (al contrario, un’eccezione di quel tipo diventa necessaria per far sì che il test abbia successo). Quindi, il messaggio di errore viene confrontato con quello previsto (accedendo all’attributo value
dell’eccezione).
Test per l’API
Il file tests/test_api.py
contiene test da eseguire tramite l’API. Questi a loro volta coinvolgeranno il service e il data layer, che vengono testati in modo indipendente nei file precedenti. A causa della natura più complessa dei test tramite API, sono richieste alcune dichiarazioni preliminari.
6 from fastapi.testclient import TestClient
7 from fastapi.encoders import jsonable_encoder
8
9 import pytest
10
11 from modules.schemas import ExpenseRead
12 from modules.crud_handler import CRUDHandler
13 from modules.crud_handler import CRUDHandlerContext
14 from modules.api import app
15 from modules.api import get_ch
16
17 from tests.common import TEST_DB_NAME
...
19 from tests.common import expenses
20 from tests.common import CRUDHandlerTestContext
21
22
23 def get_test_ch() -> CRUDHandler:
24 """Yield CRUDHandler object for testing purposes.
25
26 Pre-populated with some expenses, linked to "sem-test" DB.
27
28 Yields
29 -----------------------
30 CRUDHandler
31 The CRUDHandler object.
32 """
33 with CRUDHandlerContext(TEST_DB_NAME) as ch:
34 yield ch
get_test_ch()
è una funzione che agisce come get_ch()
nel modulo API, producendo un oggetto CRUDHandler
connesso al database di testing. Il suo utilizzo risulterà evidente dalla funzione successiva:
37 @pytest.fixture
38 def test_client():
39 """Construct FastAPI test client."""
40 app.dependency_overrides[get_ch] = get_test_ch
41 return TestClient(app)
C’è molto da discutere su questa funzione:
- Le fixture, dichiarate tramite il decoratore
@
, sono funzioni che restituiscono oggetti che possono essere utilizzati nelle funzioni di test, dove vengono passati come argomenti. I contesti (comepytest
.fixtureCRUDHandlerTestContext()
sopra) sono comunque preferibili se è necessario eseguire azioni di cleanup. app_dependency_overrides
è un attributo di tipo
degli oggettidict
FastAPI
. Le sue chiavi sono le dipendenze dell’oggetto, mentre i suoi valori sovrascriveranno le dipendenze, sostituendole nel resto del modulo.
Di default, il dizionario è vuoto e non vengono eseguite sovrascritture; qui stiamo indicando a FastAPI di sostituireget_ch()
conget_test_ch(
)
in tutti gli endpoint. Quindi, l’API accederà al database di test anziché a quello principale.TestClient
è un oggetto FastAPI che funge da client HTTP, consentendo di inviare richieste al server e ricevere risposte. Questo oggetto verrà utilizzato nelle funzioni di test seguenti, come risultato del passaggio della fixture come parametro.
Il nostro primo esempio di test API sarà il test dell’API add()
, dove una nuova spesa verrà aggiunta al database di test, che verrà poi interrogato per verificare che la procedura sia andata a buon fine.
152 def test_add_api(test_client):
153 """Tests adding function."""
154 with CRUDHandlerTestContext():
155 # Skipping category
156 new_exp = {
157 "date": "2023-12-12",
158 "type": "M",
159 "amount": -1.44,
160 "description": "added via API",
161 }
162 response = test_client.post("/add", json=new_exp)
163
164 assert response.status_code == 200
165 assert response.json() == {"message": "expense added"}
166
167 response = test_client.get("/query?types=M")
168
169 expected = [
170 expenses[2],
171 ExpenseRead(id=6, **new_exp),
172 ]
173
174 assert response.status_code == 200
175 assert response.json() == jsonable_encoder(expected)
Qui una nuova spesa è creata (in formato JSON, poiché la richiesta richiederà un corpo JSON) e passata al metodo post()
di TestClient
(il metodo HTTP chiamato dovrebbe essere lo stesso dell’endpoint). La path viene fornita come argomento posizionale, mentre il corpo viene passato tramite l’argomento json
.
Il codice di stato e il corpo della risposta (tramite i suoi attributi status_code
e json
) vengono confrontati con il valore previsto. Il metodo TestClient
.get()
viene quindi chiamato sull’URL /query
(notare i query parameters passati) e la risposta viene confrontata con il valore previsto.
Il nostro ultimo esempio di test sarà quello per l’API di rimozione: qui dobbiamo gestire il caso in cui vengano restituiti errori.
432 def test_remove_api(test_client):
433 """Tests removing request."""
434 with CRUDHandlerTestContext():
435 # Selective removal
436 response = test_client.delete("/remove?ids=3&ids=1")
437
438 assert response.status_code == 200
439 assert response.json() == {"message": "expense(s) removed"}
440
441 response = test_client.get("/query")
442
443 expected = [
444 expenses[4],
445 expenses[3],
446 expenses[1],
447 ]
448
449 assert response.json() == jsonable_encoder(expected)
450
451 # Inexistent ID
452 response = test_client.delete("/remove/?ids=19")
453 assert response.status_code == 404
454 assert response.json() == {"detail": "ID 19 not found"}
...
Come accennato in precedenza, le HTTPException
non sono vere e proprie eccezioni Python: di conseguenza, la richiesta restituisce comunque una risposta, il cui codice di stato e corpo della risposta vengono confrontati con il valore atteso.
La suite di test sopra descritta viene eseguita lanciando il comando
$ poetry run python3 -m pytest -x -s -v .
Le opzioni di riga di comando non sono strettamente necessarie, ma a mio avviso sono utili:
-v
aggiunge un utile livello di verbosità;-x
interrompe l’esecuzione al primo test fallito;-s
stampa l’output del test (che altrimenti sarebbe nascosto).
pytest
mostrerà in dettaglio quali test hanno fallito e quali hanno avuto successo, funzione per funzione, e offrirà informazioni aggiuntive in caso di fallimento.
Nelle Prossime Puntate
In questo post abbiamo capito come lanciare un programma server, gestirne le dipendenze, costruire e navigarne la documentazione ed eseguire test.
Nella prossima puntata di questa serie, impareremo come distribuire rapidamente il nostro server tramite containers con docker, e come scrivere un’interfaccia da riga di comando semplice ma efficace per usarlo.
Autore: Adriano Angelone
Dopo aver ottenuto la Laurea Magistrale in Fisica all’Università di Pisa nel 2013, ha ricevuto il Dottorato in Fisica all’Università di Strasburgo nel 2017. Ha lavorato come ricercatore post-dottorale all’Università di Strasburgo, alla SISSA (Trieste) e all’Università Sorbona (Parigi), prima di entrare in eXact-lab come Sviluppatore di Software Scientifico nel 2023.
In eXact-lab, lavora all’ottimizzazione di codici computazionali e allo sviluppo di software di gestione dati e data engineering.