🐍

Pythonでの開発・CI/CDの私的ベストプラクティス2022

2022/01/03に公開

Header Badges

はじめに

2021年、Pythonで複数の暗号系ライブラリを開発してPyPIで公開してきました。その過程で、setuptools、flit、poetryと、幾つかのパッケージ管理をわたり歩き、GitHub上でのCI/CDも色々試す中で私的なべスプラが定まってきたので、2022年初に備忘録としてまとめておきます。

具体的には、pyenvpoetrypre-committoxGitHub Actions を活用し、低コストで(=なるべく自動で)、高品質のプロダクトをPyPIにデプロイする方法・設定を共有します。個別のツールの記事はよく目にするのですが、開発ライフサイクル全体をカバーする記事がなかなか無かったので。

  • 開発環境の整備 - pyenvで複数のPythonバージョンでの開発環境を整備
  • パッケージ管理 - poetry/pyproject.tomlでの一元的なパッケージ管理
  • 静的解析、自動整形、テスト
    • flake8、black、isort、mypyなどの適用をpre-commitに集約
    • pre-commit、ドキュメント生成、複数Pythonバージョンでのテスト・ビルドをtoxに集約
  • GitHub上でのCI/CD
    • GitHub Actions による複数OS/Pythonバージョンでのテスト・ビルド・インストール
    • GitHub Actions による PyPI への自動デプロイ
    • 外部サービス活用(CodeCov - カバレッジの可視化、Read The Docs
    • botによる依存パッケージの自動メンテナンス

ここにまとめた内容は以下のリポジトリに反映されていますのでご参考まで。

  • PySETO:JWTより安全という触れ込みのPASETO(Platform-Agnostic Security Tokens)実装。全プロトコルバージョン(v1-v4)に対応。全バージョンコンプは何気に本家のリファレンス実装よりも早かったんです。
  • Python CWT:JWT/JOSEのバイナリ版、CWT/COSE実装。CWT/COSEは、主にIoT用途を想定した規格ですが、EUDCCやWebAuthnにも採用されています。

再利用歓迎です。設定ファイルの一覧は、末尾にまとめています。

前提

  • 私の開発環境: Ubuntu 20.04.2 LTS
  • リポジトリの場所:GitHub。CI/CDはGitHub 前提です。
  • PySETO を例として使います。
  • 各ツールのインストール方法は説明しません。どう使っているかだけ記します。

開発環境の整備 - pyenv

PyPIで公開するプロダクトを作る場合、複数のPythonバージョンでの動作検証(動作保証範囲の明確化)が不可欠です。このために、Pyenvで複数バージョンをインストールして、複数バージョンで動作確認・テストを行えるようにします。

pyenvのインストールと設定は公式のREADMEを参照。ちゃんと公式を見ましょう。

後述するPoetryで依存パッケージを組み込む過程で、自ずとサポートできる Python バージョンの下限が確定します。PySETOの場合は ^3.6.2 となったので、3.6(3.6.2以上)、3.7、3.8、3.9、3.10をインストールします。

以下、Python 3.9をインストールして、プロジェクト限定で利用する手順:

# Python 3.9 の一覧を確認
$ pyenv install --list | grep 3.9
# リストの中から最新バージョンをインストール
#     ※ bz2, readline, sqlite3が無い云々と WARNINGが表示される場合:
#     $ sudo apt install libbz2-dev libreadline-dev libsqlite3-dev
$ pyenv install 3.9.9
# プロジェクトで3.9.9を利用
$ cd pyseto
$ pyenv local 3.9.9
$ cat .python-version
3.9.9
$ python -V
Python 3.9.9

全てのPythonバージョンをインストールした後、同時に全バージョンを有効化する手順:

# インストール済みのバージョン確認
$ cd pyseto
$ pyenv versions
  system
  3.10.1
  3.6.15
  3.7.12
  3.8.12
* 3.9.9 (set by /home/dajiaji/works/pyseto/.python-version)
# 全バージョンを同時に有効化するには、プロジェクト直下の.python-versionに列挙しておく
# ↓ こんな感じに
$ cat .python-version
3.6.15
3.7.12
3.8.12
3.9.9
3.10.1
$ pyenv versions
  system
* 3.10.1 (set by /home/dajiaji/works/pyseto/.python-version)
* 3.6.15 (set by /home/dajiaji/works/pyseto/.python-version)
* 3.7.12 (set by /home/dajiaji/works/pyseto/.python-version)
* 3.8.12 (set by /home/dajiaji/works/pyseto/.python-version)
* 3.9.9 (set by /home/dajiaji/works/pyseto/.python-version)

パッケージ管理 - poetry

パッケージ管理にはpoetryを使います。poetryを選んだ理由は、プロジェクトの作成からパッケージ管理・ビルド、PyPIへのデプロイまで、全てが poetry(及び設定ファイルとしてのpyproject.toml - PEP518)で完結することが大きいです。flitも試したのですが、色々エコシステムとして足らないところがあり、現時点(2021-2022年)での採用は見送りました。

poetryのインストール・設定は、公式Webサイトを参照。

まずは、pyseto/pyproject.tomlを晒します。[build-system]と[tool.poetry]のlicenseまでは、poetry initで対話形式で生成されます。設定内容はpoetryによるパッケージ管理に絞っています。flake8やmypyなどの設定をpyproject.tomlに集約することもできるのですが、私は後述するpre-commit側に寄せています。

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "pyseto"
version = "1.6.3"
description = "A Python implementation of PASETO/PASERK."
authors = ["Ajitomi Daisuke <dajiaji@gmail.com>"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/dajiaji/pyseto"

include = [
  "CHANGES.rst",
  "docs",
  "poetry.lock",
  "tests",
  "tox.ini",
]

exclude = [
  "docs/_build",
]

[tool.poetry.dependencies]
python = "^3.6.2"
cryptography = "^36.0.0"
pycryptodomex = "^3.12.0"
passlib = {extras = ["argon2"], version = "^1.7.4"}
iso8601 = "^1.0.2"
Sphinx = {version = "^4.3.2", optional = true, extras = ["docs"]}
sphinx-autodoc-typehints = {version = "1.12.0", optional = true, extras = ["docs"]}
sphinx-rtd-theme = {version = "^1.0.0", optional = true, extras = ["docs"]}

[tool.poetry.extras]
docs = [
  "Sphinx",
  "sphinx-rtd-theme",
  "sphinx-autodoc-typehints",
]

[tool.poetry.dev-dependencies]
pytest = "^6.2"
pytest-cov = "^3.0.0"
tox = "^3.24.4"
pre-commit = "^2.15.0"
freezegun = "^1.1.0"

include/exclude

[tool.poetry]のinclude, excludeを使えば、MANIFEST.in を置き換えられます。無設定でpoetry buildすると、PySETOリポジトリ内の以下のファイルがパッケージに含まれていました。

  • パッケージ本体の *.py ファイル(pysetoディレクトリ配下の.pyファイル)
  • パッケージ本体の py.typed (pyseto/py.typed)
  • pyproject.toml
  • LICENSE
  • README

私の場合は、上記の通り、docsやtests、CHANGES.rst、tox.ini、poetry.lockを追加でパッケージに入れています。"docs/_build"を自動で除外してくれなかったので、.gitignoreを見てくれているわけではなさそうです。

dependencies

[tool.poetry.dependencies]と[tool.poetry.dev-dependencies]は、poetry addで追加していきます。ドキュメント生成用の依存パッケージは、後述する Read the Docs への自動デプロイのために、-E docsをつけて poetry addし、pip install .[docs] でインストールできるようにします。

# PySETOライブラリ本体で利用する依存パッケージの追加
$ poetry add cryptography
# ...

# ドキュメント生成に利用する依存パッケージの追加
$ poetry add sphinx --optional -E docs
# ...

# テスト等、開発で利用する依存パッケージの追加
$ poetry add pytest -D
# ...

静的解析、自動整形、テスト

上記のpyproject.tomlを見て、flake8やblackを使わないのか?と思われた方もいるかもしれませんが、私的べスプラとして、静的解析や自動整形は pre-commit に、テストは tox に集約しています。以下の各設定ファイルをベースに説明していきます。

pre-commit

pre-commitを使い、git commit時に静的解析・自動整形を実行するようにします。例のごとく、pre-commitのインストール方法は説明しません。公式ドキュメントを参照してください。

pyseto/.pre-commit-config.yamlは以下のとおりです。

repos:
  - repo: https://github.com/psf/black
    rev: 21.12b0
    hooks:
      - id: black
        args: [--line-length, "128"]

  - repo: https://github.com/asottile/blacken-docs
    rev: v1.12.0
    hooks:
      - id: blacken-docs

  - repo: https://github.com/PyCQA/flake8
    rev: 4.0.1
    hooks:
      - id: flake8
        args: [--ignore, "E203,E501,W503"]
        additional_dependencies: [flake8-bugbear]

  - repo: https://github.com/PyCQA/isort
    rev: 5.10.1
    hooks:
      - id: isort
        args: [--profile, "black"]

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v0.930
    hooks:
      - id: mypy
        args: [--ignore-missing-imports]
        additional_dependencies: [types-freezegun]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.1.0
    hooks:
      - id: check-json
      - id: check-toml
      - id: check-yaml
      - id: debug-statements
      - id: end-of-file-fixer
      - id: fix-byte-order-marker
      - id: trailing-whitespace

私は頻繁にコミットしていく(git commit --amend をガンガンしていく)開発スタイルなので、コミットに連動させてしまうのが楽で性に合っています。適用しているツールは以下のとおり。

  • black:コードの自動整形
  • blacken-docs:ドキュメント内のコードサンプルの自動整形
  • flake8:コーディングスタイル規約(PEP8)の準拠チェック
  • isort:importの整列
  • mypy: 型ヒントに基づく型チェック
  • pre-commit-hooks:コード以外の設定ファイル(json, toml, yaml)のチェックや自動整形を噛ませています。

flake8など各ツールの設定ファイルを分離することも可能ですが、私の場合は、この.pre-commit-config.yaml内のargsで指定して設定を集約させています。

各ツールのargsについては、お好みで設定してください。私のはクセが強いと思います(--line-length=128 としつつ、E501を設定しているとか・・)。

tox

toxを使って複数Pythonバージョンでのテストに加え、上記のpre-commitの実行、ドキュメント生成、パッケージビルドまで行います。GitHubへのプッシュ時に、GitHub Actionsと連携するための設定も含めておきます。

toxのインストール方法は説明しません。公式ドキュメントを参照してください。

pyseto/tox.ini は以下のとおりです。

[tox]
envlist =
    check
    build
    build_docs
    py{36,37,38,39,310}
isolated_build = True
skip_missing_interpreters = True


[gh-actions]
python =
    3.6: py36
    3.7: py37
    3.8: check, build, build_docs, py38
    3.9: py39
    3.10: py310


[testenv:check]
whitelist_externals = poetry
skip_install = true
commands =
  poetry install --no-root
  poetry run pre-commit run --all-files


[testenv:build]
whitelist_externals = poetry
skip_install = true
commands =
  poetry build


[testenv:build_docs]
whitelist_externals = poetry
skip_install = true
commands =
  poetry install -E docs
  poetry run sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html


[testenv]
whitelist_externals = poetry
skip_install = true
commands =
  poetry install
  poetry run pytest -ra --cov=pyseto --cov-report=term --cov-report=xml tests


[testenv:py310]
whitelist_externals = poetry
skip_install = true
commands =
  pip install cleo tomlkit poetry-core requests cachecontrol cachy html5lib pkginfo virtualenv lockfile
  poetry install
  poetry run pytest -ra --cov=pyseto --cov-report=term --cov-report=xml tests

各設定ブロックのポイントをざっと解説します。

[tox]

tox -e で実行できる環境リスト(envlist)は以下のとおり。

  • check: 上記のpre-commit実行。poetry run pre-commit run --all-files
  • build: パッケージのビルド。poetry build
  • build_doc: ドキュメントのビルド。poetry run sphinx-build ...
  • py{36,37,38,39,310}: Python3.6 - 3.10環境でのテスト。 poetry run pytest ...

私が有効化しているオプションは、isolated_buildskip_missing_interpretersの2つ。toxで独立した仮想環境を使い、もし特定のPythonバージョンがインストール出来ていなくてもエラーにならないようにしています。

[gh-actions]

GitHub Actionsにて、Pythonバージョンに対応させて実行する環境のマッピング定義。

特定のPythonバージョン(3.8)ですべての環境(check, build, build_docs, py38)を実行し、残りはテストのみを実行するようにしています。

[testenv:*]

poetryは、toxの外側でインストール出来ている前提をとっています(whitelist_externals = poetry)。その上で、各環境での実行内容は、1) poetry install による依存パッケージのインストール 2) poetryを介したビルドやテスト実行(poetry run pytest ...)で構成しています。なお現時点では、testenv:py310は特別扱いしています。いろいろと事前にインストールしないと poetry installが通らないのです。

ちなみにテストカバレッジには pytest-cov を使います。標準出力で結果確認できるようにしつつ、同時にcodecovとの連携のため、--cov-report=term--cov-report=xmlの両方を指定しています。

GitHub上でのCI/CD

GitHub Actionsや各種Bot、外部サービス活用も含めたGitHub上でのCI/CDについて。

GitHub Actions

GitHubへのPull Request時、master(私の場合main)へのPush時にGitHub Actionsを使ってCIを回します。また、パッケージリリース時も、同様にGitHub Actionsを使います。

以下、例のごとく設定ファイルベースで説明していきます。私の場合、以下の3つのGitHub Actionsを使っています。

python-package.yml

mainブランチへのPull Request及びPushをトリガとしたGitHub Actionsの設定です。

name: Python CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    name: Python ${{ matrix.python-version }} on ${{ matrix.platform }}
    runs-on: ${{ matrix.platform }}
    env:
      USING_COVERAGE: "3.8"

    strategy:
      matrix:
        platform: [ubuntu-latest, windows-latest]
        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]

    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}

    - name: Install dependencies
      run: python -m pip install tox-gh-actions poetry

    - name: Run tox
      run: poetry run tox

    - name: Upload coverage to Codecov
      if: contains(env.USING_COVERAGE, matrix.python-version) && matrix.platform == 'ubuntu-latest'
      uses: codecov/codecov-action@v1
      with:
        fail_ci_if_error: true

  package:
    name: Build package
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-python@v2
      with:
        python-version: "3.8"

    - name: Install poetry
      run: python -m pip install poetry

    - name: Build package
      run: poetry build

    - name: Show result
      run: ls -l dist

    - name: Install package
      run: python -m pip install .

    - name: Import package
      run: python -c "import pyseto; print(pyseto.__version__)"

大きくは、testpackageという2つのJobがあります。

前者(test)は、Linux(Ubuntu)、Windows それぞれで、Python3.6, 3.7, 3.8, 3.9, 3.10 の tox を回し、カバレッジレポートを CodeCov に送信しています。なお、CodeCovとの連携には、トークンを取得し、GitHubリポジトリのSettings > Secrets で CODECOV_TOKEN として設定しておく必要があります。その他のポイントとしては、tox-gh-actions を使って、GitHub Actionsでのtox実行を並列化している点ですね。

後者(package)は、Linux(Ubuntu)、WindowsにMacOSを加え、パッケージのビルド、インストール、インポート確認を行っています。ビルドまでは test ジョブにてPython3.6 ~ 3.10で動作確認を行っているので、ここではバージョンを3.8に固定しています。

python-publish.yml

続いてパッケージリリース時のGitHub Actionsの設定です。

name: Upload Python Package

on:
  release:
    types: [created]

jobs:
  deploy:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-python@v2
      with:
        python-version: "3.8"

    - name: Install dependencies
      run: python -m pip install poetry

    - name: Build and publish
      env:
        POETRY_USERNAME: ${{ secrets.PYPI_USERNAME }}
        POETRY_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
      run: |
        poetry build
        poetry publish -u $POETRY_USERNAME -p $POETRY_PASSWORD

poetry publishで、PyPIへアップロードします。ここは、Python3.8、ubuntu-latest 固定にしています。

GitHubリポジトリのSettings > Secrets で、PYPI_USERNAMEPYPI_PASSWORD を忘れずに設定しておきましょう。

codeql-analysis.yml

CodeQL Analysisは、コードのセキュリティ解析などを行ってくれるものですが、私は特にひねらずデフォルト設定のまま使っています。有効化するには、リポジトリのSecurity > Code scanning alerts から、CodeQL Analysis の Set up this workflow を選択し、デフォルトで用意されたこのyamlファイルを取り込むだけです。

外部サービス活用

ここまでで折々触れてきましたが、私はコードカバレッジ管理に CodeCov を、ドキュメントの公開に Read the Docs を使っています。完全にパブリックなリポジトリであれば無料で使えるので、こういうのは積極活用したほうが得だし楽ですね。

CodeCov

CodeCovは2021年に脆弱性が問題になりましたが、私としては解消されたと判断して使っています(そもそもパブリックなプロジェクトでしか使っていませんが)。PythonプロジェクトでCodeCovを使う公式チュートリアルがありますので、ご参照下さい。

前述した python-package.ymlから抜粋すると、以下の設定で CodeCovへのレポートアップロードを行っています。

    - name: Upload coverage to Codecov
      if: contains(env.USING_COVERAGE, matrix.python-version) && matrix.platform == 'ubuntu-latest'
      uses: codecov/codecov-action@v1
      with:
        fail_ci_if_error: true

Read the Docs

Read the Docsでドキュメントを公開する方法は、公式のチュートリアル をご参照ください。

Read the Docsサービス側の設定は割愛し、リポジトリ側の設定を書いてしまうと、ルートディレクトリに以下の .readthedocs.yml を置いておけばよいです。以下の設定によって、readthedocs.org 上で pip install .[docs] が実行され、ドキュメントが公開されます(前述した pyseto/pyproject.toml 参照)。

version: 2

python:
  version: "3.8"
  install:
    - method: pip
      path: .
      extra_requirements:
        - docs

デプロイ結果は、https://pyseto.readthedocs.io をご覧ください。

Bot連携

最後の話題ですが、依存パッケージのアップデートに、私は dependabotとpre-commit bot を使っています。依存パッケージのメンテナンスが完全にアウトソースできるのでおススメです。

  • pre-commit[bot] - GitHub Actionsでのpre-commit実行に加え、.pre-commit-config.yamlに定義されている依存パッケージのアップデートを検出し、自動でアップデートのためのPull Requestを送ってくれます。
  • dependabot[bot] - pyproject.tomlに定義されている依存パッケージのセキュリティアップデートを検出し、自動でアップデートのためのPull Requestを送ってくれます。GitHubリポジトリの Insights > Dependency graph > Dependabot から簡単に有効化できます。設定ファイルは、.github/dependabot.yml に吐かれます。インターバルなどの調整が可能です。

Badge設定

せっかくCI/CDを整えたのでバッジも設定しておきましょう。設定方法は、pyseto/README.mdのRawデータを参照してください。

Header Badges

まとめ - 設定ファイル一覧

最後に、本記事で言及した(PySETOの)パッケージ管理・CI/CDのための設定ファイル一覧を貼っておきます。

おわりに

さすがにbotだけという状況も寂しい・・。

Contributors

Discussion