【Python】引用符の中の空白を残して分割する:shlexでトークン化

PythonのTopに戻る

引用符の中にある空白を一つの値にする

name="sample A" count=3という設定文字列を空白でsplit()すると、sampleとAが別々になってしまう。POSIX風の引用符を認める短い設定であれば、shlex.split()で引用符を解釈して単語へ分けられる。分割後のname=sample Aは一つの文字列になるため、その後に最初の=でキーと値を分ければよい。

shlexは小さなシェル風構文を読む道具であり、CSVやWindowsコマンド全般のパーサーではない。ここではnameとcountという2項目だけを受け付ける設定として使い、分割した文字列をコマンドとして実行しない。ファイル形式がすでに決まっている場合は、CSVならcsv、TOMLならtomllibのように、その形式専用の道具を選ぶ。

トークン化と項目の検証を分ける

example.py

import shlex


def parse_settings(text):
    settings = {}
    for token in shlex.split(text, comments=False, posix=True):
        if "=" not in token:
            raise ValueError("expected key=value")
        key, value = token.split("=", 1)
        if key not in {"name", "count"}:
            raise ValueError("unknown key")
        if key in settings:
            raise ValueError("duplicate key")
        settings[key] = value
    if set(settings) != {"name", "count"} or not settings["name"]:
        raise ValueError("name and count are required")
    count = int(settings["count"])
    if not 1 <= count <= 100:
        raise ValueError("count must be from 1 to 100")
    return {"name": settings["name"], "count": count}


line = 'name="sample A=#1" count=3'
result = parse_settings(line)
assert result == {"name": "sample A=#1", "count": 3}
assert shlex.split('name=""', posix=True) == ["name="]
print(result)
for invalid in ('name="unfinished count=3', 'name=A count=2 count=3', 'name=A count=0'):
    try:
        parse_settings(invalid)
    except ValueError:
        print(repr(invalid), "rejected")
    else:
        raise AssertionError("unexpected acceptance")

実行結果

{'name': 'sample A=#1', 'count': 3}
'name="unfinished count=3' rejected
'name=A count=2 count=3' rejected
'name=A count=0' rejected

引用符は値をまとめるために使われる

posix=Trueでは引用符が構文として処理され、結果の単語から外れる。元の文字列でどちらの引用符を使ったかをそのまま保存する機能ではない。空の引用符で囲まれた値もトークンとして存在するので、name=""はname=になる。空値を認めるかどうかはトークン化の後に確認する。

comments=Falseを明示したため、#はこの設定ではコメント開始ではない。例の#1は名前の一部として残る。コメントを使う仕様に変更するなら、引用符の内側と外側で#の扱いがどうなるかを確認してからcomments=Trueを選ぶ。読む側が勝手にコメントを認めると、利用者が値として書いた文字列の後半を失う可能性がある。

token.split("=", 1)の1は、最初の=だけで分割する指定である。名前の中に=があっても後半へ残せる。キーの重複は最後の値で上書きせず、例では入力ミスとして拒否している。上書きを認める設定もあるが、優先順位の規則が必要になる。知らないキーを無視しないようにすると、countの綴り間違いを早めに見つけられる。

数字や引用符の誤りを扱う

引用符が閉じていないとshlex.split()がValueErrorを送出する。途中まで読めた項目だけを使って処理を続けず、設定全体を無効として扱う方が分かりやすい。countの変換もValueErrorになるが、利用者へ詳細を示すなら、トークン化の失敗と数値の失敗を別の文脈で捕まえると原因を伝えやすい。

int()は前後空白や符号なども受け付けるため、この例のcountは「Pythonで整数に変換でき、1〜100に入る値」という仕様になる。「半角数字だけ」「先頭ゼロなし」などの表記まで指定したいなら、int()の前にfullmatch()などで検証する。型変換と表記検証を同じものとして扱わない。

POSIXのバックスラッシュ規則が適用されるため、Windowsのパスを何も考えず設定に貼り付けると意図しない解釈になることがある。Windowsのシェルへ渡す文字列の正しさを、このLinux上の実行結果だけで保証することもできない。パスを主要な入力にするなら、引用規則の明確な設定ファイルやコマンド引数のリストを使う方が扱いやすい。

トークンができても実行してよいとは限らない

分割できた文字列には、危険なコマンド名やオプションも含められる。shlexを通したことは外部コマンドの実行許可や安全確認にはならない。実行が必要な別の用途では、許可する処理と引数を限定し、subprocessへリストで渡すなど、呼び出し側の設計を行う。ここでは設定値を辞書に戻すところで完結させている。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:TOML設定ファイルをtomllibで読み込み、入力ミスを早めに見つける / subprocess.runで外部コマンドの失敗・出力・時間切れを扱う

PythonのTopに戻る