Icône réalisé par Smashicons et tiré de www.flaticon.com soumis à la licence CC 3.0 BY.
Derrière ce titre pompeux se cachait une problématique que j’ai soulevée lorsque je me suis mis à devenir maniaque du taux de couverture de code dans mes projets. Et il y a encore du boulot !
En Python, on a deux versions majeures différentes bien connues : Python 2 et Python 3, lesquelles ont chacune leurs spécificités. D’autant plus que Python 3 a apporté pléthore de breaking changes, c’est-à-dire des changements qui cassent la compatibilité de Python 2 à Python 3 et qui, du temps où Python 3 sortait, devait imposer aux développeurs de bibliothèques tierces d’adapter leur code pour les deux versions majeures de Python.
(Ca va, j’explique pas trop mal ? Qu’il est loin le temps où j’étais épris pour la rédaction de contenu (exotique ?) sur Zeste de Savoir bien aimé…)
J’adore les billets, on peut vraiment écrire dans un style informel et s’éloigner du suj… Bref !
Aujourd’hui, quand on veut développer une application python, pour peu qu’on s’y connaisse, on ne réfléchit pas : on fonce vers la dernière version stable de Python - la 3.6.3 à l’heure où j’écris ces lignes - quitte à utiliser des concepts sexy. Vous ne reprendriez pas un peu de magie noire, tant qu’à faire ?
Sauf que pour développer une bibliothèque tierce accessible au plus grand nombre… Il faudrait supporter Python 2. Et c’est là que ça se corse ! Heureusement, le module six pallie ce problème.
Démonstration :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | ge0@musashi /tmp » mkvirtualenv sixpackage 1 ↵ Using base prefix '/usr' New python executable in /home/ge0/envs/sixpackage/bin/python3 Also creating executable in /home/ge0/envs/sixpackage/bin/python Installing setuptools, pip, wheel...done. virtualenvwrapper.user_scripts creating /home/ge0/envs/sixpackage/bin/predeactivate virtualenvwrapper.user_scripts creating /home/ge0/envs/sixpackage/bin/postdeactivate virtualenvwrapper.user_scripts creating /home/ge0/envs/sixpackage/bin/preactivate virtualenvwrapper.user_scripts creating /home/ge0/envs/sixpackage/bin/postactivate virtualenvwrapper.user_scripts creating /home/ge0/envs/sixpackage/bin/get_env_details (sixpackage) ge0@musashi /tmp » pip install six Collecting six Using cached six-1.11.0-py2.py3-none-any.whl Installing collected packages: six Successfully installed six-1.11.0 (sixpackage) ge0@musashi /tmp » cat test.py import six if six.PY2: print(u"Python 2!") else: print("Python 3!") # Naturally unicode (sixpackage) ge0@musashi /tmp » python2 test.py Python 2! (sixpackage) ge0@musashi /tmp » python3 test.py Python 3! |
On a un code on ne peut plus débile qui écrit une chaîne unicode à l’écran et adopte un traitement différent selon les versions de python. En réalité, c’est naze, l’exemple n’a que peu d’intérêt. J’aurais pu vous montrer un snippet Django qui exige des chaînes unicodes lorsqu’il est question de les représenter, mais ça serait allumer une bougie au lance-flamme.
BREF ! On note qu’en Python 3, toutes les chaînes sont nativement en unicode (d’où l’absence de préfixe u
qui est obligatoire en Python 2).
Problème : comment obtenir un rapport de coverage à 100% quand on teste séparément pour python 2 et python 3 ? Dans le premier cas, l’instruction print("Python 3!")
ne sera pas couverte. Dans l’autre cas, c’est l’instruction print(u"Python 2!")
qui ne le sera pas.
Démonstration :
sixpackage/utils.py
1 2 3 4 5 6 7 8 9 10 11 | """Just a dump script that exports a function expected to run for both Python 2.x and 3.x """ import six def my_universal_function(): if six.PY2: return u'This is a unicode string' else: return 'This is a unicode string' |
tests/test_sixpackage.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import six import pytest from sixpackage.util import my_universal_function @pytest.mark.skipif(not six.PY2, reason="Requires python2") def test_sixpackage_my_universal_function_py2(): assert my_universal_function() == u'This is a unicode string' @pytest.mark.skipif(not six.PY3, reason="Requires python3") def test_sixpackage_my_universal_function_py3(): assert my_universal_function() == 'This is a unicode string' |
tox.ini
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | [tox] envlist = py27,py36 [testenv] deps = pytest pytest-cov -r{toxinidir}/requirements.txt envdir = {toxworkdir}/testenv setenv = PYTHONPATH = {toxinidir} commands = pytest -v --cov=sixpackage \ {posargs:{toxinidir}/tests} --cov-report xml:{envdir}/coverage.xml \ --cov-report term [testenv:flake8] deps = {[testenv]deps} envdir = {toxworkdir}/flake8 commands = flake8 {posargs:{toxinidir}/sixpackage {toxinidir}/tests} [testenv:isort] deps = {[testenv]deps} envdir = {toxworkdir}/isort commands = isort -rc --check-only --verbose \ {posargs:{toxinidir}/sixpackage {toxinidir}/tests} |
On teste nos deux versions de python :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | (sixpackage) ge0@musashi ~/git/sixpackage ±master⚡ » tox GLOB sdist-make: /home/ge0/git/sixpackage/setup.py py27 recreate: /home/ge0/git/sixpackage/.tox/testenv py27 installdeps: pytest, pytest-cov, -r/home/ge0/git/sixpackage/requirements.txt py27 inst: /home/ge0/git/sixpackage/.tox/dist/sixpackage-0.0.1.zip py27 installed: coverage==4.4.1,py==1.4.34,pytest==3.2.3,pytest-cov==2.5.1,six==1.11.0,sixpackage==0.0.1 py27 runtests: PYTHONHASHSEED='3199303125' py27 runtests: commands[0] | pytest -v --cov=sixpackage /home/ge0/git/sixpackage/tests --cov-report xml:/home/ge0/git/sixpackage/.tox/testenv/coverage.xml --cov-report term ============================= test session starts ============================== platform linux2 -- Python 2.7.14, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /home/ge0/git/sixpackage/.tox/testenv/bin/python2.7 cachedir: .cache rootdir: /home/ge0/git/sixpackage, inifile: plugins: cov-2.5.1 collected 2 items tests/test_sixpackage.py::test_sixpackage_my_universal_function_py2 PASSED tests/test_sixpackage.py::test_sixpackage_my_universal_function_py3 SKIPPED ---------- coverage: platform linux2, python 2.7.14-final-0 ---------- Name Stmts Miss Cover -------------------------------------------- sixpackage/__init__.py 0 0 100% sixpackage/util.py 5 1 80% -------------------------------------------- TOTAL 5 1 80% Coverage XML written to file /home/ge0/git/sixpackage/.tox/testenv/coverage.xml ===================== 1 passed, 1 skipped in 0.02 seconds ====================== py36 recreate: /home/ge0/git/sixpackage/.tox/testenv py36 installdeps: pytest, pytest-cov, -r/home/ge0/git/sixpackage/requirements.txt py36 inst: /home/ge0/git/sixpackage/.tox/dist/sixpackage-0.0.1.zip py36 installed: coverage==4.4.1,py==1.4.34,pytest==3.2.3,pytest-cov==2.5.1,six==1.11.0,sixpackage==0.0.1 py36 runtests: PYTHONHASHSEED='3199303125' py36 runtests: commands[0] | pytest -v --cov=sixpackage /home/ge0/git/sixpackage/tests --cov-report xml:/home/ge0/git/sixpackage/.tox/testenv/coverage.xml --cov-report term ============================= test session starts ============================== platform linux -- Python 3.6.2, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /home/ge0/git/sixpackage/.tox/testenv/bin/python3.6 cachedir: .cache rootdir: /home/ge0/git/sixpackage, inifile: plugins: cov-2.5.1 collected 2 items tests/test_sixpackage.py::test_sixpackage_my_universal_function_py2 SKIPPED tests/test_sixpackage.py::test_sixpackage_my_universal_function_py3 PASSED ----------- coverage: platform linux, python 3.6.2-final-0 ----------- Name Stmts Miss Cover -------------------------------------------- sixpackage/__init__.py 0 0 100% sixpackage/util.py 5 1 80% -------------------------------------------- TOTAL 5 1 80% Coverage XML written to file /home/ge0/git/sixpackage/.tox/testenv/coverage.xml ===================== 1 passed, 1 skipped in 0.03 seconds ====================== ___________________________________ summary ____________________________________ py27: commands succeeded py36: commands succeeded congratulations :) |
Dans les deux cas, on obtient 80% et il manque une ligne. Pourtant on couvre tous les cas mais on aimerait un rapport qui indique 100% de couverture. En plus, on génère deux coverage et le plus récent a écrasé l’ancien…
Solution : ajouter --cov-append
à la commande pytest
. Comme ça on se sert des infos de coverage précédentes et on complète la couverture !
Notre nouveau tox.ini :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | [tox] envlist = py27,py36 [testenv] deps = pytest pytest-cov -r{toxinidir}/requirements.txt envdir = {toxworkdir}/testenv setenv = PYTHONPATH = {toxinidir} commands = pytest -v --cov=sixpackage --cov-append \ {posargs:{toxinidir}/tests} --cov-report xml:{envdir}/coverage.xml \ --cov-report term [testenv:flake8] deps = {[testenv]deps} envdir = {toxworkdir}/flake8 commands = flake8 {posargs:{toxinidir}/sixpackage {toxinidir}/tests} [testenv:isort] deps = {[testenv]deps} envdir = {toxworkdir}/isort commands = isort -rc --check-only --verbose \ {posargs:{toxinidir}/sixpackage {toxinidir}/tests} |
Résultat :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | (sixpackage) ge0@musashi ~/git/sixpackage ±master⚡ » tox GLOB sdist-make: /home/ge0/git/sixpackage/setup.py py27 recreate: /home/ge0/git/sixpackage/.tox/testenv py27 installdeps: pytest, pytest-cov, -r/home/ge0/git/sixpackage/requirements.txt py27 inst: /home/ge0/git/sixpackage/.tox/dist/sixpackage-0.0.1.zip py27 installed: coverage==4.4.1,py==1.4.34,pytest==3.2.3,pytest-cov==2.5.1,six==1.11.0,sixpackage==0.0.1 py27 runtests: PYTHONHASHSEED='3888791053' py27 runtests: commands[0] | pytest -v --cov=sixpackage --cov-append /home/ge0/git/sixpackage/tests --cov-report xml:/home/ge0/git/sixpackage/.tox/testenv/coverage.xml --cov-report term ============================= test session starts ============================== platform linux2 -- Python 2.7.14, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /home/ge0/git/sixpackage/.tox/testenv/bin/python2.7 cachedir: .cache rootdir: /home/ge0/git/sixpackage, inifile: plugins: cov-2.5.1 collected 2 items tests/test_sixpackage.py::test_sixpackage_my_universal_function_py2 PASSED tests/test_sixpackage.py::test_sixpackage_my_universal_function_py3 SKIPPED ---------- coverage: platform linux2, python 2.7.14-final-0 ---------- Name Stmts Miss Cover -------------------------------------------- sixpackage/__init__.py 0 0 100% sixpackage/util.py 5 1 80% -------------------------------------------- TOTAL 5 1 80% Coverage XML written to file /home/ge0/git/sixpackage/.tox/testenv/coverage.xml ===================== 1 passed, 1 skipped in 0.02 seconds ====================== py36 recreate: /home/ge0/git/sixpackage/.tox/testenv py36 installdeps: pytest, pytest-cov, -r/home/ge0/git/sixpackage/requirements.txt py36 inst: /home/ge0/git/sixpackage/.tox/dist/sixpackage-0.0.1.zip py36 installed: coverage==4.4.1,py==1.4.34,pytest==3.2.3,pytest-cov==2.5.1,six==1.11.0,sixpackage==0.0.1 py36 runtests: PYTHONHASHSEED='3888791053' py36 runtests: commands[0] | pytest -v --cov=sixpackage --cov-append /home/ge0/git/sixpackage/tests --cov-report xml:/home/ge0/git/sixpackage/.tox/testenv/coverage.xml --cov-report term ============================= test session starts ============================== platform linux -- Python 3.6.2, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 -- /home/ge0/git/sixpackage/.tox/testenv/bin/python3.6 cachedir: .cache rootdir: /home/ge0/git/sixpackage, inifile: plugins: cov-2.5.1 collected 2 items tests/test_sixpackage.py::test_sixpackage_my_universal_function_py2 SKIPPED tests/test_sixpackage.py::test_sixpackage_my_universal_function_py3 PASSED ----------- coverage: platform linux, python 3.6.2-final-0 ----------- Name Stmts Miss Cover -------------------------------------------- sixpackage/__init__.py 0 0 100% sixpackage/util.py 5 0 100% -------------------------------------------- TOTAL 5 0 100% Coverage XML written to file /home/ge0/git/sixpackage/.tox/testenv/coverage.xml ===================== 1 passed, 1 skipped in 0.03 seconds ====================== ___________________________________ summary ____________________________________ py27: commands succeeded py36: commands succeeded congratulations :) |
On a un rapport de coverage à 100% qu’on peut envoyer à un service tiers, comme codecov par exemple !
Sur un service d’intégration continue, le problème ne se posera pas, mais avec ma configuration actuelle, il me faut supprimer le fichier .coverage
créé à la racine de mon projet lorsque j’utilise --cov-append
! Autrement, mon rapport continuera d’être utilisé et ce n’est pas l’effet souhaité !
Et comme je suis sympa, je vous laisse un proof-of-concept par là : https://github.com/Ge0/sixpackage
Si vous avez une solution moins casse gueule, je suis preneur !
Bonne nuit !