以前、以下記事でPoetryの使い方について紹介しました。
今回はPythonの静的解析ツール(ライブラリ)をPoetryで使用する方法について、インストールから実際のプロジェクトへ適用した内容について紹介します。
7rikazhexde-techlog.hatenablog.com
7rikazhexde-techlog.hatenablog.com
変更履歴
- 2023/03/11
Ver0.2.1でTOML向けのライブラリをtomlからtomlkitに変更しました。
本記事の内容はVer0.2.0ベースの情報になります。
コミット済みのコードと参照する際は、下記コマンドを実行してリビジョンを変更してください。(各ツールの概要について知ることが目的であれば変更せず読み進めてください。)
% git clone https://github.com/7rikazhexde/dlSubscanStakingRewardsHistory.git
% git checkout ff349ef7b24333e789deb00d68babc8189f17311
静的解析ツールのインストール
インストールする静的解析ツールは下記の通りです。
追加情報
上記ツールを紹介しましたが、2024/02/07現在、isort, black, flake8はRuffで代替することができます。
詳細は公式ドキュメントと参考記事を確認してください。
- The Ruff :
The Ruff Linter is an extremely fast Python linter designed as a drop-in replacement for Flake8 (plus dozens of plugins), isort, pydocstyle, pyupgrade, autoflake, and more.
以下のコマンドでisort, flake8を代替できます。
ruff check . # Lint all files in the current directory. ruff check . --fix # Lint all files in the current directory, and fix any fixable errors. ruff check . --watch # Lint all files in the current directory, and re-lint on change.
The Ruff formatter is an extremely fast Python code formatter designed as a drop-in replacement for Black, available as part of the ruff CLI via ruff format.
The Ruff formatter is available as a production-ready Beta as of Ruff v0.1.2.
Release v0.1.2 · astral-sh/ruff · GitHubで機能追加され、以下のコマンドでblackを代替できます。
ruff format . # Format all files in the current directory. ruff format /path/to/file.py # Format a single file.
今後、静的解析ツールは、ruff, mypy, pytestを使用することがスタンダードになっていくかもしれません。
本記事で記載するプロジェクトではruffを使用していませんが、他のプロジェクトでは使用しています。(pre-commitの例はこちら)
現状特に問題なく代替できることを確認しているため徐々に置き換えていこうと考えています。
実行環境
静的解析を適用するプロジェクトは下記を使用します。
Poetryのバージョンは下記の通りです。
% poetry --version Poetry (version 1.4.0)
静的解析ツールのインストール(poetry add)
プロジェクト環境下でpoetry add
コマンドを実行して静的解析ツールをインストール(追加)します。
※以降記載される実行ログとコードでは一部記載を環境変数に置き換えていますのでご注意ください。
pytestは既に開発用の依存関係にインストール済みのため、pytest以外のパッケージがインストールされました。
-D
オプションは非推奨とのことで以降--group dev
オプションを指定します。
% poetry add -D flake8 black isort mypy pytest The --dev option is deprecated, use the `--group dev` notation instead. The following packages are already present in the pyproject.toml and will be skipped: • pytest If you want to update it to the latest compatible version, you can use `poetry update package`. If you prefer to upgrade it to the latest available version, you can use `poetry add package@latest`. Using version ^6.0.0 for flake8 Using version ^23.1.0 for black Using version ^5.12.0 for isort Using version ^1.0.1 for mypy Updating dependencies Resolving dependencies... (0.5s) Writing lock file Package operations: 12 installs, 0 updates, 0 removals • Installing click (8.1.3) • Installing mccabe (0.7.0) • Installing mypy-extensions (1.0.0) • Installing pathspec (0.11.0) • Installing platformdirs (3.1.0) • Installing pycodestyle (2.10.0) • Installing pyflakes (3.0.1) • Installing typing-extensions (4.5.0) • Installing black (23.1.0) • Installing flake8 (6.0.0) • Installing isort (5.12.0) • Installing mypy (1.0.1)
静的解析ツールの実行
isortコマンドの実行
isortはimport文を自動整理するライブラリです。
コマンドを実行したところ、複数のimport文が書かれた2つのファイルが変更されました。
% poetry run isort src Fixing $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/src/subscan.py Fixing $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/src/main.py
blackコマンドの実行
blackはPython向けのコードフォーマッターでPEP8に準拠したライブラリです。
コマンドを実行したところ、4つのファイルが変更されました。
% poetry run black src reformatted $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/src/cryptact.py reformatted $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/src/main.py reformatted $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/src/gui.py reformatted $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/src/subscan.py All done! ✨ 🍰 ✨ 4 files reformatted.
flake8コマンドの実行
flake8は文法チェック用のライブラリです。
コマンドを実行したところ、エラーがたくさん指摘されています。
% poetry run flake8 src src/gui.py:7:80: E501 line too long (86 > 79 characters) src/gui.py:21:80: E501 line too long (83 > 79 characters) src/gui.py:151:80: E501 line too long (81 > 79 characters) src/gui.py:153:80: E501 line too long (84 > 79 characters) src/gui.py:169:80: E501 line too long (84 > 79 characters) src/gui.py:171:80: E501 line too long (82 > 79 characters) src/gui.py:210:80: E501 line too long (82 > 79 characters) src/gui.py:247:80: E501 line too long (83 > 79 characters) src/gui.py:260:80: E501 line too long (86 > 79 characters) src/gui.py:275:80: E501 line too long (83 > 79 characters) src/gui.py:299:80: E501 line too long (81 > 79 characters) src/main.py:51:80: E501 line too long (83 > 79 characters) src/main.py:55:80: E501 line too long (82 > 79 characters) src/main.py:58:80: E501 line too long (84 > 79 characters) src/main.py:78:45: E712 comparison to False should be 'if cond is False:' or 'if not cond:' src/main.py:108:80: E501 line too long (85 > 79 characters) src/main.py:123:80: E501 line too long (82 > 79 characters) src/main.py:128:80: E501 line too long (131 > 79 characters) src/main.py:130:80: E501 line too long (84 > 79 characters) src/main.py:137:80: E501 line too long (149 > 79 characters) src/main.py:139:80: E501 line too long (84 > 79 characters) src/main.py:146:80: E501 line too long (82 > 79 characters) src/main.py:159:80: E501 line too long (83 > 79 characters) src/main.py:160:80: E501 line too long (94 > 79 characters) src/main.py:176:80: E501 line too long (161 > 79 characters) src/main.py:186:80: E501 line too long (88 > 79 characters) src/main.py:201:80: E501 line too long (83 > 79 characters) src/main.py:207:80: E501 line too long (84 > 79 characters) src/main.py:235:80: E501 line too long (84 > 79 characters) src/main.py:238:80: E501 line too long (85 > 79 characters) src/main.py:249:80: E501 line too long (82 > 79 characters) src/main.py:289:80: E501 line too long (107 > 79 characters) src/subscan.py:46:80: E501 line too long (84 > 79 characters) src/subscan.py:48:80: E501 line too long (80 > 79 characters) src/subscan.py:55:80: E501 line too long (88 > 79 characters) src/subscan.py:57:80: E501 line too long (80 > 79 characters) src/subscan.py:69:80: E501 line too long (80 > 79 characters) src/subscan.py:106:80: E501 line too long (88 > 79 characters) src/subscan.py:163:80: E501 line too long (88 > 79 characters) src/subscan.py:181:80: E501 line too long (80 > 79 characters) src/subscan.py:185:80: E501 line too long (83 > 79 characters) src/subscan.py:195:80: E501 line too long (88 > 79 characters) src/subscan.py:197:80: E501 line too long (86 > 79 characters) src/subscan.py:285:80: E501 line too long (81 > 79 characters) src/subscan.py:353:80: E501 line too long (80 > 79 characters) src/subscan.py:392:17: E722 do not use bare 'except' src/subscan.py:403:80: E501 line too long (181 > 79 characters) src/subscan.py:423:80: E501 line too long (106 > 79 characters) src/subscan.py:424:80: E501 line too long (87 > 79 characters) src/subscan.py:428:80: E501 line too long (86 > 79 characters) src/subscan.py:444:80: E501 line too long (80 > 79 characters) src/subscan.py:475:80: E501 line too long (84 > 79 characters) src/subscan.py:485:80: E501 line too long (85 > 79 characters) src/subscan.py:486:80: E501 line too long (87 > 79 characters) src/subscan.py:491:80: E501 line too long (82 > 79 characters) src/subscan.py:492:80: E501 line too long (87 > 79 characters) src/subscan.py:499:80: E501 line too long (84 > 79 characters) src/subscan.py:521:80: E501 line too long (83 > 79 characters) src/subscan.py:523:80: E501 line too long (83 > 79 characters) src/subscan.py:565:80: E501 line too long (80 > 79 characters) src/subscan.py:605:17: E722 do not use bare 'except' src/subscan.py:615:80: E501 line too long (181 > 79 characters) src/subscan.py:635:80: E501 line too long (86 > 79 characters) src/subscan.py:652:80: E501 line too long (80 > 79 characters) src/subscan.py:683:80: E501 line too long (84 > 79 characters) src/subscan.py:692:80: E501 line too long (82 > 79 characters) src/subscan.py:703:80: E501 line too long (84 > 79 characters)
指摘内容を確認するとE501
が大半を占めています。
E501
は一行に表示される桁数が長いという指摘です。
これは個人的には長くて良いかなと思うので、pyproject.toml
の[tool.flake8]
のignoreに追加します。
[tool.flake8] ignore = "E501"
再度実行しましたが、指摘は変わりませんでした。
% poetry run flake8 src src/gui.py:7:80: E501 line too long (86 > 79 characters) … src/subscan.py:703:80: E501 line too long (84 > 79 characters)
flake8コマンドを直接実行したところ対象から除かれました。
% flake8 src --ignore E501 src/main.py:66:21: W503 line break before binary operator src/main.py:67:21: W503 line break before binary operator src/main.py:78:45: E712 comparison to False should be 'if cond is False:' or 'if not cond:' src/main.py:79:21: W503 line break before binary operator src/main.py:80:21: W503 line break before binary operator src/subscan.py:392:17: E722 do not use bare 'except' src/subscan.py:413:21: W503 line break before binary operator src/subscan.py:414:21: W503 line break before binary operator src/subscan.py:605:17: E722 do not use bare 'except' src/subscan.py:625:21: W503 line break before binary operator src/subscan.py:626:21: W503 line break before binary operator
調べると、現状、flake8はpoetryに対応しておらず、有志の方に作成されたpyproject-flake8を使えばpyproject.toml
で管理できるようになるとのことでした。(コマンド実行時はpoetry run pflake8
で実行する必要あり。)
他にも、上記pypiの以下の記載の通り、同様にpyproject.toml
で管理する方法について取り組んでいるプロジェクトが複数ありました。
本環境ではflake8-pyprojectをインストールして実行します。
See also Two other projects aim to address the same problem:
flake9 FlakeHell Both seem to try to do a lot more than just getting pyproject.toml support. pyproject-flake8 tries to stay minimal while solving its task.
flake8-pyproject adds only pyproject.toml support, and does this as a Flake8 plugin, allowing the original flake8 command to work (rather than using pflake8).
% poetry add --group dev Flake8-pyproject Using version ^1.2.2 for flake8-pyproject Updating dependencies Resolving dependencies... (0.5s) Writing lock file Package operations: 1 install, 0 updates, 0 removals • Installing flake8-pyproject (1.2.2)
再度、poetry run flake8
を実行したころ、pyproject.toml
に追加したignoreの指定の通り、E501
の指摘は除かれました。
続けて指摘されたエラーについて確認していきます。
% poetry run flake8 src src/main.py:66:21: W503 line break before binary operator src/main.py:67:21: W503 line break before binary operator src/main.py:78:45: E712 comparison to False should be 'if cond is False:' or 'if not cond:' src/main.py:79:21: W503 line break before binary operator src/main.py:80:21: W503 line break before binary operator src/subscan.py:392:17: E722 do not use bare 'except' src/subscan.py:413:21: W503 line break before binary operator src/subscan.py:414:21: W503 line break before binary operator src/subscan.py:605:17: E722 do not use bare 'except' src/subscan.py:625:21: W503 line break before binary operator src/subscan.py:626:21: W503 line break before binary operator
# Subscan API設定値の確認 if ( address_token_value == "" or decimal_point_adjust_token_value == "" or display_digit_token_value == "" ):
一方で二項演算子の後に改行をするとW504という、W503と逆の指摘がされます。
src/main.py:65:47: W504 line break after binary operator
# Subscan API設定値の確認 if ( address_token_value == "" or decimal_point_adjust_token_value == "" or display_digit_token_value == "" ):
結論から言えば、PEP8のShould a Line Break Before or After a Binary Operator?に書かれている通り、二項演算子の前で改行することが正しいことになります。
つまり、blackによるフォーマット変更は正しいことになります。(blackは改行後に演算子を置くようにフォーマットされる仕様)
そこで、W503もignoreに追加します。
[tool.flake8] ignore = ["E501","W503"]
再度実行します。
% poetry run flake8 src src/main.py:78:45: E712 comparison to False should be 'if cond is False:' or 'if not cond:' src/subscan.py:392:17: E722 do not use bare 'except' src/subscan.py:605:17: E722 do not use bare 'except'
E712は指摘の通り、==
ではなく、is
に変更します。
E722は例外がないことによる指摘です。
例外についてはワイルドカードのexcept節(bare except)
が参考になりますが、Pythonではすべての例外は BaseException から派生したクラスのインスタンスでなければなりません。
また、E722でもControl-Cでプログラムを中断することを困難にすることが問題と説明されています。
E722の対応としては、該当の例外処理では明確なエラーを判断することができないため、Exception
を指定します。
A bare except: clause will catch SystemExit and KeyboardInterrupt exceptions, making it harder to interrupt a program with Control-C, and can disguise other problems. If you want to catch all exceptions that signal program errors, use except Exception: (bare except is equivalent to except BaseException:).
修正前
except: # ex)API rate limit exceeded break
修正後
except Exception as e: # ex)API rate limit exceeded print(e) break
修正したところエラー指摘は0件となりました。
% poetry run flake8 src # 指摘なし
mypyコマンドの実行
mypyは型アノテーションをもとに型チェックを行うライブラリです。
pyproject.toml
の設定は下記の通りです。
[tool.mypy] python_version = "3.10" no_strict_optional = true ignore_missing_imports = true check_untyped_defs = true
コマンドを実行したところ、複数指摘されました。
% poetry run mypy src src/subscan.py:7: error: Library stubs not installed for "requests" [import] src/subscan.py:7: note: Hint: "python3 -m pip install types-requests" src/subscan.py:161: error: Incompatible types in string interpolation (expression has type "List[Any]", placeholder has type "Union[int, float]") [str-format] src/subscan.py:161: error: Incompatible types in assignment (expression has type "str", variable has type "List[Any]") [assignment] src/subscan.py:452: error: "int" has no attribute "values" [attr-defined] src/subscan.py:503: error: "int" has no attribute "values" [attr-defined] src/subscan.py:639: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:650: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:652: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:662: error: "int" has no attribute "values" [attr-defined] src/subscan.py:696: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:709: error: "int" has no attribute "values" [attr-defined] src/main.py:4: error: Library stubs not installed for "toml" [import] src/main.py:4: note: Hint: "python3 -m pip install types-toml" src/main.py:4: note: (or run "mypy --install-types" to install all missing stub packages) src/main.py:4: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports Found 12 errors in 2 files (checked 4 source files)
まずは、requestsとtomlの指摘について対応します。
これは型情報を持つstubファイル (.pyi) を含まないパッケージをインポートすると起きるようです。
pyproject.tomlではMissing importsのエラーへの対応として、ignore_missing_imports = true
を指定しましたが、requestsとtomlは対象とならなかったようです。
library-stubs-not-installedの記載の通り、ライブラリのスタブが存在することを知らせているため、poetry addで追加します。
% poetry add --group dev types-requests types-toml Using version ^2.28.11.15 for types-requests Using version ^0.10.8.5 for types-toml Updating dependencies Resolving dependencies... (0.3s) Writing lock file Package operations: 3 installs, 0 updates, 0 removals • Installing types-urllib3 (1.26.25.8) • Installing types-requests (2.28.11.15) • Installing types-toml (0.10.8.5)
指摘が10件に減りました。
% poetry run mypy src src/subscan.py:161: error: Incompatible types in string interpolation (expression has type "List[Any]", placeholder has type "Union[int, float]") [str-format] src/subscan.py:161: error: Incompatible types in assignment (expression has type "str", variable has type "List[Any]") [assignment] src/subscan.py:452: error: "int" has no attribute "values" [attr-defined] src/subscan.py:503: error: "int" has no attribute "values" [attr-defined] src/subscan.py:639: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:650: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:652: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:662: error: "int" has no attribute "values" [attr-defined] src/subscan.py:696: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:709: error: "int" has no attribute "values" [attr-defined] Found 10 errors in 1 file (checked 4 source files)
src/subscan.py:161: error: Incompatible types in string interpolation (expression has type "List[Any]", placeholder has type "Union[int, float]") [str-format]
コンストラクタで空のリストを作成して初期化していましたが、リストに対してfloat型の変数を代入しているため型不整合のエラーが指摘されていました。
クラス内で変数を使用する目的で定義していましたが、self.__value
はfoamatされた変数の代入に使用して、self.__value
をリストとして使用していた箇所は別の変数名に変えることにします。
# Responseデータ1件分のデータを格納するリスト self.__value = [] … self.__value = float(one_line_headerdata_list[4]) * adjust_value self.__value = "{:.14f}".format(self.__value)
修正後
変数としてstkrwd_data_list
のリストを新規作成し、メソッド内でのみ使用するように修正しました。
# 受信したjsonデータのlist要素(item)から取得したいStakingRewardsのlistを作成するメソッド def get_reward_slash_data(self, item, response_json): stkrwd_data_list = [] for column_index in self.__reward_slash_data_token: event_index_data = response_json["data"]["list"][item][column_index] stkrwd_data_list.append(event_index_data) # 受信したjsonデータ(list)の1要素分のデータとStakingRewardsのlistとして返す return stkrwd_data_list
再度mypyコマンドを実行すると指摘が8件に減りました。
% poetry run mypy src src/subscan.py:449: error: "int" has no attribute "values" [attr-defined] src/subscan.py:500: error: "int" has no attribute "values" [attr-defined] src/subscan.py:636: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:647: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:649: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:659: error: "int" has no attribute "values" [attr-defined] src/subscan.py:693: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:706: error: "int" has no attribute "values" [attr-defined] Found 8 errors in 1 file (checked 4 source files)
src/subscan.py:449: error: "int" has no attribute "values" [attr-defined]
指摘されたコードは下記で、右辺のself.sort_df_retrieve
にはsort_dataframe
メソッドでソートされたDataFrame型のオブジェクトが代入される処理になっています。
ソートされたDataFrameからvalues.tolist
メソッドでリストに変換して代入します。
つまり、左辺はリストであるべきです。
# 抽出後のデータをリスト化 # 代入時点では左辺、右辺ともにAny型 self.response_data = self.sort_df_retrieve.values.tolist()
# ソート
self.sort_df_retrieve = self.sort_dataframe(
df_retrieve, self.sort_type
)
def sort_dataframe(self, df, sort_type): num = len(df) sort_Column = list(range(num)) df_s1 = df.assign(SortColumn=sort_Column) df_s2 = df_s1.sort_values("SortColumn", ascending=sort_type) df_s3 = df_s2.drop("SortColumn", axis=1) return df_s3
ここで、self.response_data
の定義を確認するとSubscanStakingRewardDataProcess
クラスとSubscanStakingRewardDataProcess
クラスから継承されたSubscanStakingRewardsDataProcessForCryptact
クラスで0で初期化していました。
変数の型定義を確認したところ、初期化時はint型、抽出後のデータ代入時は左辺がlist型、右辺がDataFrame型という状況でした。これは適切ではありませんでした。
self.response_data = 0 self.sort_df_retrieve = 0 print(type(self.response_data)) print(type(self.sort_df_retrieve)) ... # 抽出後のデータをリスト化 self.response_data = self.sort_df_retrieve.values.tolist() print(type(self.response_data)) print(type(self.sort_df_retrieve))
型定義確認
<class 'int'> <class 'int'> <class 'list'> <class 'pandas.core.frame.DataFrame'>
変数初期化時にリスト型とDataFrame型の変数で初期化するように変更します。
変更後
self.response_data = [] data_list = [["data1", 1], ["data2", 2]] self.sort_df_retrieve = pd.DataFrame(data_list, columns=["culumn1", "culumn2"]) print(type(self.response_data)) print(type(self.sort_df_retrieve)) ... # 抽出後のデータをリスト化 self.response_data = self.sort_df_retrieve.values.tolist() print(type(self.response_data)) print(type(self.sort_df_retrieve))
型定義確認
<class 'list'> <class 'pandas.core.frame.DataFrame'> <class 'list'> <class 'pandas.core.frame.DataFrame'>
修正し、再度mypyコマンドを実行すると指摘が4件に減りました。
残りはSubscanStakingRewardsDataFrame
クラスのメソッド使用に関する指摘でした。
% poetry run mypy src src/subscan.py:638: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] src/subscan.py:649: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:651: error: "SubscanStakingRewardsDataFrame" has no attribute "df_cryptact_header_data" [attr-defined] src/subscan.py:695: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr", "get_reward_slash_data_var_dot_ksm", or "get_reward_slash_data"? [attr-defined] Found 4 errors in 1 file (checked 4 source files)
src/subscan.py:638: error: "SubscanStakingRewardsDataFrame" has no attribute "get_reward_slash_data_var_cryptact"; maybe "get_reward_slash_data_var_astr",
本プログラムではステーキング報酬データについて、Subscan APIと以下クラスを使用して取得します。
ステーキング報酬データをPySimpleGUIのテーブルで表示するために、テーブルのカラムデータを作成するクラスとSubscanからステーキングデータの取得するクラスと整形するクラスの2つを使用する。
Subscanのデータを元にクリプタクトという損益計算向けのデータを1.で定義されたSubscanの2つのクラスを継承する形でクラスを作成しています。
<補足>
SubscanとはSubstrateと呼ばれるフレームワークで作成された暗号資産について取引履歴を管理するブロックチェーンエクスプローラーです。
例えば、Polkadot向けではSubscan.ioにアクセスすることでアドレス毎に関連するデータを参照することができます。
上記指摘はSubscanStakingRewardsDataFrame
クラスにはget_reward_slash_data_var_cryptact
メソッドがないという指摘です。
そもそもget_reward_slash_data_var_astr
メソッドには、
one_line_data_list = ( self.subscan_stkrwd_df.get_reward_slash_data_var_cryptact( one_line_headerdata_list, self.cryptact_info_data, self.adjust_value, self.digit, ) )
SubscanStakingRewardsDataProcessForCryptact
というクラスでSubscanStakingRewardsDataFrameForCryptact
クラスから作成したインスタンスであり、get_reward_slash_data_var_cryptact
メソッドは存在しています。
class SubscanStakingRewardsDataProcessForCryptact(SubscanStakingRewardDataProcess): def __init__( self, input_num, config_subscan_api_info, config_cryptact_info, token, sort ): # インスタンス作成 self.subscan_stkrwd_df = SubscanStakingRewardsDataFrameForCryptact( config_subscan_api_info, config_cryptact_info, token )
問題は何かというと
get_reward_slash_data_var_cryptact
メソッドを呼び出すインスタンス変数に問題があります。
メソッドをコールするself.subscan_stkrwd_df
は以下でも作成していました。
class SubscanStakingRewardDataProcess: def __init__(self, input_num, config_subscan_api_info, token, sort): # インスタンス作成 self.subscan_stkrwd_df = SubscanStakingRewardsDataFrame( config_subscan_api_info, token )
つまり、1.と2.の処理において、同じインスタンス変数を使用していることになります。
プログラム上は1.と2.で同時にインスタンス変数を使用すること(テーブルに表示すること)はないため問題はありませんが、仮に2つのインスタンスを同時に使用してデータを表示するようなケースでは問題になります。
そこで、SubscanStakingRewardsDataFrameForCryptact
メソッドの戻り値を代入する
インスタンス変数をself.subscan_stkrwd_df
からsubscan_stkrwd_df_for_cryptact
に変更します。
修正後、再度mypyコマンドを実行すると指摘は0件となりました。
% poetry run mypy src Success: no issues found in 4 source files
pytestコマンドの実行
pytestはPython向けに作成された単体テストを書くためのフレームワークです。
今回はcryptact.py
のコードをテスト対象としてテストコードを作成してテストしてみます。
ファイル構造は下記の通りです。(関連するファイル以外は表示していません)
% tree . ├── src │ ├── config.toml │ ├── cryptact.py │ ├── gui.py │ ├── main.py │ └── subscan.py └── tests └── test_cryptact.py
pyproject.toml
は下記の通りです。
pytestではtests
フォルダ以下にテストコードを作成する仕様(参考)であり、フォルダ名を変更しない場合は以下を記述しなくともpoetryコマンドから実行できますが、明示的に"tests"
としてフォルダを対象パスとします。
[tool.pytest.ini_options] testpaths = ["tests",]
テストコードは下記の通りです。
詳細は割愛しますが、クリプタクトでステーキングデータを使用して損益計算する場合、カスタムファイルを作成する必要があります。
ここでCryptactInfo
クラスはステーキングデータのカスタムファイル向けのカラムデータをTOMLファイル(config.toml
)から取得して作成するためのクラスです。
クラスのテストはGUIを操作してデータをテーブルに表示して確認すれば良いですが、数が増えるとそれだけ確認する作業が発生します。
現状はDOT,KSM,ASTRの3種類のトークンのため手間にはなりませんが、テストコードからテストしてみます。
作成したテストコードは下記の通りです。
pytestではPython標準のassert文を使用してテストすることができます。
テストコードでは下記のデータを作成してasset文を実行します。
テストデータ:CryptactInfoクラスで取得するテーブルのカラムデータ
期待値:tomlファイルからデータを取得したカラムデータ
また、複数のテストケースを使用する方法は後述します。
import pytest import toml from src.cryptact import CryptactInfo # 1件分のテストコード # 以下のコードをアサートしてテストする # テストデータ:CryptactInfoクラスで取得するテーブルのカラムデータ # 期待値:tomlファイルからデータを取得したカラムデータ """ def cryptact_test_1case(token,expect_token): with open("./src/config.toml") as f: config = toml.load(f) config_cryptact_info = config["cryptact_info"] action = config_cryptact_info["action"] price = config_cryptact_info["price"] counter = config_cryptact_info["counter"] fee = config_cryptact_info["fee"] feeccy = config_cryptact_info["feeccy"] source_token = f"source_{expect_token.lower()}" base_token = f"base_{expect_token.lower()}" source = config_cryptact_info[source_token] base = config_cryptact_info[base_token] ci = CryptactInfo(config_cryptact_info, token) assert ci.cryptact_info == (action, source, base, price, counter, fee, feeccy) """ # tomlファイルからデータを取得して期待値を作成するメソッド def cryptact_expect_val(token): with open("./src/config.toml") as f: config = toml.load(f) config_cryptact_info = config["cryptact_info"] action = config_cryptact_info["action"] price = config_cryptact_info["price"] counter = config_cryptact_info["counter"] fee = config_cryptact_info["fee"] feeccy = config_cryptact_info["feeccy"] source_token = f"source_{token.lower()}" base_token = f"base_{token.lower()}" source = config_cryptact_info[source_token] base = config_cryptact_info[base_token] return (action, source, base, price, counter, fee, feeccy) # 複数のテストケースを実行する場合(正常系) def cryptact_culumn_data_test(token): with open("./src/config.toml") as f: config = toml.load(f) config_cryptact_info = config["cryptact_info"] ci = CryptactInfo(config_cryptact_info, token) return ci.cryptact_info # テストケース1 @pytest.mark.parametrize( ("token", "expected"), [ ("DOT", cryptact_expect_val("DOT")), ("KSM", cryptact_expect_val("KSM")), ("ASTR", cryptact_expect_val("ASTR")), ], ) def test1(token, expected): assert cryptact_culumn_data_test(token) == expected # 複数のテストケースを実行する場合(異常系) def cryptact_culumn_data_test_ng(token): with open("./src/config.toml") as f: config = toml.load(f) config_cryptact_info = config["cryptact_info"] ci = CryptactInfo(config_cryptact_info, token) return ci.cryptact_info # テストケース2 @pytest.mark.parametrize( ("token", "expected"), [ ("DOT", cryptact_expect_val("KSM")), ("DOT", cryptact_expect_val("ASTR")), ("KSM", cryptact_expect_val("DOT")), ("KSM", cryptact_expect_val("ASTR")), ("ASTR", cryptact_expect_val("DOT")), ("ASTR", cryptact_expect_val("KSM")), ], ) def test2(token, expected): assert cryptact_culumn_data_test_ng(token) != expected # 1件ずつテストする場合 """ def test(): cryptact_test_1case("DOT","DOT") cryptact_test_1case("KSM","KSM") cryptact_test_1case("ASTR","ASTR") """
次にpytestコマンド実際に実行します。
上記環境で実行するとエラーとなりました。
E ModuleNotFoundError: No module named 'src'
と指摘されています。
これはpytestの仕様により、testsディレクトリと実行したいテストファイルの存在するディレクトリに__init__.py
がないと sys.path にpytest-testディレクトリが追加されず、pytestコマンドの実行場所からだとsrcパッケージが参照できないためでした。(参考)
% poetry run pytes t ============================================ test session starts ============================================ platform darwin -- Python 3.10.4, pytest-7.2.2, pluggy-1.0.0 rootdir: $HOME/Develop/git_local/dlSubscanStakingRewardsHistory, configfile: pyproject.toml, testpaths: tests collected 0 items / 1 error ================================================== ERRORS =================================================== __________________________________ ERROR collecting tests/test_cryptact.py __________________________________ ImportError while importing test module '$HOME/Develop/git_local/dlSubscanStakingRewardsHistory/tests/test_cryptact.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.10.4/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/importlib/__init__.py:126: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_cryptact.py:4: in <module> from src.cryptact import CryptactInfo E ModuleNotFoundError: No module named 'src' ========================================== short test summary info ========================================== ERROR tests/test_cryptact.py !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ============================================= 1 error in 0.12s ==============================================
そこで、testsフォルダに__init__.py
を追加してpoetry run pytest
を実行します。
% tree . ├── src │ ├── config.toml │ ├── cryptact.py │ ├── gui.py │ ├── main.py │ └── subscan.py └── tests ├── __init__.py └── test_cryptact.py
次に上記コードに対して、test()
を有効にして実行すると下記結果となりました。
3件実施していますが、1件分として評価されています。
% poetry run pytest ============================================ test session starts ============================================ platform darwin -- Python 3.10.4, pytest-7.2.2, pluggy-1.0.0 rootdir: $HOME/Develop/git_local/dlSubscanStakingRewardsHistory, configfile: pyproject.toml, testpaths: tests collected 1 item tests/test_cryptact.py . [100%] ============================================= 1 passed in 0.02s =============================================
コードとしてはトークン別の情報が取得できない場合のエラー処理は実走していないので、コードの役割として異常系を確認する必要はありませんが、テストコードの例としてNGケースも確認します。
cryptact_test_1case("KSM","KSM")
をcryptact_test_1case("KSM","DOT")
に変更します。
def test(): cryptact_test_1case("DOT","DOT") cryptact_test_1case("KSM","DOT") cryptact_test_1case("ASTR","ASTR")
pytestコマンドを実行するとKSMのケースでNGとなり、実行が終了しました。
% poetry run pytest ============================================ test session starts ============================================ platform darwin -- Python 3.10.4, pytest-7.2.2, pluggy-1.0.0 rootdir: $HOME/Develop/git_local/dlSubscanStakingRewardsHistory, configfile: pyproject.toml, testpaths: tests collected 1 item tests/test_cryptact.py F [100%] ================================================= FAILURES ================================================== ___________________________________________________ test ____________________________________________________ def test(): cryptact_test_1case("DOT","DOT") > cryptact_test_1case("KSM","DOT") tests/test_cryptact.py:66: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ token = 'KSM', expect_token = 'DOT' def cryptact_test_1case(token,expect_token): with open("./src/config.toml") as f: config = toml.load(f) config_cryptact_info = config["cryptact_info"] action = config_cryptact_info["action"] price = config_cryptact_info["price"] counter = config_cryptact_info["counter"] fee = config_cryptact_info["fee"] feeccy = config_cryptact_info["feeccy"] source_token = f"source_{expect_token.lower()}" base_token = f"base_{expect_token.lower()}" source = config_cryptact_info[source_token] base = config_cryptact_info[base_token] ci = CryptactInfo(config_cryptact_info, token) > assert ci.cryptact_info == (action, source, base, price, counter, fee, feeccy) E AssertionError: assert ('STAKING', '...'JPY', 0, ...) == ('STAKING', '...'JPY', 0, ...) E At index 1 diff: 'KZ8_DW_KSM_4ID1' != 'KZ8_DW_DOT_4ID1' E Use -v to get more diff tests/test_cryptact.py:24: AssertionError ========================================== short test summary info ========================================== FAILED tests/test_cryptact.py::test - AssertionError: assert ('STAKING', '...'JPY', 0, ...) == ('STAKING', '...'JPY', 0, ...) ============================================= 1 failed in 0.10s =============================================
これでもテストとしては確認できますが、正常系と異常系も含む、NG結果のテストコードも実行できるようにする必要があります。
そこで、パラメータ化したテストを参考に正常系(test1)と異常系(test2)のテストコードを作成しました。(上記コード)
pytestコマンドの実行結果は下記の通りです。
9ケース分のテストが実行されたことを確認できます。
% poetry run pytest ============================================ test session starts ============================================ platform darwin -- Python 3.10.4, pytest-7.2.2, pluggy-1.0.0 rootdir: $HOME/Develop/git_local/dlSubscanStakingRewardsHistory, configfile: pyproject.toml, testpaths: tests collected 9 items tests/test_cryptact.py ... [100%] ==================================================================== 9 passed in 0.12s =====================================================================
次にpytestの追加プラグインとしてカバレッジ結果を出力できるようにします。
pytest-cov
をインストールします。
% poetry add --group dev pytest-cov Using version ^4.0.0 for pytest-cov Updating dependencies Resolving dependencies... (0.5s) Writing lock file Package operations: 2 installs, 0 updates, 0 removals • Installing coverage (7.2.1) • Installing pytest-cov (4.0.0)
実行結果
% poetry run pytest -s -vv --cov=. --cov-branch --cov-report=html =================================================================== test session starts ==================================================================== platform darwin -- Python 3.10.4, pytest-7.2.2, pluggy-1.0.0 -- $HOME/Develop/git_local/dlSubscanStakingRewardsHistory/.venv/bin/python cachedir: .pytest_cache rootdir: $HOME/Develop/git_local/dlSubscanStakingRewardsHistory, configfile: pyproject.toml, testpaths: tests plugins: cov-4.0.0 collected 9 items tests/test_cryptact.py::test1[DOT-expected0] PASSED tests/test_cryptact.py::test1[KSM-expected1] PASSED tests/test_cryptact.py::test1[ASTR-expected2] PASSED tests/test_cryptact.py::test2[DOT-expected0] PASSED tests/test_cryptact.py::test2[DOT-expected1] PASSED tests/test_cryptact.py::test2[KSM-expected2] PASSED tests/test_cryptact.py::test2[KSM-expected3] PASSED tests/test_cryptact.py::test2[ASTR-expected4] PASSED tests/test_cryptact.py::test2[ASTR-expected5] PASSED ---------- coverage: platform darwin, python 3.10.4-final-0 ---------- Coverage HTML written to dir htmlcov ==================================================================== 9 passed in 0.27s =====================================================================
実行後ディレクトリを確認するとindex.html
が作成されました。
% tree . ├── htmlcov │ ├── coverage_html.js │ ├── d_145eef247bfb46b6_cryptact_py.html │ ├── d_a44f0ac069e85531___init___py.html │ ├── d_a44f0ac069e85531_test_cryptact_py.html │ ├── favicon_32.png │ ├── index.html │ ├── keybd_closed.png │ ├── keybd_open.png │ ├── status.json │ ├── style.css │ └── test_py.html ├── src │ ├── __pycache__ │ │ ├── cryptact.cpython-310.pyc │ │ ├── gui.cpython-310.pyc │ │ └── subscan.cpython-310.pyc │ ├── config.toml │ ├── cryptact.py │ ├── gui.py │ ├── main.py │ └── subscan.py └── tests ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-310.pyc │ └── test_cryptact.cpython-310-pytest-7.2.2.pyc └── test_cryptact.py
HTMLを確認するとcryptact.py
に関連するテスト対象コードとテストコードでカバレッジ100%であることを確認できました。
補足
テストコード作成後、src,testフォルダを指定してmypyを実行したところ下記エラーとなりました。
% poetry run mypy src tests src/cryptact.py: error: Source file found twice under different module names: "cryptact" and "src.cryptact" src/cryptact.py: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules for more info src/cryptact.py: note: Common resolutions include: a) adding `__init__.py` somewhere, b) using `--explicit-package-bases` or adjusting MYPYPATH Found 1 error in 1 file (errors prevented further checking)
For example, say your directory tree consists solely of pkg/init.py and pkg/a/b/c/d/mod.py. When determining mod.py’s fully qualified module name, mypy will look at pkg/init.py and conclude that the associated module name is pkg.a.b.c.d.mod.
上記指摘とリンクを参考に、cryptact.py
と src.cryptact
を正しく関連付ける必要があるため、 src/__init__.py
を追加して対応しました。
まとめ
Pythonの静的解析ツール(ライブラリ)をPoetryで使用する方法について、 実際のプロジェクトを例に紹介しました。
本来はコードコミット前に環境構築して静的解析を適用できれば良かったのですが、過去記事でも既存プロジェクトを例に紹介していたため、過去記事の続きとして紹介しました。
紹介した静的解析ツール(ライブラリ)
poetryコマンドによる使い方
% poetry run isort [対象フォルダ名1] [対象フォルダ名2] % poetry run black [対象フォルダ名1] [対象フォルダ名2] % poetry run flake8 s[対象フォルダ名1] [対象フォルダ名2] % poetry run mypy [対象フォルダ名1] [対象フォルダ名2] % poetry run pytest -s -vv --cov=. --cov-branch --cov-report=html
気づき
実際に静的解析ツールを使用して、事前に動作確認をして問題はなくとも、変数やメソッドの使用方法が適切ではないケースがありました。
また、コード量が増えると正しく実装できているか判断しづらくなるため、静的解析ツールを使用することは非常に有用だと感じました。
特にPoetryでインストールすることで、ツールの設定情報をPyproject.toml
で管理することが可能になり、より扱いやすくなる印象を受けました。
今後は作成するプロジェクトについても静的解析ツールをインストールして使おうと思います。
参考記事
以下の記事を参考にさせていただきました。
その設定、pyproject.tomlに全部書けます
https://data.gunosy.io/entry/linter_option_on_pyprojectPythonのコードフォーマッターのBlackの使い方
https://book.st-hakky.com/docs/application-python-black/Pythonで長い行を書くとき、は改行は演算子の前にすべし
https://rcmdnk.com/blog/2019/11/04/computer-python/mypyで静的型チェックを導入する
https://ohke.hateblo.jp/entry/2020/10/03/230000ゼロから学ぶpython
https://rinatz.github.io/python-book/ch08-02-pytest/pytestした時にModuleNotFoundErrorが出る時の原因と対処法
https://zenn.dev/pesuchin/articles/9573476d53d234f09433オレオレ Python 開発環境 https://zenn.dev/jdbtisk/articles/e6ed54b38b6a45
以上です。