Introduction au module Regex de Python

Pour rappel, le terme regex provient de la contraction du terme regular expression (expression régulière en français). Formellement, les expressions régulières permettent de décrire les grammaires régulières. Il existe plusieurs autres types de grammaires (algébriques, contextuelles et générales) et sont décrites dans la hiérarchie de Chomsky.

Cependant les grammaires régulières sont généralement suffisantes pour effectuer des tâches de traitement de texte simples telles que la validation automatique de saisie ou encore du data cleaning.

Par exemple, on peut vérifier si un numéro de téléphone est correctement formé en testant s'il correspond à la grammaire suivante : "5 paires de chiffres, éventuellement séparées par un espace".

Cette grammaire est décrite par l'expression régulière suivante :

((\d\d){4}|(\d\d[ ]){4})\d\d

En Python, cela donnerait :

import re

pattern = re.compile(r'((\d\d){4}|(\d\d[ ]){4})\d\d')

if (pattern.match('01 23 45 67 89')):
    print('Numéro valide')
else:
    print('Numéro invalide')

# output: Numéro valide

Cet article détaille le fonctionnement du module regex de la librairie standard de Python mais ne revient pas sur les fondamentaux des regex.

Compilation des regex

Avec le module regex de Python, on a le choix entre :

  • utiliser les fonctions de matching au niveau du module
  • utiliser les fonctions de matching de l'objet Pattern

Les deux méthodes sont équivalentes en matière de fonctionnalités (elles font toutes les deux appel au même code). La différence est essentiellement dans le design du code.

Lorsqu'on utilise l'objet Pattern on procède en deux étapes. On compile d'abors la regex puis ensuite on effectue des opérations sur les chaînes :

import re

# compilation
p = re.compile(r'h.*')  # retourne un objet Pattern

# opérations
p.match('hello')
p.find('world')

L'avantage de cette méthode est que l'objet Pattern retourné par re.compile peut être stocké ou passé en paramètre d'une fonction. Le design du code est plus souple. Cependant il est possible de condenser les deux étapes en utilisant les fonctions à disposition au niveau du module :

import re

# compilation et opération
re.match(r'h.*', 'hello')
# compilation et opérations
re.find(r'h.*', 'world')

Le module s'est chargé à notre place d'effectuer la compilation. On peut le voir, par exemple, en regardant le code de la fonction re.match :

# $> cat lib/python3.7/re.py

def match(pattern, string, flags=0):
    """Try to apply the pattern at the start of the string, returning
    a Match object, or None if no match was found."""
    return _compile(pattern, flags).match(string)  # <- compilation puis matching

Concernant les performances, dans la plupart des cas les deux méthodes sont équivalentes. En effet le module regex met en cache les derniers patterns compilés :

# $> cat lib/python3.7/re.py

_MAXCACHE = 512  # <- met en cache les 512 derniers patterns compilés
def _compile(pattern, flags):
    # ...
    try:
        return _cache[type(pattern), pattern, flags]  # <- mise en cache
    except KeyError:
        pass
    # ...

Eviter l'épidémie des backslashes

The Backslash Plague est le terme utilisé pour désigner ceci :

import re

re.match('\\\\\\d+', '\\1234')

Une accumulation de backslashes qui rendent la lecture de la regex très difficile pour un humain. Cette "épidémie" de backslashes provient du fait qu'ils sont utilisés par Python et par le moteur de regex comme caractère d'échappement. Par conséquent si l'on veut matcher la chaîne \1234, on doit échapper une première fois la chaîne pour l'interpréteur Python (backslases x2) puis une seconde fois pour le moteur de regex(backslases x2), soit un total de quatre backslashes.

Afin d'économiser un niveau d'échappement, on utilise les raw strings de Python, c'est-à-dire des strings non interprétées. Pour obtenir une raw string, on préfixe la chaîne avec la lettre r :

import re

re.match(r'\\\d+', '\\1234')

Les fonctions de matching

Maintenant que nous savons comment compiler (ou non) les regex et comment éviter que les backslashes polluent la lecture, passons aux fonctions qui permettent d'appliquer ces regex sur des chaînes de caractères.

Voici la liste des fonctions disponibles dans le module regex :

Fonction Description
match Match a regular expression pattern to the beginning of a string.
fullmatch Match a regular expression pattern to all of a string.
search Search a string for the presence of a pattern.
sub Substitute occurrences of a pattern found in a string.
subn Same as sub, but also return the number of substitutions made.
split Split a string by the occurrences of a pattern.
findall Find all occurrences of a pattern in a string.
finditer Return an iterator yielding a Match object for each match.
compile Compile a pattern into a Pattern object.
purge Clear the regular expression cache.
escape Backslash all non-alphanumerics in a string.

Certaines fonctions ont des comportements trés similaires (match et search par exemple), les sections ci-dessous abordent les grands types de fonctions et illustrent les nuances par des exemples.

match vs fullmatch vs search vs findall

Le fonctionement de ces quatres fonctions est similaire :

  • match évalue si le début de la chaine correspond au pattern
  • fullmatch évalue si l'intégralité de la chaine correspond au pattern
  • search évalue si une sous-chaine quelconque correspond au pattern
  • findall renvoie toutes les sous-chaines correspondant au pattern
import re

p1 = re.compile('[a-z]+')
p2 = re.compile('[0-9]+')

# match
print(p1.match('abcd 1234 efgh'))
>>> <re.Match object; span=(0, 4), match='abcd'>
print(p2.match('abcd 1234 efgh'))
>>> None

# fullmatch
print(p1.fullmatch('abcd 1234 efgh'))
>>> None
print(p2.fullmatch('abcd 1234 efgh'))
>>> None

# search
print(p1.search('abcd 1234 efgh'))
>>> <re.Match object; span=(0, 4), match='abcd'>
print(p2.search('abcd 1234 efgh'))
>>> <re.Match object; span=(5, 9), match='1234'>

# findall
print(p1.findall('abcd 1234 efgh'))
>>> ['abcd', 'efgh']
print(p2.findall('abcd 1234 efgh'))
>>> ['1234']

sub et subn

sub et subn permettent de subtituer une chaîne aux sous-chaînes qui matchent le pattern :

  • sub renvoie une nouvelle chaîne de caractères avec les substitutions effectuées
  • subn renvoie en plus le nombre de substitutions effectuées
import re

p = re.compile('[0-9]')

# sub
print(p.sub('#', '10 apples and 20 oranges'))
>>> '## apples and ## oranges'

# subn
print(p.subn('#', '10 apples and 20 oranges'))
>>> ('## apples and ## oranges', 4)

split

La fonction re.split est très similaire à la fonction str.split, à la différence que le séparateur de re.split est une regex :

import re

p = re.compile(':+')

# split
print(p.split('abcd:1234::efgh:::5678'))
>>> ['abcd', '1234', 'efgh', '5678']

Flags de compilation

Il peut être utile de modifier le comportement du moteur de regex. Par exemple, on veut parfois ne pas faire de distinction entre les lettres minuscules et majuscules. Alors plutôt que de réécrire une regex générique (au détriment de la lisibilité), on peut simplement l'indiquer au moteur de regex avec un flag.

Les flags sont optionnels et on peut en utiliser zéro, un ou plusieurs par regex. Pour utiliser plusieurs flags, il suffit de les combiner à l'aide de l'opérateur bit à bit «|».

Voici la liste des flags de compilation disponibles dans le module regex :

Flag Description
ASCII For string patterns, make \w, \W, \b, \B, \d, \D match the corresponding ASCII character categories (rather than the whole Unicode categories, which is the default). For bytes patterns, this flag is the only available behaviour and needn't be specified.
IGNORECASE Perform case-insensitive matching.
LOCALE Make \w, \W, \b, \B, dependent on the current locale.
MULTILINE "^" matches the beginning of lines (after a newline) as well as the string. "$" matches the end of lines (before a newline) as well as the end of the string.
DOTALL "." matches any character at all, including the newline.
VERBOSE Ignore whitespace and comments for nicer looking RE's.
UNICODE For compatibility only. Ignored for string patterns (it is the default), and forbidden for bytes patterns.

Quelques exemples d'utilisation :

IGNORECASE

Le flag IGNORECASE permet de rendre les regex insensibles à la casse.

import re

p1 = re.compile('[a-z]+')
p2 = re.compile('[a-z]+', re.IGNORECASE)

# without IGNORECASE
print(p1.search('helloWORLD'))
>>> <re.Match object; span=(0, 5), match='hello'>

# with IGNORECASE
print(p2.search('helloWORLD'))
>>> <re.Match object; span=(0, 10), match='helloWORLD'>

VERBOSE

Le flag VERBOSE permet d'ajouter des espaces et des commentaires dans les regex. Cela permet une amélioration de la lisibilité de la regex.

Exemple avec la regex décrivant la syntaxe d'un numéro de téléphone :

import re

# without VERBOSE, hard to read
p = re.compile(r'((\d\d){4}|(\d\d[ ]){4})\d\d')

# with VERBOSE, easier to read
p = re.compile(r'''
    (
        (\d\d){4}           # four times two numbers
        | (\d\d[ ]){4}      # or four times two numbers followed by a space
    )
    \d\d                    # ends with two numbers
''', re.VERBOSE)

À retenir

  • On peut choisir de compiler ou non les regex. Dans le cas où le nombre de regex utilisées est faible, cela n'a pas d'impact sur les performances grace à la mise en cache.
  • On utilise des raw strings afin d'éviter la multiplication des backslashes.
  • Les flags de compilation permettent de modifier le comportement du moteur de regex.