Python: Larkが1.0.0になったので試してみたんだけど挙動がおかしい

年が明けたら Lark の 1.0.0 がリリースされていたのに気が付いたので、以前作ったコマンド形式の入力ファイルをパースするのを、再度やってみた。フレームワーク的なパッケージにまとめて再利用できるようにしてみようと思う……んだけど、どうも挙動がおかしい。

要点を先に書くと、書いたパーサも入力ファイルも同じなのに、実行するたびに結果が異なる。原因はわからない。パーサの書き方が悪いのか、Lark のバグなのか。

順を追って説明する。

コマンド形式の入力ファイル

プログラムには何らかの入力が必要で、入力ファイルにも何らかのフォーマットが必要だ。コマンド形式というのは、入力データの内部表現を、ひとつずつコマンドを実行しながら組み立てようというものだ。この記事で扱うのは、そのフレームワーク的なライブラリで、ファイルからの入力である文字列を、コマンド(と引数)の列にパースするもの。

サンプルとして次のような入力ファイルを使う。

TITLE "example"
ADD 1 2
MUL
    3
//    5
    7  8  
    9
// this line is comment.
REPEAT "Hello!" 5  
    // this is comment too.
JOIN "Hello," "world!"  // in-line comment.
ADD-2  5  .7e03
NO-ARG-COMMAND
COMMAND-WITH-KEYWORD
    KW1  1
    KW2  2
    KW3  on
    KW4  off

文法

全体を「スクリプト」と呼ぶ。文法は次の通り。

  • 「スクリプト」は1つ以上の「ステートメント」からなる
  • 「ステートメント」は「コマンド」と0個以上の「引数」からなり、空白文字で区切られ、改行文字で終わる
  • ただし、空白文字で始まる行は前の行の続きとみなす。なので「コマンド」は必ず行頭からはじまる
  • 「引数」には数値、文字列、キーワード、真偽値の4種類がある
  • //から行末まではコメント

「引数」について補足しておく。数値は文字通りの数値だけど、整数/実数の区別はなし。文字列はダブルークォートで囲む。真偽値は true / falseyes / noon / off のいずれか(すべて小文字)。

で、キーワードが今日の主題。「引数」のリストの中で、次の引数が何のデータかを示す目印に使う。要するに Python のメソッドのキーワード引数のような使い方だ。大文字のラテン文字と数字からなり、行頭には来ないことでコマンドと区別できる。実装としては Ruby なら Symbol をつかうところだけど、Python にはそういうのがないので(ないよね?)、Keyword クラスを定義した。

文法ファイルは次のようになった。

?script : statement+

statement : line continued*

line : command _WS_INLINE arglist _WS_INLINE? _NL
     | command _WS_INLINE? _NL

continued : _INDENT arglist _WS_INLINE? _NL

command : CMDNAME

CMDNAME : UCASE_LETTER ("-"|UCASE_LETTER|DIGIT)*

arglist : arg
        | arglist _WS_INLINE arg

arg : number
    | string
    | keyword
    | boolean

number : SIGNED_NUMBER

string : ESCAPED_STRING

keyword : KWORD

KWORD : UCASE_LETTER (UCASE_LETTER|DIGIT)*

boolean : true
        | false

true : "true" | "yes" | "on"

false : "false" | "no" | "off"

_WS_INLINE : WS_INLINE

_NL : NEWLINE

_INDENT : WS_INLINE


%import common.UCASE_LETTER
%import common.DIGIT
%import common.SIGNED_NUMBER
%import common.ESCAPED_STRING
%import common.NEWLINE
%import common.WS_INLINE
%import common.CPP_COMMENT

COMMENT : WS_INLINE? CPP_COMMENT NEWLINE
COMMENT_INLINE : WS_INLINE? CPP_COMMENT

%ignore COMMENT
%ignore COMMENT_INLINE

パーサ

from lark import Lark
from lark.exceptions import UnexpectedInput
from lark.visitors import Interpreter


class Parser():
    def __init__(self):
        with open('grammer.lark', 'r') as f:
            grammer = f.read()
        self.parser = Lark(grammer, start='script')

    def parse(self, input_data):
        try:
            tree = self.parser.parse(input_data)
        except UnexpectedInput as e:
            context = e.get_context(input_data)
            print(f'Syntax error:  line = {e.line}  column = {e.column}\n')
            print(context)
            exit(1)

        script = ScriptInterpreter().visit(tree)
        return script


class ScriptInterpreter(Interpreter):
    def script(self, tree):
        return [self.visit(c) for c  in tree.children]

    def statement(self, tree):
        (cmd, args1) = self.visit(tree.children[0])
        args2 = flatten([ self.visit(a) for a in tree.children[1:] ])
        return (cmd, args1 + args2)

    def line(self, tree):
        cmd = self.visit(tree.children[0])
        if len(tree.children) > 1:
            args = flatten(self.visit(tree.children[1]))
        else:
            args = []
        return (cmd, args)

    def continued(self, tree):
        return self.visit(tree.children[0])

    def command(self, tree):
        return tree.children[0]

    def arglist(self, tree):
        args = [ self.visit(a) for a in tree.children ]
        return args

    def arg(self, tree):
        return self.visit(tree.children[0])

    def number(self, tree):
        return float(tree.children[0])

    def string(self, tree):
        return tree.children[0].strip('"')

    def keyword(self, tree):
        return Keyword(str(tree.children[0]))

    def boolean(self, tree):
        return self.visit(tree.children[0])

    def true(self, tree):
        return True

    def false(self, tree):
        return False


class Keyword():
    def __init__(self, val):
        self.val = val

    def __str__(self):
        return f'Keyword<{self.val}>'

    def __repr__(self):
        return f'Keyword<{self.val}>'


def flatten(lis):
    result = []
    for elem in lis:
        if isinstance(elem, list):
            result += flatten(elem)
        else:
            result.append(elem)
    return result

テスト用のスクリプトと実行結果

テスト用なので、入力データ(の内部表現)を組み立てる代わりにコマンドと引数リストを出力する。

from parsers import Parser
import sys


def main():
    parser = Parser()

    with open(sys.argv[1], 'r') as f:
        input_data = f.read()

    script = parser.parse(input_data)

    print('SCRIPT')
    for (cmd, args) in script:
        print('  COMMAND: ' + cmd)
        print('     ARGS: ' + repr(args))



main()

これを実行すると次のようになる。

takatoh@sofa: inputscriptparser-sample > python main.py example.dat
SCRIPT
  COMMAND: TITLE
     ARGS: ['example']
  COMMAND: ADD
     ARGS: [1.0, 2.0]
  COMMAND: MUL
     ARGS: [3.0, 7.0, 8.0, 9.0]
  COMMAND: REPEAT
     ARGS: ['Hello!', 5.0]
  COMMAND: JOIN
     ARGS: ['Hello,', 'world!']
  COMMAND: ADD-2
     ARGS: [5.0, 700.0]
  COMMAND: NO-ARG-COMMAND
     ARGS: []
  COMMAND: COMMAND-WITH-KEYWORD
     ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]

これは期待通り。ところが、何度か実行を続けると、時々次のような結果になる。

takatoh@sofa: inputscriptparser-sample > python main.py example.dat
SCRIPT
  COMMAND: TITLE
     ARGS: ['example']
  COMMAND: ADD
     ARGS: [1.0, 2.0]
  COMMAND: MUL
     ARGS: [3.0, 7.0, 8.0, 9.0]
  COMMAND: REPEAT
     ARGS: ['Hello!', 5.0, Keyword<JOIN>, 'Hello,', 'world!']
  COMMAND: ADD-2
     ARGS: [5.0, 700.0]
  COMMAND: NO-ARG-COMMAND
     ARGS: []
  COMMAND: COMMAND-WITH-KEYWORD
     ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]

コマンドであるべき JOIN が、REPEAT コマンドの引数リストの中に、JOIN というキーワードとして含まれてしまっている(JOIN に続く引数もろとも)。

最初に書いたとおり、文法ファイルも入力ファイルも、パーサも何も変えてない。なのに実行するたびに、JOIN だけコマンドになったりキーワードになったりする。

今のところ全くの原因不明。

気になるところといえば、入力ファイルの JOIN コマンドの前の行が空白文字とコメントであること。さらにもう一つ前の行の行末(5 のうしろ)に空白文字があること(この記事では見えないけど)だ。だけどこれは文法上は問題ないはずに思える。実際期待通りの結果になることもあるんだし。

というわけで、このままでは安心して使えない。どうしようか。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください