Jinjaテンプレートエンジン用のエクステンション入門

 Pythonで利用できるテンプレートエンジンの1つにJinjaというものがあります。一般的にはWebページのレンダリング等で使われることが多いですが、近年ではAI系ツールでもよく使われている、とても多機能なテンプレートエンジンです。

 このJinjaには、独自のテンプレートタグを追加できる「Extensions」という機能があります。このExtensionsを利用することで、Jinjaにおける標準的なテンプレートのルールにとらわれない、柔軟なテンプレート処理が実現できます。

 しかし、このExtensions機能はあまり使われていないようで、ネットを検索してもあまり情報がありません。そもそも公式ドキュメントではExtensionsを実装するためのAPIについての記載はあるものの、最低限のサンプルコードしかなく、チュートリアル的なものもほとんどありません。実際のところ、JinjaはExtensionsを使わなくてもテンプレート内から簡単にPythonの関数を呼び出すことができるため、Extensionsをわざわざ実装しなくてもほとんどのユースケースに対応できます。そのためExtensionsを実装する需要は少なく、結果として利用例が少ないのだと思われます。

 いっぽうで、ほかのテンプレートエンジンでは利用できるがJinjaでは利用できないような機能を使用したい場合、このExtensions機能を使って実装することでその機能をJinjaに追加することができます。これは、他のテンプレートエンジンでの利用を想定して作成されていたテンプレートをJinja向けに移植する際などに有用です。自分もそういった目的でJinjaのExtensionsを作ってみようとしたのですが、AIにコーディングさせようとしたところ、あまりにも情報が少なすぎるのか、まともに動作するコードを生成できませんでした。ということで、ここでJinja向けのExtensionsについての簡単な解説を記しておきます。

 なお、本記事ではJinjaの基本的な使い方や用語について理解していることを前提としています。Environmentやテンプレートの文法、テンプレートにおけるStatementExpressionなどについての説明は、別途Jinjaのドキュメント等をご参照ください。

前提情報:Jinjaの仕組みとExtensions

 Jinjaでは、テンプレートをまずAST(Abstract Syntax Tree)と呼ばれる形式の表現に変換します。ASTはHTMLにおけるDOMのようなもので、テンプレートに記載されているコンテンツをツリー構造のオブジェクトに変換したものです。テンプレートは「Parser」と呼ばれるものでASTに変換され、続いて「Compiler」と呼ばれるものでASTがPythonの実行可能コードに変換されます。ここで得られた実行可能コードにレンダリングするパラメータを与えて実行することで、最終的な出力が得られます。レンダリングするパラメータ(変数)はコンテキスト(Context)と呼ばれるもので管理されており、レンダリング時に変数とその値をContextに渡せるほか、テンプレート内からContextに変数や値を追加したり、変数の値を変更したりすることができます。

Jinjaでのレンダリングの仕組み

 Extensionsでは、このテンプレートの処理プロセスに介入する仕組みが提供されています(ドキュメント)。Extensionはjinja2.ext.Extensionクラスを継承して実装するようになっており、JinjaのEnvironmentに対し実装したExtension派生クラスを登録することで、そのExtensionsが利用できるようになります。

 たとえばSomeExtensionというExtensionを実装した場合、次のようにするとそのExtensionが利用できるようになります。

import jinja2
from jinja2 import Environment
from some_extension import SomeExtension

env = Environment(extensions=[SomeExtension])

# 下記のような書き方でもOK
# env = Environment()
# env.add_extension(SomeExtension)

 EnvironmentにExtensionを登録すると、そのEnvironmentが引数として与えられてExtensionのコンストラクタが呼び出されます。また、以下のタイミングでExtensionの次のメソッドが呼び出されるようになります。

  • テンプレートの読み込み時:preprocess()メソッド
  • テンプレートのパース時:filter_stream()メソッド
  • Parserが指定したステートメント(カスタムタグ)を読み込んだとき:parse()メソッド

 これによって下記のようなことが実現できます。

  • Extensionの登録時にEnvironmentに対してパラメータの変更・追加やメソッドの追加といった操作を行う
  • Jinjaがテンプレートを読み込む際にそのテンプレートに対してなんらかの前処理を実行する
  • パースされたテンプレートを操作する
  • テンプレート内で独自のステートメント({% ... %})を利用できるようにする

parse()メソッドの実装

 上記の4つのうち、最初の3つ(コンストラクタ、preprocess()filter_stream())ではそれぞれのメソッドに対して引数として渡されたEnvironment変数やパース前のテンプレートのソース(文字列)、パースされてトークンに分割されたテンプレートなどを操作する処理を記述できます。それぞれのメソッドの戻り値についても、preprocess()はテンプレートの文字列、filter_stream()はトークンを返すイテレータを返せば良いため、メソッドの実装は「渡された入力に対し何らかのメソッドを実行して操作を行ってそれを返す」という、直観的で分かりやすい内容になります。

 一方、カスタムタグを実装するために使用するparse()メソッドでは引数としてテンプレートの内容を読み出すためのParserオブジェクトが渡され、これを使ってテンプレートの中身をパースしてAST(Nodeオブジェクト)を生成したり、必要に応じて独自にNodeオブジェクトを作成したりして、最終的にそれらを戻り値として返すという、やや分かりにくい処理を実装することになります。下記ではこの実装に必要となるASTおよびParserについてまず解説します。

Parserの内部処理

 前述のように、Parserドキュメント)はテンプレートをASTに変換する処理を担当するクラスです。ここでは次のような手順でテンプレートをASTに変換していきます。

Parserの内部処理1:テンプレートのトークン化(tokenize)

 Parserはまず渡されたテンプレート(str形式)をTokenと呼ばれる単位に分割します。

 たとえば、<a href="{{ url }}">{{ link_text }}</a>というテンプレートが渡された場合、Parserはこれを次のように9つのトークンに分割します。

テンプレートのトークン化

 このようなテンプレートからトークンへの変換処理は、Lexerというクラスで実装されています(実装コード)。Token自体を表現するTokenクラス(ドキュメント)やそのタイプなども同じジュールで定義されています。

 変換されたトークンはTokenStreamドキュメント)というイテレータブルなオブジェクトに格納されます。Parserオブジェクトからは、そのstreamプロパティでTokenStreamにアクセスできます。

Parserの内部処理2:トークンのパース

 続いてParserはこのTokenStreamからトークンを順に取り出して、ASTのオブジェクト(Nodeクラスを継承したクラスのオブジェクト)に変換していきます(実装コード)。たとえばtypedataのトークンが取り出されたら、その値を格納したTemplateDataクラスのオブジェクトを生成します。また、typevariable_beginのトークンが取り出されたら、Parserクラスのparse_tupleというメソッド(実装)を実行し、そこでtypevariable_endのトークンに到達するまでのトークンを処理します。typenameのトークンが取り出されたら、Nameクラスのオブジェクトを生成します。

トークンのAST化

 また、ASTのオブジェクトにはノードのコンテンツを出力するOutputクラスや、Pythonの関数やメソッドを実行するCallクラスといった、なんらかの処理を実行するためのものも用意されています。上記の例では、生成されたオブジェクトを最後にOutputクラスのオブジェクトに格納することで、テンプレートの中身が出力されます。

 なお、Parserクラスにはparse_*という名称でさまざまなタイプのトークンをパースしてASTに変換するためのメソッドが実装されており、Parserは必要に応じてそれらを使ってさまざまなオブジェクトを生成します。また、前述のようにASTを生成するまでがParserの役割で、そこから出力を実際に生成するための処理はCodeGeneratorという別のクラス(実装コード)で実装されています。Extensionはこの部分の処理には介入できないため、本稿ではここから先の処理についての詳細は割愛しますが、CodeGeneratorではASTを順に走査し、その内容に応じたPythonコードを出力する、といった処理を実行しています。ここで出力されたPythonコードはPythonの中間コードに変換され、Jinjaのテンプレートレンダリングメソッドが呼び出されるたびにそのコードが実行されてテンプレートがレンダリングされます。

Extensionのparser()メソッドが呼び出されるタイミング

 Parserはトークンのパース時にステートメント開始トークン(typelinestatement_beginのトークン)に遭遇した場合、parse_statement()というメソッドを実行してそれをパースしようとします(実装)。ここでステートメントの値({%%}で囲まれた部分の最初のキーワード)がJinjaの組み込みステートメントと一致した場合、それをパースするためのメソッドが実行されます。一致しなかった場合は、Environmentに登録されているExtensionのtagsプロパティを順にチェックし、ステートメントの値と一致するキーワードを持つExtensionが見つかれば、Parserオブジェクトを引数として与えてそのExtensionのparse()メソッドを実行します。

 Extensionのparse()メソッドが実行されたタイミングでは、与えられたParserオブジェクトのstreamプロパティ(Tokenを返すイテレータ)はそのステートメントの値を格納するName型のトークンを指した状態になっています。Extensionではこのstreamプロパティ、もしくはParserオブジェクトのメソッドを使ってTokenを走査し、ASTのノードを生成してそれらを戻り値として返す処理を実装することになります。

ASTの生成

 ASTのノードはjinja2.nodesモジュールのクラスとして実装されており、一般的なクラスと同じように生成できます。たとえば、テンプレート内の定数文字列を表すTemplateDataノードは次のように作成できます。

from jinja2 import nodes

templateDataNode = nodes.TemplateData(data)

 なお、ノードによって生成時に与えられる引数は異なりますが、lineno=およびenvironment=以外のキーワード引数を与えることはできません。多くのNodes派生クラスは独自のコンストラクタ(__init__()メソッド)を持たず、Nodesクラスのコンストラクタを使用する形で実装されており、そこでこれら以外のキーワード引数が与えられた際はTypeError例外を生成するように実装されているためです(実装)。つまり、次のような記述はできません。

templateDataNode = nodes.TemplateData(data=data)

Extensionの実装例

 以上をまとめると、Jinjaで{% some_statement %}という独自タグ(独自のステートメント)を実装するには、次のような手順を踏めば良いということになります。

  1. jinja2.ext.Extensionクラスを継承したSomeExtensionクラスを定義する
  2. SomeExtensionクラスのtagsプロパティに{ "some_statement" }をセットする
  3. SomeExtensionクラスのparse()メソッドに処理を記述する

 これを踏まえて、以下ではparse()メソッドに実装する処理の実例をいくつか紹介していきます。

固定文字列を出力する

 まずはシンプルに「hello world」という固定文字列を出力する{% helloworld %}タグを実装してみましょう。この独自タグを追加するHelloWorldExtensionクラスは次のようになります。

class HelloWorldExtension(Extension):
    tags = {"helloworld"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        return nodes.Output([nodes.Const("hello world")], lineno=lineno)

 前述のように、parse()メソッドが呼び出された時点では引数として渡されたParserクラスのオブジェクト(parser)のstreamプロパティはステートメント名を格納するTokenを指しているので、next()関数でまずこのトークンを取り出しています。Token型オブジェクトのlinenoプロパティにはそのトークンがテンプレート中の何行目に記述されているかを示す数値が入っており、Nodeクラス派生オブジェクトのコンストラクタにこれを与えることで、エラー発生時にこの行番号を表示してくれるようになります。これはテンプレートのデバッグ時に役立ちます。

引数として与えた値を出力する

 次の例は、ステートメントに続く値を引数として解釈し、それを出力するタグを実装したものです。たとえば{% hello "world" %}をレンダリングするとhello, worldという文字列が得られます。

 ここでは、Parserが持つstreamイテレータが指しているトークンを取り出し、それに対応するNodeを生成するparse_expression()メソッド(ドキュメント)を使って引数部分に対応するノードを生成しています。このメソッドはトークンが定数の場合はConstノードを返します。また、トークンが定数ではない文字列(たとえばworldのような"等で囲まれていない文字列)の場合はそれを変数名と解釈し、コンテキストから対応する値を取り出すNameノードを生成します。つまり、この{% hello %}タグでは定数と変数の両方を引数として与えることができます。

class HelloExtension(Extension):
    tags = {"hello"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        next_token = parser.stream.look()
        argNode = parser.parse_expression()
        return nodes.Output([nodes.Const("hello, "), argNode], lineno=lineno)

Extensionのメソッドを呼び出す

 レンダリング時にExtentionのメソッドを呼び出すことも可能です。次の例は、Extensionの_get_now()というメソッドを呼び出すものです。たとえば{% now "%y-%m-%d %H:%M:%S" %}というテンプレートをレンダリングすると、現在時刻を示す26-07-27 00:19:25のような文字列が出力されます。また、引数を与えなかった場合は、ISO形式の文字列で現在時刻が出力されます。

 メソッドを呼び出すにはExtentionsクラスのcall_method()メソッドを利用します(ドキュメント)。注意したいのが、このメソッドは指定したメソッドをその場で実行するのではなく、レンダリング時に指定したメソッドを実行するCallノードを生成して返すという点です。

 call_method()は引数としてメソッドの実行時に与える引数を格納したリストを受け取るのですが、このリストにはNode派生クラスのオブジェクトをそのまま格納できます。つまり、parse_expression()メソッドでパースしたASTをそのまま引数として渡せます。指定したメソッドが実行される際には、ASTのノードは適切な値に変換されてから引数として渡されます。また、メソッドの戻り値も適切なASTのノードに自動変換されます。

 {% now %}タグに変数が渡されているかどうかは、Parserクラスのstreamプロパティが指しているトークンの種類で判別できます。このトークンがblock_endであれば引数は存在しないので、引数を与えずに_get_now()メソッドを実行します。

class NowExtension(Extension):
    tags = {"now"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        
        # parser.streamが指しているトークンがblock_endなら引数は存在しない
        if parser.stream.current.type == "block_end":
            # 引数がないのでメソッド呼び出し時にも引数を与えない
            arg = []
        else:
            arg = [parser.parse_expression()]

        node = self.call_method("_get_now", arg)
        return nodes.Output([node], lineno=lineno)
        
    def _get_now(self, fmt=""):
        if fmt:
            return datetime.now().strftime(fmt)
        else:
            return datetime.now().isoformat()

終了タグを使ったカスタムタグ

 Jinjaでは、{% if ... %}{% endif %}のような、endで始まる終了タグと組み合わせて使用するステートメントがあります。Extensionsでもこのような終了タグと組み合わせて使用するカスタムタグを実装できます。次の例は、{% wrap %}タグと{% endwrap %}タグに囲まれた部分を<div></div>で囲んで出力するExtensionを実装したものです。

 このように終了タグを使用する場合、tagsプロパティにendwrapを追加するのではなく、Parserクラスのparse_statements()メソッド(ドキュメント)を使用するのがポイントです。このメソッドは、第1引数で指定した条件に合致するトークンまでを取得し、それをAST(Node派生クラスの配列)に変換したものを返します。なお、drop_needle引数は終了タグ部分のトークンを破棄するかどうかを指定する引数です。終了タグに対して特に追加処理を行わないのであれば、Trueを指定します。

class WrapExtension(Extension):
    tags = {"wrap"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        body = parser.parse_statements(["name:endwrap"], drop_needle=True)
        
        # 先頭と末尾にDIV開始タグと終了タグを出力するノードを追加する
        body.insert(0, nodes.Output([nodes.Const("<div>")]))
        body.append(nodes.Output([nodes.Const("</div>")]))
        
        return body

まとめ

 ここでは基本的なExtensionの実装例のみを紹介しましたが、Extensionのparse()メソッドではテンプレートのトークンに直接アクセスできるため、他のタグ(ステートメント)に相当するトークンがパースされる前にそれを乗っ取って挙動を変えたり、本来のJinjaテンプレートではエラーになるような文法を実装する、といったことも可能になります。いっぽうで、あくまでトークンをASTに変換するといった処理しかできないため、コンテキストの値に応じて動的に内容が変わるような処理を実装したい場合は工夫が必要です。また、単にメソッドを実行してその結果をレンダリングしたいのであれば、コンテキストにメソッドを追加して実行させるほうが簡単な場合もあります。実現したい処理に応じて、ケースバイケースで利用するのが良さそうです。