Привет, Хабр! Приближается релиз Python 3.14, который несет нам множество нововведений. Среди них — новый способ форматирования строк. Давайте посмотрим, что из себя представляют t-строки, на что они годятся и как устроены внутри. Фича действительно мощная, будет интересно.

Что мы имеем сейчас

Прежде чем начать, предлагаю вспомнить, какие способы отформатировать текст в Python у нас уже есть:

Первый — максимально классический, используя +:

a = 1
b = "hello"
print("I want to say: " + b + " (" + str(a) + ")")

Думаю, все (редко ли, часто ли) прибегают к такому способу. Минусы очевидны: необходимо следить за типами переменных, явно приводить все к строке с помощью str(). А еще тут очень много лишних символов, и понять, что написано в такой строке, очень сложно. Плюс на каждую операцию конкатенации создается новый объект строки, что бьёт по производительности.

Второй способ — %:

a = 1
b = "hello"
print("I want to say: %s (%s)" % (a, b))

Очевидны многие плюсы по сравнению с предыдущим: «форматирование процентом» более лаконично. Для большего понимания того, что где будет вставляться, можно использовать имена:

print("I want to say: %(text)s (%(number)s)" % {"text": a, "number": b})

Также % позволяет делать дополнительные операции с текстом: выравнивание, заполнение (например нулями), формат чисел и фиксирование количества цифр после запятой.

Третий способ — str.format/str.format_map:

a = 1
b = "hello"
print("I want to say {} ({})".format(a, b))

Имеет все те же плюсы, что и %, но в дополнение к этому имеет более мощный язык спецификации форматирования (ссылка). Тоже поддерживает именованную замену

Четвертый способ — f-строки:

a = 1
b = "hello"
print(f"I want to say {a} ({b})")

По своей сути, это тот же str.format, но моментальный: в скобках записываются вычисляемые выражения, результат которых будет туда подставлен. Поддерживается весь арсенал str.format плюс к этому — специальный символ =, который работает так:

a = 1
print(f"{a=}")
>>> a=1

Важное отличие в том, что такую строку (в отличие от двух предыдущих способов) нельзя заранее подготовить, а отформатировать когда-нибудь потом: все выражения eagerly-evaluated.

Пятый способ — довольно забытый string.Template:

from string import Template
a = 1
b = "hello"
t = Template("I want to say $a ($b)")
print(t.substitute({"a": a, "b": b}))

Он подходит для простой замены и не поддерживает дополнительные функции вроде форматирования. Зато их можно отлично использовать каких-нибудь шаблонных сообщениях в разных форматах благодаря возможности настраивать разделитель — выбирать любой другой символ вместо $.

Синтаксис и поведение t-строк

Теперь посмотрим на синтаксис t-строк. По факту, он никак не отличается от синтаксиса f-строк, кроме, собственно, префикса. Выражения в скобках имеют такую же структуру: {выражение[!конверсия][:формат]}. Выражения все так же не ленивые.

Однако, t-строки достаточно сильно отличаются в плане поведения форматирования. Если мы попробуем написать что-то вроде print(t"Hello, {name}"), то увидим не строку, а какой-то объект Template. Давайте разбираться.

Template

Объекты этого класса создаются в результате вычисления t-строки. Что внутри имеет Template?

dir(t"")
>>> ['__add__', '__class__', '__class_getitem__', '__delattr__', 
     '__dir__', '__doc__', '__eq__', '__format__', '__ge__', 
     '__getattribute__', '__getstate__', '__gt__', '__hash__', 
     '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', 
     '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', 
     '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 
     'interpolations', 'strings', 'values']

Из всех этих атрибутов нас интересуют только interpolations, strings, values и __iter__.

В атрибуте strings лежат все строковые значения, которые лежат между интерполяциями (интерполяция в нашем случае — {выражение}). Например, t"Hello, {name}!".strings == ('Hello, ', '!', '').

В атрибуте values лежат все значения интерполяций. Например, t"{1}".values == (1,).

В атрибуте interpolations лежат все интерполяции, представленные объектами Interpolation. Он содержит в себе поля:

  • value — само посчитанное значение интерполяции;

  • expression — строка с исходным кодом выражения;

  • conversion — тип конверсии с помощью ! (!r, !a или !s). Если конверсия не указана, то содержит пустую строку;

  • format_spec — спецификация форматирования с помощью :. Если спецификация не указана, то содержит пустую строку.

Например, t"{1+2!a:ffff}".interpolations вернет (Interpolation(3, '1+2', 'a', 'ffff'),).

Наконец, __iter__ позволяет нам итерироваться по t-строке. Во время итерации мы сможем по порядку перебирать все ее элементы: строки и интерполяции.

В чем соль?

«В чем же собственно фишка t-строк?» — спросите вы. Главная фишка в том, что форматирование происходит не сразу, а вместо этого вам отдается объект, который содержит полную информацию о строке, чтобы вы потом отформатировали эту строку сами ровно так, как пожелаете. То есть, вся суть — кастомизация поведения форматирования.

Для примера давайте попробуем сделать f-строки на t-строках. Сделаем функцию f:

from string.templatelib import Interpolation

def f(template):
    string = []
    for part in template:
        match part:
	        case str():
                string.append(part)
            case Interpolation(value, _, conversion, format_spec):
                if conversion == "a":
                    value = ascii(value)
                elif conversion == "s":
                    value = str(value)
                elif conversion == "r":
                    value = repr(value)
                value = format(value, format_spec)
                string.append(value)
    return "".join(string)

Не самый красивый код, но это всего лишь пример. Мы просто проходим по всем частям, если надо — форматируем, а затем добавляем к строке.

Теперь давайте рассмотрим, чем эта штуковина может быть нам полезна. Способы применения я условно поделил на 3 группы. Некоторые из сфер применения могут быть сомнительными, пишите свое мнение в комментарии, будет интересно прочитать)

Санитизация

Один из основных аргументов «ЗА» PEP750, который привнес нам эти t-строки — упрощение защиты от XSS и SQL injection атак. Каким образом? Если раньше мы попробовали бы сделать что-то такое:

sql.execute(f"SELECT * FROM users WHERE username = '{username}';")

то нам бы закономерно на ревью дали по шапке. Ведь если у нас придет пользователь с юзернеймом '; DROP TABLE users; --, то будет очень и очень больно. Но если мы будем использовать t-строки, то наш гипотетический sql.execute сможет пройтись по всем строковым значениям в интерполяциях и сам их экранировать:

# вымышленный коннектор к бд
import my_awesome_sql_library

def execute(query):
	...
	my_awesome_sql_library.execute(sanitize_sql(query))
	...

def sanitize_sql(query):
	string = []
    for part in template:
        match part:
	        case str():
                string.append(part)
            case Interpolation(value, _, conversion, format_spec):
                if conversion == "a":
                    value = ascii(value)
                elif conversion == "s":
                    value = str(value)
                elif conversion == "r":
                    value = repr(value)
                value = format(value, format_spec)
                string.append(my_awesome_sql_library.sanitize(value))
    return "".join(string)

Мнение автора

Не совсем ясно, насколько такой подход лучше уже существующего, когда мы передаем коннектору строку с плейсхолдерами типа % и значения, а БД их сама экранирует и заменяет.

Шаблонизация

Ровно то же самое применимо и к HTML. Во-первых, это помогает разобраться с XSS: если мы собираем какую-то страничку с помощью f-строк, например:

# вымышленный веб-фреймворк

def post_html(post):
	return f"""
		<div class="post">
			<h3>{post.title}</h3>
			<small>{post.author}</small>
			<a href="/posts/{post.id}">See</a>
		</div>
	"""

@get("/posts")
def posts_page(request):
	posts = get_all_posts()
	return f"""
		<h1>Posts</h1>
		{"".join(map(post_html, posts))}
	"""

и какой-нибудь особо любопытный пользователь сделает пост с заголовком <script>alert("There I am!")</script> то мы получим XSS. Но мы можем написать все то же самое с использованием t-строк, а фреймворк будет просто делать html.escape(...) для всех интерполяций, таким образом избегая инъекции HTML кода.

Во-вторых, мы можем делать кучу разных дополнительных манипуляций с HTML кодом. Например, парсить его прямо во время форматирования. Давайте взглянем на несколько примеров, которые написал Дейв Пек — один из авторов PEP750:

text = 'Hello, "world!"'
element = html(t'<p class="greeting">{text}</p>')
expected = Element("p", {"class": "greeting"}, ['Hello, "world!"'])
assert element == expected

или такое:

def Magic(attributes, children):
	"""A simple, but extremely magical, component."""
	magic_attributes = {**attributes, "data-magic": "yes"}
	magic_children = [*children, "Magic!"]
	return Element("div", magic_attributes, magic_children)

element = html(t'&lt;{Magic} id="wow"&gt;<b>FUN!</b>')
expected = Element(
	"div",
	{"id": "wow", "data-magic": "yes"},
	[Element("b", {}, ["FUN!"]), "Magic!"],
)
assert element == expected

В эфире на тему PEP750 его создатели (Джим Бейкер, Дейв Пек, Пол Эверитт) обсуждали много разных вещей, для которых он нужен. Среди прочего, интересна такая цитата (перевод):

… В то время бытовало мнение, что HTML довольно сильно отличается от программ …
… вам хотелось разделять людей, работающих с шаблонами, от людей, пишущих ПО …
… мир так больше не работает …
… это то, в чем я изначально был заинтересован …
… мы хотим DSL-ы, которые ближе к Python, чтобы вы могли использовать mypy для них, свой форматтер для них, подсказки своей среды разработки для них …
… и, с точки зрения HTML, мы хотим перейти из этого разделения в новый мир.

По сути, они предлагают новый способ шаблонизации. Цитата с переводом:

— … новые способы шаблонизации, например jinja3 или что-нибудь еще, могут появиться и развить эти идеи, используя что-то отсюда …
— Да, абсолютно точно, и я надеюсь, что мы увидим бум таких вещей после выхода Python 3.14 …

Мнение автора

Эти идеи кажутся интересными, возможно даже и действительно начнут какое-то новое движение в SSR в мире Python, но пока об этом рано говорить до выхода 3.14 в октябре и появления первых инструментов на основе t-строк. Да и нужно проводить тщательные сравнения и бенчмарки.

Структурированное логирование

Структурированное логирование позволяет делать логи в машиночитаемом формате. Традиционный подход — использование structlog/loguru (иногда даже можно использовать обычный встроенный logging). Все так или иначе сводится к тому, что у нас есть какой-то словарик значений, который мы куда-то передаем и он становится частью структурированного сообщения в логе.

T-строки позволяют сделать структурный логгинг фактически бесплатным. Предлагаю такой пример:

logger.info(t"Received request {request['id']:id} from user {request['user']:user}")

# string formatting
>>> Received request 123 from user 0000-0000
# json formatting
>>> {
  "id": 123, 
  "user": "0000-0000", 
  "message": "Received request 123 from user 0000-0000", 
  "type": "received_request_from_user", 
  "time": "2025-05-21T13:19:14"
}

Давайте разбирать все по порядку. Во-первых, мы используем кастомную спецификацию форматирования для того, чтобы определить, под каким ключом будет лежать значение в структуре. В целом, можно применить немного иной подход, например, использовать строковое представление кода интерполяции, но тогда придется там держать только имена, т. е.:

id = request['id']
user = request['user']
logger.info(t"Received request {id} from user {user}")

Затем мы добавляем поле time — время создания сообщения и message — само сообщение. Тут необходимо еще отметить то, что для структурного логгинга также нужно, чтобы сообщения имели какой-то идентификатор, который бы позволял различать события. Здесь же этим идентификатором служит строка, в которой все интерполяции заменены на ?. Потом, где-нибудь в дашборде, где мы смотрим все логи, мы сможем сделать запрос вида: (loki) {app=my_app,type=received_request_from_user}.

Что можно сделать еще? Продолжить расширять формат-спецификацию. Так как мы заняли ее место, надо добавить возможность дописывать обычную format spec. Это можно сделать, например, так:

x = 13.00000000000004
logger.info(t"Some number: {x:my_field+.4f}")

# string formatting
>>> Some number: 13.0000
# json formatting
>>> {..., "my_field": 13.00000000000004, ...}

Можем продолжить расширять формат-спеку. Допустим, можно добавить возможность указывать форматирование для дат (хотя, скорее это уже перебор):

logger.info(t"Current hour: {datetime.now():now+%Y-%m-%d %H}")

То, как происходит форматирование таких логов под капотом, можно посмотреть в репозитории с примерами.

Мнение автора

Как по мне, то это наиболее перспективное направление, в котором можно использовать t-строки. Совокупность факторов: лаконичность (не надо ссылаться на один и тот же объект и в сообщении, и в словарике со значениями) и бесплатные автогенерируемые идентификаторы типа сообщения — дают некоторое преимущество над стандартным подходом вида

logger.info(
  "Request {id} from {user} received", 
  {"id": request.id, "user": request.user, "type": "received_request_from_user"}
)

Тем не менее, не стоит забывать, что t-строки заметно медленнее, чем стандартный str.format, да и с существующим подходом все замечательно пока живут, хотя с t-строками и получается сильно красивее.

Что внутри?

Для полноты картины нам не хватает только части с разбором того, что находится под капотом у t-строк.

Давайте посмотрим байткод для такого выражения:

t"a {1} b {2!s:fmt} c"
LOAD_CONST               4 (('a ', ' b ', ' c'))
LOAD_SMALL_INT           1
LOAD_CONST               1 ('1')
BUILD_INTERPOLATION      2
LOAD_SMALL_INT           2
LOAD_CONST               2 ('2')
LOAD_CONST               3 ('fmt')
BUILD_INTERPOLATION      7
BUILD_TUPLE              2
BUILD_TEMPLATE

Первым делом видим создание кортежа из чистых строк, которые находятся между интерполяциями. Затем видим загрузку на стек значения первой интерполяции и строки "1" — ее исходного кода, после чего опкод BUILD_INTERPOLATION создает объект Interpolation.

После видим, как загружается вторая интерполяция, ее исходный код, но теперь еще и строка "fmt", которую мы указали в качестве спецификации форматирования. А затем опять вызывается BUILD_INTERPOLATION, но уже с другим оп-аргументом. Если мы посмотрим на код:

inst(BUILD_INTERPOLATION, (value, str, format[oparg &amp; 1] -- interpolation)) {  
    PyObject *value_o = PyStackRef_AsPyObjectBorrow(value);  
    PyObject *str_o = PyStackRef_AsPyObjectBorrow(str);  
    int conversion = oparg &gt;&gt; 2;  
    PyObject *format_o;  
    if (oparg &amp; 1) format_o = PyStackRef_AsPyObjectBorrow(format[0]);  
    else format_o = &amp;_Py_STR(empty); 
    PyObject *interpolation_o = _PyInterpolation_Build(value_o, str_o, conversion, format_o); 
    ...
    interpolation = PyStackRef_FromPyObjectSteal(interpolation_o);  
}

то увидим, что оп-аргумент контроллирует наличие/отсутствие спецификации форматирования, а также конверсии (!a, !s или !r) с помощью булевых флагов. Здесь же видно, что при отсутствии спецификации форматирования, она принимает дефолтное значение пустой строки.

Следующий опкод — BUILD_TUPLE 2 — создает кортеж из двух значений на стеке. Эти значения — сформированные интерполяции. После этого опкод BUILD_TEMPLATE просто собирает вместе кортежи чистых строк и интерполяций в шаблон.

inst(BUILD_TEMPLATE, (strings, interpolations -- template)) {  
    PyObject *strings_o = PyStackRef_AsPyObjectBorrow(strings);  
    PyObject *interpolations_o = PyStackRef_AsPyObjectBorrow(interpolations);  
    PyObject *template_o = _PyTemplate_Build(strings_o, interpolations_o);  
    ...
    template = PyStackRef_FromPyObjectSteal(template_o);  
}

Строки и интерполяции чередуются, т.е., например, кортеж чистых строк у шаблона t"{a}{b}" будет содержать ("", "", "").

Заключение

T-строки — крайне мощный инструмент, который вот-вот (уже в октябре) будет доступен в стабильном виде в Python 3.14. Хотя нам и только предстоит увидеть их в действии в реальных юзкейсах, уже сейчас можно сказать, что они определенно точно привнесут кое-что новое и удобное в мир шаблонизации и структурного логирования.

Спасибо Никите Соболеву (CPython Core Developer) за пруфридинг статьи и особенно секции с внутренностями t‑строк. Еще больше приколов про внутрянку CPython и свежие новости про новые фичи вы можете узнать у него на канале «Находки в опенсорсе».

Комментарии (37)


  1. sobolevn
    26.05.2025 17:27

    Крутая статья, спасибо!


  1. n0isy
    26.05.2025 17:27

    Заметили, что будет PY PI версия? А следющая будет PEPE /sarcasm_off


    1. Biga
      26.05.2025 17:27

      В честь версии вполне можно было бы в шутку зарелизить Pithon 3.14.


  1. dimaQd
    26.05.2025 17:27

    Ждём jinja3!


  1. Octagon77
    26.05.2025 17:27

    Я привередлив. Это кому-то надо, кому-то нет. Значит, должно быть вне языка и импортироваться. Хотя бы потому, что медленнее.

    С другой стороны, есть f-строки и всё выглядит красиво и нет никаких причин почему бы ни быть и t-cтрокам, и d-строкам, и q-строкам, и r-строкам... Значит, должно быть на уровне языка. Точно так, как и сделано.

    Раз я привередлив - я бы сделал возможность писать пакеты добавляющие строки на любые буквы. Да, так сложнее, но с огдядки на "сложнее" и начинается деградация.


    1. lorc
      26.05.2025 17:27

      r-строки уже давно есть, кстати. Наверное чуть ли не с первой версии.


      1. tapeline Автор
        26.05.2025 17:27

        Более того, на discuss.python.org недавно предложили d-строки, но это вообще уже, как по мне, шиза. Суть в том, что они автоматом будут убирать кучу отступов в начале строки при, например, таком использовании:

        def f():
            print(
            """
                Hello, World!
                This is a second line
            """
        

        Как по мне, так это либо надо изначально было делать (как поступили, например, в Java), либо сейчас уже просто жить с textwrap.dedent и не плодить всякое в язык.


    1. tapeline Автор
      26.05.2025 17:27

      С какой-то стороны с вами согласен, но это уже скорее философия конкретного языка.

      Сейчас точно не вспомню, в дискассе под каким пепом это читал, но там был комментарий от Core Developer в духе:

      Необязательно, чтобы то, что добавляет PEP, было полезно всем. Python использует большое количество людей, эта фича будет полезной определенной части комьюнити.

      А насчет пакетов на добавление строк на любые буквы: у нас и так есть PEP263 и порт f-строк на Python 2 с помощью него, хотя это скорее грязный хак, чем задуманная фича.


  1. lorc
    26.05.2025 17:27

    Я понимаю что статья не о том, но

    Не совсем ясно, насколько такой подход лучше уже существующего, когда мы передаем коннектору строку с плейсхолдерами типа % и значения, а он их сам экранирует и заменяет.

    Не надо вводить людей в заблуждение. Все нормальные движки БД давно поддерживают параметры. Поэтому значения в параметризированный запрос не подставляются никогда. Опять же, парсеру запросов проще один раз распарсить, оптимизировать и закешировать `select * from users where name = $1` чем парсить тысячи разных запросов, которые отличаются только параметром. Я уж не говорю о том что это исключает SQL injection полностью и навсегда.


    1. tapeline Автор
      26.05.2025 17:27

      Да, вы правы, я не совсем правильно выразился. Исправил, чтобы не продолжать вводить в заблуждение. Спасибо!


    1. funca
      26.05.2025 17:27

      Обычно DB разрешают использовать параметры лишь вместо значений. Но довольно часто приходится параметризовывать части самого запроса: названия схем, таблиц, полей и т.п. Здесь серверная интерпретация не работает.


      1. lorc
        26.05.2025 17:27

        Ну если вы параметризируете эти части кусками из user input, то у меня для вас плохие новости...


        1. funca
          26.05.2025 17:27

          Шутка понятна, но здесь на самом деле больше нюансов в каждом диалекте SQL.


        1. n0isy
          26.05.2025 17:27

          Ну почему же. Если экранировать то все хорошо. Если вы говорите про антипаттерн, то я напомню, что like '%text%' не так просто записать параметром


          1. Stawros
            26.05.2025 17:27

            like '%text%' не так просто записать параметром

            Смотря где, в оракле можно без проблем сделать like '%'||:P_SUBSTR||'%'


  1. PyLounge
    26.05.2025 17:27

    Огонь!