msizの日記

ソフトウェア関係の覚え書きが中心になる予定

「Python3 + Flask + GitLabのOAuth2認可」サンプルを作成しました

結構時間が経ってしまいましたが、先日の記事「「Python3 + Flask + はてなAPIのOAuth認証」サンプルを作成(移植?)しました - msizの日記」の続きです。

前回はOAuth1認可のサンプルでしたが、今回はOAuth2のサンプルを作成しました。認可側は、将来コードの公開とかに便利かもしれないので、GitLabにしています。(GitHubの方がメジャーだと思いますが、無料アカウントを以前に作ったことがあり、個人で2つ無料アカウントを持てないようなので、念のために避けました)

フレームワークとして Flaskを、OAuth ライブラリとして requests-oauthlibを利用したサンプルプログラムです。


簡単な使い方は以下のとおり。

  1. GitLabにサインアップ・サインインして、OAuth2認可したいアプリを登録

    • アプリ登録の手順はGitLabの資料を参照
    • アプリを登録するとき、以下の点に注意してください
      • 「scopes」設定で「read_user」にチェックを入れる
      • 「Redirect URL」に「http://localhost:5000/callback」を含める
      • 登録完了後に表示される「Application Id」と「Secret」を控えておく(後で使います)
  2. python3でvenv環境を作成します
  3. Flask と requests-oauthlibをvenv環境でインストールします
  4. 下のPythonコードを保存します (oauth_samle_app.py とします)
  5. 環境変数 CLIENT_KEY, CLIENT_SECRET に、手順1で登録したアプリの Application Id, Secret を設定して実行します

Linux, bash系の場合の実行例)

$ python3 -m venv gitlab-oauth-sample
$ cd gitlab-oauth-sample
$ source bin/activate # 大抵は、この後プロンプトの表示が変化します。ちなみにvenv環境ぬけるときは「deactivate」を実行
$ pip install Flask requests-oauthlib
$ vim oauth_samle_app.py # vimに限らず好きな方法で、Pythonコードを保存
$ env CLIENT_KEY=<YOUR_KEY> CLIENT_SECRET=<YOUR_SECRETE> python oauth_samle_app.py

... で起動してから http://localhost:5000 に Web ブラウザでアクセスして下さい。 うまくいけば、json形式で返された、GitLabでのプロフィールが表示されます。

※なお、Flaskに関してはクイックスタートチュートリアル(日本語訳)を参考にしています。


(ここから「oauth_samle_app.py」コード)

from requests_oauthlib import OAuth2Session
from flask import Flask, request, redirect, session, url_for
from flask.json import jsonify
import os

app = Flask(__name__)


# This information is obtained upon registration of a new Gitlab OAuth
# application here: https://gitlab.com/oauth/applications/
client_id = os.environ.get("CLIENT_KEY", "<your client key>")
client_secret = os.environ.get("CLIENT_SECRET", "<your client secret>")

# gitlab api urls and OAuth2 scope
authorization_base_url = 'https://gitlab.com/oauth/authorize'
token_url = 'https://gitlab.com/oauth/token'
user_api_url = 'https://gitlab.com/api/v4/user'
scope = ['read_user']

@app.route("/")
def demo():
    """Step 1: User Authorization.

    Redirect the user/resource owner to the OAuth provider (i.e. Gitlab)
    using an URL with a few key OAuth parameters.
    """
    redirect_uri = url_for('.callback', _external=True)
    app.logger.debug("redirect_uri: {}".format(redirect_uri))
    gitlab = OAuth2Session(client_id, redirect_uri=redirect_uri, scope=scope)
    authorization_url, state = gitlab.authorization_url(authorization_base_url)

    # State is used to prevent CSRF, keep this for later.
    session['oauth_state'] = state
    return redirect(authorization_url)


# Step 2: User authorization, this happens on the provider (gitlab.com).

@app.route("/callback", methods=["GET"])
def callback():
    """ Step 3: Retrieving an access token.

    The user has been redirected back from the provider to your registered
    callback URL. With this redirection comes an authorization code included
    in the redirect URL. We will use that to obtain an access token.
    """

    redirect_uri = url_for('.callback', _external=True)
    gitlab = OAuth2Session(client_id, state=session['oauth_state'], redirect_uri=redirect_uri)
    app.logger.debug("request.url: {}".format(request.url))
    token = gitlab.fetch_token(token_url, client_secret=client_secret,
                               code=request.args.get('code'))

    # At this point you can fetch protected resources but lets save
    # the token and show how this is done from a persisted token
    # in profile() func.
    session['oauth_token'] = token

    return redirect(url_for('.profile'))


@app.route("/profile", methods=["GET"])
def profile():
    """Fetching a protected resource using an OAuth 2 token.
    """
    gitlab = OAuth2Session(client_id, token=session['oauth_token'])
    return jsonify(gitlab.get(user_api_url).json())


if __name__ == "__main__":
    # This allows us to use a plain HTTP callback
    os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = "1"

    app.secret_key = os.urandom(24)
    app.run(host='0.0.0.0', port=5000, debug=True)

「Python3 + Flask + はてなAPIのOAuth認可」サンプルを作成(移植?)しました

gistにあるPythonではてなの OAuth 対応 API を利用する をベースに、Python3 対応の OAuth1 サンプルにしました(記事作成時点で、はてなはOAuth2に対応していない)。

フレームワークとして Flask を、OAuth ライブラリとして requests-oauthlibを利用したサンプルプログラムです。

  1. はてな側の準備)はてなに新しいアプリケーションを登録(consumer_keyとconsumer_secretを用意)します(やり方は「[python3][プログラミング]Python3 はてな APIのOAuth認証する(ほぼコピペでOK) - lisz-works」あたりを参照ください)
    • ここで作成した「新しいアプリケーション」は削除できるか不明なので、「my-oauth-test」みたいな感じで、いつでも無効にできるような名前にすると無難かと思います
    • 権限は read_public だけで構いません(余計な権限はつけない方が無難です)
  2. python3でvenv環境を作成します
  3. Flask と requests-oauthlibをvenv環境でインストールします
  4. 下のPythonコードを保存します (oauth_consumer.py とします)
  5. 環境変数 CONSUMER_KEY, CONSUMER_SECRET に自分の consumer_key, consumer_secret を設定して実行します

Linux, bash系の場合の実行例)

$ python3 -m venv hatena-oauth-sample
$ cd hatena-oauth-sample
$ source bin/activate # 大抵は、この後プロンプトの表示が変化します。ちなみにvenv環境ぬけるときは「deactivate」を実行
$ pip install Flask requests-oauthlib
$ vim oauth_consumer.py # vimに限らず好きな方法で、Pythonコードを保存
$ CONSUMER_KEY=<YOUR_KEY> CONSUMER_SECRET_KEY=<YOUR_SECRETE> python oauth_consumer.py

... で起動してから http://localhost:5000 に Web ブラウザでアクセスして下さい。

OAuth認証が失敗してそうな時は、コマンド実行した端末に表示されているログを確認してください。

また、 Flask のデバッグ・モードを有効にしているので、例外の stacktrace がブラウザに表示された例外画面になった場合は、 ブラウザ上で任意の frame で Python コマンドを実行できます(やり方は、例外画面のメッセージを確認してください)。

作業中に入れたコメントのいくつかは、参考までに残しています。Flask、OAuthはよく分かってないので、お気づきの点などあればコメントしてもらえるとありがたいです。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# oauth_consumer.py
import os
import urllib.parse

try:
    import json
except ImportError:
    import simplejson as json

import flask
from flask import (
    session, request, redirect, url_for, render_template_string,
)
#import oauth2 as oauth
import requests
from requests_oauthlib import OAuth1

SECRET_KEY = 'FLASK_SEC_KEY' # used by flask to encrypt session info
SCOPE = 'read_public'
CONSUMER_KEY = os.environ.get('CONSUMER_KEY', 'HATENA_CONSUMER_KEY')
CONSUMER_SECRET = os.environ.get('CONSUMER_SECRET_KEY', 'HATENA_SECRET_KEY')
DEBUG = True

REQUEST_TOKEN_URL='https://www.hatena.com/oauth/initiate'
ACCESS_TOKEN_URL='https://www.hatena.com/oauth/token'
AUTHORIZE_URL='https://www.hatena.ne.jp/oauth/authorize'
HATENA_USERINFO_URL='http://n.hatena.com/applications/my.json'

app = flask.Flask(__name__)
app.secret_key = SECRET_KEY
app.config['DEBUG'] = DEBUG

TEMPLATE = """
<html>
    <head>
    <titile></title>
    </head>
    <body>
        {% if user %}
            <p>
                Hello {{ user.display_name }}(id:{{ user.url_name }})
                <img src="{{ user.profile_image_url }}">
            </p>
            <p><a href="{{ url_for('logout') }}">LOGOUT</a></p>
        {% else %}
            <p>Hello GUEST</p>
            <p><a href="{{ url_for('login') }}">LOGIN</a></p>
        {% endif %}
    </body>
</html>
"""

@app.route('/')
def index():
    app.logger.debug("index() is called")
    ctx = { 'user': None}
    access_token = session.get('access_token')
    if access_token:
        # access_tokenなどを使ってAPIにアクセスする
        auth = OAuth1(
            CONSUMER_KEY,
            CONSUMER_SECRET,
            access_token['oauth_token'], # 'resource_owner_key' in OAuth1 param
            access_token['oauth_token_secret']) # 'resource_owner_secret'
        r = requests.post(HATENA_USERINFO_URL, auth=auth)
        if not r.ok:
            on_oauth_error(r)
        ctx['user'] = json.loads(r.text)
    return render_template_string(TEMPLATE, **ctx)

# リクエストトークン取得から認証用URLにリダイレクトするための関数
@app.route('/login')
def login():
    # リクエストトークンの取得
    auth = OAuth1(
        CONSUMER_KEY,
        CONSUMER_SECRET,
        callback_uri=url_for('on_auth', _external=True)) # need '_external' flag to avoid rejection from hatena oauth server
    app.logger.debug("auth: {}".format(vars(auth)))
    # scope を POST データで指定して、 request_token 取得 URL へ POST 実行
    r = requests.post(REQUEST_TOKEN_URL,
                        auth=auth,
                        data='scope={}'.format(urllib.parse.quote(SCOPE)))
    if not r.ok:
        on_oauth_error(r)
    request_token = dict(urllib.parse.parse_qsl(r.text))

    # セッションへリクエストトークンを保存しておく
    app.logger.debug("request_token: {}".format(request_token))
    session['request_token'] = request_token
    # 認証用URLにリダイレクトする
    return redirect('{}?oauth_token={}'.format(
                    AUTHORIZE_URL,
                    session['request_token']['oauth_token']))

# セッションに保存されたトークンを破棄しログアウトする関数
@app.route('/logout')
def logout():
    if session.get('access_token'): session.pop('access_token')
    if session.get('request_token'): session.pop('request_token')
    return redirect(url_for('index'))

# 認証からコールバックされ、アクセストークンを取得するための関数
@app.route('/on-auth')
def on_auth():
    # login()処理中に session へ保存していた request_token を取得
    request_token = session.get('request_token')
    # 認証用URLからのリダイレクト時に設定された oauth_verifier を取得
    oauth_verifier = request.args['oauth_verifier']

    # request_token と oauth_verifier を用いてアクセストークンを取得
    auth = OAuth1(
        CONSUMER_KEY,
        CONSUMER_SECRET,
        request_token['oauth_token'], # 'resource_owner_key' in OAuth1 param
        request_token['oauth_token_secret'], # 'resource_owner_secret'
        verifier=oauth_verifier)
    r = requests.post(ACCESS_TOKEN_URL, auth=auth)
    if not r.ok:
        on_oauth_error(r)
    access_token = dict(urllib.parse.parse_qsl(r.text))
    # セッションに対し、request_token を削除、アクセストークンを記録
    if session.get('request_token'): session.pop('request_token')
    session['access_token'] = access_token
    return redirect(url_for('index'))

def on_oauth_error(res):
    if not res.ok:
        app.logger.debug("response header: {}".format(res.headers))
        app.logger.debug("response content: {}".format(res.content))
        res.raise_for_status()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

2019-11-30追記:「OAuthは認証ではなく認可」という説明をどこかで 目にしたので、タイトルを変更しました(認証→認可)。

blogsyncでブログを投稿

これはblogsyncを使って投稿しています。

pullすると拡張子「.md」でファイルが保存されたんですが、記法はMarkdown限定なんだろうか?それとも拡張子には関係なく、管理画面の基本設定メニューにある「編集モード」での指定(「見たまま」「はてな記法」「Markdown」のいずれかを選べる)に従う?

あと、認証にはblog管理画面の設定メニューで確認できるAPIKEYを使っているんで、OAuthではなくWSSE認証かな?

lenovo E130でVirtualboxを実行するとCPU使用率が70-80%になる

タイトルの通りですが、少し前から手元のlenovo E130でVirtualboxを実行するとCPU使用率が70-80%になる現象が起きていました。

先日は間違えて休止状態にしないまま持ち運んだら、リュックサックの外側からでも分かるくらいマシン本体がものすごく熱くなってしまいました。

あと、これは関係あるか分かりませんがVirtualboxしばらく使っていると画面が黒くなって点滅しているマウスカーソルしか表示されないとか、resourceがど~たらというエラーでVirutalboxが再起動できなくなったりFirefoxがサイトへアクセスしなくなったり、いろいろ不便でした。

とりあえず、手元の環境では原因が分かって解決したので、せっかくだからメモ。

原因

LenovoノートPCのカメラ関係のアプリっぽい「vcamsvc.exe」が、Virtualboxを起動するとCPUを50%程度使うようになる(CPUコアのひとつ分を全部使ってたのかも)。

対応

参照サイト: Windows7でCPU使用率が100%のまま下がらない原因と対応 | 雑記帳

  1. 「コントロールパネル ⇒ 管理ツール ⇒ サービス」でサービスの管理ツール?を起動。
  2. 一覧から「ThinkVantage Virtual Camera Controller」を右クリックして「停止」。

念のため、右クリックしてプロパティを表示してから、「スタートアップの種類」を「無効」にもしておきました。

調査方法

  1. タスクマネージャを実行して「パフォーマンス」タブから「リソースモニター」を実行*1
  1. リソースモニターで「CPU」タブを選択して、「プロセス」一覧を表示(「プロセス」行しかない場合は一覧が折り畳まれているので、右の方にある小さな丸いボタンを押す)。
  2. 「CPU」でソートして、CPUを使用しているプロセスを調査。

*1:タスクマネージャの「プロセス」タブの一覧では見つけられませんでした

Packaging Python Projectsの日本語仮訳

少し調べてたんで、はてな記法の練習も兼ねてPackaging Python Projects — Python Packaging User Guideの日本語版の仮訳を投稿。

原文のライセンスはCreative Commons Attribution-ShareAlikeというものですので、必要に応じて参照ください。

当然、日本語の誤記などは私の責任なので、そのあたりは原文の作者へは問合せしないようにしてください。

続きを読む

Sphinxで日本語文章の不自然な空白をなくしたい

Sphinx拡張機能を使って日本語文章の不自然な空白をなくす

sphinx(日本のユーザ会)で、htmlなどを作成する元ネタrestructuredtextファイルを作成するとき日本語の途中で改行を入れると、htmlなどへ変換したときにその改行箇所で空白文字が入ってしまい、ブラウザで見るときにちょっと見た目が悪くなったりします。

5年くらい前に以下の記事で対応策っぽいものを書いてましたが、数年振りにSphinxを使ってみたら、当時の記事の内容をすっかり忘れていたのでメモ。
msiz.hatenablog.jp

conf.pyの編集(1)

sphinx-quickstart」を実行したりして作成されるSphinxの設定ファイル「conf.py」で、拡張機能(を実装したPythonモジュール・パッケージ)を探すpathを調整:

...

import os
import sys
sys.path.insert(0, os.path.abspath('exts'))

...

sys.pathに追加しているだけですね。この例の場合、conf.pyのあるディレクトリに「exts」ディレクトリを作成し、そこに追加のPythonファイルを置けば探し出して読み込んでくれる。

なお、「sphinx-quickstart」で作成したconf.pyだと、それっぽい記述がコメントアウトされているので、そこを編集。

拡張機能を実装

今回は「japanese_trunc_whitespace.py」というファイル名にして、「exts」に置きました。

#!/usr/bin/python

#from docutils.core import publish_doctree, publish_from_doctree
#import sys, codecs, re, optparse
from docutils.nodes import paragraph, Text
import re

def japanese_truncate_whitespace(app, doctree, docname):
   para_list = get_para_list(doctree)
   strip_spaces_between_uchars(para_list)
   strip_spaces_around_uchars_paragraph_children(para_list)

# in order no to change literals, select only 'paragraph' nodes
#
def get_para_list(dtree):
  para_list = []
  for node in dtree.traverse():
    if isinstance(node, paragraph): para_list.append(node)
  return para_list

def strip_spaces_between_uchars(para_list):
  # non-ascii [\n\r\t] non-ascii
  __RGX = re.compile(r'([^!-~])[\n\r\t]+([^!-~])')
  # modify text inside Text node
  #
  for para in para_list:
    for node in para.traverse():
      if isinstance(node, Text):
        newtext = node.astext()
        newtext = __RGX.sub(r"\1\2", newtext)
        node.parent.replace(node, Text(newtext))

def strip_spaces_around_uchars_paragraph_children(para_list):
  # non-ascii [\s]* <End-of-TEXT>
  __RGX1 = re.compile(r'([^!-~])[\s]*$')
  # <Beginning-of-TEXT> [\s]* non-ascii
  __RGX2 = re.compile(r'^[\s]*([^!-~])')
  # modify texts over 2 nodes
  # (paragraph node can have children of Inline (reference, etc) nodes)
  #
  for para in para_list:
    prev_textnode = Text("")
    for node in para.traverse():
      new_textnode = None
      if isinstance(node, Text):
        prevtext = prev_textnode.astext()
        newtext = node.astext()
        if __RGX1.search(prevtext) and __RGX2.search(newtext):
          new_prev_textnode = Text(prev_textnode.astext().rstrip())
          new_textnode = Text(newtext.lstrip())
          prev_textnode.parent.replace(prev_textnode, new_prev_textnode)
          node.parent.replace(node, new_textnode)
          new_prev_textnode.parent = prev_textnode.parent
          new_textnode.parent = node.parent
        prev_textnode = new_textnode if new_textnode else node

def setup(app):
    app.add_config_value('japanese_trunc_whitespace', True, True)
    app.connect("doctree-resolved", japanese_truncate_whitespace)

最後の「setup(app)」で、今回の拡張機能を使用するかを「conf.py」から設定できるようにしています。

conf.pyの編集(2)

conf.pyの適当な場所で、以下の記述を追加

...

extensions = [
  'japanese_trunc_whitespace',
  ... # 他にも使用する拡張機能がある場合は指定
]
japanese_trunc_whitespace = True

...


上手くいけば、「make html」とかで作成したhtmlで、改行箇所の余分な空白がなくなるはず

IBM Cloudでテスト用gitリポジトリを作成

取り急ぎ、個人用のメモなので、分かる人しかわからない書き方をします。

以下の準備はできていることを前提として、説明は省略

  • IBM Cloudのアカウント作成
  • Cloud Foundryクライアントcliのインストール
  • Cloud FoundryクライアントcliでのIBM Cloudへのログイン

なお、手元ではVirtualbox上でCentOSをゲストOSにして作業しているので、それに依存している記述があるかも。

手順など

ここから、手順などのメモ

ローカルの作業用ディレクトリを用意し、必要なファイルを作成

mkdir /tmp/gitweb-app
cd /tmp/gitweb-app
vim requirements.txt
vim manifest.yml

作成するファイルの内容は以下のとおり

  • requirements.txt
dulwich
  • manifest.yml
applications:
- name: dulwich-web
  routes: # <unique-prefix>は独自の値に変える
  - route: unique-prefix-dulwich-web.mybluemix.net
  memory: 32M
  command: git init $HOME/gitrepo && python -m dulwich.web -l 0.0.0.0 -p $PORT $HOME/gitrepo

IBM CloudのPublic Cloud Foundryにデプロイ

cf push

動作確認

git clone http://unique-prefix-dulwich-web.mybluemix.net gitrepo1
cd gitrepo1
echo test > testfile.txt
git add testfile.txt
git commit -m "test commit" testfile.txt
git push origin master

注意事項など

以下の点などから、あくまでテスト用の使用にとどめましょう。