7.6. Etude de cas : reconnaissance de numéros de téléphone

Jusqu'ici nous nous sommes concentrés sur la reconnaissance de motifs complets, le motif est reconnu ou non. Mais les expressions régulières sont beaucoup plus puissantes que cela. Lorsqu'une expression régulière reconnaît un motif, nous pouvons sélectionner certaines parties du motif. Nous pouvons savoir ce qui a été reconnu et à quel endroit.

Cet exemple est tiré d'un autre problème réel que j'ai eu au cours de mon travail précédent. Le problème : la reconnaissance de numéros de téléphone au Etats-Unis. Le client voulait que la saisie se fasse librement (dans un champ unique), mais voulait stocker le code régional, l'indicatif, le numéro et une extension optionnelle séparément dans la base de données. Je parcourais le Web et trouvais de nombreux exemples d'expressions régulières qui avaient pour but de faire cela, mais aucune n'était assez souple.

Voici les numéros de téléphone qu'il fallait que j'accepte :

Quelle diversité ! Dans chacun de ces cas, je devais savoir que le code régional était 800, l'indicatif 555 et le reste du numéro 1212. Pour les numéros avec extension, je devais savoir que celle-ci était 1234.

Nous allons développer une solution pour la reconnaissance des numéros de téléphone. Cet exemple montre la première étape.

Exemple 7.10. Trouver des numéros

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})$') 1
>>> phonePattern.search('800-555-1212').groups()            2
('800', '555', '1212')
>>> phonePattern.search('800-555-1212-1234')                3
>>> 
1 Lisez toujours les expressions régulières de la gauche vers la droite. Celle-ci reconnaît le début de la chaîne, puis (\d{3}). Que veut dire ce \d{3}? Le {3} signifie «reconnaître exactement trois chiffres», c'est une variante de la syntaxe {n,m} que nous avons vu plus haut. \d signifie «n'importe quel chiffre» (de 0 à 9). En le mettant entre parenthèses, nous disons «reconnais exactement trois chiffres, puis identifie-les comme un groupe que je peux rappeler plus tard». Ensuite, le motif reconnaît un tiret, puis un autre groupe de trois chiffres, un autre tiret, un groupe de quatre chiffres et la fin de la chaîne.
2 Pour accéder aux groupes que l'expression régulière à identifiés, utilisez la méthode groups() de l'objet que la fonction search retourne. Elle retournera un tuple du nombre de groupes définis dans l'expression régulières. Dans ce cas, nous avons défini trois groupes, deux de trois chiffres et un de quatre.
3 Cette expression régulière n'est pas la réponse finale car elle ne prend pas en compte les numéros de téléphone avec une extension à la fin. Pour cela, nous allons devoir la modifier.

Exemple 7.11. Trouver l'extension

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})-(\d+)$') 1
>>> phonePattern.search('800-555-1212-1234').groups()             2
('800', '555', '1212', '1234')
>>> phonePattern.search('800 555 1212 1234')                      3
>>> 
>>> phonePattern.search('800-555-1212')                           4
>>> 
1 Cette expression régulière est pratiquement identique à la précédente. Elle reconnaît le début de la chaîne, puis un groupe identifié de trois chiffres, puis un tiret, puis un groupe identifié de trois chiffres, puis un tiret, puis un groupe identifié de quatre chiffres. Ce qui est nouveau, c'est qu'elle reconnaît ensuite un autre tiret puis un groupe identifié de un chiffre ou plus, puis la fin de la chaîne.
2 La méthode groups() retourne maintenant un tuple de quatre éléments, puisque l'expression régulière définit quatre groupe à identifier.
3 Malheureusement cette expression régulière n'est pas la réponse finale non plus, puisqu'elle considère que les différentes parties du numéro de téléphone sont séparées par des tirets. Et si elles étaient séparées par des espaces, des virgules ou des points ? Il nous faut une solution plus générale pour identifier différents types de séparateurs.
4 Non seulement cette expression régulière ne fait pas tout ce que nous voulions, elle est en fait un pas en arrière puisqu'elle ne peut pas reconnaître de numéros de téléphone sans extension. Ce n'est pas du tout ce que nous voulions, si l'extension est présente, nous voulons la connaître, mais si elle ne l'est pas, nous voulons tout de même connaître les différentes parties du numéro.

L'exemple suivant montre l'expression régulière qui reconnaît les séparateurs entre les différentes parties d'un numéro de téléphone.

Exemple 7.12. Reconnaissance des séparateurs

>>> phonePattern = re.compile(r'^(\d{3})\D+(\d{3})\D+(\d{4})\D+(\d+)$') 1
>>> phonePattern.search('800 555 1212 1234').groups()                   2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212-1234').groups()                   3
('800', '555', '1212', '1234')
>>> phonePattern.search('80055512121234')                               4
>>> 
>>> phonePattern.search('800-555-1212')                                 5
>>> 
1 Faites bien attention. Nous reconnaissons le début de la chaîne, puis un groupe de trois chiffres, puis \D+. Qu'est-ce que c'est que ça ? Et bien, \D reconnaît n'importe quel caractère sauf un chiffre et + signifie «un ou plus». Donc \D+ reconnaît un ou plusieurs caractères n'étant pas des chiffres. C'est ce que nous utilisons pour essayer de reconnaitre les séparateurs.
2 Utiliser \D+ au lieu de - nous permet de reconnaître des numéros de téléphone dont les différentes parties sont séparées par des espaces au lieu de tirets.
3 Bien sûr, les numéro de téléphone séparés par des tirets sont toujours reconnus.
4 Malheureusement ce n'est pas encore la réponse définitive car elle suppose qu'il y a bien un séparateur. Et si le numéro est saisi sans espaces ni tirets ?
4 Le problème de l'extension optionnelle n'a toujours pas été réglé. Maintenant nous avons deux problèmes, mais nous pouvons les régler tous les deux grâce à la même technique.

L'exemple suivant montre l'expression régulière qui reconnaît les numéros de téléphone sans séparateurs.

Exemple 7.13. Reconnaissance des numéros de téléphone sans séparateurs

>>> phonePattern = re.compile(r'^(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('80055512121234').groups()                      2
('800', '555', '1212', '1234')
>>> phonePattern.search('800.555.1212 x1234').groups()                  3
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                        4
('800', '555', '1212', '')
>>> phonePattern.search('(800)5551212 x1234')                           5
>>> 
1 La seule modification depuis la dernière étape est de remplacer le + par un *. Au lieu de \D+ entre les différentes parties du numéro de téléphone, nous avons maintenant \D*. Vous vous rappelez que + signifie «un ou plus» et bien * signifie «zéro ou plus». Nous devrions donc pouvoir reconnaître des numéros de téléphone qui n'ont pas de séparateur du tout.
2 Et ça marche. Nous avons reconnu le début de la chaîne, puis un groupe identifié de trois chiffres (800), puis zéro caractères non numériques, puis un groupe identifié de trois caractères (555), puis zéro caractères non numériques, puis un groupe identifié de quatre caractères (1212), puis zéro caractères non numériques, puis un groupe identifié d'un nombre quelconque de caractères (1234), puis la fin de la chaîne.
3 D'autre variantes marchent également maintenant : des points à la place des tirets et un espace et un x avant l'extension.
4 Finalement, nous avons trouvé une solution à notre problème, les extension sont vraiment optionnelles. Si aucune extension n'est trouvée la méthode groups() retourne un tuple de quatre éléments, mais le quatrième élément est simplement une chaîne vide.
5 La mauvaise nouvelle, c'est que nous n'avons pas terminé. Il y a un caractère supplémentaire avant le code régional, mais l'expression régulière suppose que le code régional est la première chose au début de la chaîne. Bien sûr, nous pouvons utiliser la même technique «zéro ou plus caractères non-numériques» pour sauter les caractères situés avant le code régional.

L'exemple suivant montre comment prendre en compte les caractères au début des numéros de téléphone.

Exemple 7.14. Reconnaissance des caractères de début

>>> phonePattern = re.compile(r'^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('(800)5551212 ext. 1234').groups()                 2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                           3
('800', '555', '1212', '')
>>> phonePattern.search('work 1-(800) 555.1212 #1234')                     4
>>> 
1 C'est la même chose que dans l'exemple précédent, sauf que nous reconnaissons \D*, c'est à dire zéro ou plus caractères non numériques, avant le premier groupe identifié (le code régional). Notez que nous n'identifions pas ces caractères non numériques (ils ne sont pas entre parenthèses). Si ils sont présents, nous les passons simplement et commençont à identifier le code régional quand nous y arrivons.
2 Le numéro de téléphone est correctement reconnu, même avec la parenthèse ouvrante devant le code régional (la parenthèse fermante était déjà prise en compte, elle est considérée comme un séparateur non-numérique et reconnue par le \D* suivant le premier groupe identifié).
3 Une simple vérification de cohérence pour nous assurer que nous n'avons rien endommagé de ce qui fonctionnait déjà. Puisque les caractères de début sont entièrement optionnels, le début de la chaîne est reconnu, puis zéro caractères non-numériques, puis un groupe identifié de trois caractères (800), puis un caractère non-numérique (le tiret), puis un groupe identifié de trois caractères (555), puis un caractère non-numérique (le tiret), puis un groupe identifié de quatre caractères (1212), puis zéro caractères non-numériques, puis un groupe identifié de zéro caractères numériques, puis la fin de la chaîne.
4 Mais il y a encore un problème. Pourquoi ce numéro ne fonctionne-t-il pas ? Parce qu'il y a un 1 avant le code régional et que nous avons considéré que tous les caractères de début sont non-numériques (\D*).

Faisons le point. Jusqu'à présent, nos expressions régulières commençaient toujours la reconnaissance en début de chaîne, mais maintenant nous voyons qu'il peut y avoir un nombre indeterminé de caractères à ignorer en début de chaîne. Au lieu d'essayer de les identifier pour les sauter, essayons une approche différente : ne pas reconnaître explicitemment le début de la chaîne. C'est ce que nous verrons dans l'exemple suivant.

Exemple 7.15. Un numéro de téléphone, où qu'il soit

>>> phonePattern = re.compile(r'(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()        2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                                3
('800', '555', '1212', '')
>>> phonePattern.search('80055512121234')                              4
('800', '555', '1212', '1234')
1 Notez l'absence de ^ dans cette expression régulière. Nous ne reconnaissons plus le début de la chaîne. Rien ne nous oblige à reconnaître la chaîne entière, le moteur d'expressions régulières se débrouillera pour trouver où commence la reconnaissance.
2 Maintenant, nous pouvons reconnaître un numéro de téléphone précédé de caractères et de chiffres et segmenté par des séparateurs de tout type et de toute taille.
3 Contrôle de cohérence, ça fonctionne toujours.
4 Cela aussi fonctionne toujours.

Vous avez pu voir comment les expressions régulières peuvent rapidemment échapper à tout contrôle. Parcourez les différentes versions de notre expression régulière, pouvez-vous dire quelles différences les séparent ?

Tant que nous comprenons encore la version finale (et c'est bien la version finale, si vous découvrez un cas qu'elle n'est pas capable de traiter, je ne veux pas en entendre parler), écrivons-la sous la forme d'une expression régulière détaillée avant d'oublier les choix que nous avons fait.

Exemple 7.16. Reconnaissance des numéros de téléphone (version finale)

>>> phonePattern = re.compile(r'''
                # don't match beginning of string, number can start anywhere
    (\d{3})     # area code is 3 digits (e.g. '800')
    \D*         # optional separator is any number of non-digits
    (\d{3})     # trunk is 3 digits (e.g. '555')
    \D*         # optional separator
    (\d{4})     # rest of number is 4 digits (e.g. '1212')
    \D*         # optional separator
    (\d*)       # extension is optional and can be any number of digits
    $           # end of string
    ''', re.VERBOSE)
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()        1
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                                2
('800', '555', '1212', '')
1 Mis à part le fait qu'elle est distribuée sur plusieurs lignes, c'est exactement la même expression régulière qu'à la dernière étape, il n'est donc pas étonnant qu'elle reconnaisse les mêmes entrées de manière identique.
2 Vérification de cohérence finale. Oui, ça marche toujours, nous avons terminé.

Pour en savoir plus sur les expressions régulières