🐙

Djangoのテンプレートでforのようなendタグを持つカスタムタグを書く

2023/11/03に公開

概要

pythonのWebフレームワークDjangoで{% for athlete in athlete_list %}<div>{{athlete.name}}</div>{% endfor %}のような、HTMLや他のタグを間に挟めるendタグを持つカスタムタグを自作する方法です。

初Django(というかpythonも少ししか書いたことない)なのでお作法とか違ったらすいません。

ドキュメントは下記ですが少々内容が足りないです。タグの引数の解析の説明がありません。
https://docs.djangoproject.com/en/4.2/howto/custom-template-tags/#parsing-until-another-block-tag

デフォルトタグのソースを見るのが手っ取り早いと思います。

https://github.com/django/django/blob/stable/4.2.x/django/template/defaulttags.py

この記事では以下のようなことが理解できると思います。

  • カスタムブロックタグの書き方、登録の仕方。
  • 登録したタグを他のAPPで使う方法。
  • カスタムタグの引数の解析方法と変数の解決方法。文字列引数の扱い。
  • contextへの新しい変数の代入とその掃除方法。

コードと解説

タグの登録

app直下にtemplatetagsディレクトリを作り__init__.pymy_tags.py(名前は任意)を置き、my_tags.pyでタグを登録します。

some_app/
    templatetags/
        __init__.py
        my_tags.py
# some_app/templatetags/my_tags.py
from django import template

register = template.Library()

@register.tag(name="foobar")
def do_foobar(parser, token):
    nodelist = parser.parse(("endfoobar",))
    parser.delete_first_token()
    return FoobarNode(nodelist)

class FoobarNode(template.Node):
    def __init__(self, nodelist):
        self.nodelist = nodelist

    def render(self, context):
        output = self.nodelist.render(context)
        return output

タグの登録は@register.tagでします。@registerには幾つかの登録メソッドがありますが、下記の回答にまとめてる人がいて分かりやすかったです。

https://stackoverflow.com/questions/76246817/register-filter-vs-register-simple-tag-vs-register-tag-vs-register-inclusion

do_foobarはテンプレートに書かれたタグを解析してtemplate.Nodeの子クラスを返します。とりあえず基本的なコードなのでブロックの中身だけ解析してFoobarNodeに渡し、返してます。

nodelist = parser.parse(("endfoobar",))endfoobarまでのテンプレートに書いてあるnodeの行を集めます。endfoobarの直前までを集めてるので、まだendfoobarタグは解析前の状態で残っているためparser.delete_first_token()endfoobarのタグを捨てます。これをしないとInvalid block tag on line 15: 'endfoobar', expected 'endblock'. Did you forget to register or load this tag?のようなエラーになります。

FoobarNoderender関数を定義して、そこでHTMLテキストを返すと、それがテンプレートにレンダリングされます。

返すHTMLテキストですがforのソースを見てください。

https://github.com/django/django/blob/ce44eaf6d0b0df253fb4e632dfd1554c3f1461c3/django/template/defaulttags.py#L245

django.utils.safestring.SafeStringを返してるのがわかると思います。なのでHTMLの生テキスト返すとエスケープされタグとして扱われないのかと思いましたがreturn '<div>foobar</div>'としてもそのまま出力されました。まあ、一応他のソースに倣いdjango.utils.safestring.SafeStringを返しておいた方が無難だと思います(ちなみにself.nodelist.renderの返り値はSafeStringです)。

これをテンプレートでloadすれば使えます。

# some_app/templates/some_app/index.html
{% load my_tags %}

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  {% foobar %}
    <div>foobar</div>
  {% endfoobar %}
</body>
</html>

別のappでも使いたい

別のappでも使えるようにするにはsettings.pyで登録してやる必要があります。

# settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            'libraries': {
                'some_app_my_tags': 'some_app.templatetags.my_tags',
            }
        },
    },
]
{% load some_app_my_tags %}

{% foobar %}
<div>foobar</div>
{% endfoobar %}

タグの解析

ビルトインのタグを見ると色々引数を渡せますが、自作タグにも当然渡せます、引数はテキストの配列でくるので解析が割と面倒です。

タグにテンプレート変数とテキストの引数を渡してみましょう。

# some_app/views.py

def index(request)
  render('some_app/index.html', { 'values': ['foo', 'bar'] })
# some_app/templates/some_app/index.html
{% foobar values 'テキスト' %}
  <div>{{label}}:{{value}}</div>
{% endfoobar %}

これで下記のようなHTMLがレンダリングされる実装してみようと思います。

<div>テキスト:foo</div>
<div>テキスト:bar</div>

タグに書いた引数はdo_foobarの2番目の引数tokenに入ってきます。これはdjango.template.base.Tokenのインスタンスです。

@register.tag(name="foobar")
def do_foobar(parser, token):
    bits = token.split_contents()
    var_name = parser.compile_filter(bits[1])
    label_string = parser.compile_filter(bits[2])
    nodelist = parser.parse(("endfoobar",))
    parser.delete_first_token()
    return FoobarNode(nodelist, var_name, label_string)

bitsの中身は['foobar', 'values', "'テキスト'"]となります。valuesがテキストで、"'テキスト'"はクオートで囲まれた状態できます。変数の解決はcontextにアクセスできるNodeでやりますのが、この関数ではparser.compile_filterを使って変数なのか、テキストなのか解析までやっておきます。parser.compile_filterの返り値はdjango.template.base.FilterExpressionです。

class FoobarNode(template.Node):
    def __init__(self, nodelist, var_name, label_string):
        self.nodelist = nodelist
        self.var_name = var_name
        self.label_string = label_string

    def render(self, context):
        values = self.var_name.resolve(context, ignore_failures=True) or []
        label = self.label_string.resolve(context)

        with context.push():
            context['label'] = label

            outputs = []
            for value in values:
                context['value'] = value
                outputs.append(self.nodelist.render(context))

        return mark_safe(''.join(outputs))

contextを引数にresolveを呼ぶことで変数を解決できます。この変数名はform.emailのようなプロパティーへのアクセスもちゃんと解決できます。label_stringの方は文字列ですが、resolveを呼んでおくことで変数にも対応可能です。

contextに新しい変数をアサインしますがブロックの外側からはアクセスできないように綺麗にしておきたいのでwith context.push():で囲います(pythonはブロックじゃなくてインデントなので囲うとは言わない?)。こうするとcontextに新しい空のdictが生成され、代入した変数はそこに登録、ブロックを抜けるとそのdictは削除されます。

Discussion