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.

La première chose à signaler est que 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 peut assez vite arriver à 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 » tout ou partie des informations qu'elle est censée coder. Mais il ne faut jamais perdre de vue l'erreur inverse : celle de l'expression qui valide des informations qui ne sont pas pertinentes. On prendra donc toujours soin de tester ses expressions 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, le moyen le plus direct est d'utiliser la bibliothèque adéquate, à savoir re (comme regular expression) :

import re

Cette bibliothèque offre en particulier la méthode search, qui renvoie un booléen si l'argument 1 figure dans l'argument 2 (c'est donc une version simplifiée du Trouve du cours d'algorithmique. Ainsi, dans une version très basique, si l'on écrit :

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

toto vaudra True tandis que tutu vaudra False. Jusque là, rien de bien nouveau, ni de bien malin. Toute l'affaire est qu'évidemment, on peut ne pas en rester là, et qu'l est possible d'utiliser des « jokers » dans ce type de situations.

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$", "hâtifs") vaut False
  • Les crochets [ ] marquent une liste de caractères. 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. Ainsi :
    re.search("[a-e]", "bonbon") vaut True
    re.search("[a-e]", "matin") vaut True
    re.search("[a-e]", "moulin") vaut False
  • Il est important de ne pas confondre « [A-Z] [a-z] » et « [A-Za-z] » ; « [A-Z] [a-z] » correspond a deux caractéres, une majuscule suivie d’une minuscule. « [A-Za-z] », en revanche, correspond a un unique caractére qui est soit une majuscule, soit une minuscule.
  • 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
  • Le plus + marque 1 ou plusieurs occurrences du caractère qui précède. 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. Ainsi :
    re.search("car?", "caractère") vaut True
    re.search("car?", "tocante") vaut True
    re.search("car?", "parrain") vaut False
  • 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 pipe | 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

Premier raffinement : 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 à présent 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.

Second raffinement : 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 l'expression "<.*>" dans le texte "Le titre est <i>Casus belli</i> !", on récupèrera la totalité du texte (le plus long extrait possible répondant à notre demande, soit <i>Casus belli</i>. Pour obtenir un résultat « non gourmand », c'est-à-dire minimal, on recherchera l'expression "<.*?>" ; on obtiendra alors "<i>".

Autres opérations possibles

Jusqu'ici, nous avons employé la méthode search qui effectue donc une recherche, en produisant un résultat booléen. D'autres méthodes sont disponibles, dont certaines permettent d'agir sur la chaîne traitée :

  • re.findall() : retourne toutes les occurrences d’un motif dans une chaîne sous forme de liste (par exemple, tous les mots d'une chaîne qui comptent un certain nombre de lettres, qui comportent un z, etc.)
  • re.sub() : effectue un rechercher / remplacer systématique au sein d'une chaîne – les arguments comprennent donc d'une part le motif à remplacer, d'autre part la chaîne de remplacement. 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.
  • split() : découpe la chaîne de caractère en liste, en utilisant l'expression régulière fournie en argument. Dans le cas le plus simple, celui d'un fichier csv, cette expression se limite à un caractère (virgule, point-virgule, etc.), mais on peut donc imaginer de gérer des situations plus complexes.

Je signale aussi, bien qu'elle soit plus dispensable :

  • re.match() : recherche l'expression régulière uniquement au début de la chaîne. Comme on l'a vu, on peut facilement obtenir le même résultat en utilisant l'opérateur ^

Quelques questions-réponses pour commencer

ExpressionChaîneRésultat
GR(.)+SGRIS
TRUE
GR(.)?SGRS
TRUE
GRA(.)?SGRAS
TRUE
GAS(.)?GRAS
FALSE
GR(A)?SGRAS
TRUE
GR(A)?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])03 88 00 00 00
TRUE
([0-9 ]){5}03 88 00 00 00
FALSE

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 : l'emploi du caractère \ soulève de redoutables problèmes d'interprétation par le langage. Si aucune précaution n'est prise, alors il sera probablement 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. 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, il y a deux solutions :
  1. la première consiste à doubler tous les antislash concernés. On écrira ainsi \\bchat\\b
  2. la seconde consiste à placer un r (minuscule) juste avant la chaîne patternde l'expression régulière, comme dans re.search(r'\bchat\b'). Cette seconde option est évidemment plus légère, mais elle implique que tous les antislash du pattern seront interprétés comme des codes d'échappement.

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.

En fait, nous avopns déjà rencontré le groupe au passage, mais sans nous y être arrêtés, lorsque nous avons vu le booléen OU, autrement dit le | (« pipe »).

Une expression peut comprendre plusieurs groupes, soit à la suite les uns des autres, soit de manière imbriquée. Outre le fait de pouvoir appliquer des quantificateurs, un des grands intérêts 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. 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)

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'

NB : avec les séquences d'échappement, on peut écrire la même chose (et en fait, une chose un peu meilleure) sous la forme :

'\b(\w+)\s+\1\b'

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, 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 :

  • 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.
  • telephone : formats français : 8 chiffres précédés de 06, 07, +336 ou +337. Un numéro contenant les bons éléments mais comportant des séparateurs (espaces ou points), une paire de parenthèses pour l'indicatif, etc. sera remis aux normes
  • code postal : 5 chiffres, ni plus ni moins !
  • nom : "Nom Prénom", sans virgule ni chiffre, où les deux termes commencent par des majuscules. Les éléments dépourvus d'espace seront considérés comme invalides ; pour le reste, tous les caractères autres qu'alphabétiques et le trait d'union seront purgés.

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