変数の参照を操作する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"

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

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に変換するといった処理しかできないため、コンテキストの値に応じて動的に内容が変わるような処理を実装したい場合は工夫が必要です。また、単にメソッドを実行してその結果をレンダリングしたいのであれば、コンテキストにメソッドを追加して実行させるほうが簡単な場合もあります。実現したい処理に応じて、ケースバイケースで利用するのが良さそうです。

Windows環境でAMD GPUでllama.cppを動かす

 ネット上にWindows環境でAMD Radeon GPUを使ってllama.cppを動かす情報が少なく、若干ハマったので簡単にメモを残しておきます。なお、この分野は日進月歩で進んでいるため今後このドキュメントの内容は使い物にならなくなっている場合がありますのでご注意ください。本記事の執筆日時は2025年12月27日です。

検証環境

 本記事の内容は下記の環境で検証しています。

  • OS:Windows 11 Pro 24H2
  • CPU:AMD Ryzen 6 7600X
  • GPU:AMD Radeon RX 9060 XT(16GB)
  • メインメモリ:32GB

llama.cppバイナリの入手

 llama.cppのGitHubリポジトリ上にあるinstall.mdではWingetを使えと書いてあります。この場合、llama.cppのGitHubリポジトリのReleasesページで公開されている最新バイナリの「Vulkan」版がインストールされるようです。

 いっぽうで、リリースページには「HIP」版のバイナリも公開されています。HIPはAMD GPU上で各種処理を行うための「ROCm」というソフトウェアスタックを内包するWindows向けライブラリです。こちらは単にビルド済みバイナリをZIP形式で圧縮しただけのもので、インストーラは付属していません。そのため、適当な場所に展開した後、手動でパスを通す設定などを行う必要があります。

 なお、HIPを利用するためのHIP SDKはAMD HIP SDK for Windowsページからインストーラをダウンロードできます。ただし、llama.cppのリリースページで公開されているバイナリにはHIP関連のDLLも同梱されているため、おそらくHIP SDKを別途ダウンロードする必要は無いと思います(自分は先に自前でllama.cppをビルドするためにインストールしてしまっていたので詳細は不明)。

 ちなみに、前述のようにHIPは内部的にROCmを内包しているため、HIPを利用するビルドではGPUデバイスが「ROCm0」「ROCm1」……といったデバイスIDで表示されます。

統合GPUを無効化する

 統合GPUを備えたAMDのCPUとAMDの単体GPUを組み合わせて使用している場合、HIP版のllama.cppではllama_cli.exellama_server.exeの実行時に下記のようなエラーが出て強制終了することがあります。

ggml_cuda_compute_forward: MUL_MAT failed
ROCm error: invalid device function

 AMDの統合GPUと単体GPUは両方を有効にした状態で利用できるのですが、その場合どうも単体GPUで実行すべき処理を統合GPUで実行しようとしてしまい、その結果このエラーが発生しているような雰囲気です。この場合、BIOS(EFI)設定で統合GPUを無効にすることでこの問題を解決できます。

Vulcan版とHIP版どっちが高速?

 VulcanとHIPはそれぞれ異なるライブラリのため、実行速度としてはどちらが優れているのかが気になるところです。ということで、Vulcan版とHIP版のllama.cppを使って日本語文章の英訳処理を実行し、処理速度を比較してみました。使用したモデルは16GBのデバイスで実行できるとされているgpt-oss-20bです。

 まずwingetでインストールしたVulcan版ですが、実行時間は下記のようになりました。

prompt eval time =    1735.51 ms /  1188 tokens (    1.46 ms per token,   684.53 tokens per second)
       eval time =   31974.28 ms /  2622 tokens (   12.19 ms per token,    82.00 tokens per second)
      total time =   33709.78 ms /  3810 tokens

 タスクマネージャで確認した実行時のメモリや演算リソースの消費量は以下のような感じです。

Vulcan版llama.cpp実行時のリソース消費

 一方、HIP版は下記のような結果になりました。

prompt eval time =     656.10 ms /  1188 tokens (    0.55 ms per token,  1810.71 tokens per second)
       eval time =   44236.82 ms /  3047 tokens (   14.52 ms per token,    68.88 tokens per second)
      total time =   44892.92 ms /  4235 tokens

HIP版llama.cpp実行時のリソース消費

 プロンプトの処理はHIP版のほうが3倍近く速いのですが、出力の生成はVulcan版のほうが1割ほど速いという感じです。また、HIP版のほうが若干多くGPUのメモリや演算リソースを使用する雰囲気がありますが、これについては誤差レベル程度の違いしかなさそうです。

 ということで、今回の検証ではVulcan版のほうが若干ではあるが高速という結論となりました。ただ、処理内容によってもどちらが高速かは異なるという話もあるので、引き続き検証していきたいところです。

Windows 11におけるStore版PowerShellのショートカットの作り方

 思い立ったときにすぐに、開発用のディレクトリを作業ディレクトリにした状態でPowerShellを起動できると便利なのですが、それを実現する方法を調べたところマトモな結果が出てこなかったのでここでまとめておきます。

前提条件:Windows PowerShell 5.1とPowerShell 7は違う

 Windows 11にはWindows PowerShell 5.1が標準で提供されています。いっぽう、PowerShellの(本記事執筆時の)最新版はPowerShell 7系(手元の環境では7.5.4)です。そのため、最新版のPowerShellを利用したい場合、別途何らかの方法でPowerShellをインストールする必要があります(MicrosoftのPowerShellインストールに関するドキュメント)。

 自分はMicrosoft Storeからインストールを行ったのですが、ここで混乱を招くのが、PowerShell 7系をインストールしても、Windows 11で標準提供されているWindows PowerShell 5.1はそのまま残される点です。

Microsoft StoreのPowerShellページ

色々なところで紹介されているPowerShellのショートカット作成方法は古い

 PowerShellのショートカットを作成する方法としてGoogle検索でよく見つかるのが、コントロールパネルの「Windowsツール」からショートカットを作成する、というものです。確かに、「Windowsツール」内のWindows PowerShellのコンテキストメニューには「ショートカットの作成」という項目があり、ここからショートカットを作成できます。しかし、このWindows PowerShell項目は、古いバージョン(Windowsで標準提供されているバージョン)のWindows PowerShell 5.1を起動する項目なのです。つまり、ここからショートカットを作っても、最新のPowerShell 7系は起動できません。

「Windowsツール」内の「Windows PowerShell」項目からショートカットは作成できるが、これではPowerShell 7系は起動できない

 PowerShell 7系のショートカットを作るには、まずPowerShell 7系の本体(つまり.exeファイル)がどこにあるのかを調べる必要があります。ここで面倒なのが、Microsoft Store経由でインストールされたソフトウェアはファイルシステム上隠蔽されており、そのファイルには直接アクセスできないという点です。

 たとえば、スタートメニューからPowerShellのコンテキストメニューを表示させても、そこには「ファイルの場所を開く」項目はありません。このコンテキストメニューからショートカットを作成することもできません。

スタートメニューからPowerShellのコンテキストメニューを開いてもショートカットは作成できない

 実は、Microsoft Store経由でダウンロードしたソフトウェアは、(システムドライブがC:の場合)通常は「C:\Program Files\WindowsApps」ディレクトリ以下にインストールされます。しかし、このディレクトリはエクスプローラーでは表示されず、またパスをエクスプローラーに入力して開こうとしても、デフォルトでは管理者権限を持っていてもアクセスできないよう制限がかかっています。単にショートカットを作成するためだけにこの設定を変更するのも微妙ですよね……。

PowerShell 7系を起動するショートカットの作り方

 ということで試行錯誤して得られた手順は次のようになります。

 まず、Windowsターミナルを起動し、タイトルバーの何もない場所を右クリックしてコンテキストメニューを開いて「設定」をクリックします。

PowerShellのタイトルバーを右クリックし、続いて「設定」をクリックする

 するとWindowsターミナルの設定画面が表示されるので、ここで「プロファイル」項目内にある「PowerShell」をクリックします。そこに「コマンドライン」という項目があり、ここからPowerShellを起動するためのコマンドライン文字列を確認できます。この文字列を選択し、コピーしておきます。

PowerShellのプロファイルからPowerShellを起動するコマンドラインを取得できる

 続いてエクスプローラーでショートカットを作成したいフォルダを開き、コンテキストメニューの「新規作成」-「ショートカット」を選択して「ショートカットの作成」ウィザードを開きます。「項目の場所を入力してください」というテキストボックスがあるので、そこで先ほどコピーしておいたPowerShellを起動するためのコマンドライン文字列をペーストします。あとは「次へ」をクリックしてウィザードを進めれば、ショートカットが作成されます。

ショートカット作成ウィザード

 ショートカットが作成できたら、そのアイコンを右クリックし、「プロパティ」をクリックしてプロパティを開きます。ここで「ショートカット」タブ内に「作業フォルダー」という項目があるので、ここでPowerShell起動時に作業フォルダーに設定したいパスを指定し、「OK」をクリックすれば完了です。

ショートカットのプロパティで作業フォルダーを指定する

「ターミナルで開く」で良いのでは?

 エクスプローラーのコンテキストメニューには「ターミナルで開く」という項目があり、これを選択することでそのディレクトリを作業ディレクトリにした状態で、Windowsターミナルで「既定のプロファイル」で選択したプロファイルを開くことができます。つまり、PowerShell 7系を「既定のプロファイル」に設定しておけば、今回のようにわざわざショートカットを作成せずとも、簡単にPowerShellを起動できます。ただ、既定のプロファイルを別のものにしている場合や、ショートカットを格納したディレクトリ以外を作業ディレクトリにして起動したい場合などには、今回紹介した内容が役立つのではないでしょうか。

Lenovo Yoga Tab(11.1インチ、2025)レビュー

 今まで使っていたiPad Air(第三世代)からの置き換えとしてLenovo Yoga Tab(11.1インチ、2025年モデル)を購入しました。初めてのAndroidタブレットと言うことで若干の不安があったのですが、結果としては大きな不満点はなく良いデバイスでした。ということで以下簡単なレビューです。

購入経路とパッケージ、本体デザイン

 今回はLenovo公式の直販サイトで購入しました。価格は税込みで6万3,800円。ちょうどキャンペーンで「1年間 アクシデント・ダメージ・プロテクション」が99円だったのでこちらも追加しています。こちらはAppleの「Apple Care+」に相当するようなサービスで、画面破損等についても無料修理の対象になるようです。発売直後の9月26日に注文したためか出荷まで1週間程度という表示でしたが、結果としてはおよそ6日後の10月2日に手元に届きました。

 パッケージはこのような簡素なものですが、デジタル製品としては十分な感じです。

Lenovo Yoga Tab 11.1のパッケージ

 パッケージを開けるとまずは本体が入っており、その下にペン、充電器、保証書、USB type-Cケーブルが収められています。

パッケージ内部にはまず本体が収められている

付属品は本来の下に収められている

 充電器は最大45W出力(5V/3A、9V/3A、12V/3A、5~11V/4.5A)とのことで、高速充電に対応しています。

付属充電器は45W出力対応

 ペンは高級感のあるデザインです。ペン先の交換も可能で、予備のペン先が1つ付属していました。

ペンも標準で付属 ]

 Apple Pencil(初代)と比べると少し短く、重量も若干軽いため取り回しやすそうです。有線での充電には対応しておらず、第2世代Apple Pencilのように本体にマグネットで取り付けることで充電します。

Apple Pencil(初代)との比較

 本機は画面解像度が3200×2000ドット(16:10)ということもあってiPadよりも縦長なフォルムです。ただし縦持ちした場合の高さはiPadとほぼ同じで、その分幅が狭くなっています。

本体はやや縦長

 背面にはカメラ×2とフラッシュ(LED)を搭載。技適マークやシリアルナンバーがシールで貼られている点はデザイン的にやや残念なところです。

背面には技適マーク等がシールで貼り付けられている

使用感

 OSはAndroid 15ですが、UIはカスタマイズされた「Lenovo ZUI」というものが採用されています。初回起動時に「17」という表示が出ますが、これはAndroidのバージョンではなくZUIのバージョンです。

起動時に「17」との表示が出るが、こちらはAndroidバージョンではなくZUIのバージョン

 Androidにおけるメーカー独自のUIカスタマイズについてはさまざまな意見があるかと思いますが、このZUIについてはホーム画面下にiPadOSのDockのようなもの(ランチャー)が設置されていたり、設定画面は2ペインデザインになっていたりと、かなりiPadOSを意識したようなものになっています。

ホーム画面下にはDock風のランチャーを搭載

設定画面はiPadOS風

 マルチウィンドウや分割画面にも対応しており、画面上部に表示されている「…」をタップするとウィンドウを分割したりフローティングしたりするためのメニューが表示される点もiPadOSにそっくりです(iPadOS 26ではこのUIは廃止されてしまいましたが)。もちろんiPadOSとは設定項目やカスタマイズできる部分などが異なるため、iPadそのままの使い勝手というわけではないのですが、iPadからの乗換えでもほとんど違和感なく操作できるかと思います。

マルチウィンドウにも対応

 それに加えて、画面端スワイプでの戻る操作といったAndroidのジェスチャーナビゲーションもそのまま使えるので、Androidスマホと併用している場合でも特に違和感なさそうです。UI操作へのレスポンスも良好で、操作感においてはiPadと比べても十分満足できるものでした。

 なお、デフォルトでは画面リフレッシュレート設定が「インテリジェント」になっており、状況に応じてリフレッシュレートを自動で調節するようになっています。ただ、この場合Webブラウザなどでの画面スクロール時にやや文字がブレるような感覚がありました。そのため、リフレッシュレート設定を「標準」(60Hz)もしくは「最大」(144Hz)に設定することをおすすめします。

 画面サイズに関しては、前述のように縦長になるように持った場合iPadよりもやや幅が狭くなります。WebやSNSの閲覧においてはほとんど困ることはないですが、横長になるように持った状態でWebマンガなどを見開きで閲覧する場合、Webブラウザのヘッダー領域の分だけページを表示する領域の高さが小さくなるため、全体としてページが小さめに表示されてしまいます。全画面表示にすればおおむね問題ないのですが、全画面表示をサポートしていないWebマンガサイトも一部存在するため、その点では若干不便です。こちらは強制的に全画面表示を可能にするブラウザ拡張機能等を利用することで解決できるかもしれません。

アプリの対応

 Androidにおいてはタブレット専用のアプリというものはほぼ存在せず、基本的にはAndroidスマートフォン向けのアプリはすべて動作します。ただし、UIがタブレットに最適化されておらず、単に画面を大きく引き伸ばされただけのUIになってしまうものも多いです。Googleの提供しているアプリについては基本的にタブレット向けのUIを備えているため、使い勝手の面では困ることは基本的にありませんが、たとえばAndroid版のXアプリにはそのような気の利いた仕組みはなく、単純に画面脇に大きめの余白があるデザインでコンテンツが表示されます。

 なお、自分はメインのWebブラウザとしてFirefoxを使用しているのですが、Android版gのFirefoxはタブレット向けに最適化されたUIを備えており、タブレットでも快適に利用できます。拡張機能も利用可能ですし、基本設定で動画の自動再生をブロックすることも可能です。自分は個人的にWeb閲覧中に突然音が流れるのが大変ストレスで、そのためiPadでは基本的に音量を0にしておいて、必要な時だけ音量を上げ、コンテンツを見終わったらまた下げる、という作業を繰り返していたのですが、これが不要になるだけでもAndroidタブレットにした価値があると思っています。

生体認証の利用

 本機は指紋認証機能を搭載しておらず、生体認証は顔認証のみとなっています。ロック解除時などの顔認証のスピードは十分速く、精度としてもiPhoneのFaceIDと同程度のようです。ただ、カメラは長辺部、つまり横長になるように持ったときの上側に設置されているため、縦長になるように持った際には持ち方によっては指でカメラが覆われてしまうことがある点には注意が必要でした。

タッチペンの利用

 自分は絵を描くわけではなく、ペンでのメモ等もほとんど使わないのでペンについては未知数です。ひとまず普通に使えることは確認しています。Apple Pencil Proのようなペン軸を押し込んで操作する(スクイーズ)機能はないようですが、ペンの軸をタップしたりスワイプしたりしての操作には対応しており、たとえばペンを握った状態で、人差し指で軸を撫でるような操作でコピー&ペーストが行えます。この際に操作に応じてペンが振動し、コピーやペーストが行えたことが分かる仕組みになっています。

保護ケース

 AndroidデバイスはiPhoneやiPadと比べてケースや画面保護フィルムの選択肢が少ないという弱点があります。ただ、本機に関しては中国では「Xiaoxin Pad Pro GT 11.1」という名称で販売されており、このXiaoxin Pad Pro GT 11.1向けのケースや保護フィルムがそのまま利用できます。AliExpress等の中国系通販サイトで簡単に購入できますので、(そういったサイトが利用できれば)ケースや保護フィルムの入手性は高いです。最近は円安のため「激安」ではないものの、国内で流通しているiPad向けのケースとさほど変わらないか、少し安いくらいの価格で購入できます。たとえば自分が購入したこちらのケースは1,318円でした。

AliExpressで購入したケース AliExpressで購入したケース(背面)

 ちなみにこちらのケース、ペンも一緒に収納できるのですが、その分幅が大きくなるのと、収納した状態ではペンが充電ができないという非常にアレな罠がありました……。それ以外はまったく問題なく、むしろスイッチ等も問題なく押せるような構造になっていてよくできている感じなので余計残念でした。

コストパフォーマンス面での考察

 昨今では(円安がその大きな要因の1つかとは思いますが)Apple製品は販売価格の上昇が激しく、iPad miniですら現行モデルは7万8,800円から、iPad Airは9万8,800円からとだいぶお高くなっています。無印iPadは現在ではエントリーモデルという位置付けになっていることもあって5万8,800円からと購入しやすいお値段ではありますが、搭載するA16プロセッサは3年前(2022年9月)に発売されたiPhone 14 Proやその翌年に発売されたiPhone 15に搭載されていたものであり、それ以外のスペックについてもFaceIDやApple Intelligenceには非対応など、まさに「廉価版」というものになっています。

 一方のLenovo Yoga Tab (11.1インチ、2025)はiPad(128GBモデル)よりも5,000円ほど高い(6万3,800円)ですが、メモリやストレージはiPadの倍で、タッチペンも標準装備されています。プロセッサに関しても、Snapdragon 8 Gen 3は2年前にリリースされたものではありますが、単純な処理性能ではiPadのA16やiPan miniのA17 Proと比較してほぼ同等以上となっています(シングルスレッド性能はA17 Proよりも若干劣るがコア数が多いため総合性能としてはA17 Proよりも高い数値になる傾向が高い)。絶対的な性能でいうと当然iPad AirやiPad Proのほうが高いのですが、動画編集や重いグラフィック処理などを行わないのであれば本機でも十分ではないかと思います。