ちょっと話題の記事

Pythonプロジェクトを快適にするために導入したツールとその設定

2021.12.24

はじめに

私は普段よくTypeScript/Node.jsを書くことが多く、ESLintやPrettierといった開発ツールのエコシステムが便利で、効率よくコーディングできた体験がありました。今回Pythonプロジェクトに参画するにあたり、同様のことが出来ないか検討し、設定した内容について共有します。

個人的にも結構癖のある設定をしている自覚はあり、あくまで設定の一例として記事を見て頂けたらと存じます。

対象読者

普段別言語がメインが、急遽Pythonプロジェクトを作る必要があり、下記の要件がある人向けです

  • パッケージの依存関係を管理したい
  • チーム開発するため、リンター、コードフォーマッター、テストツールを導入したい
    • ただし、mypyで静的解析をがっつりと通せる自信がない
  • 環境毎に挙動を変更できるようにしたい
  • ソースコード保存時、コードフォーマッターを適用したい
  • テストが書きたい
  • 関数をモックしたい
  • テスト実行を自動化したい

利用するツール

Python関連

Pythonは3.8以上を想定

ツール名 用途
Poetry パッケージ管理ツール
isort インポートの宣言順を整えてくれるコードフォーマッター
black 改行や、'"の統一 末尾,の統一といった広義なコードフォーマッター
flake8 PEP8準拠かチェックするリンター
pysen 上記のリンター、コードフォーマッターの統合ツール
pytest テストツール プラグイン設定に関しては後述
python-dotenv 環境毎にファイル用意し、環境変数を切り替える際に利用するツール

その他

ツール名 用途
GitHub Actions テスト自動化

ツールのセットアップ

Pythonについては割愛。私はanyenv+pyenvを利用してPythonの管理をしています。

Poetry

python-poetry/poetryを参考に、poetryをインストール

curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python

パスを通します

export PATH="$HOME/.poetry/bin:$PATH"

プロジェクトの作成手順

本項を実行するのが面倒な方向けに、GitHubにプロジェクトのテンプレートリポジトリshuntaka9576/python-project-templateを用意しました。クローンしてお使いください。

プロジェクトの初期設定作業

プロジェクト用のディレクトリを作成

mkdir python-project-template
cd python-project-template

PoetryでPythonプロジェクトの初期化を行います

poetry init

実行時、対話型でPythonプロジェクトオプションを設定(コメントで設定内容を補足)

Package name [python-project-template]: # Enterでスキップ
Version [0.1.0]: # Enterでスキップ
Description []: # Enterでスキップ
Author [xxxx <xxxxxx@gmail.com>, n to skip]: # Enterでスキップ
License []: # Enterでスキップ
Compatible Python versions [^3.7]: # Enterでスキップ
Would you like to define your main dependencies interactively? (yes/no) [yes] no # no
Would you like to define your development dependencies interactively? (yes/no) [yes] # no
# 作成されるpyproject.tomlが標準出力に出力
Do you confirm generation? (yes/no) [yes] # Enterでスキップ
# pyproject.tomlが作成される

プロジェクトルートの.venvでパッケージを管理したいので、以下のコマンドを実行します

poetry config --local virtualenvs.in-project true
# poetry.tomlが作成される

pysenの導入と設定

pysenを導入します

poetry add -D pysen==0.9.1 -E lint
poetry add -D pytest

pyproject.tomlpysenの設定を追記 mypyは、導入するハードルが高いプロジェクトもあることから、今回falseとしています。(すいません)

[tool.pysen]
version = "0.9"

[tool.pysen.lint]
enable_black = true
enable_flake8 = true
enable_isort = true
enable_mypy = false
mypy_preset = "strict"
line_length = 88
py_version = "py37"
[[tool.pysen.lint.mypy_targets]]
  paths = ["."]

pytestの導入と設定

pytestを導入します

poetry add -D pytest

pytest補助パッケージを追加

# モックパッケージ
poetry add -D pytest-mock
# テストの表示を見やすくするパッケージ
poetry add -D pytest-sugar

dotenvの導入と設定

dotenvを導入します。下記のように追加するとCLI機能のみ追加できます。

poetry add "python-dotenv[cli]"

dotenvは、以下のように実行されることを想定し、本番向けパッケージとして追加しています

# 開発
.venv/bin/dotenv --file .env.development run -- .venv/bin/python src/main.py

# 本番
.venv/bin/dotenv --file .env run -- .venv/bin/python src/main.py

タスクランナーの設定

今回はMakefileを利用しますが、本設定は任意です。

コマンド 説明
make start {環境名} {環境名}で実行
make lint リンター実行
make lint-fix フォマッター実行後、リンター実行
make install-dev 開発ツールを含めた依存解決
make install 開発ツールを除いた依存解決(本番環境向け)
start:
        if [ -n "${ENV}" ]; then \
                .venv/bin/dotenv --file ${ENV} run -- .venv/bin/python src/main.py; \

lint:
        poetry run pysen run lint

lint-fix:
        poetry run pysen run format && \
        poetry run pysen run lint

test-unit:
        poetry run pytest

install-dev:
        poetry install

install:
        poetry install --no-dev

本番環境のみ入れたいパッケージがある場合

IoT開発等では、開発時はMacで本番はラズパイみたいなケースの場合、アーキテクチャ依存で追加できないパッケージがあったりします。 例えばRPi.GPIOは、GPIOがないMacでは追加できません。その際はpyproject.tomlextrasオプションを活用します。

[tool.poetry.extras]
prd = ["RPi.GPIO"]

--extras prd指定時のみ、前述の[]の中のパッケージをインストールすることが可能です 本オプションを利用する場合、前述のMakefileは以下のように書き換える必要があります

install:
-       poetry install --no-dev
+       poetry install --no-dev --extras prd

Visual Studio Code設定

詳細はリポジトリをご参照ください

ポイントは下記です。

  • エディタ側既にPython設定がある場合を考慮し、副作用を与えないため、推奨設定を使いたい人のみsettings.default.jsonsettings.jsonにリネームする運用 対象箇所
  • 保存時にblackが適用されるようになっている 対象箇所
  • エディタ側で実行されるflake8pysen経由で実行されるflake8の設定を同じにするように、VSCode側の設定を.config/flake8に記述 対象箇所
  • 外部パッケージをコード補完できるように、.venvのPythonを指定 対象箇所

GitHub Actions設定

依存関係の取得 -> 静的解析 -> テストの順で実行されます

.github/workflows/ci.yml

name: ci python-project-template
env:
  PROJECT_NAME: python-project-template
on: [push]
jobs:
  deploy:
    name: CI
    timeout-minutes: 5
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.7, 3.8]
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v1
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install Poetry
        run: |
          curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
          echo "$HOME/.poetry/bin" >> $GITHUB_PATH
      - name: Install Packages
        run: |
          poetry install
      - name: Run Lint
        run: |
          poetry run pysen run lint
      - name: Run Test
        run: |
          poetry run pytest

プロジェクトの使い方

本項だけ試したい場合は、shuntaka9576/python-project-templateをクローンしてください。

依存関係の取得

開発

poetry install
# or make install

本番

poetry install --no-dev
# or make install-dev

パッケージの追加

開発向け

poetry add -D [package名]

本番向け

poetry add [package名]

静的解析/コードフォーマット

Visual Studio Code側にも自動でフォーマットするような設定をしていますが、基本pysenの実行結果を正として運用します。実装してコミットする前に下記のコマンドを実行します。CIでも実行します。

poetry run pysen run format && poetry run pysen run lint
# 下記のコマンドが実行されます
# or make lint-fix

テスト

ユーザーのIDを指定して、ブログ記事一覧を取ってくるという想定でコードを書きます

Pythonのアプリ側のコードを作成

src/main.py

from typing import List, Optional, TypedDict


class DbArticle(TypedDict):
    title: str
    user_id: str
    extra_field1: str
    extra_field2: str


class Article(TypedDict):
    title: str
    user_id: str


def get_articles_from_db(userId: str) -> Optional[List[DbArticle]]:
    # TODO DB問い合わせ処理
    return None



def get_articles(user_id: str) -> List[Article]:
    db_article_list = get_articles_from_db(user_id)
    article_list: List[Article] = []

    if db_article_list is None:
        return []
    else:
        for db_article in db_article_list:
            article: Article = {
                "title": db_article["title"],
                "user_id": db_article["user_id"],
            }
            article_list.append(article)

        return article_list


if __name__ == "__main__":
    user_id = "alice"
    article_list = get_articles(user_id)

    if len(article_list) == 0:
        print("Not found article")
    else:
        print(article_list)

Pythonのテストコードを作成。適宜init.pyを作る必要があるため、詳細はリポジトリをご参照ください

python-project-template/tests/unit/test_main.py

import src.main


class TestClass(object):
    def test_get_articles(self, mocker) -> None:
        mock_get_articles_from_db = mocker.patch.object(
            src.main, "get_articles_from_db"
        )
        mock_get_articles_from_db.return_value = [
            {
                "title": "blog title",
                "user_id": "test_user_id",
                "extra_field1": "extra_field1_value",
                "extra_field2": "extra_field2_value",
            }
        ]

        article_list = src.main.get_articles("test_user_id")

        mock_get_articles_from_db.assert_called_once_with("test_user_id")
        assert article_list == [
            {
                "title": "blog title",
                "user_id": "test_user_id",
            }
        ]

実行

poetry run pytest
# or make test-unit

実行結果は、pytest-sugarを利用しているため、デフォルトよりテスト結果をリッチに表示してくれるのでおすすめです。

$ make test-unit
poetry run pytest
Test session starts (platform: darwin, Python 3.8.9, pytest 6.2.4, pytest-sugar 0.9.4)
rootdir: /Users/takahashi.shunichi/repos/github.com/shuntaka9576/python-prj-sample
plugins: sugar-0.9.4, mock-3.6.1
collecting ...
 tests/unit/test_main.py ✓                                  100% ██████████

Results (0.04s):
       1 passed

最後に

本設定を作り込んだのが少し古く、一応VSCodeのLSPの設定など見直し更新をかけていますが不備がありましたら申し訳ございません。心残りとしては、mypyをもう少しがっつり組み込めたら、より効率よく開発が出来たなぁと感じております。 ご参考になれば幸いです。