通知メールの文面で、宛名は今わかるが発送日は後で決まる、ということがある。str.format()は足りないキーが1つでもあるとKeyErrorになるので、埋められるところだけ先に埋めることができない。

tmpl = "Hello {name}, your order {order_id} ships on {date}"
tmpl.format(name="Alice")
# KeyError: 'order_id'

dictのサブクラスに__missing__を定義すると、存在しないキーをd[key]で引いたときの動きを変えられる。str.format_map()はキーをd[key]で引くので、この2つを組み合わせると足りないところを{key}のまま残せる。

使い方

class Keep(dict):
    def __missing__(self, key):
        return "{" + key + "}"
 
 
step1 = tmpl.format_map(Keep(name="Alice"))
# 'Hello Alice, your order {order_id} ships on {date}'
 
step1.format_map(Keep(order_id=42, date="10/9"))
# 'Hello Alice, your order 42 ships on 10/9'

format(**d)ではなくformat_map(d)を使うのがポイントで、**で展開すると普通のdictにコピーされて__missing__が効かない。

__missing__が呼ばれる条件

__missing__はdict.__getitem__がキーを見つけられなかったときにだけ呼ばれる。get()やinでは呼ばれない。

k = Keep(name="Alice")
k["x"]      # '{x}'
k.get("x")  # None
"x" in k    # False

collections.defaultdictも同じ仕組みで、__missing__の中で値を作って登録している。

ハマりどころ

残したプレースホルダーに書式指定や属性アクセスが付いていると、そこで失敗する。__missing__が返すのはただの文字列なので、その文字列に対して書式指定や属性アクセスが適用されるからだ。

'{name} {date}'       -> 'Alice {date}'
'{name} {price:,}'    -> ValueError Cannot specify ',' with 's'.
'{name} {user.email}' -> AttributeError 'str' object has no attribute 'email'
'{name} {items[0]}'   -> 'Alice {'
'{name!r} {date!r}'   -> "'Alice' '{date}'"

{items[0]}は例外にならず、'{items}'の0文字目の{が入る。エラーにならないぶん気づきにくい。!rの場合は文字列{date}のreprが入るので、引用符で囲まれた'{date}'が残る。2回目に埋めると'10/9'になり、結果的に引用符付きの値になる。書式指定や!rを使うテンプレートは、この方法では分割して埋められないと考えた方がよい。

単純な置き換えだけでよければ、標準ライブラリのstring.Template.safe_substitute()でも同じことができる。こちらは$nameの形式で、足りないキーをそのまま残す。

string.Template("Hello $name, ships on $date").safe_substitute(name="Alice")
# 'Hello Alice, ships on $date'

関連

References