Jinja2 — Python-библиотека для рендеринга шаблонов, являющаяся де-факто стандартом при написании веб-приложений на Flask и довольно популярной альтернативой встроенной системе шаблонов Django. Хотя и будучи сильно привязана к языку, Jinja2 позиционирует себя как инструмент для дизайнеров и верстальщиков, упрощающий вёрстку и отделяющий её от разработки, и пытающийся по мере возможностей изолировать не-разработчиков от Python. Вёрстка, впрочем, не единственное возможное её применение; например, в своей работе я использую шаблоны Jinja2 для генерации SQL-запросов.
Jinja2 расширяема, и многие возможности (например, интернационализация и управление циклами) реализованы именно как расширения. Однако, документация по написанию расширений, как мне кажется, несколько неполна; от примера несложного (но тщательно прокомментированного) расширения она перескакивает сразу к описанию API некоторых классов Jinja2, которое довольно трудно читать подряд. В этой статье я попытаюсь исправить это упущение и создать в голове читателя полную и ясную картину того, как работает Jinja2, как устроены её расширения и как с помощью расширений модифицировать разные этапы обработки шаблонов.
Глобально, Jinja2 компилирует каждый шаблон в Python executable, который принимает на вход контекст и возвращает строку — отрендеренный шаблон. Весь процесс в целом выглядит так.
Для создания расширения в Jinja2 нужно определить класс, наследующийся от
Краткая иллюстрация вместо тысячи слов:
Осталось научить их что-то делать! Для этого в нашем распоряжении, по большому счёту, всего три метода, которые можно переопределять:
Что ж, начнём по порядку.
Простейший способ управлять напрямую загрузкой исходников шаблонов — реализовать собственный загрузчик (loader). Сделать это элементарно: наследуемся от
Однако, это оффтоп: где тут расширения? Понятное дело, что я могу в любой момент отнаследоваться от чего захочу и поменять там что сочту нужным! Удивительно, но в API расширений тоже на всякий случай есть способ управлять напрямую исходным кодом шаблонов.
Так, класс
В этом методе можно делать всё, что угодно. Технически, где-то здесь вы можете реализовать компиляцию своего собственного языка шаблонов в шаблоны Jinja2. Но… зачем? Вероятно, возможность модифицировать исходники напрямую может пригодится вам как вспомогательная при написании нетривиальных расширений. Однако, здесь не требуется знание API Jinja2 или особенностей её реализации, поэтому мы не будем больше вдаваться в детали этого этапа и пойдём дальше, к токенизации.
Куда бо?льший интерес для нас представляет метод
В целом взаимодействие лексического и синтаксического анализаторов в Jinja2 устроено следующим образом. Лексический анализатор (
Токены в Jinja2 — объекты очень простые. По сути, это кортежи из трёх именованных полей:
Различные константы для поля
Типичное расширение, манипулирующее токенами, должно выглядеть как-то так:
В качестве примера давайте напишем расширение, которое меняет логику рендеринга переменных. Допустим, вы хотите, чтобы некоторые ваши объекты при рендеринге в Jinja2 вели себя не так, как при конвертации в строку функцией
Пример использования:
Можно посмотреть целиком на Github.
Последний и самый интересный метод класса
Работает он в связке с атрибутом
Соответственно, метод
При этом атрибут
Далее внутри метода
Пока же логика существующих типов узлов вроде
Распарсив всё, что нужно, вы можете захотеть создать один или больше узлов дерева, чтобы вернуть их в качестве результата парсинга. Что нужно знать о создании узлов Jinja2:
В качестве примера применения всех этих знаний давайте рассмотрим простое расширение, которое добавляет конструкцию
Пример использования:
Можно посмотреть целиком на Github.
Поскольку из-за тонкостей архитектуры Jinja2 добавлять новые классы узлов синтаксического дерева нельзя, требуется некий универсальный узел, в котором можно было бы делать любую обработку, какая вздумается. Такой узел есть, и это
Давайте сперва вспомним, как работает тег
Происходит следующее:
Однако макрос в Jinja2 — это не что иное, как функция, которая возвращает строку. Поэтому узлу
Типичное расширение, использующее
Как это работает?
Посмотреть с примером использования можно на Github.
Ну и напоследок чуть более сложный пример расширения, использующего
Пример использования:
Можно посмотреть целиком на Github.
Спасибо за внимание и успехов в разработке собственных расширений!
Права на логотип Jinja и его части, использованные в статье, принадлежат команде Jinja (подробнее).
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
.lineno = next(parser.stream).lineno
Обычно первая строка в кодеparse
. Вызовnext
сдвигает парсер на следующий после имени тега токен и возвращает текущий. Мы запоминаем из него только номер строки; нам надо будет указывать его при создании узлов, чтобы в случае ошибок в трейсбеке корректно указывался их источник — наш кастомный тег. (Подробно о создании узлов будет чуть ниже.)parser.stream.expect(token_description)
Вернуть текущий токен и сдвинуться на следующий, если текущий подходит под описание, или упасть с ошибкой. Здесь описание — это либоtype
токена, либо строка вида"type:value"
. Так,parser.stream.expect("integer")
попытается прочитать число и вернуть его, или падает;parser.stream.expect("name:in")
используется при парсинге тега for, чтобы удостовериться, что дальше в коде идёт ключевое словоin
, и пропустить его.parser.stream.skip_if(token_description)
ВозвращаетTrue
и свдигает на следующий токен, если текущий токен подходит под описание; иначе возвращаетFalse
. Типичное использование — парсинг опциональных конструкций. Например, всё в том же коде парсинга for:
if parser.stream.skip_if('name:if'): test = self.parse_expression()
(Да-да, в Jinja2 у цикла for есть опциональный суффикс if.)expr_node = parser.parse_expression()
Пытается распарсить выражение и вернуть соответствующий узел AST или падает. Стоит использовать для парсинга параметров тегов. В примере выше for использует этот вызов, чтобы распарсить условие фильтрации; его же он использует и послеexpect("name:in")
, чтобы понять, по какому итераблу будет цикл.target_node = parser.parse_assign_target(extra_end_rules=[])
Пытается распарсить lvalue, то есть выражение, которому можно присваивать, или падает. Типичные примеры — имя переменной, несколько имён переменных через запятую, выражение с индексом. Поскольку Python допускает свободные запятые в конце кортежей (например,for a, b, c, in []: pass
), этот метод может принимать дополнительные условия останова (так, тег for при парсинге списка переменных цикла вызывает его сextra_end_rules=["name:in"]
, чтобыin
случайно не распозналось как ещё одна переменная).
body_nodes = parser.parse_statements(end_tokens=[], drop_needle=True)
Парсит внутренности тега. Предполагает, чтоparser.stream.current
уже указывает на%}
(иначе падает), и парсит шаблон до тех пор, пока не наткнётся на конец файла или на токен, подходящий под одно из описаний вend_tokens
. Так, тег if вызывает этот метод сend_tokens=["name:elif", "name:else", "name:endif"]
. Параметрdrop_needle=True
указывает, что этот последний токен после парсинга нужно выбросить; удобно, если тело вашего тега может закончиться только одним способом.
Распарсив всё, что нужно, вы можете захотеть создать один или больше узлов дерева, чтобы вернуть их в качестве результата парсинга. Что нужно знать о создании узлов 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 (подробнее).