7rikazhexde’s tech log

技術的な興味関心、備忘録、アウトプットなどを書いています。

Pythonの静的解析ツールをPoetryで使用する方法

以前、以下記事で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 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の例はこちら)
現状特に問題なく代替できることを確認しているため徐々に置き換えていこうと考えています。

実行環境

静的解析を適用するプロジェクトは下記を使用します。

github.com

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コマンドの実行

blackPython向けのコードフォーマッターで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

W503二項演算子の前で改行したことによる指摘です。

# 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と以下クラスを使用して取得します。

  1. ステーキング報酬データをPySimpleGUIのテーブルで表示するために、テーブルのカラムデータを作成するクラスとSubscanからステーキングデータの取得するクラスと整形するクラスの2つを使用する。

  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コマンドの実行

pytestPython向けに作成された単体テストを書くためのフレームワークです。

今回は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%であることを確認できました。

カバレッジ結果(index.html)

補足

テストコード作成後、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.pysrc.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で管理することが可能になり、より扱いやすくなる印象を受けました。

今後は作成するプロジェクトについても静的解析ツールをインストールして使おうと思います。

参考記事

以下の記事を参考にさせていただきました。

以上です。