jinja2用のCLIのファイルパスの解決方法を変更した

github.com

jinja2用のCLIのファイルパスの解決方法を変更した。

なぜファイルパスの解決方法を変更する必要があるのか?

なぜファイルパスの解決方法を変更する必要があるのか?と言うと、デフォルトの解決方法ではコマンドの実行位置に依存してしまうため。

このままの場合にはinclude,extends,importなどjinja2が持つテンプレートを参照するテンプレートが現れる機能を使おうとした瞬間に常に不都合が起きる。

とりあえず、今回はincludeを使った例を考えてみる。

デフォルトの解決方法の場合(NG)

デフォルトの解決方法では常に特定のルートの位置からの相対的な位置を指定する(実はこれは完全に正確な表現ではないのだけれど)。これはWAFなどのHTMLのテンプレートの管理としては便利。一方で今回想定するケースのようにCLIで利用する場合においては上手く機能しない(これについては後に詳しく説明する)

例えば以下のようなディレクトリ構造で利用する場合のことを考えてみる。

.
├── parts
│   └── header.html.j2
└── templates
    └── detail.html.j2

2 directories, 2 files

templates/detail.htmlからparts/header.htmlを使う形で利用するとする。このときファイルの内容はそれぞれ以下の様になる。

detail.html

{%- with title="detail" %}{%- include "parts/header.html.j2" -%}{% endwith -%}
<detail>
  detail content
</detail>

parts/header.html

<header>{{title}}</header>

CLIをつくる場合にはrootは現在位置(cwd)を想定することが多い。j2cliなどではdetail.html中であっても、detail.htmlからの相対位置ではなくrootからの相対位置のparts/header.htmlを指定する。これは想定したrootの位置で実行した場合には動作する。

$ pwd
/tmp/example_kamidana/00

$ j2 templates/detail.html.j2
<header>detail</header>
<detail>
  detail content
</detail>

さて、ここで現在位置を変えてみる。すると当然失敗する。

$ cd ..
$ pwd
/tmp/example_kamidana

$ j2 00/templates/detail.html.j2
Traceback (most recent call last):
...
    raise jinja2.TemplateNotFound(template)
jinja2.exceptions.TemplateNotFound: parts/header.html.j2

これはファイルパスを計算するrootが変わったため。変更後の位置での実行を成功させるためには以下の様に変更する必要がある。

templates/detail.html (diff)

@@ -1,4 +1,4 @@
-{%- with title="detail" %}{%- include "parts/header.html.j2" -%}{% endwith -%}
+{%- with title="detail" %}{%- include "00/parts/header.html.j2" -%}{% endwith -%}
 <detail>
   detail content
 </detail>

root位置をオプションで指定することにすれば、テンプレート内のファイルパスを変えることなくコマンドの実行を成功させることができるようになるが、今度はコマンドの実行の度にrootの適切な位置を指定する必要が出てくる。

テンプレート自身からの相対位置を指定する形に変えた場合(OK)

ファイルパスの解決方法をテンプレート自身からの相対位置を指定する形に変えた場合にはこのような問題が起きる事が無い。

もう一度冒頭のディレクトリ構造を見てみる。

.
├── parts
│   └── header.html.j2
└── templates
    └── detail.html.j2

2 directories, 2 files

今度のdetail.html.j2中のincludeではdetail.html.j2からの相対位置を指定する。以下の様に変える。detail.htmlから見ると1つ上に登った位置のpartsにheaders.htmlが存在するのでその様に変える。

templates/detail.html (diff)

@@ -1,4 +1,4 @@
-{%- with title="detail" %}{%- include "parts/header.html.j2" -%}{% endwith -%}
+{%- with title="detail" %}{%- include "../parts/header.html.j2" -%}{% endwith -%}
 <detail>
   detail content
 </detail>

テンプレートからの相対位置は常に一定。なのでどのような所から呼び出しても変更する必要がなくなる。

$ pwd
/tmp/example_kamidana/01

$ kamidana templates/detail.html.j2
<header>detail</header><detail>
  detail content
</detail>

$ cd ..
$ kamidana 01/templates/detail.html.j2
<header>detail</header><detail>
  detail content
</detail>

ファイルパスの解決方法の変更方法について

ファイルパスの解決方法について、jinja2の対応がけっこうきれいで感心したのでちょっとだけ説明を追加する。

Environment.join_path()

ファイルパスの解決方法を変更したい、jinja2はこれに対応するためのhookを用意してくれている。それがjinja2.environment:Environmentのjoin_path()というメソッド。

(個人的にはコードを読んでこの機能を発見したが)ドキュメントからたどり着くこともできる。

例えば get_template()APIドキュメントに以下の説明が存在する。

Load a template from the loader. If a loader is configured this method asks the loader for the template and returns a Template. If the parent parameter is not None, join_path() is called to get the real template name before loading.

(強調はこちらで勝手に付与したもの)

そして join_path()の説明には以下の様な記述がある。

Join a template with the parent. By default all the lookups are relative to the loader root so this method returns the template parameter unchanged, but if the paths should be relative to the parent template, this function can be used to calculate the real template name.

正確に言うと、rootとなるディレクトリからの相対位置ではなく、何も変更せずに指定されたものをそのままloaderに渡すということ。j2cliなどの利用しているloaderがFileSystemLoaderなどなので、現在位置(root)からの相対位置ということになる。

実際のEnvironementのjoin_path(),get_template()の定義は以下のようなもの。

class Environment(object):
# ...

    def join_path(self, template, parent):
        return template

    @internalcode
    def get_template(self, name, parent=None, globals=None):
        if isinstance(name, Template):
            return name
        if parent is not None:
            name = self.join_path(name, parent)
        return self._load_template(name, self.make_globals(globals))

確かに何も変更せずにnameをそのまま渡している。

実際の対応方法

対応方法を書く前に、現状の動作(j2cliの動作)と同じ動きをするコードをミニマムに実装してみる。

00render.py

import sys
import jinja2

env = jinja2.Environment(loader=jinja2.FileSystemLoader(os.getcwd()))
template = env.get_template(sys.argv[1])
print(template.render())

利用するテンプレートの位置は、join_path()に渡されるタイミングではparentという引数として渡されるので、例えば以下の様に変更する。join_path()を変更したサブクラスを作れば良い。

01render.py

import sys
import os.path
import jinja2
import jinja2.ext as ext  # withを使うために利用


class Environment(jinja2.Environment):
    def join_path(self, name, parent, *, start=None):
        # xxx: FileSystemLoaderに依存した実装
        start = start or self.loader.searchpath[0]

        if parent is None:
            return name
        path = os.path.normpath(
            os.path.join(os.path.abspath(os.path.dirname(parent)), name)
        )
        return os.path.relpath(path, start=start)


env = Environment(loader=jinja2.FileSystemLoader(os.getcwd()), extensions=[ext.with_])
template = env.get_template(sys.argv[1])
print(template.render())

今度は大丈夫。

$ python 01render.py templates/detail.html.j2
<header>detail</header>
<detail>
  detail content
</detail>

$ cd ..
$ python 01/01render.py 01/templates/detail.html.j2
<header>detail</header>
<detail>
  detail content
</detail>

エラーメッセージ

これで終わりだったら手軽に解決で良かったのだけれど。前回エラーメッセージを親切にした手前これで終わりでは片手落ちになってしまう。前回というのは以下の記事の内容のこと。

けっこう頑張って以下のような形になるようにした(-> のカーソルがある部分は例によってANSI Colorでハイライトされる)。

例えばincludeファイルの指定をheader-404.html.j2にしてみる(もちろんそのようなファイルは存在しない)。

$ kamidana templates/detail.html.j2
------------------------------------------------------------
exception: kamidana._path.XTemplatePathNotFound
message: [Errno 2] No such file or directory: '../parts/header-404.html.j2'
where: templates/detail.html.j2
------------------------------------------------------------
templates/detail.html.j2:
  ->  1: {%- with title="detail" %}{%- include "../parts/header-404.html.j2" -%}{% endwith%}
      2: <detail>
      3:   detail content
      4: </detail>

Traceback:
  File "$HOME/venvs/my/lib/python3.7/site-packages/jinja2/loaders.py", line 314, in get_source
    rv = self.load_func(template)
  File "$HOME/venvs/my/kamidana/kamidana/loader.py", line 27, in load
    raise XTemplatePathNotFound(filename, exc=e).with_traceback(e.__traceback__)
  File "$HOME/venvs/my/kamidana/kamidana/loader.py", line 23, in load
    with open(filename) as rf:

エラー表示はネストした場合やcompile時に分かるような表現(e.g. jinja2.TemplateSyntaxError)にも対応した。これらについては別の記事で気力があれば詳しく書くかもしれない。

ちなみに通常は以下の様な不親切なエラーになる(j2cliもほぼ同様)。

$ python 01render.py templates/detail.html.j2
Traceback (most recent call last):
  File "01render.py", line 22, in <module>
    print(template.render())
  File "$HOME/venvs/my/lib/python3.7/site-packages/jinja2/asyncsupport.py", line 76, in render
    return original_render(self, *args, **kwargs)
  File "$HOME/venvs/my/lib/python3.7/site-packages/jinja2/environment.py", line 1008, in render
    return self.environment.handle_exception(exc_info, True)
  File "$HOME/venvs/my/lib/python3.7/site-packages/jinja2/environment.py", line 780, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "$HOME/venvs/my/lib/python3.7/site-packages/jinja2/_compat.py", line 37, in reraise
    raise value.with_traceback(tb)
  File "$HOME/venvs/my/individual-sandbox/daily/20190217/example_kamidana/02/templates/detail.html.j2", line 1, in top-level template code
    {%- with title="detail" %}{%- include "../parts/header-404.html.j2" -%}{% endwith%}
  File "$HOME/venvs/my/lib/python3.7/site-packages/jinja2/loaders.py", line 187, in get_source
    raise TemplateNotFound(template)
jinja2.exceptions.TemplateNotFound: parts/header-404.html.j2