https://habrahabr.ru/post/340254/Jinja2 — Python-библиотека для рендеринга шаблонов, являющаяся де-факто стандартом при написании веб-приложений на Flask и довольно популярной альтернативой встроенной системе шаблонов Django. Хотя и будучи сильно привязана к языку, Jinja2 позиционирует себя как инструмент для дизайнеров и верстальщиков, упрощающий вёрстку и отделяющий её от разработки, и пытающийся по мере возможностей изолировать не-разработчиков от Python. Вёрстка, впрочем, не единственное возможное её применение; например, в своей работе я использую шаблоны Jinja2 для генерации SQL-запросов.
Jinja2
расширяема, и многие возможности (например, интернационализация и управление циклами) реализованы именно как расширения. Однако, документация по написанию расширений, как мне кажется, несколько неполна; от примера несложного (но тщательно прокомментированного) расширения она перескакивает сразу к описанию API
некоторых классов Jinja2, которое довольно трудно читать подряд. В этой статье я попытаюсь исправить это упущение и создать в голове читателя полную и ясную картину того, как работает Jinja2, как устроены её расширения и как с помощью расширений модифицировать разные этапы обработки шаблонов.
Глобально, Jinja2 компилирует каждый шаблон в Python executable, который принимает на вход контекст и возвращает строку — отрендеренный шаблон. Весь процесс в целом выглядит так.
- Загрузка. Вы можете хранить шаблоны в файловой системе, в папке с вашим Python-пакетом, в памяти или просто генерировать на лету — в первую очередь Jinja2 определяет, какой из способов актуален, и загружает исходники шаблона в память.
- Токенизация. Лексический анализатор (lexer) бьёт исходный текст шаблона на простейшие сущности — токены. Пример токена — открывающая теги конструкция
{%
.
- Парсинг. Синтаксический анализатор (parser) разбирает поток токенов, вычленяя синтаксические конструкции. Пример синтаксической конструкции — подставляющая значение переменной конструкция
{{ variable }}
(она состоит из трёх токенов — открывающего {{
, имени variable
и закрывающего }}
).
- Оптимизация. На этом этапе вычисляются все константные выражения. Например, конструкция
{{ 1 + 2 }}
будет превращена в {{ 3 }}
.
- Генерация. Синтаксические конструкции, до сих пор хранившиеся в виде абстрактного синтаксического дерева (AST), конвертируются в код на Python.
- Компиляция. Полученный Python-код компилируется встроенной функций
compile
. Получившийся объект можно запускать встроенной фукнцией exec
, что шаблоны и делают при рендеринге.
Для создания расширения в Jinja2 нужно определить класс, наследующийся от
jinja2.ext.Extension
. Чтобы активировать расширение, достаточно перечислить его в списке расширений при создании окружения (environment) или добавить после создания методом
add_extension
.
Краткая иллюстрация вместо тысячи слов:
from jinja2 import Environment
from jinja2.ext import Extension
class MyFirstExtension(Extension):
pass
class MySecondExtension(Extension):
pass
environment = Environment(extensions=[MyFirstExtension])
environment.add_extension(MySecondExtension)
print(environment.extensions)
# печатает что-то вроде
# {'__main__.MySecondExtension': <__main__.MySecondExtension object at 0x0000000002FF1780>, '__main__.MyFirstExtension': <__main__.MyFirstExtension object at 0x0000000002FE9BA8>}
Осталось научить их что-то делать! Для этого в нашем распоряжении, по большому счёту, всего три метода, которые можно переопределять:
preprocess
;
filter_stream
(что бы это ни значило);
parse
.
Что ж, начнём по порядку.
Простейший способ управлять напрямую загрузкой исходников шаблонов — реализовать собственный загрузчик (loader). Сделать это элементарно: наследуемся от
jinja2.loaders.BaseLoader
, переопределяем метод
get_source(environment, template_name)
— готово. Иногда это бывает даже осмысленно. Так, если в один прекрасный день вы смогли заменить целую папку шаблонов одной изящной генерирующей их функцией, для обратной совместимости с другими частями программы вы можете захотеть написать загрузчик, делающий вид, что эти шаблоны там всё ещё есть (а сами сделать сладостный
git rm
).
Однако, это оффтоп: где тут расширения? Понятное дело, что я могу в любой момент отнаследоваться от чего захочу и поменять там что сочту нужным! Удивительно, но в API расширений тоже на всякий случай есть способ управлять напрямую исходным кодом шаблонов.
Так, класс
Extension
содержит метод
preprocess
, который вызывается для каждого шаблона после загрузки и перед токенизацией. Сигнатура выглядит так:
def preprocess(self, source, name, filename=None):
"""
Параметры:
source (String) - исходный код шаблона
name (String) - имя шаблона
filename (String или None) - имя файла (если есть)
Возвращает:
String - предобработанный исходный код шаблона
"""
В этом методе можно делать всё, что угодно. Технически, где-то здесь вы можете реализовать компиляцию своего собственного языка шаблонов в шаблоны Jinja2. Но… зачем? Вероятно, возможность модифицировать исходники напрямую может пригодится вам как вспомогательная при написании нетривиальных расширений. Однако, здесь не требуется знание API Jinja2 или особенностей её реализации, поэтому мы не будем больше вдаваться в детали этого этапа и пойдём дальше, к токенизации.
Куда бо́льший интерес для нас представляет метод
filter_stream
, привлекающий богатыми возможностями для кастомизации, которые он открывает, и своим загадочным именем. Сигнатура выглядит так:
def filter_stream(self, stream):
"""
Параметры:
stream (jinja2.lexer.TokenStream) - поток токенов из лексического анализатора
Возвращает:
jinja2.lexer.TokenStream - поток токенов в синтаксический анализатор
"""
В целом взаимодействие лексического и синтаксического анализаторов в Jinja2 устроено следующим образом. Лексический анализатор (
jinja2.lexer.Lexer
) производит генератор, выдающий один за другим все токены (
jinja2.lexer.Token
), и оборачивает этот генератор в объект
jinja2.lexer.TokenStream
, который буферизует поток и предоставляет ряд удобных при синтаксическом анализе вспомогательных методов (например, возможность посмотреть текущий токен, не выдёргивая его из потока). Расширения, в свою очередь, могут влиять на этот поток, причём не только фильтровать (как предполагает название метода), но и обогащать.
Токены в Jinja2 — объекты очень простые. По сути, это кортежи из трёх именованных полей:
lineno
— номер строки с токеном;
type
— вид токена;
value
— строковое значение токена.
Различные константы для поля
type
определены в
jinja2/lexer.py
:
TOKEN_ADD TOKEN_NE TOKEN_VARIABLE_BEGIN
TOKEN_ASSIGN TOKEN_PIPE TOKEN_VARIABLE_END
TOKEN_COLON TOKEN_POW TOKEN_RAW_BEGIN
TOKEN_COMMA TOKEN_RBRACE TOKEN_RAW_END
TOKEN_DIV TOKEN_RBRACKET TOKEN_COMMENT_BEGIN
TOKEN_DOT TOKEN_RPAREN TOKEN_COMMENT_END
TOKEN_EQ TOKEN_SEMICOLON TOKEN_COMMENT
TOKEN_FLOORDIV TOKEN_SUB TOKEN_LINESTATEMENT_BEGIN
TOKEN_GT TOKEN_TILDE TOKEN_LINESTATEMENT_END
TOKEN_GTEQ TOKEN_WHITESPACE TOKEN_LINECOMMENT_BEGIN
TOKEN_LBRACE TOKEN_FLOAT TOKEN_LINECOMMENT_END
TOKEN_LBRACKET TOKEN_INTEGER TOKEN_LINECOMMENT
TOKEN_LPAREN TOKEN_NAME TOKEN_DATA
TOKEN_LT TOKEN_STRING TOKEN_INITIAL
TOKEN_LTEQ TOKEN_OPERATOR TOKEN_EOF
TOKEN_MOD TOKEN_BLOCK_BEGIN
TOKEN_MUL TOKEN_BLOCK_END
Типичное расширение, манипулирующее токенами, должно выглядеть как-то так:
from jinja2.ext import Extension
from jinja2.lexer import TokenStream
class TokensModifyingExtension(Extension):
def filter_stream(self, stream):
generator = self._generator(stream)
return lexer.TokenStream(generator, stream.name, stream.filename)
def _generator(self, stream):
for token in stream:
# Тут можно проверить тип токена и отбросить его. Или заменить.
# Или добавить какие-то дополнительные токены перед
yield token
# или после него.
В качестве примера давайте напишем расширение, которое меняет логику рендеринга переменных. Допустим, вы хотите, чтобы некоторые ваши объекты при рендеринге в Jinja2 вели себя не так, как при конвертации в строку функцией
str
. Пусть у наших объектов возникнет опция определить метод
__jinja__(self)
, который будет использоваться в шаблонах. Проще всего сделать это, добавив кастомный фильтр, вызывающий метод
__jinja__
, и автоматически подставлять его вызов в каждую конструкцию вида
{{ <expression> }}
. Весь код расширения будет выглядеть так:
from jinja2 import Environment
from jinja2.ext import Extension
from jinja2 import lexer
class VariablesCustomRenderingExtension(Extension):
# Определяем наш кастомный фильтр. Для удобства я сложил его в класс с
# расширением, но вы можете найти ему место получше.
@staticmethod
def _jinja_or_str(obj):
try:
return obj.__jinja__()
except AttributeError:
return obj
def __init__(self, environment):
super(VariablesCustomRenderingExtension, self).__init__(environment)
# Регистрируем наш кастомный фильтр. Здесь можно заморочиться и
# генерировать для него заведомо оригинальное имя, ну да ладно.
self._filter_name = "jinja_or_str"
environment.filters.setdefault(self._filter_name, self._jinja_or_str)
def filter_stream(self, stream):
generator = self._generator(stream)
return lexer.TokenStream(generator, stream.name, stream.filename)
def _generator(self, stream):
# Возвращаем поток токенов как есть, только каждую конструкцию вида
# {{ <expression> }} заменяем на {{ (<expression>)|jinja_or_str }}
for token in stream:
if token.type == lexer.TOKEN_VARIABLE_END:
# Если видим конец конструкции {{ <expression> }} - дописываем
# перед ним `)|jinja_or_str`.
yield lexer.Token(token.lineno, lexer.TOKEN_RPAREN, ")")
yield lexer.Token(token.lineno, lexer.TOKEN_PIPE, "|")
yield lexer.Token(
token.lineno, lexer.TOKEN_NAME, self._filter_name)
yield token
if token.type == lexer.TOKEN_VARIABLE_BEGIN:
# Если видим начало конструкции {{ <expression> }} - дописываем
# после него `(`.
yield lexer.Token(token.lineno, lexer.TOKEN_LPAREN, "(")
Пример использования:
class Kohai(object):
def __jinja__(self):
return "senpai rendered me!"
if __name__ == "__main__":
env = Environment(extensions=[VariablesCustomRenderingExtension])
template = env.from_string("""Kohai says: {{ kohai }}""")
print(template.render(kohai=Kohai()))
# Печатает "Kohai says: senpai rendered me!".
Можно посмотреть целиком
на Github.
Последний и самый интересный метод класса
Extension
, доступный для переопределения, —
parse
.
def parse(self, parser):
"""
Параметры:
parse (jinja2.parser.Parser) - текущий синтаксический анализатор
Возвращает:
jinja2.nodes.Stmt или List[jinja2.nodes.Stmt] - узлы AST,
получившиеся в результате парсинга
"""
Работает он в связке с атрибутом
tags
, который можно определить в классе расширения. Атрибут этот должен содержать множество тегов, обработка которых будет доверена вашему расширению, например:
class RepeatNTimesExtension(Extension):
tags = {"repeat"}
Соответственно, метод
parse
вызовется, когда синтаксический анализ дойдёт до конструкции с началом соответствующего тега:
some text and then {% repeat ...
^
При этом атрибут
parser.stream.current
, указывающий на обрабатываемый сейчас токен, будет содержать
Token(lineno, TOKEN_NAME, "repeat")
.
Далее внутри метода
parse
нам нужно распарсить наш кастомный тег и выдать результат парсинга — один или несколько узлов синтаксического дерева. Jinja2 не позволяет заводить свои собственные типы узлов, поэтому придётся довольствоваться встроенными; к счастью, есть (почти) универсальный узел
CallBlock
, про который я расскажу ниже.
Пока же логика существующих типов узлов вроде
For
нас устраивает, вот набор рецептов, которые вы можете захотеть использовать внутри метода
parse
.
Распарсив всё, что нужно, вы можете захотеть создать один или больше узлов дерева, чтобы вернуть их в качестве результата парсинга. Что нужно знать о создании узлов Jinja2:
- Все классы узлов определены в
jinja2.nodes
и наследуются от jinja2.nodes.Node
. Их список нельзя расширить.
- Напрямую возвращать из
parse
можно только узлы, наследующиеся от jinja2.nodes.Stmt
. Остальные могут иногда работать, но могут и всё сломать. Таким образом, вы можете выбирать из следующих классов:
Assign ExprStmt Include
AssignBlock Extends Macro
Block FilterBlock Output
Break For Scope
CallBlock FromImport ScopedEvalContextModifier
Continue If
EvalContextModifier Import
- В каждом классе, наследующемся от
Node
, определено поле fields
со списком полей. Создавать узел можно либо указывая все поля, либо не указывая никаких полей (они будут инициализированы None
и их значения можно будет указать позже). Также при создании у всех узлов ключевым аргументом можно указывать lineno
; используйте это, чтобы получать адекватные трейсбеки в случае ошибок.
Примеры:
from jinja2.nodes import * # не делайте так
# Указываем (единственное) поле:
template_name = Const("lib/stuff.j2")
# Указываем все поля сразу (опциональные могут быть указаны как None):
inc_node = Include(template_name, False, False, lineno=0)
# Не указываем полей и заполняем потихоньку:
inc_node = Include(lineno=0)
inc_node.template = template_name
inc_node.with_context = False
inc_node.ignore_missing = False
# Кстати, этот подход более устойчив. Разработчики Jinja2 могут добавить
# в существующий класс опциональных полей (как это случилось с If в какой-то
# момент) и подход выше сломается; в случае постепенного же заполнения они
# просто инициализируются None, как и, скорее всего, должны.
# lineno тоже можно указывать после создания:
inc_node = Include()
inc_node.lineno = 0
Обратите внимание, что поля нельзя указывать ключевыми аргументами: конструкция
Include(template=template_name, with_context=False, ignore_missing=False)
не заработает.
- Многие поля узлов — тоже узлы. Так,
Include
не согласится принять строку "lib/stuff.j2"
в качестве поля template — только nodes.Const("lib/stuff.j2")
. Если вы не уверены, какого типа то или иное поле, найдите код, парсящий соответствующий узел, в jinja2/parser.py
— там несложно разобраться (по крайней мере после прочтения этой статьи… должно быть).
В качестве примера применения всех этих знаний давайте рассмотрим простое расширение, которое добавляет конструкцию
{% repeat N times %}...{% endrepeat %}
как синтаксический сахар для конструкции
{% for _ in range(N) %}...{% endfor %}
:
from jinja2.ext import Extension
from jinja2 import nodes
class RepeatNTimesExtension(Extension):
# Мы хотим, чтобы парсер взывал к нам только при виде тега repeat.
# Если он взял и сам где-то встретил endrepeat, это ошибка.
tags = {"repeat"}
def parse(self, parser):
lineno = next(parser.stream).lineno
# Заводим выражение для переменной цикла. "store" - это контекст (может
# быть также "load", но тогда в переменную не предполагается запись).
index = nodes.Name("_", "store", lineno=lineno)
# Парсим выражение для N. Мы умные и принимаем не только литералы.
how_many_times = parser.parse_expression()
# Следующая конструкция - то, как Jinja2 распарсила бы
# выражение `range(N)`.
iterable = nodes.Call(
nodes.Name("range", "load"), [how_many_times], [], None, None)
# Дальше должно быть ключевое слово times.
# Оно здесь не особо нужно, но мы эстеты.
parser.stream.expect("name:times")
# Парсим тело цикла до конструкции {% endrepeat %}.
body = parser.parse_statements(["name:endrepeat"], drop_needle=True)
# Возвращаем цикл for. Здесь нужно просто правильно указать всякие
# вспомогательные параметры.
return nodes.For(index, iterable, body, [], None, False, lineno=lineno)
Пример использования:
if __name__ == "__main__":
env = Environment(extensions=[RepeatNTimesExtension])
template = env.from_string(u"""
{%- repeat 3 times -%}
{% if not loop.first and not loop.last %}, {% endif -%}
{% if loop.last %} и ещё раз {% endif -%}
учиться
{%- endrepeat -%}
""")
print(template.render())
# Печатает "учиться, учиться и ещё раз учиться".
Можно посмотреть целиком
на Github.
Поскольку из-за тонкостей архитектуры Jinja2 добавлять новые классы узлов синтаксического дерева нельзя, требуется некий универсальный узел, в котором можно было бы делать любую обработку, какая вздумается. Такой узел есть, и это
CallBlock
.
Давайте сперва вспомним, как работает тег
{% call %}
сам по себе. Пример из
официальной документации:
{% macro dump_users(users) -%}
<ul>
{%- for user in users %}
<li><p>{{ user.username|e }}</p>{{ caller(user) }}</li>
{%- endfor %}
</ul>
{%- endmacro %}
{% call(user) dump_users(list_of_user) %}
<dl>
<dl>Realname</dl>
<dd>{{ user.realname|e }}</dd>
<dl>Description</dl>
<dd>{{ user.description }}</dd>
</dl>
{% endcall %}
Происходит следующее:
- Создаётся временный макрос с именем
caller
. Тело макроса — содержимое между {% call... %}
и {% endcall %}
. Макрос может как иметь аргументы (в примере выше это один аргумент user
), так и не иметь (если используется упрощённая конструкция {% call something(...) %}
).
- Вызывается макрос, указанный после конструкции
call(...)
. Он имеет доступ к макросу caller
и, возможно, пользуется им (а возможно, и нет).
Однако макрос в Jinja2 — это не что иное, как функция, которая возвращает строку. Поэтому узлу
CallBlock
можно с тем же успехом скармливать функции, определённые нами где-то в недрах наших расширений.
Типичное расширение, использующее
CallBlock
для обработки текста, выглядит как-то так:
from jinja2.ext import Extension
from jinja2 import nodes
class ReplaceTabsWithSpacesExtension(Extension):
tags = {"replacetabs"}
def parse(self, parser):
lineno = next(parser.stream).lineno
# Парсим тело, как обычно:
body = parser.parse_statements(
["name:endreplacetabs"], drop_needle=True)
# Магия!
return nodes.CallBlock(
self.call_method("_process", [nodes.Const(" ")]),
[], [], body, lineno=lineno)
def _process(self, replacement, caller):
text = caller()
return text.replace("\t", replacement)
Как это работает?
call_method
— это специальный метод класса Extension
, который заворачивает вызов метода класса в узел Jinja2. Результат можно передавать как параметр туда, где Jinja2 ожидает любое выражение, и в особенности туда, где она ожидает именно вызов функции — в CallBlock
.
- Когда возвращённому из нашего метода
parse
CodeBlock
'у придёт время рендериться, он вызовет метод ReplaceTabsWithSpacesExtension._process
. Сначала будут переданы аргументы, указанные при вызове call_method
(в нашем случае один аргумент — строка из четырёх пробелов), затем — тот самый caller
, который есть просто макрос Jinja2 и который можно просто вызвать, чтобы получить строку.
- Если макрос
caller
должен вызываться с аргументами, их надо перечислить в полях узла CodeBlock
(там, где в нашем примере пустые списки).
Посмотреть с примером использования можно
на Github.
Ну и напоследок чуть более сложный пример расширения, использующего
CallBlock
и ещё кое-что из того, что мы сегодня прошли — исправитель индентации. Известно, что практически невозможно писать хоть сколько-то нетривиальные шаблоны на Jinja2 так, чтобы и исходный код шаблона, и результат выглядели хорошо с точки зрения отступов. Попробуем добавить тег, который исправляет это недоразумение.
import re
from jinja2.ext import Extension
from jinja2 import lexer, nodes
# Нам потребуется сохранять в токенах дополнительную информацию, однако из-за
# указанного там __slots__ = () напрямую это невозможно. К счастью, Jinja2
# спокойно относится к классам-потомкам lexer.Token.
class RichToken(lexer.Token):
pass
class AutoindentExtension(Extension):
tags = {"autoindent"}
# Скомпилируем эти тривиальные регулярные выражения заранее -
# нас же беспокоит эффективность?
_indent_regex = re.compile(r"^ *")
_whitespace_regex = re.compile(r"^\s*$")
def _generator(self, stream):
# Здесь мы обогащаем каждый токен информацией о том, какой отступ был
# в строке с ним. Считаются только отступы в сыром тексте (не внутри
# конструкций Jinja2).
last_line = ""
last_indent = 0
for token in stream:
if token.type == lexer.TOKEN_DATA:
# Сырой текст - учитываем.
last_line += token.value
if "\n" in last_line:
_, last_line = last_line.rsplit("\n", 1)
last_indent = self._indent(last_line)
# Обогащаем уран^W наш токен.
token = RichToken(*token)
token.last_indent = last_indent
yield token
def filter_stream(self, stream):
return lexer.TokenStream(
self._generator(stream), stream.name, stream.filename)
def parse(self, parser):
# Токен с ключевым словом autoindent, как и все остальные токены, знает,
# какой был отступ. Сохраним, чтобы передать его в наш код.
last_indent = nodes.Const(parser.stream.current.last_indent)
lineno = next(parser.stream).lineno
body = parser.parse_statements(["name:endautoindent"], drop_needle=True)
# Эту магию мы уже прошли :)
return nodes.CallBlock(
self.call_method("_autoindent", [last_indent]),
[], [], body, lineno=lineno)
def _autoindent(self, last_indent, caller):
text = caller()
# Дальнейший код просто делает так, чтобы отступы в тексте были не
# больше last_indent. Первая строка не учитывается (что, кстати, может
# привести к артефактам при последовательной записи тегов, ну да ладно),
# строки из одних пробельных символов не учитываются.
lines = text.split("\n")
if len(lines) < 2:
return text
first_line, tail_lines = lines[0], lines[1:]
min_indent = min(
self._indent(line)
for line in tail_lines
if not self._whitespace_regex.match(line)
)
if min_indent <= last_indent:
return text
dindent = min_indent - last_indent
tail = "\n".join(line[dindent:] for line in tail_lines)
return "\n".join((first_line, tail))
def _indent(self, string):
return len(self._indent_regex.match(string).group())
Пример использования:
if __name__ == "__main__":
env = Environment(extensions=[AutoindentExtension])
template = env.from_string(u"""
{%- autoindent %}
{% if True %}
What is true, is true.
{% endif %}
{% if not False %}
But what is false, is not true.
{% endif %}
{% endautoindent -%}
""")
print(template.render())
# Печатает текст с нулевым отступом.
Можно посмотреть целиком
на Github.
Спасибо за внимание и успехов в разработке собственных расширений!
Права на логотип Jinja и его части, использованные в статье, принадлежат команде Jinja (подробнее).