jinja2のエラーメッセージを親切にする機能を付けてみて気づいたtemplate継承の複雑さ

過去の記事CLI用のjinja2 wrapperを書いていたという話をした。この中でエラーメッセージを親切にするということに取り組んだことにも触れた。

この親切なエラーメッセージのテストのために、jinja2のtemplate継承を試してみた所、意外と複雑な機能だなーと再確認できたのでそのメモ。

jinja2のtemplate継承

通常のプログラミングと同じような感覚でtemplateを継承するようなイメージ。flaskのドキュメントの例がわかりやすい。

リンク先の内容では以下2つのtemplateを用意し、

  • child tempalte (child.html)
  • base template (layout.html)

child templateがbase template (layout.html) を継承しているという例。以下のコードはjinja2のドキュメントからのコピー。

layout.html

<!doctype html>
<html>
  <head>
    {%- block head %}
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <title>{%- block title %}{% endblock %} - My Webpage</title>
    {%- endblock %}
  </head>
  <body>
    <div id="content">{% block content %}{% endblock %}</div>
    <div id="footer">
      {%- block footer %}
      &copy; Copyright 2010 by <a href="http://domain.invalid/">you</a>.
      {%- endblock %}
    </div>
  </body>
</html>

child.html

{%- extends "layout.html" %}
{%- block title %}Index{% endblock %}
{%- block head %}
  {{ super() }}
  <style type="text/css">
    .important { color: #336699; }
  </style>
{%- endblock %}
{%- block content %}
  <h1>Index</h1>
  <p class="important">
    Welcome on my awesome homepage.
{%- endblock %}

これを実行すると良い感じにtitle,head,contentのブロックが解決されて1つのhtmlになる。これがtemplate継承。最近はwebアプリを書くときには概ねSPAになってしまったのでしばらくhtmlをテンプレートエンジンでレンダリングするということは無くなってしまったけれど。昔はけっこうよく使っていた(それもあってかついつい例を考えるとなるとhtmlになってしまったりする)。

$ kamidana child.html -a missing.py

実際に利用してみると以下の様な出力になる。これがtemplate継承。

<!doctype html>
<html>
  <head>
  
    <link rel="stylesheet" href="static/style.css">
    <title>Index - My Webpage</title>
  <style type="text/css">
    .important { color: #336699; }
  </style>
  </head>
  <body>
    <div id="content">
  <h1>Index</h1>
  <p class="important">
    Welcome on my awesome homepage.</div>
    <div id="footer">
      &copy; Copyright 2010 by <a href="http://domain.invalid/">you</a>.
    </div>
  </body>
</html>

missing.py

from kamidana import as_global


@as_global
def url_for(prefix, *, filename):
    return f"{prefix}/{filename}"

エラーメッセージ

ところでtemplate継承の話は本題ではなく、ここからが本題で、先程のコマンドの実行例では -a missing.py を付けていた。これは指定なしに実行した場合にエラーになるから付けたものなのだけれど。以下の様なエラーが出る。

$ kamidana child.html
------------------------------------------------------------
exception: jinja2.exceptions.UndefinedError
message: 'url_for' is undefined
where: layout.html
------------------------------------------------------------
child.html:
  ->  1: {% extends "layout.html" %}
      2: {% block title %}Index{% endblock %}
      3: {% block head %}
      4:   {{ super() }}

layout.html:
      2: <!doctype html>
      3: <html>
      4:   <head>
  ->  5:     {% block head %}
      6:     <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
      7:     <title>{% block title %}{% endblock %} - My Webpage</title>
      8:     {% endblock %}

child.html:
      1: {% extends "layout.html" %}
      2: {% block title %}Index{% endblock %}
      3: {% block head %}
  ->  4:   {{ super() }}
      5:   <style type="text/css">
      6:     .important { color: #336699; }
      7:   </style>

layout.html:
      3: <html>
      4:   <head>
      5:     {% block head %}
  ->  6:     <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
      7:     <title>{% block title %}{% endblock %} - My Webpage</title>
      8:     {% endblock %}
      9:   </head>

個人的にはけっこうきれいなエラー表示になっていると思う(自作したkamidanaの機能)(頑張った)。

  1. まず前提としてurl_forがundefinedというエラー。
  2. そのエラーは、chlid.htmlの継承元のテンプレートのlayout.htmlで発生している。
  3. 更に具体的にはhead blockの部分。
  4. head block自体はchild.htmlでオーバーライドされている
  5. 実際にはそのオーバーライドされたblockのsuperで親の方のhead blockも見る。
  6. 親の方(つまりlayout.html)のblockのところでundefinedな関数を見つけてエラー

文章にしてみると長いし。けっこう昔は暗黙の内に使っていたのだけれど。こうしてみるとテンプレート継承という機能は複雑な機能なのかもしれない。

おまけ jinja2のmacro機能

ちなみに先ほどのtemplate継承がtemplate method pattern的なものないしはOOPLの継承を模したものと見るとすると、高階関数あるいはストラテジーパターンのように見ることができるような機能もある。それがjinja2のmacroの機能でcallerを使った場合。

例えば先程の継承の部分のhead blockの例を以下の様に書ける。

layout2.html

{%- macro head() %}
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <title>{{ caller() }} - My Webpage</title>
{%- endmacro%}

callerがrubyのblockのyield的なイメージで考えると一番わかり易いかもしれない。これをimportとcallを使って呼び出す。callのブロックがcallerとして展開される。

child2.html

{%- import "layout2.html" as layout %}
<head>
{%- call layout.head() %}Index{% endcall %}
</head>

実際に実行すると以下。

$ kamidana child2.html -a missing.py

<head>
    <link rel="stylesheet" href="static/style.css">
    <title>Index - My Webpage</title>
</head>

個人的にはテンプレート継承よりこちらの方が好み。