djangoのviewとurl patternの定義をもう少しstrictな感じにできないかと考えていた

まとまってない感じの文章だけれど備忘録として残す。

djangoでurls.pyにview関数を登録する時に、以下のような状況がだるいと思った。

  • urls.pyに定義したview関数を登録し忘れる
  • view関数の引数を変えた後に、urls.pyのpatternを変更し忘れている

そしてそもそものurls.pyでview関数を登録するという記述の仕方だと完全に解決できないな〜ということを思った。 逆に、urls.pyの定義をviews.pyに持ち込んでこちらがわでチェックしながら登録することができるかもしれないというようなことを思った。

それのシミュレーションをするようなコードを書いてみた。

djangoの通常のview関数の登録

djangoの場合、views.pyで定義した関数をurls.pyで登録する。(実際の記述方法と今回の記述は異なる)

view.py

def aview(request):
    return "a"


def bview(request, x):
    return "b", x


def c(request, x, y):
    return "c", x, y

urls.py

# このrootはapplicationで1つだけ(rootに数多くのrouterが属しているイメージ)
# 後述するviewのチェックに対応するためにこのような形になっている

root = Root()
r = Router()
root.add(r)

r.register("/a", "a", aview)
r.register("/b/(?P<x>[^/]+)", "b", bview)

利用するときは以下の通り

class DummyRequest(object):
    def __init__(self, path):
        self.path = path

    def reverse(self, name, **kwargs):
        rx = root.pattern_from_name(name)
        return format_from_pattern(rx.pattern).format(**kwargs)

print(r(DummyRequest("/a")))  # => a
print(r(DummyRequest("/b/xxx")))  # => ('b', 'xxx')

print(DummyRequest("/a").reverse("a"))  # => /a
print(DummyRequest("/a").reverse("b", x="yyyy"))  # => /b/yyyy

# 登録されていないviewなので使えない。
# print(DummyRequest("/a").reverse("c", x="yyyy", y="zzzz"))  # oops

完全に動く定義ができている時には何の問題もないのだけれど。 不完全な定義であった場合に、動かず、該当のurlにブラウザアクセスしなければその間違いに気づかないというのが非常に億劫。

appendix

シミュレーションに使った動作するRouter/Rootの実装

import re


class Root(object):
    def __init__(self):
        self.routers = []

    def __call__(self, request):
        for r in self.routers:
            result = r(request)
            if result is not None:
                return result

    def add(self, router):
        self.routers.append(router)

    def pattern_from_name(self, name):
        for r in self.routers:
            if name in r.name_pattern_map:
                return r.name_pattern_map[name]


class Router(object):
    def __init__(self):
        self.name_pattern_map = {}  # (name -> pattern)
        self.name_view_map = {}  # (name -> view)

    def register(self, pattern, name, view):
        if name in self.name_pattern_map:
            raise Exception("Conflict name={}".format(name))
        self.name_pattern_map[name] = re.compile(pattern)
        self.name_view_map[name] = view

    def __call__(self, request):
        for name, pattern in self.name_pattern_map.items():
            m = pattern.search(request.path)
            if m is not None:
                request.args = m.groups()
                return self.name_view_map[name](request, *request.args)


def format_from_pattern(pattern):
    return re.sub("\(\?P<([\w+])>[^(]+\)", lambda m: "{%s}" % m.groups(1), pattern)


def extract_args_from_pattern(pattern):
    return re.findall("\(\?P<([\w+])>[^(]+\)", pattern)

djangoのurl patternのmatchingをviews.py側でやる。

色々やり方があると思ったけれど。とりあえずメタクラスでやることにした。 またオプションは以下のようにMetaを使った形のもので定義することが多いが。以下のような問題があると思っている。

  • そもそもコードが冗長になって嫌。
  • 必須の引数が何なのかわかりづらい。
# Metaを使った例。

class FooModel(Model):
    class Meta:
       app_label = "myapp"

変わりにbaseクラスを生成する関数を作ってこちらを使うようにしたほうが良いのかもしれない。こちらなら必須の引数がなんだかわかりやすい。

FooMeta # metaclass

def creat_base_class(x) # x は必須の引数
    return FooMeta("FooBase", (),  {"x": x})

横道にそれてしまった。今回は、ViewDefinitionというクラスに所属する関数(not method)がview関数ということにする。 (現在の制限としてはclass base viewが使えないというものがある。これは実装を工夫すれば解決できそう)

これで以下ようなことが出来るようにする。

  • view関数を定義したがurls.pyでpatternと結びつけるのを忘れた
  • urls.pyでpatternと結びつけたが、view関数を定義するのを忘れた
  • urls.pyでpatternと結びつけたがview関数のパラメータと一致していない

これらは全て現状のdjangoではruntime時に該当のurlにアクセスしてみない限りわからない。

以下の様な形で使う。

urls.py

root = Root()
r = Router()
root.add(r)

r.register_pattern("/a", "a")
r.register_pattern("/b/(?P<x>[^/]+)", "b")

# cに対応するviewが定義されていたがpatternとmatchingが定義されていなかった場合のエラー
# Exception: view[name=c] is not registered. plese router.register_pattern(pattern, 'c')
r.register_pattern("/c/(?P<x>[^/]+)/(?P<y>[^/]+)", "c")

views.py

from .urls import r

class ViewDefinition(make_view_definition(router=r)):
    def a(request):
        return "a"

    def b(request, x):
        return "b", x

    # cについてpatternとの対応が定義されていたがview関数が登録していなかった場合のエラー
    # Exception: view[name=c] is not defined. please def c(request, x, y):
    # cについて対応も定義されていたが正規表現とview関数の引数が一致しなかった場合のエラー
    # Exception: view[name=c] is invalid definition(pattern_args=['x', 'y'] != view_args=['x'])
    def c(request, x, y):
        return "c", x, y

同様な形で使えます。

print(r(DummyRequest("/a")))  # => a
print(r(DummyRequest("/b/xxx")))  # => ('b', 'xxx')
print(r(DummyRequest("/c/xxx/yyy")))  # => ('c', 'xxx', 'yyy')

print(DummyRequest("/a").reverse("a"))  # => /a
print(DummyRequest("/a").reverse("b", x="yyyy"))  # => /b/yyyy
print(DummyRequest("/a").reverse("c", x="yyyy", y="zzz"))  # => /c/yyyy/zzz

良いこと

  • viewの登録忘れが解消される
  • viewとpatternの引数の対応が確保される

問題点

  • (ある1つのrouterの範囲でしか網羅性をチェックできない)
  • class base viewに対応できない。(これどうにか出来るかも)
  • わざわざクラスを作らないといけない。

今回はmetaclassで定義したけれどViewImporterというかViewResolverのようなものを定義してあげてそれに任せるというような形にすることでも対応出来るかもしれない。

実際に動く定義

class Router(object):
    def __init__(self):
        self.name_pattern_map = {}  # (name -> pattern)
        self.name_view_map = {}  # (name -> view)

    def register_pattern(self, pattern, name):
        if name in self.name_pattern_map:
            raise Exception("Conflict name={}".format(name))
        self.name_pattern_map[name] = re.compile(pattern)

    def register_view(self, view, name):
        self.name_view_map[name] = view

    def __call__(self, request):
        for name, pattern in self.name_pattern_map.items():
            m = pattern.search(request.path)
            if m is not None:
                request.args = m.groups()
                return self.name_view_map[name](request, *request.args)


class ViewDefinitionMeta(type):
    @classmethod
    def extract_attribute(cls, name, attrs, bases):
        if name in attrs:
            return attrs[name]

        for c in reversed(bases):
            if hasattr(c, name):
                return getattr(c, name)

    @classmethod
    def configure_from_router_information(cls, router, attrs, bases):
        """check: view function from router information"""
        for name, rx in router.name_pattern_map.items():
            pattern_args = extract_args_from_pattern(rx.pattern)

            view = cls.extract_attribute(name, attrs, bases)
            if view is None:
                fmt = "view[name={}] is not defined. please def {}(request, {}):"
                raise Exception(fmt.format(name, name, ", ".join(pattern_args)))

            import inspect
            view_args = inspect.getargspec(view).args[1:]
            if tuple(view_args) != tuple(pattern_args):
                fmt = "view[name={}] is invalid definition(pattern_args={} != view_args={})"
                raise Exception(fmt.format(name, pattern_args, view_args))

            # register: view function
            router.name_view_map[name] = view

    @classmethod
    def configure_from_view_information(cls, router, attrs):
        """check: router information from view function"""
        for name, attr in attrs.items():
            if name.startswith("_"):
                continue
            if callable(attr) and not isinstance(attr, Router):
                view = attr
                if name not in router.name_pattern_map:
                    fmt = "view[name={}] is not registered. plese router.register_pattern(pattern, {!r})"
                    raise Exception(fmt.format(name, name))

                # makes not to Class.<name> is unbound method.
                attrs[name] = staticmethod(view)

    def __new__(cls, clsname, bases, attrs):
        if bases:
            router = cls.extract_attribute("router", attrs, bases)
            if router is None:
                raise Exception("router is not defined")
            cls.configure_from_router_information(router, attrs, bases)
            cls.configure_from_view_information(router, attrs)
        return super(ViewDefinitionMeta, cls).__new__(cls, clsname, bases, attrs)


def make_view_definition(router):
    return ViewDefinitionMeta("ViewDefinition", (), {"router": router})

gist https://gist.github.com/podhmo/c986478c0036c4545efd