[TurboGears] フォームの中にあるウィジェットのIDは、「_」となる。

環境

この記事の内容は、TurboGears 1.0.1で確認しました。

現象

例えば、以下のようなフォームのウィジェットを作成します。

from turbogears import widgets

class Foo(widgets.Form):
    def __init__(self):
        self.text_widget = widgets.TextField(name="bar")

    template = """
    ${text_widget.display()}
"""

すなわち、barという名前のテキストボックスを持つフォームです。これを、次のようにしてインスタンス化し、表示します。

foo = Foo(name="foo")
foo.display()

このとき、フォームの中のテキストボックスのid属性の値は、"bar"ではなく、"foo_bar"になります(name属性の値は"bar"です)。

なぜこんな仕様になっているのか?

おそらく、同一のクラスから複数のフォームを生成し、画面に出力する場合に、中の要素を一意に区別できるようにするためです。

詳細

このような動作をするコードを、調べてみたいと思います。

とりかかりとして、テキストボックスのid属性がどのようにして出力されるのかを調べます。TextFieldのソースコードは、turbogears/widgets/forms.pyにあります。

class TextField(FormField):
    "A standard, single-line text field."

    template = """
    <input xmlns:py="http://purl.org/kid/ns#"
        type="text"
        name="${name}"
        class="${field_class}"
        id="${field_id}"
        value="${value}"
        py:attrs="attrs"
    />
    """
    params = ["attrs"]
    params_doc = {'attrs' : 'Dictionary containing extra (X)HTML attributes for'
                            ' the input tag'}
    attrs = {}

どうやら、id属性はオブジェクトのfield_idの値となるようです。

そこで、field_idをgrepして探してみます。すると、turbogears/widgets/forms.pyのFormFieldクラス(TextFieldクラスの基底クラスです)の中に、定義している箇所が見つかりました。

class FormField(InputWidget):
(略)
    def _get_field_id(self):
        return build_name_from_path(self.path, '_', '_')
    field_id = property(_get_field_id)

field_idの値は、path属性の値と、build_name_from_path関数から決定されるようです。pathをgrepすると、turbogears/widgets/forms.pyのInputWidgetクラス(FormFieldクラスの基底クラスです)で見つかりました。

class InputWidget(Widget):
(略)
    def _get_path(self):
        return get_path(self, None)[:]
    _get_path = update_path(_get_path)
    path = property(_get_path)

path属性の値は、_get_pathメソッドから得られますが、_get_pathメソッドはupdate_path関数によってデコレートされています。まず、update_path関数の方から調べてみます。これは、turbogears/widgets/forms.pyで見つかりました。

def update_path(func):
    def _update_path(self, *args, **kw):
        update = append_to_path(self, None)
        returnval = func(self, *args, **kw)
        if update:
            pop_from_path()
        return returnval
    return _update_path

これを踏まえると、_get_pathメソッドというのは、

    def _get_path(self):
        update = append_to_path(self, None)
        returnval = get_path(self, None)[:]
        if update:
            pop_from_path()
        return returnval

のように書き直すことができます。では、append_to_path関数とget_path関数、pop_from_path関数を見てみましょう。まずappend_to_path関数です。これはturbogears/widgets/forms.pyにあります。

from cherrypy import request
(略)
def append_to_path(widget, repetition):
    path = []
    if request_available():
        if hasattr(request, "tg_widgets_path"):
            path = request.tg_widgets_path
        else:
            request.tg_widgets_path = path
    if (not path) or (path[-1].widget is not widget):
        path.append(Bunch(widget=widget,
                          repetition=repetition))
        return True
    else:
        return False

ぱっと見ではよくわからないので、前半と後半に分けて考えてみます。request_available関数は以下のようにturbogears/util.pyにありますが、これは真になりそうなので省略します。

def request_available():
    """Check if cherrypy.request is available."""
    try:
        setattr(request, "tg_dumb_attribute", True)
        return True
    except AttributeError:
        return False

さらに、処理結果が変わらないように行を入れ換えたappend_to_path関数の前半部分が、以下です。

    if hasattr(request, "tg_widgets_path"):
        path = request.tg_widgets_path
    else:
        path = []
        request.tg_widgets_path = path

すなわち、

  1. requestオブジェクトにtg_widgets_path属性があれば、その値をpath変数に設定する。
  2. requestオブジェクトにtg_widgets_path属性がなければ、path変数を空のリストに設定し、さらにrequestオブジェクトのtg_widgets_path属性を同じように空のリストで初期化する。

ということになりますが、要するに最終的には、

  1. requestオブジェクトはtg_widgets_path属性を持つ。
  2. path変数の値はrequest.tg_widgets_pathの値に等しい。

となる、ということです。続いて後半部分(下記)です。

    if (not path) or (path[-1].widget is not widget):
        path.append(Bunch(widget=widget,
                          repetition=repetition))
        return True
    else:
        return False

このif文は、現段階ではどちらが実行されるのか分かりません。ここは保留にしておきます。なお、ここでpathに追加されるBunchオブジェクトというのはturbogears/util.pyで定義されていて、

class Bunch(dict):
    __setattr__ = dict.__setitem__

    def __delattr__(self, name):
        try:
            del self[name]
        except KeyError:
            raise AttributeError

    def __getattr__(self, name):
        try:
            return self[name]
        except KeyError:
            raise AttributeError

という、プロパティをキーとして使える辞書です。

さて、次にget_pathメソッドを探します。これはturbogears/widgets/forms.pyにありました。

def get_path(default_widget, default_repetition):
    default_path = [Bunch(widget=default_widget,
                          repetition=default_repetition)]
    return getattr(request, "tg_widgets_path", default_path)

get_path関数は、get_path(self, None)として呼び出しているので、request.tg_widgets_pathがなければ、[Bunch(widget=self, repetition=None)]が返ることになります。

最後に、pop_from_path関数を見てみます。

def pop_from_path():
    if request_available() and hasattr(request, "tg_widgets_path"):
        request.tg_widgets_path.pop()

この関数では、request.tg_widgets_pathの最後の要素を取り除いています。ここで、書き直した_get_path関数を思い出します(以下に再掲します)。

    def _get_path(self):
        update = append_to_path(self, None)
        returnval = get_path(self, None)[:]
        if update:
            pop_from_path()
        return returnval

pop_from_path関数は、append_to_path関数がTrueを返したときのみ実行されます。そして、append_to_path関数がTrueを返すのは、request.tg_widgets_pathにBunchオブジェクトが追加されるときでした。以上より、request.tg_wigets_pathにappend_to_path関数で追加したものをpop_from_path関数で取り出す、という挙動が見えてきます。

ここまでの内容から考えると、_get_path関数(すなわちpathプロパティ)の値というのは、requestオブジェクトにtg_widgets_pathメンバがあるかどうかで変わってきそうです。まず、requestオブジェクトにtg_widgets_pathメンバがない場合の挙動を追ってみます。このとき、

  1. append_to_path関数で、request.tg_widgets_pathを空のリストとする。
  2. append_to_path関数で、request.tg_widgets_pathにBunch(widgets=self, repetition=None)オブジェクトを追加する。
  3. get_path関数が、[Bunch(widgets=self, repetition=None)]を返す。
  4. pop_from_path関数で、request.tg_widgets_pathからBunchオブジェクトを取り除く(空のリストにする)。

次に、request.tg_widgets_pathにself以外の要素を持つリストが既に設定されていた場合は、

  1. append_to_path関数で、request.tg_widgets_pathにBunch(widgets=self, repetition=None)オブジェクトを追加する。
  2. get_path関数が、request.tg_widgets_pathを返す。
  3. pop_from_path関数で、request.tg_widgets_pathからBunchオブジェクトを取り除く。

となります。また、tg_wigets_pathをgrepした結果を眺めてみたところ、get_path関数の戻り値となる(つまり、pathプロパティの値となる)request.tg_widgets_pathは、Bunchオブジェクトのリストとなっていると考えて間違いなさそうです。

これでpathプロパティの値(の性質)が分かりました。field_idの値を求めるのに必要なのは、あとはbuild_name_from_path関数だけです。これは、turbogears/widgets/forms.pyにあります。

def build_name_from_path(path, repeating_marker='-', nesting_marker='.'):
    name = []
    for item in path:
        if item.repetition is not None:
            name.append(item.widget.name + repeating_marker + str(item.repetition)) 
        else:
            name.append(item.widget.name)
    return nesting_marker.join(name)

build_name_from_path関数の第1引数のpathは、(pathプロパティの値だから)Bunchオブジェクトのリストです。また、これまで見てきた中では、BunchオブジェクトのrepetitionはすべてNoneになっていました。他の引数も踏まえると、上の関数の中身は、以下のようになります。

    name = []
    for item in path:
        name.append(item.widget.name)
    return "_".join(name)

以上をすべてひっくるめて考えてみると、request.tg_widgets_pathのリストにウィジェット(を持つBunchオブジェクト)を出し入れしながら、ウィジェットの名前をつなげていく、という挙動が浮かびます。

では、他にはどんなところでrequest.tg_widgets_pathに要素を追加しているのでしょうか? update_path関数がポイントになりそうなので、これをgrepしてみたら、turbogears/widgets/forms.pyのInputWidgetクラスのdisplayメソッドが、

class InputWidget(Widget):
(略)
    def display(self, value=None, **params):
        return super(InputWidget, self).display(value, **params)
    display = update_path(display)

となっていました。このdisplayメソッドを書き直すと、以下のようになります。

    def display(self, value=None, **params):
        update = append_to_path(self, None)
        returnval = super(InputWidget, self).display(value, **params)
        if update:
            pop_from_path()
        return returnval

super(InputWidet, self).displayメソッドというのは、turbogears/widgets/base.pyのWidgetクラスのdisplayメソッドで、

class Widget(object):
(略)
    def display(self, value=None, **params):
(略)
        return view.engines.get('kid').transform(params, self.template_c)

となっており、最終的にウィジェットを表示するメソッドです。すなわち、InputWidgetクラスを継承するウィジェットを表示すると、その中にあるウィジェットのid属性には、親ウィジェットの名前が追加されることになります。ここで継承関係を確認します。

InputWidget CompoundWidget
    ^             ^
    |             |
  CompoundInputWidget
          ^
          |
  FormFieldsContainer
          ^
          |
        Form

FormクラスはInputWidgetクラスを継承しています。よって、フォームの中のウィジェットのid属性の値には、フォームの名前が付加されます。

問題点

このTurboGearsの仕様は、オブジェクトを一意に決定できるidを生成しようという意図によるものです。それは理解できるのですが、問題があります。私が現在開発しているアプリケーションのある画面では、自分で作成したウィジェット(CompoundWidgetを継承しています)を含むフォームウィジェットを使っています。この画面ではある操作をすると、Ajaxによってそのウィジェットを置き換える新しいHTMLが返ってくるという仕組みになっています。

 +----------------+
 |ブラウザ        |
 |+--------------+|
 ||フォーム      ||
 ||+------------+||  +------------+   +------+
 |||ウィジェット|<---|ウィジェット|---|サーバ|
 ||+------------+||  +------------+   +------+
 |+--------------+|
 +----------------+

ここで問題というのは、初期画面表示の段階ではフォームを表示するので、中のウィジェットのid属性の値にはフォームの名前が付加されていますが、Ajaxウィジェットだけ送るときはウィジェットしか表示しないので、id属性の値にフォームの名前が付加されないのです。これによって、ある操作をするとウィジェットのidが変わってしまうという現象が起きてしまいます。

解決策

この問題を解決するため、ウィジェットだけを返すメソッドに、以下を記述しています。

        from turbogears.widgets import forms
        from turbogears import widgets
        forms.append_to_path(widgets.Form(name="foo"), None)

すなわち、架空のフォームを追加して、あたかもウィジェットがフォームの中にあるかのようにするわけです。

所感

Ajaxを使うとき、ユーザインターフェースをどこで作成するかによって、2つの方法があると思います。

  1. 表示に必要なデータのみをJSONなどの形式でもらい、ブラウザ側でHTMLを組み立てる。
  2. 表示するHTMLをサーバで生成し、ブラウザではそれを表示するだけ。

1.の方法だと、HTMLを作成するコードをサーバ側とブラウザ側の2箇所で書かなくてはならず、二度手間になるので、私は2.の方法をとっています。しかし、TurboGearsは2.の方法とは相性が悪いようです。今回とった解決策も、TurboGearsの内部に踏み込んでいるので、後のバージョンでも可能かどうか、不安です。