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:
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.
| Comando | Quando | Cosa fa se il lock è vecchio |
|---|---|---|
uv sync | Sviluppo quotidiano | Aggiorna il lock se serve |
uv sync --locked | CI, container, deploy | Fallisce con errore chiaro |
uv sync --frozen | Casi rari in cui il lock è fuori dal tuo controllo | Procede 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 fare | Comando | Cosa succede |
|---|---|---|
| Aggiungere una libreria | uv add pandas | La scrive in pyproject.toml, risolve, aggiorna uv.lock, installa nel .venv |
| Togliere una libreria | uv remove pandas | L'opposto: aggiorna entrambi i file e l'ambiente |
| Eseguire qualcosa nell'ambiente | uv run pytest | Sincronizza l'ambiente se serve, poi esegue |
| Usare uno strumento una tantum | uvx 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
| Pacchetto | Oggi (12 novembre 2026) | Come al 1° gennaio 2026 |
|---|---|---|
| scikit-learn | 1.9.1 | 1.8.0 |
| pandas | 3.1.1 | 2.3.3 |
| numpy | 2.5.4 | 2.3.5 |
| scipy | 1.18.1 | 1.16.3 |
| joblib | 1.6.0 | 1.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:
- Le dipendenze transitive non sono fissate: se fissi
scikit-learn==1.9.1ma non NumPy, NumPy è libero di cambiare sotto di lui. - Mancano gli hash: nulla ti dice che il file scaricato oggi sia lo stesso scaricato ieri con lo stesso numero di versione. Con
--generate-hashesogni riga porta la sua impronta e l'installazione la verifica. - Non dice con quale Python: un pacchetto compatibile con Python 3.12 può non esserlo con 3.14.
- Non separa ciò che serve a eseguire da ciò che serve a sviluppare, a meno di tenere più file e sincronizzarli a mano.
- 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'intervallorequires-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:
uv pip install -r requirements.txt: usa UV come sostituto veloce di pip, senza cambiare nulla del progetto.uv initnella cartella, poiuv add -r requirements.txt: importa le righe nelpyproject.tomle crea il lock.- Togli il
requirements.txtdalla fonte di verità, oppure generalo dal lock quando serve a strumenti che lo richiedono. - Esegui i test, committa
pyproject.tomleuv.lockinsieme.
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:
| Stile | Esempio | Quando |
|---|---|---|
| Solo minimo | scikit-learn>=1.5 | Applicazioni: il lock fa il lavoro di fissaggio |
| Compatibile | scikit-learn~=1.9 (da 1.9 a prima di 2.0) | Quando vuoi escludere salti maggiori noti per rompere |
| Tetto esplicito | pandas>=2.2,<3 | Quando 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
- Documentazione di UV - progetti, lockfile,
uv sync,uv python pin, metadati inline degli script; i comandi della lezione sono stati anche eseguiti con UV 0.12.21, consultata il 2026-11-12 - UV: struttura del progetto e lockfile - il lockfile universale, consultata il 2026-11-12
- Python Packaging User Guide: scrivere il pyproject.toml - i campi
[project],requires-python,dependencies, consultata il 2026-11-12 - Python Packaging User Guide: version specifiers - il significato di
>=,~=e dei tetti, consultata il 2026-11-12 - Il file
ml-service/pyproject.tomle il runbookdocs/01_foundation/04_deploy/11_deploy_manuale.mddel repository del sito - il caso reale (conteggi di righe conwc -l, 2026-11-12)
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