メイン

2007年10月 4日

DjangoのURL逆引き機能
このエントリーをブックマークに追加 このエントリーをlivedoorクリップに追加

Chihiroです。

今回は、Django 0.96以降から改善が進んでいるDjangoのURL逆引き機能に関して紹介したいと思います。この機能を上手く使うと、Djangoを使ったWeb開発において、URLの設計と維持にかかる手間を大幅に削減できます。

公式ドキュメントの日本語訳にもこの機能の説明はありますが、説明が分散していて全体像が見えにくいと思いますので、この機能を使わない場合の問題点を指摘し、次に使った場合、ルーティング、モデル、ビュー、テンプレートがそれぞれどのように書けるかを説明したいと思います。

なお、動作確認はSubversionから取得した開発版Django(rev.6447)を使用して行っています。

Django0.96以前のURL定義の問題点

Djangoの大きな特徴として、「自由なURL設計」がありますが、一方でモデル、テンプレート、ビュー、ルーティングにおいて冗長にURLの定義しなくてはいけないという問題点がありました。

例えば、

http://example.com/entries/1978/

というURLで指示されるリソース(例えばブログのエントリ)を考えた場合、まずurls.pyに記述するルーティング定義(URLConf)に、次のような設定を行う必要があります。

patterns += ('myaproject.myapp.views',
             (r'^entries/(?P<entry_id>\d+)/', 'edit_entry'),
             )

次に、Adminツールなどの恩恵を受けるために、Djangoの流儀に従って、次のような感じでモデルにget_abosolute_urlメソッドを実装していました。

from django.db import models

class Entry(models.Model):
    title = models.CharField(max_length=250)
    content = models.TextField()

    def get_absolute_url(self):
      return '/entries/%s/' % self.id

    def __unicode__(self):
        return self.title

URLConfで書いたr'^entries/(?P<entry_id>\d+)/'は正規表現で、モデルに書いたのは'/entries/%s/'というフォーマット文字列という違いはありますが、似たような定義を繰り返しています。

さらに、リソースを参照したり、リソースに対して何らかの操作を行ったりする場合、そのためのURLのフォーマットをテンプレート内に直接書く必要があります。

例えば、あるブログのエントリの編集画面のURLを"/entries/{entry_id}/edit/"のように設計した場合、テンプレートの中に次のように書くことになるでしょう。

{% if entries %}
<ul>
  {% for entry in entries %}
  <li>{{ entry.title|escape }}<a href="/entries/{{ entry.id }}/edit/">{% trans "Edit" %}</a></li>

  {% endfor %}
</ul>
{% endif %}

ここまでの例で分かるように、モデル、ルーティング、テンプレートにおいて、"^entries/(?P<entry_id>\d+)/"、"/entries/%s/"、 "/entries/{{ entry.id }}/edit/"のように、似たようなURLの定義を繰り返す必要がありました。

URL定義のDRY化

開発版Djangoでは、URLConfでのURL定義を元に、ビュー関数名からリソースのURLの解決を行えるようになりました。分かりやすく言うと、urls.pyにURL設計をまとめることができるようになったのです。

基本的なCRUDを例にとって説明します。

まず、URLConfは次のように定義できます。

# -*- coding: utf-8 -*-
# myproject/urls.py
from django.conf.urls.defaults import *
from myapp.models import Entry

urlpatterns = patterns(
    'myproject.myapp.views',

    # エントリの新規作成
    url(r'^entries/new/$',
        'create_entry',
        name='create_entry'),

    # エントリの編集
    url(r'^entries/(?P<entry_id>\d+)/edit/$',
        'edit_entry',
        name='edit_entry'),

    # エントリの削除
    url(r'^entries/(?P<entry_id>\d+)/delete/$',
        'delete_entry',
        name='delete_entry'),
)

urlpatterns += patterns(
    'django.views.generic.list_detail',

    # エントリ一覧
    # GenericViewを使います
    url(r'^entries/$',
        'object_list',
        dict(queryset=Entry.objects.all(),
             allow_empty=True),
        name='show_entries'

        ),

    # エントリ詳細
    # GenericViewを使います
    url(r'^entries/(?P<object_id>\d+)/$',
        'object_detail',
        dict(queryset=Entry.objects.all()),
        name='show_entry_detail'
        ),
    )

urlpatterns += patterns(
    'django.views.generic.simple',

    # 上のどれでもない場合は/entries/にリダイレクト
    url(r'',
        'redirect_to',
        dict(url='/entries/')
        ),

    )

上のコード例にあるように、開発版Djangoでは、今までのタプル(シーケンス)でのURL定義の他に、新たにurlという関数で定義を行えるようになりました。この関数を使うと、名前つきのURLパターンを定義するのが多少楽になります。

名前つきをURLパターンを使った方が、URL定義の逆引きをするときにタイプ量が減るという単純なメリットもありますが、名前付きURLを使わないと解決できないケースもあります。詳しくは公式のドキュメントを参照してください。

開発版Djangoの機能を活用すると、URLの設計(URLのフォーマットを決める作業)はこのURLConfの中で完結し、モデル・ビュー・テンプレートでは、URLパターンの名称とモデルのキーなどのパラメータからURLを決定することができるようになります。

まず、モデルでは、permalinkデコレータ関数を使うと、URLConfに基づいてモデルのURLを構成することができます。(公式ドキュメントによる解説)

from django.db import models
from django.db.models import permalink

class Entry(models.Model):
    title = models.CharField(max_length=250)
    content = models.TextField()

    @permalink
    def get_absolute_url(self):
        return ('show_entry_detail', [str(self.id)])

    def __unicode__(self):
        return self.title

ビュー関数からURLの解決を行う場合には、reverse関数を使います。(公式ドキュメントによる解説)

# -*- coding: utf-8 -*-
from django.shortcuts import *
from django.http import *
from django.core.urlresolvers import reverse
import django.newforms as forms

from models import Entry

EntryForm = forms.models.form_for_model(Entry)

def create_entry(request):
    """
    新しいエントリを作成する。
    作成後は、エントリ一覧のページにリダイレクトする。
    """
    form = EntryForm(request.POST or None)
    if form.is_valid():
        entry = form.save()
        return HttpResponseRedirect(reverse('show_entries'))

    return render_to_response('myapp/entry_form.html', {'form': form})

テンプレート内からURLを解決する場合は、{% url %}タグを使います。(公式ドキュメントによる解説)

このタグは、{% url URLパターンの名前 パラメータ %}のように使います。

{% load i18n %}
{% extends "base.html" %}
{% block content %}
<div>
  <a href="{% url create_entry %}">{% trans "Post a new entry" %}</a>
</div>

{% if object_list %}
  <ul>
  {% for entry in object_list %}
    <li>{{ entry.title|escape }}
        (<a href="{% url edit_entry entry_id=entry.id %}">{% trans "Edit" %}</a> |
         <a href="{% url delete_entry entry_id=entry.id %}">{% trans "Delete" %}</a>)
    </li>
  {% endfor %}
  </li>
{% else %}
  <p>{% trans "No entries" %}</p>
{% endif %}
{% endblock %}

まとめ

permalink, reverse, {% url %}を使うと、URL設計を変更したい場合などに、変更にかかるコストを大幅に削減できます。またURL設計をURLConfに集約できるので、コードやテンプレートの可読性も高まります。

例えば、「当初、"/entries/{entry_id}/"のようなURLをブログ・エントリのパーマリンクとしていたが、開発段階でパーマリンクを"{entry_id}/"に変更することになった」というケースを考えてみてください。この場合でも、permalink, reverse, {% url %}を使っていれば、URLConfを書き換えるだけで済みます。

今回のコード例は下記のリンクからダウンロードできますので、どうぞご利用ください。

django071003.tgz

2007年9月 5日

Migrateのご紹介
このエントリーをブックマークに追加 このエントリーをlivedoorクリップに追加

こんにちは、chihiroです。今回はデータベース・スキーマのバージョン管理ツールであるMigrateを紹介します。

Migrate
http://erosson.com/migrate/docs/index.html

インストール

開発版の方を使いますので、レポジトリからコードをチェックアウトしてインストールします。

$ svn co http://erosson.com/migrate/svn/migrate/branches/monkeypatch_removal migrate
$ cd migrate
$ python setup.py install

もしくは、easy_installで直接インストールします。

$ easy_install http://erosson.com/migrate/svn/migrate/branches/monkeypatch_removal

また、MigrateはPython製のORMであるSQLAlchemyをベースにしているので、こちらもインストールしておきます。

SQLAlchemyは近いうちにバージョン0.4系が正式版になると思われますが、今回は0.3.10でテストしました。

$ easy_install SQLAlchemy==0.3.10

レポジトリの初期化

インストールが完了すると、PythonのHomeディレクトリにbin/migrateというスクリプトがインストールされます。

最初に、このmigrateを実行してデータベース・レポジトリを初期化します。

初期化のコマンドは"create <レポジトリ名> <プロジェクトの説明文>"です。

$ migrate create db_repository "My blog project"

これでカレントディレクトリにdb_repositoryというレポジトリが作成されます。ここからは、ここで作成されたdb_repository/manage.pyというスクリプトを使って管理を行います。

まず、データベースにバージョン管理用のテーブルを作成します。

コマンドは"version_control <データベースのURL(DSN)>"です。

DSNについてはSQLAlchemyのドキュメントを参照してください。

$ python db_repository/manage.py version_control mysql://username:password@localhost/dbname

毎回DSNを指定するのは手間がかかるので、db_respository/manage.pyを書き換えて、このプロジェクトで使うDSNを指定しておくと便利です。

#!/usr/bin/env python
# db_repository/manage.py
from migrate.versioning.shell import main

main(repository='db_repository',
     url='mysql://username:password@localhost/dbname')

スキーマのバージョンを確認する

Migrateレポジトリのバージョン番号を調べるには、"version"コマンドを使います。

$ python db_repository/manage.py version
0

一方、データベース側のバージョン番号を調べるには"db_version"コマンドを使います。

python db_repository/manage.py db_version
0

スキーマ定義スクリプトを書く

まず"script"コマンドを使ってテーブル定義用のスクリプトを作成します。

$ python db_repository/manage.py script script.py

"script.py"というのはレポジトリにコミットされない一時ファイルなので、実際にはどんな名前でも構いません。

このscript.pyを編集し、SQLAlchemyの流儀でテーブルを定義します。

# script.py
from sqlalchemy import *
from migrate import *

def upgrade():
    user = Table('user',
                 Column('user_id', Integer, primary_key=True),
                 Column('username', Unicode(50), nullable=False),
                 Column('password', String(250), nullable=False),
                 mysql_engine='InnoDB',
                 )

    post = Table('post',
                 Column('post_id', Integer, primary_key=True),
                 Column('user_id', Integer, ForeignKey(user.c.user_id), nullable=False),
                 Column('title', Unicode(250), nullable=False),
                 Column('content', Unicode, nullable=False),
                 Column('created_at', DateTime, nullable=False),
                 Column('updated_at', DateTime, nullable=True),
                 mysql_engine='InnoDB',
                 )

    user.create(migrate_engine)
    post.create(migrate_engine)

def downgrade():
    migrate_engine.execute("DROP TABLE post")
    migrate_engine.execute("DROP TABLE user")

upgrade()関数にアップグレード時の操作を、downgrade()関数にダウングレード時の操作を書きます。

すべてのスキーマ操作をSQLAlchemyを使ってPython風に書くのが理想なのでしょうが、僕の場合は、「CREATE TABLEだけはSQLAlchemyを使って書き、それ以外の操作をmigrate_engine.executeを使ってSQLを直接発行する」というような使い方をしています。

スクリプトが完成したならば、テストを行います。

$ python db_repository/manage.py test script.py
Upgrading... done
Downgrading... done
Success

アップグレード、ダウングレード共に問題ないことを確認した上で、レポジトリにコミットします。

$ python db_repository/manage.py commit script.py

コミットすると、db_repository/versionsディレクトリに"<バージョン番号>/<バージョン番号>.py"として先ほどのスクリプトがコピーされます。

"version"コマンドでレポジトリのバージョン番号を確認してみましょう。

$ python db_repository/manage.py version
1

アップグレード/ダウングレード

データベースのスキーマのバージョンを上げるには"upgrade"コマンドを使います。

$ python db_repository/manage.py upgrade
0 -> 1...  done

"db_version"コマンドでデータベースのバージョン番号を確認してみます。

$ python db_repository/manage.py db_version
1

パラメータなしで"upgrade"コマンドを実行した場合、レポジトリの最新のバージョン番号まで自動的にアップグレードが行われます。もし、ある特定のバージョンまでアップグレードを行うときには、--versionパラメータを指定します。

$ python db_repository/manage.py upgrade --version=1
0 -> 1...  done

ダウングレードする場合は、"downgrade"コマンドを使います。この時は、--versionパラメータでバージョン番号を明示的に指定しなくてはなりません。

$ python db_repository/manage.py downgrade --version=0
1 -> 0...  done

Migrateを使った開発サイクル

ここまでのところを簡潔にまとめると、Migrateを使った基本的な開発サイクルは次のようになるでしょう。

$ python db_respository/manage.py script script.py
$ python db_respository/manage.py test script.py
$ python db_respository/manage.py commit script.py
$ python db_respository/manage.py upgrade

余談ですが、この手のマイグレーションツールを初めて使い始めたとき、「ダウングレードってするの?」という疑問を抱くときがあります。個人的には、ダウングレードは結構使用しています。ただ、ほとんどが「アップグレードのスクリプトやスキーマ設計にミスがあった」という場合なので、開発スタイルや性格の問題なのかもしれませんが・・・

MigrateのTips

複数のURLを扱う

開発版データベースと本番データベースのように複数のデータベースを管理する必要がある場合、"manage"コマンドを使って、管理用スクリプトを複数作っておくと便利です。

$ python db_repository/manage.py manage db_manage.py \
  --url=mysql://username:pass@localhost/db \
  --repository=db_repository
$ python db_repository/manage.py manage db_manage_dev.py \
  --url=mysql://username:pass@localhost/db_dev \
  --repository=db_repository

以降は、db_manage_dev.pyを使用して開発を行い、本番環境ではdb_manage.pyを使用してスキーマの管理を行います。

カラムの追加

公式のドキュメントの説明では、migrate.changesetを使ってカラムの追加、変更、削除を行えるとされていますが、正しく動作している感じがしません。

僕の場合は、migrate_engine.executeを使って直接ALTER文を発行しています。

まとめにかえて - Migrateの問題点

残念ながら、このツールは開発が止まってしまい、今後の動向は不透明です。また、エラーメッセージが非常に分かりにくいという問題点もあります。

しかし、現在僕が開発しているアプリケーションもこのMigrateに依存しており、このまま死なせるにはあまりにも惜しいツールですので、僕自身がメンテナンスに名乗りを上げるかもしれません。そういった意味で、あえてこの場で紹介させていただきました。

2007年8月14日

Pythonで携帯の機種判別をする
このエントリーをブックマークに追加 このエントリーをlivedoorクリップに追加

こんにちは、chihiroです。今回はPythonでモバイルサイトを開発する場合に便利なライブラリ、WSGIUserAgentMobileを紹介します。これはUserAgentからキャリアや端末情報を取得するためのライブラリです。

Google Code WSGIUserAgentMobile
http://code.google.com/p/wsgiuseragentmobile/

これは基本的にはPHPのPEAR::Net_UserAgent_Mobile、さらに元を辿ればPerlのHTTP-MobileAgentを、僕がPythonに移植したものですが、いくつか独自のメソッドやプロパティを追加しています。インストールから、基本的な使い方、Djangoで使用する場合の例をご紹介します。

インストール

諸般の事情によりまだPython Package Indexに登録していないので、開発レポジトリから直接インストールして下さい。

レポジトリからチェックアウトしてからインストールする場合、次のようにします。

$ svn checkout http://wsgiuseragentmobile.googlecode.com/svn/trunk/ uamobile
$ cd uamobile
$ python setup.py install

または、easy_installを使って直接レポジトリからインストールすることもできます。

$ easy_install http://wsgiuseragentmobile.googlecode.com/svn/trunk/

簡単な使い方

デバイスの判定を行うには、uamobileモジュールのdetect関数を使います。detectに与える引数は辞書型オブジェクトです。

>>> from uamobile import detect
>>> detect({'HTTP_USER_AGENT':'KDDI-SA31 UP.Browser/6.2.0.6.3.129 (GUI) MMP/2.0'})
<EZwebUserAgent "KDDI-SA31 UP.Browser/6.2.0.6.3.129 (GUI) MMP/2.0">

detect関数には、少なくともHTTP_USER_AGENTというキーと値だけは与えなくてはなりません。さもないと、当然ながらUserAgentの判定ができないので、detect関数はNonMobileオブジェクトを返します。

>>> detect({})
<NonMobileUserAgent "">

よく使うメソッド・プロパティ

carrier, short_carrierでキャリア名、キャリア名の頭文字を取得できます。

>>> dev = detect({'HTTP_USER_AGENT':'KDDI-SA31 UP.Browser/6.2.0.6.3.129 (GUI) MMP/2.0'})
>>> dev.carrier
'EZweb'
>>> dev.short_carrier
'E'

PHP版、Perl版との違いとしては、ライブラリ内部での用語・表現に、VodafoneではなくSoftBank、AirHではなくWILLCOMという語を使用している点があります。

>>> dev = detect({'HTTP_USER_AGENT':'Vodafone/1.0/V904SH/SHJ003 Browser/VF-NetFront/3.3 Profile/MIDP-2.0 Configuration/CLDC-1.1'})
>>> dev.carrier
'SoftBank'
>>> dev.short_carrier
'S'
>>> dev = detect({'HTTP_USER_AGENT':'Mozilla/3.0(WILLCOM;SANYO/WX310SA/2;1/1/C128) NetFront/3.3'})
<WillcomUserAgent "Mozilla/3.0(WILLCOM;SANYO/WX310SA/2;1/1/C128) NetFront/3.3">
>>> dev.carrier
'WILLCOM'
>>> dev.short_carrier
'W'

キャリアを判定するメソッドとして、is_docomo(), is_ezweb(), is_softbank(), is_willcom(), is_nonmobile()があります。それぞれ、DoCoMo端末、au端末、SoftBank端末、WILLCOM端末、それ以外のときにTrueを返します。

>>> dev = u.detect({'HTTP_USER_AGENT':'DoCoMo/2.0 N904i(c100;TB;W30H20)'})
>>> dev.is_docomo()
True
>>> dev.is_ezweb()
False
>>> dev.is_softbank()
False
>>> dev.is_willcom()
False
>>> dev.is_nonmobile()
False
>>>

serialnumberプロパティから、DoCoMo端末ならばFOMA製造番号、au端末ならばサブスクライバID、SoftBank端末ならば製造番号を取得できます。

>>> dev = detect({'HTTP_USER_AGENT':'DoCoMo/2.0 N904i(c100;TB;W30H20;ser333333333333333;icc8888888888888888888F)'})
>>> dev.is_docomo()
True
>>> dev.serialnumber
'333333333333333'
>>> dev = detect({'HTTP_USER_AGENT':'Vodafone/1.0/V904SH/SHJ003/SN333333333333333 Browser/VF-NetFront/3.3 Profile/MIDP-2.0 Configuration/CLDC-1.1'})
/CLDC-1.1">
>>> dev.serialnumber
333333333333333

SoftBankだけの特殊なプロパティとしてjphone_uidがあります。これはSoftBank利用者が通知設定を有効にしている場合(My SoftBank⇒各種変更手続き⇒ユーザID通知設定)に、コンテンツ提供者に通知される端末毎に一意なIDです。実際のjphone_uidは英数字(a-zA-Z0-9)からなる16桁の文字列です。

>>> dev = detect({'HTTP_USER_AGENT': 'Vodafone/1.0/V904SH/SHJ003 Browser/VF-NetFront/3.3 Profile/MIDP-2.0 Configuration/CLDC-1.1',
                 'HTTP_X_JPHONE_UID': 'xxxxxxxxxxxxxxxx' })
>>>dev.jphone_uid
'xxxxxxxxxxxxxxxx'

displayプロパティでDisplayオブジェクトを取得できます。これは端末の画面サイズにあわせて表示するコンテンツを変える場合に便利です。

>>> dev = u.detect({'HTTP_USER_AGENT':'DoCoMo/2.0 N904i(c100;TB;W30H20)'})
>>> dev.display.width
240
>>> dev.display.height
352
>>> dev.display.color
1
>>> dev.display.depth
262144

端末がCookieを利用かどうかを調べるには、supports_cookie()メソッドを使います。

>>> docomo = u.detect({'HTTP_USER_AGENT':'DoCoMo/2.0 N904i(c100;TB;W30H20)'})
>>> docomo.supports_cookie()
False
>>> au = detect({'HTTP_USER_AGENT':'KDDI-SA31 UP.Browser/6.2.0.6.3.129 (GUI) MMP/2.0'})
>>> au.supports_cookie()
True
>>> sb = detect({'HTTP_USER_AGENT':'Vodafone/1.0/V904SH/SHJ003 Browser/VF-NetFront/3.3 Profile/MIDP-2.0 Configuration/CLDC-1.1'})
>>> sb.supports_cookie()
True

応用編・Djangoで使う

Djangoで使用する場合、Middleware層に端末判定処理を追加するとよいでしょう。例えば、myprojectというプロジェクトのmyappというアプリケーションにmiddleware.pyとして、次のようなMiddlewareを用意します。

# myproject/myapp/middleware.py
from uamobile import detect, exceptions

class UserAgentMobileMiddleware(object):
    def process_request(self, request):
        try:
            request.device = detect(request.META)
        except exceptions.NoMatchingError, e:
            raise

判定に失敗した場合、NoMatchingErrorが発生します。上記のMiddlewareでは、たんに、例外を再度raiseしているだけですが、本来ならば、非モバイルブラウザとしてそのまま処理を続行するか、あるいは、エラー画面を表示するといった対応が必要でしょう。

このMiddlewareを有効にするために、settings.pyのMIDDLEWARE_CLASSESにこのクラスを追記します。

MIDDLEWARE_CLASSES = (
    'django.middleware.common.CommonMiddleware',
    # 追加
    'myproject.myapp.middleware.UserAgentMobileMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.middleware.doc.XViewMiddleware',
)

後はビューやテンプレートから"request.device"のようにしてUserAgentサブクラスのインスタンスにアクセスできます。

Djangoで本格的なモバイルサイトを構築する場合には、Cookieが使えない端末のセッションIDの処理といったモバイルサイトならではの対応が必要とされますが、そのあたりのノウハウはまた機会を改めて紹介したいと思います。

まとめにかえて

このWSGIUserAgentMobileは僕が開発を担当しているgumiでも使用しています。興味がある方は、是非、携帯でhttp://gu3.jp/にアクセスしてみて下さい。

また、このライブラリはまだベータ版なのですが、バグの報告や何かご質問がありましたら、コメントやトラックバックでお知らせいただくか、下記メールアドレスに直接ご連絡いただければ幸いです。

>>> 'Y2hpaGlyb19zYWthdG9rdUB1bm9oLm5ldA=='.decode('base64')

2007年7月18日

Rails風フレームワークPylonsで簡易Wikiを作ってみる
このエントリーをブックマークに追加 このエントリーをlivedoorクリップに追加

Yet Another Sakatokuです。今回はPyhonで書かれたRuby on Rails風のフレームワークPylonsの使い方を簡単に紹介したいと思います。

PylonsはPythonのWebフレームワークとしては、現在のところ、Django, TurboGearsにつぐ三番手(以下)と見なされていますが、TurboGears 2がPylonsと合流して、Pylons上に旧TurboGearsのAPIを提供していくことが表明されましたので、今後大きな勢力になっていくと思われます。

サンプル・アプリケーション

サンプルとして、簡易Wiki(CoCoWiki)を作ってみました。以下のURLからダウンロードできるので参考にしてみてください。

cocowiki.tar.gz

Pylonsの開発サーバ上で動作させるには、ダウンロードしたtar.gzを解凍し、cocowikiディレクトリで"paster serve"コマンドで実行します。

$ tar xzf cocowiki.tar.gz
$ cd cocowiki
$ paster serve development.ini

上手く動作していれば、http://localhost:5000/にアクセスすると次のような画面が表示されると思います。

cocowiki
cocowiki posted by (C)フォト蔵

動作確認はWindows版Python2.5.1, Linux版Python2.4.3で行いました。後述の通り、あらかじめPylonsとSAContextをインストールしている必要があります。また、Python2.4以前のバージョンの場合は、pysqlite2ライブラリも必要です。

Pylonsのインストール

例によってeasy_installを使ってインストールします。ただ、僕の環境ではsetuptoolsのバージョンが古いというエラーが発生したので、最初に以下のURLから最新版のsetuptoolsをダウンロードし、インストールしました。

http://cheeseshop.python.org/pypi/setuptools

次に、Pylonsをインストールします。現在配布されているバージョンは0.9.6RC1です。

$ easy_install -U Pylons

これで、Pylonsの依存するライブラリがすべてインストールされるはずです。

Pylons 0.9.6の大きな変更点として、データベース接続にSAContextというライブラリの使用が推奨されるようになったことがあります。今回作成するアプリケーションではこのSAContextを使うので、これもインストールしておきます。

$ easy_install -U SAContext

プロジェクトを作成する

新しくPylonsプロジェクトの作成するには"paster create"コマンドを使います。

プロジェクト名は、"cocowiki"としました。

$ paster create --template=pylons cocowiki
$ cd cocowiki

開発用のサーバを起動するには、"paster serve"コマンドを使います。

$ paster serve development.ini

アプリケーションの設定

まずdevelopment.inにアプリケーションの設定を記述します。

今回はSessionを使わないので、とりあえずはデータベースの設定のみです。

sqliteを使うことにしましたが、SQLAlchemyがサポートするデータベースならば何でも構いません。

sqlalchemy.default.uri = sqlite:///%(here)s/data/cocowiki.sqlite.db
sqlalchemy.default.echo = true
sqlalchemy.default.echo_pool = false
sqlalchemy.default.pool_recycle = 3600

SQLAlchemyのサポートするデータベースとURIの記述の仕方については、こちらのドキュメント(英語)を参照してみてください。

モデル定義とデータベースの初期化

次にモデルの定義をcocowiki/model/__init__.pyに記述します。

# -*- coding: utf-8 -*-
# cocowiki/model/__init__.py
from sqlalchemy import *
from sqlalchemy.ext.assignmapper import assign_mapper

from urllib import quote
from datetime import datetime

from sacontext import PylonsSAContext

# Pylons 0.9.6から以前のpylons.databasesがdeprecated扱いになりました。
# 代わりにSAContextのPylonsSAContextを使うことが推奨されています。
# 詳しくはhttp://sluggo.scrapping.cc/python/sacontext/を参照してください。
sac = PylonsSAContext()
sac.add_engine_from_config(None)

# テーブル定義
pages = Table('pages', sac.metadata,
              Column('id', Integer, primary_key=True),
              Column('word', Unicode(250), nullable=False, unique=True),
              Column('content', Unicode, nullable=False),
              Column('created_at', DateTime, default=datetime.now()),
              Column('updated_at', DateTime, default=datetime.now(), onupdate=datetime.now()),
              mysql_engine='InnoDB',
              )

# Wikiのページに相当するドメインオブジェクト
class Page(object):
    def __init__(self, word=None, content=None):
        self.word = word
        self.content = content

    def __repr__(self):
        return '<cocowiki.model.Page "%s">' % self.word

    def get_absolute_url(self):
        """
        このオブジェクトのURLを取得します
        これはPylonsの流儀ではないかもしれませんが便利です!
        """
        return '/%s' % quote(self.word.encode('utf-8'))

# AssignMapperを使ってテーブルとドメインオブジェクトを
# マッピングします
assign_mapper(sac.session_context,
              Page,
              pages)

モデルの設計は、あっさりとしたもので済ませることにしました。

いくつか注釈を付け加えるならば、

  • このような単純な例ならば、ElixirというSQLAlchemyのラッパーライブラリを使う手もあります。このライブラリを使うと、モデル定義をよりRails的に(ActiveRecord的に)行うことができます。ただ、個人的にはあまり好みではないので使っていません。
  • Pylonsのサンプルでは、しばしば、"assign_mapper"ではなく"mapper"が使われていますが、assign_mapperの方がコードが簡潔になるのでお勧めです。
  • もっと大きなアプリケーションを書く場合、project/model/__init__.pyにドメインオブジェクトの定義も、テーブルの定義も一緒に書いてしまうと収拾が付かなくなります。本来ならば、project/model/tables.py, project/model/domains.py...のように細かくモジュールを分けるのがお勧めです。このアプローチはSQLAlchemyのサンプル(test/zblog)が参考になります。

データベースの初期化のコードは、cocowiki/websetup.pyに記述しておきます。

# -*- coding: utf-8 -*-
"""Setup the cocowiki application"""
from paste.deploy import appconfig
from pylons import config

from cocowiki.config.environment import load_environment

def setup_config(command, filename, section, vars):
    """Place any commands to setup cocowiki here"""
    conf = appconfig('config:' + filename)
    load_environment(conf.global_conf, conf.local_conf)

    from sqlalchemy import exceptions
    from cocowiki.model import sac, Page
    sac.metadata.create_all(checkfirst=True)

    try:
        start_page = Page('StartPage', u'ここうぃきの世界にようこそ')
        sac.session_context.current.flush()
    except exceptions.SQLError, e:
        pass

コマンドラインから、"paster setup-app"コマンドを使うと、このコードを実行することができます。

$ paster setup-app development.ini

コントローラの作成, ルーティングの設定

コントローラを作成します。

$ paster controller page

これで、cocowiki/controllers/pages.pyにPagesControllerというコントローラクラスが生成されます。このコントローラのメソッドに各処理(アクション)を実装していきます。

それらのアクションとURLを対応付けているのが、cocowiki/config/routing.pyです。ルーティングについて簡潔にまとまっているページがなかなか見つからないので、Routeのドキュメントにあたるのが一番早そうです。今回は、ドキュメントを参考に次のようなURL設計にしてみました。

/list
登録済みのページ一覧の表示
/WikiWord
WikiWordというページの表示
/WikiWord;edit
WikiWordというページの編集(新規登録)フォームの表示

"/list"で一覧表示というのが、あまりエレガントではないかもしれません。また、リソースの新規作成と更新をURL上でも、HTTPメソッドの上でも区別しないことにしています。"/WikiWord"というURLにPOSTすると、ページがすでにある場合は更新し、無い場合は新規作成します。

このような動作に対応するrouting.pyは、以下のようになります。

# cocowiki/config/routing.py

def make_map():	
	# ...略

    # 登録されている単語を表示するページです。
    map.connect('list', controller='pages', action='index')

    # Wikiの編集フォームのページ
    map.connect('*(word);edit', controller='pages', action='edit')

    # Wiki更新のアクションのマッピングです。
    # フォームの内容にエラーがある場合は、再度上のeditページを表示します。
    map.connect('*word', controller='pages', action='update', conditions={'method':['POST']})

    # Wikiの個々のページです。
    # 上記のform, edit, list以外の単語がWikiワードになります。
    map.connect('*word', controller='pages', action='show')

    return map

Wikiとは違って、リソースをデータベースのinteger型のキーで識別するようなアプリケーションでは、Rails風(REST風)のURLマッピングが使えます。この場合は、コントローラを"paster restcontroller"で作成します。

$ paster restcontroller object objects

"paster restcontroller"に関しては、別の場所でメモを残したことがあるので、興味がある方は参照してみてください。Pylons 0.9.6になっても、この部分は変わっていないと思います。

コントローラの実装

いよいよコントローラにロジックを書いていくわけですが、すべてのアクションについて解説していくと長くなるので、ページの取得の部分(showメソッド)を例にとって、Pylonsの基本を説明していきます。

class PagesController(BaseController):
    def show(self, word=None, format='html'):
        """
        Wikiワード"Word"を表示します。
        WordがNoneの場合、つまりURLが"/"の場合は、/StartPage
        にリダイレクトさせます。
        """
        if not word:
            redirect_to('/StartPage')

        c.page = Page.get_by(word=word)
        if c.page is None:
            # wordでページが登録されていない場合は、
            # 新規登録のフォームを表示します。
            return self.edit(word)

        # 更新順に10件のページを取得します。
        # limit=11で最大11件表示しているのは、「もっと見る」の
        # リンクの表示する条件を簡略化するためです。
        c.words = Page.select(order_by=desc(Page.c.updated_at),
                              limit=11)
        return render('show')
  • ビュー(テンプレート)に値を渡すには、"c"を使います。上記の例では、"c.page = ...", "c.words = ...."がそれに当たります。
  • リダイレクトさせる時にはredirect_toを使います。これは例外として処理されるので、return redirect_to('/path')のようにする必要はありません。
  • HTTPエラー(例えば404 Not Found)を返したい場合は、abort(404)を使います。
  • 以前のバージョンのPylonsではテンプレートをレンダリングするのに、render_responseというメソッドを使っていましたが、今はrenderを使うようです。

他のメソッドは、サンプルアプリケーションのコードを参照してください。Pylonsの流儀に慣れてしまえば、難しいことは何もしていないことがお分かりになると思います。

テンプレートの作成

テンプレートは、cocowiki/templatesに配置します。現在のPylonsの標準テンプレートエンジンはMakoで、上述のrenderメソッドを使った場合、render('show')ならばcocowiki/templates/show.makが使用されます。

Makoの構文は以前簡単に紹介したとおりですが、今回は継承機能を使ってみました。Makoの継承機能についてはこちらのメモもご覧下さい。

実際のcocowiki/templates/show.makは次のようになっています。

<%inherit file="base.mak" />
<%def name="title()">${c.page.word | h}</%def>
<h2>${title()}</h2>
${ h.cocowiki_markup(c.page.content) }
<h3>${ _('Menu') }</h3>
<ul>
  <li>${ h.link_to(_('Edit this page'), h.url_for(word=c.page.word, action='edit')) }</li>
</ul>

<h3>${ _('Recent changes') }</h3>
% if c.words:
<ul>
% for word in c.words[:10]:
  <li><a href="${word.get_absolute_url()}">${word.word | h}</a></li>
% endfor
</ul>
  % if len(c.words) > 10:
    <span>» ${ h.link_to(_('See more'), h.url_for(word=None, action='index')) }</span>
  % endif
% else:
  <p>${_('No pages')}</p>
% endif

Pylonsのテンプレートについてまず知っておくべきことは、次のようなことです。

  • コントローラで割り当てた変数は、テンプレートからも"c"で参照できます。
  • "h"を使うと、cocowiki/lib/helpers.pyに定義されている関数、変数を参照できます。上記の例で言うならば、h.cocowiki_markup, h.link_to, h.url_for等がそれに当たります。
  • 今回は使いませんでしたが、"g"を使って、cocowiki/lib/app_globals.pyに定義されている値を参照することができます。これはアプリケーション共通の設定を参照するのに便利です。
  • "request"を使って、そのリクエストの根底にあるWSGIRequestオブジェクトを参照することができます。これは、リクエストのPATH_INFOを調べたりする際に使用できます。

独自のビュー・ヘルパー関数を定義する

今回作成したアプリケーションでは、cocowiki/lib/helpers.pyに簡易Wiki記法をHTMLに変換するcocowiki_markupという関数を定義しています。

# -*- coding: utf-8 -*-
from webhelpers import *
from urllib import quote
import re
from cgi import escape

WIKI_WORD_RE = re.compile(r'\[\[(.+?)\]\]')

def cocowiki_markup(value):
    """

    WIKIのページデータをHTMLに変換します。
    """
    def repl(matcher):
        word = matcher.group(1)
        return '<a href="%s">%s</a>' % (quote(word.encode('utf-8')),
                                        escape(word))

    # markdown記法で変換
    value = markdown(value)

    # http://example.com, mail@example.comをリンクに変換
    value = auto_link(value)

    # [[word]]という表記をWikiワードとして扱う
    value = WIKI_WORD_RE.sub(repl, value)

    return value

静的なファイルの配信

cocowiki/publicに置いたファイルは静的なファイルとして配信することができますので、ここにスタイルシートや画像をおきました。

まとめと謝辞

以上、Pylonsの概要を駆け足で紹介しました。サンプルアプリケーションと合わせて参照していただくことを念頭に執筆しましたので、かなり説明を省略しているところもあるかと思います。

分かりにくいところや、間違いがあった場合は、お気軽にコメントやトラックバックをいただけましたら幸いです。

また、サンプルアプリケーションのテンプレートには、stansさんが配布されているテンプレートを利用させて頂きました。ありがとうございました。

2007年6月13日

Pythonでメールを送信したい人のためのサンプル集
このエントリーをブックマークに追加 このエントリーをlivedoorクリップに追加

chihiroです。

最近Pythonでのメールを送受信に試行錯誤することがあり、ようやく分かってきたので、ここにまとめておきたいと思います。

Pythonでメールを送信する

Python標準ライブラリでメールを送信する場合、

  1. emailパッケージを使ってMIME文書を作成
  2. smtplibを使って送信

という手順を踏みます。

emailパッケージははじめはとっつきにくいのですが、 各クラスのインターフェイスは統一感があり、よく練られているので、一度分かってしまえば明快です。 国際化されたヘッダーやテキスト以外のコンテンツの扱いに関しても問題ないので、 "battery inside"なPythonのありがたみを実感できるパッケージだと思います。

基本的な例

テキスト形式のメッセージをlocalhost:25から送信する例です。

# -*- coding: utf-8 -*-
import smtplib
from email.MIMEText import MIMEText
from email.Utils import formatdate

def create_message(from_addr, to_addr, subject, body):
    msg = MIMEText(body)
    msg['Subject'] = subject
    msg['From'] = from_addr
    msg['To'] = to_addr
    msg['Date'] = formatdate()
    return msg

def send(from_addr, to_addr, msg):
    # SMTPの引数を省略した場合はlocalhost:25
    s = smtplib.SMTP()
    s.sendmail(from_addr, [to_addr], msg.as_string())
    s.close()

if __name__ == '__main__':
    from_addr = 'spam@example.com'
    to_addr = 'egg@example.com'
    msg = create_message(from_addr, to_addr, 'test subject', 'test body')
    send(from_addr, to_addr, msg)

MIMETextクラスのインスタンスを作って、text/plainなMIME文書を作ります。 smtplibによる接続・送信は、認証がない場合は単純明快です。

gmailを使って送信してみる

gmailはTLSを使って接続しなくてはならないので、接続と認証の手順が上の例とは異なります。

# -*- coding: utf-8 -*-
import smtplib
from email.MIMEText import MIMEText

def create_message(from_addr, to_addr, subject, body):
    # 上と同じ
    pass

def send_via_gmail(from_addr, to_addr, msg):
    s = smtplib.SMTP('smtp.gmail.com', 587)
    s.ehlo()
    s.starttls()
    s.ehlo()
    s.login('yourname@gmail.com', 'password')
    s.sendmail(from_addr, [to_addr], msg.as_string())
    s.close()

if __name__ == '__main__':
    from_addr = 'yourname@gmail.com'
    to_addr = 'egg@example.com'
    msg = create_message(from_addr, to_addr, 'test subject', 'test body')
    send_via_gmail(from_addr, to_addr, msg)

日本語を含んだメール

emailパッケージのデフォルトの文字セットは'us-ascii'なので、 日本語を含んだメールを送信する場合は、明示的に指定する必要があります。

plain/textのMIME文書の場合は、MIMETextオブジェクトのコンストラクタで文字セットを指定するのが一番簡単です。 それに加えて、メールのヘッダーは、email.HeaderのHeaderオブジェクトを使って国際化しなくてはなりません。

# -*- coding: utf-8 -*-
import smtplib
from email.MIMEText import MIMEText
from email.Header import Header
from email.Utils import formatdate

def send(from_addr, to_addr, msg):
    # 上に同じ
    pass

def create_message2(from_addr, to_addr, subject, body, encoding):
    # 'text/plain; charset="encoding"'というMIME文書を作ります
    msg = MIMEText(body, 'plain', encoding)
    msg['Subject'] = Header(subject, encoding)
    msg['From'] = from_addr
    msg['To'] = to_addr
    msg['Date'] = formatdate()
    return msg

if __name__ == '__main__':
    from_addr = 'spam@example.com'
    to_addr = 'egg@example.com'
    msg = create_message2(from_addr, to_addr, u'テスト', u'本文', 'ISO-2022-JP')
    send(from_addr, to_addr, msg)

DoCoMoに絵文字入りのメールを送る

文字セットにShift_JISを使用すると、DoCoMo端末に対して絵文字を含んだメールを送信することができます。

ただし、Pythonのemailパッケージは、入力文字コードとしてShift_JIS, EUC-JPを選択した場合、 自動的に出力文字コードをISO-2022-JPに変換しようとするので、強制的にShift_JISで送信するには追加の処理が必要です。

以下は、"NO Erlang[絵文字:ひらめき] NO Life[絵文字:ハート(複数)]"というメールを送信するサンプルです。 DoCoMoでは、"0xf90xa0"が「ひらめき」の絵文字、"0xf90x94"が「ハート(複数)」の絵文字です。

# -*- coding: utf-8 -*-
import smtplib
from email.MIMEText import MIMEText
from email.Header import Header
from email.Utils import formatdate
from email import Charset

# ヘッダーをQuoted-Printable, ボディーをbase64でエンコードします
Charset.add_charset('shift_jis', Charset.QP, Charset.BASE64, 'shift_jis')

# 文字コードの変換には、"shift_jis"ではなく"cp932"を使っています
Charset.add_codec('shift_jis', 'cp932')

def send(from_addr, to_addr, msg):
    # 上に同じ
    pass

def create_message_for_docomo(from_addr, to_addr, subject, body):
    msg = MIMEText(body, 'plain', 'shift_jis')
    msg['Subject'] = Header(subject, 'shift_jis')
    msg['From'] = from_addr
    msg['To'] = to_addr
    msg['Date'] = formatdate()
    return msg

if __name__ == '__main__':
    from_addr = 'spam@example.com'
    to_addr = 'your_email_address@docomo.ne.jp'
    body = 'NO Erlang\xf9\xa0No Life\xf9\x94'
    subject = 'test'
    msg = create_message_for_docomo(from_addr, to_addr, subject, body)
    send(from_addr, to_addr, msg)

ポイントは、Charset.add_charsetでshift_jisの設定を上書きしていることと、 Charset.add_codecで明示的にコーデックを指定することで、shift_jisのエンコード、 デコードにcp932を使用するようにしていることです。

SoftBankに絵文字入りのメールを送る

一方、SoftBankには、UTF-8を使うと絵文字入りメールを送信できるようです。 SoftBankの技術資料「絵文字一覧」に、各絵文字のユニコードが公開されているので、これを参考に、ユニコード文字列の本文をUTF-8にエンコードしてMIME文書を作ります。 これさえ分かってしまえば、難しいことはありません。

例えば、蛇の絵文字はユニコードだとU+E52Dなので、"アイ ラブ Python[絵文字:蛇]"という文書ならば、次のようにします。

# -*- coding: utf-8 -*-
import smtplib
from email.MIMEText import MIMEText
from email.Header import Header
from email.Utils import formatdate

def send(from_addr, to_addr, msg):
    # 上に同じ
    pass

def create_message_for_softbank(from_addr, to_addr, subject, body):
    msg = MIMEText(body, 'plain', 'utf-8')
    msg['Subject'] = Header(subject, 'utf-8')
    msg['From'] = from_addr
    msg['To'] = to_addr
    msg['Date'] = formatdate()
    return msg

if __name__ == '__main__':
    from_addr = 'spam@example.com'
    to_addr = 'your_email_address@softbank.ne.jp'
    body = u'アイラブ Python\ue52d'.encode('utf-8')
    subject = 'test'
    msg = create_message_for_softbank(from_addr, to_addr, subject, body)
    send(from_addr, to_addr, msg)

DoCoMoの例と違い、Charset.add_charsetを行っていませんが、これは、UTF-8を使用した場合、 初めから「ヘッダーにはQuoted-Printableかbase64, 本文にはbase64」でエンコードするように定義されているためです。

添付付きメール・デコメール

添付ファイル付きのメールを送るには、MIMEMultipartクラスのインスタンスをつくり、 MIMEText, MIMEAudio, MIMEImage等のMIMEオブジェクトをattachしていきます。

添付付きメールを送るだけでは捻りがないので、HTMLメール(DoCoMoのデコメール)の送信例を紹介します。

# -*- coding: utf-8 -*-
import smtplib
from email.MIMEText import MIMEText
from email.MIMEMultipart import MIMEMultipart
from email.Header import Header
from email.Utils import formatdate

def send(from_addr, to_addr, msg):
    # 上に同じ
    pass

HTML_BODY = """<html>
<body>
<img src="cid:a"><br>
Hello World<br>
<img src="cid:c"><br>
Welcome to the world of Python<br>
<img src="cid:b"><br>
<body>
</html>"""

def create_deco(from_addr, to_addr):
    msg = MIMEMultipart()
    msg['Subject'] = Header(u"デコメ", 'ISO-2022-JP')
    msg['From'] = from_addr
    msg['To'] = to_addr
    msg['Date'] = formatdate()

    related = MIMEMultipart('related')
    alt = MIMEMultipart('alternative')
    related.attach(alt)

    content = MIMEText(HTML_BODY, 'html', 'ISO-2022-JP')
    alt.attach(content)

    for filename in ['a', 'b', 'c']:
        fp = file('%s.gif' % filename, 'rb')
        
        # Content-Type: image/gif; name="a.gif"
        img = MIMEImage(fp.read(), 'gif', name=filename)
        
        # HTMLからイメージを参照するにはContent-IDが必要です
        img['Content-ID'] = '<%s>' % filename
        related.attach(img)

    msg.attach(related)
    return msg

if __name__ == '__main__':
    from_addr = 'spam@example.com'
    to_addr = 'your_email_address@docomo.ne.jp'
    msg = create_deco(from_addr, to_addr)
    send(from_addr, to_addr, msg)

送信結果は次のようになります。

デコメールに関しては以前にharukiさん詳しい資料を公開していますので、興味のある方はこちらもご覧下さい。

おまけ

泥臭いサンプル集になってしまいましたが、それなりに動くWebアプリケーション、 Webサイトを作ろうとするならば、もう少し洗練された解決策が必要です。

  • Twistedtwisted.mail.smtpを使えば、非同期IOを利用して効率よくメールの送信を行えそうです。
  • 開発版Djangodjango.core.mailモジュールは大幅にリファクタリングされて、メッセージの作成と送信を分離できるようになりました。Djangoをインストールしておけば、もちろんDjangoのテンプレートエンジンを利用できるので、「テンプレートからメッセージの作成」「MIME文書作成」「送信」という処理をきれいに行うことができそうです。email, smtplibパッケージの使用例としても参考になります。

僕自身まだ研究中で、いろいろ試行錯誤している段階なのですが、 もう少し整理できたならば、またこのブログで紹介したいと思います。

また、自分自身いろいろ調査していて、 Python標準の「ライブラリリファレンス」 がやはり一番参考になると実感しています。こちらも合わせてどうぞ。

SaaS提供の高性能CMS RCMS
SaaS提供の高性能CMS


About Python

ブログ「ウノウラボ Unoh Labs」のカテゴリ「Python」に投稿されたすべてのエントリーのアーカイブのページです。過去のものから新しいものへ順番に並んでいます。

前のカテゴリはOverseasです。

次のカテゴリはsymfonyです。

他にも多くのエントリーがあります。メインページアーカイブページも見てください。

ウノウサービス