PythonでWSGI準拠のWebサーバを自作し、その上でBottleを動かしてみた

以前、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考に、PythonでWebサーバを書きました。
「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」が良かったのでPythonで書いてみた - メモ的な思考的な

gihyo.jp

 
この時は3章以降は読むだけにしましたが、やはり3章以降もPythonで実装してみたいと考えました。

3章以降はJava + Tomcatを使っていたため、Pythonの場合はWSGIを使って作るのが良さそうです。
java - What is the Python equivalent of Tomcat? - Stack Overflow

 
まずはWSGIの仕様を知ろうと、PEP3333の日本語訳を中心に読んでみました。

 
次に、WSGI準拠Webサーバをイチから作成する方法を調べてみたところ、以下の記事を見つけました(以下、「参考記事」と表記)。
Let’s Build A Web Server. Part 2. - Ruslan's Blog

図などで詳しく説明されていたため、だいたいの流れは理解できました。ありがとうございました。

 
上記を元にWSGI準拠のWebサーバを実装してみたため、その時の内容をメモしておきます*1

なお、誤りなどがあれば、ご指摘いただけるとありがたいです。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit
  • bottle 0.12.9

 
今回は、WSGI準拠の自作Webサーバ上で、WSGIアプリケーションフレームワークのBottleを使ったアプリを動かします。
Bottle: Python Web Framework — Bottle 0.13-dev documentation

Bottleアプリの内容は、

bottle_app.py

@route('/')
def index():
    # Bottleで設定するレスポンスヘッダを確認
    print("----Bottle's response_header----")
    print(bottle.response.headerlist)
    return template('index.html')

@route('/static/css/<filename>')
def static_css(filename):
    return static_file(filename, root=STATIC_DIR + '/css/')

@route('/static/images/<filename>')
def static_css(filename):
    return static_file(filename, root=STATIC_DIR + '/images/')

app = bottle.default_app()

 
views/index.html

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>テスト</title>
        <link rel="stylesheet" href="/static/css/default.css">
    </head>
   <body>
       <h1>テストタイトル</h1>
       <p>テストページです</p>
       <p>今日のシナノゴールド</p>
       <img src="/static/images/shinanogold.png">
   </body>
</html>

 
static/css/default.css

h1 {
    color: red;
}

です。

 

1つのレスポンスを返すWSGI Webサーバの作成

まずは、単純に1つのレスポンスを返すWSGI Webサーバを作成します。

前回作成した以下のWebサーバをベースにします。
syakyo_create_web_server/modoki01_1_5_3.py at master · thinkAmi-sandbox/syakyo_create_web_server

 
WSGI準拠とするため、

  • WSGI環境変数の作成
  • WSGIアプリケーションから呼び出されるコールバック関数の作成
  • WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し

の3点を追加します。

 

WSGI環境変数の作成

PEP3333では、このあたりに書いてあります。

今回は、

env = {}
env['wsgi.version']      = (1, 0)   # WSGIのバージョン:決め打ち
env['wsgi.url_scheme']   = 'http'   # urlのスキーム(http/https)
# HTTP リクエスト本体のバイト列を読み出すことができる入力ストリーム 
env['wsgi.input']        = io.BytesIO(byte_request)
env['wsgi.errors']       = sys.stderr
env['wsgi.multithread']  = False
env['wsgi.multiprocess'] = False
env['wsgi.run_once']     = False
env['REQUEST_METHOD']    = request_method    # GET
env['PATH_INFO']         = path              # /
env['SERVER_NAME']       = server_name       # FQDN
env['SERVER_PORT']       = str(port)         # 8888

としました。

 

WSGIアプリケーションから呼び出されるコールバック関数の作成

PEP3333では、このあたりに書いてあります。

コールバック関数で必要な引数は、

  • status
    • 文字列で、 200 OK などが入ってくる
  • response_headers
    • 文字列で、 [('Content-Length', '398'), ('Content-Type', 'text/html; charset=UTF-8')] などが入ってくる
  • exc_info=None
    • エラーがなければ、基本はNone

の3つです。引数名は任意ですが、今回はPEP3333に合わせます。

 
コールバック関数の中では、ステータスコードやレスポンスヘッダがWSGIアプリケーションから渡されます。

ただ、

  • コールバック関数の戻り値として、それらをWebサーバへ渡すことができない
  • コールバック関数の中以外では、それらを取得できない

という制約があります。

そのため、参考記事ではインスタンス変数を使ってWebサーバへそれらを渡していました。

ただ、今回は関数による実装のため、

def start_response(status, response_headers, exc_info=None):
    # 任意の内容の、WSGIサーバで追加するレスポンスヘッダ
    server_headers = [
        ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'),
        ('Server', 'HenaWSGIServer 0.1'),
    ]

    global headers_set
    headers_set = [status, response_headers + server_headers]

と、グローバル変数へと保存しました。

 

WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し

上記で作成した環境変数とコールバック関数をWSGIアプリケーションに渡します。

byte_response_body = application(env, start_response)

 

ブラウザへのレスポンスについて

以前作ったWebサーバでは、ステータスライン・レスポンスヘッダ・レスポンスボディを1行ずつ返していました。

今回も同じように1行ずつ返したところ、

Content-Length: 398

Content-Type: text/html; charset=UTF-8

Date: Sat, 16 Jul 2016 00:00:00 JST

Server: HenaWSGIServer 0.1


<!DOCTYPE html>
<html lang="ja">
...

のようなテキストがブラウザに表示されてしまい、正しいレスポンスとは認識されませんでした。

 
そこで

# ステータスラインからレスポンスボディまで、一括で送信する
# ステータスライン
str_response = 'HTTP/1.1 {status}\r\n'.format(status=status)

# レスポンスヘッダ
for header in response_headers:
    str_response += '{0}: {1}\r\n'.format(*header)

# レスポンスヘッダとレスポンスボディを分ける、改行コード
str_response += '\r\n'

# レスポンスボディ
for byte_body in byte_response_body:
    # WSGIアプリからもらったレスポンスボディはバイト列
    # レスポンスヘッダなどと結合するため、一度文字列へとデコードする
    str_response += byte_body.decode('utf-8')

# クライアントへ送信
# バイト列で送信する必要があるため、エンコードしてから送信
connection.sendall(str_response.encode('utf-8'))

のように、ステータスラインやレスポンスヘッダ、レスポンスボディを連結して、一括して返すようにしました。

 

動作確認

この時点のソースコードは以下の通りです。
wsgi_webserver-sample/single_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

 
動作確認として、コマンドプロンプトより

>python single_response_wsgi_server.py bottle_app:app

と実行し、ブラウザで確認したところ

f:id:thinkAmi:20160718170207p:plain

と表示されました。

1つのレスポンスしか処理できないため、CSSや画像の部分は対応できていません。

 

普通にWebページを表示するWebサーバの作成

今度はCSSや画像も含めたWebページを表示できる、WSGI準拠のWebサーバを作ります。

前回作成した以下のWebサーバのように、threading.Threadにてマルチスレッド化することで、複数のリクエストとレスポンスを扱えるようにします。
syakyo_create_web_server/modoki02_main_2_6.py at master · thinkAmi-sandbox/syakyo_create_web_server

また、せっかくなので、クラス形式でWebサーバを作ってみます。

 

コールバック関数のstatusやresponse_headersの扱いについて

1つのレスポンスを処理するバージョンでは、グローバル変数を使ってstatusresponse_headersを保存していました。

ただ、今回はマルチスレッド化するためグローバル変数が使えません。また、参考記事のようなクラス構成でインスタンス変数を使った場合、スレッド間でインスタンス変数を上書きしてしまいます。

スレッド固有のデータを保持するthreading.local()を使うことも考えましたが、うまい使い方が思い浮かびませんでした。
Python の threadling.local (スレッドローカル) を試してみる | CUBE SUGAR STORAGE

そんな中、WSGIのリファレンス実装であるwsgirefモジュールの構成を見たところ、serverとhandlerのクラスが分かれていました。これならマルチスレッド化できそうです。
cpython/Lib/wsgiref at master · python/cpython

 
そこで、

  • メインスレッドのサーバクラス(MyWSGIServer)でsocket.accept()
  • データを受信したら、別スレッドのハンドラクラス(MyWSGIHandler)でリクエストとレスポンスを処理

の形としました。

 
念のため、MyWSGIHandlerクラスのオブジェクトについて、

while True:
    client_connection, client_address = self.listen_socket.accept()
    handler = MyWSGIHandler(client_connection, client_address, self.application, self.server_name, self.server_port)

    # 念のため、インスタンスの識別値を確認
    print("handler object id: {}".format(id(handler)))

    # スレッドで画像ファイルとかも受け取れるようにする
    thread = threading.Thread(target=handler.handle_one_request)
    thread.start()

のように、オブジェクト識別値を確認してみました。

結果は

handler object id: 29123088
handler object id: 30610064
handler object id: 59447632
handler object id: 59448240

となり、リクエストごとにオブジェクトが生成されていました。

 

WSGI環境変数について

WSGI環境変数のうち、

  • wsgi.multithread
  • wsgi.multiprocess
  • wsgi.run_once

の値をどうするか悩みましたが、今回は

env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True

としました。

正しくないような気もしますので、ご存じの方がいればご指摘ください。

 

画像のレスポンスについて

上記の内容でWSGI準拠サーバを作成し、ブラウザで確認したところ、

f:id:thinkAmi:20160718171706p:plain

となりました。画像のレスポンスがうまくいっていないようです。

コンソールを確認したところ、

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

というエラーが出ていました。

レスポンスボディを結合する際の

str_response += byte_body.decode('utf-8')

で発生したようです。

 
画像なのでバイナリデータなのかなと思い、Bottleからのレスポンスボディを見たところ、

<bottle.WSGIFileWrapper object at 0x03305B30>

のようなBottleオブジェクトでした。

 
ファイルオブジェクトをsocketで送信する方法を探そうと公式ドキュメントを見たところ、Python3.5より、socket.sendfile()が追加されていました。
18.1. socket — 低水準ネットワークインターフェース — Python 3.5.1 ドキュメント

Bottleでの使用例も探してみたところ、

yield connection.sendall(response.render_headers())

res = yield connection.sendfile(self.filelike, 0, self.blocksize)

https://github.com/j4cbo/chiral/blob/master/chiral/web/httpd.py#L146

のような形で、レスポンスヘッダの送信後に画像データを送れば良さそうでした。

 

動作確認

コマンドプロンプトより、

>python single_response_wsgi_server.py bottle_app:app

と実行し、ブラウザで確認したところ、

f:id:thinkAmi:20160718172248p:plain

画像も表示されていました。

 

ソースコード

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

こちらにも残しておきます。

import socket
import io
import sys
import threading

class MyWSGIHandler(object):
    def __init__(self, client_connection, client_address, application, server_name, server_port):
        self.connection = client_connection
        self.address = client_address
        self.application = application
        self.server_name = server_name
        self.server_port = server_port

        # コールバック関数で取得できるステータスコード・レスポンスヘッダの保存先
        self.headers_set = []


    def handle_one_request(self):
        request_data = self.connection.recv(1024)
        env = self.get_environ(request_data)
        byte_response_body = self.application(env, self.start_response)
        self.finish_response(byte_response_body)


    def get_environ(self, byte_request):
        byte_request_line = byte_request.splitlines()[0]
        str_request_line = byte_request_line.decode('utf-8')
        str_request_line_without_crlf = str_request_line.rstrip('\r\n')
        request_method, path, request_version = str_request_line_without_crlf.split()

        env = {}
        env['wsgi.version']      = (1, 0)
        env['wsgi.url_scheme']   = 'http'
        env['wsgi.input']        = io.BytesIO(byte_request) 
        env['wsgi.errors']       = sys.stderr
        env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
        env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
        env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True
        env['REQUEST_METHOD']    = request_method        # GET
        env['PATH_INFO']         = path                  # /
        env['SERVER_NAME']       = self.server_name      # FQDN
        env['SERVER_PORT']       = str(self.server_port) # 8888

        return env


    def start_response(self, status, response_headers, exc_info=None):
        server_headers = [
            ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'),
            ('Server', 'MyWSGIServer 0.2'),
        ]
        self.headers_set = [status, response_headers + server_headers]


    def finish_response(self, byte_response_body):
        try:
            status, response_headers = self.headers_set

            # ステータスライン
            str_response = 'HTTP/1.1 {status}\r\n'.format(status=status)

            # レスポンスヘッダ
            for header in response_headers:
                str_response += '{0}: {1}\r\n'.format(*header)

            # レスポンスヘッダとレスポンスボディを分ける、改行コード
            str_response += '\r\n'

            # レスポンスボディ
            print(byte_response_body)
            # 画像データのレスポンスがあった場合のオブジェクト
            # => <bottle.WSGIFileWrapper object at 0x03305B30>
            # これをそのままdecodeするとエラーになる
            # => UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

            if 'image' in str_response:
                # 画像データの場合
                # レスポンスヘッダを送信した後、sendfile()で画像データを送信する
                self.connection.sendall(str_response.encode('utf-8'))
                self.connection.sendfile(byte_response_body)

            else:
                # 画像データ以外の場合
                for byte_body in byte_response_body:
                    # WSGIアプリからもらったレスポンスボディはバイト列
                    # レスポンスヘッダなどと結合するため、一度文字列へとデコードする
                    str_response += byte_body.decode('utf-8')

                # クライアントへ送信
                # バイト列で送信する必要があるため、エンコードしてから送信
                self.connection.sendall(str_response.encode('utf-8'))

        finally:
            self.connection.close()


class MyWSGIServer(object):
    def __init__(self, ip_address, port, wsgi_app):
        self.listen_socket = socket.socket(
            socket.AF_INET,
            socket.SOCK_STREAM
        )
        self.listen_socket.bind((ip_address, port))
        self.listen_socket.listen(1)
        self.application = wsgi_app
        self.server_name = socket.getfqdn(ip_address)
        self.server_port = port
    
    def serve_forever(self):
        while True:
            client_connection, client_address = self.listen_socket.accept()
            handler = MyWSGIHandler(client_connection, client_address, 
                self.application, self.server_name, self.server_port)

            # 念のため、オブジェクト識別値を確認
            print("handler object id: {}".format(id(handler)))

            # スレッドで画像ファイルとかも受け取れるようにする
            thread = threading.Thread(target=handler.handle_one_request)
            thread.start()


def make_server(ip_address, port, wsgi_app):
    server = MyWSGIServer(ip_address, port, wsgi_app)
    return server


if __name__ == '__main__':
    if len(sys.argv) < 2:
        sys.exit('"module:callable"の形でWSGIアプリケーションを指定してください')

    app_path = sys.argv[1]
    module, application = app_path.split(':')
    module = __import__(module)
    wsgi_app = getattr(module, application)

    # WSGIサーバの起動
    httpd = make_server('', 8888, wsgi_app)
    print('MyWSGIServer: ホスト{address}、ポート{port}にて起動しました\n'.format(
        address=httpd.server_name, port=httpd.server_port))
    httpd.serve_forever()

 

その他参考

*1:もっとも、WSGI準拠とはいいつつ、BottleでGETが動く最低限のものしか実装していませんが...

PowerShellを使って、Gmail APIからメールを送信する

以前、PowerShellを使ってSMTPGmailを送信したことがありました。
PowerShellを使って、SMTPでGmailを送信する - メモ的な思考的な

 
今回は、以下の記事を参考に、Gmail APIを使たGmailの送信を試してみました*1
Powershell: Googlemail (GMail) nativ mit Powershell verwalten - administrator.de

なお、自分が実装してみたのは送信部分だけですが、記事にはそれ以外の内容についても記載されています。

 
ちなみに、Gmail APIでは .NET向けにライブラリも用意されていますが、PowerShellでNuGetパッケージを扱うやり方が分からなかったため、今回は使用しませんでした。
.NET Quickstart  |  Gmail API  |  Google Developers

 
目次

 

環境

 

Gmail APIの有効化とclient_id.jsonファイルの取得

以前Pythonでやった時と同じ方法で作業します。
Python3 + google-api-python-clientで、Gmail APIを使ってメールを送信する - メモ的な思考的な

  • dotnetチュートリアルへ移動
  • Step1のa.にある、this wizardをクリック
  • Google Developers Console で Gmail API を有効にするアプリケーションの登録画面で、以下を選択し、同意して続行をクリック
    • 新機能のお知らせ: 任意
    • 利用規約の順守:はい
  • プロジェクトMy Projectを作成していますの表示後、しばらく待つと、API が有効化されましたの表示が出る

    • 認証情報に進むをクリック
  • プロジェクトへの認証情報の追加画面

    • 1. 必要な認証情報の種類を調べるで、以下を選択し、必要な認証情報をクリック
      • 使用する APIGmail API
      • API を呼び出す場所: その他のUI (Windows、CLIツールなど)
      • アクセスするデータの種類: ユーザーデータ
    • 2.OAuth 2.0 クライアント ID を作成する
      • 名前: 任意 (MyPowerShellGmailSender など)
      • クライアントIDの作成をクリック
    • 3.OAuth 2.0 同意画面を設定する
      • メールアドレス: 自分のメールアドレス
      • ユーザーに表示するサービス名: 任意(MyPowerShellGmailSender Auth など)
      • 次へ をクリック
    • 4.認証情報をダウンロードする

 

OAuth2.0での認証部分の作成

参考サイトだと、Auth-Google関数の部分になります。

処理の流れは

  • 未認証の場合、ブラウザ経由で認証し、トークンを取得
  • 認証済&有効期限内の場合、トークンを再利用
  • 認証済&有効期間超過の場合、トークンをリフレッシュ

でした。以下の記事と同じ流れでした。
Google API OAuth2.0のアクセストークン&リフレッシュトークン取得手順メモ - Qiita

また、参考サイトではトークンなどはグローバル変数に入れていましたが、今回はPython版のgoogle-api-python-clientに合わせて、ローカルのJSONファイルとして保存することにします。

 
実装した時に悩んだ部分は以下の通りです。

 

JSONファイルの読み書き

ConvertFrom-JsonConvertTo-Jsonを使います。
PowerShell で JSON をファイル入出力 する - tech.guitarrapc.cóm

 
なお、読み込んだJSONオブジェクトの各項目は

$json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
$auth = $json.installed

のように、<オブジェクト名>.<項目名>でアクセスします。

 

IEで認可した時のcodeを取得

参考サイトだと、ブラウザに表示されているcodeをコピー&ペーストする形でした。

良い方法がないか調べたところ、

と、IEを使えば自動化できそうでした。

そこで、

$ie = New-Object -ComObject InternetExplorer.Application
$ie.Navigate($auth_url)
$ie.Visible = $true 

$code = ""
while($true){
    $title = $ie.Document.title
    if (($title) -and ($title.Contains("Success"))) {
        $code = $title.Replace("Success code=", "")
        break
    }
    Start-Sleep -s 1
}

$ie.Quit()
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($ie)
Remove-Variable ie

としました。

 

PowerShellで既存のオブジェクトにプロパティを追加

OAuth2.0で取得したトークンの内容は

{
    "access_token":  "xxx",
    "token_type":  "Bearer",
    "expires_in":  3600,
    "refresh_token":  "xxx",
}

でした。有効期限切れまでの秒数(expires_in)はあるものの、有効期限の開始日時はありませんでした。

そのため、Add-Memberを使って

$credential | Add-Member created_at (Get-Date).ToString($DATE_FORMAT) -Force

のように、$credentialオブジェクトに created_atプロパティ(有効期限の開始日時)を追加し、有効期限を判定しやすくしました。
Add-Member - TechNet

 

OAuth2.0のURLにあるパラメータの意味について

以下が参考になりました。

 

メール送信部分の作成

参考サイトだとSend-GoogleMailMessage関数になります。

処理の流れは、

  • アクセストークンを取得
  • System.Net.Mail.MailMessageクラスを使ってメールのヘッダやボディを作成
  • リフレクションで非公開メソッドを使って、MailMessageバイト列へ変換
  • System.Convert.ToBase64String()を使って、Base64エンコードした文字列を取得
  • エンドポイントへPOST

でした。

 
.NETでBase64エンコードする場合、System.Convert.ToBase64String()を使いますが、

という点が悩ましかったです。

それらを解消する方法を調べたところ、以下のC#Gmail APIを使っているサイトが参考になりました。
Sending Email with the Gmail API in .NET / C# | Jason Pettys Blog

そのサイトでは

  • System.Convert.ToBase64String()に渡しやすい、AE.Net.Mailライブラリを使う
  • System.Convert.ToBase64String()で足りない部分は、自分で変換する

と実装しており、これで良さそうでした。

 
その他で悩んだ部分は以下の通りです。

 

PowerShellモジュールの読込

今回、

  • OAuth2.0での認証(google_credential.psm1)
  • メール送信(gmail_sender.ps1)

は別のPowerShellファイルとして用意しました。そのため、gmail_sender.ps1の中でgoogle_credential.psm1を読み込む必要があります。

方法としては

$path = Join-Path . "google_credential.psm1"
Import-Module -Name $path

のように、Import-Moduleを使うのが良さそうでした。
Importing a PowerShell Module

 

AE.Net.Mailライブラリ(dll)の読込

まずは、読み込むためのAE.Net.Mail.dllファイルを取得します。

C#プロジェクトとかであればNuGetを使いますが、今回は直接ダウンロードします。

以下のページのDownloadより、最新版のae.net.mail.1.7.10.nupkgをダウンロードします。
NuGet Gallery | AE.Net.Mail

拡張子をzipに変更・展開すると、libディレクトリの中にnet40net45の2ディレクトリがありました。

PowerShellで使っているバージョンを調べると、

PS > $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.0.10586.122
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.10586.122
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

より、CLRVersionが4.0でした。

そのため、net40ディレクトリのAE.Net.Mail.dllスクリプトと同じディレクトリへとコピーします。

また、インターネットからダウンロードしたdllのため、今回はdllのプロパティよりブロックの解除をしておきます。
Powershell load dll got error: Add-Type : Could not load file or assembly 'WebDriver.dll' or one of its dependencies. Operation is not supported - Stack Overflow

 
あとは、

$dll = Join-Path . "AE.NET.Mail.dll"
Add-Type -Path $dll
$msg = New-Object AE.Net.Mail.MailMessage

のように、Add-Typeを使うことで、dllに含まれるクラスが利用可能になります。
Add-Type - TechNet

 

リクエストURLのuserIdパラメータについて

参考サイトでは、OAuth認証をしたユーザのメールアドレスを、System.Net.WebUtility.UrlEncode()エンコードした値を使っていました。
WindowsアプリケーションでHTMLデコード/エンコードを行うには?[4以降、C#、VB] - @IT

ただ、ドキュメントをよく読むと、

The user's email address. The special value me can be used to indicate the authenticated user.

Users.messages: send  |  Gmail API  |  Google Developers

のように、meという値も設定可能だったため、今回はこちらを使いました。

 

Bounceメールについて

正常に送信できたにもかかわらず、Bounceメールも同時に送信されました。

原因は、Reply-Toに値を設定していなかったためです。
GMail API Emails Bouncing - Stack Overflow

 

ソースコード全体

以下の通りとなりました。

google_credential.psm1

set CREDENTIAL_FILE (Join-Path . "credential.json")
set SECRET_FILE (Join-Path . "client_id.json")
set DATE_FORMAT "yyyy/MM/dd HH:mm:ss"
set GMAIL_SCOPE "https://www.googleapis.com/auth/gmail.send"

function Save-GoogleCredential($credential){
    $credential | Add-Member created_at (Get-Date).ToString($DATE_FORMAT) -Force
    $credential_file = Join-Path . $CREDENTIAL_FILE
    $credential | ConvertTo-Json | Out-File $CREDENTIAL_FILE -Encoding utf8
}

function Get-GoogleCredential(){
    if (-not(Test-Path $SECRET_FILE)) {
        Write-Host "Not found client_id.json file"
        return $null
    }

    $json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
    $auth = $json.installed

    if (Test-Path $CREDENTIAL_FILE) {
        $current_credential = Get-Content $CREDENTIAL_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
        if (-not ($current_credential.access_token -and $current_credential.token_type -and $current_credential.expires_in `
                  -and $current_credential.refresh_token -and $current_credential.created_at))
        {
            Write-Host "No credential file: $($CREDENTIAL_FILE)"
            return $null
        }

        $elapsed_seconds = ((Get-Date) - [DateTime]::ParseExact($current_credential.created_at, $DATE_FORMAT, $null)).TotalSeconds
        if ($elapsed_seconds -lt $current_credential.expires_in ) {
            Write-Host "Reuse access token..."
            return $current_credential
        }
        else{
            Write-Host "Refresh access token..."

            $refresh_body = @{
                "refresh_token" = $current_credential.refresh_token;
                "client_id" = $auth.client_id;
                "client_secret" = $auth.client_secret;
                "grant_type" = "refresh_token";
            }

            try {
                $refreshed_credential = Invoke-RestMethod -Method Post -Uri $auth.token_uri -Body $refresh_body
            }
            catch [System.Exception] {
                Write-Host $Error
                return $null
            }
            
            Save-GoogleCredential $refreshed_credential
            return $refreshed_credential
        }
    }

    Write-Host "New access token..."

    $gmail_scope = "https://www.googleapis.com/auth/gmail.send"
    $auth_url = "$($auth.auth_uri)?scope=$($GMAIL_SCOPE)"
    $auth_url += "&redirect_uri=$($auth.redirect_uris[0])"
    $auth_url += "&client_id=$($auth.client_id)"
    $auth_url += "&response_type=code&approval_prompt=force&access_type=offline"

    $ie = New-Object -ComObject InternetExplorer.Application
    $ie.Navigate($auth_url)
    $ie.Visible = $true 

    $code = ""
    while($true){
        $title = $ie.Document.title
        if (($title) -and ($title.Contains("Success"))) {
            $code = $title.Replace("Success code=", "")
            break
        }
        Start-Sleep -s 1
    }

    $ie.Quit()
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($ie)
    Remove-Variable ie

    try {
        $new_body = @{
            "client_id" = $auth.client_id;
            "client_secret" = $auth.client_secret;
            "redirect_uri" = $auth.redirect_uris[0];
            "grant_type" = "authorization_code";
            "code" = $code;
        }
        $new_credential = Invoke-RestMethod -Method Post -Uri $auth.token_uri -Body $new_body
    }
    catch [System.Exception] {
        Write-Host $Error
    }
    Save-GoogleCredential $new_credential
    return $new_credential
}

Export-ModuleMemberFunction Get-GoogleCredential

 
gmail_sender.ps1

function ConvertTo-Base64Url($str){
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($str)
    $b64str = [System.Convert]::ToBase64String($bytes)
    $without_plus = $b64str -replace '\+', '-'
    $without_slash = $without_plus -replace '/', '_'
    $without_equal = $without_slash -replace '=', ''

    return $without_equal
}

function Send-Gmail(){
    $mail = @{
        from = "example@gmail.com";
        to = "example+to@gmail.com";
    }

    $path = Join-Path . "google_credential.psm1"
    Import-Module -Name $path
    $credential = Get-GoogleCredential
    Write-Host $credential

    if (-not $credential){
        Write-Host "Not Authenticated."
        return
    }

    $dll = Join-Path . "AE.NET.Mail.dll"
    Add-Type -Path $dll
    $msg = New-Object AE.Net.Mail.MailMessage

    $from = New-Object System.Net.Mail.MailAddress $mail["from"]
    $msg.From = $from
    $to = New-Object System.Net.Mail.MailAddress $mail["to"]
    $msg.To.Add($to)
    $msg.ReplyTo.Add($from)

    $msg.Subject = "gmail api subject"
    $msg.Body = "body: ハロー Gmail API!"

    $sw = New-Object System.IO.StringWriter
    $msg.Save($sw)
    $raw = ConvertTo-Base64Url $sw.ToString()
    $body = @{ "raw" = $raw; } | ConvertTo-Json

    $user_id = "me"
    $uri = "https://www.googleapis.com/gmail/v1/users/$($user_id)/messages/send?access_token=$($credential.access_token)"

    try {
        $result = Invoke-RestMethod $uri -Method POST -ErrorAction Stop -Body $body -ContentType "application/json"
    }
    catch [System.Exception] {
        Write-Host $Error
        return
    }
    Write-Host $result
}


# エントリポイント
Send-Gmail

 
また、同じ内容でGitHubにも上げてあります。自分の理解用なので、いくつかコメントが入っています。
PowerShell_misc/gmail_api at master · thinkAmi/PowerShell_misc

*1:元はドイツ語ですが、英語に翻訳するとだいぶ見やすくなります

PowerShellからイベントログの内容をメールで送信する

Windowsのイベントログの内容をメールで送信したい場合、今まではNotifEventLogSecondを使っていました(現在では開発終了)。
NotifEventLogSecond - With nothing better to do

ソースコードは公開されています。
With nothing better to do

 
今後どうしようかと考えましたが、PowerShellでも同様のことができるとの記載があったため、

  • 実現するために使うもの
    • PowerShellGet-WinEventコマンドレットとタスクスケジューラ
  • 対象のイベントログのレベル
    • 重大・エラー・警告の3つ
  • メールの送信タイミング
    • イベントログに出力された時にメール送信
    • 一日分のイベントログ内容をメール送信

という形で試してみることにしました。

 
なお、Get-EventLogでもイベントログを取得できますが、両者の違いは以下が参考になりました。
遥佐保の技術メモ:[PowerShell]不定期イベントログを捕まえる!nmcapとPowerShellで必要なイベントログのみ取得する(2/2) - livedoor Blog(ブログ)

 
なお、Windows Server2008 x64ではFilterHashtableオプションがないため、Get-WinEventでは作りにくいかと思います。
Get-WinEvent -FilterHashtable on Windows 2008 x64

 
目次

 

環境

 

メールの内容について

NotifEventLogSecondを使っていた際は、

  • 日時
  • レベル
  • ログの名前
  • ソース
  • タスクのカテゴリ
  • イベントID
  • キーワード
  • ユーザー
  • コンピューター
  • オペコード
  • メッセージ

を送信していたため、PowerShellでも同じようにしたいと考えました。

 
Get-WinEventコマンドレットの戻り値は System.Diagnostics.Eventing.Reader.EventLogRecordオブジェクトのようでした。
Get-WinEvent - TechNet

MSDNによると、EventLogRecordからほぼ同じような内容が取得できそうでした。
EventLogRecord クラス (System.Diagnostics.Eventing.Reader) - MSDN

 
ただ、

  • ユーザー名
  • メッセージ

についてはそのままでは取得できなかったので、調べてみました。

 

ユーザー名

以下を参考に、UserIdからユーザー名へと変換するのが良さそうでした。
ドメイン名、ユーザ名と関連するSIDを相互変換するPowerShellスクリプト - YOMON8.NET

(New-Object System.Security.Principal.SecurityIdentifier($event.UserId)).Translate([System.Security.Principal.NTAccount]).Value

 

メッセージ

EventLogRecordクラスのプロパティにはメッセージそのものはありませんでした。そのため、Propertiesプロパティに含まれているのかなと考えました。
EventLogRecord.Properties プロパティ (System.Diagnostics.Eventing.Reader) - MSDN

試してみたところ、

# 本来は、「サービス "BITS" (DLL "C:\Windows\System32\bitsperf.dll") の Open プロシージャに失敗しました。...」が欲しい
$log_property = ""
foreach ($p in $event.properties){
    $log_property += $p.value
}

# => BITSC:\Windows\System32\bitsperf.dll82 0 0 0 0 0 0 0

メッセージではありませんでした。

 
PowerShell独自のプロパティの追加があるのかなと考え、以下を参考に.NETのプロパティ以外を表示してみます。

$event | Get-Member -Force | Where-Object {
    ($_.MemberType -ne "Method" -and
     $_.MemberType -ne "Property"
    )
}

# 結果
pstypenames       CodeProperty
psadapted         MemberSet
psbase            MemberSet
psextended        MemberSet
psobject          MemberSet
PSStandardMembers MemberSet
Message           NoteProperty

NotePropertyとしてMessageが追加されていました。

Messageプロパティの中身を見たところ、イベントログのメッセージと同じだったため、これを使うことにします。

 

イベントログに出力された時にメール送信

検知方法について

「イベントログに出力された時」を検知する場合の方法として、

がありました。

個人的な興味として、タスクスケジューラと組み合わせてみたかったため、今回は後者の方法で実装します。

 

Get-WinEventのフィルタについて

Get-WinEventコマンドレットには取得時のフィルタが必要です。フィルタの方法としては、

  • Get-EventLog | Where-Object
  • Get-EventLog -FilterHashTable

がありますが、前者だと全件取得後の絞込によりパフォーマンスが厳しいため、後者を使います。
イベントログから特定のイベントを抽出 at SE の雑記

 
フィルタとして使用するハッシュは、

$filter = @{
    LogName = $log_name;
    Level = 1,2,3;
    StartTime = $start_date;
    EndTime = $end_date;
}

とし、それぞれ

  • LogNameは、PowerShellスクリプトの引数として、ログ名をカンマで結合したものを与える
  • Levelは、1(重大)、2(エラー)、3(警告)の3つ
  • StartTimeは、イベントログで呼ばれた時から30秒前
    • $start_date = (Get-Date).AddSeconds(-30)
      • イベント発生してから30秒以上経過してからの起動はないものと想定
  • EndTimeは、現在時刻 (Get-Date)

とします。

 

既知のイベントログエラーの除外について

Get-WinEventコマンドレットでは、除外フィルターの設定が見当たりませんでした。そのため、以下を参考に除外リストを作り、除外することにしました。
UranuxTech blog: [PowerShell] Get-WinEventで必要なログのみを取得する

# 除外リスト
$exclusions = @(
    @{
        Id = 9999;
        LogName = "Application";
    }
)

# Get-WinEventの戻り値$eventsから、既知のものを除外
foreach($e in $exclusions){
    $events = $events | Where-Object { -not (
        $_.Id -eq $e.Id -and
        $_.LogName -eq $e.LogName
    )}
}

 

文字列中の変数展開について

文字列に変数を埋め込む場合、

$val = "ほげ"
"値 $val"
# => 値 ほげ

としますが、そのままではオブジェクトのプロパティは展開できません。

方法を探したところ、以下にありました。
リテラル - 文字列 | powershell チートシート - Qiita

$val.name = "ほげ"
"値 $($val.name)"
#=> 値 ほげ

変数を$()で囲めば良いようです。

 

全体のコード

上記を踏まえたコードは以下の通りです。

$mail = @{
    from = "example+from@gmail.com";
    to = "example+to@gmail.com";
    smtp_server = "smtp.gmail.com";
    smtp_port = 587;
    user = "example+to@gmail.com";
    password = "1234";
}


function Send-Gmail($mail, $msg){
    $password = ConvertTo-SecureString $mail["password"] -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential $mail["user"], $password
    $host_name = [Net.Dns]::GetHostName()
    Send-MailMessage -To $mail["to"] `
                     -From $mail["from"] `
                     -SmtpServer $mail["smtp_server"] `
                     -Credential $credential `
                     -Port $mail["smtp_port"] `
                     -Subject "$host_name ログ" `
                     -Body $msg `
                     -Encoding UTF8 `
                     -UseSsl
}


# オブジェクトのプロパティを示すため、カッコでくくる
# 念のため、呼ばれた前30秒のログを取得する
$start_date = (Get-Date).AddSeconds(-30)
$end_date = Get-Date

# 除外するイベントログ情報
$exclusions = @(
    @{
        Id = 9999;
        LogName = "Application";
    }
)

# ログ名は、引数にカンマ区切りでセットする
# 例) System,Application
$log_name = $args[0] -split ","

# FilterHashTable用のフィルタ
$filter = @{
    LogName = $log_name;
    Level = 1,2,3;
    StartTime = $start_date;
    EndTime = $end_date;
}

# FilterHashTableでフィルタした後のイベントを取得
$events = Get-WinEvent -FilterHashTable $filter

# 除外指定されているイベント情報は送信しない
foreach($e in $exclusions){
    $events = $events | Where-Object { -not (
        $_.Id -eq $e.Id -and
        $_.LogName -eq $e.LogName
    )}
}


$msg = @"
対象イベント件数: $($events.Count)
--------------------------------------------------


"@

foreach($event in $events){
    $log_task_category = if ($event.Task){ "$($event.TaskDisplayName) ($($event.Task))" } else { "なし" }
    $log_edited_user_id = if ($event.UserId){ "($($event.UserId))" } else { "" }
    
    $log_user_name = if ($event.UserId) {
        # http://yomon.hatenablog.com/entry/2015/06/19/183522
        (New-Object System.Security.Principal.SecurityIdentifier($event.UserId)).Translate([System.Security.Principal.NTAccount]).Value
    } else { "" }

    $log_property = ""
    foreach ($p in $event.properties){
        $log_property += $p.value
    }

    $msg += @"
日時: $($event.TimeCreated.ToString("yyyy/MM/dd HH:mm:ss"))
レベル: $($event.LevelDisplayName) ($($event.Level))
ログの名前: $($event.LogName)
ソース: $($event.ProviderName) 
タスクのカテゴリ: $log_task_category
イベントID: $($event.Id)
キーワード: $($event.KeywordsDisplayNames)
ユーザー: $($log_user_name) $log_edited_user_id
コンピューター: $($event.MachineName)
オペコード: $($event.OpcodeDisplayName) ($($event.Opcode))
プロパティ:
$log_property


メッセージ:
$($event.Message)

--------------------------------------------------


"@
}

Send-Gmail $mail $msg

 
テストしてみます。Write-EventLogでイベントログへ書き込みます。
PowerShellでイベントログに情報を出力 at SE の雑記

Write-EventLog -LogName Application -EntryType Error -Source Application -EventId 1001 -Message "test event"

 
その後PowerShellスクリプトを実行し、イベントログの内容がメール送信されることを確認します。

 

一日分のイベントログ内容をメール送信

基本はイベント発生ごとと同じです。

変更箇所は、

  • StartTimeを、(Get-Date).AddDays(-1)へと変更
  • LogNameを、イベントビューアーのカスタムビューにある管理イベントで設定されているものへと変更

です。

管理イベントで設定されているイベント名は、管理イベントのプロパティ > フィルターの編集 > XMLタブを選択し、

<QueryList>
  <Query Id="0" Path="Application">
    <Select Path="Application">*[System[(Level=1  or Level=2 or Level=3)]]</Select>
    <Select Path="Security">*[System[(Level=1  or Level=2 or Level=3)]]</Select>
    <Select Path="System">*[System[(Level=1  or Level=2 or Level=3)]]</Select>
...
  </Query>
</QueryList>

を確認します。この中の<Select>タグのPath属性値がログ名になります(例: Application、Security、System など)。

 
そのため、それらを配列にして、LogNameに渡します。

$log_name = @(
    "Application", "Security", "System"
)

$filter = @{
    LogName = $log_name;
# ...
}

 

タスクスケジューラの設定

以下を参考に、PowerShell向けのタスクを追加します。
Tech TIPS:WindowsのタスクスケジューラーでPowerShellのスクリプトを実行する際には「パス」に注意 - @IT

 

イベントログに出力された時にメール送信
タブ名 項目
全般 セキュリティオプション ●ユーザーがログオンしているかどうかにかかわらず実行する を選択
■最上位の特権で実行する にチェック
トリガー タスクの開始 イベント時
設定 カスタム
トリガーのフィルター イベントレベル 重大、エラー、警告
●ログごと 選択
イベントログ Application,システム
操作 操作 プログラムの開始
プログラム/スクリプト C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
引数の追加 -Command "D:\Sandbox\path\to\send_log_by_event.ps1 System,Application" *1

 

一日分のイベントログ内容をメール送信
タブ名 項目
全般 セキュリティオプション ●ユーザーがログオンしているかどうかにかかわらず実行する を選択
■最上位の特権で実行する にチェック
トリガー タスクの開始 スケジュールに従う
設定 毎日(任意の時間をセット)
〃 - フィルター イベントレベル 重大、エラー、警告
〃 - フィルター ●ログごと 選択
〃 - フィルター イベントログ Application,システム
操作 操作 プログラムの開始
プログラム/スクリプト C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
引数の追加 -Command "D:\Sandbox\path\to\send_log_per_day"

 

ソースコード

GitHubに上げました。
PowerShell_misc/send_event_log_mail at master · thinkAmi/PowerShell_misc

ディレクトリの中にあるファイルはそれぞれ、

  • send_log_when_handled_event.ps1 (イベントログに出力された時にメール送信)
  • send_daily_log.ps1 (一日分のイベントログ内容をメール送信)

です。

 

その他参考

*1:PowerShellの引数は、「.ps1 + 半角スペース + 引数」として設定します

PowerShellを使って、SMTPでGmailを送信する

以前、PowerShellからGmailを送信したことがありました。
Windowsでキーサインパーティに参加した時のまとめ & caffっぽいツールを作ってみた - メモ的な思考的な

 
読み返してみると、

  • System.Net.Mail
  • System.Web.Mail

を使っており、PowerShellSend-MailMessageコマンドレットは使っていませんでした。

 
そこで今回は

  • System.Net.Mail
  • Send-MailMessage

を使ってメールを送信してみます。

 
メールまわりの情報は以下の通りとします。

$mail = @{
    from = "example@gmail.com";
    to = "example+to@gmail.com";
    smtp_server = "smtp.gmail.com";
    smtp_port = 587;
    user = "example@gmail.com";
    password = "1234";
}

 
なお、SMTPのポートについては、Gmailでは465よりも587のほうが良さそうです。

 

環境

 

System.Net.Mail版

$client = New-Object Net.Mail.SmtpClient($mail["smtp_server"], $mail["smtp_port"])

# GmailはSMTP + SSLで送信する
$client.EnableSsl = $true

# SMTP Authのため、認証情報を設定する
$client.Credentials = New-Object Net.NetworkCredential($mail["user"], $mail["password"])

$msg = New-Object Net.Mail.MailMessage($mail["from"], $mail["to"], "subject", "body: Hello Net class")

$client.Send($msg)

 

Send-MailMessage

$password = ConvertTo-SecureString $mail["password"] -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential $mail["user"], $password

Send-MailMessage -To $mail["to"] `
                 -From $mail["from"] `
                 -SmtpServer $mail["smtp_server"] `
                 -Credential $credential `
                 -Port $mail["smtp_port"] `
                 -Subject "subject" `
                 -Body "body: Hello Cmdlet" `
                 -Encoding UTF8 `
                 -UseSsl

 

ソースコード

GitHubに置きました。send_gmail_by_smtpディレクトリ以下が今回のファイルです。
thinkAmi/PowerShell_misc: Misc collection of PowerShell code

 

参考

VS CodeでPowerShellファイルを作成・実行したら文字化けした

今までPowerShellPowerShell ISEを使って書いていたのですが、以下の記事を読んで、Visual Studio Codeで書いてみました。
VS Code での PowerShell サポートが強化されました - tech.guitarrapc.cóm

 
Hello world的な

"Hello world!"
"ハローワールド!"

というexample.ps1スクリプトを作成し、実行してみたところ、

Hello world!
繝上Ο繝シ繝ッ繝シ繝ォ繝・

と、日本語部分が文字化けしたため、対応をメモしておきます。

 

環境

 

原因と対応

VS Codeでファイルを新規作成するときのデフォルトの文字コードUTF-8 (BOMなし)のためです。

 
試しに、日本語が含まれるps1ファイルの文字コード

などにして実行した場合、

Hello world!
ハローワールド!

と正しく表示されました。

 
どの文字コードで保存しておくのが良いのか調べてみたところ、

より、UTF-8 with BOMが良さそうでした。

 
VS Codeでファイルの文字コードGUIで変更する場合は以下の通りです。
特集:Visual Studio Code早分かりガイド:Visual Studio Codeの使い方、基本の「キ」 (3/5) - @IT

今回はコマンドパレットを使ってみます。

  • Ctrl + Shift + Pでコマンドパレットを開く
  • chfと入力しChange File Encodingのみに絞られたところで、Enter
  • sと入力し、Save with Encodingのみに絞られたところで、Enter
  • UTF-8 with BOMを選択

 
再度実行すると、

Hello world!
ハローワールド!

となりました。

 

PowerShell ISEで作成したps1ファイルの文字コードについて

手元のPowerShell ISEでps1ファイルを作成して文字コードを確認するとUTF-8 with BOMでした。

一方、公式資料で確認すると、

By default, Windows PowerShell ISE saves new script files (.ps1), script data files (.psd1), and script module files (.psm1) as Unicode (BigEndianUnicode) by default.

How to Write and Run Scripts in the Windows PowerShell ISE

UTF-16 BEと書かれていました。

 
この違いはどこから来るのかを調べてみましたが、公式資料は見当たらないものの、v3.0から変更になったようでした。

 

文字コードまわりの資料

PowerShell

 

一般

#stapy #glnagano 第7回 Python勉強会 in GEEKLAB.NAGANOに参加しました

7/6にギークラボ長野で開かれた、第7回 Python勉強会 in GEEKLAB.NAGANOに参加しました。
Python勉強会 in GEEKLAB.NAGANO #7 - GEEKLAB.NAGANO | Doorkeeper

 
「みんなのPython勉強会 #14」の東京会場を中継する形での勉強会でした。資料などは以下のページにまとまっています。
みんなのPython勉強会#14 - connpass

 
今回のテーマは「Python x Web」ということで、HTTP通信の基本からスクレイピングまでの盛りだくさんでした。

 

いま、ふたたびのWeb入門 (辻 真吾さん)

Webの基礎から詳しく解説していただきました。

最近PythonでWebサーバを書いてみた直後だったこともあり、自分の理解度の確認と復習ができてよかったです。

 

bottleではじめるWEBアプリの最初の一歩 (山田 聡さん)

Webアプリの最初の一歩ということで、Bottleを使ったWebアプリの説明がありました。

1ファイルで書かれているWebフレームワークであり、レンタルサーバでも動かせる場合もあるので、Bottleは扱いやすく感じています。

 

Reactiveなウェブサイトへの誘い (山下 陽介さん)

DjangoといくつかのJavaScriptフレームワークの紹介が参考になりました。

DjangoとTornadoを併用とあったことから、Tornadoはどんなものか気になったため、今度さわってみようと思いました。

もし資料が公開されたら後で読み返そうと思います。

 
勉強会中にDjango + WebSocket調べてみたところ、HerokuのblogにDjango Channelsの記事がありました。DjangoだけでWebSocketを扱える未来が来るのか、気になりました。
Finally, Real-Time Django Is Here: Get Started with Django Channels | Heroku

 

Webスクレイピング - 小手先の技術 (嶋田 健志さん)

Webスクレイピング用のPythonライブラリの紹介・使い方でした。

自分がさわったことがあったのはlxmlだけだったため、それ以外のライブラリはどんな感じで動かすのか気になっていたので、ためになりました。

Scrapyは気になっていましたが、結構大がかりと感じました。

また、紹介のあった「PythonによるWebスクレイピング」も良さそうでした。

 

その他

今回は平日の定時後ということもあり、

を使って移動しました。

回数券の有効期間は1ヶ月以内であるものの、特急の乗り継ぎもできるようで、なかなか良いものです。

 
最後になりましたが、開催・運営をされたみなさま、ありがとうございました。

特に、勉強会中に東京-長野間中継の調整で手を尽くしてくださったみなさま、ありがとうございました。

Visual Studio CodeでPython + Djangoを書いて、py.testを実行してみた

それなりの規模のDjangoアプリを書く場合、PyCharmなどのIDEを使っています。

ただ、諸般の事情によりIDEが使えないことも考えて、Visual Studio Code(以下VS Code)のPython拡張を試してみました。
Python with Visual Studio Code - Visual Studio Code

 
なお、今回は上記の公式ドキュメント例にもある通り、DonJayamanneさんのPython support for Visual Studio Codeを使います。
DonJayamanne/pythonVSCode: Python support for Visual Studio Code

 
目次

 

環境

 

環境準備

Python support for Visual Studio Codeのインストール

コマンドプロンプトからDjango用のディレクトリを用意し、そのディレクトリでVS Codeを起動します。

# ディレクトリ作成と移動
D:\Sandbox>mkdir vscode_django
D:\Sandbox>cd vscode_django

# カレントディレクトリとして VS Codeを起動 (末尾のドットを忘れずに)
D:\Sandbox\vscode_django>code .

 
VS Codeが起動したら、

  • Ctrl + Alt + P(もしくはF1)でコマンドパレットを起動
  • ei(Extensions: Install Extension) と入力して、拡張機能:拡張機能のインストールを選択
  • Python (DonJayamanne)を選択し、インストール
  • インストール後、VS Codeを再起動

と、Pythonを設定します。

 

Djangoのインストールと確認

今回はVS Code上のターミナルで実行します。ただ、現時点のバージョンではコピー&ペーストができません(↑・↓で入力履歴の再表示は可能)。
ターミナルを統合した「Visual Studio Code」インサイダー版、6月から日毎リリースに - 窓の杜

  • 表示 > Toggle Integrated Terminal (もしくはCtrl + @) を選択
  • 画面の下にターミナルが開く
  • ターミナルに以下を入力
# virtualenv環境を作成
D:\Sandbox\vscode_django>virtualenv -p c:\python35-32\python.exe env
D:\Sandbox\vscode_django>env\Scripts\activate

# pipでインストール
(env) D:\Sandbox\vscode_django>pip install django
...
Successfully installed django-1.9.7

 
続いて、Djangoのインストール先を確認するため、以下のPythonスクリプトを作成します。
Python Tips:ライブラリ・モジュールの場所を調べたい - Life with Python

django_checker.py

import django
print(django.__file__)

 
Debug Viewに切り替えます(Ctrl + Shift + D)。

  • 実行ボタンを押す(F5)
  • 環境の選択を求められるので、Pythonを選択
  • 情報バーにPlease set up the launch configuration file for your applicationが、エディタにlaunch.jsonが表示
  • launch.jsonはデフォルトのまま、django_checker.pyファイルを選択
  • 実行環境でPythonが選択されていることを確認の上、実行ボタンを押す(F5)
  • ファイアウォールのブロック表示がでたら、必要な内容をチェックしてアクセスを許可
  • デバッグコンソールに以下が表示
Traceback (most recent call last):
  File "d:\Sandbox\vscode_django\django_checker.py", line 1, in <module>
    import django
ImportError: No module named 'django'

Djangoが認識されていないことから、virtualenvのPythonインタプリタは使われていないようです。

 
公式ドキュメントによると、対応方法としては

  • settings.jsonpython.pythonPathを指定する
  • virtualenv環境をactivateしてから、VS Codeを起動する
  • launch.jsonpythonPathを指定する
    • 今回の場合、"pythonPath": "${workspaceRoot}/env/Scripts/python.exe",
    • ただし、この場合はデバッグ時だけ有効で、エディタのインテリセンスなどが効かない

の3つが挙げられていました。
Python Path and Version · DonJayamanne/pythonVSCode Wiki

エディタのインテリセンスは効かせたいこと、グローバルな設定は避けたいことから、今回はvirtualenv環境をactivateしてから、VS Codeを起動する方法をとります。

 
そのため、いったん VS Codeを閉じてから、以下のようにVS Codeを起動します。

# virtualenvをon
D:\Sandbox\vscode_django>env\Scripts\activate

# VS Codeの起動
(env) D:\Sandbox\vscode_django>code .

 
再度、django_checker.pyファイルを選択し、デバッグ実行(F5)します。デバッグコンソールには

D:\Sandbox\vscode_django\env\lib\site-packages\django\__init__.py

と表示され、virtualenvのPythonインタプリタで実行されるようになりました。

 

Djangoアプリの作成

It worked!の確認

VS Code ターミナルを起動し(Ctrl + @)、Djangoアプリを作成します。

(env) D:\Sandbox\vscode_django>django-admin startproject myproject .
(env) D:\Sandbox\vscode_django>python manage.py startapp myapp

また、myproject/settings.pyINSTALLED_APPSに、上記作成のアプリmyappを追加します。

 
Djangoアプリができたため、

  • Debug viewに切り替える(Ctrl + Shift + D)
  • Djangoを選んでから、実行ボタンを押す(F5)
  • manage.pyの一行目のところで停止するが、そのまま続行(F5)
  • Debug Consoleに開発サーバの起動が表示
  • ブラウザでhttp://localhost:8000/へとアクセスすると、It worked!が表示されます。

を行い、動作を確認します。

 
なお、該当するlaunch.jsonの内容は

{
    "name": "Django",
    "type": "python",
    "request": "launch",
    "stopOnEntry": true,
    "program": "${workspaceRoot}/manage.py",
    "args": [
        "runserver",
        "--noreload"
    ],
    "debugOptions": [
        "WaitOnAbnormalExit",
        "WaitOnNormalExit",
        "RedirectOutput",
        "DjangoDebugging"
    ]
},

でした。

--noreloadがあることから、オートリロードされないのがデフォルトのようです。もし、オートリロードしたい場合はその部分を削除します。

 

Djangoアプリへ機能を追加

Hello worldを表示するよう、機能を追加します。

myproject/urls.py

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

 
myapp/urls.py

from django.conf.urls import url
from django.views.generic.base import TemplateView

urlpatterns = [
    url(r'^$', 
        TemplateView.as_view(template_name='hello.html'),
        name='hello'),
]

 
myapp/templates/hello.html

<h1>Hello world!</h1>

 
完成したため、

  • Djangoを選択して実行ボタンを押す(F5)
  • http://localhost:8000/myapp/へアクセスすると、Hello world!が表示

にて動作を確認します。

 

py.testの設定と実行

以前、Djangoアプリをpytest-djangoでテストしたことがあったため、同じように作業します。
Djangoアプリについて、pytest-djangoを使ってテストしてみた - メモ的な思考的な

 
ターミナルにてインストールします。

(env) D:\Sandbox\vscode_django>pip install pytest pytest-django

 
pytest.iniを書きます。

pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE=myproject.settings
norecursedirs=env

 
URL解決のテストを書きます。

py.testのデフォルトでは、test_*.pyというファイル名であればテストファイルとみなされます。そのため、Djangoが自動生成したmyapp/tests.pyはテストの対象とはなりません。
My tests are not being found. Why not? | FAQ — pytest-django documentation

そこで、myapp/tests/ ディレクトリを作成し、その中にテストファイルtest_urls.pyを作成することにします。

 
まずはエラーとなるよう、テストコードを書きます。

myapp/tests/test_urls.py

# myapp.urls.py
# urlpatterns = [
#    url(r'^$', TemplateView.as_view(template_name='hello.html'), name='hello'),]

# テストコード
class URL解決テスト(TestCase):
    def test_helloのパスが404とならないこと(self):
        try:
            resolve('/mysite/hello')
        except Resolver404:
            pytest.fail('raise error')

 
ターミナルから実行します。

(env) D:\Sandbox\vscode_django>py.test

...
myapp\tests\test_urls.py:11: Failed

エラーになりました。また、一部が文字化けしています。

 
テストが通るように修正します。

myapp.urls.py

urlpatterns = [
    url(r'^hello$', 
        TemplateView.as_view(template_name='hello.html'),
        name='hello'),
]

 
もう一度テストします。

(env) D:\Sandbox\vscode_django>py.test
...
========================== 1 passed =...

テストが通りました。

 
以上より、VS Codeを使ってPython + Djangoを書いて、py.testを実行することができました。

 

IDE機能

IDE機能もありました。

 

インテリセンス

f:id:thinkAmi:20160705220750p:plain

 

メソッドの引数の表示

f:id:thinkAmi:20160705220802p:plain

 

コードナビゲーション
  • Go To Definition(定義へ移動)
  • Peek Definition(定義をここに表示)
  • Find All References(すべての参照の検索)

f:id:thinkAmi:20160705220812p:plain

 

linter設定

Linting · DonJayamanne/pythonVSCode Wiki

 
他にもいろいろとありました。

なお、現時点では、タブはまだprerelease Insiders buildのため、今後に期待します。
Tabbed editor support in Visual Studio Code

2016/7/9 追記

先日リリースされた1.3.0にてタブ機能が追加されました。

2016/7/9 追記おわり

 

ソースコード

GitHubにあげておきました。
thinkAmi-sandbox/vscode_django-sample

なお、デバッグ時のみvirtualenv環境が使われるのを確認するために、pythonPathを追加した.vscode/launch.jsonも入れてあります。

 

その他参考

 
なお、公式ドキュメントではlaunch.jsonに関する説明が見当たりませんでした。そのため、以下を参考にしました。
特集:VS Code早分かりガイド:Visual Studio CodeでNode.jsアプリをデバッグする (2/4) - @IT