Cours Python avec exercices

écrit par Christophe Darmangeat dans le cadre du M2 PiSE

Les expressions régulières

Que signifie ce terme un peu abscons ? Il désigne, de manière générale, un ensemble de « jokers » pouvant servir à vérifier si une donnée correspond à une certaine forme. Tout bêtement, si c'est un nom propre, commence-t-il effectivement par une majuscule ? Si c'est un code postal, comprend-il effectivement cinq chiffres ? Si c'est un mot de passe, fait-il au minimum 12 caractères, dont au moins une majuscule et un chiffre ? Etc.

Les expressions régulières ne sont absolument pas une particularité spécifique à Python : on les retrouve dans beaucoup de langages ou de codages informatiques – les plus anciens se souviennent, émus, du ms-dos, de ses * et de ses ? pour désigner des noms de fichiers. On peut même en trouver quelques équivalents (rudimentaires) dans d'autres contextes : ainsi, au Scrabble, où le jeton vierge (joker) remplace n'importe quelle lettre de l'alphabet. La seconde chose est que comme très souvent en informatique, le principe de base est extrêmement simple, mais en combinant ces éléments simples, on arrive assez vite à des problèmes d'une complexité redoutable.

Avertissement : quand on travaille avec les expressions régulières, il faut toujours veiller à deux possibilités d'erreurs : la première, à laquelle on pense en premier, est celle de l'expression qui « n'attrape pas » toutes les informations qu'elle est censée coder. Mais il ne faut jamais perdre de vue l'erreur inverse : celle de l'expression qui « attrape » des informations qui ne sont pas pertinentes. On prendra donc toujours soin de tester ses expressions régulières par rapport à ces deux erreurs possibles.

Avertissement bis : faut-il le préciser, en matière d'erreurs, il est toujours possible d'avoir « fromage et dessert », et ainsi d'écrire une expression qui parvient à la fois à ne pas récupérer toutes les informations pertinentes et à récuperer certaines informations qui ne le sont pas...

Premiers éléments

Pour jouer avec les expression régulières, il est indispensable d'utiliser la bibliothèque adéquate, à savoir re (comme regular expression) :

import re

Cette bibliothèque offre diverses méthodes. Les deux principales sont search et findall. Search renvoie un résultat booléen indiquant si l'expression est présente ou non dans un texte donné (en fait, search renvoie un objet qui possède différentes propriétés indiquant le résultat de la recherche, mais qui peut être utilisé comme un simple booléen : c'est ce que nous ferons ici). Findall renvoie, sous forme de liste, toutes les occurrences de l'expression dans le texte.

Ainsi, dans une version très basique qui n'utilise pas les epressions régulières, si l'on écrit :

toto = re.search("monsieur", "Bonjour monsieur")
toto = re.search("madame", "Bonjour monsieur")

toto vaudra True tandis que tutu vaudra False.

Et si l'on écrit :

tata = re.findall("on", "Bonjour monsieur")

Le contenu de tata sera ["on", "on"] dans la mesure où ces deux caractères apparaissent deux fois dans la chaîne dans laquelle on les recherche.

Naturellement, sans expression régulière, tout ceci n'est pas très intéressant... mais cela va rapidement le devenir, dans la mesure où il est possible d'utiliser des « jokers » qui vont tout changer et qui, bien maniés, vont donner à ces instructions une incroyable puissance.

Les bases

  • Le point . remplace n'importe quel caractère, en un exemplaire. Ainsi :
    re.search("li..e", "livre") vaut True
    re.search("li..e", "ligue") vaut True
    re.search("li..e", "longe") vaut False
    re.search("li..e", "lire") vaut False
  • Le circonflexe ^ est le symbole d'ancrage initial ; il marque le début de la chaîne. Ainsi :
    re.search("^bon", "bonjour") vaut True
    re.search("^bon", "bonsoir") vaut True
    re.search("^bon", "abondant") vaut False
  • Le dollar $ est le symbole d'ancrage final ; il marque la fin de la chaîne. Ainsi :
    re.search("tif$", "hâtif") vaut True
    re.search("tif$", "primitif") vaut True
    re.search("tif$", "rétifs") vaut False
  • Les crochets [ ] marquent une liste de caractères, dans laquelle il suffit que l'un soit présent. Ainsi :
    re.search("[kwz]", "kapok") vaut True
    re.search("[kwz]", "zoo") vaut True
    re.search("[kwz]", "chat") vaut False
  • Les crochets [ ] associés à un tiret marquent - une plage de caractères, c'est-à-dire une série, dans l'ordre de la table ascii. Ainsi :
    re.search("[a-e]", "bonbon") vaut True
    re.search("[a-e]", "matin") vaut True
    re.search("[a-e]", "moulin") vaut False
  • NB : On notera toute la différence entre « [A-Z][a-z] » et « [A-Za-z] » . « [A-Z][a-z] » correspond à deux caractères, une majuscule suivie d’une minuscule. « [A-Za-z] », en revanche, correspond à un caractère unique, qui est soit une majuscule, soit une minuscule.

Les quantificateurs

  • Les accolades { } permettent de spécifier un nombre d'occurrences précis du caractère qui précède. Ainsi :
    re.search("n{2}", "ennuyeux") vaut True
    re.search("n{2}", "connard") vaut True
    re.search("n{2}", "canard") vaut False
    re.search("n{2, 4}", "canard") vaut False
    re.search("n{2, 4}", "cannnard") vaut True
  • Le plus + marque 1 ou plusieurs occurrences du caractère qui précède, autrement dit « au moins une fois ». Ainsi :
    re.search("et+", "minet") vaut True
    re.search("et+", "dinette") vaut True
    re.search("et+", "fauteuil") vaut False
  • Le point d'interrogation ? marque 0 ou 1 occurrences du caractère qui précède, autrement dit « au plus une fois ». Ainsi :
    re.search("car?", "caractère") vaut True
    re.search("car?", "tocante") vaut True
    re.search("car?", "parrain") vaut False
  • L'étoile * marque 0, 1 ou plusieurs occurrences du caractère qui précède. Ainsi :
    re.search("te*", "guitare") vaut True
    re.search("te*", "nuitee") vaut True
    re.search("te*", "bouquin") vaut False
  • Au premier abord, il peut sembler inutile de préciser ainsi qu'on veut un caractère, ou une série de caractères, en n'importe quelle quantité (y compris zéro !). En fait, c'est très utile pour dire en substance « peu importe ce qui se trouve ici, et même si rien ne s'y trouve ».

Deux raffinements

  • L'exclusion (^) : placé au début d'une expression régulière, l'accent circonflexe signifie comme on vient de le voir : « début de la chaîne ». Mais placé au début d'une plage, il change totalement de sens et marque l'exclusion. Ainsi, [^a-z] signifie : « n'importe quel caractère sauf une minuscule ». On peut ainsi formuler des règles dans les deux sens, soit en stipulant ce que l'on veut, soit en stipulant ce qu'on ne veut pas.
  • La gourmandise : par défaut, les codes (ou quantificateurs) des expressions régulières sont dits « gourmands », c’est-à-dire qu’ils capturent autant de caractères que possible. Ainsi, si l'on recherche par un findall l'expression "(.*)" dans "Je vois (un chat) et (un chien) dans le jardin", on va récupérer la chaîne "(un chat) et (un chien)" : elle correspond au plus long extrait possible répondant à notre demande. Pour récupérer séparément ce qui se trouve entre chaque paire de parenthèses, nous devons obliger le résultat à être « non gourmand », c'est-à-dire à s'arrêter non à la dernière parenthèse rencontrée, mais dès la première. On utilisera alors le point d'interrogation dans l'expression, en recherchant dorénavant "(.*?)". La liste renvoyée par le findall comportera dorénavant deux éléments distincts : "(un chat)" et "(un chien)".

Autres utilisations

Les expressions régulières ne sont pas uniquement utilisables lorsqu'il s'agit de récupérer de l'information, comme on l'a fait avec search et findall.

On peut également les mobiliser pour traiter / modifier une chaîne, en particulier avec la méthode re.sub(), qui est en quelque sorte une version améliorée de replace : tout comme celle-ci, elle effectue un rechercher / remplacer systématique au sein d'une chaîne, mais elle admet que le motif à remplacer soit une expression régulière. Cette méthode est donc extrêmement puissante pour nettoyer ou transformer des données textuelles. À noter la variante re.subn() qui, en plus de la chaîne modifiée, renvoie également le nombre de remplacements effectués.

La méthode split(), que nous avons déjà vue et qui découpe la chaîne de caractère en liste, admet elle aussi qu'une expression régulière lui soit fournie en argument. Si dans le cas le plus simple, celui d'un fichier csv, cette expression se limite à un caractère (virgule, point-virgule, etc.), on peut donc imaginer de gérer des situations plus complexes.

Quelques questions-réponses

Pour commencer, avec search :

ExpressionChaîneSearch
GR.+SGRIS
TRUE
GR.?SGRAS
TRUE
GRA.+SGRAAAAS
TRUE
GAS.?GRAS
FALSE
GRA?SGROOOS
FALSE
GRA?SGRS
TRUE
M.+NMAISON
TRUE
M.+O+NMAISON
TRUE
M.+[a-z]+NMAISON
FALSE
M.+[A-Z]+NMAISON
TRUE
^!!MAISON!
TRUE
!MAISON!MAISON!
TRUE
^!MAISO!$!MAISON!
FALSE
^!MAISON!$!MAISON!
TRUE
^!M.+!$!MAISON!
TRUE
[0-9][0-9][0-9]03 88 00 00 00
FALSE
[0-9 ]{8}03 88 00 00 00
TRUE

Et maintenant, avec findall. La chaîne dans laquelle on recherche l'expression est :
"Alice a 3 chats, Bob a 12 chiens, Clara a 1 chat et 2 poissons."

ExpressionFindall
[0-9]+
['3', '12', '1', '2']
c[a-z]+
['ce', chats', 'chiens', 'chat']
[A-Z][a-z]+
['Alice', 'Bob', 'Clara']
[0-9]{2}
['12']
[a-z]+s
['chats', 'chiens', 'poissons']
[a-z]*o[a-z]*
['ob', 'poissons']
[a-zA-Z]{3,}
['Alice', 'chats', 'Bob', 'chiens', 'Clara', 'chat', 'poissons']
a.*s
['a 3 chats, Bob a 12 chiens, Clara a 1 chat et 2 poissons']
a.*?s
['a 3 chats', 'a 12 chiens', 'a 1 chat et 2 poissons']
[a-z]{3}
['lic', 'cha', 'ts', 'chi', 'ens', 'lar', 'cha', 'poi', 'sso']

Les séquences d'échappement

Certains codes dits d'échappement (à cause de l'antislash qui les précède) remplacent avantageusement une information plus lourde, voire ouvrent des possibilités spécifiques. Voici les plus courantes :

  • \b : délimiteur de mot. Ainsi, \bchat\b correspond à « chat » mais pas à « chatter » ni à « achat »
  • \s : correspond à n'importe quel caractère d'espacement (espace, tabulation, nouvelle ligne, etc.).
  • \d : correspond à n'importe quel chiffre. C'est donc l'équivalent de [0-9].
  • \D : correspond à tout caractère qui n'est pas un chiffre.
  • \n : correspond à une nouvelle ligne.
!!! Syntaxe, priez pour nous : Si aucune précaution n'est prise, alors le caractère \ sera considéré pour lui-même, et l'expression \bchat\b, au lieu de correspondre au mot « chat », matchera avec la suite antislash – b – c – h – a – t – b – antislash. Dès lors, pour faire comprendre à python que l'antislash d'une expression régulière n'est pas un caractère ordinaire mais un code à interpréter avec ce qui suit, la solution la plus simple consiste à placer un r (minuscule) juste avant la chaîne pattern de l'expression régulière, comme dans re.search(r"\bchat\b").

Et maintenant, c'est parti, nous allons écrire les expressions régulières nous-mêmes !

Ex 1 : Premiers pas

Pour cette série de questions, on écrit un petit programme qui demande une saisie au clavier et qui dit ensuite à l'utilisateur si cette saisie correspond ou non à l'expression régulière concernée. Il faut donc vérifier successivement si la chaîne entrée au clavier est bel et bien :

  1. un code postal français (composé de 5 chiffres)
  2. un mot de sept lettres qui commence par un « b », finit par « re » et contient un « i » en troisième position
  3. un mot qui commence par deux minuscules suivies de "re" et d’une minuscule.
  4. un mot qui commence par "co" (et donc, éventuellement, par "co-")
  5. une des conjugaisons du verbe « arriver » au présent de l'indicatif.
  6. une plaque d'immatriculation française (deux lettres majuscules, trois chiffres, deux lettres majuscules, séparés par des tirets)
  7. les termes "mail", "e-mail" et "email", au singulier et au pluriel
  8. un nom propre
  9. une couleur 24 bit exprimée en hexadécimal
  10. un mot composé, comme « porte-clefs »
  11. un passage entre parenthèses (attention, il ne faut pas qu'il y ait plusieurs séries de parenthèses !)
  12. dans un texte en anglais, des noms écrits au possessif, singulier ou pluriel (student's, students', sister's, countries', etc.)
  13. dans un texte en anglais, des possessifs concernant des noms propres (John's, Hanna's, Dickens', Edward's)
  14. une url valide (NB : elle peut commencer par http ou https, et le dernier point est suivi de deux, trois ou quatre lettres qui terminent la chaîne)
  15. un nombre en chiffres romains

Quelques armes supplémentaires

Les groupes

Un outil supplémentaire très précieux est celui des groupes. Dans une expression régulière, un groupe définit un sous-ensemble (comme qui dirait : un groupe !) de caractères, auquel on peut appliquer des quantificateurs ou des traitements particuliers. Ainsi, (bof) désigne la suite de caractères « bof » – à ne surtout pas confondre avec [bof], qui signifie : « un b, un o ou un f ». Si l'on écrit (bof)+, le quantificateur + s'applique à l'ensemble du groupe ; autrement dit, l'expression désigne les séries de caractères qui incluent au moins une fois « bof », et donc « bof », « bofbof », « bofbofbof », etc.

Les groupes donnent donc davantage de souplesse aux quantificateurs. Mais ce n'est pas leur seul intérêt. Une expression peut en effet comprendre plusieurs groupes, soit à la suite les uns des autres, soit de manière imbriquée. Le grand intérêt de cette affaire est que ces groupes sont implicitement numérotés (indicés), dans l'ordre de l'ouverture des parenthèses. On peut ainsi faire du découpage, et récupérer ainsi les morceaux de la chaîne qui correspondent aux différents groupes. Prenons l'exemple d'une date au format jj/mm/aaaa. Normalement, sa recherche dans un texte ressemblera à :

[0-9]{2}/[0-9]{2}/[0-9]{4}

Ce qu'on peut également écrire sous la forme :

\d{2}/\d{2}/\d{4}

Ajoutons des groupes, et nous pourrons ensuite récupérer chaque information individuellement :

match = re.search("(\d{2})/(\d{2})/(\d{4})", texte)
if match:
   jour = match.group(1)
   mois = match.group(2)
   annee = match.group(3)

On remarque au passage que les groupes constituent ici des propriétés de l'objet match, qui est lui-même de type booléen – plus exactement,n dont la propriété par défaut est de type booléen.

Attention ! Pour qu'une information soit effectivement capturée dans un groupe et qu'elle puisse être récupérée, il faut impérativement que ce groupe apparaisse de manière explicite, c'est-à-dire sans quantificateur, dans la regex. Ainsi, l'expression (\d\d){5} ne récupèrera pas cinq groupes... mais un seul, correspondant au dernier duo de chiffres qui aura été repéré.

Le « OU »

Maintenant que nous connaissonbs le groupe, nous pouvons employer l'opérateur pipe (prononcer « païpe »), soit |, fonctionne de la même manière que le OU booléen. Ainsi :

   re.search("bijou(s|x)", "bijous") vaut True
   re.search("bijou(s|x)", "bijoux") vaut True
   re.search("bijou(s|x)", "bijour") vaut False

Les backreferences

La présence des groupes permet également d'utiliser ces indices dans l'expression régulière elle-même. Imaginons par exemple que nous souhaitions détecter, dans un texte, la présence de mots qui se suivent à l'identique (comme dans « Je suis très très content »). Pour faire très simple, nous pouvons considérer qu'un mot est une suite composée d'un nombre quelconque de caractères, suivi d'une espace, ce qui s'écrit '[a-zA-Z]+ '. Comment repérer le fait que ce mot soit répété ? En en faisant un groupe, puis en demandant si ce groupe est suivi par lui-même (ici, le groupe étant le premier et seul de l'expression, il porte l'indice 1 :

'([a-zA-Z]+) \1'

Les assertions positives anticipées (positive lookahead)

Cette appellation barbare désigne une idée assez simple : la possibilité de repérer des éléments dans une chaîne en quelque sorte « à l'avance », c'est-à-dire sans la parcourir. Le cas classique est celui où l'on recherche si la chaîne comporte certains éléments sans savoir dans quel ordre ceux se présentent. Imaginons le cas où nous voulons savoir si une phrase contient certains mots, dans un ordre quelconque. Avec l'approche standard, on pourrait imaginer de s'en sortir avec un OU – mais cette solution devient rapidement intenable si le nombre de mots augmente : pour 2 mots, il faut un OU, pour 3 mots il en faut 6, pour 4 mots il en faut 24, etc. C'est pourquoi le recours aux « APA » est fort utile.

Pour obtenir ce résultat, la syntaxe consiste à définir l'ensemble recherché comme un groupe, et à placer au début de celui-ci la séquence ?=.*. Ainsi, si nous cherchons si un texte contient, comme celui de Rabelais, « veaux », « vaches », « cochons », « poulets », et ceci dans n'importe quel ordre, l'expression régulière correspondante sera :

(?=.*\bveaux\b)(?=.*\bvaches\b)(?=.*\bcochons\b)(?=.*\bpoulets\b)

Et maintenant, à vous de jouer.

Ex 2 : Après les premiers pas, les suivants

Dans la même lignée que l'exercice précédent, écrivez les expressions régulières permettant d'identifier :

  1. un groupe constitué d'un caractère (n'importe lequel) répété trois fois de suite
  2. les mots contenant les caractères « bla » au moins deux fois à la suite (blabla, blablabla, etc.)
  3. un groupe constitué de trois caractères, où le premier et le troisième sont identiques
  4. une adresse mail, qui comporte une arobase unique
  5. une adresse mail, qui comporte une arobase unique et un point unique après cette arobase

Ex 3 : Travail en série

Cette fois, pour varier un peu les plaisirs, l'application devra aller chercher un fichier texte qui contiendra les diverses propositions à raison d'une par ligne. Le programme affichera à l'écran la liste des propositions valides, et celle des propositions invalides. Pour la série concernant les mots de passe, on pourra utiliser ce fichier, et pour celle concernant les numéros de téléphone celui-ci.

  1. un mot de passe d'au moins huit caractères
  2. un mot de passe d'au moins huit caractères, comportant au moins une lettre et un chiffre
  3. un mot de passe d'au moins huit caractères, comportant au moins une majuscule, une minuscule et un chiffre
  4. un mot de passe d'au moins huit caractères, comportant au moins une majuscule, une minuscule, un chiffre et un caractère spécial
  5. un numéro de téléphone français, composé de 10 chiffres
  6. un numéro de téléphone français, composé de 5 groupes de 2 chiffres séparés ou non par des espaces
  7. un numéro de téléphone français, composé de 5 groupes de 2 chiffres séparés ou non par des espaces, des tirets ou des points
  8. un numéro de téléphone français, composé de 5 groupes de 2 chiffres séparés ou non par des espaces, des tirets ou des points, et éventuellement précédé par +33 ou(33)

Ex 4 : Nettoyage de données

Vous travaillez dans une entreprise qui reçoit des milliers d’enregistrements de formulaires clients chaque mois. Ces données sont parfois mal formatées ou contiennent des erreurs. On vous demande d'écrire un code capable de nettoyer et de valider certains champs essentiels (email, numéro de téléphone, code postal, nom, etc.) afin que les données puissent être intégrées dans une base. Les données se présentent sous la forme d'un fichier texte de type csv (délimiteur : point-virgule), où chaque ligne représente un client, les champs étant dans l'ordre le nom, le prénom, le téléphone, l'adresse et le code postal. Pour tester votre code, on met à votre disposition un extrait de ce fichier. Il s'agit donc à la fois de nettoyer et de vérifier les données, qui obéiront aux conditions suivantes :

  • nom : "Nom Prénom", sans autre caractère qu'alphabétique. Le nom peut éventuellement être composé de plusieurs parties (cas du nom à particule). Et le nom, tout comme le prénom, peut également comporter un trait d'union.
  • email : format classique, avec dans l'ordre un identifiant, une arobase, un point et un nom de domaine long de deux à quatre caractères (ex : prénom.nom@domaine.com). Toute adresse non conforme sera rejetée.
  • code postal : 5 chiffres, ni plus ni moins !
  • téléphone : 8 chiffres précédés de 06, 07, +336 ou +337. Un numéro contenant les bons éléments sera remis aux normes, en commençant par le préfixe suivi des huit chiffres du numéro, le tout séparé par des espaces (question assez difficile !)

Le code devra produire deux fichiers .csv, l'un constitué des enregistrements valides (et mis au propre), l'autre des enregistrements invalides.