Licence CC BY-NC-ND, Thierry Parmentelat & Arnaud Legout

from IPython.display import HTML
HTML(filename="_static/style.html")

expressions régulières#

il s’agit d’une notion transverse aux langages de programmation, et présente dans la plupart d’entre eux
et en particulier historiquement dans Perl, qui en avait fait un first-class citizen

c’est quoi ?#

vous cherchez à trouver toutes les adresses e-mail, ou tous les numéros de téléphone dans un texte ?
vous voulez savoir si une chaine pourrait être utilisée comme non de variable Python ?

les expressions régulières sont faites pour ce genre d’application
voici pour commencer deux exemples élémentaires:

  • a* décrit tous les mots composés de 0 ou plusieurs a

    • '', 'a', 'aa', … sont les mots reconnus

  • (ab)+ : toutes les suites de au moins 1 occurrence de ab

    • 'ab', 'abab', 'ababab', … sont les mots reconnus

le module re#

en Python, les expressions régulières sont accessibles au travers du module re

import re

re.match()#

re.match() vérifie si l’expression régulière peut être trouvée au début de la chaine

# en anglais on dit pattern, ou regexp
# en français on dit filtre, ou encore parfois motif

pattern = "a*"
# OUI

re.match(pattern, 'aa')
<re.Match object; span=(0, 2), match='aa'>
# OUI

re.match('(ab)+', 'ab')
<re.Match object; span=(0, 2), match='ab'>
# NON: retourne None

re.match('(ab)+', 'ba')
# ici seulement LE DÉBUT du mot est reconnu, mais c'est OK

match = re.match('(ab)+', 'ababzzz')
match
<re.Match object; span=(0, 4), match='abab'>
# le détail de ce qui a été trouvé

match.start(), match.end()
(0, 4)
# par contre ici le seul match n'est pas au début, donc NON

re.match('a+', 'zzzaaa')

les objets Match#

le résultat de re.match() est … de type Match, qui fournit

  • les détails de ce qui a été trouvé (où et quoi)

  • et aussi les sous-chaines correspondant aux groupes, dont on reparlera…

pattern, string = "(ab)+", "the baba of regexps"
(match := re.search(pattern, string))
<re.Match object; span=(5, 7), match='ab'>
begin, end = match.span()
string[begin:end]
'ab'

helper#

la fonction suivante va juste nous servir à visualiser les résultats d’un re.match() sur plusieurs chaines

# digression : une fonction utilitaire pour montrer
# le comportement d'un même pattern sur plusieurs chaines

def match_all(pattern, strings):
    """
    match a pattern against a set of strings 
    and display results properly aligned 
    """
    # compute max space
    margin = max(len(x) for x in strings) + 2 # for the quotes
    
    for string in strings:
        string_repr = f"'{string}'"
        print(f"'{pattern}' ⇆ {string_repr:>{margin}} → ", end="")
        
        if not (match := re.match(pattern, string)):
            print("NO")
        elif not (match.start() == 0 and match.end() == len(string)):
            # start() is always 0
            print(f"PARTIAL until char {match.end()}")
        else:
            print("FULL MATCH")
# ce qui nous permettra de faire par exemple

match_all('(ab)+', ['ab', 'abab', 'ababzzz', ''])
'(ab)+' ⇆      'ab' → FULL MATCH
'(ab)+' ⇆    'abab' → FULL MATCH
'(ab)+' ⇆ 'ababzzz' → PARTIAL until char 4
'(ab)+' ⇆        '' → NO

construire un pattern#

une fois ceci en place, voyons les différents outils - on va dire aussi opérateurs - qui nous permettent de construire ce fameux pattern

un caractère précis a#

# si j'écris dans un pattern un caractère "normal"
# ça signifie que je veux trouver exactement ce caractère dans la chaine
match_all("a", ["a", "ab", "bc"])
'a' ⇆  'a' → FULL MATCH
'a' ⇆ 'ab' → PARTIAL until char 1
'a' ⇆ 'bc' → NO
match_all(r"\.", ["a", ".a"])
'\.' ⇆  'a' → NO
'\.' ⇆ '.a' → PARTIAL until char 1

n’importe quel caractère : .#

# un '.' dans le pattern signifie EXACTEMENT UN un caractère
# mais n'importe lequel, à cet endroit dans la chaine

match_all('.', ['a', 'Θ', '.', 'ab', ''])
'.' ⇆  'a' → FULL MATCH
'.' ⇆  'Θ' → FULL MATCH
'.' ⇆  '.' → FULL MATCH
'.' ⇆ 'ab' → PARTIAL until char 1
'.' ⇆   '' → NO
# pour bien comprendre la nécessité du \ devant le .

match_all(r'\.', ['a', 'Θ', '.', 'ab', ''])
'\.' ⇆  'a' → NO
'\.' ⇆  'Θ' → NO
'\.' ⇆  '.' → FULL MATCH
'\.' ⇆ 'ab' → NO
'\.' ⇆   '' → NO

un seul caractère parmi un ensemble: [..]#

avec les [] on peut désigner un ensemble de caractères, par exemple

  • [acg] exactement un caractère parmi a ou c ou g

  • [a-z] une lettre minuscule

  • [a-zA-Z0-9_] une lettre ou un chiffre ou un underscore

match_all('[a-z]', ['a', '', '0'])
'[a-z]' ⇆ 'a' → FULL MATCH
'[a-z]' ⇆  '' → NO
'[a-z]' ⇆ '0' → NO
match_all('[a-z0-9]', ['a', '9', '-'])
'[a-z0-9]' ⇆ 'a' → FULL MATCH
'[a-z0-9]' ⇆ '9' → FULL MATCH
'[a-z0-9]' ⇆ '-' → NO
# pour insérer un '-', on peut par exemple le mettre à la fin
match_all('[0-9+-]', ['0', '+', '-', 'A'])
'[0-9+-]' ⇆ '0' → FULL MATCH
'[0-9+-]' ⇆ '+' → FULL MATCH
'[0-9+-]' ⇆ '-' → FULL MATCH
'[0-9+-]' ⇆ 'A' → NO

idem mais complémenté : [^..]#

si l’ensemble de caractères entre [] commence par un ^, alors cela désigne le complémentaire dans l’espace des caractères

# complémentaires
match_all('[^a-z]', ['a', '0', '↑', 'Θ'])
'[^a-z]' ⇆ 'a' → NO
'[^a-z]' ⇆ '0' → FULL MATCH
'[^a-z]' ⇆ '↑' → FULL MATCH
'[^a-z]' ⇆ 'Θ' → FULL MATCH
match_all('[^a-z0-9]', ['a', '9', '-', 'Θ'])
'[^a-z0-9]' ⇆ 'a' → NO
'[^a-z0-9]' ⇆ '9' → NO
'[^a-z0-9]' ⇆ '-' → FULL MATCH
'[^a-z0-9]' ⇆ 'Θ' → FULL MATCH

0 ou plusieurs occurrences : ..*#

en ajoutant * après un pattern, cela signifie 0 ou plus occurrences de ce pattern
on en a vu un exemple déjà avec a*, mais le pattern peut être arbitrairement complexe

# toutes les lettres au début de la chaine
match_all('[a-z]*', ['', 'abc', 'xyz9'])
'[a-z]*' ⇆     '' → FULL MATCH
'[a-z]*' ⇆  'abc' → FULL MATCH
'[a-z]*' ⇆ 'xyz9' → PARTIAL until char 3
# toutes les suites de 'ab' au début de la chaine

match_all('(ab)*', ['', 'ab', 'abcd', 'abab'])
'(ab)*' ⇆     '' → FULL MATCH
'(ab)*' ⇆   'ab' → FULL MATCH
'(ab)*' ⇆ 'abcd' → PARTIAL until char 2
'(ab)*' ⇆ 'abab' → FULL MATCH

utilisez les parenthèses !

le * s’applique au (bout de) pattern immédiatement à sa gauche
on peut avoir à utiliser des parenthèses si nécessaire

# ici l'étoile s'applique seulement au 'b'

match_all('ab*', ['a', 'abb', 'abab', 'baba'])
'ab*' ⇆    'a' → FULL MATCH
'ab*' ⇆  'abb' → FULL MATCH
'ab*' ⇆ 'abab' → PARTIAL until char 2
'ab*' ⇆ 'baba' → NO
# si je veux qu'il s'applique à 'ab', je mets des parenthèses

match_all('(ab)*', ['a', 'abb', 'abab', 'baba'])
'(ab)*' ⇆    'a' → PARTIAL until char 0
'(ab)*' ⇆  'abb' → PARTIAL until char 2
'(ab)*' ⇆ 'abab' → FULL MATCH
'(ab)*' ⇆ 'baba' → PARTIAL until char 0

1 ou plusieurs occurrences : ..+#

exactement comme *, sauf qu’il faut au moins une occurrence cette fois

match_all('[a-z]+', ['', 'abc', 'xyz9'])
'[a-z]+' ⇆     '' → NO
'[a-z]+' ⇆  'abc' → FULL MATCH
'[a-z]+' ⇆ 'xyz9' → PARTIAL until char 3
match_all('(ab)+', ['', 'ab', 'abcd', 'abab'])
'(ab)+' ⇆     '' → NO
'(ab)+' ⇆   'ab' → FULL MATCH
'(ab)+' ⇆ 'abcd' → PARTIAL until char 2
'(ab)+' ⇆ 'abab' → FULL MATCH

concaténation#

ensuite, et de manière très naturelle, quand on concatène deux filtres, la chaine doit matcher l’un puis l’autre, évidemment

# c'est le seul mot qui matche
match_all('ABC', ['ABC']) 
'ABC' ⇆ 'ABC' → FULL MATCH
match_all('A*B', ['B', 'AB', 'AAB', 'AAAB']) 
'A*B' ⇆    'B' → FULL MATCH
'A*B' ⇆   'AB' → FULL MATCH
'A*B' ⇆  'AAB' → FULL MATCH
'A*B' ⇆ 'AAAB' → FULL MATCH

groupement : (..)#

les parenthèses sont très utiles, on l’a déjà vu à l’instant avec notre exemple (ab)*
il faut savoir aussi que cela définit un groupe qui peut être retrouvé dans le match
notamment grâce à la méthode groups()

très utile !

c’est notamment comme ça qu’on peut retrouver des morceaux dans une chaine

dans quel ordre ?

dans cette forme simple les groupes sont anonymes, et on les retrouve par leur rang, i.e. l’ordre dans lequel apparaissent les parenthèses ouvrantes

# ici on a deux groupes
pattern = "([a-z]+)=([a-z0-9]+)"

# cette chaine a bien la bonne forme
string = "foo=barbar99"

# la preuve
match = re.match(pattern, string)
match
<re.Match object; span=(0, 12), match='foo=barbar99'>
# et si on veut ensuite extraire de la chaine
# les deux parties (la variable et la valeur)
# on les a ici, dans l'ordre où apparaissent les groupes
match.groups()
('foo', 'barbar99')

on peut aussi nommer les groupes

en général on n’aime pas trop l’idée de coder avec le rang du groupe (c’est fragile)
on verra plus bas comment, pour éviter ça, on peut les nommer

alternative : ..|..#

pour filtrer avec une regexp ou une autre
ça se complique un peu, attention à bien lire les choses

# 'ab' ou 'cd'

match_all('ab|cd', ['ab', 'cd', 'abcd'])
'ab|cd' ⇆   'ab' → FULL MATCH
'ab|cd' ⇆   'cd' → FULL MATCH
'ab|cd' ⇆ 'abcd' → PARTIAL until char 2
# 'ab' ou 'cd*'

match_all('ab|cd*', ['ab', 'c', 'cd', 'cdd'])
'ab|cd*' ⇆  'ab' → FULL MATCH
'ab|cd*' ⇆   'c' → FULL MATCH
'ab|cd*' ⇆  'cd' → FULL MATCH
'ab|cd*' ⇆ 'cdd' → FULL MATCH
# 'ab' ou '(cd)*'

match_all('ab|(cd)*', ['ab', 'c', 'cd', 'cdd'])
'ab|(cd)*' ⇆  'ab' → FULL MATCH
'ab|(cd)*' ⇆   'c' → PARTIAL until char 0
'ab|(cd)*' ⇆  'cd' → FULL MATCH
'ab|(cd)*' ⇆ 'cdd' → PARTIAL until char 2
# 0 ou + occurrences de (ab ou cd)

match_all('(ab|cd)*', ['ab', 'c', 'cd', 'cdd', 'abcd'])
'(ab|cd)*' ⇆   'ab' → FULL MATCH
'(ab|cd)*' ⇆    'c' → PARTIAL until char 0
'(ab|cd)*' ⇆   'cd' → FULL MATCH
'(ab|cd)*' ⇆  'cdd' → PARTIAL until char 2
'(ab|cd)*' ⇆ 'abcd' → FULL MATCH

0 ou 1 occurrences : ..?#

très pratique pour les morceaux optionnels

# 0 ou 1 caractère

match_all('[a-z]?', ['', 'b', 'xy'])
'[a-z]?' ⇆   '' → FULL MATCH
'[a-z]?' ⇆  'b' → FULL MATCH
'[a-z]?' ⇆ 'xy' → PARTIAL until char 1

nombre d’occurrences dans un intervalle : ..{n,m}#

  • a{3} : exactement 3 occurrences de a

  • a{3,} : au moins 3 occurrences

  • a{3,6} : entre 3 et 6 occurrences

bien sûr

le a peut bien sûr être n’importe quelle regexp compliquée hein

# entre 1 et 3 occurrences de 'ab'

match_all('(ab){1,3}', ['', 'ab', 'abab', 'ababab', 'ababababababab'])
'(ab){1,3}' ⇆               '' → NO
'(ab){1,3}' ⇆             'ab' → FULL MATCH
'(ab){1,3}' ⇆           'abab' → FULL MATCH
'(ab){1,3}' ⇆         'ababab' → FULL MATCH
'(ab){1,3}' ⇆ 'ababababababab' → PARTIAL until char 6

classes de caractères \s etc..#

raccourcis qui filtrent un caractère dans une classe
définis en fonction de la configuration de l’OS en termes de langue

  • \s (pour Space) : exactement un caractère de séparation (typiquement Espace, Tabulation, Newline)

  • \w (pour Word) : exactement un caractère alphabétique ou numérique

  • \d (pour Digit) : un chiffre

  • \S, \W et \D : les complémentaires

match_all('\w+', ['eFç0', 'été', ' ta98'])
'\w+' ⇆  'eFç0' → FULL MATCH
'\w+' ⇆   'été' → FULL MATCH
'\w+' ⇆ ' ta98' → NO
<>:1: SyntaxWarning: invalid escape sequence '\w'
<>:1: SyntaxWarning: invalid escape sequence '\w'
/tmp/ipykernel_1185/1590116627.py:1: SyntaxWarning: invalid escape sequence '\w'
  match_all('\w+', ['eFç0', 'été', ' ta98'])
match_all('\s?\w+', ['eFç0', 'été', ' ta98'])
'\s?\w+' ⇆  'eFç0' → FULL MATCH
'\s?\w+' ⇆   'été' → FULL MATCH
'\s?\w+' ⇆ ' ta98' → FULL MATCH
<>:1: SyntaxWarning: invalid escape sequence '\s'
<>:1: SyntaxWarning: invalid escape sequence '\s'
/tmp/ipykernel_1185/2921324008.py:1: SyntaxWarning: invalid escape sequence '\s'
  match_all('\s?\w+', ['eFç0', 'été', ' ta98'])

groupe nommé : (?P<name>..)#

pour obtenir le même effet que les groupes anonymes (..)
mais en leur donnant un nom pour que la recherche soit moins fragile

# la même regexp essentiellement que tout à l'heure 
# mais avec deux groupes nommés cette fois
pattern = "(?P<variable>[a-z]+)=(?P<valeur>[a-z0-9]+)"

string = "foo=barbar99"

match = re.match(pattern, string)
match
<re.Match object; span=(0, 12), match='foo=barbar99'>
# et c'est plus lisible pour aller extraire les morceaux

match.group('variable'), match.group('valeur')
('foo', 'barbar99')

début et fin de chaine : ^ et $#

parfois on veut s’assurer qu’on filtre bien toute la chaine, pour cela on peut le préciser avec ces deux marques

signes équivalents

pour info, on peut aussi utiliser respectivement \A et \Z

# le comportement par défaut de match()

match_all('ab|cd', ['ab', 'abcd'])
'ab|cd' ⇆   'ab' → FULL MATCH
'ab|cd' ⇆ 'abcd' → PARTIAL until char 2
# pour forcer la chaine à matcher jusqu'au bout
# on ajoute un $ 

match_all('(ab|cd)$', ['ab', 'abcd'])
'(ab|cd)$' ⇆   'ab' → FULL MATCH
'(ab|cd)$' ⇆ 'abcd' → NO

plusieurs occurrences d’un groupe : (?P=name)#

on peut aussi spécifier que le même groupe apparaisse plusieurs fois

# la deuxième occurrence de <nom> doit être la même que la première
pattern = r'(?P<nom>\w+).*(?P=nom)'

string1 = 'Jean again Jean'
string2 = 'Jean nope Pierre'

match_all(pattern, [string1, string2])
'(?P<nom>\w+).*(?P=nom)' ⇆  'Jean again Jean' → FULL MATCH
'(?P<nom>\w+).*(?P=nom)' ⇆ 'Jean nope Pierre' → NO

quelques conseils#

  • chez certaines personnes, il y a un avant et un après les expressions régulières
    je ne veux pas vous refroidir, mais ça n’est très clairement pas un outil à utiliser à tour de bras !

  • dès que ça devient un tout petit peu compliqué, pensez à utiliser un testeur en ligne, vous gagnerez du temps
    https://pythex.org
    https://regex101.com/ (bien choisir Python) … et plein d’autres …

pour aller plus loin#