Python 3.14 t-strings 上手:从 f-string 的坑到自己实现安全模板引擎

2026-09-25 0 212

f-string 是 Python 里最顺手的语法糖,顺到很多人已经把它当成字符串拼接的默认方案。写 SQL 用 f-string,拼 HTML 用 f-string,打日志还是 f-string。

问题在于,这三件事本质上是同一件事:把不可信的运行时数据塞进一个有语法的文本结构里。而 f-string 的求值结果永远是一个扁平的 str——一旦求值完成,你再也分不清哪一段是程序写死的字面量,哪一段是用户传进来的值。这个信息的丢失,就是 SQL 注入、XSS 和各种密钥泄露日志的根因。

Python 3.14 正式版引入的 t-strings(PEP 750)改的就是这一点。

一、t-string 到底返回了什么

语法上只多了一个字母:

from string.templatelib import Template, Interpolation

name = "alice"
t = t"hello, {name}"

print(type(t))   # <class 'string.templatelib.Template'>
print(t)         # 直接 print 会报错吗?不会,Template 有 __repr__

注意两件事。第一,t 不是字符串,是 Template 对象。第二,t 没有继承 str,所以你没法把它丢给 requests.post(url, data=t) 这种接口,它会在运行时报类型错误。

这个”不兼容”是故意设计的。t-string 想让你在把模板交给下游之前,先显式地做一次转换。

Template 的两个核心属性

t = t"SELECT * FROM users WHERE name = {user} AND age > {age}"

print(t.strings)
# ('SELECT * FROM users WHERE name = ', ' AND age > ', '')

print(t.interpolations)
# (Interpolation(...), Interpolation(...))

规律很清楚:strings 的长度永远是 interpolations 长度加一,中间交替排列。数组下标 i 对应的插值,正好夹在 strings[i] 和 strings[i+1] 之间。

每个 Interpolation 身上挂着四个东西:

item = t.interpolations[0]
print(item.value)        # 原始对象,注意是原始对象,没有被 str() 过
print(item.expression)   # 'user',源码里写的表达式文本
print(item.conversion)   # None / 'r' / 's' / 'a'
print(item.format_spec)  # '' 或 '>10' 之类的格式描述

value 是关键设计。在 f-string 里,f"{obj}" 会立刻调用 obj.__format__,你拿回来的已经是字符串了。t-string 不这么干,它把原始对象原封不动递给你,把”要不要转成字符串、怎么转”的决定权交还给调用方。

expression 是另一个被低估的属性。它保留了源码里那个表达式的字面文本,这件事 f-string 做不到。后面第三个实战例子会用到它。

Template 可以迭代

除了按下标访问,Template 本身是可迭代的,交替产出 str 和 Interpolation:

for part in t:
    match part:
        case str():
            print("字面量:", part)
        case Interpolation(value, expression, conversion, format_spec):
            print("插值:", expression, "=", repr(value))

写解析器的时候,这种 match 写法比双下标循环好读不少。下面三个实战例子,我会根据场景混用两种写法。

二、实战一:把 t-string 编译成参数化 SQL

先看传统写法错在哪:

user_input = "'; DROP TABLE users; --"
sql = f"SELECT * FROM users WHERE name = '{user_input}'"
cursor.execute(sql)

这行代码执行的时候,数据库看到的是三条语句。正确做法是让驱动层处理参数,也就是占位符 + 参数列表。但很多人在写复杂查询时,会因为”占位符拼起来太麻烦”而退回到 f-string。t-string 刚好把这个心智负担抹掉了。

from string.templatelib import Template

def to_sql(t: Template) -> tuple[str, tuple]:
    """把 t-string 编译成 (带占位符的 SQL, 参数元组)"""
    text = []
    params = []

    for idx, literal in enumerate(t.strings):
        text.append(literal)
        if idx < len(t.interpolations):
            text.append("?")                       # sqlite / mysql 用 %s,psycopg 用 %s
            params.append(t.interpolations[idx].value)

    return "".join(text), tuple(params)

用起来是这样:

user_input = "'; DROP TABLE users; --"
query, args = to_sql(
    t"SELECT * FROM users WHERE name = {user_input} AND status = {1}"
)

print(query)
# SELECT * FROM users WHERE name = ? AND status = ?

print(args)
# ("'; DROP TABLE users; --", 1)

cursor.execute(query, args)

用户输入被原样放进了参数列表,数据库驱动会把它当成纯数据,注入路径就此关闭。

这里有个细节值得注意:params 里存的是 value 本身,不是 str(value)。整数保持整数,datetime 保持 datetime——驱动层能拿到正确的类型信息,这比先转成字符串再让驱动猜类型要可靠得多。

如果同一个变量在 SQL 里出现两次,写成 {user} 和 {user} 两次就行,参数列表里自然会有两个值,不必给占位符编号。

三、实战二:自动转义的 HTML 构造器

HTML 场景比 SQL 更微妙。SQL 的规则是”所有插值都当参数”,非黑即白。HTML 里则存在”这段 HTML 我自己生成的,是安全的”这种情况,不能无脑全转义。

所以需要一个显式的安全标记类型:

from html import escape
from string.templatelib import Template


class Safe(str):
    """标记一段已经转义过、可以原样输出的 HTML 片段"""
    __slots__ = ()


def html(t: Template) -> Safe:
    chunks = []

    for idx, literal in enumerate(t.strings):
        chunks.append(literal)
        if idx < len(t.interpolations):
            value = t.interpolations[idx].value
            if isinstance(value, Safe):
                chunks.append(value)          # 已被标记为安全,直接放行
            else:
                chunks.append(escape(str(value)))

    return Safe("".join(chunks))

试一下跨站脚本:

name = '<img src=x onerror=alert(1)>'

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

再试嵌套,这是最容易出问题的地方——外层模板不能把内层已经转义过的内容再转一遍:

row = html(t"<li>{name}</li>")          # 这里已经转义过了
list_html = html(t"<ul>{row}</ul>")       # row 是 Safe,不会被二次转义

print(list_html)
# <ul><li>&lt;img src=x onerror=alert(1)&gt;</li></ul>

如果不用 Safe 标记,嵌套场景下 &lt; 会被转成 &amp;lt;,页面上就会直接显示出乱码般的实体字符。这个坑在 f-string 时代靠人工维护”哪些变量是安全的”来解决,项目一大就必然出错。t-string 把这件事变成了类型系统能管的问题。

四、实战三:日志脱敏,用上 expression 属性

前两个例子,说实话用自定义函数 + 手工参数列表也能做到。第三个例子才是 t-string 真正没有替代方案的地方。

想象一个 API 调用日志:

logger.info(f"调用支付接口 user={user} api_key={api_key} amount={amount}")
api_key = "sk-live-abcdefghijklmnopqrstuvwxyz"

密钥进了日志文件,然后被采集系统同步到某台不该有它的机器上。事后想从日志里清理,已经扩散了。

t-string 的 expression 能在不执行额外代码的前提下,知道”这个插值来自哪个变量名”:

import re
from string.templatelib import Template

SENSITIVE_NAMES = ("password", "passwd", "token", "secret", "api_key", "apikey", "credential")

VALUE_PATTERNS = [
    (re.compile(r"sk-[A-Za-z0-9]{10,}"), "sk-***"),
    (re.compile(r"(?i)(bearers+)[w-.]+"), r"1***"),
    (re.compile(r"bd{16,19}b"), "***card***"),
]


def _mask_by_value(text: str) -> str:
    for pattern, repl in VALUE_PATTERNS:
        text = pattern.sub(repl, text)
    return text


def redact(t: Template) -> str:
    parts = []

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

        item = t.interpolations[idx]
        varname = item.expression.lower()

        # 第一层:按变量名判断
        if any(key in varname for key in SENSITIVE_NAMES):
            parts.append("<redacted>")
            continue

        # 第二层:按值的形态兜底
        parts.append(_mask_by_value(str(item.value)))

    return "".join(parts)

跑一下:

user = "alice"
api_key = "sk-live-abcdefghijklmnopqrstuvwxyz"
order_id = "202503170001"

print(redact(t"支付回调 user={user} api_key={api_key} order={order_id}"))
# 支付回调 user=alice api_key=<redacted> order=202503170001

这里的关键在于:item.expression 拿到的是源码里写的变量名 "api_key"。用 f-string 你拿不到这个信息——求值的那一刻,一切都已经变成字符串了,脱敏函数只能去正则匹配值的长相,猜错的概率很高。

当然,expression 也有局限:t"{config['key']}" 拿到的是 "config['key']" 这整段文本,t"{get_key()}" 拿到的是 "get_key()"。所以它是”变量名启发式”,不是万能的。两层规则(名字 + 值的形态)叠在一起才够用。

五、几个实际会踩到的坑

1. Template 不能相加

t1 = t"hello {name}"
t2 = t"world {age}"

t1 + t2   # TypeError

这是有意为之。如果允许拼接,解析器就没法在编译期判断边界。需要合并的话,得自己写个小工具把两个 Template 的 strings 和 interpolations 重新组装成一个新的 Template。

2. conversion 和 format_spec 不会自动生效

前面提过,value 是原始对象。这意味着 t"{x!r}" 里的 !r、t"{x:.2f}" 里的 :.2f 都只是被记录下来了,不会自己执行。要在自己的渲染函数里显式处理:

def resolve(item) -> str:
    value = item.value
    if item.conversion == "r":
        value = repr(value)
    elif item.conversion == "s":
        value = str(value)
    elif item.conversion == "a":
        value = ascii(value)

    if item.format_spec:
        value = format(value, item.format_spec)

    return str(value)

注意 format() 要放在转换之后,这和 f-string 的行为一致(f"{x!r:>10}" 是先 repr 再右对齐)。反过来说,如果你的场景里格式说明符没意义(比如 SQL 参数化),干脆忽略它,直接把 value 传给驱动。

3. 不要给 t-string 套一层自动 str 转换的封装

有人图省事,写个 class LazyStr(str) 让 Template 自动转成字符串。这一步一旦做了,t-string 的全部价值就归零了——你回到了 f-string 的起点,只是多了一层开销。

4. 版本检测

import sys
if sys.version_info < (3, 14):
    raise RuntimeError("本项目依赖 Python 3.14 的 t-strings")

如果库需要同时支持旧版本,可以用 uv 的依赖分组或者 importlib 做条件导入,把 t-string 相关的实现放在单独模块里。

六、什么时候值得用

t-string 不是 f-string 的替代品。给一段普通文本插几个变量,f-string 依然是最好的选择,它更短、更快、更直观。

t-string 真正解决的是这一小类场景:字符串会被另一种语言解析,且其中一部分内容是外部输入。SQL、HTML、shell 命令、正则表达式、日志、模板引擎、序列化格式,都属于这一类。

判断标准很简单:如果你写完之后需要加一句注释解释”这里为什么不能直接拼”,那就是该用 t-string 的地方。

另外值得一提的是,t-string 的落地方式决定了它对现有代码的侵入性很低。你不需要重写整个项目,只需要在真正危险的那几个函数上,把参数类型从 str 改成 Template,然后在函数体里做一次解析。调用方从 f"..." 改成 t"..." 就行了,改一个字母。剩下的工作——转义、参数化、脱敏——全部收敛到那几个函数里。

这种”把安全性从人的注意力转移到类型签名上”的思路,大概是 t-string 相比 f-string 最实质的进步。

Python 3.14 t-strings 上手:从 f-string 的坑到自己实现安全模板引擎
收藏 (0) 打赏

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

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

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

淘吗网 python Python 3.14 t-strings 上手:从 f-string 的坑到自己实现安全模板引擎 https://www.taomawang.com/server/python/2814.html

常见问题

相关文章

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

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