2016年の振り返りと2017年の目標

昨年も振り返りと目標を立てていたので、今年も行います。

 

目標の振り返り

2015年の振り返りと2016年の目標 - メモ的な思考的なで立てた目標を振り返ってみます。

 

できる限り手を動かして、何かのアプリを作る

手は動かしていましたが、何かのアプリを作ることはできませんでした。

ただ、写経ではあるもののPythonWSGIサーバを作りました。

 
WSGIサーバの作成を通じて、Webサーバの動作やWSGIに関する知識を深めることができました。

そのおかげで最近いろいろと助かっています。

 

SCとる

無事にとれました。

あとは論文のある試験だけなので、これで一段落といったところです。

 

その他

GitHub

f:id:thinkAmi:20161231230606p:plain

 

転職

今までWindowsPython書いていましたが、MacPythonを書くようになりました。ご縁に感謝しています。

まわりはできる方々ばかりで、常に良い刺激が得られる環境となりました。ありがたい限りです。

また、テストコードが必要な環境に置かれているのも良い感じです。

 

人生の夏休み

一ヶ月ほど人生の夏休みをとりました。

いろいろとやったような気もしますが、何もできなかったような気もします。

それでも、お世話になった方々へのご挨拶などができ、気持ちの切り替えにつながりました。

 

勉強会

2015年に比べ、勉強会への参加が増えました。

  1. デブサミ2016の二日目に行ってきました #devsumi - メモ的な思考的な
  2. Tokyo ComCamp 2016 powered by MVPs に参加しました #JCCMVP - メモ的な思考的な
  3. Android Study Jams&機械学習予習会に行ってきました #GDG信州 - メモ的な思考的な
  4. Google I/O報告会 2016 in 信州に参加しました #io16jp #GDG信州 - メモ的な思考的な
  5. #stapy #glnagano 第7回 Python勉強会 in GEEKLAB.NAGANOに参加しました - メモ的な思考的な
  6. #gcpug #glnagano GCPUG信州 キックオフ勉強会(機械学習/TensorFlow)に参加しました - メモ的な思考的な
  7. #nseg #jxug #glnagano JXUGC No.19 Xamarin ハンズオン 長野大会 featuring NSEG に参加しました - メモ的な思考的な

 

2017年の目標っぽいもの

まだ生活リズムが整っていないこともあり、

  • Pythonの基礎知識を身につける
  • MacLinuxに慣れる
  • 生活リズムを整える

と、昨年よりもさらに抽象的な目標としたいと思います。

少なくとも生活リズムを整えて身近なイベントには参加したいところです*1

 
そんな感じですが、今年もよろしくお願いします。

*1:が、少なくともあと半年は見通しが立たなかったりします...

Python + pytestで、プレフィクスがアンダースコア2つの関数(プライベート関数)をテストする

pytestにて、プライベート関数のテストで悩んだことがあったため、メモを残します。

なお、今回のテスト対象コードは以下とします。

target.py

def __double_underscore_function():
    return 'double'

 
目次

 

環境

  • Windows10
  • Python 3.6.0 (32bit)
  • pytest 3.0.5

 

プライベート関数のimportについて

通常のコードの場合

Pythonでは変数・関数・メソッド名の先頭に_ (アンダースコア)があると、それらはプライベートなものとして扱われます。

 
ただ、プライベート関数の場合

standard_usage.py

from target import __double_underscore_function
import target

def main():
    print(f'from import: {__double_underscore_function()}')
    print(f'import: {target.__double_underscore_function()}')

if __name__ == '__main__':
    main()

と関数の中で使った場合でも

>python standard_usage.py
from import: double
import: double

と正常に動作します。

 
一方、

from target import __double_underscore_function

class Main(object):
    def run_with_import_from(self):
        print(f'from import: {__double_underscore_function()}')

    def run_with_import(self):
        print(f'import: {target.__double_underscore_function()}')

if __name__ == '__main__':
    m = Main()
    m.run_with_import()

と、クラスの中で使った場合

  • run_with_import_from()の場合、NameError: name '_Main__double_underscore_function' is not defined
  • run_with_import()の場合、AttributeError: module 'target' has no attribute '_Main__double_underscore_function'

という例外が送出されます。

マングリングっぽい動きです。
9.6. プライベート変数 | 9. クラス — Python 3.5.2 ドキュメント

 

テストコードの場合

テストコードの場合も同様で、

test_pytest_ver.py

import pytest
from target import __double_underscore_function
import target


class Test_function(object):
    def test_double_underscore_prefix_function_using_from_import(self):
        assert __double_underscore_function() == 'double'

    def test_double_undersocre_prefix_function_using_import(self):
        assert target.__double_underscore_function() == 'double'

と書くと、

>pytest test_pytest_ver.py
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\dir, inifile: pytest.ini
collected 2 items

test_pytest_ver.py FF

================================== FAILURES ===================================
___ Test_function.test_double_underscore_prefix_function_using_from_import ____

self = <test_pytest_ver.py.Test_function object at 0x04530930>

    def test_double_underscore_prefix_function_using_from_import(self):
>       assert __double_underscore_function() == 'double'
E       NameError: name '_Test_function__double_underscore_function' is not defined

test_pytest_ver.py.py:8: NameError
______ Test_function.test_double_undersocre_prefix_function_using_import ______

self = <test_pytest_ver.py.Test_function object at 0x045274D0>

    def test_double_undersocre_prefix_function_using_import(self):
>       assert target.__double_underscore_function() == 'double'
E       AttributeError: module 'target' has no attribute '_Test_function__double_underscore_function'

test_pytest_ver.py.py:11: AttributeError
========================== 2 failed in 0.19 seconds ===========================

エラーになり、テストが正常に実行できません。

 

対応

手元で動かしたところ、以下のどちらかの方法で対応できそうでした(ただ、他にもより良い方法があれば知りたいです)。

  • クラスの外側でテストする
  • import時にasエイリアスを付ける

 

クラスを使わないテストコードにする

クラスによるグループ化を諦めて、クラスを使わないテストコードにします。

test_pytest_ver.py

from target import __double_underscore_function

def test_double_underscore_prefix_function_using_from_import():
    assert __double_underscore_function() == 'double'

実行してみます。

>pytest test_pytest_ver.py
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\dir, inifile: pytest.ini
collected 1 items

test_pytest_ver.py .

========================== 1 passed in 0.05 seconds ===========================

テストできました。

 

import時にasエイリアスを付ける

クラスでグループ化したい場合は、import ... asエイリアスを付けたテストコードにします。

test_pytest_ver.py

from target import __double_underscore_function as double_underscore_function

class Test_function(object):
    def test_double_undersocre_prefix_function_using_from_import_alias(self):
        assert double_underscore_function() == 'double'

実行してみます。

>pytest test_pytest_ver.py
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\dir, inifile: pytest.ini
collected 1 items

test_tmp.py .

========================== 1 passed in 0.05 seconds ===========================

テストできました。

 

その他

プレフィクスがアンダースコア1つの場合

マングリングは働かないため、普通にimportしても動作します。

そのため、

# テスト対象のコード
def _single_underscore_function():
    return 'single'

というテスト対象コードに対して

# テストコード
from target import _single_underscore_function

class Test_function(object):
    def test_single_underscore_prefix_function_using_from_import(self):
        assert _single_underscore_function() == 'single'

と書けば動作します。

 

標準モジュールunittestの場合

unittestではunittest.TestCaseを継承してテストコードを書く必要があります。

そのため、import ... asとする方法しかなさそうです。

import unittest
from target import __double_underscore_function
import target
from target import __double_underscore_function as double_underscore_function


class Test_function(unittest.TestCase):
    @unittest.expectedFailure
    def test_double_underscore_prefix_function_using_from_import(self):
        self.assertEqual(__double_underscore_function(), 'double')
        # => NameError: name '_Test_function__double_function' is not defined

    @unittest.expectedFailure
    def test_double_undersocre_prefix_function_using_import(self):
        assert target.__double_underscore_function() == 'double'
        # => AttributeError: module 'target' has no attribute '_Test_function__double_underscore_function'

    def test_double_undersocre_prefix_function_using_from_import_alias(self):
        self.assertEqual(double_underscore_function(), 'double')
        # => pass


if __name__ == '__main__':
    unittest.main()

 

ソースコード

GitHubに上げました。test_double_underscore_prefix_moduleディレクトリ以下が今回のファイルです。
thinkAmi-sandbox/python_pytest-sample

Pythonのテストコードで、モジュールをモックに差し替える

Pythonにて、開発環境に無いモジュールをimportしているプロダクションコードに対して、テストコードを書く機会がありました。

ただ、テストコードにてモジュールをモックに差し替える方法で悩んだため、メモを残します。

 
目次

 

環境

 

対応

以下を参考に、sys.modules辞書の該当モジュールをモックに差し替えます。
python - How to mock an import - Stack Overflow

 
例えば、

# 開発環境で使えないモジュールたち
from disabled_package import disabled_module
from disabled_package.disabled_module import disabled_submodule

class Target(object):
    def square(self, value):
        # モジュールに含まれる関数(call_function())や定数(CONST)を使用
        disabled_module.call_function(disabled_submodule.CONST)

        return value ** 2

というコードがあり、モジュール

  • disabled_package
  • disabled_package.disabled_module

が開発環境で使えないとします。

 
この状態で

import pytest
from target import Target

class Test_Target(object):
    def test_square(self):
        # Arrange
        sut = Target()
        # Act
        actual = sut.square(2)
        # Assert
        assert actual == 4

というテストコードを作成・実行しても、

(env) >pytest
============================= test session starts =============================
platform win32 -- Python 3.5.2, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: path\to\python_mock_sample, inifile: pytest.ini
collected 0 items / 1 errors

=================================== ERRORS ====================================
_______________ ERROR collecting mocking_module/test_target.py ________________
ImportError while importing test module 'path\to\python_mock_sample\mocking_module\test_target.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
test_target.py:11: in <module>
    from target import Target
target.py:1: in <module>
    from disabled_package import disabled_module
E   ImportError: No module named 'disabled_package'
!!!!!!!!!!!!!!!!!!! Interrupted: 1 errors during collection !!!!!!!!!!!!!!!!!!!
=========================== 1 error in 0.33 seconds ===========================

失敗します。

 
そのため、テストコードを修正し、

import pytest
from unittest.mock import Mock

# モジュールをモックへ差し替えるように追加
import sys
sys.modules['disabled_package'] = Mock()
sys.modules['disabled_package.disabled_module'] = Mock()

from target import Target

class Test_Target(object):
    def test_square(self):
        # Arrange
        sut = Target()
        # Act
        actual = sut.square(2)
        # Assert
        assert actual == 4

テスト対象コードのimport前に、開発環境で使えないモジュールをモックへと差し替えます。

 
その後、テストを実行したところ、

(env) >pytest
============================= test session starts =============================
platform win32 -- Python 3.5.2, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: D:\Sandbox\python_mock_sample, inifile: pytest.ini
collected 1 items

test_target.py .

========================== 1 passed in 0.09 seconds ===========================

成功しました。

 
ちなみに、sys.modules

ロード済みモジュールのモジュール名とモジュールオブジェクトの辞書。

sys.modules | 28.1. sys — システムパラメータと関数 — Python 2.7.x ドキュメント

とのことです。

 
確認のため

import sys
import pytest

def main():
    print(sys.modules['pytest'])

を作成・実行してみたところ、

<module 'pytest' from 'path\\to\\python_mock_sample\\env\\lib\\site-packages\\pytest.py'>

と、モジュールオブジェクトが入っていました。

これにより、モジュールオブジェクトをモックに差し替えられることが分かりました。

 

ソースコード

GitHubに上げました。e.g_mocking_moduleディレクトリの中が今回のコードです。
thinkAmi-sandbox/python_mock-sample

DjangoにDjangoミドルウェアとWSGIミドルウェアを組み込んでみた

この記事は「Django Advent Calendar 2016 - Qiita」の13日目の記事です。

 
Djangoにはミドルウェアというフレームワークがあるため、リクエスト/レスポンス処理をフックして処理を追加できます。
Middleware | Django documentation | Django

また、DjangoWSGI規格に沿っていることから、WSGIミドルウェアでも処理を追加できます。
第3回 WSGIミドルウェアの作成:WSGIとPythonでスマートなWebアプリケーション開発を|gihyo.jp … 技術評論社

 
そこで今回は、

をそれぞれ作成し、Djangoに組み込んでみます。

 
目次

 

環境

 

ミドルウェアを組み込む前のDjangoアプリ

http://localhost:8000/myapp/にアクセスすると「Hello world」を表示するDjangoアプリを用意します。

myproject/urls.py

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^myapp/', include('myapp.urls', 'my')),
]

myapp/urls.py

urlpatterns = [
    url(r'^$',
        MyAppView.as_view(),
        name='index',
    ),
]

myapp/views.py

from django.views import View
from django.http import HttpResponse

class MyAppView(View):
    def get(self, request, *args, **kwargs):
        print('called: MyAppView')
        return HttpResponse('Hello world')

 

Djangoミドルウェアの組み込み

Django1.10以降の書き方のDjangoミドルウェアを組み込む

Django1.10より、Djangoミドルウェアの書き方が変更されました。そこで今回は1.10以降のDjangoミドルウェアの作法に従って書いてみます。

 
今回は各タイミングでprint()するミドルウェアを作成しました。

myproject/django_middleware_in_project.py

class DjangoMiddlewareInProject(object):
    def __init__(self, get_response):
        self.get_response = get_response
        print('proj: one-time cofiguration')

    def __call__(self, request):
        print('proj: before request')
        response = self.get_response(request)
        print('proj: after request')
        
        return response

 
MIDDLEWAREに追加します。

myproject/settings.py

MIDDLEWARE = [
    ...
    'myproject.django_middleware_in_project.DjangoMiddlewareInProject',
]

 
runserverし、http://localhost:8000/myapp/にアクセス後のログを見ます。

# 起動直後
(env) >python manage.py runserver
...
Django version 1.10.4, using settings 'myproject.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.
proj: one-time cofiguration
proj: one-time cofiguration

# http://localhost:8000/myapp/にアクセス後
proj: before request
called: MyAppView
proj: after request

 
各タイミングでDjangoミドルウェアが動作していることが分かりました*1

 

複数のDjangoミドルウェアの動作順を確認する

複数Djangoミドルウェアを組み込んだ場合の動作順が気になったため、試してみます。

先ほど作成したDjangoMiddlewareInProjectに加え、それと同じように動作するミドルウェア

  • myapp/django_middleware_in_app1.py
  • myapp/django_middleware_in_app2.py

として用意します。

また、MIDDLEWAREの設定を以下とします。

MIDDLEWARE = [
    ...
    'myapp.django_middleware_in_app1.DjangoMiddlewareInApp1',
    'myproject.django_middleware_in_project.DjangoMiddlewareInProject',
    'myapp.django_middleware_in_app2.DjangoMiddlewareInApp2',
]

 
runserverし、http://localhost:8000/myapp/にアクセス後のログを見ます。

(env) >python manage.py runserver
...
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
app1: before request
proj: before request
app2: before request
called: MyAppView
app2: after request
proj: after request
app1: after request

 
配置場所(myproject/ or myapp/)に関係なく、組み込み順(app1 > project > app2)とは逆順(app2 > project > app1)で動作するようです。

 

WSGIミドルウェアの組み込み

wsgi_lineprofの組み込み

以下を参考に、WSGIミドルウェアwsgi_lineprofを組み込みます。
How to deploy with WSGI | Django documentation | Django

 
念のため、wsgi_lineprofのソースコードを確認すると、def __call__(self, env, start_response):がありました(このあたり)。

そのため、WSGIミドルウェアとしてDjangoに組み込めそうです。

 
まずはpipでインストールします。Windowsでも問題なくインストールできました。

(env) >pip install wsgi_lineprof
...
Successfully installed wsgi-lineprof-0.2.0

(env) >pip list
Django (1.10.4)
pip (7.1.2)
setuptools (18.2)
six (1.10.0)
wheel (0.24.0)
wsgi-lineprof (0.2.0)

 
続いて、WSGIミドルウェアを組み込みます。

myproject/wsgi.py

import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
application = get_wsgi_application()

# 以下を追加
from wsgi_lineprof.middleware import LineProfilerMiddleware
application = LineProfilerMiddleware(application)

 
runserverしてhttp://localhost:8000/myapp/にアクセス後のログを見ます。

...
File: path\to\django_project_dir\env\lib\site-packages\django\core\handlers\wsgi.py
Name: __init__
Total time: 6.1594e-06 [sec]
  Line      Hits         Time  Code
===================================
    32                             def __init__(self, stream, limit, buf_size=64 * 1024 * 1024):
    33         1            4          self.stream = stream
    34         1            2          self.remaining = limit
    35         1            2          self.buffer = b''
    36         1            4          self.buf_size = buf_size

[**/Dec/2016 **:**:**] "GET /myapp/ HTTP/1.1" 200 11

 
wsgi_lineprofが動作しています。

 

ミドルウェアでの例外ハンドリングについて

Djangoアプリで送出した例外のハンドリングについて

以前、WSGIアプリの例外をハンドリングするWSGIミドルウェアを作りました。
WSGIアプリの例外をハンドリングするWSGIミドルウェア | Pythonで、WSGIミドルウェアを作ってみた - メモ的な思考的な

 
そこで、今回も同様のWSGIミドルウェアを組み込んでみます。

まずは例外を出すViewを作成します。

myapp/views.py

class MyExceptionView(View):
    def get(self, request, *args, **kwargs):
        print('called: MyExceptionView')
        raise AssertionError(request)

        return HttpResponse('raised exception')

myapp/urls.py

urlpatterns = [
    ...
    url(r'^exception$',
        MyExceptionView.as_view(),
        name='exception',
    )
]

 
続いて、例外をハンドリングするWSGIミドルウェアを作成します。

myporject/wsgi_middleware_exception_handling.py

class WSGIMiddlewareExceptionHandling(object):
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        print("called: WSGIMiddlewareExceptionHandling")

        try:
            return self.app(environ, start_response)
        except:
            print('handled exception by WSGIMiddlewareExceptionHandling')
            start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
            return [b"raised exception."]

 
最後にDjangoに組み込みます。

myproject/wsgi.py

application = get_wsgi_application()

# 以下を追加
from .wsgi_middleware_exception_handling import WSGIMiddlewareExceptionHandling
application = WSGIMiddlewareExceptionHandling(application)

 
runserverし、http://localhost:8000/myapp/exceptionにブラウザでアクセスすると、

AssertionError at /myapp/exception
<WSGIRequest: GET '/myapp/exception'>

Djangoのエラーページが表示されました。

 
ログを見ます。

(env) D:\Sandbox\Django_WSGI_middleware_sample>python manage.py runserver
...
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
app2: one-time cofiguration
proj: one-time cofiguration
app1: one-time cofiguration
called: WSGIMiddlewareErrorHandling
app1: before request
proj: before request
app2: before request
called: MyExceptionView
Internal Server Error: /myapp/exception
Traceback (most recent call last):
...
AssertionError: <WSGIRequest: GET '/myapp/exception'>
app2: after request
proj: after request
app1: after request
[**/Dec/2016 **:**:**] "GET /myapp/exception HTTP/1.1" 500 75449

「WSGIMiddlewareErrorHandling」は起動しているようです。

一方、「handled exception by WSGIMiddlewareErrorHandling」が表示されていないため、Djangoアプリが送出した例外をWSGIミドルウェアではハンドリングできていないようです。

 
Djangoのドキュメントを読むと、以下に例外ハンドリングに関する記載がありました。

 
意訳すると、

とようです。

 
そこで、その挙動を確認するDjangoミドルウェアを作成してみます。

myproject/django_middleware_exception_handling.py

class DjangoMiddlewareExceptionHandling(object):
    def __init__(self, get_response):
        self.get_response = get_response
        print('django_middleware: one-time cofiguration')

    def __call__(self, request):
        print('django_middleware: before request')

        try:
            response = self.get_response(request)
            print('Django response data:{}'.format(response))
        except:
            print('handled exception by DjangoMiddlewareExceptionHandling')

        print('django_middleware: after request')
        return response

    def process_exception(self, request, exception):
        print('handled exception by process_exception in DjangoMiddlewareExceptionHandling')

 
Djangoミドルウェアとして登録します。

myproject/settings.py

MIDDLEWARE = [
    ...
    # DjangoMiddlewareExceptionHandlingのみ有効化
    # 'myapp.django_middleware_in_app1.DjangoMiddlewareInApp1',
    # 'myproject.django_middleware_in_project.DjangoMiddlewareInProject',
    # 'myapp.django_middleware_in_app2.DjangoMiddlewareInApp2',
    'myproject.django_middleware_exception_handling.DjangoMiddlewareExceptionHandling',
]

 
再度http://localhost:8000/myapp/exceptionにアクセスし、ログを見ます。

(env) >python manage.py runserver
...
django_middleware: one-time cofiguration
django_middleware: one-time cofiguration
called: WSGIMiddlewareExceptionHandling
django_middleware: before request
called: MyExceptionView

# process_exception()の動作ログ
handled exception by process_exception in DjangoMiddlewareExceptionHandling
Internal Server Error: /myapp/exception
Traceback (most recent call last):
...
AssertionError: <WSGIRequest: GET '/myapp/exception'>

# get_response()コールバックの戻り値
Django response data:<HttpResponse status_code=500, "text/html">
django_middleware: after request
[**/Dec/2016 **:**:**] "GET /myapp/exception HTTP/1.1" 500 75197

 
これらより、

  • get_response()コールバックの戻り値に、ステータスコードなどがある
  • 例外をexceptした時のログなし
  • process_exception()が動作したときのログあり

ということが分かりました。

 
Djangoアプリの例外はDjangoミドルウェアでハンドリングできそうです。

 

WSGIミドルウェアで送出した例外のハンドリングについて

DjangoミドルウェアWSGIミドルウェアの挙動をみてみます。

まずは例外を送出するWSGIミドルウェアを作成します。

wsgi_middleware_raise_exception.py

class WSGIMiddlewareRaiseException(object):
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        print('called: WSGIMiddlewareRaiseException')
        raise AssertionError

        self.app(environ, start_response)

 
Djangoに組み込みます。

myproject/wsgi.py

from .wsgi_middleware_raise_exception import WSGIMiddlewareRaiseException
application = WSGIMiddlewareRaiseException(application)

from .wsgi_middleware_exception_handling import WSGIMiddlewareExceptionHandling
application = WSGIMiddlewareExceptionHandling(application)

 
runserverし、http://localhost:8000/myapp/exceptionにブラウザでアクセスすると、

raised exception.

と表示されました。

 
ログを見ます。

(env) D:\Sandbox\Django_WSGI_middleware_sample>python manage.py runserver
...
django_middleware: one-time cofiguration
django_middleware: one-time cofiguration
called: WSGIMiddlewareExceptionHandling
called: WSGIMiddlewareRaiseException
handled exception by WSGIMiddlewareExceptionHandling
[**/Dec/2016 **:**:**] "GET /myapp/exception HTTP/1.1" 500 17

 
これらより、

ということが分かりました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/Django_WSGI_middleware-sample

*1:起動直後に__init__()が2回呼ばれているのが気になりますけれど、環境のせいでしょうか コメントをいただきました。django.contrib.staticfilesがWSGIアプリケーションだったため、staticfilesとmyappの2回分呼ばれていました

Django + Handsontable.jsを使って、Excel風な入力画面を作ってみた

この記事は「Django Advent Calendar 2016 - Qiita」の11日目の記事です。

 
最近、DjangoExcel風な入力画面を持つWebアプリを作る機会がありました。

何か良い方法がないかを調べたところ、jQueryへの依存がないJavaScriptライブラリHandsontableを知りました*1
handsontable/handsontable: Handsontable Community Edition - A JavaScript/HTML5 Spreadsheet Library for Developers

 
公式ドキュメントが充実している他、日本語のQiita記事も分かりやすく書かれていました。

 
ただ、実際にやってみたところ色々と悩んだたため、メモを残しておきます。

 
目次

 
 

環境

 
完成イメージはこんな感じです。

f:id:thinkAmi:20161210233021p:plain

 

Django側の実装

URLとModel

プロジェクトとアプリを作ります。

(env) >pip install django
(env) >django-admin startproject myproject .

(env) >python manage.py startapp myapp

 

myproject/urls.py

from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    # addition for myapp
    url(r'^myapp/', include('myapp.urls', 'my')),
]

 

myapp/urls.py

urlpatterns = [
    # 一覧ページ
    url(r'^records/$',
        IndexListView.as_view(),
        name='record-index',
    ),
    # 新規作成ページ
    url(r'^records/new$', 
        TemplateView.as_view(template_name='myapp/detail.html'),
        name='record-new'
    ),
    # 編集ページ
    url(r'^records/(?P<pk>[0-9]+)/edit$', 
        TemplateView.as_view(template_name='myapp/detail.html'),
        name='record-edit'
    ),
    # Ajax用
    url(r'^ajax/records/(?P<pk>[0-9]+)$',
        HandsonTableView.as_view(),
        name='ajax',
    ),
]

 

myapp/models.py

from django.db import models

class Header(models.Model):
    update_at = models.DateTimeField('UpdateAt')

class Detail(models.Model):
    header = models.ForeignKey(Header)
    purchase_date = models.DateField('Date')
    name = models.CharField('Name', max_length=255)
    price = models.DecimalField('Price', max_digits=10, decimal_places=0)

 

View

一覧用・新規作成用・編集用のViewはurls.pyで設定したため、views.pyにはAjax用を実装します。

基底のViewは

  • HTMLテンプレートは不要
  • GETとPOSTへのレスポンスが返せればいい

を考慮し、django.views.Viewを使います*2

myapp/views.py

class HandsonTableView(View):
    def get(self, request, *args, **kwargs):
        ...
    def post(self, request, *args, **kwargs):
        ...

 

Ajax用ViewのGETを実装

get()メソッドには

  • ModelをJSON化する
  • JSONをレスポンスとして返す

を実装します。

 
ModelをJSON化する方法について調べたところ、django.core.serializersを使うのが良さそうでした。

ただ、そのままではHandsontableに不要な項目がJSONに含まれてしまいます。そのため、以下を参考に拡張シリアライザを作ります。
Django標準のjson serializerをカスタマイズする - 勉強不足

myproject/serializers.py

class Serializer(Serializer):
    def get_dump_object(self, obj):
        return self._current

    def start_serialization(self):
        super(Serializer, self).start_serialization()
        # 日本語対応
        self.json_kwargs["ensure_ascii"] = False
        # タブインデントは2にしておく
        self.json_kwargs['indent'] = 2

 
合わせて、拡張シリアライザの設定をsettings.pyに追加します。

myproject/settings.py

SERIALIZATION_MODULES = {
    "handsontablejson": "myproject.serializers"
}

 
続いて、JSONをレスポンスとして返す方法について調べたところ、django.http.JsonResponseがありました。

ただ、ソースコードを読んでみると、

とのことで、JsonResponseと拡張シリアライザを使うとjson_dumps()が2回呼ばれてしまいます。

そのため、今回はHttpResponseと拡張シリアライザを使います。

myapp/views.py

def get(self, request, *args, **kwargs):
    details = Detail.objects.filter(header__pk=self.kwargs.get('pk')).select_related().all()

    return HttpResponse(
        serializers.serialize('handsontablejson', details),
        content_type='application/json'
    )

 

Ajax用ViewのPOSTを実装

myapp/views.py

def post(self, request, *args, **kwargs):
    body_unicode = request.body.decode('utf-8')
    body = json.loads(body_unicode)

    header = self.update_header(self.kwargs.get('pk'))

    # 明細部分は、DELETE&INSERTで作る
    Detail.objects.filter(header=header).delete()

    for b in body:
        Detail(
            header=header,
            purchase_date=b.get('purchase_date'),
            name = b.get('name'),
            price = b.get('price'),
        ).save()

    return HttpResponse('OK')


def update_header(self, pk):
    if int(pk) == NEW_PAGE_ID:
        new_header = Header(update_at = timezone.now())
        new_header.save()
        return new_header

    header = Header.objects.filter(pk=pk).first()
    header.update_at = timezone.now()
    header.save()
    return header

 

Viewのテンプレートを用意

myapp/templates/myapp/header_list.html

<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="utf-8" />
    <title>My Site</title>
</head>
<body>
    <div id="main">
        <h1>一覧</h1>
        <a href="{% url 'my:record-new' %}">新規登録</a>
        <table>
            <tr>
                <th>更新日時</th>
                <th>操作</th>
            </tr>
            {% for record in object_list %}
                <tr>
                    <td>{{ record.update_at }}</td>
                    <td><a href="{% url 'my:record-edit' record.id %}">編集</a></td>
                </tr>
            {% endfor %}
        </table>
    </div>
</body>
</html>

 

myapp/templates/myapp/edit.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Handsontable</title>
    {% load static %}
    <link rel="stylesheet" href="{% static "libs/handsontable.css" %}" />
  </head>
  <body>
    <!--id=gridにHandsontableの値が設定される-->
    <div id="grid">
    </div>

    <button type="button" id="add">行の追加</button>
    <button type="button" id="save">保存</button>
    <a href="{% url 'my:record-index' %}">一覧へ戻る</a>

    <script src="{% static "libs/handsontable.full.min.js" %}"></script>
    <script src="{% static "libs/js.cookie.js" %}"></script>
    <script src="{% static "js/myscript.js" %}"></script>
  </body>
</html>

 

静的ファイルの設定

collectstaticを使う場合に備えて、settings.pyにSTATIC_ROOTを設定します。

myproject/settings.py

STATIC_ROOT = os.path.join(BASE_DIR, 'static')

 
以上でDjango側の実装は完了です。

 

JavaScript側の実装

ライブラリの配置

 

Handsontableオブジェクトの設定

データバインディングする時はdataSchemaの設定が必要です。

ただ、今回はcolumnsでtype指定も同時に行ったせいか、dataSchemaの設定をしなくても動作しました。

myapp/static/js/myscript.js

var data = [];

var grid = document.getElementById('grid');

var table = new Handsontable(grid, {
    data: data,

    columns: [
        { data: 'purchase_date', type: 'text' },
        { data: 'name', type: 'text' },
        { data: 'price', type: 'numeric' },
    ],
    
    // 列ヘッダを表示
    colHeaders: ["日付", "名前", "価格"],
    // 行ヘッダを表示(1からの連番)
    rowHeaders: true,
    // 列幅
    colWidths: [120, 200, 100]
});

 

イベントリスナーの追加

今回、

  • loadした時に対象データを読み込む
  • ボタンを押したときにHandsontableのhookを発火する

というイベントリスナーを実装します。

 
myapp/static/js/myscript.js

const NEW_PAGE_ID = 0;
var id = (() => {
    var found = location.pathname.match(/\/myapp\/records\/(.*?)\/edit$/);
    // 新規作成の時は、便宜上id=0とみなして処理する
    return found ? found[1] : NEW_PAGE_ID;
})();

document.addEventListener("DOMContentLoaded", () => {
    // loadした時に、Handsontableの初期値を取得・表示
    fetch(`/myapp/ajax/records/${id}`, {
        method: 'GET',
    }).then(response => {
        console.log(response.url, response.type, response.status);

        response.json().then(json => {
            for (var i = 0; i < json.length; i++){
                data.push({
                    purchase_date: json[i].purchase_date,
                    name: json[i].name,
                    price: json[i].price,
                });
            }

            table.render();
        });
    }).catch(err => console.error(err));
}, false);

document.getElementById('save').addEventListener('click', () => {
    // 保存ボタンを押したときに発火するHandsontableのhook
    Handsontable.hooks.run(table, 'onSave', data);
});

document.getElementById('add').addEventListener('click', () => {
    // 行追加を押したときに発火するHandsontableのhook
    Handsontable.hooks.run(table, 'onAddRow', data);
});

 

AjaxでPOSTする時のCSRF対策をパスする方法

Djangoのデフォルトではdjango.middleware.csrf.CsrfViewMiddlewareが組み込んであるため、POST時はCSRF対策をパスする必要があります。

そのため、公式ドキュメントを参考に、

  • Cookieからキーがcsrftokenの値を取得
  • POST時にX-CSRFTokenとしてHTTPリクエストヘッダに追加

を実装します。

myapp/static/js/myscript.js

Handsontable.hooks.add('onAddRow', mydata => {
    // 行の追加
    table.alter('insert_row', data.length);
});

Handsontable.hooks.add('onSave', mydata => {
    // 保存時の処理
    // CSRF対策のCookieを取得する
    // https://docs.djangoproject.com/en/1.10/ref/csrf/#ajax
    var csrftoken = Cookies.get('csrftoken');
    
    // Djangoのdjango.middleware.csrf.CsrfViewMiddlewareを使っているため、
    // POST時にmodeとcredentialsとX-CSRFTokenヘッダを付ける
    fetch(`/myapp/ajax/records/${id}`, {
        method: 'POST',
        headers: {
            'content-type': 'application/json',
            'X-CSRFToken': csrftoken
        },
        mode: 'same-origin',
        credentials: 'same-origin',
        body: JSON.stringify(mydata),
    }).then(response => {
        console.log(response.url, response.type, response.status);

        if (response.status == '200'){
            window.alert('保存しました');
            // 一覧にリダイレクト
            location.href = '/myapp/records';
        }
        else{
            window.alert('保存できませんでした');
        }
    }).catch(err => console.error(err));
});

 
以上でJavaScript側の実装も完了です。

 

確認

(env) >python manage.py makemigrations
(env) >python manage.py migrate
(env) >python manage.py runserver

で動作を確認します。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/Django_Handsontable-sample

 
なお、

  • handsontable.js
  • js_cookie.js

のライブラリは上記GitHubに含んでいませんので、試す際にはダウンロードしてmyapp/static/libの下に置いてください。

*1:有償のPro版と無償のFree版があります

*2:Django1.10より、django.views.generic.Viewの他にdjango.views.Viewをimportできるようになったようです:https://docs.djangoproject.com/en/1.10/ref/class-based-views/base/#django.views.generic.base.View

Pythonで、WSGIミドルウェアを作ってみた

WSGIアプリケーションを作る中で、WSGIミドルウェアの存在を知りました。
第3回 WSGIミドルウェアの作成:WSGIとPythonでスマートなWebアプリケーション開発を|gihyo.jp … 技術評論社

 
そこで今回、WSGIミドルウェアを作ってみることにしました。

目次

 

環境

  • Windows10
  • Python3.5.2

   

ターミナルに出力するだけのWSGIミドルウェア

関数ベースのWSGIアプリの場合
# func_with_wsgi_middleware.py
from wsgiref.simple_server import make_server

# WSGI Middleware
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware")
        return self.app(environ, start_response)

# WSGI app
def hello_app(environ, start_response):
    print("WSGI app")
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, middleware."]

# Middlewareの追加
application = Middleware(hello_app)

if __name__ == "__main__":
    # Middlewareが含まれるWSGIアプリを起動
    httpd = make_server('', 8000, application)
    print("Serving on port 8000...")
    httpd.serve_forever()

 
実行すると、

$ python func_with_wsgi_middleware.py
Middleware
WSGI app

がターミナルに表示されました。WSGIミドルウェアが動作しているようです。

 

クラスベースのWSGIアプリの場合
# class_with_wsgi_middleware.py
# ミドルウェアは同じ

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("WSGI app")
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, class with middleware."]

# Middlewareを追加
application = WsgiApp()
application = Middleware(application)

で関数ベースと同じことができます。

 

複数のWSGIミドルウェアWSGIアプリに載せる場合

# multi_middleware.py
class Middleware1(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware1")
        return self.app(environ, start_response)

class Middleware2(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware2")
        return self.app(environ, start_response)

class Middleware3(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware3")
        return self.app(environ, start_response)

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("WSGI app")
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, class with middleware."]
    
application = WsgiApp()
application = Middleware1(application)
application = Middleware2(application)
application = Middleware3(application)

 
実行すると、

$ python multi_middleware.py
Middleware3
Middleware2
Middleware1
WSGI app

と、追加したのとは逆順でWSGIミドルウェアが動作しました。

 

レスポンスを返すWSGIミドルウェア

# middleware_response.py
# WSGI Middleware
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        if environ['QUERY_STRING']:
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"respond by middleware."]
        return self.app(environ, start_response)

# WSGIアプリは今までのものと同様

application = WsgiApp()
application = Middleware(application)

とすると、

  • クエリ文字列が無い場合、ブラウザにrespond by app.と表示
  • クエリ文字列がある場合、ブラウザにrespond by middleware.と表示

となります。

 

レスポンスを返すのはWSGIミドルウェアだけの場合

WSGIアプリとWSGIミドルウェアがあり、両方実行されるがレスポンスはWSGIミドルウェアのみというケースを考えます。

最初、

# only_middleware_response_without_app.py
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("middle ware")
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"respond by middleware."]

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("wsgi app")

application = WsgiApp()
applicatoin = Middleware(application)

としてみましたが、実行してみると

$ python only_middleware_response_without_app.py
middle ware 

と、"wsgi app"はprintされませんでした。

 
WSGIアプリを実行するには、

# only_middleware_response_with_app.py
class Middleware(object):
    def __call__(self, environ, start_response):
        print("middle ware")

        # WSGI appの処理をしたいときは、レスポンスを返す前に呼び出す
        self.app(environ, start_response)

        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"respond by middleware."]

と、レスポンスを返す前にWSGIアプリを呼び出す必要があります。

 

HTTPヘッダを追加するWSGIミドルウェア

HTTPヘッダを追加するWSGIミドルウェアが欲しかったため調べてみたところ、stackoverflowに情報がありました。
python - How to add http headers in WSGI middleware? - Stack Overflow

# add_header_middleware.py
class Middleware(object):
    def __init__(self, app):
        self.app = app
    
    def __call__(self, environ, start_response):
        # __call__()メソッドの中に、start_responseをカスタマイズする関数を用意する
        # また、これは関数なので、第一引数にはselfを設定しないこと
        def start_response_with_cookie(status_code, headers, exc_info=None):
            print("custom start_response")
            headers.append(('Set-Cookie', "hoge=fuga"))
            return start_response(status_code, headers, exc_info)

        print("Middleware")
        return self.app(environ, start_response_with_cookie)

と、ミドルウェア__call__()メソッドの中にネストして、カスタマイズしたstart_response()関数を用意します。

 
実行すると、

$ python add_header_middleware.py
Middleware
WSGI app
custom start_response

となり、レスポンスヘッダにも以下が出力されていました。

Content-Type: text/plain
Set-Cookie: hoge=fuga

 

WSGIアプリの例外をハンドリングするWSGIミドルウェア

# exception_from_app.py
class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware")
            return self.app(environ, start_response)
        except Exception:
            print("raised exception")
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"raised exception."]

class WsgiApp(object):
    def __call__(self, environ, start_response):
        print("app")
        raise Exception

application = WsgiApp()
application = Middleware(application)

としたところ、

$ python exception_from_app.py
Middleware
app
raised exception

となり、ブラウザには「raised exception.」と表示されました。

 

複数WSGIミドルウェアがあった時の例外の伝播

WSGIアプリで例外が発生した時に、複数WSGIミドルウェアがあった場合はどうなるかを調べるため、

class Middleware1(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware1")
            return self.app(environ, start_response)
        except Exception:
            print("catch middleware1")
            raise

class Middleware2(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware2")
            return self.app(environ, start_response)
        except Exception:
            print("catch middleware2")
            raise

class Middleware3(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        try:
            print("Middleware3")
            return self.app(environ, start_response)
        except Exception:
            print("catch middleware3")
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"catch exception middleware3."]

application = WsgiApp()
application = Middleware1(application)
application = Middleware2(application)
application = Middleware3(application)

としたところ、ブラウザに「raised exception.」と表示されました。

ターミナルには

$ python exception_from_app_with_multi_middleware.py
Middleware3
Middleware2
Middleware1
app
catch middleware1
catch middleware2
catch middleware3

となりました。

アプリの例外では、複数WSGIミドルウェアをさかのぼるようです。

WSGIミドルウェアで例外が出た場合

class Middleware(object):
    def __init__(self, app):
        self.app = app
    def __call__(self, environ, start_response):
        print("Middleware")
        raise Exception

class WsgiApp(object):
    def __call__(self, environ, start_response):
        try:
            print("WSGI app")
            start_response('200 OK', [('Content-Type', 'text/plain')])
            return [b"Hello, class with middleware."]
        except:
            print("exception")

としたところ、ブラウザに「A server error occurred. Please contact the administrator.」と表示されました。

ターミナルには

$ python exception_from_middleware.py
Middleware
Traceback (most recent call last):
#...
  File "exception_from_middleware.py", line 12, in __call__
    raise Exception
Exception

と表示されました。

まだWSGIアプリが呼び出されていないため、WSGIアプリでの例外捕捉はできないようです。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_middleware_sample

mod_wsgiやuWSGIで使われる、デフォルトのWSGI callback名を調べてみた

WSGIアプリの書き方について調べている時、

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

や、

class MyWSGIApplication(object):
    def __call__(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, world."]

application = MyWSGIApplication()

と、applicationというcallback名を使っている例が多かったです。

WSGI callback名の決まりがあるのかを調べたところ、特に決まりはなさそうでした。
python - How do I actually use WSGI? - Stack Overflow

 
そんな中、WSGIアプリをmod_wsgiやuWSGIで動かす時に、デフォルトのWSGI callback名が気になったため、調べてみました。

 

mod_wsgi

applicationです。
WSGICallableObject — mod_wsgi 4.5.8 documentation

 
callback名を変更するには、WSGICallableObjectに設定をすれば良さそうでした。

 

uWSGI

applicationだと思います*1

 
callback名を変更するには、

を使うのが良さそうでした。

 

まとめ

特に指定のない限り、WSGI callback名はapplicationとしておけば良さそうです。

*1:公式ドキュメントには明記されていませんでしたが、Snippetsなどで使われていたため