プログラミング初心者がアーキテクトっぽく語る

見苦しい記事も多数あるとは思いますが訂正しつつブログと共に成長していければと思います

PythonでReflection

Pythonでクラス名やメソッド名から動的にインスタンスやメソッドを取得する方法を紹介します。


インスタンスの取得

importlib.import_moduleを使ってmoduleをimportして、getattrでmoduleからクラスを取得するのが一般的なようです。

from importlib import import_module
module = import_module(module_name)
clazz = getattr(module, clazz_name)
instance = clazz()

builtinの__import__を使ってもできます。

module = __import__(module_name,fromlist=[clazz_name])
clazz = getattr(module, clazz_name)
insance = clazz()

moduleのtypeを見るとモジュール自体ではなく__init__.pyが読み込まれているようなので挙動に注意が必要そうです。


メソッドの取得

getattrメソッドでオブジェクトから取得できます。

method = getattr(instance, method_name)
method()

__main__.pyってなに

時折、見かける「__main__.py」 ってなにか調べてみました。


python -mで実行したときに呼ばれる

python -m パッケージ名」を実行するとそのパーケージの__main__.pyが呼ばれます。

パッケージの作り方によっては__main__.py以外のモジュールを呼び出すことも可能なようです。


なぜpython -mを使うの?

setup.pyでentry_points/console_scriptsを定義すればパッケージ名のみで実行できます。

https://architecting.hateblo.jp/entry/2020/12/24/173311

なのになぜpython -mでパッケージを実行するのでしょう?調査にもっとも苦労したのがここです。憶測を含んでいます。

python -mを使うパッケージの例として有名なものを挙げます。

  • unittest
  • json.tool
  • htttp.server

これらはいずれも「ライブラリ」であり「ツール」でもあります。

unittestはテストコードを記述するためのライブラリであると同時にテストコードを探索、実行、結果報告するツールでもあります。

json.toolはJSON形式を扱うライブラリであると同時にJSONファイルを整形するツールです。

http.serverはWebサーバを記述するためのライブラリであると同時に簡易なWebサーバを立ち上げるツールです。

このように一つのパッケージがライブラリとツールの両方の機能を提供しているときに、ライブラリとして利用する場合はimport、ツールとして利用する場合はpython -mで呼び出すのがPython界隈の慣例のようです。

そのようなパッケージのsetup.pyではentry_points/console_scriptsは定義しないようです。実際、上記3つのツールはいずれもpython -mなしでは実行できません。ライブラリ利用者が誤ってツール機能を実行してしまうことを防止しているのかと推測します。


私的まとめ

  • ライブラリとツールの両方を一つのパッケージで提供するとき__main__.pyを使う
  • このときsetup.pyではentry_points/console_scriptsは定義しない
  • ライブラリ利用者はimportで利用する
  • ツール利用者はpython -mで利用する
  • ライブラリ機能しか提供しないパッケージでは__main__.pyは使わない
    • 誤ってpython -mで実行されると困る
  • ツール機能しか提供しないパッケージでは__main__.pyは使わない
    • entry_points/console_scriptsを定義してPATHを通した方がpython -mよりもCLI利用者の利便性が高い
    • CLIからの実行方式を2つ提供する意味がない
    • でもプログラムのエントリポイントがわかりやすいというメリットはあるかもしれない

おまけ

python -m hogepython hoge.__main__.pyの違いは__package__にパッケージ名が入るかNoneが入るかのだけのようです。処理上は差がないと言えると思います。

Pythonで引数を扱う

引数を扱うときに便利なのがParserです。簡単に引数を処理することができ、定義も簡単です。一度使えばsys.argvを直接処理する気なんてなくなるはずです。

PythonではいくつかParserの選択肢がありますがargparse一択だと思います。

標準ライブラリの選択肢

引数をParseする標準ライブラリにはgetopt、optparse、argpaseがあります。

getopt

  • C 言語の getopt() 関数に慣れ親しんだ人向けの実装
    • でもCを書ける人がargparseを学ぶ労力なんてゴミみたいなものじゃないかと

optparse

  • getoptより少しだけコード量が少なくなる
  • Python3.2 で非推奨になった
  • 将来、廃止予定

argparse

  • optparseの後継
  • Python Tutorialのおすすめ

argparseの基本的な使い方

parser = argparse.ArgumentParser()
parser.add_argument('-a', '--action', help='action for a file', required=True, choices=['show', 'delete'])
parser.add_argument('-f', '--file', help='name of a file', required=True)
args = parser.parse_args()
print(args.action)
print(args.file)

これだけで必須引数の有無確認、エラーやヘルプ表示などを自動で処理してくれます。

詳細はこちらで確認してください。

Argparse チュートリアル — Python 3.9.1 ドキュメント


感想

optparseは使ったことがありませんが、非推奨で廃止予定のものを今から使う理由はないと思います。

argparseをgetoptと比べるとヘルプ表示処理やいくつかのエラー処理を書かなくていい分、argparseの方が少し楽です。ただ劇的な差はない印象でした。

argparseにはなんでもできる柔軟性はありません。例えば「第一引数がAのときは第二引数はBかCで、第一引数がXのときは第二引数はYかZで、、」みたいな複雑な状況はargparseだけでは対応できません。ある程度の自分でコーディングする必要があります。

それでもsys.argvを直接処理するよりはかなり楽で、しかも標準ライブラリなので基本はこれでよいと思います。

PyCharmでテストコードのフォルダを多階層にしたらテストが動かなくなった

PyCharm + unittestでTDDっぽいことを試しました。最後にテストコードのフォルダ階層をプロジェクトコードのフォルダ階層と一致させたらそれまで動いていた単体試験の大半が動かなくなってしまいました。

ただのsys.pathの理解不足だったのですが同じことで困っている人がいるかもしれなので投稿します。


コード完成

PyCharmでTDDっぽくコードを作成し、完成させました。

そのときの構成は以下のような感じです。__init__.pyは省略してます。

myproject/
├── packageA
│   ├── subpackageA
│   │   └── moduleA.py
│   └── subpackageB
│       └── moduleB.py
└── tests
    ├── test_moduleA.py
    └── test_moduleB.py
    └── test_data.json

プロジェクトコードはmyproject/packageA/に置き、テストコードとテストデータはmyproject/tests/に置きました。


テストコードのフォルダ階層を変更

testsフォルダには1階層しかありません。これで困っていたわけではありませんがJavaっぽくテストコードをプロジェクトコードと同じ多階層構造にしようと思いました。

Javaではテストコードをプロジェクトコードと同じ構成にすることが多いと思います。というかJavaIDEがそれを推奨してくることが多いと思います。

変更の結果がこちらです。

myproject/
├── packageA
│   ├── subpackageA
│   │   └── moduleA.py
│   └── subpackageB
│       └── moduleB.py
└── tests
    ├── packageA
    │   ├── subpackageA
    │   │   └── test_moduleA.py
    │   └── subpackageB
    │       └── test_moduleB.py
    └── test_data.json

myproject/tests/配下の構造がプロジェクトコードと同じになりました。これでどのプロジェクトコードのテストがどこにあるかひと目でわかるようになりました。


単体試験が動かない!

しかしさっきまで動いていた単体試験の大半が動かなくなってしまいました。理由はimportエラーです。

かなり悩みましたが問題はsys.pathでした。PyCharmも少し関わってます。


相対パスはない

プロジェクトコードは動くのに単体試験でプロジェクトコードのimportエラーが出るとき真っ先に疑うべきは相対パスです。

プロジェクトコードでもテストコードでも相対パスは使うべきではありません。理由は他の方々が詳しく書いてくれています。

https://qiita.com/jesus_isao/items/f93c11248192645eb25d

https://comeroutewithme.com/2018/02/24/python-relative-imports-and-unittests-derp/

なお私はこれには該当しませんでした。


プロジェクトコードと同じパッケージ名で勘違い

テストコードtest_moduleA.pyがプロジェクトコードpackageA.subpackageA.moduleAをimportできません。その理由を調査します。

以下の文をテストコードtest_moduleA.pyに仕込んでsys.pathを表示しました。

import sys
print(sys.path)

見やすいように改行と行番号を入れます。

1: [
2:  '/Users/john/PycharmProjects/myproject/tests',
3:  '/Users/john/PycharmProjects/myproject',
4:  '/Applications/PyCharm CE.app/Contents/plugins/python-ce/helpers/pycharm',
5:  '/Users/john/.pyenv/versions/3.8.0/lib/python38.zip',
6:  '/Users/john/.pyenv/versions/3.8.0/lib/python3.8',
7:  '/Users/john/.pyenv/versions/3.8.0/lib/python3.8/lib-dynload',
8:  '/Users/john/PycharmProjects/test_unittest/.venv/lib/python3.8/site-packages'
9: ]

4行目以下のPathはmyprojectとは全く関係がありません。packageA.subpackageA.moduleAをimportするために頼れるのは2、3行目だけです。この2行に注目しましょう。

2行目のPathを見るとmyproject/testsです。myproject/testsの構成は以下の通りです。

myproject/tests/
├── packageA
│   ├── subpackageA
│   │   └── test_moduleA.py
│   └── subpackageB
│       └── test_moduleB.py
└── test.json

Loaderの気持ちになってこのフォルダからpackageA.subpackageA.moduleAを探してみましょう。

あ、、、packageA.subpacakgeAってある。

これは絶対パスではtests.packageA.subpacakgeAだけどmyproject/testsの中から見るとpackageA.subpacakgeAかもしれない。

myproject/tests/packageAをmyproject/tests/packageA_に変更して試したら、、、動いた!

tests.packageA.subpacakgeAをプロダクションコードのpackageA.subpacakgeAと勘違いしたことがimportエラーの原因でした。


対策調査

原因はわかりましたが解決にはなってません。フォルダ名はプロジェクトコードと同じにしたいのです。調査を続けます。

フォルダ名を変更したくないならsys.pathを変えるしかありません。

2行目のパスはPyCharmのEdit ConfigurationのWorking directoryの値です。そこでWorking Directoryをmyproject/に変更しました。以下がそのときのsys.pathです。

1: [
2:  '/Users/john/PycharmProjects/myproject',
3:  '/Users/john/PycharmProjects/myproject',
4:  '/Applications/PyCharm CE.app/Contents/plugins/python-ce/helpers/pycharm',
5:  '/Users/john/.pyenv/versions/3.8.0/lib/python38.zip',
6:  '/Users/john/.pyenv/versions/3.8.0/lib/python3.8',
7:  '/Users/john/.pyenv/versions/3.8.0/lib/python3.8/lib-dynload',
8:  '/Users/john/PycharmProjects/test_unittest/.venv/lib/python3.8/site-packages'
9: ]

2行目がmyprojectになりました。以下がmyprojectの構成です。

myproject/
├── packageA
│   ├── subpackageA
│   │   └── moduleA.py
│   └── subpackageB
│       └── moduleB.py
└── tests
    ├── packageA
    │   ├── subpackageA
    │   │   └── test_moduleA.py
    │   └── subpackageB
    │       └── test_moduleB.py
    └── test_data.json

Loaderの気持ちになってこのフォルダからpackageA.subpackageA.moduleAを探してみましょう。

前回勘違いしたテストコードは今回は「tests.packageA.subpackageA」と見えます。勘違いすることはありません。間違えることなくプロダクションコードのpackageA.subpackageA.moduleAにたどり着きます。これなら問題なさそうです。

テストを実行したら予想通り動きました。


テストデータが見つからない

Working directoryをmyproject/に変更したら、テストデータの読み込みに失敗するテストコードがいくつか生まれました。

一難去ってまた一難。心が萎えます。

幸いすぐに原因はわかりました。

変更前のWorking directoryはmyproject/testsだったのでテストコードはopen('./test_data.json', 'r')でテストデータを読み込めました。

Working directoryがmyprojectになったのでopen('./tests/test_data.json', 'r')と変更する必要がありました。そこまではPyCharmが面倒をみてくれなかったわけです。


結論

プロダクションコードとテストコードのフォルダ構成を同じにしたときは、sys.pathの先頭にproject_root/tests/が来ないようにしましょう。PyCharmの場合はEdit ConfigurationのWorking directoryで変更できます。

個人的には全てのテストコードはproject_rootをWorking Directoryとして実行できるようにimport文やファイル読み込み処理を実装するとわかりやすいと思いました。

そのためのチェックとしてproject_root/testsのWorking directoryをproject_root/にしてproject_root/testsを右クリックして全ての試験を実行するとよいと思います。

単体試験をするときはsys.pathとPyCharmのWorking directoryにご注意を!

Pythonの配布パッケージに.py以外のファイルを含めたいとき

PythonのコードでYAMLJSONのファイルを参照していて、そのファイルをパッケージに含めたいときにどうすればよいか解説します。

そのようなファイルは「リソース」と呼ばれます。


コードの書き方

リソースをopenで読み込むと単体試験ではうまくいきますが、パッケージにするとうまく動きません。

リソースの読み込みにはimportlibを使います。

from importlib import resources

str_data = resources.read_text('パッケージ名', 'リソース名'))    

「パッケージ名」はリソースが置いてあるパッケージの名前です。

resource.read_textはstr型のデータを返します。JSONであればjson.loads(str_data)でdict形式に変換するとよいでしょう。

importlibはPython3.7からです。Python3.7未満の環境ではpkg_resourcesのを使うそうです。


配布パッケージの作り型

以前、配布パッケージの作り方を紹介しました。

architecting.hateblo.jp

この方法だと.py以外のファイルはパッケージに含まれません。

リソースをパッケージに追加するにはsetup.pyに以下の2行を追加します。

package_data={'パッケージ名': ['リソース名']},
include_package_data=True,

パッケージ名を空('')にすると「全てのパッケージ」という意味になります。

リソース名にはワイルドカードが使えます。

後は普通にsdistなどを作ればOKです。

PyCharmでプロジェクトフォルダをRenameしたらちょっと面倒なことになった

PyCharmでプロジェクトの名前を変更したらunittestが動かなくなったりpyenvの仮想環境や__pycache__に古い名前が残ったりして少し苦労した話です。


プロジェクト名変更

コードが完成しunittestの単体テストも全て動いた後に最初に適当に命名したプロジェクト名が気に入らなくなったので変更することにしました。

プロジェクト名を右クリックしてRefactor → Renameを選びます。

f:id:hogehoge666:20201226221332p:plain

「Rename directory」と「Rename project」という2つの選択肢が現れたのでとりあえず「Rename project」を選択。

f:id:hogehoge666:20201226220952p:plain

ディレクトリ名は変化がなく、プロジェクト名のみ変更されました。

単体試験を実行すると全てパスします。PyCharmがすべてのimport文を変更してくれたようです。便利!


プロジェクトフォルダ名変更

プロジェクト名とプロジェクトフォルダ名が一致しないのが気持ち悪くなってきました。

今度は「Rename directory」を選択します。

無事、ディレクトリ名が変わりました。

念の為単体試験を実行すると、、、あれ???エラー???


unittestの復旧

コードの中を確認しましたがプロジェクトフォルダ名を参照しているところはありません。

PyCharmが持っている内部データの問題かと思ってとりあえずPyCharmを終了、起動してみました。

すると起動してきたPyCharmが色々とindexingを始めました。

indexing終了後に単体試験を実行しましたがまだ動きません。

するとPyCharmが「パスが変だよ」的なメッセージを出してます。

ご丁寧に「Fix」オプションがあるので勿論、クリック。

するとなにか処理を始めました。

なにかの処理が終わったのを見計らって再度、試験を実行します。

治りました!


仮想環境名が古いまま

全て問題なく動作するようになりましたがpipenv shellで仮想環境に入ったときにプロンプトを見て仮想環境名が古いプロジェクト名のままなことに気づきました。

.venv配下をgrepすると古い名前が色々なところに残っています。ここまで古い名前が残っていると本当に問題なく動いていたのか不安になります。

一つずつ修正するのも面倒ですし、まだどこかに古いゴミが残っていないとも限らないので仮想環境を再作成することにしました。


仮想環境の名前変更

PipfileとPipfile.lockを念の為、別の場所に保存します。(結果的に不要でした)

pipenv --rmします。するとなぜか.venvの削除に失敗したというメッセージが出ました。rmコマンドで削除します。

rm -rf .venv

Python 3.8の仮想環境を再作成します。

pipenv --python 3.8

PipfileとPipfile.lockが無事なことを確認して、同じライブラリを再インストールします。

pipenv sync

これでOKなはずです。単体試験を実行して環境が復旧していることを確認します。

pipenv shellで感想環境に入ってプロンプトを確認すると新しいプロジェクトフォルダ名と一致してました。


__pycache__に古い名前が残っている

念の為プロジェクト全体をgrepしました。

すると__pycache__の中の.pycファイルにまだ古い名前が残っています。

プロジェクトフォルダを右クリックしてClean Python Compiled Filesを選択。

f:id:hogehoge666:20201226222216p:plain

コードを実行して.pycファイルを再生成。

再びプロジェクト全体をgrepしたらHit件数0!


まとめ

たかだかプロジェクトの名前を変えるだけでかなり時間がかかりました。

でもやり方さえわかれば大したことはありません。

これで今後は最初に命名したプロジェクト名に束縛されずに済むと思えばいい勉強だったと思います。

PyCharmでプロジェクト名とプロジェクトフォルダ名を変更する手順をまとめる以下の通りです。

  1. プロジェクト名を右クリックしてRefactor → Rename → Rename project
  2. 単体試験動作OK
  3. プロジェクト名を右クリックしてRefactor → Rename → Rename direcotry
  4. 単体試験動作NG
  5. PyCharmを再起動
  6. indexng終了後「PATHが変だよ」エラーでFixを選択
  7. 単体試験動作OK
  8. pipenvで仮想環境を削除、再作成
  9. プロジェクトフォルダを右クリック → Clean Python Compiled Files 完了!

Git MergeでConflictが発生したときの対処

異なるブランチで同じファイルの同じ場所を同時に編集するとConflictが発生する。

f:id:hogehoge666:20200828151728p:plain

Conflictが発生した状態でMergeしようとすると失敗する。

$ git merge feature -m "merge feature branch into master"
Auto-merging hello.py
CONFLICT (content): Merge conflict in hello.py
Automatic merge failed; fix conflicts and then commit the result.

Mergeに失敗したファイルにはConflictの内容がMarkerと一緒に挿入される。

$ cat hello.py 
def main():
<<<<<<< HEAD
    print("Hello World from master branch!")

def master_branch():
=======
    print("Hello World from feature branch!")

def feature_branch():
>>>>>>> feature
    pass
 
if __name__ == '__main__':
    main()
$

Mergeをやめる場合はgit merge --abortする。

それでもMergeしたい場合はConflictを解消後、add、commitする。

Conflict解消作業中にコードが壊れる可能性があるので解消後にはテストを再実施する。

以下にConflict解消作業の詳細を示す。


状況確認

コミットログを確認。
Mergeは完了していない。

$ git log --oneline
1b6bd11 (HEAD -> master) Modify hello.py on master branch
e93be91 (origin/master) Create hello.py
71bf522 first commit

Working DirectoryとStaging Areaの状態を確認。
hello.pyがmergeできていない。

$ git status
On branch master
<略>
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)

    both modified:   hello.py

no changes added to commit (use "git add" and/or "git commit -a")

Conflictの内容確認

git diffでどのファイルにどんなMarkerが挿入されたか雰囲気を掴む。
手軽だがworking directoryの状況とずれているので直感的に全体像を把握しにくい。

$ git diff
diff --cc hello.py
index 7a983db,b2bef2f..0000000
--- a/hello.py
+++ b/hello.py
@@@ -1,7 -1,7 +1,13 @@@
  def main():
++<<<<<<< HEAD
 +    print("Hello World from master branch!")
 +
 +def master_branch():
++=======
+     print("Hello World from feature branch!")
+ 
+ def feature_branch():
++>>>>>>> feature
      pass
   
  if __name__ == '__main__':
$ 

Makerが挿入されたファイルの中身を確認。

$ cat hello.py 
def main():
<<<<<<< HEAD
    print("Hello World from master branch!")

def master_branch():
=======
    print("Hello World from feature branch!")

def feature_branch():
>>>>>>> feature
    pass
 
if __name__ == '__main__':
    main()
$

原因になったCommitを確認

一覧表示。

$ git log --merge
commit fd201265f633ba617ef50a6f9bd19401ceae32d8 (feature)
Author: xxxxx <xxxxx@gmail.com>
Date:   Fri Aug 28 12:11:50 2020 +0900

    Modify hello.py on feature branch

commit 1b6bd11bae35867e566a85ec3bde3a59143ded08 (HEAD -> master)
Author: xxxxx <xxxxx@gmail.com>
Date:   Fri Aug 28 12:09:38 2020 +0900

    Modify hello.py on master branch
$

詳細表示。

$ git show 1b6bd11bae35867e566a85ec3bde3a59143ded08
commit 1b6bd11bae35867e566a85ec3bde3a59143ded08 (HEAD -> master)
Author: xxxxx <xxxxx@gmail.com>
Date:   Fri Aug 28 12:09:38 2020 +0900

    Modify hello.py on master branch

diff --git a/hello.py b/hello.py
index 2e45b94..7a983db 100644
--- a/hello.py
+++ b/hello.py
@@ -1,5 +1,8 @@
 def main():
-    print("Hello World!")
+    print("Hello World from master branch!")
+
+def master_branch():
+    pass
  
 if __name__ == '__main__':
     main()
$

Mergeをやめる

Markerなどが消えてGit Merge前の状態に戻る。
履歴も残らない。

git merge --abort

Conflictを解消する

masterブランチのhello.pyを採用。

git checkout --ours hello.py

featureブランチのhello.pyを採用。

git checkout --theirs hello.py

手動で編集。

$ vi hello.py 
def main():
    print("Hello World from master branch!")

def feature_branch():
    pass
 
if __name__ == '__main__':
    main()

コミット

変更をコミット。

git add hello.py
git commit -m "resolve conflict b/w master and feature branch"

確認。

$ git log --oneline
031d9a3 (HEAD -> master, origin/master) resolve conflict b/w master and feature branch
fd20126 (feature) Modify hello.py on feature branch
1b6bd11 Modify hello.py on master branch
e93be91 Create hello.py
71bf522 first commit

Pull RequestでConflictが発生した場合

Conflict発生

Pull RequestでConflictが発生すると以下のように表示される。
Conflictを解消するまでMergeボタンが非活性化されている。
WebUI上で解消する方法とLocal Repositoryで解消してPushする2つの方法がある

f:id:hogehoge666:20200828151744p:plain

1. WebUI上でConflictを解消

WebEditorで解消してMark as resolvedボタンを押すとMergeボタンが活性化する。

f:id:hogehoge666:20200828151759p:plain

2. Local RepositoryでConflict 解消
  1. featureブランチを最新化
  2. featureブランチにmasterをMerge
  3. Conflictを解消してコミット
  4. masterにfeatureブランチをMerge --no-ff
  5. Push

git pullでConflictが発生した場合

Conflict発生

既にGitにRepositoryが作成されている状況で、ローカルにもレポジトリをgit initで作成して作業したとする。 作業後、git pushする前にgit pullを実行すると以下のようにmergeでエラーが出る。

$ git pull origin main
From https://github.com/<username>/<repository>
 * branch            main       -> FETCH_HEAD
fatal: refusing to merge unrelated histories
$

ファイルにConflictのMarkerすら挿入されないのでこのままではなにもできない。

1. 強制的にmerge + Conflict発生

こういう時に強制的にmergeをさせるのが「allow-unrelated-histories」だ。

まずはfetchする。

$ git fetch
$

次に「allow-unrelated-histories」付きでmergeする。

$ git merge --allow-unrelated-histories origin/main
Auto-merging hello.py
CONFLICT (add/add): Merge conflict in hello.py
Automatic merge failed; fix conflicts and then commit the result.
$ 

mergeが実行されConflictが発生した。

Conflict 解消

後は今まで述べた手順でConflictを解決してcommit、pushすれば解決だ。

例えばローカルで編集した内容を優先するなら下記のようにする。

git checkout --ours hello.py
git add .
git commit -m "resolved conflict"
git push