uv, un package manager Python adapté à la Data - Partie 1 : théorie et fonctionnalités

Parenthèse Personnelle - Pourquoi j’ai adopté uv

Je suis entré dans le monde de la Data sept ans plus tôt, bombardé dans un environnement de développement Spark/Scala. J’en suis venu à apprécier l’univers JVM et à considérer ses outils de build comme acquis, d’abord Sbt, puis surtout Maven. Qu’on ne me comprenne pas de travers, je pense toujours que Maven est une créature de Frankenstein beaucoup trop verbeuse avec un système de Plugin/convention-over-configuration qui le rend difficile d’accès. Cependant il s’agit d’un outil puissant, et surtout admis par la communauté. C’est d'ailleurs la seule raison qui m’a fait le croiser dans un cadre de développement Scala, plutôt que d’utiliser l’outil de construction de projet qui s’appelle littéralement Scala Build Tool…

Puis j’ai dû évoluer, et ma stack technique aussi. Adieu Scala, Maven ou Sbt, bonjour PySpark, dbt, Snowflake et compagnie. Et bonjour Python, pip install dbt-core dbt-snowflake snowflake-cli.

Entre pip, conda, setuptools, venv, pyenv, un requirements.txt fait une fois sur deux à la main, une fois sur deux résultant d’un pip freeze, impossible de reproduire l’environnement de travail du voisin. 

Divers outils se sont construits par dessus ce plat de nouilles pour adresser diverses parties de ce scope. On peut citer par exemple Poetry, Hatchling, on a aussi déjà parlé de Conda, etc. Bien que certains aient atteint une certaine popularité et une efficacité incontestable sur leur domaine, aucun réel consensus n’a abouti. 

Schéma humoristique illustrant le chaos d’un environnement Python
Environnement Python - xkcd.com

uv est l’un de ces outils. Malgré tout, ce n’est pas un outil parmi les autres, il s'agit de celui qui m’a réconcilié avec la gestion des packages Python et m’a permis de retrouver l’approche descriptive que pouvait offrir Maven sur la JVM.

Qu’est-ce qu’uv ?

uv, c’est le gestionnaire de package d’Astral, l’entreprise également derrière Ruff, le dernier Linter/Formatter Python à la mode. C’est important pour plusieurs raisons : 

  • Astral surfe sur la vague “Python in Rust”. Ses utilitaires sont créés pour Python, en Rust, ce qui les rend incomparables en termes de performance1.
  • Il s’agit d’une entreprise. Contrairement à la majorité de l’outillage Python existant, il existe toute une équipe dédiée à la maintenance et à l’évolution d’uv et Ruff. L’équipe compte aujourd’hui une vingtaine de personnes, allant croissant. 
  • Cet avantage en termes de pérennité se traduit cependant par une dépendance à Astral. Un changement de licence est vite arrivé (pour ceux qui comme moi viennent de Scala, on se remémore le drame des migrations Scala 3 avec Akka2).

Positionnement

En tant qu’”outil par dessus le plat de nouilles” à part entière, uv adresse certaines problématiques et délègue les autres. Je trouve très pertinente la catégorisation d’Anna-Lena Popkes à ce sujet. En voici le résumé, je vous laisse consulter l’article entier ici.

Diagramme de Venn classant les outils de packaging Python en 5 catégories: management, publishing, building, environnements virtuels, gestion des versions Python
Catégorisation de l'outillage Python - https://alpopkes.com

On comprend immédiatement qu’uv n’est pas au centre des fonctionnalités. Si l’on décompose son périmètre :

  • environment management : comme venv, uv permet de créer et maintenir des environnements virtuels. Il permet l’encapsulation de ses dépendances.
  • package management : comme pip, uv permet de télécharger des dépendances Python depuis le dépôt officiel Pypi.
  • Python version management : comme pyenv, uv permet de gérer plusieurs versions de Python.

Ce qu’uv ne fait pas :

  • package building : uv ne permet pas de créer une distribution sans outil complémentaire.
  • package publishing : il ne permet pas non plus d’aller à lui tout seul publier dans Pypi. 

En fait, uv est un outil de build front-end. C’est lui qui va se charger de gérer les dépendances et l’environnement, et déléguer à un outil de build back-end le soin de construire notre distribution (wheel3). Dans la suite de cet article, on utilisera Hatchling4 pour réaliser ces fonctions.

Schéma décrivant le processus de construction et distribution d'un package Python
Processus de construction d'un package Python

Ainsi, on a bien uv qui se charge de l’environnement virtuel, de la gestion de Python et des dépendances, et Hatchling qui se charge des opérations complémentaires, nous fournissant un outillage complet, sur les cinq catégories décrites initialement.

Un mot sur pyproject.toml

Les PEP517 et PEP621 formalisent respectivement la description du back end et du front end de build au sein du fichier pyproject.toml. Pour les développeurs JVM, on peut voir ce fichier comme un équivalent du pom.xml de Maven. Concrètement, voici un exemple de fichier pyproject.toml : 

[project]
name = "my-module"
version = "0.1.0"
description = "a sample module definition"
requires-python = ">=3.12"
dependencies = ["pytest>=8.3.5"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Il se compose de deux tables différentes : project et build-system. La partie project contient les métadonnées du projet et la partie build-system contient les informations relatives au back end de build. Un outil front-end comme uv va lire ce fichier, télécharger le back end correspondant et lui déléguer l’exécution du build.

Les comparaisons évidentes

J’utilise Conda depuis 10 ans, pourquoi je changerai pour un outil qui fait pareil ?

Conda est un peu le mouton noir de la grande famille du tooling Python. Il fait plein de choses, mais dans son coin. Premièrement, il ne se base pas sur Pypi, le dépôt officiel, mais sur son propre dépôt Anaconda. Il est dépendant d’une installation pip supplémentaire pour installer depuis Pypi.

Deuxièmement, l’outil n’est absolument pas aligné sur les standards de packaging, PEP517 et PEP621. Cela signifie qu’il ne respecte pas le contrat défini par le fichier de configuration pyproject.toml. 

Ton schéma dit que Poetry fait plus de choses, pourquoi je changerai pour un outil moins puissant ?

C’est à la fois vrai et faux. En fait, le Poetry décrit dans ce schéma regroupe deux outils, Poetry et Poetry-core, respectivement le front end et le back end

Effectivement, la combinaison des deux fait plus de choses, mais il serait plus juste de comparer uv avec la partie front-end de Poetry. Rien n’empêche en effet d’utiliser uv avec Poetry-core, si on le configure au sein du fichier pyproject.toml.

Tour d’horizon des fonctionnalités

Après cette dose conséquente de théorie, il est plus que temps de voir ENFIN comment uv fonctionne. Commençons par l’installation. Il s’agit du seul outil qu’il sera nécessaire d’installer en dehors d’un environnement virtuel. Pour la méthode, au choix : cUrl, pipx, homebrew, cargo, WinGet, etc. Toutes les méthodes sont décrites sur la documentation officielle bien mieux que je ne pourrais le faire. 

Commandes principales

uv init

# Options principales
uv init [<MODULE>] [--app | --lib | --bare] [--build-backend <BACKEND>] [--python <PYTHON_VERSION>]

La commande init crée un module managé par uv. Par exemple, uv init mon-module va initialiser un répertoire mon-module tel que : 

Capture d'écran de l'arborescence généré par l'appel à uv init
Fichiers générés par uv init

On a vu tout à l’heure qu’uv a besoin d’un back end de build pour être exhaustif. --build-backend initialise la table [build-system] du pyproject.toml. La documentation liste les back ends compatibles actuellement :

 --build-backend <BUILD_BACKEND>  Initialize a build-backend of choice for the project [possible values: hatch, flit, pdm, setuptools, maturin, scikit]

--app, --lib ou --bare font partie des commandes modifiant les fichiers générés. Leur fonctionnement est un peu compliqué et dépend des autres options utilisées (en fait de la présence ou non de l’option build-backend). 

Schéma décrivant l'intrication des paramètres bare/app/lib et build-backend dans le fonctionnement du build
Description de l'interaction des options --app / --lib / --bare et --build-backend

--python, enfin, est une option un peu plus fine qu’elle ne le laisse penser. Plutôt qu’une interprétation impérative qui aurait le sens de “j’associe cette version de Python à ce module”, il faut la comprendre de manière plus déclarative comme “voici la version de Python minimum avec laquelle mon module peut fonctionner”. Il existe plusieurs moyens de déterminer ensuite une version d’exécution de Python suivant le contexte, nous en verrons quelques une par la suite.

uv add / uv remove

# principales options
uv add <PACKAGES> [--group <GROUP>]
uv remove <PACKAGES> [--group <GROUP>]

uv offre une interface ligne de commande pour l’ajout et la suppression de package qui évite d’interagir directement avec le fichier pyproject.toml. Par exemple, dans le module mon-module créé précédemment, uv add pyspark va éditer automatiquement le fichier :

Avant Après
[project]
name = "mon-module"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
[project]
name = "mon-module"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
	"pyspark>=3.5.5",
]

--group permet de gérer plusieurs ensembles de dépendances distincts. C’est utile si l’on a par exemple besoin de certaines dépendances pendant les développements, qu’on ne voudra pas embarquer dans la distribution finale. Les dépendances de test en sont un bon exemple : uv add pytest --group dev.

Avant Après
[project]
name = "mon-module"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
	"pyspark>=3.5.5",
]
[project]
name = "mon-module"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
	"pyspark>=3.5.5",
]

[dependency-groups]
dev = [
	"pytest>=8.3.5",
]

💡C’est un cas tellement courant qu’uv propose un raccourci à --group dev--dev
Pour être totalement transparent, c’est le genre de raccourci en provenance directe de Poetry.
“A thousand generations of wisdom are not superstition; they’re good common sense.” - Robert Jordan

uv sync

# principales options
uv sync [--group <GROUP>]

Toutes les dépendances ajoutées par uv add sont téléchargées dans un cache propre à uv (l’équivalent d’un .m2 pour Maven), puis installées dans un environnement virtuel propre au module. uv sync permet de générer cet environnement virtuel. Par défaut, la synchronisation est effectuée après chaque appel à uv add, cependant il est parfois nécessaire de l’effectuer manuellement. Entre autre :

  • au clone/pull d’un repository git (on tire le pyproject d’un environnement distant)
  • pour synchroniser l’environnement virtuel avec un groupe de dépendance particulier (auquel cas on utilisera par exemple uv sync --group dev)

💡L’argument --group est répétable.
On utilisera la commande uv sync --group group1 --group group2 pour synchroniser le venv avec les dépendances des groupes group1 et group2.

uv build / uv publish

uv build
uv publish

Je ne m’étendrai pas vraiment sur ces commandes, il s’agit des points d’entrées vers les appels au back end de build. Leurs actions dépendent donc de l’outil utilisé pour le build. Pour montrer quand même un exemple, uv build exécuté sur le module mon-module va créer un répertoire dist au sein du module : 

Capture d'écran de l'arborescence généré par l'appel à uv build
Fichiers générés par uv build

Les artefacts créés, .whl et .tar.gz, sont respectivement ce qu’on appelle la binary distribution et la source distribution du module.

uv run

uv run [--group <GROUP>] <COMMAND>

On utilisera la commande uv run pour exécuter un script présent dans notre environnement virtuel. On voudra par exemple exécuter nos tests après avoir installé pytest via uv add pytest --group dev. On exécutera simplement dans ce cas uv run --group dev pytest.

uv pip / uv venv

Il s’agit d’interfaces présentes pour la compatibilité avec les outils pip et venv. Leur fonctionnement est le même que celui des outils natifs. Ils permettent respectivement de manipuler les dépendances et l’environnement virtuel Python. Attention cependant, ces commandes n’éditent pas le fichier pyproject.toml.

On les utilisera essentiellement pour assurer une compatibilité avec des projets existants, tout en conservant au maximum la puissance d’uv (gestion des versions de Python, utilisation du pyproject.toml pour manipuler un back end, etc).

uv tool

Il s’agit d’une interface un peu particulière puisqu’elle ne concerne pas le projet à proprement parler. Celle-ci est composée de deux parties :

  • l’installation/désinstallation d’utilitaire Python au travers des commandes uv tool install, uv tool uninstall
  • l’exécution d’utilitaires : uv tool run

La véritable particularité d’uv tool réside dans le fait que l’installation est facultative. Si l’utilitaire n’est pas installé, d’après la documentation :

Tools can be invoked without installation using uv tool run, in which case their dependencies are installed in a temporary virtual environment isolated from the current project. 

En pratique, c’est surtout cette commande que l’on va exécuter, c’est d’ailleurs la pratique recommandée par uv, qui nous propose même un alias uvx

uv tool run <COMMAND> [--from <PACKAGE>] [--with <EXTRA_DEPS>]
uvx <COMMAND> [--from <PACKAGE>] [--with <EXTRA_DEPS>]

--from sert à spécifier le package source dans le cas où son nom diffère du script exécutable, tandis que --with permet de spécifier des packages supplémentaires à inclure avec l’exécution. Par exemple, si je veux exécuter la commande dbt avec l’adapter dbt-snowflake:

uvx --from dbt-core --with dbt-snowflake dbt compile --select +my_model+

uv cheat sheet

De la théorie, beaucoup de commandes, pas beaucoup de concret pour le moment. Avant de passer à la pratique et de voir comment construire son projet de A à Z, je vous propose une fiche récapitulative pour résumer tout ça.

Je veux Je fais
Manager mon projet avec uv
Initialiser un module dans le répertoire courant uv init
Initialiser un module dans un nouveau répertoire uv init <MODULE_NAME>
Créer un module orienté bibliothèque, avec un src-layout uv init --lib
Créer un projet avec une version de Python particulière uv init --python <PYTHON_VERSION>
Créer le strict minimum uv init --bare
Gérer mes dépendances
Ajouter une dépendance uv add <PACKAGE>
Supprimer une dépendance uv remove <PACKAGE>
Ajouter une dépendance dans un groupe particulier uv add <PACKAGE> --group <GROUP>
Synchroniser mon environnement virtuel uv sync
Synchroniser mon environnement virtuel avec un groupe de dépendances uv sync --group <GROUP>
Construire mon projet
initialiser un module avec un back end défini uv init --build-backend <BACKEND>
Construire mon projet avec ce back end uv build
Déployer mon projet avec ce back end uv publish
Utiliser des scripts Python
Installer des scripts Python uv tool install <PACKAGE>
Désinstaller des scripts Python uv tool uninstall <PACKAGE>
Exécuter des scripts (potentiellement sans les installer) uvx <PACKAGE>
Exécuter un script qui se nomme différemment de son package uvx --from <PACKAGE> <SCRIPT>
Exécuter un script avec une dépendance supplémentaire uvx --with <PACKAGE> <SCRIPT>

Conclusion

Dans cette première partie, nous n’avons fait qu’effleurer les notions de packaging Python, ainsi que les fonctionnalités d’uv. Je vous recommande encore une fois d’aller consulter la documentation officielle pour l’ensemble de ses commandes et paramètres.

Pour que toutes ces notions ne restent pas abstraites, vous êtes les bienvenus dans la suite de cet article disponible prochainement, consacrée à la mise en place d’un environnement de développement avec uv. Nous y verrons comment manager ses différents modules, ainsi que tout l’écosystème gravitant autour, notamment la configuration d’un IDE et les différents moyens de déploiement.

Ressources

https://astral.sh

https://docs.astral.sh/uv/getting-started/installation

https://docs.astral.sh/uv/concepts/tools

https://github.com/astral-sh/uv

https://github.com/pypa/hatch

https://alpopkes.com/posts/python/packaging_tools

https://pythonwheels.com

https://peps.python.org/pep-0517

https://peps.python.org/pep-0621

https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout

https://softwaremill.com/what-to-do-with-your-end-of-life-akka