Pythonのt-string入門—f-stringとの違いと安全な文字列生成

Pythonのt-string入門—f-stringとの違いと安全な文字列生成 | mohablog

Python 3.14で t"..." という新しい文字列リテラルが入りました。f-stringによく似た見た目ですが、返ってくるのは str ではなく Template オブジェクト。SQLやHTMLへ値を埋め込む処理を安全に組み立てるための部品です。

目次

t-stringは文字列ではなくTemplateを返す

name = "Stilton"
tmpl = t"Try some {name} cheese!"
print(type(tmpl))
<class 'string.templatelib.Template'>

同じ構文をf-stringで書けば、結果は "Try some Stilton cheese!" という str。t-stringは最終文字列をまだ作りません。静的な文字列部分と補間した値を別々に抱えたまま、Template として返ってきます。

接頭辞が t に変わるだけ

リテラルの違いは先頭の t(または T)だけ。{} による補間、!r の変換指定、:.2f のフォーマット指定も、f-stringとまったく同じ書き方が通ります。違うのは評価結果の型だけです。Template はイミュータブルで、生成後に中身を書き換えることはできません。

なぜ文字列をその場で作らないのか

公式の “PEP 750: Template string literals” は、t-stringの用途としてSQLのサニタイズ、HTMLやシェルのエスケープ、DSL、ロギングを挙げています。f-stringは補間値を str.__format__ で即座に文字列化し、静的部分とつなげてしまう。途中にエスケープ処理を挟む隙がありません。t-stringは「つなげる前の状態」を手元に残すことで、その隙を作ります。値と地の文を分けて保持し、結合のルールを呼び出し側に委ねる設計です。

評価が即時か遅延か

f-stringとt-stringの差は「いつ文字列になるか」です。f-stringは書いた瞬間に各補間値へ str.__format__ が走り、結果を連結した str が手に入ります。途中に割り込む余地はありません。t-stringはこの連結を保留し、補間値を .values から後で取り出せる形で残します。

この違いは取り回しに効きます。f-stringの結果は連結済みの文字列なので、どこからどこまでが外部入力だったかを後から復元できません。t-stringは外部入力(補間値)と固定文(静的文字列)の境界を型のレベルで保ったまま渡せます。エスケープやプレースホルダ化を後段のハンドラに任せられるのはこのためです。

Templateの中身を分解する

Template は3つの属性を持ちます。静的文字列の .strings、補間の .interpolations、補間値だけを集めた .values

name, qty = "Stilton", 3
tmpl = t"{name} x {qty}"

print(list(tmpl))
print(tmpl.strings)
print(tmpl.values)
[Interpolation('Stilton', 'name', None, ''), ' x ', Interpolation(3, 'qty', None, '')]
('', ' x ', '')
('Stilton', 3)

.valuestuple(i.value for i in tmpl.interpolations) と等価で、補間値だけを順番に取り出すショートカットです。ハンドラを書くときは、.strings.interpolations を交互に並べれば元の並び順を再現できます。

イテレートと .strings で挙動が分かれる

ここに1つ落とし穴があります。Template をそのままイテレートすると、補間と補間にはさまれた空文字列は省かれます。一方 .strings は省きません。連続した補間でこの差が出ます。

a, b = "A", "B"
tmpl = t"{a}{b}"

print(list(tmpl))     # 空文字列は飛ばされる
print(tmpl.strings)   # 空文字列も保持される
[Interpolation('A', 'a', None, ''), Interpolation('B', 'b', None, '')]
('', '', '')

.strings は必ず .interpolations より1つ多く、空テンプレートでも ('',) を返します。プレースホルダの個数を .strings の境界から数えるようなロジックを書くなら、この「常にN+1個」という不変条件が効いてきます。逆にイテレートに頼ると空の境目を取りこぼすため、固定長を前提にした処理では .strings 側を使うのが安全。

Interpolationが持つ4つのフィールド

補間1つは Interpolation として表現され、次の4フィールドを持ちます。

フィールド内容t"{1 + 2!r:>5}" の例
value評価済みの値3
expression波カッコ内の式テキスト'1 + 2'
conversion変換フラグ(s/r/a/None)'r'
format_specフォーマット指定文字列'>5'

expression には元の式テキストがそのまま入ります。t"{user.id}" なら 'user.id'。ログでは値だけでなく式そのものを残せるため、どの変数を出力したのかを記録できます。これがf-stringにはない情報です。

f-stringでSQLを組み立てる危うさをt-stringで断つ

補間値をそのままクエリへ入れるSQLでは、この差がそのまま脆弱性になります。f-stringで値を直接埋め込むと何が起きるか。

user_input = "Robert'); DROP TABLE students;--"
query = f"SELECT * FROM users WHERE name = '{user_input}'"
print(query)
SELECT * FROM users WHERE name = 'Robert'); DROP TABLE students;--'

入力がそのままクエリ構文の一部になりました。古典的なSQLインジェクションです。業務で引き継いだコードに f"... WHERE id = {req}" のような行が混ざっていて、レビューのたびに同じ指摘を書いていました。f-stringは値と構文を文字列化の瞬間に溶かしてしまうため、後段で分離する手段が残りません。

Templateをプレースホルダ付きクエリに変換する

t-stringなら、補間値を ? に置き換えながらクエリ文字列と値リストを分けて取り出せます。静的部分はそのまま、補間部分だけをプレースホルダに落とすハンドラを書きます。

from string.templatelib import Template, Interpolation

def to_query(template: Template) -> tuple[str, list]:
    parts, params = [], []
    for item in template:
        if isinstance(item, Interpolation):
            parts.append("?")
            params.append(item.value)
        else:
            parts.append(item)
    return "".join(parts), params

user_input = "Robert'); DROP TABLE students;--"
min_age = 18
query, params = to_query(
    t"SELECT * FROM users WHERE name = {user_input} AND age >= {min_age}"
)
print(query)
print(params)
SELECT * FROM users WHERE name = ? AND age >= ?
["Robert'); DROP TABLE students;--", 18]

値が構文に混ざる経路をなくす

補間した値は必ず ? に落ち、params 側へ分離されます。あとは cursor.execute(query, params) にそのまま渡せば、エスケープはDBドライバが受け持つ。書き手がエスケープを忘れても、値が構文に混ざりません。f-stringのときに呼び出し側の注意力に頼っていた部分を、ハンドラの構造で肩代わりした格好です。

ここで効くのが、補間値はどんな型でもいいという点です。user_input が文字列でも min_age が整数でも、ハンドラは item.value をそのまま params へ積むだけ。型変換やクォートの判断をハンドラ側で一切しないことが、むしろ安全につながります。エスケープを「やらない」ことで、エスケープミスの余地を消す設計です。

Template同士は + で連結できる

条件によってクエリを組み替える場面では、Template 同士を + でつなげます。連結しても補間値の分離は保たれます。

base = t"SELECT * FROM logs"
where = t" WHERE level = {'ERROR'}"
combined = base + where

print(combined.strings)
print(combined.values)
('SELECT * FROM logs WHERE level = ', '')
('ERROR',)

ただし Template と素の str の連結はできません。t"x" + "y"TypeError。文字列を足したいなら t"y" のようにテンプレート側へそろえます。

HTMLは補間値だけをエスケープする

import html
from string.templatelib import Template, Interpolation

def render_html(template: Template) -> str:
    out = []
    for item in template:
        if isinstance(item, Interpolation):
            out.append(html.escape(str(item.value)))
        else:
            out.append(item)
    return "".join(out)

comment = '<script>alert("xss")</script>'
print(render_html(t"<p>{comment}</p>"))
<p>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>

静的な <p> はタグのまま、補間した comment だけが html.escape を通ってエスケープされました。テンプレート作者が「どこが固定でどこが外部入力か」を型レベルで区別できるため、エスケープ漏れを構造で防げます。SQLのハンドラと骨格は同じで、補間部分への処理を html.escape に差し替えただけです。

conversionとformat_specは自前で適用する

t-stringで気をつける点が1つ。format_specconversion は自動では効きません。Template はあくまで指定を保持するだけで、適用するのはハンドラ側の仕事です。

price = 1980
tmpl = t"price: {price:,}"
i = tmpl.interpolations[0]

print(i.value)                        # 指定は未適用
print(format(i.value, i.format_spec)) # 自前で適用
1980
1,980

str() ではレンダリングされない

Templatestr() を掛けても、補間値を埋めた文字列にはなりません。返るのは Template(strings=..., interpolations=...) という表現。f-stringの感覚で str(tmpl) と書くと、意図した出力になりません。レンダリングは必ずハンドラを通します。この仕様は安全側に倒した設計で、エスケープを通さない素の文字列化を事故として起こさせない狙いがあります。

convert ヘルパでf-string互換の変換をかける

!s / !r / !a の変換は string.templatelib.convert で再現できます。f-stringと同じ変換セマンティクスを、自分のハンドラに組み込むための関数です。

from string.templatelib import convert

i = t"{'a b'!r}".interpolations[0]
print(convert(i.value, i.conversion))
'a b'

ハンドラを正しく書くなら、conversionconvert で適用し、その結果に format_specformat() で当てる、という順番をf-stringに合わせます。この2段を省くと、!r:.2f を書いたのに効かない、という挙動になります。

ログの遅延評価とf-stringの使い分け

遅延評価はログでも効きます。出力対象が確定するまで __str__ を呼ばないため、ログレベルで捨てられる行の文字列化コストを後ろにずらせます。重い repr を持つオブジェクトをログに渡すとき、出力されない行のコストを払わずに済む。とはいえ常用するものではありません。判断の目安はこの通り。

場面選ぶ方
画面表示やメッセージ整形f-string
SQL・シェルコマンドの組み立てt-string + ハンドラ
HTMLなどエスケープが要る出力t-string + ハンドラ
重い __str__ を遅延させたいログt-string
外部入力を含まない定型文f-string

t-stringはf-stringの置き換えではありません。補間値に処理を挟みたいヘルパを書く人のための層です。アプリ実装の大半はf-stringのままで構いません。SQLドライバやテンプレートエンジンの作者が、利用者に安全なAPIを渡すための土台と捉えるのが実態に近いです。

まとめ

  • t-string(Python 3.14, PEP 750)は str ではなく string.templatelib.Template を返す
  • Template.strings / .interpolations / .values で静的部分と補間値を分離して保持する
  • イテレートは補間間の空文字列を飛ばすが .strings は保持する。固定長の処理では .strings 側を使う
  • SQLは補間値を ? に落とすハンドラで、値と構文の混在経路を断てる
  • Template 同士は + で連結できるが、素の str とは連結できない
  • format_specconversion は自動適用されない。format()convert で自前適用する
  • アプリ層の整形はf-string、エスケープやサニタイズを挟むヘルパはt-string
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次