• # Pyparsing & Parsimonious

    Posté par (site web personnel) . En réponse au journal Génération de code (Python) avec Grako. Évalué à 4.

    Sommaire

    Super journal, mais pourquoi n'a-t'il pas déjà été promu en dépêche ;-)

    Tombant d'autant plus à pic que Grako vient de sortir une version 3.9.3 le 30 juin dernier.

    Personnellement j'avais essayé parcimonious et pyparsing.
    Contrairement à Grako qui génère du code python à utiliser dans son application, parsimonious et pyparsing
    sont à utiliser comme une lib standard dans laquelle on définie une grammaire chargée à l'exécution.
    C'est en fait ce que tu fais via l'intégration de l'outil en ligne de commande Grako à ton code, je pense
    même que ça mériterai que tu soumettes une PR pour ajouter cette fonctionnalité à la lib de base.

    Pyparsing

    Pyparsing (passé en 2.1.5 le 13 juin dernier, décidement c'est le mois !) fourni un moyen pythonique de définir sa grammaire :

    from pyparsing import Forward, Combine, Word, Optional, Literal, ZeroOrMore, nums
    expression = Forward()
    nombre = Combine(Word("+-"+nums, nums) + Optional(Literal('.') + nums)).setName('nombre')
    facteur = (Literal("(").suppress() + expression + Literal(")").suppress()).setName('facteur') | nombre
    terme = ZeroOrMore(facteur.setResultsName('left') + Word('* /').setResultsName('op')) + facteur.setResultsName('right')
    expression << ZeroOrMore(terme.setResultsName('left') + Word('+ -').setResultsName('op')) + terme.setResultsName('right')
    ast = expression.parseString("45 + 98 / (76 * 2)")
    print(ast.asDict())

    Qui retourne

    {
     'left': '76',
     'op': '* ',
     'right': {
     'left': '76',
     'op': '* ',
     'right': '76'
     }
    }
    

    On remarque les suppress pour exclure un token de l'ast ainsi que setResultsName qui est l'équivalent de la syntaxe de nommage de Grako.
    On peut voir aussi l'utilisation de l'objet Forward permettant de faire référence à un élément avant sa définition.

    Une option intéressante est de pouvoir utiliser la méthode setParseAction permettant de déclencher une fonction prenant le nœud du token
    en argument. Cela permet de customiser l'ast, voir de faire son évaluation pendant cette phase.

    Toutefois d'un point de vu ressenti personnel, je trouve qu'on galère beaucoup pour écrire sa grammaire justement à cause de ce problème de forward.
    Dans mon exemple j'utilise des ZeroOrMore pour éviter d'utiliser de la récursion (les forwards donc) qui en général ne
    fait rien d'autre que des erreurs de parsing plus ou moins abscons.
    De plus on perd la compatibilité avec la syntaxe BNF ce qui rend les choses plus compliqués si on veut réimplémenter dans un autre langage.

    Parcimonious

    Parcimonious avait une syntaxe plus proche d'une bnf :

    RAW_GRAMMAR = """
    expression = addition / soustraction / terme
    addition = terme ws "+" ws expression
    soustraction = terme ws "-" ws expression
    terme = multiplication / division / facteur
    multiplication = facteur ws "*" ws terme
    division = facteur ws "/" ws terme
    facteur = ("(" ws expression ws ")") / nombre
    nombre = ~"[-+]"? chiffre+ ("." chiffre+)?
    chiffre = ~"[0-9]"
    ws = ~"\\s*"
    parser = Grammar(RAW_GRAMMAR)
    ast = parser.parse("45 + 98 / (76 * 2)")
    print(ast)

    Le résultat est très verbeux :

    <Node called "expression" matching "45 + 98 / (76 * 2)">
     <Node called "addition" matching "45 + 98 / (76 * 2)">
     <Node called "terme" matching "45">
     <Node called "facteur" matching "45">
     <Node called "nombre" matching "45">
     <Node matching "">
     <Node matching "45">
     <RegexNode called "chiffre" matching "4">
     <RegexNode called "chiffre" matching "5">
     <Node matching "">
     <RegexNode called "ws" matching " ">
     <Node matching "+">
     <RegexNode called "ws" matching " ">
     <Node called "expression" matching "98 / (76 * 2)">
     <Node called "terme" matching "98 / (76 * 2)">
     <Node called "division" matching "98 / (76 * 2)">
     <Node called "facteur" matching "98">
     <Node called "nombre" matching "98">
     <Node matching "">
     <Node matching "98">
     <RegexNode called "chiffre" matching "9">
     <RegexNode called "chiffre" matching "8">
     <Node matching "">
     <RegexNode called "ws" matching " ">
     <Node matching "/">
     <RegexNode called "ws" matching " ">
     <Node called "terme" matching "(76 * 2)">
     <Node called "facteur" matching "(76 * 2)">
     <Node matching "(76 * 2)">
     <Node matching "(">
     <RegexNode called "ws" matching "">
     <Node called "expression" matching "76 * 2">
     <Node called "terme" matching "76 * 2">
     <Node called "multiplication" matching "76 * 2">
     <Node called "facteur" matching "76">
     <Node called "nombre" matching "76">
     <Node matching "">
     <Node matching "76">
     <RegexNode called "chiffre" matching "7">
     <RegexNode called "chiffre" matching "6">
     <Node matching "">
     <RegexNode called "ws" matching " ">
     <Node matching "*">
     <RegexNode called "ws" matching " ">
     <Node called "terme" matching "2">
     <Node called "facteur" matching "2">
     <Node called "nombre" matching "2">
     <Node matching "">
     <Node matching "2">
     <RegexNode called "chiffre" matching "2">
     <Node matching "">
     <RegexNode called "ws" matching "">
     <Node matching ")">
    

    Contrairement à pyparsing, pas d'équivalent à suppress ici, mêmes les espaces sont présent dans l'ast
    (ici sous la forme de la règle ws).

    Il faut donc gérer ça dans le visitor parcourant l'ast :

    class Visitor(NodeVisitor):
     def visit_expression(self, n, vc):
     return vc[0]
     def visit_addition(self, n, vc):
     left, _, _, _, right = vc
     return left + right
     def visit_soustraction(self, n, vc):
     left, _, _, _, right = vc
     return left - right
     def visit_term(self, n, vc):
     return vc[0]
     def visit_multiplication(self, n, vc):
     left, _, _, _, right = vc
     return left * right
     def visit_division(self, n, vc):
     left, _, _, _, right = vc
     return left / right
     def visit_facteur(self, n, vc):
     if len(vc) == 1:
     return vc[0]
     else:
     return vc[2]
     def visit_nombre(self, n, vc):
     return float(n.text)
     def generic_visit(self, n, vc):
     return vc[0] if vc else n
    Visitor().visit(ast)

    Niveau débuggage, c'est là encore pas très confortable, exemple:

    >>> print(vc)
    [s = '(76 * 2)'
    Node('', s, 0, 1), s = '(76 * 2)'
    RegexNode('ws', s, 1, 1), 152.0, s = '(76 * 2)'
    RegexNode('ws', s, 7, 7), s = '(76 * 2)'
    Node('', s, 7, 8)]

    Là il faut comprendre qu'on a une liste de nœuds, simplement leurs __repr__ retournent une string contenant un retour à la ligne et pas de séparation claire !
    J'aurai préféré un truc du style :

    [<s = '(76 * 2)', Node('', s, 0, 1)>,
    <s = '(76 * 2)', RegexNode('ws', s, 1, 1)>,
    152.0,
    <s = '(76 * 2)', RegexNode('ws', s, 7, 7)>,
    <s = '(76 * 2)', Node('', s, 7, 8)>]

    Je devrais leur faire une PR mais vu que le projet n'a pas évolué depuis 2 ans...

    Bilan

    Je n'ai été qu'à moitié convaincu par ces deux lib, je pense que pyparsing est pas mal pour les petites grammaire
    en raison de son API et de sa capacité à évaluer les nœuds tout en les parsants.
    Parsimonious de son côté me semble trop simple et son développement semble stoppé (pas de maj depuis 2ans), à éviter donc.