記事一覧:2026年08月02日

変数の参照を操作するJinjaテンプレートエンジン用カスタムタグを作った

 前回記事「Jinjaテンプレートエンジン用のエクステンション入門」ではJinjaテンプレートエンジン用エクステンションの作り方を紹介しましたが、実際にエクステンションを作ってみようとすると細かいところでよく分からないところが出てきたりしたので、今回は実際に作成したエクステンションの紹介と、前回記事についていくつか補足したいと思います。

作成したJinja用エクステンション

 今回作成したのは、{% importprop %}というカスタムタグを提供するエクステンションです。このカスタムタグは{% endimportprop %}タグと組み合わせて使用するもので、オブジェクト(一般的にはdict型のオブジェクト)を引数として取ります。

 {% importprop %}{% endimportprop %}で囲まれたブロック内では、引数で指定したオブジェクトの属性(もしくはdictに格納されている要素)に対してprefixなし、つまり「オブジェクト名.」という表記を省略した形でアクセスできるようになります。

 たとえば、次のテンプレートでは{% importprop %}{% endimportprop %}で囲まれたブロック内でbarという変数を参照していますが、実際にはfoo.barの値が参照されるようになります。

{% importprops foo %}Value of the `bar` property of `foo` is {{ bar }}.{% endimportprops %}

 もし対応する属性が存在しなかった場合は、そのブロック外で定義されていた値が代わりに使用されます(ブロック外でその値が定義されていなければundefinedになる)。

 JavaScriptではwithという構文がありますが(ドキュメント)、これはこのwith文と同じような働きをするものと考えると分かりやすいかもしれません(ただし、現代のJavaScriptにおいてはwithの使用は非推奨です)。

 このカスタムタグは、dict型の変数を格納したlistfor文でイテレートしてテンプレートをレンダリングしたいが、もしそのdict内で特定のキーとそれに対応する値がセットされていなかった場合は別のデフォルト値を代わりにレンダリングしたい、というケースでの利用を想定しています。

 たとえば次の例では、ブロック内でtypenameの2つの変数が使用されていますが、これらは実際にはitem.typeitem.nameを参照して値が決定され、かつitem.typeが定義されていなければその代わりにtype変数(ここでは"default"という文字列が格納されている)が参照されます。

{% set type = "default" %}
{% for item in items %}
{% importprops item %}
<li><span class="iten-name {{ type }}">{{ name }}</span>
{% endimportprops %}
{% endfor %}

 このExtensionはGitHubで公開しているので、詳しくはそちらもご参照ください。

実装のポイント

 前述のように、このカスタムタグはJavaScriptのwith文にインスパイアされたものです。Jinja2にはすでにwith文{% with %}タグ)が実装されてはいるのですが、これは変数名と代入する値を引数で指定する必要があるため、JavaScriptのwithのようにdictの要素すべてをまとめてprefix無しでアクセスしたいという用途には使えません。ただ、動作としては似ており、かつこのwithタグは以前はExtensionとして実装されていたので、当初はこれを少し修正すればすぐに実装できると考えたのですが、実際は次のような理由で簡単ではありませんでした。

  • Extensionはテンプレートのパース処理には介入できるが、テンプレートレンダリング時のコンテキストを直接操作することはできない
    • コンテキスト内の変数に対してなんらかの処理を行いたい場合は、Pythonコードで処理を行うのではなく、必要な処理をASTノードとして組み立てて、それをExtensionのparse()メソッドの戻り値として返す必要がある
  • コンテキストに値をセットする処理を行うASTノードは定義されているが、その変数名には定数しか与えられないようだ
    • つまり、コンテキスト内に格納されている値を変数名として使用することができなさそう
      • (もしかしたら可能なのかもしれないが見つけられなかった)

 よくよく考えてみると、JinjaではテンプレートをPythonコードに変換し、そのコードに変数とその値を引数として与えて実行することでレンダリングを行っており、かつPythonでは変数の値を参照して変数の名前を定義することは基本的にできません。そのため、Jinjaにおいてもコンテキスト内の値を参照して変数を定義することは難しいのではないか、と判断しました。

 ちなみに、当初はコンテキスト内に動的に変数を追加するような実装を想定して仕様を作成し、それをAIに渡して実装してもらおうとしたのですが、なにやら禅問答のようなものが始まり長考に入ってしまったためそちらは諦めています。テストコードについてはほぼ適切なものが出力されていたので仕様については理解できていたようなのですが、実装方法を見つけられなかったようでした(そもそもJinjaのExtensionの実装に関する知識が少なかったのかもしれません。もしかしたら高性能なAIを使えば実装できたのかもしれませんが……)。

 そもそもこのExtensionで実現したいことは変数を追加定義することではなく、prefixなしでの変数アクセスをprefix付きのアクセスのように扱わせたい、ということです。そこで、ブロック内の変数アクセスをすべて書き換える、という方針に転換して再度実装を考えることにしました。

ASTにおいて変数へのアクセスはどう表現されているのか

 JinjaのASTではStatement(テンプレート上で{% ... %}の形で表現されている部分)やExpression(テンプレート上で{{ ... }}の形で表現されている部分)といったテンプレートの構成要素に対応したクラスが用意されており、文字列として渡したテンプレートはパースされた後にこれらクラスのオブジェクトに変換されます。

 たとえば変数へのアクセスは、jinja2.nodes.Nameというクラスのオブジェクトに変換されます(ドキュメント)。このクラスは変数名を示すnameと、変数に対する操作を示すctxという2つのパラメータを持っています。たとえば、fooという変数から値を取り出すという操作に対応するノードは次のようになります。

jinja2.nodes.Name("foo", "load")

 また、fooオブジェクトのbar属性、つまりfoo.barから値を取り出す操作は、次のようにjinja2.nodes.Getattrクラスを使ったノードで表現されます。

jinja2.nodes.Getattr(jinja2.nodes.Name("foo", "load"), "bar", "load")

 つまり、barという変数へのアクセスを、fooオブジェクトのbar属性へのアクセスに置き換えるには、jinja2.nodes.Name("bar", "load")jinja2.nodes.Getattr(jinja2.nodes.Name("foo", "load"), "bar", "load")に置き換えれば良い、ということになります。また、属性が存在するかどうかはjinja2.nodes.Testクラスを使ったノードで表現できます。今回作成したExtensionでは、このような置換を行うために次のような関数を実装し、{% importprop %}{% endimportprop %}で囲まれたブロック内のjinja2.nodes.Name型のオブジェクトに対してこの関数を適用することで変数へのアクセスを書き換えています。

def replacer(node:Node):
    """Internal function to generate a node to get value if given name exists"""
    return nodes.CondExpr(
        nodes.Test(
            nodes.Getattr(arg_node, node.name, "load", lineno=node.lineno),
            "defined", [], {}, None, None),
        nodes.Getattr(arg_node, node.name, "load", lineno=node.lineno),
        node)

ASTの各クラスのコンストラクタはどのような引数を取るのか

 さて、このようにASTを操作する処理を実装する場合、ASTを構成する各クラス(jinja2.nodes.Nodeの派生クラス)がどのようなパラメータを取るのかを知る必要があります。しかし、ドキュメントにはそれぞれのクラスのコンストラクタがどのような引数を取るのかは明記されていません。たとえば、上記のjinja2.nodes.Testクラスについては次のように表記されています(ドキュメント)。

class jinja2.nodes.Test(node, name, args, kwargs, dyn_args, dyn_kwargs)
 
Apply a test to an expression. name is the name of the test, the other field are the same as Call.

 また、jinja2.nodes.Callクラスのドキュメントは次のようになっています、

 class jinja2.nodes.Call(node, args, kwargs, dyn_args, dyn_kwargs)

Calls an expression. args is a list of arguments, kwargs a list of keyword arguments (list of Keyword nodes), and dyn_args and dyn_kwargs has to be either None or a node that is used as node for dynamic positional (*args) or keyword (**kwargs) arguments.

 ここから、kwargs以下の引数にはそれぞれNodejinja2.nodes.Node)派生クラスのリストを、name引数は実行するテストの名前を与えれば良さそうで、またnode引数はその名前からNodeクラスのオブジェクトを取りそうな雰囲気はありますが、具体的にどのような値を与えれば良いのかは、これだけではまったく分かりません。ただ、幸いなことにJinjaのソースコードには現代のPythonコードらしく型注釈が付与されており、それを確認することでどのような引数を与えれば良いかを確認できます。

 たとえばjinja2.nodes.Testクラスは次のように実装されています(ソースコード)。

class Test(_FilterTestCommon):
    """Apply a test to an expression. ``name`` is the name of the test,
    the other field are the same as :class:`Call`.

    .. versionchanged:: 3.0
        ``as_const`` shares the same logic for filters and tests. Tests
        check for volatile, async, and ``@pass_context`` etc.
        decorators.
    """

    _is_filter = False

 ここだけを見てもよく分からないのですが、派生元の_FilterTestCommonクラスは次のようになっています(ソースコード)。

class _FilterTestCommon(Expr):
    fields = ("node", "name", "args", "kwargs", "dyn_args", "dyn_kwargs")
    node: Expr
    name: str
    args: t.List[Expr]
    kwargs: t.List[Pair]
    dyn_args: t.Optional[Expr]
    dyn_kwargs: t.Optional[Expr]
    abstract = True
    _is_filter = True

 jinja2.nodes.Nodeクラスの派生クラスはやや特殊な実装になっており、fieldsクラス変数にコンストラクタが受け取る引数の名前が格納されています。そして、各引数の型はその下の型定義で確認できます。この例の場合、次のようになります。

  • node: jinja2.nodes.Exprの派生クラス
  • name: str(文字列)
  • args: jinja2.nodes.Expr`派生クラスを格納したリスト
  • kwargs: jinja2.nodes.Pair`派生クラスを格納したリスト
  • dyn_args: jinja2.nodes.Exprの派生クラス(オプショナル)
  • dyn_kwargs: jinja2.nodes.Expr`の派生クラス(オプショナル)

 これで、少なくともそれぞれの引数にどのような型の値を与えれば良いのかについては判別できます。

 ちなみに、この型宣言だけでは分からないのですが、name引数にはJinjaドキュメントの「List of Builtin Tests」に記載されているテスト名のいずれかを指定します。たとえば、{% if variable is defined %}に相当するノードではdefinedを指定します。また、argskwargsには各テストに与えるvalue以外の引数を指定します。definedは引数としてvalueのみを取るため不要ですが、たとえばsameasはのように追加の引数を取るので、この追加の引数をargs引数で与える形になります。

{% if foo.attribute is sameas false %}

 このように、ドキュメントとソースコードを一緒に確認しつつASTを組み立てたり操作しなければいけない点が、JinjaのExtension実装で大変なところでしょう。

 なお、jinja2.nodes.Node派生クラスのオブジェクトは、コンストラクタでパラメータを指定せず(つまりコンストラクタでは引数を与えない)、インスタンスを作成したあとに引数のフィールド名と同じ属性に値を代入することでも操作できます。たとえば、some_node = jinja2.nodes.Name("foo", "load")というコードは、次のように書くことができます。

some_node = jinja2.nodes.Name()
some_node.name = "foo"
some_node.ctx = "load"

 補完機能を持つエディタでコードを書いている場合、コンストラクタに引数を与える形だとコード補完や型チェックが動作しない可能性があるため、後者の書き方のほうが良いのかもしれません。