PROJET AUTOBLOG


Sam & Max: Python, Django, Git et du cul

Site original : Sam & Max: Python, Django, Git et du cul

⇐ retour index

Compter et grouper : encore plus fainéant 5

mercredi 1 juillet 2015 à 21:37

Après avoir bien galéré à créer un compteur à la main avec un dico, vous avez découvert les joies des méthodes dict.get et dict.setdefault. Puis évidemment quelqu’un vous a pointé vers collections.defaultdict, et enfin, vous avez fini par découvrir collections.Counter. Joie.

Le parcours est à peu près toujours le même quand on veut grouper ou compter des valeurs en Python.

Malgré cela, je vois encore des gens qui sous utilisent ces collections. Par exemple, Counter peut compter automatiquement :

>>> from collections import Counter
>>> Counter('jfsqmfjdklmqfjsdqklmfjdsqhfdqsjkhfdshjkl')
    Counter({'j': 6, 'f': 6, 'q': 5, 's': 5, 'd': 5, 'k': 4, 'l': 3, 'm': 3, 'h': 3})

Mais ce que ne réalisent pas beaucoup de développeurs, c’est que cet objet accepte n’importe quel itérable en paramètre. Nous sommes en Python, et rededjiou, je me tue à répéter que l’itération est la philosophie centrale du langage.

Donc le compteur peut prendre une expression génératrice en paramètre.

Par exemple, si vous voulez compter un truc un peu plus complexe que des éléments, comme mettons, le ratio de lignes commentées dans un fichier, vous n’avez pas besoin de faire ça :

count = Counter()
for line in open('/etc/fstab', encoding='ascii'):
        count[line.startswith('#')] += 1
 # out : Counter({True: 10, False: 3})

Ceci marchera parfaitement :

count = Counter(line.startswith('#') for line in open('/etc/fstab', encoding='ascii'))
# out : Counter({True: 10, False: 3})

Vous pouvez également utiliser des générateurs plus complexes. Combien de fichiers par types d’extensions ?

import os
import pathlib
 
def get_extensions(path):
    for dirpath, dirnames, files in os.walk(path):
        for name in files:
            ext = pathlib.Path(name).suffix
            if ext: # on ignore les fichiers sans extension
                yield ext
 
 
Counter(get_extensions('/etc')).most_common(9)
 # Out : 
 # ('.conf', 632),
 # ('.0', 348),
 # ('.gz', 323),
 # ('.jhansonxi', 207),
 # ('.pem', 177),
 # ('.load', 127),
 # ('.ttb', 86),
 # ('.ktb', 80),
 # ('.kti', 55)]

Notez que le Counter peut faire plus que compter. Ici il nous donne les 9 plus grandes valeurs du classement, mais en prime, il peut aussi nous faire des opérations ensemblistes :

>>> c = Counter("aabbbbbbbbbbbbcccc")
>>> c & Counter('aaaaaaaaaaaaaaabbcddddddd') # valeurs min
    Counter({'b': 2, 'a': 2, 'c': 1})
>>> c | Counter('aaaaaaaaaaaaaaabbcddddddd') # valeurs max
    Counter({'a': 15, 'b': 12, 'd': 7, 'c': 4})

Le compteur fournit par Python est donc naturellement très, très puissant.

Une autre chose qui est rarement faite : sous-classer ces types.

Par exemple, si vous avez souvent des opérations où il faut grouper des valeurs :

from collections import defaultdict
 
class Grouper(defaultdict):
 
    def __init__(self, iterable):
        super(Grouper, self).__init__(list)
        self.update(iterable)
 
    def update(self, iterable):
        try:
            iterable = iterable.items()
        except AttributeError:
            iterable = iterable
        for k, v in iterable:
            self[k].append(v)

On prend un default dict, on lui dit qu’un update ajoute les éléments à la liste en valeur plutôt que de la remplacer, et zou, vous avez un dictionnaire qui va grouper toutes les valeurs automatiquement.

Liste des fichiers par extensions ? Fastoche !

def get_extensions(path):
    for dirpath, dirnames, files in os.walk(path):
        for name in files:
            ext = pathlib.Path(name).suffix
            if ext: 
                yield ext, name # on rajoute le name ici
 
>>>files = Grouper(get_extensions('/etc'))
>>> files['.tti']
['en-na-ascii.tti',
 'numbers-french.tti',
 'devanagari.tti',
 'letters-cyrillic.tti',
 'punctuation-basic.tti',
 'malayalam.tti',
 'ascii-basic.tti',
 'spaces.tti',
 'letters-latin.tti',
 'letters-latin-dot8.tti',
 'en-chess.tti',
 'numbers-dot8.tti',
 'punctuation-tibetan.tti',
 'boxes.tti',
 'gujarati.tti',
 'numbers-nemeth.tti',
 'punctuation-alternate.tti',
 'common.tti',
 'blocks.tti',
 'gurmukhi.tti',
 'kannada.tti',
 'telugu.tti',
 'tamil.tti',
 'numbers-dot6.tti',
 'de-chess.tti',
 'control-latin.tti',
 'letters-tibetan.tti',
 'oriya.tti',
 'bengali.tti']

Bref, compter et grouper sont des opérations si communes : ne vous faites par chier à refaire tout ça à la main.

Quelques astuces à propos de and et or 19

mardi 30 juin 2015 à 15:46

Dans beaucoup de langages populaires, and et or sont écrits && et ||. Ces symboles existent en Python, mais ils sont là pour appliquer des opérations binaires :

>>> bin(0b010 & 0b111)
'0b10'
 
>>> bin(0b010 | 0b111)
'0b111'

Ce n’est néanmoins pas la seule bizarrerie de Python dans le domaine.

Shortcuts

Les opérateurs and et or court-circuitent les conditions dès que possible, c’est à dire qu’ils retournent la valeur au plus tôt, même si ça signifie ne pas exécuter tout le code.

Par exemple, prenons deux fonctions:

def vrai():
    print('Yeah !')
    return True
 
def faux():
    print('Errrr...')
    return False

Si je fais un or dessus, ça va me retourner True, et afficher deux messages :

>>> faux() or vrai()
Errrr...
Yeah !
True

Mais si j’INVERSE les deux fonctions, alors je n’aurais qu’un seul message qui va s’afficher :

>>> vrai() or faux()
Yeah !
True

La raison est que or sait qu’il peut retourner True dès qu’il obtient au moins une valeur True. vrai() retourne True, donc or sait que tout la condition sera forcément vraie, et il n’exécute pas le code du reste de la condition. Ainsi, faux() n’est jamais appelée.

and fait pareil :

>>> vrai() and faux()
Yeah !
Errrr...
False

Et à l’envers :

>>> faux() and vrai()
Errrr...
False

Car dans le second cas, and sait qu’il doit avoir toutes les valeurs à True pour renvoyer True. Comme il reçoit False dès le premier test, il ne va pas plus loin, et vrai() n’est jamais appelée.

Le but de cette fonctionnalité est d’autoriser le développeur à mettre les fonctions qui sont les plus gourmandes en ressource tout à droite de la condition, ainsi elle ne seront pas toujours appelées, ce qui améliore les perfs.

Si vous avez besoin que les fonctions soient toujours appelées car elles ont des effets de bord (c’est mal, boooouh !), il suffit de mettre leurs résultats dans des variables :

>>> a = vrai()
Yeah !
>>> b = faux()
Errrr...
>>> b and a
False

Pas de bool

La plupart des opérateurs utilisés pour faire des tests retournent des booléans :

>>> 1 > 2
False
 
>>> "a" in "chat"
True

Mais and et or ne retournent pas des booléans. Dès qu’ils sont certains du résultats de la condition, ils retournent la valeurs qu’ils ont sous la main.

Cela est du au fait qu’en Python, tout a une valeur True ou False dans un contexte booléen. Pour faire simple, n’importe quel objet mis dans une condition vaut soit True, soit False.

Par exemple, une liste vide vaut False dans une condition, une liste non vide vaut True :

>>> couleurs = []
>>> if couleurs:
   ...:     print("J'ai une couleur !")
>>> couleurs.append('rouge')
>>> if couleurs:
    print("J'ai une couleur !")
J'ai une couleur !

On peut le vérifier facilement :

>>> bool([])
    False
>>> bool(['rouge'])
    True

Il est facile de se souvenir de ce qui est faux ou vrai en Python. False, None, 0 et tout ce qui est vide est faux :

>>> for x in (False, None, 0, "", [], set(), {}, ()):
   ...:     print(type(x), bool(x))
   ...:     
<class 'bool'>, False
<class 'NoneType'>, False
<class 'int'>, False
<class 'str'>, False
<class 'list'>, False
<class 'set'>, False
<class 'dict'>, False
<class 'tuple'>, False

Tout le reste est vrai :

>>> for x in (Ellipsis, True, 432, "foo", ["bar"],  set("ba"), {"pa": "pa"}, 
              ("doh",), lambda : None, len):
    print(type(x), bool(x))
<class 'ellipsis'> True
<class 'bool'> True
<class 'int'> True
<class 'str'> True
<class 'list'> True
<class 'set'> True
<class 'dict'> True
<class 'tuple'> True
<class 'function'> True
<class 'builtin_function_or_method'> True

Du coup, and et or vont vérifier la valeur de chaque objet de la condition, et retourner le premier à partir duquel ils sont certains du résultat de la condition entière.

Par exemple, si je fais :

>>> True and True and False and False
    False

and n’est certain que la condition est fausse qu’au moment où on attend le premier False. C’est donc ce False qu’il retourne.

Cela est beaucoup plus clair quand on le fait avec des objets plus complexes :

>>> "a" and 1 and [] and {}
    []

Puisque :

>>> bool('a')
    True
>>> bool(1)
    True
>>> bool([])
    False
>>> bool({})
    False

and n’est certain du résultat de la condition qu’en arrivant sur [], qu’il retourne.

Si tous les éléments sont vrais, il va donc prendre le dernier :

>>> "a" and 1 and True and [1, 2, 3]
    [1, 2, 3]

C’est la même chose pour or :

>>> "" or None or False or 0
    0

Là, or ne peut pas savoir si la condition est fausse avant d’arriver au tout dernier élément, qu’il retourne.

Mais si je glisse un truc vrai dans le lot :

>>> "" or {1: 2} or False or 0
    {1: 2}

Comme il n’a besoin que d’un élément vrai pour que toute la condition soit vraie, dès qu’il en rencontre un, il le retourne.

Il n’y a pas de XOR

Le “ou” exclusif, opération qui retourne vrai seulement si un élément est vrai mais pas l’autre, n’existe pas sous la forme d’un opérateur en Python. Évidement on peut l’émuler manuellement :

def xor(a, b):
    return (a and not b) or (not a and b)

Mais une astuce de sioux permet un résultat plus court avec une syntaxe un poil plus proche des langages qui possèdent cet opérateur :

bool(a) ^ bool(b)

Exemple :

>>> bool(['pomme']) ^ bool([])
    True
>>> bool(['pomme']) ^ bool(['banane'])
    False

^ est en effet l’opérateur XOR pour les opérations binaires. La partie marrante, c’est qu’en Python :

>>> True == 1
    True
>>> False == 0
    True

Et comme :

>>> 1 ^ 1
    0
>>> 1 ^ 0
    1

Alors:

>>> True ^ True
    False
>>> True ^ False
    True

On obtient le résultat voulu.

Oui, c’est un peu tordu, je vous l’accorde.

Lire un format binaire en Python avec struct 22

vendredi 26 juin 2015 à 08:51

Une suite de valeurs ne veut rien dire en soi, et même le sacro-saint binaire supposé être le socle de toute l’informatique n’a aucun sens si on ne connaît pas le format utilisé pour ce qu’il doit représenter.

Toujours la même opposition entre données et représentation.

Par exemple, le binaire peut représenter un chiffre en base 2 ou un texte encodé.

Pour autant, cela ne veut pas dire qu’il n’existe pas des formats prépondérant. En informatique, beaucoup de données binaires sont organisées pour correspondre aux structures de données du langage C, ces dernières étant une implémentation du standard IEEE 754 (en effet les strings sont des arrays d’int en C, donc le texte et les nombres sont des suites de chiffres).

Par exemple, si vous créez un array numpy contenant des nombres de 0 à 1000 stockés en int32 et sauvegardez son contenu dans un fichier :

>>> import numpy
>>> numpy.arange(0, 1000, dtype=np.int32).tofile('/tmp/data')

Le fichier va ici contenir une suite de 1 et de 0 représentant 1000 entiers, chacun comme un paquet de 4 octets organisés selon la sémantique que comprend le langage C.

Pour avoir une idée de l’organisation du contenu, on peut prendre un éditeur hexa qui vous affichera :

0000 0000 0100 0000 0200 0000 0300 0000 0400 0000 0500 0000 0600 0000 0700 0000 0800 0000 0900 0000 0a00 0000 0b00 0000 0c00 0000 0d00 0000 0e00 0000 0f00 0000 1000 0000 1100 0000 1200 0000 1300 0000

Ça se lit ainsi :

0000 0000 => 0
0100 0000 => 1
0200 0000 => 2
0300 0000 => 3
0400 0000 => 4
0500 0000 => 5
0600 0000 => 6
0700 0000 => 7
0800 0000 => 8
0900 0000 => 9
0a00 0000 => 10
0b00 0000 => 11
0c00 0000 => 12
0d00 0000 => 13
0e00 0000 => 14
0f00 0000 => 15
1000 0000 => 16
1100 0000 => 17
1200 0000 => 18
1300 0000 => 19
...

Numpy étant codé en C, cela semble plutôt logique qu’il dump tout ça dans ce format.

Mais c’est une représentation tellement courante que de nombreux formats standards l’utilisent. Par exemple, les archives et les images stockent souvent leurs données ainsi.

Prenez le format d’image PNG, la RFC indique que la taille de l’image est stockée dans le fichier sous la forme de deux entiers représentés par 4 octets chacun, ordonnés en big-endian, entre l’octet 16 et l’octet 24.

On peut donc récupérer ces informations en lisant son fichier image :

with open('image.png', 'rb') as f:
    taille = f.read(24)[16:24]

Le problème étant : comment lire cette info ? C’est un blob binaire qui ne veut rien dire pour Python :

print(taille)
b'\x00\x00\x07\x80\x00\x00\x048'

Le module struct est fait pour ça, on lui passe une donnée au format structure C, et il la convertit en type Python. Cela marche ansi, pardon, ainsi :

struct.unpack('motif_du_format_a_convertir', donnee)

Le format à convertir est une chaîne de caractères qui contient des symboles décrivant la structure de la donnée qu’on souhaite récupérer. Little-endian ou big-endian ? String, Int, Bool ?

Pour la taille de la photo, on sait qu’il y a deux entiers, non signés (une taille ne va pas être négative), en big-endian. D’après la doc de struct, on peut lui désigner un entier non signé avec ‘I’, et il faut les qualifier avec ‘>’ pour l’ordre big-endian. Du coup:

taille = struct.unpack('>II', taille)
print(taille)
(1920, 1080)

Il se trouve que mon image de test est un screenshot et que mon écran a une résolution de 1920×1080 :)

On peut faire l’opération inverse avec struct.pack, et bien entendu manipuler des formats plus complexes : il suffit de changer le motif qui représente le format à convertir.

Une boucle while de moins 5

mardi 23 juin 2015 à 12:04

Si vous devez retenir un truc de la partie Python de ce blog, c’est qu’en Python, l’itération est tout.

Du coup, on utilise pas beaucoup while, à part dans quelques cas particuliers.

Le cas d’école, c’est la lecture d’un fichier octet par octet.

Imaginez, vous créez un petit array de float écrits en 64 bits :

>>> import numpy as np
>>> a = np.sin(np.linspace(2.0, 3.0, num=100))
>>> a.dtype
dtype('float64')

Vous sauvegardez tout ça dans un fichier :

>>> a.tofile('/tmp/data')

Si vous voulez lire le fichier hors de numpy, il faut le charger float par float, donc le lire 64 bits par 64 bits soit par groupes de 8 octets.

La méthode canonique :

with open('/tmp/data', 'rb') as f:
    while True:
        nombre = f.read(8)
        if not nombre:
            break
        # faire un truc avec le nombre

Mais il existe une autre manière de faire cela, moins connue : utiliser iter().

with open('/tmp/data', 'rb') as f:
    for nombre in iter(lambda: f.read(8), b''):
        # faire un truc avec nombre

Cela marche car iter(), parmi ses nombreuses fonctionnalités, accepte un callable en paramètre (ici notre lambda), et va l’appeler jusqu’à ce que celui-ci retourne une valeur dite “sentinelle” (ici notre second paramètre).

Liste des cours et tutos mise à jour 14

lundi 22 juin 2015 à 23:01

Les listes des cours et tutos du blog a été mise à jour.

Bon, là, je dois avouer que je suis super fier. On a l’équivalent de plusieurs bouquins maintenant en magasin, et si je crève demain au moins j’aurais laissé un chouette boulot derrière.