python

Руководство по созданию расширений для Jinja2

  • пятница, 20 октября 2017 г. в 03:12:50
https://habrahabr.ru/post/340254/
  • Программирование
  • Python


Jinja2 — Python-библиотека для рендеринга шаблонов, являющаяся де-факто стандартом при написании веб-приложений на Flask и довольно популярной альтернативой встроенной системе шаблонов Django. Хотя и будучи сильно привязана к языку, Jinja2 позиционирует себя как инструмент для дизайнеров и верстальщиков, упрощающий вёрстку и отделяющий её от разработки, и пытающийся по мере возможностей изолировать не-разработчиков от Python. Вёрстка, впрочем, не единственное возможное её применение; например, в своей работе я использую шаблоны Jinja2 для генерации SQL-запросов.

Jinja2 расширяема, и многие возможности (например, интернационализация и управление циклами) реализованы именно как расширения. Однако, документация по написанию расширений, как мне кажется, несколько неполна; от примера несложного (но тщательно прокомментированного) расширения она перескакивает сразу к описанию API некоторых классов Jinja2, которое довольно трудно читать подряд. В этой статье я попытаюсь исправить это упущение и создать в голове читателя полную и ясную картину того, как работает Jinja2, как устроены её расширения и как с помощью расширений модифицировать разные этапы обработки шаблонов.

Как работает Jinja2


Глобально, Jinja2 компилирует каждый шаблон в Python executable, который принимает на вход контекст и возвращает строку — отрендеренный шаблон. Весь процесс в целом выглядит так.

  1. Загрузка. Вы можете хранить шаблоны в файловой системе, в папке с вашим Python-пакетом, в памяти или просто генерировать на лету — в первую очередь Jinja2 определяет, какой из способов актуален, и загружает исходники шаблона в память.
  2. Токенизация. Лексический анализатор (lexer) бьёт исходный текст шаблона на простейшие сущности — токены. Пример токена — открывающая теги конструкция {%.
  3. Парсинг. Синтаксический анализатор (parser) разбирает поток токенов, вычленяя синтаксические конструкции. Пример синтаксической конструкции — подставляющая значение переменной конструкция {{ variable }} (она состоит из трёх токенов — открывающего {{, имени variable и закрывающего }}).
  4. Оптимизация. На этом этапе вычисляются все константные выражения. Например, конструкция {{ 1 + 2 }} будет превращена в {{ 3 }}.
  5. Генерация. Синтаксические конструкции, до сих пор хранившиеся в виде абстрактного синтаксического дерева (AST), конвертируются в код на Python.
  6. Компиляция. Полученный Python-код компилируется встроенной функций compile. Получившийся объект можно запускать встроенной фукнцией exec, что шаблоны и делают при рендеринге.

Как устроены расширения в Jinja2


Для создания расширения в 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.

Управляем составлением AST


Последний и самый интересный метод класса 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.

Используем CallBlock


Поскольку из-за тонкостей архитектуры 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 %}

Происходит следующее:

  1. Создаётся временный макрос с именем caller. Тело макроса — содержимое между {% call... %} и {% endcall %}. Макрос может как иметь аргументы (в примере выше это один аргумент user), так и не иметь (если используется упрощённая конструкция {% call something(...) %}).
  2. Вызывается макрос, указанный после конструкции 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 (подробнее).