Le PEP8 et au delà, par la pratique


Je lis régulièrement des commentaires de batailles d’opinions sur le PEP8. Pas mal sont en fait dues à un manque de compréhension de son application. Je vais donc vous montrer des codes et les transformer pour qu’ils soient plus propres.

Rappelez-vous toujours que des recommandations stylistiques sont essentiellement arbitraires. Elles servent à avoir une base commune, et il n’y en pas de meilleures.

On recommande les espaces au lieu des tabs. Les tabs sont sémantiquement plus adaptés et plus flexibles. Les espaces permettent d’avoir un seul type de caractères non imprimables dans son code et autorise un alignement fin d’une expression divisée en plusieurs lignes. Il n’y a pas de meilleur choix. Juste un choix tranché pour passer à autre chose de plus productif, comme coder.

On recommande par contre une limite de 80 caractères pour les lignes. Cela permet à l’œil, qui scanne par micro-sauts, de parser plus facilement le code. Mais aussi de faciliter le multi-fenêtrage. Néanmoins cette limite peut être brisée ponctuellement si le coût pour la lisibilité du code est trop important. Tout est une question d’équilibre.

Ready ? Oh yeah !

L’espacement

def  foo (bar = 'test'):
   if bar=='test' : # this is a test
      bar={1:2 , 3 : 4*4}

Devient:

def foo(bar='test'):
    if bar == 'test':  # this is a test
        bar = {1: 2, 3: 4*4}

On ne double pas les espaces. On n’a pas d’espace avant le ‘:’ ou le ‘,’ mais un après. Les opérateurs sont entourés d’espaces, sauf le ‘=’ quand il est utilisé dans la signature de la fonction ou pour les opérations mathématiques (pour ces dernières, les deux sont tolérés).

Un commentaire inline est précédé de 2 espaces, une indentation est 4 espaces.

Les sauts de lignes aussi sont importants:

import stuff
 
def foo():
    pass
 
def bar():
    pass

Devient:

import stuff
 
 
def foo():
    pass
 
 
def bar():
    pass

Les déclarations à la racine du fichier sont séparées de 2 lignes. Pas plus, pas moins.

Mais à l’intérieur d’une classe, on sépare les méthodes d’une seule ligne.

class Foo:
 
 
    def bar1():
        pass
 
 
    def bar2():
        pass

Devient:

class Foo:
 
    def bar1():
        pass
 
    def bar2():
        pass

Tout cela contribue à donner un rythme régulier et familier au code. Le cerveau humain adore les motifs, et une fois qu’il est ancré, il est facile à reconnaître et demande peu d’effort à traiter.

Les espaces servent aussi à grouper les choses qui sont liées, et à séparer les choses qui ne le sont pas.

Le style de saut de ligne à l’intérieur d’une fonction ou méthode est libre, mais évitez de sauter deux lignes d’un coup.

Le nom des variables

Si on s’en tient au PEP8, on fait du snake case:

def pasUnTrucCommeCa():
    niCommeCa = True
 
def mais_un_truc_comme_ca():
    ou_comme_ca = True

Sauf pour les classes:

class UnTrucCommeCaEstBon:

Et si on a un acronyme:

def lower_case_lol_ptdr():
    ...
 
class UpperCaseLOLPTDR:
    ...

Malgré la possibilité d’utiliser des caractères non ASCII dans les noms en Python 3, ce n’est pas recommandé. Même si c’est tentant de faire:

>>> Σ = lambda *x: sum(x)
>>> Σ(1, 2, 3)
6

Néanmoins c’est l’arbre qui cache la forêt. Il y a plus important : donner un contexte à son code.

Si on a:

numbers = (random.randint(100) for _ in range(100))
group = lambda x: sum(map(int, str(x)))
numbers = (math.sqrt(x) for x in numbers if group(x) == 9)

Le contexte du code donne peu d’informations et il faut lire toutes les instructions pour bien comprendre ce qui se passe. On peut faire mieux:

def digits_sum(number):
    """ Take a number xyz and return x + y + z """
    return sum(map(int, str(number)))
 
rand_sample = (random.randint(100) for _ in range(100))
sqrt_sample = (math.sqrt(x) for x in rand_sample if digits_sum(x) == 9)

Ici, en utilisant un meilleur nommage et en rajoutant du contexte, on rend son code bien plus facile à lire, même si il est plus long.

Le PEP8 n’est donc que le début. Un bon code est un code qui s’auto-documente.

Notez que certains variables sont longues, et d’autres n’ont qu’une seule lettre. C’est parce qu’il existe une sorte de convention informelle sur certains noms dans la communauté :

i et j sont utilisés dans pour tout ce qui est incrément:

for i, stuff in enumerate(foo):

x, y et z sont utilisés pour contenir les éléments des boucles:

for x in foo:
    for y in bar:

_ est utilisé soit comme alias de gettext:

from gettext import gettext as _

Soit comme variable inutilisée:

(random.randint(100) for _ in range(100))

_ a aussi un sens particulier dans le shell Python : elle contient la dernière chose affichée automatiquement.

f pour un fichier dans un bloc with:

with open(stuff) as f:

Si vous avez deux fichiers, nommez les:

with open(foo) as foo_file, open(bar) as bar_file:

*args et **kwargs pour toute collection d’arguments hétérogènes :

def foo(a, b, **kwarg):

Mais attention, si les arguments sont homogènes, on les nomme:

def merge_files(*paths):

Et bien entendu self pour l’instance en cours, ou cls pour la classe en cours:

class Foo:
 
    def bar(self):
        ...
 
    @classmethod
    def barbar(cls):
        ...

Dans tous les cas, évitez les noms qui sont utilisés par les built-ins :

>>> dir(__builtins__)
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EnvironmentError', 'Exception', 'False', 'FileExistsError', 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'NameError', 'None', 'NotADirectoryError', 'NotImplemented', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'SystemExit', 'TabError', 'TimeoutError', 'True', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'ZeroDivisionError', '_', '__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

Les erreurs les plus communes : list, dict, min, max, next, file, id, input et str.

La longueur des lignes

C’est le point le plus sujet à polémique. Quelques astuces pour se faciliter la vie.

L’indentation est votre amie.

dico = {0: None, 1: None, 2: None, 3: None, 4: None, 5: None, 6: None, 7: None, 8: None, 9: None}
tpl = ('jaune', 'bleu', 'rouge', 'noir', 'octarine')
res = arcpy.FeatureClassToGeodatabase_conversion(['file1.shp', 'file2.shp' ], '/path/to/file.gdb')

Devient:

dico {
    0: None,
    1: None,
    2: None,
    3: None,
    4: None,
    5: None,
    6: None,
    7: None,
    8: None,
    9: None
}
 
tpl = (
    'jaune',
    'bleu',
    'rouge',
    'noir',
    'octarine'
)
 
res = arcpy.FeatureClassToGeodatabase_conversion([
        'file1.shp',
        'file2.shp'
    ],
    '/path/to/file.gdb'
)

Les parenthèses permettent des choses merveilleuses.

from module import package1, package2, package3, package4, package5, package6, package7
query = db.query(MyTableName).filter_by(MyTableName.the_column_name == the_variable, MyTableName.second_attribute > other_stuff).first())
string = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
from module import (package1, package2, package3, package4,
                    package5, package6, package7)
query = (db.query(MyTableName)
           .filter_by(MyTableName.the_column_name == the_variable,
                      MyTableName.second_attribute > other_stuff)
           .first())
string = ("Lorem ipsum dolor sit amet, consectetur adipisicing elit, "
          "sed do eiusmod tempor incididunt ut labore et dolore magna "
          "aliqua. Ut enim ad minim veniam, quis nostrud exercitation "
          "ullamco laboris nisi ut aliquip ex ea commodo consequat.")

Les variables intermédiaires documentent le code:

sqrt_sample = (math.sqrt(x) for x in (random.randint(100) for _ in range(100)) if sum(map(int, str(number))) == 9)

Devient bien plus clair avec :

def digits_sum(number):
    """ Take a number xyz and return x + y + z """
    return sum(map(int, str(number)))
 
rand_sample = (random.randint(100) for _ in range(100))
sqrt_sample = (math.sqrt(x) for x in rand_sample if digits_sum(x) == 9)

return, break et continue permettent de limiter l’indentation:

def foo():
    if bar:
        rand_sample = (random.randint(100) for _ in range(100))
        return (math.sqrt(x) for x in rand_sample if digits_sum(x) == 9)
 
    return None

Est plus élégant écrit ainsi:

def foo():
    if not bar:
        return None
 
    rand_sample = (random.randint(100) for _ in range(100))
    return (math.sqrt(x) for x in rand_sample if digits_sum(x) == 9)

any, all et itertools.product évident pas mal de blocs:

for x in foo:
    if barbarbarbarbarbarbar(x):
        meh()
        break 
 
for x in foo:
    for y in bar:
        meh(y, y)

Devient:

is_bar = (barbarbarbarbarbarbar(x) for x in foo)
if any(is_bar):
   meh()
 
import itertools
for x, y in itertools.product(foo, bar):
    meh(y, y)

Le fait est que Python est bourré d’outils très expressifs. Oui, il arrivera parfois que vous deviez briser la limite des 80 charactères. Je le fais souvent pour mettre des URLs en commentaire par exemple. Mais ce n’est pas le cas typique si vous utilisez le langage tel qu’il a été prévu.

Passer en mode pro

Le PEP8, c’est un point de départ. Quand on a un script et qu’il grandit sous la forme d’une bibliothèque, il faut plus que le reformatter.

A partir d’un certain point, on voudra coiffer son code et lui mettre un costume. Reprenons :

def digits_sum(number):
    """ Take a number xyz and return x + y + z """
    return sum(map(int, str(number)))
 
rand_sample = (random.randint(100) for _ in range(100))
sqrt_sample = (math.sqrt(x) for x in rand_sample if digits_sum(x) == 9)

On lui fait un relooking pour son entretien d’embauche :

def digits_sum(number):
    """ Take a number xyz and return x + y + z
 
        Arguments:
            number (int): the number with digits to sum.
                            It can't be a float.abs
 
        Returns:
            An int, the sum of all digits of the number.
 
        Example:
            >>> digits_sum(123)
            6
    """
    return sum(map(int, str(number)))
 
def gen_squareroot_sample(size=100, randstart=0, randstop=100, filter_on=9):
    """ Generate a sample of random numbers square root
 
        Take `size` number between `randstart` and `randstop`,
        sum it's digits. If the resulting value is equal to `filter_on`,
        yield it.
 
        Arguments:
            size (int): the size of the pool to draw the numbers from
            randstart (int): the lower boundary to generate a number
            randstop (int): the upper boundary to generate a number
            filter_on (int): the value to compare to the digits_sum
 
        Returns:
            Generator[float]
 
        Example:
            >>> list(gen_squareroot_sample(10, 0, 100, filter_on=5))
            [5.291502622129181, 6.708203932499369, 7.280109889280518]
 
    """
    for x in range(size):
        dsum = digits_sum(random.randint(randstart, randstop))
        if dsum == filter_on:
            yield math.sqrt(x)

Et dans un autre fichier :

sqrt_sample = gen_squareroot_sample()

L’important ici:

  • Le code devient modulable car on en fait des fonctions.
  • Les fonctions ont des paramètres avec des valeurs par défaut pour faciliter la vie.
  • On utilise les docstrings pour documenter le code.
  • Des noms bien choisis complètent cette documentation. Le code devient aussi plus verbeux pour laisser la place à la modularité, mais aussi la facilité d’édition.

Il ne faut pas laisser le PEP8 vous limiter à la vision de la syntaxe. La qualité du code est importante : sa capacité à être lu, compris, utilisé facilement et modifié.

Pour cette même raison, il faut travailler ses APIS.

Si vous avez :

class DataSource:
 
    def open(self):
        ...
 
    def close(self):
        ...
 
    def getTopic(self, topic):
       ...

Et que ça s’utilise comme ça:

ds = DataSource()
ds.open()
data = ds.getTopic('foo')
ds.close()

Vous pouvez raler sur le getTopic. Mais si vous vous limitez à ça, vous ratez l’essentiel : cette API n’est pas idiomatique.

Une version plus proche de la philosophie du langage serait:

class DataSource:
 
    def open(self):
        ...
 
    def close(self):
        ...
 
    def get_topic(self, topic):
       ...
 
    def __getitem__(self, index):
        return self.get_topic(index)
 
    def __enter__(self):
        self.open()
        return self
 
    def __exit__(self, *args, **kwargs):
        self.close()

Et que ça s’utilise comme ça:

with DataSource() as ds:
    data = ds['foo']

Le style d’un langage va bien au-delà des règles de la syntaxe.

Les docstrings

Je suis beaucoup plus relaxe sur le style des docstrings. Il y a pourtant bien le PEP 257 pour elles.

Simplement ne pas le respecter parfaitement n’affecte pas autant leur lisibilité car c’est du texte libre.

Quelques conseils tout de même.

  • Si elle peut tenir sur une ligne, mettez tout sur une ligne, y compris les """.
  • Utilisez """, pas '''. La majorité des gens le font, et ça fera très bizarre de mélanger.
  • Choisissez une convention de documentation et tenez-vous y. Les plus populaires sont celles de sphinx, numpy et Google. J’ai une préférence pour la dernière.
  • N’hésitez pas à la faire longue, avec un exemple. Il n’est pas rare que la docstring soit plus longue que le code qu’elle documente.
  • Une bonne docstring et une fonction gardée courte et avec une signature bien nommée a rarement besoin de commentaires.
  • Les docstests sont un enfer à maintenir.

En bonus

flake8 est un excellent linter qui vérifiera votre style de code. Il existe en ligne de commande ou en plugin pour la plupart des éditeurs.

Dans le même genre, mccabe vérifira la complexité de votre code et vous dira si vous êtes en train de fumer en vous attributant un score. Il existe aussi intégré comme plugin de flake8 et activable via une option.

Tox vous permet d’orchestrer tout ça, en plus de vos tests unittaires. Je ferai un article dessus un de ces 4.

Si vous voyez des commentaires comme # noqa ou # xxx: ignore ou # xxx: disable=YYY, ce sont des commentaires pour ponctuellement dire à ces outils de ne pas prendre en considération ces lignes.

Car souvenez-vous, ces règles sont là pour vous aider. Si à un moment précis elles cessent d’etre utiles, vous avez tous les droits de pouvoir les ignorer.

Mais ces règles communes font de Python un langage à l’écosystème exceptionnel. Elles facilitent énormément le travail en équipe, le partage et la productivité. Une fois habitué à cela, travailler dans d’autres conditions vous paraitra un poids inutile.

14 thoughts on “Le PEP8 et au delà, par la pratique

  • ashgan

    hop, un petit passage de grammar nazi:

    Les déclaratitons à la racine du fichiers sont séparés

    N’hésitez pas à la faire longue, avec un example

    Il n’est pas rare que la docstring soit plus longue que le code quelle document

  • Sam Post author

    @HarmO: ton lien est très différent, il lui manque pas mal d’infos importantes, mais il a une partie intéressante, bien que très classique, sur le code idiomatique. Pas grand chose sur l’indentation, gros manques sur le nommage des variables, code en Python 2… Mais ça vaut le coup de le lire quand même en complément.

    @Brice: toutes les vidéos de Hettinger devrait être obligatoires :)

  • Johaven

    La PEP 8 est un peu plus souple à propos des 80 caractères, il est acceptable d’utiliser 100 caractères. Les 80 caractères c’est quand même un poil barbare en 2017 avec nos beaux écrans wide :)

  • yuiio

    Y’a un truc que je pige pas entre ces deux bouts de code de l’exemple :

    for x in foo:

    if barbarbarbarbarbarbar(x):

    meh()

    Dans ce cas potentiellement meh() peut être exécutée plusieurs fois.

    is_bar = (barbarbarbarbarbarbar(x) for x in foo)

    if any(is_bar):

    meh()

    Alors que là meh() ne pourra jamais être exécuter plus d’une fois.

    Les deux codes n’ont donc pas la même logique. C’est ça ou je me trompe ?

  • Rber

    Thanks pour l’article

    @yuiio a raison, les 2 examples n’ont pas le même effet.

    Il faudrait placer un break dans le premier après l’exécution du code pour que ca concorde.

    Et pour le coté grammar nazi. Il manque un ‘c’ a ‘pontuellement’ dansle dernier paragraphe.

    Par ailleurs. Pour les linter, il y’ a pylint que j’aime notammenent pour travailler en groupes sur un projet python. il est ultra rigoureux et vérifie énormement de choses. Mais une fois un ensemble de règles et de contraintes bien définies, il simplifie la vie.

  • joseph

    Super dans l’ensemble … mais pas du tout d’accord avec la partie “La longueur des lignes” (bon tu as prévenu que c’est le point le plus sujet à polémique ;))…

    Utiliser 11 lignes pour définir dico, c’est du vice! dico = {0: None, 1: None, 2: None, 3: None, 4: None, 5: None, 6: None, 7: None, 8: None, 9: None} est bien mieux.

    Utiliser 11 lignes pour rien, c’est forcer l’utilisateur à scroller plus que de raison dans son code, … c’est aussi rendre le parsing de l’oeil difficile. Je m’explique. Si l’oeil est habitué à :

    1 ligne de code = 1 action

    tout va bien, on sait qu’en parcourant le code, on avance à peu près à ce rythme.

    Avec de telles multilines (11 lignes pour rien), on casse le rythme : le cerveau doit se préparer au fait qu’à certains endroits 1 ligne peut être super importante, … à qu’à certains autres, 1 ligne “ne pèse même pas” 1 onzième de ligne, en terme d’importance.

    Comment scroller efficacement et sereinement dans son code dans ces conditions? Impossible.

    (Ma) conclusion : Définir dico dans ton exemple ne doit pas prendre plus d’une ligne.

  • Sam Post author

    @yuiio: ouai il me manque un break.

    @joseph: avec ta solution, tu donnes plus d’importance au dico qu’à ses composants. IMO le contenu est plus important ici.

  • mothsart

    @Johaven : tu oublies les fusions de sources qui te font comparer 3 fichiers en même temps : du coup, tu passes à 240 caractères et non plus 80 donc pour moi ça reste toujours d’actualités… et puis dès que tu dépasses 80 caractères (et que ce n’est pas juste du string codé en dur ou du docstring) c’est que t’as à mon sens un soucis d’architecture. (l’exemple type : trop de boucles imbriqués)

    @Sam : dans ton exemple sur DataSource, ne serait-t-il pas judicieux de mettre un underscore devant open, close et get_topic afin de signaler que ces fonctions sont désormais privés ?

  • Sam Post author

    Elles ne sont pas privées. Tu pourrais vouloir open() en dehors d’un with pour retourner l’objet. Un return amène le with a appeler close(). Tu veux aussi garder get_topic afin d’autoriser plus tard un get_topic(name, default=None).

  • Poliorcetics

    Merci pour cet article, il m’aide beaucoup ! Le style d’écriture est très entraînant aussi. :)

  • Réchèr

    Pour le gros dictionnaire et le gros tuple, je conseille fortement d’ajouter une virgule après le dernier élément (quel que soit le nombre de ligne sur lequel vous écrivez la variable).

    tpl = (
        'jaune',
        'bleu',
        'rouge',
        'noir',
        'octarine',
    )
    

    Ça devient ensuite beaucoup plus facile de rajouter des éléments ou de les réordonner. Il suffit de prendre systématiquement l’élément et la virgule qui vient juste après, sans avoir à réfléchir si c’est le dernier ou pas.

    Python est l’un des rares langages permettant d’ajouter une virgule inutile à la fin des énumérations, autant en profiter.

    Autre exemple : le JSON ne le permet pas, et c’est assez lourdingue. Dans Sublime Text, à chaque fois que je veux déplacer un élément de “Settings – Default” vers “Settings – User”, je me fais pourrir parce que j’ai oublié d’enlever la virgule finale.

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.