Spaces:
Running
Running
Add CI Pipeline with Tests and Docker build
Browse files- .github/workflows/ci_papeline.yaml +77 -0
- Dockerfile +32 -0
- app/__init__.py +23 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/api/__init__.py +23 -0
- app/api/__pycache__/__init__.cpython-312.pyc +0 -0
- app/api/__pycache__/main.cpython-312.pyc +0 -0
- app/api/main.py +6 -1
- app/model/__init__.py +23 -0
- app/model/__pycache__/__init__.cpython-312.pyc +0 -0
- requirements.txt +1 -1
- tests/__init__.py +23 -0
- tests/__pycache__/__init__.cpython-312.pyc +0 -0
- tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc +0 -0
- tests/test_api.py +40 -0
.github/workflows/ci_papeline.yaml
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: MLOps CI Pipeline
|
| 2 |
+
|
| 3 |
+
# 1. TRIGGER: Quando deve partire questa pipeline?
|
| 4 |
+
# Parte su ogni "push" sul ramo main e su ogni "pull request"
|
| 5 |
+
on:
|
| 6 |
+
push:
|
| 7 |
+
branches: [ "main" ]
|
| 8 |
+
pull_request:
|
| 9 |
+
branches: [ "main" ]
|
| 10 |
+
|
| 11 |
+
# Appena attivato il trigger, viene avviato build docker sotto che richieste l'esecuzione di run_test, se ok parte la creazione dell'immagine docker
|
| 12 |
+
jobs:
|
| 13 |
+
# JOB 1: Esecuzione dei Test Automatici
|
| 14 |
+
run_tests:
|
| 15 |
+
runs-on: ubuntu-latest # Usa una macchina Linux di GitHub
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
# A. Scarica il codice dalla tua repository
|
| 19 |
+
- name: Checkout code
|
| 20 |
+
uses: actions/checkout@v3 # serve proprio a dire al robot di GitHub: "Scarica la versione del codice contenuta in questo specifico commit che ha appena fatto scattare il trigger".
|
| 21 |
+
# Dobbiamo testare sui nuovi file che stiamo pushando, per questo ci assicuriamo di farlo con i nuovi file
|
| 22 |
+
|
| 23 |
+
# B. Installa Python 3.9 (lo stesso del Dockerfile)
|
| 24 |
+
- name: Set up Python 3.9
|
| 25 |
+
uses: actions/setup-python@v4
|
| 26 |
+
with:
|
| 27 |
+
python-version: "3.9"
|
| 28 |
+
cache: 'pip' # <--- FONDAMENTALE: Cacha le librerie (così non riscarica le dipendenze ogni volta)
|
| 29 |
+
|
| 30 |
+
# C. Installa le librerie
|
| 31 |
+
- name: Install dependencies
|
| 32 |
+
run: |
|
| 33 |
+
python -m pip install --upgrade pip
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
|
| 36 |
+
# D. Lancia Pytest
|
| 37 |
+
- name: Run Tests
|
| 38 |
+
run: |
|
| 39 |
+
python -m pytest
|
| 40 |
+
|
| 41 |
+
# JOB 2: Verifica della Build Docker
|
| 42 |
+
# Questo job parte SOLO se "run_tests" ha successo (needs: run_tests)
|
| 43 |
+
build_docker:
|
| 44 |
+
needs: run_tests
|
| 45 |
+
runs-on: ubuntu-latest
|
| 46 |
+
|
| 47 |
+
steps:
|
| 48 |
+
- name: Checkout code
|
| 49 |
+
uses: actions/checkout@v3
|
| 50 |
+
|
| 51 |
+
- name: Build Docker Image
|
| 52 |
+
# Prova a costruire l'immagine. Se il Dockerfile è rotto, questo step fallisce.
|
| 53 |
+
# E' solo un test, dopo che abbiamo fatto girare pytest su test_api.py, ora testiamo che il dockerfile funzioni creando l'immmagine che poi distruggiamo
|
| 54 |
+
run: docker build -t reputation-monitor:test .
|
| 55 |
+
|
| 56 |
+
"""
|
| 57 |
+
I file vengono salvati nella repository PRIMA che il test parta. È il fatto che tu abbia "pushato" i file che sveglia il robot e gli fa iniziare il lavoro.
|
| 58 |
+
|
| 59 |
+
Ecco la sequenza temporale esatta:
|
| 60 |
+
|
| 61 |
+
⏳ La Timeline Reale
|
| 62 |
+
Tu fai git push: I tuoi file vengono caricati su GitHub. In questo istante, il codice nella repository è già aggiornato (anche se fosse rotto).
|
| 63 |
+
|
| 64 |
+
GitHub vede il cambiamento: "Ehi, è arrivato nuovo codice! Devo lanciare la pipeline".
|
| 65 |
+
|
| 66 |
+
Parte la CI (Test & Build): GitHub scarica quel codice appena caricato ed esegue i test e la build Docker.
|
| 67 |
+
|
| 68 |
+
Esito:
|
| 69 |
+
|
| 70 |
+
🟢 Se passa: Accanto al tuo commit compare una spunta verde. Tutto bene.
|
| 71 |
+
|
| 72 |
+
🔴 Se fallisce: Accanto al tuo commit compare una croce rossa. MA i file restano lì. GitHub non cancella il tuo codice se il test fallisce; ti avvisa solo che è "bacato".
|
| 73 |
+
|
| 74 |
+
Se il codice rotto viene caricato comunque, a che serve il test?
|
| 75 |
+
|
| 76 |
+
Professionalmente si crea un ramo diverso della repository, un nuovo branch che non va in produzione, e si pusha li facendo partire CI/CD. POi se tutto ok viene anche aggiornato il main che è sacro
|
| 77 |
+
"""
|
Dockerfile
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. Usiamo un'immagine base ufficiale di Python (leggera e sicura)
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
# 2. Impostiamo la cartella di lavoro dentro il container
|
| 5 |
+
WORKDIR /code
|
| 6 |
+
|
| 7 |
+
# 3. Ottimizzazione della Cache:
|
| 8 |
+
# Copiamo PRIMA solo i requirements. Questo permette a Docker di non
|
| 9 |
+
# riscaricare tutte le librerie se cambi solo il codice ma non le dipendenze.
|
| 10 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 11 |
+
|
| 12 |
+
# 4. Installiamo le dipendenze
|
| 13 |
+
# --no-cache-dir serve a mantenere l'immagine leggera
|
| 14 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 15 |
+
|
| 16 |
+
# 5. Copiamo tutto il resto del codice dentro il container
|
| 17 |
+
COPY ./app /code/app
|
| 18 |
+
|
| 19 |
+
# 6. (Opzionale ma consigliato) Scarichiamo il modello durante la build
|
| 20 |
+
# In MLOps avanzato si fa per evitare che il container debba scaricare
|
| 21 |
+
# 500MB ogni volta che si avvia.
|
| 22 |
+
# Creiamo un piccolo script inline per fare il "pre-load"
|
| 23 |
+
RUN python -c "from transformers import AutoTokenizer, AutoModelForSequenceClassification; \
|
| 24 |
+
name='cardiffnlp/twitter-roberta-base-sentiment-latest'; \
|
| 25 |
+
AutoTokenizer.from_pretrained(name); \
|
| 26 |
+
AutoModelForSequenceClassification.from_pretrained(name)"
|
| 27 |
+
|
| 28 |
+
# 7. Esponiamo la porta 8000
|
| 29 |
+
EXPOSE 8000
|
| 30 |
+
|
| 31 |
+
# 8. Il comando che parte quando avvii il container
|
| 32 |
+
CMD ["uvicorn", "app.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
app/__init__.py
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SPIEGAZIONE DI QUESTO FILE
|
| 3 |
+
|
| 4 |
+
Immagina le cartelle del tuo computer come delle semplici scatole. Per Python, una cartella è solo una scatola "muta": non sa che dentro c'è del codice collegato che può essere usato altrove.
|
| 5 |
+
|
| 6 |
+
Il file __init__.py serve a trasformare quella scatola in un Pacchetto (Package).
|
| 7 |
+
|
| 8 |
+
1. La metafora della "Bandiera" 🚩
|
| 9 |
+
Pensa al file __init__.py come a una bandiera piantata sopra la cartella che dice a Python:
|
| 10 |
+
|
| 11 |
+
"Ehi! Questa non è una cartella qualsiasi piena di file a caso. Questa è una libreria di codice Python! Puoi entrare qui e importare le funzioni che trovi."
|
| 12 |
+
|
| 13 |
+
2. Cosa fa tecnicamente?
|
| 14 |
+
Senza __init__.py: Se scrivi from app.api import main, Python potrebbe dirti "Non trovo app", perché la tratta come una semplice directory di file.
|
| 15 |
+
|
| 16 |
+
Con __init__.py: Python riconosce app come un oggetto importabile e ti permette di navigare al suo interno con il punto (.).
|
| 17 |
+
|
| 18 |
+
3. Perché ti serviva per i test?
|
| 19 |
+
Quando hai lanciato pytest, lui doveva collegare due mondi separati: la cartella tests e la cartella app. Mettendo __init__.py, hai detto a Python che tutto il tuo progetto è un insieme di moduli collegati, permettendo al file di test di "vedere" e importare il codice della tua applicazione principale.
|
| 20 |
+
|
| 21 |
+
Nota: Il file può essere (e spesso è) completamente vuoto. La sua sola presenza è sufficiente a fare la magia.
|
| 22 |
+
|
| 23 |
+
"""
|
app/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/app/__pycache__/__init__.cpython-312.pyc and b/app/__pycache__/__init__.cpython-312.pyc differ
|
|
|
app/api/__init__.py
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SPIEGAZIONE DI QUESTO FILE
|
| 3 |
+
|
| 4 |
+
Immagina le cartelle del tuo computer come delle semplici scatole. Per Python, una cartella è solo una scatola "muta": non sa che dentro c'è del codice collegato che può essere usato altrove.
|
| 5 |
+
|
| 6 |
+
Il file __init__.py serve a trasformare quella scatola in un Pacchetto (Package).
|
| 7 |
+
|
| 8 |
+
1. La metafora della "Bandiera" 🚩
|
| 9 |
+
Pensa al file __init__.py come a una bandiera piantata sopra la cartella che dice a Python:
|
| 10 |
+
|
| 11 |
+
"Ehi! Questa non è una cartella qualsiasi piena di file a caso. Questa è una libreria di codice Python! Puoi entrare qui e importare le funzioni che trovi."
|
| 12 |
+
|
| 13 |
+
2. Cosa fa tecnicamente?
|
| 14 |
+
Senza __init__.py: Se scrivi from app.api import main, Python potrebbe dirti "Non trovo app", perché la tratta come una semplice directory di file.
|
| 15 |
+
|
| 16 |
+
Con __init__.py: Python riconosce app come un oggetto importabile e ti permette di navigare al suo interno con il punto (.).
|
| 17 |
+
|
| 18 |
+
3. Perché ti serviva per i test?
|
| 19 |
+
Quando hai lanciato pytest, lui doveva collegare due mondi separati: la cartella tests e la cartella app. Mettendo __init__.py, hai detto a Python che tutto il tuo progetto è un insieme di moduli collegati, permettendo al file di test di "vedere" e importare il codice della tua applicazione principale.
|
| 20 |
+
|
| 21 |
+
Nota: Il file può essere (e spesso è) completamente vuoto. La sua sola presenza è sufficiente a fare la magia.
|
| 22 |
+
|
| 23 |
+
"""
|
app/api/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/app/api/__pycache__/__init__.cpython-312.pyc and b/app/api/__pycache__/__init__.cpython-312.pyc differ
|
|
|
app/api/__pycache__/main.cpython-312.pyc
CHANGED
|
Binary files a/app/api/__pycache__/main.cpython-312.pyc and b/app/api/__pycache__/main.cpython-312.pyc differ
|
|
|
app/api/main.py
CHANGED
|
@@ -29,7 +29,12 @@ def home():
|
|
| 29 |
# Serve a capire se il container è vivo
|
| 30 |
@app.get("/health")
|
| 31 |
def health_check():
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# 5. Endpoint di Previsione (Dummy per ora)
|
| 35 |
@app.post("/predict", response_model=SentimentResponse)
|
|
|
|
| 29 |
# Serve a capire se il container è vivo
|
| 30 |
@app.get("/health")
|
| 31 |
def health_check():
|
| 32 |
+
# VERIFICA: Controlliamo se il modello è stato caricato in memoria
|
| 33 |
+
if model_instance.model is not None:
|
| 34 |
+
return {"status": "ok", "model_loaded": True}
|
| 35 |
+
else:
|
| 36 |
+
# Se il modello non c'è, restituiamo errore 503 (Service Unavailable)
|
| 37 |
+
raise HTTPException(status_code=503, detail="Model not loaded yet")
|
| 38 |
|
| 39 |
# 5. Endpoint di Previsione (Dummy per ora)
|
| 40 |
@app.post("/predict", response_model=SentimentResponse)
|
app/model/__init__.py
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SPIEGAZIONE DI QUESTO FILE
|
| 3 |
+
|
| 4 |
+
Immagina le cartelle del tuo computer come delle semplici scatole. Per Python, una cartella è solo una scatola "muta": non sa che dentro c'è del codice collegato che può essere usato altrove.
|
| 5 |
+
|
| 6 |
+
Il file __init__.py serve a trasformare quella scatola in un Pacchetto (Package).
|
| 7 |
+
|
| 8 |
+
1. La metafora della "Bandiera" 🚩
|
| 9 |
+
Pensa al file __init__.py come a una bandiera piantata sopra la cartella che dice a Python:
|
| 10 |
+
|
| 11 |
+
"Ehi! Questa non è una cartella qualsiasi piena di file a caso. Questa è una libreria di codice Python! Puoi entrare qui e importare le funzioni che trovi."
|
| 12 |
+
|
| 13 |
+
2. Cosa fa tecnicamente?
|
| 14 |
+
Senza __init__.py: Se scrivi from app.api import main, Python potrebbe dirti "Non trovo app", perché la tratta come una semplice directory di file.
|
| 15 |
+
|
| 16 |
+
Con __init__.py: Python riconosce app come un oggetto importabile e ti permette di navigare al suo interno con il punto (.).
|
| 17 |
+
|
| 18 |
+
3. Perché ti serviva per i test?
|
| 19 |
+
Quando hai lanciato pytest, lui doveva collegare due mondi separati: la cartella tests e la cartella app. Mettendo __init__.py, hai detto a Python che tutto il tuo progetto è un insieme di moduli collegati, permettendo al file di test di "vedere" e importare il codice della tua applicazione principale.
|
| 20 |
+
|
| 21 |
+
Nota: Il file può essere (e spesso è) completamente vuoto. La sua sola presenza è sufficiente a fare la magia.
|
| 22 |
+
|
| 23 |
+
"""
|
app/model/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/app/model/__pycache__/__init__.cpython-312.pyc and b/app/model/__pycache__/__init__.cpython-312.pyc differ
|
|
|
requirements.txt
CHANGED
|
@@ -16,4 +16,4 @@ numpy
|
|
| 16 |
|
| 17 |
# --- Testing ---
|
| 18 |
pytest
|
| 19 |
-
httpx
|
|
|
|
| 16 |
|
| 17 |
# --- Testing ---
|
| 18 |
pytest
|
| 19 |
+
httpx==0.27.0
|
tests/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SPIEGAZIONE DI QUESTO FILE
|
| 3 |
+
|
| 4 |
+
Immagina le cartelle del tuo computer come delle semplici scatole. Per Python, una cartella è solo una scatola "muta": non sa che dentro c'è del codice collegato che può essere usato altrove.
|
| 5 |
+
|
| 6 |
+
Il file __init__.py serve a trasformare quella scatola in un Pacchetto (Package).
|
| 7 |
+
|
| 8 |
+
1. La metafora della "Bandiera" 🚩
|
| 9 |
+
Pensa al file __init__.py come a una bandiera piantata sopra la cartella che dice a Python:
|
| 10 |
+
|
| 11 |
+
"Ehi! Questa non è una cartella qualsiasi piena di file a caso. Questa è una libreria di codice Python! Puoi entrare qui e importare le funzioni che trovi."
|
| 12 |
+
|
| 13 |
+
2. Cosa fa tecnicamente?
|
| 14 |
+
Senza __init__.py: Se scrivi from app.api import main, Python potrebbe dirti "Non trovo app", perché la tratta come una semplice directory di file.
|
| 15 |
+
|
| 16 |
+
Con __init__.py: Python riconosce app come un oggetto importabile e ti permette di navigare al suo interno con il punto (.).
|
| 17 |
+
|
| 18 |
+
3. Perché ti serviva per i test?
|
| 19 |
+
Quando hai lanciato pytest, lui doveva collegare due mondi separati: la cartella tests e la cartella app. Mettendo __init__.py, hai detto a Python che tutto il tuo progetto è un insieme di moduli collegati, permettendo al file di test di "vedere" e importare il codice della tua applicazione principale.
|
| 20 |
+
|
| 21 |
+
Nota: Il file può essere (e spesso è) completamente vuoto. La sua sola presenza è sufficiente a fare la magia.
|
| 22 |
+
|
| 23 |
+
"""
|
tests/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (1.52 kB). View file
|
|
|
tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc
ADDED
|
Binary file (5.99 kB). View file
|
|
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from app.api.main import app
|
| 3 |
+
|
| 4 |
+
# Creiamo un client di test (simula il browser)
|
| 5 |
+
client = TestClient(app)
|
| 6 |
+
|
| 7 |
+
def test_health_check():
|
| 8 |
+
"""Verifica che l'endpoint /health risponda status 200"""
|
| 9 |
+
response = client.get("/health")
|
| 10 |
+
assert response.status_code == 200
|
| 11 |
+
assert response.json() == {"status": "ok", "model_loaded": True}
|
| 12 |
+
|
| 13 |
+
def test_prediction_positive():
|
| 14 |
+
"""Verifica che il modello riconosca un sentimento positivo"""
|
| 15 |
+
payload = {"text": "I love this service, it is amazing!"}
|
| 16 |
+
response = client.post("/predict", json=payload)
|
| 17 |
+
|
| 18 |
+
assert response.status_code == 200
|
| 19 |
+
json_data = response.json()
|
| 20 |
+
assert "sentiment" in json_data
|
| 21 |
+
assert "confidence" in json_data
|
| 22 |
+
# Ci aspettiamo che sia positivo (o almeno non negativo)
|
| 23 |
+
assert json_data["sentiment"] == "positive"
|
| 24 |
+
|
| 25 |
+
def test_prediction_negative():
|
| 26 |
+
"""Verifica che il modello riconosca un sentimento negativo"""
|
| 27 |
+
payload = {"text": "This is terrible, I hate it."}
|
| 28 |
+
response = client.post("/predict", json=payload)
|
| 29 |
+
|
| 30 |
+
assert response.status_code == 200
|
| 31 |
+
assert response.json()["sentiment"] == "negative"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
"""
|
| 35 |
+
scrivi sul terminale: pytest Serve per lanciare il test
|
| 36 |
+
Oppure: pytest -v per vedere tutti i dettagli
|
| 37 |
+
|
| 38 |
+
P.S: pytest agisce come un detective, quando lancio il comando scanziona la cartella corrente
|
| 39 |
+
e cerca file che iniziano con "test_" o finiscono con "_test.py", una volta entrato nel file lancia solo le funzioni che iniziano con "test_"
|
| 40 |
+
"""
|