2020年7月13日月曜日

Flask-Migrate を使ってみた

概要

Flask + SQLAlchemy を使う場合には専用のライブラリがあります
データベースに変更を加える際のマイグレーションも専用のライブラリが用意されているので使ってみました

環境

  • macOS 10.15.5
  • MySQL 8.0.19
  • Python 3.8.3
  • Flask-Migrate 2.5.3

MySQL 準備

今回は Homebrew で最新版をインストールします
最新版だと Python の mysqlclient がエラーになりますがトラブルシューティングに記載の方法で修正できるので最新版を使います

  • brew install mysql
  • brew services start mysql

また事前にデータベースを作成しておきましょう
ユーザやパスワードはデフォルトのものを使います

  • mysql -u root -e "createdb test;"

ライブラリインストール

必要なライブラリをインストールしましょう
今回は MySQL を使うのでドライバとして mysqlclient を追加でインストールします

  • pipenv install Flask-Migrate mysqlclient

Flask アプリの作成

マイグレーションするには Flask のアプリが必要になります
簡単なアプリで良いので作成しましょう

  • vim my_app/__init__.py
from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

マイグレーション定義の追加

作成した Flask アプリにマイグレーションの定義を追加します
本当は別モジュールで管理したいところですが Flask-Migrate の仕様上そのままアプリに定義を追加します

from flask import Flask
app = Flask(__name__)

# migrate
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app.config['SQLALCHEMY_DATABASE_URI'] = "mysql+mysqldb://{}:{}@{}/{}?charset=utf8".format("root", "", "localhost", "test")
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True

db = SQLAlchemy(app)
migrate = Migrate(app, db)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(128))

@app.route('/')
def hello_world():
    return 'Hello, World!'

マイグレーションするテーブル定義は db.Model を継承したクラスを作成することで行います
このあたりは SQLAlchemy の使い方をそのまま流用できます
また Migrate クラスのオブジェクト (migrate) をこのファイル内のどこかで必ず生成する必要があります
これが生成されていないとトラブルシューティングにある KeyError が発生します

マイグレーションリポジトリの初期化

まずはマイグレーションするための専用のリポジトリを作成する必要があります
実行時に FLASK アプリが存在しているモジュール名を指定しましょう
あとは flask db init コマンドを実行すれば OK です

  • FLASK_APP=my_app pipenv run flask db init

成功すると migrations ディレクトリ配下にマイグレーションに必要なファイルが作成されます
どうやら alembic というマイグレーションライブラリの仕組みを使っているようです

マイグレーションスクリプトの作成

次にマイグレーションするためのスクリプトを作成します
この段階ではまだテーブルなどの作成、変更はされません
コマンドは flask db migrate になります (コマンドを見るとマイグレーションされそうですがされません)
-m を使ってマイグレーション時のコメントを残すことができます
コメントは履歴に残るのでどういったマイグレーションだったのかわかりやすいコメントを残しましょう

  • FLASK_APP=my_app pipenv run flask db migrate -m "Initial migration."
  • FLASK_APP=my_app pipenv run flask db history
<base> -> 2367123248c0 (head), Initial migration.

以下のように成功すれば OK です

INFO [alembic.runtime.migration] Context impl MySQLImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added table 'user' Generating /path/to/work/migrations/versions/2367123248c0_initial_migration.py … done

migrations/versions/2367123248c0_initial_migration.py がマイグレーションに使用されたスクリプトになるので実際に実行されるコマンドを確認したい場合はこのファイルを確認しましょう

アップグレードしてみる

では実際にテーブルを作成してみます
コマンドは flask db upgrade になります

  • FLASK_APP=my_app pipenv run flask db upgrade
INFO [alembic.runtime.migration] Context impl MySQLImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade -> 2367123248c0, Initial migration.

実行されたマイグレーションスクリプトが先程作成されたスクリプトであることが確認できます
これで実際に MySQL を覗いてみるとテーブルが作成されているのが確認できると思います

  • mysql -u root test -e "desc user;"
+——-+————–+——+—–+———+—————-+ | Field | Type | Null | Key | Default | Extra | +——-+————–+——+—–+———+—————-+ | id | int | NO | PRI | NULL | auto_increment | | name | varchar(128) | YES | | NULL | | +——-+————–+——+—–+———+—————-+

データベースの構成を変更してみる

とりあえずテーブルを作成することはできました
今度はカラムを一つ追加するマイグレーションをしてみます

  • vim my_app/__init__.py
from flask import Flask
app = Flask(__name__)

# migrate
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app.config['SQLALCHEMY_DATABASE_URI'] = "mysql+mysqldb://{}:{}@{}/{}?charset=utf8".format("root", "", "localhost", "test")
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True

db = SQLAlchemy(app)
migrate = Migrate(app, db)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(128))
    age = db.Column(db.Integer) # <- New

@app.route('/')
def hello_world():
    return 'Hello, World!'

こんな感じでカラムを一つ追加してみましょう
そして migrate -> upgrade してみます

  • FLASK_APP=my_app pipenv run flask db migrate -m "Add a new column."
  • FLASK_APP=my_app pipenv run flask db upgrade

先ほどと同様にマイグレーションスクリプトが作成されてそれが実行されたのが確認できると思います
MySQL を直接確認してもわかると思います
なお history でもちゃんとマイグレーションの履歴が追加されているのが確認できると思います

2367123248c0 -> 1f939fcbbde7 (head), Add a new column.
<base> -> 2367123248c0, Initial migration.

ダウングレードもできる

ちなみにロールバックするコマンドもあり downgrade を使います

  • FLASK_APP=my_app pipenv run flask db downgrade

この場合 history は変わらないので注意しましょう
現在どこまでマイグレーションスクリプトが適用されているか確認するのは current コマンドを使います

  • FLASK_APP=my_app pipenv run flask db current

heads や show コマンドはマイグレーションスクリプトの現在の最新を確認するコマンドなのでごっちゃにならないようにしましょう

おまけ: Python スクリプトとしても実行できる

今回は flask コマンドのサブコマンドとして実行しましたが普通に python コマンドからでも実行できます
Flask-Script というライブラリが追加で必要になるのでインストールします

  • pipenv install Flask-Script

スクリプトは Flask アプリのモジュールが参照できるパスに作成する必要があります

  • vim my_app/__init__.py
from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

マイグレーション用のスクリプトは少し変わります
先程インストールした Flask-Script の Manager クラスを使います
またスクリプトとして実行できるように main を追加します

  • vim manager.py
from my_app import app
from flask_sqlalchemy import SQLAlchemy
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand

app.config['SQLALCHEMY_DATABASE_URI'] = "mysql+mysqldb://{}:{}@{}/{}?charset=utf8".format("root", "", "localhost", "test")
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True

db = SQLAlchemy(app)
migrate = Migrate(app, db)

manager = Manager(app)
manager.add_command('db', MigrateCommand)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(128))
    age = db.Column(db.Integer)

if __name__ == '__main__':
    manager.run()

あとは init -> migrate -> upgrade の流れを実行すれば OK です
実行するときは作成した manager.py を python コマンドで実行します

  • FLASK_APP=my_app pipenv run python manager.py db init
  • FLASK_APP=my_app pipenv run python manager.py db migrate -m "Initial migration."
  • FLASK_APP=my_app pipenv run python manager.py db upgrade

またスクリプト版をテストする場合は MySQL 側のデータも作成おきましょう

  • mysql -u root test -e "drop table alembic_version;"
  • mysql -u root test -e "drop table user;"

最後に

Flask + Flask-Migrate を使って MySQL のマイグレート管理をしてみました
ディレクトリとファイルの構成にルールがあるのでそこだけ注意しましょう
これでデータベースのマイグレーションの変更履歴もちゃんと git で管理できるようになります

トラブルシューティング

KeyError: 'migrate' が出る場合

マイグレーションする場合は FLASK_APP で指定したアプリのモジュール配下にある __init__.py を使います
そこに Migrate オブジェクトの migarate が記載されていることを想定しているので別ファイルや別モジュールにマイグレーションの定義をしている場合は KeyError になるようです
ファイルの構成やディレクトリの構成を見直すのとちゃんと __init__.py に migrate オブジェクトが存在するか確認しましょう

Library not loaded: /usr/local/opt/mysql/lib/libmysqlclient.20.dylib

おそらく Mac の環境でなければでないと思います
今回使用した MySQL のバージョンは最新の 8.0.19 になります
その場合はクライアント用のライブラリが /usr/local/opt/mysql/lib/libmysqlclient.21.dylib になるようで Python の mysqlclient ライブラリだとまだそれを使用していないようでエラーになります

一番てっとり早いのは libmysqlclient.20.dylib のシンボリックリンクを作成すれば解決するはずです

  • cd /usr/local/opt/mysql/lib
  • ln -s libmysqlclient.dylib libmysqlclient.20.dylib

参考サイト

2020年7月12日日曜日

マウスの調子が悪くなったら六角ドライバを使ってマウスを解体して掃除してみよう

概要

マウスホイールやクリックの調子が悪い場合はマウスを解体してお掃除するともとに戻ることが多いです
今回は iBuffalo のマウスを解体して掃除してみました

環境

  • iBuffalo BSMBW28S
  • 六角ドライバ

使用する六角ドライバ

アマゾンで 980 円で購入した精密ドライバセットを使います
多くの種類の六角ドライバが付属されているのでこれがあれば大抵の六角ドライバは開けられるようになります
他にも三角や五角形ドライバも使えます

あと使う道具としてカッターやマイナスドライバがあるとマウスのカバーを外すときに便利です

写真だとわかりづらいですが今回使う六角ドライバです
かなり小さいドライバでないと外せませんでした

ネジを外す

今回のマウスは全面にネジがついています
まずは全面のカバーを外しましょう、これは後ろにあるボタンを押せば簡単に外せます

電池も取りましょう

ネジは三箇所ありました
それぞれ外しましょう

カバーを外す

カバーは 2 つあり灰色のカバーと更にその外側にある白色のカバーを外します
まずは灰色ですがこちらのほうが外しにくくなっていました
手だと難しいので初めは隙間にカッターなどを挿し込んでテコの原理で外していきましょう

灰色のカバーを外した後に更に白色のカバーを固定するネジがあったのでこれも外します
同じサイズの六角ドライバで開けられました

これもカッターなどでゆっくり外しましょう

ホコリを取る

ここまでくれば基盤部分も外せます
ホイール部分やセンサ部分に溜まっているホコリをティッシュで取りましょう

今回のマウスの場合、クリック用のボタンが単純なタクトスイッチなのでもしボタンのバネなどがおかしくなってクリックできない場合は基盤を裏返してタクトスイッチだけはんだ付けして交換してもよいかなと思います

最後に

iBuffalo のマウスを解体して掃除してみました
結構ホコリが溜まるようなので調子が悪くなったら掃除してみると復活するかもしれません
電子部品を壊れてしまった場合は交換することもできそうですが特殊なスイッチを使っていたり PCB 基盤上の電子部品を交換するのはかなり難しいのでそれらが壊れた場合はあきらめて新品を購入しましょう

2020年7月11日土曜日

iPhone で VR をやろう

概要

iPhone で VR を体験するには VR ゴーグルが必要です
VR ゴーグル自体はかなり安く購入できます
今回は以下の VR ゴーグルを試してみました

環境

  • iPhoneXR
  • VR SHINECON

開封

まずは開封します

中身はゴーグル本体の他、専用のヘッドセットと Bluetooth リモコン、説明書が付属されていました

リモコンを Bluetooth で接続する

単4電池 2 本が必要になります

電源ボタンを長押しするとペアリングモードになります

あとは iPhone 側で接続するだけです
SC-B03-69A8 という名前で検出できました

A が音量ダウンで X が音量アップのボタンになっていました
なのでカメラを起動した状態で A で押せば写真が撮れます

ヘッドセットを装着する

横に装着できる部分があるので左右に装着しましょう

ただしイヤホンジャックがない iPhone (例えば iPhoneXR など) はヘッドセットが使えないので装着しないでも良いかなと思います

アプリを起動して iPhone を装着する

VR アプリを起動して装着しましょう
起動する前に装着してしまうと端末が操作できないのでアプリを起動できません

中に挟むところがあるので手で開いて iPhone を挟めば OK です

基本的には VR アプリ側で目線操作できたりコントローラ操作できるようになっているはずなので装着後にピント調整や正面の設定をしましょう

いざ装着

あとは頭に装着するだけです
このあとでピンと調整をしましょう

ピントを調整する

上の部分にピントを調整する部分があるので回して調整しましょう

また反対側には音量を調整するボタンと物理的に画面の真ん中をタップするボタンが付いています

音量ボタンはイヤホンジャックを通して操作しているのでイヤホンジャックが接続できない場合は使えません

ピントが合わない場合には

ピントが合わないと確実に 3D酔いになります
今回購入した VR ゴーグルはレンズの可動域に限界があるのでどうしてもピントが合わないことがあります
ピントが合わない場合には正直諦めましょう

なぜかと言うと 3D酔いになり体調を崩すことになるので無理して使わないほうが絶対に良いです
格安 VR ゴーグルはどうしても没入感が低いのでピントが合わず 3D酔いになる場合は潔くあきらめて別の商品にするかヘッドマウントディスプレイにしましょう
今回のゴーグルは 3D酔いしやすいほうかなと思います

あとは楽しむだけ

あとは好きなゲームや動画を見るだけです
操作感としてはアプリを起動してからセットする必要があるのでそれがかなり面倒ですが格安 VR ゴーグルはそういうものなんだと思います
慣れれば簡単にセットできるようになると思います

Tips: スペックの低い iPhone だとアプリが強制終了してしまう

VR アプリはかなりのリソースを使うようです
iPhone6 などのスペックの低い iPhone だと強制終了されてしまうことがありました
アプリにもよると思いますが使うときは少なくとも iPhone8 以上は必要かもしれません

2020年7月4日土曜日

Ruby で prepend を使ってモンキーパッチを当てる方法を考える

概要

前回はオープンクラスを使った方法を紹介しました
今回は prepend を使って実現する方法を考えます

環境

  • macOS 10.15.5
  • Ruby 2.7.1p83

サンプル1: クラスに prepend してしまう

シンプルにやるならこれで OK
ただ範囲が全体に及ぶのでオブジェクトからクラスをオープンして特異クラスに prepend して上げたほうがいいかもしれない

require 'net/https'

module MonkeyUser
  def hello
    "monkeyuser,99"
  end
end

class User
  prepend MonkeyUser

  def initialize(name, age)
    @name = name
    @age = age
  end

  def hello
    "#{@name},#{@age}"
  end

  def access
    uri = URI.parse 'https://kaka-request-dumper.herokuapp.com/'
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    req = Net::HTTP::Get.new uri.request_uri
    res = http.request req
    res.body
  end
end

u1 = User.new("hawksnowlog", 10)
p u1.hello

サンプル2: オブジェクトからクラスをオープンして prepend する

こっちのほうがスコープ的にはキレイに書けている
ただ正直なところパッと見て何をやっているかは Ruby のメタプログラミングを理解している人でないとわからないかもしれない

require 'net/https'

module MonkeyUser
  def hello
    "monkeyuser,99"
  end
end

class User
  def initialize(name, age)
    @name = name
    @age = age
  end

  def hello
    "#{@name},#{@age}"
  end
end

u1 = User.new("hawksnowlog", 10)
u2 = User.new("hawksnowlog", 10)
p u1.hello
class << u2
  prepend MonkeyUser
end
p u2.hello
p u1.hello

2020年7月3日金曜日

Ruby (test/unit) でモンキーパッチを当てる方法を考える

概要

主にテスト時に当てることを想定しています
基本はオープンクラスを使う感じにはなると思います
prepend や delegate を使ってもできるかもしれませんが今回はオープンクラスを使った方法を考えます

環境

  • macOS 10.15.5
  • Ruby 2.7.1p83

サンプルコード

メインのコードです
外部にアクセスすることを想定して作っています
理由は net/http 自体にモンキーパッチを当てるケースがあることを想定しているためです

  • vim lib/user.rb
require 'net/https'

class User
  def initialize(name, age)
    @name = name
    @age = age
  end

  def hello
    "#{@name},#{@age}"
  end

  def access
    uri = URI.parse 'https://kaka-request-dumper.herokuapp.com/'
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    req = Net::HTTP::Get.new uri.request_uri
    res = http.request req
    res.body
  end
end

テストコード

テストコードを複数のファイルに分けて書く場合を想定しています
メインのテストファイルを書いてあとは test ディレクトリ配下に test_hogehoge.rb のようなファイルを作成していきます

  • vim test/run_test.rb
base_dir = File.expand_path(File.join(File.dirname(__FILE__), ".."))
lib_dir  = File.join(base_dir, "lib")
test_dir = File.join(base_dir, "test")

$LOAD_PATH.unshift(lib_dir)

require 'test/unit'

exit Test::Unit::AutoRunner.run(true, test_dir)
  • vim test/test_user.rb
require 'user'
require 'json'

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,5", u.hello)
  end

  def test_acccess
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
    # assert_nothing_thrown {
    #   JSON.parse(u.access)
    # }
  end
end

実行

すべて失敗することを確認しましょう
ここからモンキーパッチを当ててテストを成功するようにしてみます

  • ruby test/run_test.rb

パッチを当ててみる (単純にクラスをオープンするだけ)

テストコード内でオープンすればアプリ側に影響はないのでこれが一番単純です
ruby のオープンクラスはグローバルなのですべてのテストに影響する点に注意しましょう

  • vim test/test_user.rb
require 'user'
require 'json'

class User
  def hello
    "hawksnowlog,5"
  end

  def access
    '{"key":"value"}'
  end
end

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,5", u.hello)
  end

  def test_acccess
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end
end

net/http にパッチを当てるには

既存のクラスにパッチを当ててみます
愚直にやるのであれば先ほどと同じように既存のクラスを開いてメソッドを再定義すれば OK です

  • vim test/test_user.rb
require 'user'
require 'json'

class User
  def hello
    "hawksnowlog,5"
  end
end

module Net
  class HTTP
    def request(req, data = nil)
      HTTPResponse.new(nil, nil, nil)
    end
  end

  class HTTPResponse
    def body
      '{"key":"value"}'
    end
  end
end

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,5", u.hello)
  end

  def test_acccess
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end
end

そもそもパッチは別ディレクトリで管理したほうがいい

パッチをテストと同じファイルに書いていましたが管理しやすいように別ディレクトリにしたほうが良いでしょう
また後で紹介しますが alias でパッチを当てる前の古いメソッドを保存しておきます

  • vim monkeypatch/patch.rb
module Net
  class HTTP
    alias old_request request
    def request(req, data = nil)
      HTTPResponse.new(nil, nil, nil)
    end
  end

  class HTTPResponse
    alias old_body body
    def body
      '{"key":"value"}'
    end
  end
end

そしてこの monkeypatch/ ディレクトリを run_test.rb でロードしていつでもパッチを当てられるようにしておきます

  • vim test/run_test.rb
base_dir = File.expand_path(File.join(File.dirname(__FILE__), ".."))
lib_dir  = File.join(base_dir, "lib")
patch_dir = File.join(base_dir, "monkeypatch")
test_dir = File.join(base_dir, "test")

$LOAD_PATH.unshift(patch_dir)
$LOAD_PATH.unshift(lib_dir)

require 'test/unit'

exit Test::Unit::AutoRunner.run(true, test_dir)

こうすることで以下のような感じでテスト時にパッチを require することができます
ただ前述の通りオープンクラスはグローバルに影響するためどこで require しても影響はグローバルに及ぶことを認識しておきましょう

  • vim test/test_user.rb
require 'user'
require 'json'

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,10", u.hello)
  end

  def test_acccess
    # これ以降のテストもパッチは当たった状態になる
    require 'patch'
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end
end

パッチを外すには

ではグローバルに当たってしまったパッチを外すにはどうするか考えます
一番簡単そうに実現できそうなのは alias を使う方法かなと思います
解除する場合を考慮してパッチを当てる前のメソッドを残しておきそれを後からアンパッチします
alias は先程 patch.rb で定義したのでアンパッチ用のファイルを作成します

  • vim monkeypatch/unpatch.rb
module Net
  class HTTP
    alias request old_request
  end

  class HTTPResponse
    alias body old_body
  end
end

あとはパッチを外したいテストのときに require すれば OK です

  • vim test/test_user.rb
require 'user'
require 'json'

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,10", u.hello)
  end

  def test_acccess
    require 'patch'
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end

  def test_acccess2
    require 'unpatch'
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("body"))
  end
end

最後に

Ruby でオープンクラスを使ったモンキーパッチの当て方を考えてみました
基本的にはモックと同じようにテストや開発時のデバッグに使うことが多いかなと思います
オープンクラスを使った場合のポイントはスコープがグローバルになるということをしっかりと理解して使いましょう
またプロダクションのコードで同じようにモンキーパッチを当てるようなことをする場合はアプリ全体に影響を及ぼす可能性があるので注意して使いましょう

実は過去に rspec-mock という rspec 用のモックライブラリの使い方を紹介しています
すでに rspec を使っているのであればこっちを使ったほうがテスト専用なので安心して使えるかなと思います

参考サイト

2020年7月2日木曜日

pytest の monkeypatch を使って簡単モック作成

概要

pytest の monkeypatch という機能を使うとインスタンスメソッドや変数を簡単にモックすることができます
今回はいくつかある monkeypatch を使ったモックの作成方法を紹介します

環境

  • macOS 10.15.5
  • Python 3.8.3

インストール

  • pipenv install pytest

サンプルコード

テスト対象のコードを用意します
今回はわかりやすいように外部にアクセスするメソッドを作っています

  • vim user.py
from http.client import HTTPSConnection


class User():
    FAVORITE_FRAMEWORKS = {
        "ruby": "sinatra",
        "swift": "spritekit",
        "python": "flask"
    }

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def hello(self):
        return("%s,%i" % (self.name, self.age))

    def access(self):
        con = HTTPSConnection("kaka-request-dumper.herokuapp.com")
        con.request('GET', '/')
        res = con.getresponse()
        return res.read().decode()

テストコード

hello と access のテストをするコードを作成します
以下ではわざとエラーになるようにしてます

  • vim test_user.py
from user import User

def test_hello(monkeypatch):
    u = User("hawksnowlog", 10)
    assert(u.hello() == "hawksnowlog,5")

def test_access(monkeypatch):
    u = User("hawksnowlog", 10)
    assert(u.access() == '{"key":"value"}')

とりあえず実行

  • pipenv run pytest

どちらのテストもエラーになることが確認できると思います

メソッドをモックしてエラーにならないようにしてみる

今回はお試しなのでモックを使うことでテストがエラーにならないようにしてみます
monkeypatch.setattr を使うことで User クラス内にある hello と access メソッドをモックし期待するレスポンスが返ってくるように修正します

  • vim test_user.py
from user import User

def test_hello(monkeypatch):
    # hello メソッドにパッチを当ててレスポンスを操作する
    # return は callable でなければならない
    monkeypatch.setattr(User, "hello", lambda self: "hawksnowlog,5")
    u = User("hawksnowlog", 10)
    assert(u.hello() == "hawksnowlog,5")

def test_access(monkeypatch):
    # モック用の内部メソッドを作成してそれを登録することもできる
    def mock_return(self):
        return '{"key":"value"}'

    monkeypatch.setattr(User, "access", mock_return)
    u = User("hawksnowlog", 10)
    assert(u.access() == '{"key":"value"}')

setattr は 3 つの引数を指定します
それぞれ「クラス」「メソッド名」「モック」になります
モックに関しては callable でなければなりません
モック用のメソッドを用意してそれを指定しても良いですし lambda 式が使える Python なのであれば lambda を指定しても OK です

モックするメソッドが引数を持つ場合は引数が同じになるようにしましょう

基本的にはこの setattr が使えれば問題ないですが他にも monkeypatch することができます

辞書の値を変更する

例えば定数として持っている辞書の値を変更することができます
setitem というパッチを当てます

def test_langs(monkeypatch):
    monkeypatch.setitem(User.FAVORITE_FRAMEWORKS, "ruby", "rails")
    assert(User.FAVORITE_FRAMEWORKS["ruby"] == "rails")

ちなみに辞書から要素を削除する delitem というメソッドもあります

他には

  • delattr で特定の関数や属性を削除することができます
  • setenv は環境変数をテスト時に上書きすることができます
  • delenv は環境変数をテスト時に削除することができます
  • chdir はテスト時の実行ディレクトリを変更することができます
  • syspath_prepend は sys.path で参照できる配列の先頭に指定の仮想パスを追加することができます
def test_syspath(monkeypatch):
    monkeypatch.syspath_prepend("hoge")
    assert(sys.path[0] == "hoge")

最後に

pytest の monkeypatch を試してみました
setattr が基本なのでこれが使えれば大体はカバーできると思います
かなり強力なのでこれが使いたいためにテストフレームワークを pytest にする人もいるんじゃないかなと思います

参考サイト

2020年7月1日水曜日

scikit-learn で SVM を試してみた

概要

過去に Ruby で SVM を試しました
今回は Python の scikit-learn を使って SVM を試してみます
環境は Jupyter notebook 上にプリインストールされている scikit-learn を使います

環境

  • macOS 10.15.5
  • Python 3.8.3
    • scikit-learn 0.22.1 on Jupyter notebook

学習データとテストデータのダウンロード

前回と同じ SVM 用の手書き文字データを使います

  • wget 'https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits'
  • wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits.t

データの読み込み

SVM のデータフォーマットである libsvm データフォーマットであれば load_svmlight_file というメソッドがそのまま使えます

from sklearn.datasets import load_svmlight_file
index_train, label_train = load_svmlight_file("./pendigits")

こんな感じでラベルとデータの部分に分割してくれます

学習させる

では学習させます
scikit-learn に svm を使います

from sklearn import svm
clf = svm.SVC()
clf.fit(index_train, label_train)

たったこれだけです
svm.SVC で SVM 用のオブジェクトを作成しあとは fit で学習データを与えればこれだけで学習できます
clf は classifier の略で分類器になります
この分類器を使ってテストデータを評価してみます

評価する

テストデータを読み込んでテストしてみましょう

index_test, label_test = load_svmlight_file("./pendigits.t")
clf.predict(index_test)

まずテストデータを評価するとテストデータがどれに分類されたかの結果の配列が返ってきます
配列はテストデータの行分あります
あとはこの結果の配列と label_test の値を比較すれば OK です
正解率を出すのに便利な accuracy_score という関数があるのでこれを使います

result = clf.predict(index_test)
from sklearn.metrics import accuracy_score
accuracy_score(label_test, result)

これで実行すると「0.9817038307604345」という値が得られると思います
前回 Ruby で 0.9824 くらいだったのでほぼ同じ値になりました
今回は SVM のカーネルやガンマ値などのチューニングは行わなかったのでそこの誤差が出た感じだと思います

ちなみにデフォルトは SVC(C=1.0, break_ties=False, cache_size=200, class_weight=None, coef0=0.0, decision_function_shape='ovr', degree=3, gamma='scale', kernel='rbf', max_iter=-1, probability=False, random_state=None, shrinking=True, tol=0.001, verbose=False) でした
各パラメータの意味はこちらがわかりやすく解説しています

おまけ: モデルを保存するには

pickle という機能があるのでこれを使えば外部に保存できます

import pickle
s = pickle.dumps(clf)
clf = pickle.loads(s)

再度使う場合は pickle.loads すれば OK です

最後に

Python の scikit-learn を使って SVM を試してみました
データさえあれば SVM の仕組みを知らなくても簡単に分類することができました
コードの行数も Ruby のときに比べてだいぶ少なくて済むのも嬉しい点かなと思います

参考サイト