Bien que Python soit aujourd’hui extrêmement populaire il y a une facette de celui-ci qui semble toujours peu maitrisée par les développeurs qui l’utilisent : le packaging de code. Hors le packaging est une étape importante lorsque l’on veut partager et réutiliser du code sans avoir à le dupliquer dans chacun de nos projets.
Nous pouvons constater qu’au cours de l’évolution du langage une multitude d’outils qui traitaient du packaging sont apparus puis sont devenus obsolètes pour certains d’entre eux. Certaines PEP (Python Enhancement Proposal) ont également apporté de nouvelles approches dans la manière de packager du code. Tout cela a pu accroître la confusion sur le sujet.
Aujourd’hui nous allons revoir les bases du packaging en ayant pour objectif de packager et ensuite publier sur PyPI (Python Package Index), le dépôt central des librairies Python, une librairie que nous avons développé.
Nous allons nous concentrer sur la partie packaging dans ce premier article.
Présentation de la librairie
Notre librairie cobaye se nomme relink et est un client vers l’API du service homonyme : Ce service permet de raccourcir des URLs pour pouvoir les insérer plus facilement dans du texte comme dans un tweet par exemple.
Voici la structure de la librairie :
Voyons maintenant le contenu du fichier client.py qui est le principal fichier de notre librairie, celui qui permet d’interagir avec l’API relink :
import logging import requests class RelinkClient: API_URL = " RELINK_URL = " def shorten_url(self, url: str) -> str: try: response = requests.post(self.API_URL, json={"url": url}) response.raise_for_status() return self.RELINK_URL + response.json()["hashid"] except Exception as error: logging.error(f"Could not shorten url, something went wrong : {error}") raise def get_full_url(self, relink_url: str) -> str: try: response = requests.get(f"{self.API_URL}{relink_url.replace(self.RELINK_URL, '')}/") response.raise_for_status() return response.json()["url"] except Exception as error: logging.error( f"Could not get back the full url of the relink url, something went wrong : {error}" ) raise
Le client est assez simple, il contient 2 méthodes qui correspondent aux 2 urls de l’API, l’une pour raccourcir une url donnée et l’autre pour faire l’opération inverse. J’ai utilisé la librairie requests pour faciliter le requêtage HTTP.
L’utilisation se fait de la manière suivante :
$ python Python 3.7.5 Type "help", "copyright", "credits" or "license" for more information. >>> from relink.client import RelinkClient >>> client = RelinkClient() >>> client.shorten_url(" ' >>> client.get_full_url(" '
Au niveau de la gestion des dépendances j’ai manuellement construit un environnement virtuel qui se trouve dans le dossier venv et j’ai fait l’installation des dépendances à la main via pip install à l’intérieur de celui-ci.
Le fichier requirements.txt suivant a été généré avec la commande pip freeze > requirements.txt
certifi==2020.4.5.1 chardet==3.0.4 idna==2.9 requests==2.23.0 requests-mock==1.8.0 six==1.14.0 urllib3==1.25.9
Il contient donc requests et ses différentes dépendances et j’ai aussi installé la librairie requests-mock pour pouvoir simuler les appels HTTP vers l’API dans les tests unitaires. Les tests unitaires quant à eux respectent la structure du framework unittest de Python.
Le fichier setup.py
Notre librairie est donc fonctionnelle sur le papier mais on ne peut pour le moment pas la packager. Pour cela il va falloir créer un nouveau fichier, le fichier setup.py. Ce fichier sera la pierre angulaire du packaging et de la distribution de notre librairie. Son rôle principal est d’appeler la fonction setup() de l’outil d’installation choisi. Mais il en existe plusieurs : distutils, setuptools, distribute ou distutils2. Lequel choisir ?
Setuptools
Choisissez setuptools. C’est l’outil qui est aujourd’hui recommandé par la Python Packaging Authority (PyPA), il possède plus de fonctionnalités (notamment celle de définir des dépendances que nous verrons par la suite) et n’est pas intégré dans la librairie standard Python, ce qui lui permet d’avoir les mêmes fonctionnalités peu importe la version du language que vous utilisez contrairement à distutils qui sera couplé à votre version de Python. Enfin vous pouvez oublier distribute et distutils2 car ce sont des projets abandonnés depuis longtemps.
Nous pouvons donc importer la méthode setup du module setuptools et l’appeler ensuite dans notre fichier setup.py :
from setuptools import setup setup()
setuptools est inclus par défaut lorsque vous créez un environnement virtuel Python avec virtualenv, vous avez cette ligne lors de la création qui vous le confirme : « Installing setuptools, pip, wheel… »
Et voyons maintenant ce qu’il se passe si nous exécutons le fichier setup.py depuis la ligne de commande :
romain at macbookromain in ~/Workspaces/Python/relink (venv) $ python setup.py -h Common commands: (see '--help-commands' for more) setup.py build will build the package underneath 'build/' setup.py install will install the package Global options: --verbose (-v) run verbosely (default) --quiet (-q) run quietly (turns verbosity off) --dry-run (-n) don't actually do anything --help (-h) show detailed help message --no-user-cfg ignore pydistutils.cfg in your home directory --command-packages list of packages that provide distutils commands
Notre simple appel à la fonction setup() a transformé notre fichier setup.py en véritable interface de ligne de commande qui va nous permettre de contrôler le packaging de notre librairie via des sous-commandes et des options.
Mais setuptools sait encore très peu de choses sur notre librairie, pour cela nous allons renseigner les principaux arguments attendus par la fonction setup :
from setuptools import setup setup(name="relink", version="0.0.1", description="A client for API", author="Romain Ardiet", author_email="romardie@publicisgroupe.net", packages=["relink", "tests"], install_requires=["requests"], extras_require={ "dev": ["requests-mock"], }, license="Apache 2.0")
La plupart des arguments sont assez explicites. Vous pouvez noter que j’ai décidé d’inclure les tests lorsque je vais packager la librairie. J’aurais pu remplacer la définition en dur des packages à intégrer par l’utilisation de la méthode find_packages() de setuptools qui se charge d’inclure automatiquement tous les modules de votre librairie, c’est utile si vous en avez beaucoup.
Gestion des dépendances avec setup.py et setuptools
Les dépendances renseignées dans le paramètre install_requires seront automatiquement installées lorsque nous installerons notre librairie depuis l’extérieur mais pas celles renseignés dans le paramètre extra_requires. Ici je me sers du paramètre extra_requires pour spécifier les dépendances nécessaires lorsque l’on développe la librairie, notamment les dépendances utilisées dans les tests unitaires. Mais elles ne sont pas nécessaires pour le bon usage de la librairie dans le cas nominal.
Vous pouvez noter que je n’ai pas fixé de version précise pour la dépendance requests contrairement à ce qu’il y avait dans mon fichier requirements.txt. C’est principalement pour éviter un éventuel conflit de versions si une application qui utilisait ma librairie dépendait elle-même d’une version précise de requests. Si vous n’êtes pas à l’aise à l’idée que votre librairie puisse être compatible avec n’importe quelle version de vos dépendances vous pouvez quand même restreindre les versions compatibles, par exemple : requests>=2.0.0,<3.
Voyons maintenant comment nous pouvons installer nos dépendances en local en ne passant plus par le fichier requirements.txt mais avec setup.py. Pour cela il faut lancer la commande suivante : pip install -e « .[dev] »
Cette commande peut paraître un peu obscure donc essayons d’y voir un peu plus clair : -e est un raccourci de l’option –editable, le « . » fait référence au dossier actuel de notre librairie, et [dev] fait référence à la clé dev déclarée dans le paramètre extra_requires. Mis bout à bout, cette commande indique que nous voulons pouvoir éditer notre librairie avec toutes les dépendances nécessaires, celles déclarées dans le paramètre install_requires et aussi celles déclarées dans la clé dev du paramètre extra_require.
La commande pip install -e . installe quant à elle les dépendances renseignées dans les install_requires mais pas les extra_require.
Lancement des tests avec setup.py et setuptools
Avant la création du fichier setup.py nous pouvions lancer les tests de notre librairie en invoquant la commande discover du module unittest de Python :
$ python -m unittest discover --verbose test_get_full_url (tests.test_client.RelinkClientTestCase) ... ok test_shorten_url (tests.test_client.RelinkClientTestCase) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.009s OK
Avec le fichier setup.py nous pouvons maintenant simplifier le lancement en invoquant la commande test de celui-ci, voyons ce qu’il en résulte :
$ python setup.py test running test WARNING: Testing via this command is deprecated and will be removed in a future version. Users looking for a generic test entry point independent of test runner are encouraged to use tox. running egg_info writing relink.egg-info/PKG-INFO writing dependency_links to relink.egg-info/dependency_links.txt writing requirements to relink.egg-info/requires.txt writing top-level names to relink.egg-info/top_level.txt reading manifest file 'relink.egg-info/SOURCES.txt' writing manifest file 'relink.egg-info/SOURCES.txt' running build_ext test_get_full_url (tests.test_client.RelinkClientTestCase) ... ok test_shorten_url (tests.test_client.RelinkClientTestCase) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.010s OK
Nous pouvons voir que les tests sont bien exécutés mais nous avons un warning nous indiquant que cette commande va bientôt être supprimée. Cela peut paraitre surprenant mais cela résulte du fait que la commande test est très basique et ne vérifie pas par exemple que l’environnement Python dans lequel vont se lancer les tests est conforme et contient bien les dépendances attendues du projet.
C’est pour cela que les développeurs de setuptools préconisent d’utiliser un outil plus robuste comme tox qui pourra automatiser à la fois la création de l’environnement virtuel, l’installation des dépendances ainsi que le lancement des tests. C’est cet outil que je vous recommanderais d’utiliser notamment dans un environnement d’intégration continue comme Jenkins ou Gitlab. En revanche en local il s’avère que la commande python setup.py test est bien utile car elle est beaucoup plus rapide à s’exécuter que tox et aussi plus rapide à écrire que la commande discover de unittest.
Création d’une distribution source
Passons maintenant au packaging à proprement parler de relink, la première commande que nous pouvons lancer est python setup.py sdist (sdist = source distribution). Ceci va construire une distribution source au format .tar.gz dans un nouveau dossier dist : si l’on extrait l’archive avec la commande tar -xvf relink-0.0.1.tar.gz on retrouve nos sources ainsi que le fichier setup.py :
Une chose importante à noter ici est qu’une distribution source est considérée comme non-construite, c’est à dire que lorsque pip installera notre librairie au format tar.gz il exécutera le fichier setup.py afin d’extraire les métadonnées et finaliser l’installation.
Si par exemple notre librairie contenait une extension C notre archive .tar.gz ne contiendrait pas la version compilée de celle-ci au format .dll ou .so et nécessiterait sa compilation au moment de l’installation par pip. C’est pourquoi les librairies Python qui font usage d’extensions C comme numpy par exemple utilisent un autre format de distribution, le format wheel.
Création d’une distribution construite au format wheel
Le format wheel est apparu en 2012 avec la PEP 427, c’est un format d’installation qui est déjà construit et qui ne nécessitera donc pas de processus de build au moment de l’installation via pip. Il vient remplacer le vénérable format egg. Comme lui wheel est une archive au format ZIP. Mais contrairement à une archive egg on ne peut pas directement importer une archive wheel depuis du code Python. Le format wheel a été pensé comme un format d’installation à part entière et donc d’être en combinaison avec pip pour plus de standardisation dans les pratiques de distribution de code.
De plus le format wheel n’inclut pas le bytecode Python (les fichiers *.pyc) ce qui fait qu’une même archive wheel peut-être distribuée à plusieurs versions de Python différentes, ce qui n’était pas le cas avec le format egg.
Pour construire notre librairie au format wheel, nous lançons la commande suivante : python setup.py bdist_wheel (bdist = built distribution). Cela génère un nouveau fichier dans le dossier dist : relink-0.0.1-py3-none-any.whl. Ce fichier peut-être décompressé avec la commande unzip car wheel est un format ZIP : unzip relink-0.0.1-py3-none-any.whl -d relink-0.0.1/. Regardons ce qu’il y a à l’intérieur :
La grosse différence avec la distribution source faite précédemment est que le fichier setup.py n’est pas inclus dans l’archive, ce qui nous confirme que pip n’en a pas besoin lorsqu’il installe une archive wheel.
L’autre différence est la présence du dossier .dist-info/ alors que c’était un dossier .egg-info/ qui avait été généré dans la distribution source, .dist-info/ respecte une spécification décrite au travers de la PEP 376 et supporte mieux le processus de désinstallation du package lorsque celui-ci est installé dans un environnement virtuel par exemple.
Les différents types de wheel : pur python, universelle, plateforme
Une chose que nous pouvons aussi remarquer est le nom de l’archive wheel généré un peu étrange : relink-0.0.1-py3-none-any.whl
- py3 indique que la librairie est compatible avec l’ensemble des versions 3 de Python, nous aurions pu passer l’option –universal au moment de construire l’archive wheel pour qu’elle soit également compatible avec Python 2 (même si Python 2 est officiellement en fin de vie depuis Janvier 2020 !). On parle dans ce cas d’une wheel universelle.
- none indique que notre librairie ne dépend pas d’une version spécifique de l’API C de Python.
- any signifie que notre wheel est compatible avec n’importe quelle plateforme telle que Windows, MacOS ou Linux.
- .whl est le nom d’extension d’une archive wheel.
Cela peut vous sembler inutile de détailler tout cela sachant que notre librairie relink contient seulement du code Python mais rappelez-vous que le format wheel est un format déjà construit, donc pour les librairies qui utilisent des extensions C celles-ci doivent êtres déjà compilées à l’intérieur du wheel.
Ces extensions compilées étant le plus souvent spécifiques à une plateforme (.so, .dll, etc), il s’avère nécessaire de créer plusieurs archives wheel pour chacune des plateformes. Regardons par exemple quelques unes des wheels fournies par la libraire numpy sur PyPI :
On parle de wheel plateforme dans ce cas là.
pip téléchargera et installera automatiquement l’archive wheel correspondante à votre plateforme et à votre version de Python lorsque vous installez la librairie. Par exemple dans mon cas je suis sur mac et sur Python 3.7 :
$ pip install numpy Collecting numpy Downloading numpy-1.18.4-cp37-cp37m-macosx_10_9_x86_64.whl (15.1 MB) |███████████████▌ | 7.3 MB 1.9 MB/s eta 0:00:05
Conclusion
Nous venons de voir les principaux outils pour pouvoir packager du code Python : le fichier setup.py et le module setuptools.
Nous avons également vu les deux types de distributions qu’il était possible de construire : une distribution source au format tar.gz et une distribution construite au format wheel. Je vous recommande de toujours générer les deux même si les dernières versions de pip préfèreront installer l’archive wheel pour plus de rapidité car elle ne nécessite pas d’étape de build.
Dans un prochain article nous verrons la prochaine étape qui sera de déployer notre librairie sur PyPI afin de la mettre à disposition de l’ensemble de la communauté Python.