ぽよメモ

レガシーシステム考古学専攻

Django 2.0 + Channels 2.1 でチュートリアルやっていく(2)

前回のおさらい

poyo.hatenablog.jp

前回は簡単なプロジェクトのセットアップで終わっていました.これから実際にWebSocketを捌く実装をやっていくことになります.

Tutorial Part 2: Implement a Chat Server

chat roomのviewを書く

chat/templates/room.htmlを作成します.下記コードをコピペでOKです.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    <textarea id="chat-log" cols="100" rows="20"></textarea><br/>
    <input id="chat-message-input" type="text" size="100"/><br/>
    <input id="chat-message-submit" type="button" value="Send"/>
</body>
<script>
    var roomName = {{ room_name_json }};

    var chatSocket = new WebSocket(
        'ws://' + window.location.host +
        '/ws/chat/' + roomName + '/');

    chatSocket.onmessage = function(e) {
        var data = JSON.parse(e.data);
        var message = data['message'];
        document.querySelector('#chat-log').value += (message + '\n');
    };

    chatSocket.onclose = function(e) {
        console.error('Chat socket closed unexpectedly');
    };

    document.querySelector('#chat-message-input').focus();
    document.querySelector('#chat-message-input').onkeyup = function(e) {
        if (e.keyCode === 13) {  // enter, return
            document.querySelector('#chat-message-submit').click();
        }
    };

    document.querySelector('#chat-message-submit').onclick = function(e) {
        var messageInputDom = document.querySelector('#chat-message-input');
        var message = messageInputDom.value;
        chatSocket.send(JSON.stringify({
            'message': message
        }));

        messageInputDom.value = '';
    };
</script>
</html>

簡単にコードを読むとjavascriptでWebSocketクライアントを生成し,メッセージの送信を行おうとしていることがわかります.

次にこれを返すviewをchat/views.pyに定義します.

from django.shortcuts import render
from django.utils.safestring import mark_safe
import json


def index(request):
    return render(request, 'chat/index.html', {})


def room(request, room_name):
    return render(request, 'chat/room.html', {
        'room_name_json': mark_safe(json.dumps(room_name))
    })

chat/urls.pyへルーティングを追加します.

from django.urls import path
from . import views


urlpatterns = [
    path('', views.index, name='index'),
    # チュートリアルの元コードは→url(r'^(?P<room_name>[^/]+)/$', views.room, name='room'),
    path('<slug:room_name>/', views.room, name='room'),
]

動かしてみます.

$ python manage.py runserver

127.0.0.1:8000/chat/lobby/へアクセスしてみます.このlobbyというのはチャットルームの名前になります(なんでもいい).
以下の様なページが表示されます.

f:id:pudding_info:20180511002452p:plain

またコンソールに下記の様なエラーが出ます.これは想定内のエラーです.

[2018/05/11 00:04:55] HTTP GET /chat/lobby/ 200 [0.03, 127.0.0.1:63086]
2018-05-11 00:04:55,440 - ERROR - ws_protocol - [Failure instance: Traceback: <class 'ValueError'>: No application configured for scope type 'websocket'
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/defer.py:500:errback
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/defer.py:567:_startRunCallbacks
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/defer.py:653:_runCallbacks
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/defer.py:1442:gotResult
--- <exception caught here> ---
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/defer.py:1384:_inlineCallbacks
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/python/failure.py:422:throwExceptionIntoGenerator
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/daphne/server.py:186:create_application
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/python/threadpool.py:250:inContext
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/python/threadpool.py:266:<lambda>
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/python/context.py:122:callWithContext
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/python/context.py:85:callWithContext
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/channels/staticfiles.py:41:__call__
/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/channels/routing.py:58:__call__
]
Exception in callback AsyncioSelectorReactor.callLater.<locals>.run() at /Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/asyncioreactor.py:287
handle: <TimerHandle when=253833.004511811 AsyncioSelectorReactor.callLater.<locals>.run() at /Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/asyncioreactor.py:287>
Traceback (most recent call last):
  File "/usr/local/var/pyenv/versions/3.6.0/Python.framework/Versions/3.6/lib/python3.6/asyncio/events.py", line 126, in _run
    self._callback(*self._args)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/asyncioreactor.py", line 290, in run
    f(*args, **kwargs)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/twisted/internet/tcp.py", line 289, in connectionLost
    protocol.connectionLost(reason)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/autobahn/twisted/websocket.py", line 128, in connectionLost
    self._connectionLost(reason)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/autobahn/websocket/protocol.py", line 2467, in _connectionLost
    WebSocketProtocol._connectionLost(self, reason)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/autobahn/websocket/protocol.py", line 1096, in _connectionLost
    self._onClose(self.wasClean, WebSocketProtocol.CLOSE_STATUS_CODE_ABNORMAL_CLOSE, "connection was closed uncleanly (%s)" % self.wasNotCleanReason)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/autobahn/twisted/websocket.py", line 171, in _onClose
    self.onClose(wasClean, code, reason)
  File "/Users/pudding/ghq/github.com/pddg/channels_tutorial1/env/lib/python3.6/site-packages/daphne/ws_protocol.py", line 146, in onClose
    self.application_queue.put_nowait({
AttributeError: 'WebSocketProtocol' object has no attribute 'application_queue'

ブラウザのコンソールにも下記の様なエラーが出ているかと思います*1

WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/lobby/' failed: Error during WebSocket handshake: net::ERR_CONNECTION_RESET

Consumerを書く

DjangoがHttpリクエストを受け,urlの設定を元にview関数を呼び出してリクエストを処理するように,channelsはWebSocketのコネクションを受け入れると,consumerを呼び出しイベントを処理します.

今回は非常に簡単な,送られてきた文字列をオウム返しするだけのconsumerを作成します.

chat/consumers.pyを作成します.

from channels.generic.websocket import WebsocketConsumer
import json

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.accept()

    def disconnect(self, close_code):
        pass

    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        self.send(text_data=json.dumps({
            'message': message
        }))

WebsocketConsumerというそのものズバリなクラスを継承し,独自のconsumerを作成します.
receive()で送られてくるjson形式の文字列を処理して中からmessageを取り出し,またjsonに埋め込んで返します.簡単な作りです.

次にchat/routing.pyを新しく作り,以下の様に記述します.

from django.urls import path
from . import consumers


websocket_urlpatterns = [
    # 元のコードは→url(r'^ws/chat/(?P<room_name>[^/]+)/$', consumers.ChatConsumer),
    path('ws/chat/<slug:room_name>/', consumers.ChatConsumer),
]

更に,前回作成しておいたchannels_tutorial1/routing.pyも下記の様に編集します.

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from chat import routing

application = ProtocolTypeRouter({
    # (http->django views is added by default)
    'websocket': AuthMiddlewareStack(
        URLRouter(
            routing.websocket_urlpatterns
        )
    ),
})

このとき,ProtocolTypeRouterはサーバへのアクセスのプロトコルを見て,websocketであればAuthMiddlewareStackに接続を投げます.
AuthMiddlewareStackAuthenticationMiddlewareのような働きをし,認証ユーザへの参照を付与します.その後URLRouterへ渡され,これが接続するパスを見てルーティングするconsumerを決めます.

ではここで動かして確認してみます.

$ python manage.py runserver

同じくhttp://127.0.0.1:8000/chat/lobbyにアクセスし,適当にメッセージを打って送ります.例ではhogeとかfugaとかpiyoとか送ったときの図です.

f:id:pudding_info:20180511010546p:plain

わーい!返ってきた〜〜〜

しかし,別のタブを開いて同じチャットルームを開いても,タブ間でのやりとりはまだできません.これを実行可能にするためにchannel layerを使用します.

channel layerを有効化する

channel layerはconsumerのインスタンスが互いに,またはDjangoのオブジェクトと通信できるようにする一種の通信システムです,channel layerは下記の様な事象を抽象化します.

  • channel.メッセージボックスのようなもので,名前が付けられている.名前を持つ人はここにメッセージを送信できる.
  • group.これは関連するchannelの集まりで,同じように名前が付けられている,名前を持つ人はこのgroupに自由にchannelを追加し,全てのchannelにメッセージを送信できる.

consumerインスタンスはそれぞれ一意な名前が自動生成されており,channel layerで互いに通信できるようになっています.

このchannel layerのバックエンドにRedisを使用します.チュートリアルではdockerでredisサーバを起動していますが,起動できれば何でも良いと思います*2

$ docker run -d -p 6379:6379 redis

channelsでredisを扱うためのパッケージを導入します.

$ pip install channels_redis

settings.pyにchannel backendの設定を記述します.

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}
  • Redisサーバが起動
  • ChannelバックエンドにRedisを指定

までできれば,簡単にコマンドラインで動作を確かめます.

$ python manage.py shell
>>> import channels.layers
>>> channel_layer = channels.layers.get_channel_layer()
>>> from asgiref.sync import async_to_sync
>>> async_to_sync(channel_layer.send)('test_channel', {'type': 'hello'})
>>> async_to_sync(channel_layer.receive)('test_channel')
{'type': 'hello'}

このとき,例えば一度receiveした後に再びreceiveすると入力されるまで待ちます(Control+Cで終了します).

確認が出来たところでconsumerを書き直し,consumerのインスタンス間で通信できるようにします.

from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
import json


class ChatConsumer(WebsocketConsumer):

    def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        # 元のコードは→self.room_group_name = 'chat_%s' % self.room_name
        self.room_group_name = 'chat_{}'.format(self.room_name)

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        # Leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    def receive(self, text_data, **kwargs):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

cunsumerインスタンスは作成時に接続されてきたURLのパスに従ってグループに所属し,グループ内でインスタンス間のメッセージ送受信が出来るようになります.

サーバを起動して確かめます.

$ python manage.py runserver

ブラウザのタブを2つ並べて開き,メッセージを送信します.どちらの画面にも応答が返ってくることが分かります.

f:id:pudding_info:20180511015514g:plain

だんだんWebSocketをやっているという気持ちになってきましたね.次はPart3です.

*1:Vivaldiではこうでした.

*2:homebrewでインストールできます

Django 2.0 + Channels 2.1 でチュートリアルやっていく(1)

はじめに

channelsの公式チュートリアルを順番にやっていこうというだけのものです.Djangoの基礎知識に関しては一切触れません.ご了承ください.
そろそろWebSocketもやらないとね?って天の声に言われたので,今回はTutorial Part 1: Basic Setupからやっていきます.Django 2.0を使用しているので,チュートリアルとところどころコードが違います.

環境構築

あらかじめ断っておくとWindowsではやりません.

仮想環境としてvenvを使用していきます.

$ python3 -V
3.6.0
$ python3 -m venv env
$ source env/bin/activate

必要なライブラリをインストールします

$ pip install --upgrade pip
$ pip install django channels
$ pip freeze
asgiref==2.3.0
async-timeout==2.0.1
attrs==18.1.0
autobahn==18.4.1
Automat==0.6.0
channels==2.1.1
constantly==15.1.0
daphne==2.1.1
Django==2.0.5
hyperlink==18.0.0
idna==2.6
incremental==17.5.0
pytz==2018.4
six==1.11.0
Twisted==18.4.0
txaio==2.10.0
zope.interface==4.5.0

今回作るもの

In this tutorial we will build a simple chat server. It will have two pages:

・ An index view that lets you type the name of a chat room to join.
・ A room view that lets you see messages posted in a particular chat room.

The room view will use a WebSocket to communicate with the Django server and listen for any messages that are posted.

とあるように,チャットアプリケーションを作成します.

仕様

  • 2つのページを作る
    • 参加するチャットルームの名前を入力する初期ページ
    • 特定のルームに投稿されたメッセージを見れるルームページ
  • ルームのページではWebSocketを使用してDjangoサーバとの通信を行う

Tutorial Part1: Basic Setup

アプリケーション初期設定

プロジェクト名はchannels_tutorial1,アプリケーション名はchatとします.

$ django-admin startproject channels_tutorial1
$ cd channels_tutorial1
$ python manage.py startapp chat
$ tree -L 2
.
├── channels_tutorial1
│   ├── __init__.py
│   ├── __pycache__
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── chat
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   ├── models.py
│   ├── tests.py
│   └── views.py
└── manage.py

4 directories, 11 files

settings.pyを日本語環境向けに編集します.106~108行目あたりです.

LANGUAGE_CODE = 'ja-jp'

TIME_ZONE = 'Asia/Tokyo'

シークレットキーがそのまま埋め込まれているのが精神的に良くないのでdotenvで分離します.

$ pip install python-dotenv

manage.pyと同じディレクトリに下記のような.envファイルを用意します.値は適当に入れてください.

SECRET_KEY=f%1(dv)6-)sadf3-c+vr*8b1j&2=^38o^&i!ekni6%=y^sxogsdif
DEBUG=True

dotenv.load_dotenv.envファイルをロードします.

import os
import dotenv

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# .envを読み込み
dotenv.load_dotenv(os.path.join(BASE_DIR, '.env'))

SECRET_KEY = os.getenv('SECRET_KEY')
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'

INSTALLED_APPSchatアプリを追加します

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'chat'
]

初期ページ追加

chatディレクトリの中にテンプレートを入れるためのtemplatesディレクトリを作成し,更に中にchatディレクトリを作成,index.htmlを配置します.

$ pwd
/path/to/channels_tutorial1
$ cd chat
$ mkdir templates
$ cd templates
$ mkdir chat
$ touch chat/index.html

index.htmlを下記のように編集します.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Rooms</title>
</head>
<body>
    What chat room would you like to enter?<br/>
    <input id="room-name-input" type="text" size="100"/><br/>
    <input id="room-name-submit" type="button" value="Enter"/>
</body>
<script>
    document.querySelector('#room-name-input').focus();
    document.querySelector('#room-name-input').onkeyup = function(e) {
        if (e.keyCode === 13) {  // enter, return
            document.querySelector('#room-name-submit').click();
        }
    };

    document.querySelector('#room-name-submit').onclick = function(e) {
        var roomName = document.querySelector('#room-name-input').value;
        window.location.pathname = '/chat/' + roomName + '/';
    };
</script>
</html>

chat/views.pyに下記の様にindex.htmlをrenderして返す関数を定義します.

from django.shortcuts import render


def index(request):
    return render(request, 'chat/index.html', {})

chat/urls.pyを作成し,下記の様にindexへのurlpatternを定義します.

from django.urls import path

from .views import index


urlpatterns = [
    # 元のコードは→url(r'^$', views.index, name='index'),
    path('', index, name='index'),
]

settings.pyと同階層にあるurls.pyがルートになっているので,ここからchat/urls.pyを取り込みます.

from django.contrib import admin
from django.urls import path, include

from chat import urls as chat_urls


urlpatterns = [
    # 元のコードは→url(r'^chat/', include('chat.urls')),
    path('chat/', include(chat_urls)),
    # 元のコードは→url(r'^admin/', admin.site.urls),
    path('admin/', admin.site.urls),
]

ここまで出来たら一度runserverで動かして,http://127.0.0.1:8000/chat/にアクセスしてみます.

$ python manage.py runserver

System check identified no issues (0 silenced).
May 05, 2018 - 01:07:36
Django version 2.0.5, using settings 'channels_tutorial1.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

下記の様なページが表示されると思います.

f:id:pudding_info:20180505011127p:plain

ASGI/Channelsサーバの起動

ここまでは標準のDjangoの機能を使用してきましたが,ここからはchannelsモジュールを使用して行きます.

まず,channelsが用いるルーティングファイルを記述します.これはurls.pyのようなものに相当するようです.channels_tutorial1/routing.pyを作成し下記の様に編集します.

from channels.routing import ProtocolTypeRouter

application = ProtocolTypeRouter({
    # (http->django views is added by default)
})

INSTALLED_APPSchannelsを追加し,ASGI_APPLICATIONを定義します.

INSTALLED_APPS = [
    # 省略
    'chat',
    'channels',
]

ASGI_APPLICATION = 'channels_tutorial1.routing.application'

runserverしてみると,先ほどとは違う,ASGI/Channelsサーバが起動します((チュートリアルではこれがいくつかのrunserverをラップしてしまうようなコマンドに対して競合する可能性を示唆しています.例えば静的ファイルを配信する設定を簡便にするwhitenoiseなどがその筆頭のようで,その場合はchannelsINSTALLED_APPSの先頭に持ってくる,それらのアプリケーションを削除するなどが対策として上げられています.)).

$ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).
May 05, 2018 - 01:16:55
Django version 2.0.5, using settings 'channels_tutorial1.settings'
Starting ASGI/Channels version 2.1.1 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
2018-05-05 01:16:55,548 - INFO - server - HTTP/2 support not enabled (install the http2 and tls Twisted extras)
2018-05-05 01:16:55,548 - INFO - server - Configuring endpoint tcp:port=8000:interface=127.0.0.1
2018-05-05 01:16:55,555 - INFO - server - Listening on TCP address 127.0.0.1:8000

再びhttp://127.0.0.1:8000にアクセスし,表示されれば完了です.次回Part 2に続きます.

poyo.hatenablog.jp

Travis CI + Codecov + NoseでCIしてSlackに通知

きっかけ

これまでもTravis CIを使ってきていて、それ自体にはそんなに戸惑うことは無かったんですが、カバレッジも取得したくなったりしたので少し調べたら日本語情報があまりなかったのでここに残しておくことにしました。

環境

開発環境は以下

必要なPythonモジュールなど

  • nose 1.3.7
  • codecov 2.0.15
  • coverage 4.5.1

各種サービスのセットアップ

Travis CI

Circle CIがどうにも複雑に見えてしまいずっとTravisを使っているため、今回もTravisです。
特に難しいところはなく、普通にサインアップしてCIしたいリポジトリを有効にするだけです。Build only if .travis.yml is present にチェックを入れておくと良いと思います。

また、シークレットなトークンを暗号化するのに使うtravisというコマンドをgemでインストールします。

$ gem install travis
$ travis login

.travis.ymlの文法エラーでCIをパスできないとめちゃくちゃ悲しくなるのでlintコマンドを覚えると良いと思います。

$ cat .travis.yml
language: python
python:
  - "3.5"
  - "3.6"
script:
  - nosetests -v
$ travis lint
Hooray, .travis.yml looks valid :)

Codecov

codecov.io

Codecovはカバレッジを表示してくれるWebサービスです。Pythonにはcodecovというpipでインストールできるモジュールがあります*1

同様のサービスとしてCoverallsなどがありますが、あまりデザインが好みで無かった上、Python用のパッケージが2種類あってうーんという感じだったのでこちらにしました*2

上記リンクから、Githubアカウント、Gitlabアカウント、Bitbucketアカウントでサインアップできます。それぞれのオンプレ版にも対応しているみたいなので、かなり良さそうです。また、Teamでの使用もパブリックリポジトリなら無制限?のようです。プライベートリポジトリでの利用を検討している場合はリポジトリごと、またはユーザ数単位での課金になるようです*3

f:id:pudding_info:20180311181631p:plain

有効にするリポジトリを選択するとトークンが表示され、カバレッジリポートのアップロードをしろみたいな画面になりますが、Travis CIから使うなら無視して良いです

f:id:pudding_info:20180311181256p:plain

設定はもう終わりです(すごい)。

リポジトリのセットアップ

テストする

僕はずっとnoseを使っているので、今回もこれで行きます。フォルダ構成は以下の様にしました。student_portal_crawlerが今回テストするモジュールです。

$ tree -L 3 -I venv
.
├── LICENSE
├── README.md
├── requirements.txt
└── student_portal_crawler
    ├── __init__.py
    ├── browser.py
    ├── page
    ├── parser
    └── test
        ├── __init__.py
        └── test_script.py

noseのインストールおよびテスト実行は下記の様にします。テストの書き方についてはここでは解説しません。

$ pip install nose
# テストの実行
$ nosetests
.............................
----------------------------------------------------------------------
Ran 20 tests in 0.680s

OK
# テストの実行とカバレッジの計算
$ pip install coverage
$ nosetests --with-coverage

このままでは使用しているモジュール全てのカバレッジを取得してしまうので、--cover-packageオプションで取得するモジュールを指定してやります。今回の場合はstudent_portal_crawlerになります。

$ nosetests --with-coverage --cover-package=student_portal_crawler
.............................
Name                                                  Stmts   Miss Branch BrPart  Cover
---------------------------------------------------------------------------------------
student_portal_crawler/__init__.py                        1      0      0      0   100%
student_portal_crawler/browser.py                        20      0      2      0   100%
student_portal_crawler/page/__init__.py                   1      0      0      0   100%
student_portal_crawler/page/base.py                      34      4      6      1    78%
student_portal_crawler/parser/__init__.py                 4      0      0      0   100%
student_portal_crawler/parser/base.py                    38      0     10      0   100%
student_portal_crawler/parser/lec_info.py                18      0      6      0   100%
student_portal_crawler/parser/static.py                   5      0      0      0   100%
student_portal_crawler/parser/utils.py                    9      0      2      0   100%
student_portal_crawler/shibboleth_login/__init__.py       1      0      0      0   100%
student_portal_crawler/shibboleth_login/login.py         53      2      8      0    97%
---------------------------------------------------------------------------------------
TOTAL                                                   184      6     34      1    95%
----------------------------------------------------------------------
Ran 29 tests in 0.319s

OK

.travis.ymlを書く

シンプルにPythonのバージョンいくつかでテストし、パスしたらCodecovにカバレッジを送信、Slackに結果を通知します。

まずは自分のSlackのIntegrationからTravis CIを追加します。Hubotとかで節約している人も居るかとは思いますが、僕は面倒なので豪華に行きます。

f:id:pudding_info:20180311184429p:plain

追加したら投稿するチャンネルを決め、トークンを取得します。

f:id:pudding_info:20180311184634p:plain

Travis CIでの使い方についての説明の中に、トークンの暗号化についての項目があるのでコピペして実行します。事前に.travis.ymlがないとエラーが出るので作成しておきます。

f:id:pudding_info:20180311184825p:plain

$ touch .travis.yml
$ travis encrypt "{{チーム名}}:{{トークン}}" --add notifications.slack
$ cat .travis.yml
notifications:
  slack:
    secure: npdNlesznnxrC7KsuB7....

Travis CIで利用可能なPythonバージョンは複数有り、最新は3.8-devnightlyまで使える*4ようなのですが、

Recent Python development branches require OpenSSL 1.0.2+. As this library is not available for Trusty, 3.7-dev, 3.8-dev, and nightly do not work (or use outdated archive).

という記述があり、どうやったら使えるのか分からず3.6-devまでしか使っていません…*5

最終的に下記の様にしました。

language: python

python:
  - "3.5"
  - "3.5-dev"
  - "3.6"
  - "3.6-dev"

install:
  - pip install -r requirements.txt
  - pip install codecov coverage

script:
  - nosetests -v --with-coverage --cover-package=student_portal_crawler

after_success:
  - codecov

notifications:
  email: false
  slack:
    secure: npdNlesznnxrC7K...

after_successcodecovコマンドを実行することで、Codecovにカバレッジリポートが送信されます。トークンなどを加える必要は有りません
また、Slackへの通知のみで十分なのでメール通知はオフにしています。

.coveragercを書く

このままではテストコードまで含めたカバレッジがcodecovに表示されてしまったり、テストしないコード(デバッグ用のステートメントなど)まで含まれてしまうため、.coveragercという設定ファイルを置いて制御します。

[run]
branch = True
source = student_portal_crawler

[report]
exclude_lines =
    if TYPE_CHECKING:
    if __name__ == .__main__.:
ignore_errors = True
omit =
    student_portal_crawler/test/*

typingモジュールを使用して型ヒントの定義をしているので、常にFalseとなり実行されないif TYPE_CHECKING:という文が所々に現れるのでこれを排除しています。
また、実際の実行コード(if __name__ == '__main':)も排除しています。

READMEへバッジを貼る

CI回してカバレッジ取得する最大の目的といっても過言ではない、READMEへのバッジを貼ります。

Travis CI

https://travis-ci.org/ユーザ名/リポジトリ名にアクセスし、リポジトリ名の横に表示されているバッジをクリックすると、形式を選択してコピーできます。

f:id:pudding_info:20180311200415p:plain

Codecov

https://codecov.io/gh/ユーザ名/リポジトリ名/settings/badgeにアクセスすると形式を選択してコピーできます

f:id:pudding_info:20180311200633p:plain

後はpushすればCIが走ります。

結果を確認

Githubへpushしてしばらく待つとテストが完了します。

f:id:pudding_info:20180311203617p:plain

こんな風にSlackに通知が来ます。

f:id:pudding_info:20180311203517p:plain

Codecovに自動でカバレッジが送信され、以下の様に表示されます。

f:id:pudding_info:20180311204144p:plain

また、リンクを開いていくとどのモジュールのカバレッジが低いのか丸わかりな上、各ファイルのどの行がテストされていないのかも一発で分かります。

f:id:pudding_info:20180311204324p:plain

まとめ

取り扱いが面倒なことが多いトークン等の設定が必要ないため、Codecov + Travis CIはなかなかオススメの組み合わせかと思います。
どんどんunittestを書いて徳を積んでいきましょう!

*1:基本Travisから叩くときにしか使用しないので、ローカルにはインストールしていません。

*2:h-miyako.hatenablog.com

*3:Educationプランの問い合わせをするとプライベートリポジトリの数を尋ねられたので、学生でプライベートリポジトリでも使いたい〜って場合は連絡してみると良いと思います。

*4:Building a Python Project - Travis CI

*5:trustyがダメでpreciseならokみたいなことは無いだろうと思って試していません