Arrête de tirer la couverture !

Ou comment concilier plusieurs versions de python et aboutir à une couverture de code optimale

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 !



3 commentaires

Petite question peut-être naïve, lors d’un nouveau développement, est-il vraiment pertinent d’aller supporter python2 ?

Eskimon

J’y ai répondu dans le billet : dans le cadre d’une bibliothèque que tu veux distribuer au plus grand nombre, ça a un intérêt, notamment lorsqu’une application "legacy" (Edit: en python 2.x, j’entends) souhaite l’utiliser pour une raison tierce.

+2 -0

Cela dit, Python 2 commence à se tasser vraiment avec sa fin de vie officielle prévue pour 2020. Il y a plusieurs grosses bibliothèques tierces qui prévoient de lâcher le support d’ici à 2020. Ce sont en plus des bibliothèques scientifiques, et pourtant c’est un monde avec une inertie technique assez importante.

À moins d’un cas vraiment particulier comme tu le dis d’une bibliothèque développée en annexe d’un projet Python 2, l’intérêt est plutôt limité…

C’est tout de même un billet sympathique, ça fait plaisir de te revoir écrire ici ! :)

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte