14.5. roman.py, étape 5

Maintenant que fromRoman fonctionne pour des entrées correctes, nous devons mettre en place la dernière pièce du puzzle : le faire fonctionner avec des entrées incorrectes. Cela veut dire trouver une manière d’examiner une chaîne et de déterminer si elle constitue un nombre en chiffres romains valide. C’est intrinsèquement plus difficile que de valider une entrée numérique dans toRoman, mais nous avons un outil puissant à notre disposition : les expressions régulières.

Si vous n’êtes pas familiarisé avec les expressions régulières et que vous n’avez pas lu le Chapitre 7, Expressions régulières, il est sans doute temps de le faire.

Comme nous l’avons vu au Section 7.3, «Exemple : chiffres romains», il y a plusieurs règles simples pour construire des nombres en chiffres romains à l’aide des lettres M, D, C, L, X, V et I. Récapitulons ces règles :

  1. Les caractères sont additifs. I est 1, II est 2 et III est 3. VI est 6 (littéralement «5 et 1»), VII est 7 et VIII est 8.
  2. Les caractères en un (I, X, C, and M) peuvent être répétés jusqu’à trois fois. A 4, vous devez soustraire du prochain caractère en cinq. Vous ne pouvez pas représenter 4 par IIII, au lieu de ça il est représenté par IV1 de moins que 5»). 40 s’écrit XL10 de moins que 50»), 41 s’écrit XLI, 42 XLII, 43 XLIII et 44 XLIV10 de moins que 50, puis 1 de moins que 5»).
  3. De manière similaire, à 9, vous devez soustraire du prochain caractère en un : 8 est VIII mais 9 est IX1 de moins que 10»), pas VIIII (puisque le caractère I ne peut être répété quatre fois). 90 est XC et 900 CM.
  4. Les caractères en cinq ne peuvent être répétés. 10 est toujours représenté par X, jamais par VV. 100 est toujours C, jamais LL.
  5. Les chiffres romains sont toujours écrits du plus haut vers le plus bas et lus de gauche à droite, l’ordre des caractères est donc très important. DC est 600, CD est un nombre totalement différent (400, «100 de moins que 500»). CI est 101, IC n’est même pas valide en chiffres romains (puisqu’on ne peut pas soustraire 1 directement de 100, il faudrait l’écrire XCIX, «10 de moins que 100, puis 1 de moins que 10»).

Exemple 14.12. roman5.py

Ce fichier est disponible dans le sous-répertoire py/roman/stage5/ du répertoire des exemples.

Si vous ne l’avez pas déjà fait, vous pouvez télécharger cet exemple ainsi que les autres exemples du livre.

"""Convert to and from Roman numerals"""
import re

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"

    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not re.search(romanNumeralPattern, s):                                    2
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
1 C’est simplement l’extension du motif que nous avons vu au Section 7.3, «Exemple : chiffres romains». Les dizaines sont soit XC (90), soit XL (40), soit un L optionnel suivi de 0 à 3 X optionnels. Les unités sont soit IX (9), soit IV (4), soit un V optionnel suivi de 0 à 3 I optionnels.
2 Une fois toute cette logique encodée dans notre expression régulière, le code vérifiant la validité des nombres romain est une formalité. Si re.search retourne un objet, alors l’expression régulière à reconnu la chaîne et notre entrée est valide, sinon notre entrée est invalide.

A ce stade, vous avez le droit d’être sceptique quant à la capacité de cette expression régulière longue et disgracieuse d’intercepter tous les types de nombres romains invalides. Mais vous n’avez pas à me croire sur parole, observez plutôt les résultats :

Exemple 14.13. Sortie de romantest5.py avec roman5.py


fromRoman should only accept uppercase input ... ok          1
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      2
fromRoman should fail with repeated pairs of numerals ... ok 3
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 2.864s

OK                                                           4
1 Il y a une chose que je n’ai pas mentionné à propos des expressions régulières, c’est que par défaut, elles sont sensibles à la casse. Comme notre expression régulière romanNumeralPattern est exprimée en majuscules, notre vérification re.search rejettera toute entrée qui n’est pas entièrement en majuscules. Donc notre test d’entrée en majuscules uniquement passe.
2 Plus important encore, notre test d’entrée incorrecte passe. Par exemple, le test d’antécédent mal formé vérifie les cas comme MCMC. Comme nous l’avons vu, cela ne correspond pas à notre expression régulière, donc fromRoman déclenche une exception InvalidRomanNumeralError, ce qui est ce que le cas de test d’antécédent mal formé attend, donc le test passe.
3 En fait, tous les tests d’entrées incorrectes passent. Cette expression régulière intercepte tout ce que nous avons pu imaginer quand nous avons écrit nos cas de test.
4 Et le prix du triomphe modeste est attribué au petit «OK» qui est affiché par le module unittest quand tous les tests passent.
NOTE
Quand tous vos tests passent, arrêtez d’écrire du code.