Python 3.14 t-string 实战:把 SQL 拼接和 HTML 转义收进一个函数

2026-10-28 0 159

代码评审的时候看到这么一行:

cursor.execute(f"SELECT * FROM {table} WHERE id = {uid}")

uid 来自查询参数,table 来自一个前端传过来的 tab 名称。两个都是用户能控制的。这类代码在项目里一般不止一处,散落在各个 view 里,靠人一个个改成参数化,改完过半年又会被新人写回来。

问题的根源不是大家不知道要用参数化,而是 f-string 太顺手了,顺手到没有地方可以统一拦截。Python 3.14 给的 t-string 正好补上这个位置:插值不再直接变成字符串,而是变成一个可以先检查、再决定怎么处理的结构。

t-string 到底返回什么

前面加个 t,语法和 f-string 一模一样,但结果不是 str,而是一个 Template 对象。

from string.templatelib import Template

name = "订单"
t = t"SELECT * FROM {name}"

print(type(t))              # <class 'string.templatelib.Template'>
print(t.strings)            # ('SELECT * FROM ', '')
print(t.interpolations)     # (Interpolation(value='订单', expression='name', conversion=None, format_spec=''),)
print(t.values)             # ('订单',)

关键在于 strings 和 interpolations 这两个元组。字面量部分和插值部分是分开存的,插值里还保留了原始表达式的文本(expression='name')。f-string 在求值那一刻这些东西就混成一个字符串了,t-string 把它们留着,就是为了让你在拼之前先过一道手。

顺带说,strings 永远比 interpolations 多一个。t"SELECT * FROM {name}" 的 strings 是 ('SELECT * FROM ', ''),末尾那个空串别漏了,写处理函数的时候按 strings[i] + interpolations[i] 交替取,最后再把多出来的那个 strings[-1] 补上,顺序不能乱。

案例一:HTML 转义

从简单的开始,先把插值全部转义,看结构是怎么用的。

from string.templatelib import Template, convert
from html import escape

def render_html(t: Template) -> str:
    parts = []
    for i, literal in enumerate(t.strings):
        parts.append(literal)
        if i < len(t.interpolations):
            interp = t.interpolations[i]
            value = convert(interp.value, interp.conversion, interp.format_spec)
            parts.append(escape(str(value)))
    return "".join(parts)

注意 convert() 这一步。它负责处理 !r、!s 这种转换符和 :.2f 这种格式说明,如果不调用、直接 str(interp.value),那么 t"{price:.2f}" 出来的会是一串完整浮点,和 f-string 的行为对不上,属于自己给自己埋坑。

用起来是这样:

user_input = '<img src=x onerror=alert(1)>'
print(render_html(t"<p>你好,{user_input}</p>"))
# <p>你好,&lt;img src=x onerror=alert(1)&gt;</p>

这里要讲清楚一件事:t-string 本身不提供任何安全性。它只是把值送到了你手里,会不会被转义,取决于 render_html 里有没有调 escape。真正的好处是这段逻辑只有一份,审核的时候只需要盯这一个函数,不用去翻三百个 view。

另外这个函数只解决元素内容上下文。如果插值落在属性里,比如 t'<a href="{url}" rel="external nofollow" >',只转义尖括号是不够的,javascript: 开头的 URL 照样能执行。属性上下文要单独处理 scheme 白名单。一个函数包打天下的想法趁早放弃。

案例二:把 SQL 拼接变成显式的两车道

SQL 的情况比 HTML 麻烦,因为并不是所有插值都该参数化。表名、列名这类标识符,数据库驱动不让你绑参数,只能拼进语句;而值必须走参数绑定。这两类东西混在同一行 f-string 里,就是事故的源头。

t-string 的 format_spec 刚好可以拿来打标记。约定:{col:ident} 表示这是个标识符,走白名单校验后直接拼;其余按普通值处理,用占位符加参数列表。

from string.templatelib import Template, convert

class UnsafeIdentifier(ValueError):
    pass

def build_sql(t: Template, allowed_cols: set[str]) -> tuple[str, list]:
    query = []
    params = []

    for i, literal in enumerate(t.strings):
        query.append(literal)
        if i >= len(t.interpolations):
            continue

        interp = t.interpolations[i]

        if interp.format_spec == "ident":
            ident = str(interp.value)
            if ident not in allowed_cols:
                raise UnsafeIdentifier(f"列名不在白名单: {ident!r}")
            query.append(ident)
        else:
            query.append("%s")
            params.append(convert(interp.value, interp.conversion, interp.format_spec))

    return "".join(query), params

调用:

ALLOWED = {"status", "amount", "created_at"}

col = "status"
uid = "1 OR 1=1"
q, p = build_sql(t"SELECT {col:ident} FROM orders WHERE id = {uid}", ALLOWED)

print(q)   # SELECT status FROM orders WHERE id = %s
print(p)   # ['1 OR 1=1']

uid 里那串注入尝试被原样塞进了参数列表,最终能否被正确当值处理,取决于驱动。这里出现的是 %s,是 psycopg 2/3 和 mysqlclient 的风格;换到 SQLAlchemy Core 要改成 :name,用 asyncpg 就得改成 $1。这个细节不注意,代码换驱动时全是 bug,最好是让 build_sql 接收一个参数风格参数。

{col:ident} 有个容易被误读的地方:ident 并不是什么内置语法,它只是被塞进 format_spec 的普通字符串,和 {x:.2f} 里的 .2f 是一个位置的东西。所以你在 ident 分支里千万不能调 convert,那会真的去执行 format(col, "ident"),抛一个 ValueError: Invalid format specifier 出来,信息还特别难懂。直接取 interp.value 就行,上面代码里也是这么做的。

还有一个真实会遇到的问题:format_spec 一旦被用来打标记,就没法再给这个插值加格式了。想让列名带引号比如 {col:ident} 同时又要格式化数字,两件事挤一个位置,只能放弃一个。我的处理是标识符不加任何格式化,用得上格式化的基本都是值,没有冲突。

案例三:结构化日志,用到了另一个字段

Interpolation 里除了 value,还有个 expression,是变量在源码里的原始写法。这个信息 f-string 拿不到,但做日志时正好能当字段名。

from string.templatelib import Template, convert

def to_log_fields(t: Template) -> dict:
    fields = {}
    for interp in t.interpolations:
        key = interp.expression
        if not key.isidentifier():
            raise ValueError(f"日志字段名必须是单个变量,收到: {key!r}")
        fields[key] = convert(interp.value, interp.conversion, interp.format_spec)
    return fields
order_id = 1024
amount = 199.5
print(to_log_fields(t"支付成功 {order_id} {amount}"))
# {'order_id': 1024, 'amount': 199.5}

isidentifier() 这个检查不能省。expression 是源码文本,可能是 user.id、a + b、甚至 get_user(x),直接拿来当 JSON 的 key 会生成一堆像是打错字的字段名。我见过最离谱的一个是 "d['k']",写进日志系统之后查询方完全没法用。

另外这里其实没有省下任何开销。很多介绍文章把 t-string 说成支持惰性求值,这个说法要打个折:模板构造的那一刻,{} 里的表达式就已经执行完了。t"{expensive()}" 里 expensive() 照常会跑,副作用照常发生。t-string 延迟的只是“拼接和格式化”这一步,不是“求值”这一步。想把日志计算成本省下来,还是得老老实实做 if logger.isEnabledFor(DEBUG): 的短路,t-string 帮不上忙。

几个绕不过去的限制

版本门槛。 3.14 以下直接语法报错,不是 ImportError 是 SyntaxError,整个文件都加载不了。所以任何公共库如果用了 t-string,最低版本会被锁死。这个成本要在设计阶段就想清楚,别写完了才发现 CI 上还有 3.11 的镜像。

不能当字符串用。 Template 不是 str 的子类,传给 print、logging、json.dumps 都不会得到你以为的结果。每一步都得显式处理,这是设计上的取舍,但也意味着不能“先写 t-string,以后再改处理逻辑”,因为原始代码里处处依赖它就是个字符串。

大括号规则和 f-string 一样。 字面量里的 { 要写成 {{。JSON 模板、正则、C 代码片段里这种字符很常见,写的时候会有点烦。这一点没办法绕过。

表达式里的引号。 Python 3.12 之后 f-string 内部可以用同种引号了,t-string 继承了这一点,t"{d["key"]}" 能过。但如果代码还要兼容 3.11,就别这么写。

不要贪心。 看到 t-string 就想把所有字符串都换掉,是过度设计。真正值得换的只有一类:插值值来自外部输入、又会被拼成一门有语法的语言(SQL、HTML、shell、正则)。普通的消息拼接、路径组合、日志,老老实实用 f-string,多一层处理反而更难读。

完整示例

把三块逻辑放一起,3.14 环境直接 python demo.py 能跑。

# Python 3.14+
from string.templatelib import Template, convert
from html import escape


def render_html(t: Template) -> str:
    parts = []
    for i, literal in enumerate(t.strings):
        parts.append(literal)
        if i < len(t.interpolations):
            interp = t.interpolations[i]
            value = convert(interp.value, interp.conversion, interp.format_spec)
            parts.append(escape(str(value)))
    return "".join(parts)


class UnsafeIdentifier(ValueError):
    pass


def build_sql(t: Template, allowed_cols: set[str]) -> tuple[str, list]:
    query, params = [], []
    for i, literal in enumerate(t.strings):
        query.append(literal)
        if i >= len(t.interpolations):
            continue
        interp = t.interpolations[i]
        if interp.format_spec == "ident":
            ident = str(interp.value)
            if ident not in allowed_cols:
                raise UnsafeIdentifier(f"列名不在白名单: {ident!r}")
            query.append(ident)
        else:
            query.append("%s")
            params.append(convert(interp.value, interp.conversion, interp.format_spec))
    return "".join(query), params


def to_log_fields(t: Template) -> dict:
    fields = {}
    for interp in t.interpolations:
        key = interp.expression
        if not key.isidentifier():
            raise ValueError(f"日志字段名必须是单个变量,收到: {key!r}")
        fields[key] = convert(interp.value, interp.conversion, interp.format_spec)
    return fields


if __name__ == "__main__":
    payload = "<script>alert(1)</script>"
    print(render_html(t"<div>{payload}</div>"))

    cols = {"status", "amount", "created_at"}
    q, p = build_sql(
        t"SELECT {col:ident} FROM orders WHERE status = {st} AND amount > {amt}",
        cols,
    )
    col, st, amt = "amount", "paid", 100
    print(q, p)

    try:
        build_sql(t"SELECT {bad:ident} FROM orders", cols)
    except UnsafeIdentifier as e:
        print("拦住了:", e)

    print(to_log_fields(t"支付成功 {st} {amt}"))

最后那段刻意留了一个拦截失败的例子,跑起来应该看到 拦住了: 列名不在白名单: 'bad'。如果没看到,检查一下 bad 是不是被当成值处理了——那说明 format_spec 的判断写错了,这类错误不会报异常,只会静默地走上另一条分支,非常隐蔽。

该不该迁移

如果项目里已经在用 sqlalchemy.text() 加 bindparam,或者有 jinja2 的 autoescape 兜底,那 t-string 带来的增量其实不大,不值得为了它锁版本。它的价值在新项目、或者那种“拼接散落各处、没有统一入口”的老代码库——它能让你把散落的点收成一个函数,收完之后审一处就是审全部。

至于什么时候能放心用,取决于你最低支持的 Python 版本什么时候能抬到 3.14。这个决策通常不在写代码的人手里,得跟部署那边对齐。在那之前,t-string 更适合先在工具脚本、内部服务这种版本好控的地方试,而不是直接往核心业务里塞。

Python 3.14 t-string 实战:把 SQL 拼接和 HTML 转义收进一个函数
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python Python 3.14 t-string 实战:把 SQL 拼接和 HTML 转义收进一个函数 https://www.taomawang.com/server/python/2978.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务