Salta al contenuto principale

105 - ML-Ops: UV, ambienti e dipendenze che si ripetono uguali

Con UV dichiari le dipendenze in pyproject.toml, le fissi in uv.lock e ricrei lo stesso ambiente ovunque con uv sync --locked, versione di Python compresa.

Cosa ne pensi di questo articolo?

105 - ML-Ops: UV, ambienti e dipendenze che si ripetono uguali
Machine Learning Engineerfedericocalo.dev

In Breve

Con UV dichiari le dipendenze in pyproject.toml, le fissi in uv.lock e ricrei lo stesso ambiente ovunque con uv sync --locked, versione di Python compresa.

UV è uno strumento per gestire progetti Python: installa le versioni di Python, crea gli ambienti virtuali, risolve e installa le dipendenze e le registra in un lockfile. Questa lezione usa UV 0.12 e ti dà una frase da ricordare: il lockfile è la differenza tra "funziona da me" e "funziona ovunque". Chiude la prima delle cinque dipendenze implicite del notebook di cui parla l'introduzione al modulo: le versioni delle librerie installate per caso.

Le parole del packaging Python

Un pacchetto (package) è una libreria Python distribuita in forma installabile, con nome e versione: scikit-learn è un pacchetto, come un elettrodomestico in commercio con modello e numero di serie. PyPI è il grande magazzino pubblico da cui uv e pip scaricano. La versione è l'etichetta numerica di un rilascio, di solito maggiore.minore.patch (1.9.1): un salto di "maggiore" può rompere la compatibilità, una "patch" di norma corregge errori. L'interprete è il programma python che esegue il codice, e le librerie sono costruite per alcune sue versioni e non per altre. L'ambiente virtuale (.venv) è una cartella con un Python e le librerie solo per questo progetto, l'armadietto personale di ogni progetto: due progetti che vogliono versioni diverse della stessa libreria non si pestano i piedi.

La risoluzione (resolution) è il calcolo con cui lo strumento sceglie, per ogni pacchetto, una versione compatibile con i vincoli di tutti gli altri, come comporre un orario in cui nessuno è in due aule insieme. Il lockfile (uv.lock) è il file in cui il risultato viene scritto, con versioni esatte e hash (impronte digitali) dei file scaricati: è lo scontrino. Una dipendenza di sviluppo serve a chi lavora sul progetto (per esempio pytest), non a chi lo esegue in produzione, e in UV sta in un gruppo (dev).

Il percorso, dall'intenzione all'ambiente funzionante:

UV: pyproject.toml dichiara, la risoluzione sceglie le versioni, uv.lock le registra, uv sync costruisce l'ambiente .venvpyproject.tomlDICHIARATOscikit-learn>=1.5Risoluzionesceglie versionicoerentiuv.lockRISOLTOversioni + hash.venvAMBIENTEuv sync --lockeduv add / uv lock -> scrive -> uv sync

L'analogia: la lista della spesa e lo scontrino

Scrivere sul foglietto "farina, uova, latte" è una dichiarazione: dice cosa vuoi, non quale marca né quale lotto. Lo scontrino invece è un registro di ciò che hai comprato davvero: farina del tal mulino, uova di quel lotto, prezzo e data. Se vuoi rifare la stessa torta identica, ti serve lo scontrino, non il foglietto. In UV il foglietto è pyproject.toml e lo scontrino è uv.lock.

Il meccanismo: dichiarato e risolto

In pyproject.toml scrivi i vincoli, per esempio scikit-learn>=1.5: "una versione da 1.5 in su". Questo vincolo ammette moltissime versioni, e tra un mese ne esisterà una nuova. Quando UV risolve le dipendenze sceglie una combinazione concreta e coerente di tutti i pacchetti, diretti e transitivi, e la scrive in uv.lock con le versioni esatte e gli hash dei file. Chi clona il progetto e sincronizza ottiene quella combinazione, non quella che il risolutore sceglierebbe oggi.

Il primo progetto, passo per passo

Ho eseguito questi comandi il 2026-11-12 con UV 0.12.21 (versioni scelte dal risolutore quel giorno: scikit-learn 1.9.1, pandas 3.1.1, pytest 9.2.0), due volte in cartelle diverse con le stesse uscite:

uv init churn --python 3.12        # crea la cartella con pyproject.toml, .python-version, src/
cd churn
uv add scikit-learn pandas         # aggiunge le dipendenze, crea .venv e uv.lock
uv add --dev pytest                # dipendenza solo di sviluppo (gruppo "dev")
uv run pytest --version            # esegue un comando DENTRO l'ambiente del progetto
# pytest 9.2.0
uv tree --depth 1                  # le dipendenze dirette, ad albero
# churn v0.1.0
# ├── pandas v3.1.1
# ├── scikit-learn v1.9.1
# └── pytest v9.2.0 (group: dev)

Dopo questi comandi trovi pyproject.toml con le dipendenze dichiarate ("scikit-learn>=1.9.1": UV scrive come vincolo minimo la versione che ha appena installato), uv.lock con tutto risolto, .venv/ con l'ambiente, e .python-version con la versione di Python (3.12). uv run è il comando che userai di più: non devi "attivare" l'ambiente a mano, UV sincronizza e poi esegue. Va in Git (lezione su Git) tutto tranne .venv/: pyproject.toml, uv.lock, .python-version, perché la cartella dell'ambiente si ricrea.

Sincronizzare in modo bloccato: il comando che salva il rilascio

Su un'altra macchina, in una pipeline o in un container, il comando giusto è uv sync --locked: installa esattamente il lockfile e, se non è aggiornato, fallisce. Ho provato cosa succede quando qualcuno modifica pyproject.toml a mano, aggiungendo tqdm, senza rigenerare il lock:

uv sync --locked
# Resolved 18 packages in 447ms
# error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
#
# hint: To update the lockfile, run `uv lock`.
uv sync --frozen
# (nessun errore: exit 0)
uv pip list | grep -i tqdm      # nessuna riga: tqdm NON è stato installato

È l'errore giusto, al momento giusto: la dichiarazione e il registro non coincidono, e ti viene detto. Esiste anche --frozen, che usa il lock senza controllare se è allineato: nella stessa prova è terminato con exit 0 e tqdm, aggiunto a mano, non risultava installato. Per un rilascio, --locked è più sicuro, perché trasforma l'incoerenza in un errore invece di nasconderla.

ComandoQuandoCosa fa se il lock è vecchio
uv syncSviluppo quotidianoAggiorna il lock se serve
uv sync --lockedCI, container, deployFallisce con errore chiaro
uv sync --frozenCasi rari in cui il lock è fuori dal tuo controlloProcede senza controllare

Aggiornare in modo deliberato

Il lockfile fissa le versioni, quindi il progetto non cambia da solo. Aggiornare diventa un atto volontario e tracciabile:

uv lock --upgrade-package scikit-learn   # aggiorna solo quel pacchetto (e ciò che serve)
uv lock --upgrade                        # aggiorna tutto, entro i vincoli
git diff uv.lock                         # guarda cosa è cambiato, poi esegui i test

La sequenza è: aggiorna, leggi il diff, esegui uv run pytest, e solo se è verde fai il commit. Un aggiornamento di scikit-learn può cambiare i numeri di un modello: il test con soglia è ciò che te lo dice. Nella prova i due uv lock --upgrade non hanno cambiato nulla perché il lock era stato creato pochi minuti prima; il diff era vuoto, e git diff non l'ho eseguito perché la cartella di prova non è un repository.

La trappola della versione di Python

Con uv init --bare --python 3.12 demo il progetto dichiara requires-python = ">=3.12" ma non crea il file .python-version (verificato: la cartella contiene solo pyproject.toml). Alla prima uv run UV ha scelto CPython 3.13.13, la più recente installata sulla macchina: il vincolo ">=3.12" lo permette. Se poi l'immagine di produzione usa Python 3.12, hai sviluppato su una versione diversa da quella che gira. Per questo uv python pin 3.12 scrive il file .python-version (nella prova, dopo il pin e un rm -rf .venv, UV ha risposto Using CPython 3.12.13): la versione dell'interprete è una dipendenza come le altre. Lo stesso vale per i modelli salvati: un file .joblib prodotto con una versione di scikit-learn può non caricarsi (o caricarsi con avvisi) con un'altra. Training e serving devono condividere il lock; la compatibilità dei modelli salvati tra versioni è dichiarata nella documentazione di scikit-learn e qui non l'ho riprovata.

Cosa c'è dentro i file: leggere pyproject.toml e uv.lock

Capire i due file toglie il mistero. Ho creato un progetto minimo (uv init --bare --python 3.12 demo, poi uv add "scikit-learn>=1.5" joblib).

[project]
name = "demo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "joblib>=1.6.0",
    "scikit-learn>=1.5",
]

requires-python dice con quali Python il progetto è garantito; dependencies elenca i vincoli di primo livello. Nota che UV ha scritto joblib>=1.6.0 (la versione installata) per la dipendenza aggiunta senza vincolo, mentre ha lasciato scikit-learn>=1.5 come l'avevi digitato. Il file è breve di proposito, perché lo leggono le persone. Il lockfile invece lo legge la macchina; ecco la voce di un pacchetto, con gli indirizzi e gli hash abbreviati:

[[package]]
name = "cloudpickle"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/.../cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb6...", size = 22330, ... }
wheels = [ { url = "https://files.pythonhosted.org/.../cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6...", size = 22228, ... } ]

Per ogni pacchetto ([[package]]): nome, versione esatta, da dove viene (source), e gli indirizzi e gli hash dei file (sdist = codice sorgente, wheels = pacchetti già compilati). L'hash serve a una cosa precisa: se qualcuno sostituisse il file su un mirror con uno manomesso, l'hash non coinciderebbe e l'installazione fallirebbe. Il lockfile del progetto d'esempio ha 252 righe (wc -l uv.lock) per 8 pacchetti risolti, il progetto più 7: nessuno le scrive, nessuno le modifica a mano. uv tree mostra le relazioni leggibili:

demo v0.1.0
├── joblib v1.6.0
│   └── cloudpickle v3.1.2
└── scikit-learn v1.9.1
    ├── joblib v1.6.0 (*)
    ├── narwhals v2.28.0
    ├── numpy v2.5.4
    ├── scipy v1.18.1
    │   └── numpy v2.5.4
    └── threadpoolctl v3.8.0
(*) Package tree already displayed

Dalle due dipendenze scelte da te scendono altri cinque pacchetti (cloudpickle, narwhals, numpy, scipy, threadpoolctl); joblib compare due volte perché è raggiunto anche da scikit-learn, e l'asterisco (*) segnala un nodo già mostrato. In tutto 7 pacchetti installati, più il progetto stesso: gli 8 che uv lock dichiara di aver risolto. Sono le dipendenze transitive di cui parla la lezione sull'open source: il lockfile le fissa tutte.

L'ambiente si ricrea, non si ripara

L'ambiente virtuale è un derivato: lo si può cancellare senza perdere nulla. L'ho fatto di proposito:

rm -rf .venv
uv run python -c "import sys, sklearn; print(sys.version.split()[0], sklearn.__version__)"
# Using CPython 3.12.13
# Creating virtual environment at: .venv
# Installed 7 packages in 227ms
# 3.12.13 1.9.1

Questo cambia il modo di lavorare: se l'ambiente ha un comportamento strano, non lo "aggiusti" installando e disinstallando a mano (sporcandolo): lo butti e lo rifai dal lock. È il rimedio più semplice contro "da me funziona". Con la cache di UV già calda la ricostruzione è durata tra 0,57 e 0,75 secondi in tre esecuzioni (macchina di prova, 2026-11-12); la prima volta, con la cache non ancora pronta per quell'ambiente, ne è servita una da 8,4. Con uv python list --only-installed vedi le versioni di Python disponibili sulla macchina: nella mia c'erano 3.10, 3.11, 3.12, 3.13 e 3.14, ciascuna indipendente da quella di sistema; se la versione richiesta non fosse presente, UV la scaricherebbe.

Quattro comandi che sostituiscono "pip install" nel lavoro quotidiano

Cosa vuoi fareComandoCosa succede
Aggiungere una libreriauv add pandasLa scrive in pyproject.toml, risolve, aggiorna uv.lock, installa nel .venv
Togliere una libreriauv remove pandasL'opposto: aggiorna entrambi i file e l'ambiente
Eseguire qualcosa nell'ambienteuv run pytestSincronizza l'ambiente se serve, poi esegue
Usare uno strumento una tantumuvx pip-audit ...Lo esegue in un ambiente temporaneo, senza sporcare il progetto

La differenza concettuale con pip install è che uv add modifica la dichiarazione, non solo l'ambiente: ciò che installi resta scritto nel progetto. Con pip install da solo, l'ambiente cambia ma nessun file lo registra: è la ricetta per "ho installato qualcosa e non so più cosa". (Di questa tabella ho eseguito uv add e uvx pip-audit; uv remove non l'ho provato.)

Errori frequenti e cosa significano

Il messaggio error: The lockfile at uv.lock needs to be updated, but --locked was provided vuol dire che pyproject.toml e lock non coincidono (visto sopra): si rimedia con uv lock e con il commit di entrambi. "No solution found when resolving dependencies" vuol dire che due pacchetti chiedono versioni incompatibili di un terzo: il messaggio elenca la catena dei vincoli e va letta dal basso, e spesso si risolve rilassando un vincolo o aggiornando uno dei due (non ho riprodotto questo errore). Se il progetto funziona con uv run e non con python dal terminale, stai usando il Python di sistema e non quello del .venv: usa sempre uv run, oppure attiva l'ambiente con source .venv/bin/activate. E se un notebook non vede le librerie, il kernel di Jupyter usa un altro Python: avvialo dall'ambiente del progetto, per esempio con uv run --with jupyter jupyter lab (comando dalla documentazione di UV, non eseguito qui).

Un'abitudine che risparmia ore: committare i due file insieme

Ogni volta che cambia una dipendenza, nello stesso commit vanno pyproject.toml e uv.lock. Se si separano, c'è almeno un commit in cui il progetto è incoerente, e un git bisect (visto nella lezione su Git) che passa da lì dà risultati falsi. Una regola semplice da far rispettare con la CI (lezione sul deploy manuale contro CI/CD) è un passo uv sync --locked, che fallisce se i due file sono disallineati.

Il perché: perché un requirements.txt da solo non basta

Il metodo storico per "dichiarare le dipendenze" in Python è un file requirements.txt: una riga per pacchetto, spesso senza versione o con un vincolo largo. Se lo hai usato, hai probabilmente sperimentato il problema senza dargli un nome: lo stesso file produce ambienti diversi in momenti diversi. Lo si può dimostrare con un esperimento. Ho scritto un file di due righe (scikit-learn e pandas, nessuna versione) e ho chiesto a UV di calcolare quali versioni installerebbe oggi, e quali avrebbe installato il 1° gennaio 2026 (l'opzione --exclude-newer ignora tutto ciò che è stato pubblicato dopo la data indicata):

uv pip compile requirements.in --python-version 3.12
uv pip compile requirements.in --python-version 3.12 --exclude-newer 2026-01-01
PacchettoOggi (12 novembre 2026)Come al 1° gennaio 2026
scikit-learn1.9.11.8.0
pandas3.1.12.3.3
numpy2.5.42.3.5
scipy1.18.11.16.3
joblib1.6.01.5.3

Stesso file di due righe, due ambienti diversi: tra le due date pandas è passato da una versione maggiore (2) a un'altra (3), il tipo di salto in cui i cambi di comportamento sono ammessi. Chi ha installato a gennaio e chi ha installato oggi, seguendo la stessa documentazione, non hanno lo stesso progetto. Questo non è un difetto di pip o di UV: è la conseguenza di ciò che il file dice, cioè "voglio una versione qualunque, purché compatibile". Il file descrive un desiderio, non un ambiente.

Ci sono cinque lacune in un requirements.txt tipico, e vale la pena nominarle perché sono esattamente ciò che un lockfile colma:

  1. Le dipendenze transitive non sono fissate: se fissi scikit-learn==1.9.1 ma non NumPy, NumPy è libero di cambiare sotto di lui.
  2. Mancano gli hash: nulla ti dice che il file scaricato oggi sia lo stesso scaricato ieri con lo stesso numero di versione. Con --generate-hashes ogni riga porta la sua impronta e l'installazione la verifica.
  3. Non dice con quale Python: un pacchetto compatibile con Python 3.12 può non esserlo con 3.14.
  4. Non separa ciò che serve a eseguire da ciò che serve a sviluppare, a meno di tenere più file e sincronizzarli a mano.
  5. Non distingue il "desiderato" dal "risolto": o scrivi i vincoli larghi (e perdi la ripetibilità) o scrivi tutto fissato (e perdi la leggibilità e rendi pesantissimo ogni aggiornamento).

Il lockfile risolve l'ultimo punto separando i due ruoli in due file, che è la ragione della coppia pyproject.toml / uv.lock: il primo è scritto per le persone, il secondo per le macchine. Anche con i vecchi strumenti si può fare lo stesso (uv pip compile trasforma un file di desideri, requirements.in, in un file fissato con tutte le versioni, e con --generate-hashes aggiunge gli hash), ma UV integra i due ruoli in un solo flusso.

Cosa il lockfile garantisce, e cosa lascia fuori

Dire che un lockfile "rende riproducibile" è vero solo in parte, e la differenza è una competenza vera. Il lockfile garantisce le versioni esatte e gli hash di tutti i pacchetti Python, diretti e transitivi; è universale (secondo la documentazione di UV viene risolto una volta per tutte le piattaforme supportate e per tutte le versioni di Python ammesse da requires-python), quindi lo stesso file serve al tuo portatile e al server.

Non garantisce, e quindi va gestito altrove, sei cose:

  • La versione di Python: la dichiara .python-version (e il lock indica solo l'intervallo requires-python). Due file, due ruoli.
  • Le librerie di sistema: molti pacchetti includono codice nativo che dipende dal sistema operativo. Il lock fissa il pacchetto Python, non il sistema sotto; per chiudere il cerchio serve il container.
  • L'hardware: lo stesso modello e la stessa versione di una libreria numerica, su processori o GPU diversi, possono dare risultati leggermente diversi per l'ordine delle operazioni in virgola mobile. Un test con tolleranza (approx, in pytest) esiste per questo.
  • I dati: il lock non sa nulla del dataset. Se il file di input cambia, il risultato cambia, a parità di ambiente (si tracciano con MLflow).
  • I seed: la casualità non fissata dà risultati diversi con ambiente identico.
  • La configurazione: variabili d'ambiente e segreti, trattati in variabili d'ambiente e segreti.

Le sei voci sono gli anelli mancanti di una catena: la riproducibilità ne ha sei (codice, dipendenze, sistema, configurazione, dati, casualità) e un lockfile ne chiude solo uno. Ogni lezione del modulo ne chiude un altro. Quando qualcuno dice "il mio esperimento non è riproducibile", la domanda utile è: quale anello?

Il caso reale: un lock che non è usato dove conta

Il servizio ML di questo sito contiene, nella stessa cartella, quattro file che parlano di dipendenze: pyproject.toml (la dichiarazione), uv.lock (3.636 righe, il risolto), requirements.txt (110 righe) e requirements-authoring.txt (55 righe, per l'elaborazione di contenuti; conteggi con wc -l del 2026-11-12). Il commento in testa a requirements.txt dice: "Sincronizzato con pyproject.toml — la fonte di verità resta pyproject.toml. Aggiorna entrambi quando cambi le dipendenze."

La procedura di deploy documentata (docs/01_foundation/04_deploy/11_deploy_manuale.md) installa il pacchetto con pip install /opt/portfolio/repo/ml-service dentro un ambiente già esistente sul server: in quel passo le versioni vengono risolte dai vincoli di pyproject.toml (per esempio fastapi>=0.115.0), non lette dal lock. Il lockfile garantisce quindi l'ambiente di sviluppo, ma non è ciò che il server installa. Non è un errore da drammatizzare, è un trade-off consapevole o un debito da pianificare: l'alternativa sarebbe un uv sync --locked nel passo di deploy. E due file che devono dire la stessa cosa e vanno aggiornati a mano sono un debito a sé: prima o poi divergeranno, e nessun controllo se ne accorgerà. Il commento è la mitigazione di chi conosce il rischio; il rimedio strutturale sarebbe generare uno dei due dall'altro (uv export --no-dev --format requirements-txt produce un requirements.txt dal lock, provato) e controllare con la CI che coincidano. Moralità: un lockfile che nessuno usa nel punto critico dà solo l'illusione della riproducibilità. Verifica sempre dove viene letto.

Gruppi di dipendenze: produzione e sviluppo

Un servizio in produzione non ha bisogno di pytest. UV separa le dipendenze in gruppi: quelle del progetto ([project] dependencies) e i gruppi nominati ([dependency-groups]), come dev. In produzione si sincronizza escludendo i gruppi di sviluppo:

uv sync --locked --no-dev
# Uninstalled 5 packages in 84ms
#  - iniconfig==2.3.1
#  - packaging==26.3
#  - pluggy==1.7.0
#  - pygments==2.21.0
#  - pytest==9.2.0
.venv/bin/python -c "import pytest"
# ModuleNotFoundError: No module named 'pytest'

Dopo il comando pytest e le sue quattro dipendenze sono disinstallati dall'ambiente (UV elenca i pacchetti rimossi con il segno -), e import pytest dà ModuleNotFoundError. Attenzione a una trappola che ho trovato provando: un semplice uv run python -c "import pytest" reinstalla il gruppo dev prima di eseguire, perché uv run sincronizza con i gruppi predefiniti; per restare senza dev serve uv run --no-dev (con cui ottengo lo stesso ModuleNotFoundError) o l'interprete del .venv direttamente. Con uv sync --locked i pacchetti tornano. Meno pacchetti in produzione significa meno superficie d'attacco (vedi la lezione sull'open source) e un'immagine più piccola. Per funzionalità opzionali del tuo progetto esistono gli extras (uv sync --extra nome), pensati per chi usa il tuo pacchetto come libreria; non l'ho provato.

Script a file singolo con dipendenze dichiarate

Non tutto è un progetto. Per uno script di una sera, UV supporta i metadati inline: un blocco di commenti in testa al file che dichiara versione di Python e dipendenze.

# /// script
# requires-python = ">=3.12"
# dependencies = ["scikit-learn>=1.5"]
# ///
import sklearn
print("sklearn", sklearn.__version__)
# Output di "uv run quick.py": sklearn 1.9.1

Con uv run quick.py UV legge il blocco, crea un ambiente temporaneo con quelle dipendenze, esegue lo script e stampa sklearn 1.9.1 (provato in una cartella senza alcun progetto UV, con Installed 7 packages). Lo script porta con sé le sue dipendenze: puoi mandarlo a un collega senza spiegazioni. È il passo intermedio ideale tra "notebook" e "progetto": un file solo, già dichiarativo.

Migrare da pip e requirements.txt senza drammi

Se hai un progetto esistente con requirements.txt, il passaggio può essere graduale:

  1. uv pip install -r requirements.txt: usa UV come sostituto veloce di pip, senza cambiare nulla del progetto.
  2. uv init nella cartella, poi uv add -r requirements.txt: importa le righe nel pyproject.toml e crea il lock.
  3. Togli il requirements.txt dalla fonte di verità, oppure generalo dal lock quando serve a strumenti che lo richiedono.
  4. Esegui i test, committa pyproject.toml e uv.lock insieme.

L'ordine conta: il primo passo non cambia nulla e ti dice se l'ambiente si ricostruisce, il secondo sposta la dichiarazione, il terzo evita di avere due fonti di verità. Ho provato i primi due su un requirements.txt di due righe (scikit-learn, pandas): uv add -r requirements.txt ha scritto "pandas>=3.1.1" e "scikit-learn>=1.9.1" nel pyproject.toml. Un consiglio pratico: la prima volta, confronta le versioni prima e dopo (pip freeze contro uv tree), perché il lock potrebbe scegliere versioni più recenti di quelle che avevi, e ti conviene saperlo prima di scoprirlo da un test rosso.

Aggiornare con metodo

Un lockfile fissa il passato, e il passato invecchia: nuove versioni correggono vulnerabilità e difetti. Una routine ragionevole procede così: ogni tanto (a cadenza che decidi: mensile, o a ogni rilascio) esegui uv lock --upgrade in un branch dedicato; guarda il diff di uv.lock per vedere quali pacchetti sono cambiati e di quanto (maggiore, minore, patch); esegui l'intera suite di test, perché un cambio di versione di scikit-learn può spostare i numeri di un modello e il test con soglia è ciò che ti avvisa; se qualcosa si rompe, aggiorna un pacchetto per volta con uv lock --upgrade-package nome per isolare il colpevole, una piccola bisezione manuale; infine esegui l'audit di sicurezza (pip-audit, come nella lezione sull'open source) sul lock risultante.

Il vantaggio dell'approccio è che l'aggiornamento diventa un evento con un commit, un diff e dei test, non un cambiamento invisibile che accade la prossima volta che qualcuno ricrea l'ambiente. Dove il rischio è alto (modelli addestrati in passato che devono continuare a essere serviti), si fa il contrario: si congela, e si aggiorna solo con un riaddestramento deliberato.

Vincoli larghi, stretti, o con tetto

Quando dichiari scikit-learn>=1.5 fai una scommessa: tutte le versioni future saranno compatibili. Di solito va bene per le versioni minori, meno per le maggiori. Tre stili:

StileEsempioQuando
Solo minimoscikit-learn>=1.5Applicazioni: il lock fa il lavoro di fissaggio
Compatibilescikit-learn~=1.9 (da 1.9 a prima di 2.0)Quando vuoi escludere salti maggiori noti per rompere
Tetto esplicitopandas>=2.2,<3Quando sai che una versione maggiore ti rompe e non sei pronto

Per le applicazioni (un servizio) la regola è lasciare i vincoli larghi e far lavorare il lock: il lock fa già da blocco preciso. Per le librerie che altri installeranno, i vincoli larghi sono invece un favore ai tuoi utenti: troppo stretti, e il tuo pacchetto non convive con altri nello stesso ambiente. La sintassi ~= è quella dei "compatible release" della specifica dei version specifier.

UV contro pip

pip è ovunque e funziona; UV aggiunge il lockfile e la gestione di Python in un solo strumento ed è molto veloce, ma è uno strumento in più da installare. Con uv pip install mantieni l'interfaccia familiare, e il bloccare le versioni dà ripetibilità al prezzo di una routine di aggiornamento: senza, ti perdi le correzioni di sicurezza.

Prova tu: quattro esercizi guidati

Esercizio 1: la stessa dichiarazione in due momenti diversi

Crea requirements.in con due righe (scikit-learn e pandas). Esegui uv pip compile requirements.in --python-version 3.12 e poi di nuovo con --exclude-newer e una data di alcuni mesi fa (nel formato AAAA-MM-GG). Dovresti vedere versioni diverse per più pacchetti (nella mia prova, pandas 3.1.1 contro 2.3.3 con data 1° gennaio 2026). Cosa impari: che un file senza versioni descrive un desiderio, e che l'opzione --exclude-newer è un modo per "viaggiare nel tempo" e riprodurre un ambiente del passato.

Esercizio 2: buttare l'ambiente e ricrearlo

In un progetto UV funzionante, cancella la cartella .venv e riesegui uv run python -c "import sklearn; print(sklearn.__version__)". Dovresti vedere Creating virtual environment, Installed N packages e la versione stampata, in pochi secondi (tra 0,57 e 0,75 nelle mie prove, con la cache calda). Cosa impari: che l'ambiente è un derivato e non va "riparato"; e il valore di avere tutto dichiarato.

Esercizio 3: provocare l'errore del lockfile

Modifica a mano pyproject.toml aggiungendo una dipendenza che non è già installata (per esempio tqdm>=4.66) senza eseguire uv lock. Poi uv sync --locked. Dovresti vedere error: The lockfile at uv.lock needs to be updated, but --locked was provided. Prova ora uv sync --frozen e verifica con uv pip list che la libreria non è stata installata. Infine uv lock e uv sync --locked. Cosa impari: la differenza tra un controllo che ti avvisa e uno che ti lascia nell'ambiguità. Scegli una libreria non ancora presente nell'ambiente: se fosse già una dipendenza transitiva, l'esercizio non mostrerebbe nulla.

Esercizio 4: lo script con metadati inline

Scrivi un file quick.py con in testa il blocco # /// script ... # /// che dichiara requires-python e una dipendenza, e che stampa la versione della libreria. Eseguilo con uv run quick.py in una cartella senza alcun progetto UV. Dovresti vedere che UV crea un ambiente temporaneo, installa la dipendenza e stampa la versione. Cosa impari: che per uno script di una sera non serve creare un progetto, e che lo script porta con sé ciò di cui ha bisogno.

Se vuoi un quinto esercizio: esegui uv sync --locked --no-dev e verifica con l'interprete del .venv che pytest scompaia dall'ambiente, poi uv sync --locked per riaverlo. È la differenza tra l'ambiente di produzione e quello di sviluppo, in un comando.

Cosa sai fare adesso

Sai dichiarare le dipendenze in pyproject.toml, farle registrare in uv.lock e versionare i due file insieme. Sai lavorare con uv run, rilasciare con uv sync --locked e aggiornare con uv lock --upgrade-package sapendo leggere il diff. Sai fissare la versione di Python con .python-version, distinguere ciò che il lock garantisce da ciò che lascia fuori, e controllare dove un lock viene davvero letto, perché un lock ignorato dal deploy non protegge. Prossima lezione: pytest, i test che ti fanno dormire la notte.

Nella stessa serie trovi anche ML-Ops: Dockerfile multi-stage per un servizio ML, utente non root e healthcheck, ML-Ops: model registry, versioni, alias e promozione di un modello e ML-Ops: il deploy, dove gira il modello e come scegliere.

Fonti

Tutti i blocchi eseguibili (UV 0.12.21, Python 3.12.13 e 3.13.13, 2026-11-12) sono stati eseguiti due volte in cartelle diverse con uscite identiche, salvo i tempi, riportati come intervallo; le versioni dei pacchetti sono quelle scelte dal risolutore quel giorno e cambieranno. Non eseguiti: uv remove, uv run --with jupyter jupyter lab, gli extras, l'errore "No solution found". Il nome del progetto "churn" e i dati del modulo sono didattici.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti