Python

Jinja2でコードを自動生成する方法

以前にT4を使ってコード生成を行う方法を紹介しましたが、T4はWindowsがメインになるため、Mac上では利用できません。そこでプラットフォームに依存せず利用可能なPythonベースのテンプレートエンジン「Jinja2」に乗り換えました。

ここでは個人的な備忘録として、Jinja2の基本的な使い方を残しておきます。
Jinja2は柔軟に組み込むことが可能なので、サーバーサイド側でコード生成を行うことも可能ですが、ここで説明しているのは、ローカルにあるテンプレートファイルから任意のタイミングでソースコードを生成する方法になります。

Jinja2とは

Jinja2はPythonをベースにしたテンプレートエンジンです。テンプレートエンジンの中では有名なパッケージの1つで、プラットフォームに依存せず利用できます。
軽く使ってみた感じでは、T4よりもシンプルに、かつ柔軟な書き方ができる印象です。

余談ですが、Jinjaという名称は、テンプレート(Template)→Temple→神社という由来だとか…。

 

Jinja2の導入方法

Jinja2はPythonのパッケージとして提供されているため、Pythonのパッケージとして導入します。Anacondaのcondaでも、Python標準のpipでもインストール可能ですが、ここではAnacondaの環境にconda経由で導入する方法を紹介します。
なお、Jinja2専用に「jinja2」という仮想環境を作成しています。

> conda create -n jinja2 python=3.7
> conda activate jinja2
> conda install jinja2
> conda install pyyaml

これで、Jinja2のインストールが完了しました。
YAMLを利用するためのpyyamlは必須ではありませんが、今回はコード生成時の設定(パラメータなど)を外部のYAMLファイルで一元管理させるためにインストールしています。

 

Jinja2の基本的な使い方

基本的には、テンプレートの文字列をJinja2に渡し、パラメータと共にrenderメソッドを呼び出せば結果の文字列を取得できます。シンプルに書くと、次のようなコードになります。

from jinja2 import Template
template = Template('Hello {{name}}')
result = template.render( name='Jinja2' )
print(result) # 'Hello Jinja2'

この処理を実行すると、テンプレートの{{name}}がrenderの引数で渡した値’Jinja2’に置き換わり、’Hello Jinja2′ という文字列が返されます。

実際にJinja2を利用する場合、明確なルールはありませんが、以下に示す3つの役割にファイルを分けると管理しやすくなります。

  1. テンプレートファイル(拡張子は j2 を使う事が多い?)
    テンプレート部分だけを抜き出したファイルになります。
    上のソースコードでは 'Hello {{name}}' の部分に該当します。
  2. 設定ファイル(コード生成時のパラメータなどをまとめたファイル)
    上のソースコードでは、renderに渡している name='Jinja2' という部分に該当します。コード生成に必要なパラメータを設定ファイルとして管理しておけば、値を変更したい場合も設定ファイルだけを変更すれば対応できるようになります。
  3. コード生成を行うPythonの実行コード
    各種テンプレートと設定ファイルを読み込んでコードを生成するプログラムです。このプログラムを実行させれば、テンプレートと設定ファイルの内容を元に、各種コードを生成するようにします。

設定ファイルは、最終的に「key=value」の形で値を取得できれば良いので、ファイル自体はXMLでもjsonでもYAMLでも、何でもかまいません。今回は、階層的な情報を一番シンプルに記載できるYAMLを使って設定ファイルを記述しています。

 

各種ソースコード

実際のソースコードを以下に載せておきます。
テンプレートは複数ファイル、設定ファイルとコード生成用プログラムは1ファイルで構成されています。

テンプレートファイル(template1.j2、template2.j2)

テンプレートファイルは、基本的には出力したい内容をそのまま記載し、動的に変更したい部分だけを特殊な記述({{}}{% %}など)で記載します。詳細は後に記載しますが、パラメータ値の埋め込みや、繰り返し、条件分岐などが利用できます。

// Generate from template1.j2
namespace
{
    const std::string kGuid{ "{{plugin.guid}}" };
    const MenuInfo kMenu{ '{{plugin.menu.key}}', u"{{plugin.menu.name}}" };
    constexpr bool kCanPreview{ {{plugin.options.canPreview | boolstr}} };
}

 

// Generate from template2.j2
void Custom::Init() noexcept
{
    {%- for p in properties %}
    {%- if p.type == 'Boolean' %}
    this->{{p.name}} = { {{p.default | boolstr}} };
    {%- else %}
    this->{{p.name}} = { {{p.default}}, {{p.min}}, {{p.max}} };
    {%- endif %}
    {%- endfor %}
}

 

設定ファイル(config.yaml)

設定ファイルは簡単に記載できるYAMLを利用しています。
階層関係はPythonと同じようにインデントで表現し、「key: value」という形式で記載します。また、リスト項目は先頭に ‘-‘ を付けて表現します。

# Plugin settings
plugin:
    guid: 88888888-8888-8888-8888-888888888888
    menu: {key: p, name: SamplePlugin}
    options:
        canPreview: true

# Properties
properties:
    - {type: Boolean, name: param1, default: true}
    - {type: Integer, name: param2, default: 0, min: 0, max: 100}

 

コード生成用プログラム(builder.py)

genJ2メソッドで、指定されたテンプレートと設定ファイルに基づいてJinja2によるコード生成を行い、結果をファイルに出力します。各ファイルはBOM付きのutf-8形式で保存しているため、encoding='utf_8_sig' としていますが、このあたりは利用している文字コードに合わせて適宜修正してください。

boolstr、hexメソッドは独自に定義したフィルタ関数で、env.filtersに登録しておくことで、テンプレート変換時に利用することが可能です。このフィルタ機能は、設定されている値を編集して出力したい場合に便利です。(例えば、設定ファイルの数値を、16進数表記の0x00とうフォーマットで出力したい場合など)
また、フィルタは env.filters['filterName'] = method という形で好きなメソッドを登録できるため、Pythonの標準的なメソッドを登録することも可能です。

import yaml
from jinja2 import Template, Environment, FileSystemLoader

#----------------------------------------------------------------
# Convert the value of boolean to 'true' or 'false'.
def boolstr(value):
    if value: return 'true'
    return 'false'

# Convert to 0x00 format.
def hex(value):
    return format(value, '#04x')

#----------------------------------------------------------------
# Generate file from Jinja2 template.
def genJ2(templateFile, configFile, genFile):
    # Load template.
    env = Environment(loader=FileSystemLoader('.', encoding='utf_8_sig'))
    env.filters['boolstr'] = boolstr
    env.filters['hex'] = hex
    tpl = env.get_template(templateFile)

    # Load config.
    with open(configFile, encoding='utf_8_sig') as stream:
        data = yaml.load(stream)

    # Render
    render = tpl.render(data)

    # Output
    with open(genFile, 'w', encoding='utf_8_sig') as stream:
        stream.write(render)

# ----------------------------------------------------------------
genJ2('template1.j2', 'config.yaml', 'source1.h')
genJ2('template2.j2', 'config.yaml', 'source2.cpp')

Jinja2はPythonで書かれているため、bool型の値を出力すると、先頭が大文字のTrue/Falseという内容で出力されます。そのためbool型を’true/false’という文字列に変換するフィルタ(boolstr)を定義しています。

設定ファイルをjsonなどで記載している場合は、yaml.load(stream)の部分を、json.load(stream)などに切り替えれば、簡単にjsonに対応できます。

 

実行方法と実行結果

上で作成したコード生成用のプログラム(builder.py)を実行すれば、テンプレートファイルからソースファイルが作成されます。builder.pyは普通のPythonプログラムなので、コンソールなどから以下のコマンドで実行出来ます。(IDE経由などで実行してもOKです)

> python builder.py

 

これで、template1.j2、template2.j2から、以下のようなファイル(source1.h、source2.cpp)が生成されます。

// Generate from template1.j2
namespace
{
    static const std::string kGuid{ "88888888-8888-8888-8888-888888888888" };
    static const MenuInfo kMenu{ 'p', u"SamplePlugin" };
    constexpr bool kCanPreview{ true };
}

 

// Generate from template2.j2
void Custom::Init() noexcept
{
    this->param1 = { true };
    this->param2 = { 0, 0, 100 };
}

 

 

Jinja2テンプレートで使える記述

Jinja2テンプレートで良く利用する記述について、簡単に載せておきます。詳細についてはJinja2のドキュメントをご参照ください。

変数

変数は {{...}} という形で埋め込みます。注意する点としては、{{{}}} のように、括弧が3つ以上連続するとJinja2で正しく認識できないので、間に半角スペースを入れて、{ {{}} }のように記載するようにしてください。(特にソースコードのテンプレートを作成している場合は、プログラム中の{}と、Jinja2の変数の{{}}が混在する場合が多くなるので)

変数が階層構造を持っている場合は、{{a.b}}{{a.b.c}} のように、ドットで区切って値を指定します。

{{name}}         # nameという変数の値に置き換える
{{a.b}}          # a.bという階層の値に置き換える
{{a.b.c}}        # a.b.cという階層の値に置き換える
x={{{value}}};   # NG: {{{,}}}という記載はエラー(x={100};のようなコードを出力したい)
x={ {{value}} }; # OK: 半角スペースを入れると正しく認識される。

 

フィルタ

{{}}で単純に出力すると、変数の値がそのまま出力されます。(bool型の場合は、True/Falseという値で出力されます)このとき、値をそのまま出力するのでは無く、任意のフィルタ(編集処理など)を通した結果を出力したい場合はフィルタ処理が利用できます。
フィルタ処理は{{value|filter}}のように、間を「|」で区切って指定します。また、{{value|filter1|filter2|filter3}}のように、複数のフィルタを適用することも可能です。

フィルタには、lower, max, join…など複数のビルトインフィルタ(詳細はJinja2のドキュメント参照)が利用できるほか、独自のフィルタを作成することも可能です。独自フィルタについては、先のソースで「boolstr、hex」という独自フィルタを追加しているので、そちらを参考にしてください。

{{name|lower}}             # nameを小文字に変換して出力(ビルトインフィルタ)
{{value|filter1|filter2}}  # filter1,2を通した結果を出力
{{canUse|boolstr}}         # 独自に作成したboolstrフィルタを通して出力

ループ処理

複数の値をループして処理したい場合、{% for val in values %}...{% endfor %}という記述でループ処理が行えます。ループ処理の内部は、新しく定義した変数(ここではval)を使って各要素の値にアクセスできます。
また、loop.indexloop.index0など、ループ中で利用できる組み込み変数が複数用意されているので詳細はドキュメントをご参照ください。ループの最初や最後を判定するloop.firstloop.lastも利用頻度が高いと思います。

このとき、{% ... %}の部分は結果には出力されませんが、この{% ... %}が記載されていた段落自体は空白行として結果に出力されてしまいます。そのため、実際に生成された結果を見ると、ループする度に余分な改行が入ったようなコードになります。これを防ぐには、%の後ろに「-」を付けて、{%- ... %}のように記載すると、先頭部分の余計な空白が削除されて、余計な改行が入りません。(後ろにハイフンを付けて、{% ... -%}と書いたり、前後に{%- ... -%}とする事も可能です)

{%- for p in properties %}       # propertiesの数だけループ(各要素はpに代入)
// loop count = {{loop.index0}}  # ループ内で使える組み込み変数(要素番号0から開始のindex)
x += {{p.value}}                 # 各要素へのアクセス
{%- endfor %}                    # ループの終わり

分岐処理

値によって処理を分岐したい場合は、{% if %}{% elif %}{% else %}{% endif %}を利用します。これらの分岐についても、実際の出力結果には空白行として出力されるので、余分な改行を入れたく無い場合は{%- ... %}のように%の後ろに「-」を付けて定義してください。

{%- if x.value == 'xxx' %}    # x.valueの値が'xxx'の時
// value = xxx
{%- elif x.value == 'yyy' %}  # x.valueの値が'yyy'の時
// value = yyy
{%- else %}                   # それ以外の場合
// others
{%- endif %}                  # 分岐終了