Pythonで開発する時のディレクトリ構成を晒します


Pythonでコードを書き始めて気づけば5年くらい経ってました… 時の流れが早すぎる… Pythonで開発をする際の構成がおおよそ落ち着いてきたので、まとめる意味も含めここで紹介しようと思います。

概要

一概にPythonを書く時といってもPyPIに公開することを見据えて書く時と、 自分だけが使う予定のコードを書く時ではさすがに真剣度(とは)が違います。 そこで今回は雑に書く時、少し真面目に書く時、真剣に書く時の3種類に分けて紹介しようと思います。 全体を通して Poetry を使った開発、gitでバージョン管理しGitHubをリモートリポジトリとすることを前提としています。 私の開発環境はUbuntuですが、今回の内容ではあまり関係ないと思います。

雑に書く時の構成

「雑に書く」の基準としては、自分以外に見せる予定が無い、自分も将来使いまわす予定がない、をイメージしています。 逆に言うならどんなコードを書くときも絶対にしている基本の部分です。 まず最初にディレクトリ構成がこちらです。

sample-project
├── .gitignore
├── .pre-commit-config.yaml
├── README.rst
├── poetry.lock
├── pyproject.toml
└── src
    └── sample_project
        ├── __init__.py
        ├── main.py
        └── command.py

ディレクトリ構成としては src/ 配下にコードをまとめているのがポイントですかね。 最初はpyptoject.tomlと同レベルに sample_project ディレクトリを作成する構成をとっていました。 これの利点としては Poetry がプロジェクト名と同名のディレクトリを自動でインポートしてくれるので、 特に設定をしなくてもそのまま利用出来たところです。 ただ、pytestのベストプラクティスを眺めていた時にこの構成を見つけ、 なにやら非常におすすめらしいので(よくわかってない)この構成を使ってみました。 個人的には後述する pyproject.toml 内で src/ と指定することで、別プロジェクトでも使い回しが出来るようになったことが気に入ってますw

また、特に重要となる pyproject.toml の内容もまとめて紹介します。

[tool.poetry]
name = "sample-project"
version = "0.9.0"
description = "sample project"
authors = ["KAWAI Shun <your@mail.example.com>"]
packages = [
  { from = "src/", include = "sample_project" }
]

[tool.poetry.dependencies]
python = "^3.10"
click = "^8.1.3"

[tool.poetry.dev-dependencies]
flake8 = "^4.0.1"
pyproject-flake8 = "^0.0.1-alpha.4"
isort = "^5.10.1"
autoflake = "^1.4"
black = "^22.6.0"
poethepoet = "^0.15.0"
pre-commit = "^2.19.0"

[tool.poetry.scripts]
command = "sample_project.command:cli"

[tool.poe.tasks.lint]
sequence = [
  { cmd = "pflake8 src/" },
]
help = "check syntax"
ignore_fail = "return_non_zero"

[tool.poe.tasks.format]
sequence = [
  { cmd = "autoflake -ir --remove-all-unused-imports --ignore-init-module-imports src/" },
  { cmd = "isort src/" },
  { cmd = "black src/" },
  "lint"
]
help = "format code style"

[tool.isort]
profile = "black"

[tool.flake8]
max-line-length = 88

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

[build-system]poetry initで自動生成されたままのものなので割愛します。 poetry init した後、poetry add -D flake8 pyproject-flake8 autoflake isort black poethepoet pre-commitとしています。 ポイントとしては以下です。

  • リンターの導入(flake8pyproject-flake8)
  • フォーマッターの導入(autoflakeisortblack)
  • タスクランナーの導入(poethepoet)
  • pre-commitの導入

リンターの導入

リンターとはコードのスタイルチェックをしてくれるツールです。 コードとしてはエラーにならないが、コード記述のルールに沿わないものをチェックしてくれます。 例として以下のコードにflake8を実行するとエラーになります。

import click

@click.command()
def cli():
    no_use_var = 'no use'
    click.echo("Sample Project")

こんな感じで怒られます。

src/sample_project/command.py:3:1: E302 expected 2 blank lines, found 1
src/sample_project/command.py:5:5: F841 local variable 'no_use_var' is assigned to but never used

E302 expected 2 blank lines, found 1 E302エラーは簡単に言うと、 関数間の改行は2行開けようねということです。 これくらいだと別にいいだろ!となりますねw

F841 local variable 'no_use_var' is assigned to but never used F841エラーは見ての通り、 no_use_varという変数は使われていないよと教えてくれてます。 こういう「動作上問題無いが、書き方として望ましくない箇所」をエラーにして教えてくれます。 これらは将来的にバグを生む可能性があるので全て潰しておくのが吉です。

ただ、ひたすらコード書いた後にこのエラーが100個とか出てきて白目を剥くことが多々あるのですが、 それの対処法は後述するフォーマッターがうまいことしてくれます。

pyproject-flake8は最近見つけたライブラリで痒いところに手が届くやつです。 というのも、flake8の設定は setup.cfgtox.ini.flake8のいずれかに書く必要があります。 私の構成ではmax-line-length = 88を設定する必要があったのですが(後述)このためだけにファイルが一つ増えるのは億劫でした。 そこで見つけたのがpyproject-flake8で読んで字のごとくpyproject.tomlflake8の設定を記述出来るようになります。

もちろん将来的にflake8pyproject.tomlを設定ファイルとして参照してくれるようになったらお役御免なのですが、 現状は非常に助かっているので私のなかでは欠かせないライブラリになりました。

注意点として flake8 を実行するときのコマンドは flake8 ですが、 pyproject-flake8 を利用して実行する際のコマンドは pflake8 になります。

フォーマッターの導入

フォーマッターとはコードのスタイル整形をしてくれるツールです。 リンターがエラーとする箇所を出来る限り自動で直してくれます。 現状私が使っているフォーマッターはautoflakeisortblackの3つです。

それぞれ、順番に解説していきます。

autoflake

autoflake は使っていない変数や使っていないインポートを削除してくれます。 特に使っていないインポートを削除してくれる機能が嬉しく、そのためだけに導入していると言っても過言ではありません。

逆に使っていない変数を削除してくれる機能は最近は使わなくなってきました。

var = 1 + 2

というコードのvarという変数が利用されていない場合、autoflakeを実行すると以下のようになります。

1 + 2

いやそこは残るのか!

確かに右辺で大事な関数の呼び出しなどしている可能性もあるので、迂闊に削除出来ないのでしょう。 ただこうなってしまうとコード上問題は無いので、リンターにもかからず永遠に意味のないコードが埋もれてしまう可能性があります。 そのため利用されていない変数はリンターにエラーとして出してもらい自分で対処するのが一番良さそうです。

isort

isort はインポート文をいい感じに並び替えてくれます。

例として以下のコードを並び替えてもらうと、

import sys
from pathlib import Path
import click
import os

こうなります。

import os
import sys
from pathlib import Path

import click

インポート文をアルファベット順で並べ替えるのがメインの機能ですが、標準ライブラリとそうでないライブラリを分けてくれる機能もあります。 また import 形式と from 形式も区別してくれてますね。 この後紹介する black と足並みを揃えてもらうために profile = "black" の設定が必須です。

black

これも最近知ったフォーマッターですがかなり気に入ってます。 blackに関しては先に紹介したautoflakeisort以外のフォーマットを全てしてくれます。 最強便利マンだと思ってます。 先述した flake8 に怒られるコードを例に紹介します。

import click

@click.command()
def cli():
    no_use_var = 'no use'
    click.echo("Sample Project")

これに対し black を実行すると以下のようになります。

import click


@click.command()
def cli():
    no_use_var = "no use"
    click.echo("Sample Project")

E302エラーは修正されていますね! F841エラーはそのままです。 autoflakeで述べたようにこれの修正は難しいのだと思ってます。 また注目すべき点としては 'no use'"no use" になってます。 Pythonのコードではシングルクオートでもダブルクオートでもエラーになりません。 pep8では、 「どちらでもいいけど統一しよう」ということらしいです。

blackでは全てダブルクオートで統一するように修正してくれます。 勝手に統一してくれるという点で非常に気に入ってます。 シングルクオート派の人がいたら鬱陶しい機能でしょうが、私は特にこだわりりがないので問題になっていません。

タスクランナーの導入

タスクランナーとはよく実行するコマンドを登録しておけるものです。 例えば autoflake を実行する場合 autoflake -ir --remove-all-unused-impoprts --ignore-init-module-imports src/ というコマンドを実行します。 これを毎回入力して実行するのはあまりにも大変です。 そこで活躍するのが poethepoet というライブラリです。

前述した pyproject.toml 内の tool.poe で始まるセクションはすべて poethepoet の設定になります。

[tool.poe.tasks.lint]
sequence = [
  { cmd = "pflake8 src/" },
]
help = "check syntax"
ignore_fail = "return_non_zero"

[tool.poe.tasks.format]
sequence = [
  { cmd = "autoflake -ir --remove-all-unused-imports --ignore-init-module-imports src/" },
  { cmd = "isort src/" },
  { cmd = "black src/" },
  "lint"
]
help = "format code style"

このようによく使うコマンドを設定に書いておくことで簡単に呼び出せるようになります。 例えば [tool.poe.tasks.lint] に記載されているコマンドは、以下のコマンドで呼び出せます。

poetry run poe lint

tool.poe.tasks のあとは任意の文字列を設定でき、呼び出す際にはその文字列を利用します。 tool.poe.tasks.format だとしたら呼び出す際のコマンドは poetry run poe format となるわけです。

上記の設定では poetry run poe lint でリンターの実行。 poetry run poe format でフォーマッターの実行が可能になります。

これで長いオプションを入力する必要もなくなり、autoflakeisortblackもコマンド一発で全て実行出来るようになります。 poethepoet が無かったらここまで poetry をガッツリ使うようになることも無かったかもしれません。 それくらい助かってます。

pre-commitの導入

pre-commit というのはもともと githook のひとつです。参考 git commit する際に自動で実行されるスクリプトを設定しやすくしたライブラリが pre-commit です。

pre-commit の設定は .pre-commit-config.yaml に記述します。 例として私が使っている設定を晒します。

# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/psf/black
  rev: 22.6.0
  hooks:
  - id: black
    language_version: python3

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

- repo: https://github.com/myint/autoflake.git
  rev: v1.4
  hooks:
  - id: autoflake
    args:
      - "-i"
      - "--remove-all-unused-imports"
      - "--ignore-init-module-imports"

- repo: https://gitlab.com/pycqa/flake8
  rev: 3.9.2
  hooks:
  - id: flake8
    # max-line-length setting is the same as black
    # commit cannot be done when cyclomatic complexity is more than 10.
    args: [--max-line-length, "88"]

コミット時に含まれてるファイルをチェックし、設定に記述されているコマンドを実行していきます。 他にも設定ファイルをフォーマットしたり、rstファイルをスタイルチェックしてくれたりするのですが、 正直うまく使いこなせていません。

というのも、それぞれのコマンドの設定が .pre-commit-config-yamlpyproject.toml に分かれてしまっているのが一番納得のいっていないポイントです。 あまり変更しない箇所なのでいいっちゃいいのですが、たまーに変更したくなったときに両者のファイルを修正しなければならないのがうまくありません。

これに関してはよりよい構成を探している最中です、アドバイス等いただけると飛んで喜びます…

pre-commit のフックはインストールする必要があります。 インストールというのも .git/hooks/ にスクリプトを配置する必要があるのですが、 もちろん手動で配置する必要はありません。 以下のコマンドでフックのインストールが完了します。

poetry run pre-commit install

これでコミットするたびに flake8 等が実行されるようになりました。 私は基本的にコード書きながら poetry run poe format するので必要無いと言えば無いのですが、 忘れてコミットしようとすることもあるので、その時は pre-commit がエラーになり、 フォーマット前のコードをコミットすることを防いでくれます。

少し真面目に書く時の構成

「少し真面目に書く」の基準としては、知人や職場の人も見る予定がある、自分でも今後複数回使う予定がある、をイメージしています。 ある程度他人に見られる想定をしてコードを書きますが、最終的には直接説明したりすればいいかなという温度感です。 あと自分以外に利用してもらうことを想定するので、コードの完成度も少し考えるようになります。

ディレクトリ構成はこちらです。

sample-project
├── .gitignore
├── .pre-commit-config.yaml
├── README.rst
├── CHANGELOG.rst
├── poetry.lock
├── pyproject.toml
├── .github
│   └── workflows
│       └── main.yml
├── src
│   └── sample_project
│       ├── __init__.py
│       ├── main.py
│       └── command.py
└── tests
    └── test_command.py

雑に書く時の構成から変更されたポイントとしては tests/ ディレクトリや、.github/ ディレクトリが増えた点でしょうか。 このくらいからはテストコードもしっかり書こうとします。作業が増えるのであまり好きではないですが(小声) 他人に見られることを考えるなら最低限のテストコードは書きます。 また GitHub Actions の設定も追加します。GitHub Actionsを使うようになったのは最近ですが(さらに小声) 自動でテストしてくれるので使わない理由はありません。 テストコードをしっかり書き、GitHub Actionsでテストを実行することでプログラムの動作が保証されます。 GitHub Actions を使うためにもテストコードを書く必要が出てきます。

CHANGELOG.rst が増えていますがこれには改定履歴をせっせと書きます。 「changelogあるとそれっぽいよな〜」くらいの軽い気持ちで追加してます。 実際機能追加した時期などわかると後々便利なのでしょうが、 あとから changelog を確認しなければならないほど長いことこの構成を使っていないので、 あまり恩恵は受けていませんw

また他人に利用してもらうことを想定するので利用手順やインストール手順をちゃんと書きます。 これは README.rst にせっせと書きます。 README.md ではなく README.rst なのは後述する Sphinx でインポートすることを想定しているためです。 そこまでするつもりのない場合は README.md で書いたりします。正直どっちでもいいです。

またこの構成での pyproject.toml の内容も晒します。

[tool.poetry]
name = "sample-project"
version = "0.9.0"
description = "sample project"
authors = ["KAWAI Shun <your@mail.example.com>"]
packages = [
  { from = "src/", include = "sample_project" }
]
include = [
  "CHANGELOG.rst"
]

[tool.poetry.dependencies]
python = "^3.10"
click = "^8.1.3"

[tool.poetry.dev-dependencies]
flake8 = "^4.0.1"
pyproject-flake8 = "^0.0.1-alpha.4"
isort = "^5.10.1"
autoflake = "^1.4"
black = "^22.6.0"
poethepoet = "^0.15.0"
pre-commit = "^2.19.0"
pytest = "^7.1.1"
pytest-cov = "^3.0.0"
mypy = "^0.942"
types-setuptools = "^57.4.12"

[tool.poetry.scripts]
command = "sample_project.command:cli"

[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"

[tool.poe.tasks.lint]
sequence = [
  { cmd = "pflake8 src/ tests/" },
  { cmd = "mypy src/ tests/" }
]
help = "check syntax"
ignore_fail = "return_non_zero"

[tool.poe.tasks.format]
sequence = [
  { cmd = "autoflake -ir --remove-all-unused-imports --ignore-init-module-imports src/ tests/" },
  { cmd = "isort src/ tests/" },
  { cmd = "black src/ tests/" },
  "lint"
]
help = "format code style"

[tool.poe.tasks.test]
cmd = "pytest -v --cov=src/ --cov-report=html --cov-report=xml --cov-report=term tests/"
help = "run test"

[tool.isort]
profile = "black"

[tool.flake8]
max-line-length = 88

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

雑に書く時の構成に追加して poetry add -D pytest pytest-cov mypy types-setuptools としています。 ポイントとしては以下です。

  • mypyの導入
  • テストコードの導入(pytestpytest-cov)
  • poetry-dynamic-versioningの導入
  • GitHub Actionsの導入

mypyの導入

Pythonの型ヒントというやつですね。最近少しづつ付けられるようになりました。 mypyは型ヒントを見てチェックをしてくれます。公式曰く「static type checker」です。

もともとPythonは動的に型を判断する動的型付け言語です。 プログラム内で変数の中に入る型はプログラム実行中に決まります。

>>> def printType(var):
...   print(type(var))
...
>>> printType(123)
<class 'int'>
>>> printType("abc")
<class 'str'>

静的型付け言語や動的型付け言語などしらべるとたくさん情報が出てくると思います。

Python3.5から typing というモジュールが追加され、Pythonでも静的型付けが出来るようになってきました。 先程の関数の引数 varstr 型を受け付けるように型ヒントを付けると以下のようになります。

def printType(var: str):
  print(type(var))

ただPythonは「型ヒント」というように、型の情報はあくまでヒントにすぎず、これに沿わなくてもプログラムは動作します。

>>> def printType(var: str):
...   print(type(var))
...
>>> printType(123)
<class 'int'>
>>> printType("abc")
<class 'str'>

そしてこの型ヒントを見てコードのチェックをしてくれる賢いやつが mypy というツールになります。 mypy は型ヒントを見てコード実行前に不正な代入が行われていないかチェックしてくれます。 先程のコードを mypy を使ってチェックすると以下のようなエラーになります。

error: Argument 1 to "printType" has incompatible type "int"; expected "str"

printTypeにはstrが渡されるはずなのにintが渡されていると言われてますね。 リンター同様に未来のバグを発見することが出来ます。 例えば以下のようなコードがあるとします。

from typing import Union

def func(var: Union[str, int]) -> int:
    return len(var)

length = func("abc")
print(length)

Union というのは typing モジュールに含まれるもので、 Union[str, int] というのは「strintのどちらか」という意味になります。

def func(var: Union[str, int]) -> int: というのは、 「varという引数はstrintの型を受け取り、この関数の返り値は int になる」という意味です。

このプログラムはエラーなく動作します。

$ python3 test.py
3

ですがmypyを実行するとエラーになります。

$ mypy test.py
test.py:4: error: Argument 1 to "len" has incompatible type "Union[str, int]"; expected "Sized"
Found 1 error in 1 file (checked 1 source file)

varintstr を許容するはずですが、関数内で利用されている len(var)int では動作しないためです。 将来的に int 型の値を入れていたらエラーになっていたことでしょう。 こういった将来的にバグをうむ可能性がある箇所を事前にチェックしてくれるのでとても気に入っています。 先程のプログラムは以下の用に修正すると mypy のチェックに通るようになります。

from typing import Union

def func(var: Union[str, int]) -> int:
    if isinstance(var, int):
        return var
    return len(var)

length = func("abc")
print(length)

(そもそもどういう意図のコードなのかは置いておいてください私もわかりません)

最近ようやく慣れてきましたがまだ直し方のわからないエラーに遭遇することがあります…

またpoetry run poe lintmypy の実行も出来るように[tool.poe.tasks.lint] の設定も追加されています。

テストコードの導入

コードの動作を保証するためにテストコードも導入します。 テストコードは実際に動作するコードとは別に、それの動作をテストするためのコードです。 Pythonのテストコードを導入するライブラリとしては pytest がメジャーだと思います(自分調べ) 私はそれに加え pytest-cov というライブラリも合わせて導入しています。

私の構成ではテストコードは tests/ ディレクトリ配下にまとめます。 pytest は指定したディレクトリ内の test_*.py というファイルか *_test.py というファイルをテストコードと認識し、 テストを実行していきます。 詳しくは 公式に記載されています。

例えば先程のコード

from typing import Union

def func(var: Union[str, int]) -> int:
    if isinstance(var, int):
        return var
    return len(var)

length = func("abc")
print(length)

これのテストコードの例としては以下のようになります。

from sample_project.main import func


def test_func_success_str():
    assert func("test") == 4

from sample_project.main import func は先程のコードが src/sample_project/main.py に記載されていることを想定しています。 今回テストする func 関数をインポートしています。

このテストを実行するには pytest tests/ と実行してください。

$ poetry run pytest tests/
=========================================================== test session starts ============================================================
platform linux -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /tmp/sample-project
plugins: cov-3.0.0
collected 1 items                                                                                                                          

tests/test_func.py  .                                                                                                                [100%]

============================================================ 1 passed in 0.01s =============================================================

よい感じに出力してくれます。

テストの書き方は様々な考え方があるのでこう書くべき!みたいなのはここでは言及しませんが、 関数ごとに最低限の機能を満たしているか確認するようなテストコードはあると将来の自分が助かります。

少し時間をあけた後にプログラムの改修をした際などに、既存の機能が破壊されていないことを手軽に確認出来ますし、 手作業で確認するのと違って確認漏れなどが発生しづらいので、将来の自分が安心して手を加えられるようにもテストコードはあるに越したことはないと思っています。

カバレッジ

テストにはカバレッジ(coverage)というもの(?)があります。 テストカバー率と言えばまだわかりやすいかもしれません。 簡単に言うと「コード全体の内テストコードでカバー出来た部分の割合」でしょうか。

pytest では pytest-cov というプラグインを使うことで簡単に計測できます。 pytest-cov がインストールされている環境では pytest に新しいオプションが追加されます。 それが --cov--cov-report です。

先程のテストコードをもとに実行するコマンドを pytest --cov=src/ tests/ としてみましょう。

$ poetry run pytest --cov=src/ tests/                       
=========================================================== test session starts ============================================================
platform linux -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /home/shun/document/tmp/sample-project
plugins: cov-3.0.0
collected 1 item                                                                                                                           

tests/test_func.py .                                                                                                                 [100%]

---------- coverage: platform linux, python 3.10.4-final-0 -----------
Name                         Stmts   Miss  Cover
------------------------------------------------
src/sample_project/main.py       7      1    86%
------------------------------------------------
TOTAL                            7      1    86%


============================================================ 1 passed in 0.02s =============================================================

86%となっていますね。これはテストコード実行時に src/sample_project/main.py の86%の行が実行されたことを示します。

さらに --cov-report オプションを追加してみましょう。 これは計測結果の出力形式を指定できます。 私のお気に入りは --cov-report=html です。

$ poetry run pytest --cov=src/ --cov-report=html tests/
=========================================================== test session starts ============================================================
platform linux -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /home/shun/document/tmp/sample-project
plugins: cov-3.0.0
collected 1 item                                                                                                                           

tests/test_func.py .                                                                                                                 [100%]

---------- coverage: platform linux, python 3.10.4-final-0 -----------
Coverage HTML written to dir htmlcov


============================================================ 1 passed in 0.03s =============================================================

Coverage HTML written to dir htmlcov と書かれているように、計測結果がHTMLファイルでhtmlcov配下に出力されます。 お好きなWebブラウザで htmlcov/index.html を表示すると計測結果がとても見やすく表示されます。

/images/20220719/coverage_report_top.png

また、コードを直接表示し、テストで実行された行と実行されなかった行を見やすく表示してくれます。

/images/20220719/coverage_report_main_86.png

今回は6行目 return var の行が実行されていませんでした。 つまり varint だった場合のテストが無かったわけです。 これを解消するにはテストを増やしましょう。

from sample_project.main import func


def test_func_success_str():
    assert func("test") == 4

def test_func_success_int():
    assert func(10) == 10
/images/20220719/coverage_report_main_100.png

全てのコードがテストで実行され、カバレッジが100%になりました。

今回のように「テストコードの作成漏れ」を探すのにとても助かります。 ただ「カバレッジが100%のコードがいいコード」とは限りません。 カバレッジを100%にしたいがために追加したテストコードは、 必ずしも適切なテストコードになるとは限らないからです。

ただカバレッジが100%になったときは達成感があるので、100%に出来るならしてしまいます。(小声)

カバレッジをどこまで求めるかは人によって意見の分かれそうなところですね。

poetry-dynamic-versioningの導入

poetry-dynamic-versiongというライブラリを導入します。 導入するといってもこれは今までのライブとは少しテイストが違います。

というのも、Poetry 自体の拡張ライブラリになります。 Poetry が導入されている環境にインストールする必要があるので pyproject.toml には記載しません。 例えば Poetrypython3 -m pip install --user poetry としてインストールした場合は python3 -m pip install --user poetry-dynamic-versioning とします。

結局 poetry-dynamic-versioning とは何かと言うと、パッケージのバージョンを外部から取得出来るようにするツールです。

というのも Poetry ではパッケージのバージョンは pyproject.toml に記述するしか方法がありません。 setup.cfg の頃はコード内の変数から取得出来たりsetuptools-scm というライブラリを利用して、gitのタグをそのままバージョンとする方法がありました。

poetry-dynamic-versioningはまさにそれらと同等のことを Poetry で実現するためのライブラリです。

poetry-dynamic-versioning の設定はpyproject.toml[tool.poetry-dynamic-versioning]に記述します。 私が使っている設定は以下になります。

[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"

ほぼデフォルトのままですね。さらに言えば vcs = "git" は明示的に書かなくても動作するので実際は enable = true の1行で動作します。

これを設定し、v0.0.0の形式でgitのタグを設定すると、タグの値をバージョンとして認識してくれます。

$ git tag v0.9.1

$ poetry version
sample-project 0.9.1

設定をすることでタグから追加のコミットがあった場合、バージョン名に開発版を示すタグをつけたり出来ます。 私はそこまでは必要としていないので使っていません。興味がある方は公式のドキュメントを読んでみてください。 結構いろんなことが出来ます。

__version__

ライブラリには __version__ という変数を設定しているものをよく見ます。 正直必要性はよくわかっていませんが せっかくなら定義しておきたいですね。

私がよく書く __init__.py はこちらです。

__name__ = "sample-project"
import pkg_resources

__version__ = pkg_resources.get_distribution(__name__).version

pkg_resources を使ってインストールされているライブラリのメタデータを参照する形になります。 こうすることで pyproject.toml と別に直接バージョンを書く必要がなくなります。

PoetryのどこかしらのIssueで見てから参考にさせてもらってます。

実はこの書き方、このまま mypy を通すとエラーになります。

$ poetry run poe lint
Poe => pflake8 src/ tests/
Poe => mypy src/
src/sample_project/__init__.py:2: error: Library stubs not installed for "pkg_resources" (or incompatible with Python 3.10)
src/sample_project/__init__.py:2: note: Hint: "python3 -m pip install types-setuptools"
src/sample_project/__init__.py:2: note: (or run "mypy --install-types" to install all missing stub packages)
src/sample_project/__init__.py:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
Found 1 error in 1 file (checked 2 source files)
Error: Subtasks lint[1] returned non-zero exit status

pkg_resources の型情報を追加でインストールする必要があるのですね。 そのために types-setuptools をあわせてインストールしています。

$ poetry add -D types-setuptools 
Using version ^63.2.0 for types-setuptools

Updating dependencies
Resolving dependencies... (0.2s)

Writing lock file

Package operations: 1 install, 0 updates, 0 removals

  - Installing types-setuptools (63.2.0)

$ poetry run poe lint
Poe => pflake8 src/ tests/
Poe => mypy src/
Success: no issues found in 2 source files

GitHub Actionsの導入

GitHub ActionsはGitHubが提供しているCIツールです。 CIとは継続的インテグレーションというもので、継続的インテグレーションが何かと言うと…なんでしょう()

テストコードの実行やカバレッジ計測、パッケージのリリースなどの自動化が出来ます。

Travis CICircle CIなどのサービスもありますが、 私はGitHubのリポジトリであればそのまま利用出来るGitHub Actionsに落ち着きました。

Github Actionsの設定は、.github/workflows/内にYAMLファイルで記述します。 これも一度作ったものをコピペし続けてる秘伝のタレになってますw そんな私の GitHub Actionsの設定はこちらになります。

name: Test
on:
  workflow_dispatch:
  push:
    branches:
      - '*'
    tags:
      - 'v*.*.*'

jobs:
  lint:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - {name: '3.10', python: '3.10', os: ubuntu-latest}
          - {name: '3.9', python: '3.9', os: ubuntu-latest}
          - {name: '3.8', python: '3.8', os: ubuntu-latest}
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python }}
      - name: update pip
        run: pip install -U pip setuptools wheel
      - name: install poetry
        run: pip install poetry poetry-dynamic-versioning
      - name: install libraries
        run: poetry install
      - name: run lint
        run: poetry run poe lint
  test:
    needs: lint
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - {name: '3.10', python: '3.10', os: ubuntu-latest}
          - {name: '3.9', python: '3.9', os: ubuntu-latest}
          - {name: '3.8', python: '3.8', os: ubuntu-latest}
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python }}
      - name: update pip
        run: pip install -U pip setuptools wheel
      - name: install poetry
        run: pip install poetry poetry-dynamic-versioning
      - name: install libraries
        run: poetry install
      - name: run test
        run: poetry run poe test
      - name: upload codecov
        uses: codecov/codecov-action@v2
        with:
          fail_ci_if_error: true
          token: ${{ secrets.CODECOV_TOKEN }}
  release:
    needs: test
    name: Release
    runs-on: ubuntu-latest
    steps:
      - if: startsWith(github.ref, 'refs/tags/v')
        env:
          REF: ${{ github.ref }}
        run: echo "${REF##*/}"
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: '3.9'
      - name: Update pip
        run: pip install -U pip setuptools wheel
      - name: Install poetry
        run: pip install poetry poetry-dynamic-versioning
      - name: Install dependent libraries
        run: poetry install
      - name: Build package
        run: poetry build
      - name: Upload artifact
        uses: actions/upload-artifact@v1
        with:
          name: 'dist'
          path: 'dist'
      - name: Create Release
        if: startsWith(github.ref, 'refs/tags/v')
        uses: ncipollo/release-action@v1
        with:
          artifacts: 'dist/*'
          token: ${{ secrets.GITHUB_TOKEN }}
          draft: false
      #- name: Publish to PyPI
      #  if: startsWith(github.ref, 'refs/tags/v')
      #  env:
      #    POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
      #  run: poetry publish

軽く説明すると、on でこのワークフローを動かすタイミングを定義しており、 jobs でこのワークフローのジョブを定義しています。

job には linttestreleaseの3つのジョブが定義されています。

lintジョブでは Python3.8Python3.9Python3.10の3つのバージョンで poetry run poe lint を実行しています。

lintジョブに成功した場合はtestジョブが実行されます。 testジョブでは Python3.8Python3.9Python3.10のバージョンでpoetry run poe testを実行しています。 また、Codecovというサービスを使ってカバレッジの記録をしています。 そのため事前にCODECOV_TOKENという変数にCodecoveのアクセストークンを設定しておく必要があります。 GitHub Actionsではアクセストークンなど、リポジトリには入れずに利用したい変数をリポジトリのSettings->Security->Secrets->Actions->New repository secretで設定することが出来ます。

testジョブに成功した場合はreleaseジョブが実行されます。 releaseジョブではpoetry buildを実行しGitHubのリリースページを作成しています。 Create Releaseステップでは if: startsWith(github.ref, 'refs/tags/v')としています。 こうすることでgitのタグをプッシュした時のみ動作するようになります。

まとめると、コミット時にlint -> test -> release(poetry build のみ)の順にジョブが動作し、タグをプッシュした時に lint -> test -> release(Releaseの作成も込み)という順でジョブが実行されます。

開発中は特に気にせずコミットし続けるだけでテストやカバレッジの計測をしてくれる上、 タグをプッシュした際はパッケージを作成しReleaseの作成まで自動でしてくれます。

上の設定はどのプロジェクトでもほぼコピペで動作するのでとても重宝しています。

真剣に書くときの構成

「真剣に書く」の基準としては、PyPIに公開する予定がある、をイメージしています。 赤の他人が見て利用出来ることを想定するので、ドキュメントをしっかり書きます。 逆にここまでしっかり作り込まない時はREADMEにつらつら書いて済ませてしまいます。

ディレクトリ構成はこちらです。

sample-project
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── README.rst
├── CHANGELOG.rst
├── LICENSE
├── poetry.lock
├── pyproject.toml
├── .github
│   └── workflows
│       └── main.yml
├── docs
│   ├── conf.py
│   ├── index.rst
│   ├── quickstart.rst
│   └── changelog.rst
├── src
│   └── sample_project
│       ├── __init__.py
│       ├── main.py
│       └── command.py
└── tests
    └── test_command.py

少し真面目に書く時の構成から変更されたポイントとしてはやはり docs/ ディレクトリでしょうか。 自分以外にプログラムを利用してもらおうと思った時一番大事になる部分がドキュメントだと最近思うようになりました。 そのためPyPIに公開するようなプログラムは Sphinx でちゃんとドキュメントを整備すべきだと思い、 この構成に落ち着きました。 実際に他人がドキュメントを見るかどうかはわかりませんが、 ここで言う「自分以外」には「未来の自分」も含まれます。 と言うか多分一番助かってるのが未来の自分だと思いますw

LINCENSEファイルなども増えていますが、これはGitHubでリポジトリ作成時に自動生成されるものを利用しています。

また、この構成での pyproject.toml を晒します。

[tool.poetry]
name = "sample-project"
version = "0.9.0"
description = "sample project"
authors = ["KAWAI Shun <your@mail.example.com>"]
packages = [
  { from = "src/", include = "sample_project" }
]
include = [
  "CHANGELOG.rst"
]

[tool.poetry.dependencies]
python = "^3.10"
click = "^8.1.3"

[tool.poetry.dev-dependencies]
flake8 = "^4.0.1"
pyproject-flake8 = "^0.0.1-alpha.4"
isort = "^5.10.1"
autoflake = "^1.4"
black = "^22.6.0"
poethepoet = "^0.15.0"
pre-commit = "^2.19.0"
pytest = "^7.1.1"
pytest-cov = "^3.0.0"
mypy = "^0.942"
types-setuptools = "^57.4.12"
Sphinx = "^5.0.2"

[tool.poetry.scripts]
command = "sample_project.command:cli"

[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"

[tool.poe.tasks.lint]
sequence = [
  { cmd = "pflake8 src/ tests/" },
  { cmd = "mypy src/ tests/" }
]
help = "check syntax"
ignore_fail = "return_non_zero"

[tool.poe.tasks.format]
sequence = [
  { cmd = "autoflake -ir --remove-all-unused-imports --ignore-init-module-imports src/ tests/" },
  { cmd = "isort src/ tests/" },
  { cmd = "black src/ tests/" },
  "lint"
]
help = "format code style"

[tool.poe.tasks.test]
cmd = "pytest -v --cov=src/ --cov-report=html --cov-report=xml --cov-report=term tests/"
help = "run test"

[tool.poe.tasks.doc]
sequence = [ 
  { cmd = "sphinx-apidoc -f -e -o docs/ src/sample_project/ "},
  { cmd = "sphinx-build docs/ build-docs/"},
]
help = "build document"

[tool.isort]
profile = "black"

[tool.flake8]
max-line-length = 88

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

少し真面目に書く時の構成に追加して poetry add -D Sphinx としています。 また Sphinx の初期設定のため sphinx-quickstart を実行します。 sphinx-quickstartを実行すると対話的に設定を入力し、 source/index.rstsource/conf.pyを生成してくれます。

$ poetry run sphinx-quickstart --sep -l ja --ext-autodoc --no-makefile --no-batchfile --extensions sphinx.ext.napoleon
Sphinx 5.0.2 クイックスタートユーティリティへようこそ。

以下の設定値を入力してください(Enter キーのみ押した場合、
かっこで囲まれた値をデフォルト値として受け入れます)。

選択されたルートパス: .

プロジェクト名は、ビルドされたドキュメントのいくつかの場所にあります。
> プロジェクト名: sample-project
> 著者名(複数可): KAWAI Shun <your@mail.example.com>
> プロジェクトのリリース []: 0.9.0

ファイル /tmp/sample-project/source/conf.py を作成しています。
ファイル /tmp/sample-project/source/index.rst を作成しています。

終了:初期ディレクトリ構造が作成されました。

マスターファイル /tmp/sample-project/source/index.rst を作成して
他のドキュメントソースファイルを作成します。次のように、ドキュメントを構築するには sphinx-build コマンドを使用してください。
 sphinx-build -b builder /tmp/sample-project/source /tmp/sample-project/build
"builder" はサポートされているビルダーの 1 つです。 例: html, latex, または linkcheck

また sourceディレクトリだと srcと被ってしまうので、mv source/ docs/ としディレクトリ名をリネームします。 ここまでやってこの構成は完成です。

それではこの構成のポイントをまとめます。

  • Sphinxの導入
  • .readthedocs.yaml の導入
  • PyPIへの公開

Sphinxの導入

Sphinxとはドキュメント生成ツールです。 reStructuredTextというマークアップランゲージを利用してドキュメントを記述出来ます。 まだあまり長い文章を書いたことはありませんが、当然1からHTMLを書くよりははるかに楽です。

Sphinxの設定ファイルは docs/conf.py になります。 Pythonファイルなのでコードを書くことも出来ますが、あまり必要になったことはありません。

上述したように設定ファイルはsphinx-quickstartコマンドで生成します。

$ poetry run sphinx-quickstart --sep -l ja --ext-autodoc --no-makefile --no-batchfile --extensions sphinx.ext.napoleon

--extensions sphinx.ext.napoleonnapoleon拡張を有効にし、 --ext-autodocautodoc拡張を有効にしています。

ドキュメントのビルドは次のコマンドで実行できます。

$ poetry run sphinx-build docs/ build-docs/

ビルドされたドキュメントは build-docs/index.html を手頃なブラウザで表示すると閲覧できます。

後述しますがビルドは Read the Docs に任せてしまうので自分でビルドする必要はありません。 ですが書きながら体裁を確認するのに、やはりビルドする必要は出てきます。 そのため[tool.poe.tasks.doc]にコマンドを定義し、poetry run poe doc でドキュメントがビルド出来るようにしています。 ビルドされたドキュメントは build-docs/ ディレクトリに保存されますので、 このディレクトリを .gitignore でgitから除外しています。

私が最初に作成する docs/index.rstdocs/changelog.rst ファイルの内容を晒します。

  • index.rst
Welcome to Sample Project's documentation!
==========================================

.. include:: ../README.rst

Document
--------

.. toctree::
   :maxdepth: 2

   quickstart.rst

Chagelog
--------

.. toctree::
   :maxdepth: 2

   changelog.rst


Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
  • changelog.rst
.. include:: ../CHANGELOG.rst

changelog.rst はとてもシンプルで CHANGELOG.rst の内容をそのまま出力しています。

index.rst.. include:: ../README.rst に関しても同様で、 README.rstの内容をそのまま出力しています。

.. toctree:: がリンクのような機能を果たします。

.. toctree::
   :maxdepth: 2

   quickstart.rst

とすることで quickstart.rst へのリンクが作成されます。 あとは quickstart.rst をせっせと書いたり、 はたまた追加のreSTファイルを作成し、toctreeに追記したりします。

sphinx-apidoc

Sphinxをインストールした環境にはsphinx-apidoc というコマンドが存在します。 これが非常に便利でこれを使うだけでそれっぽいAPIリファレンスが生成出来ます。

sphinx-apidocは適切な箇所(関数やクラスの先頭)に適切な構文でコメントを書くと、 それを認識し、APIドキュメントを生成してくれます。 docstring で検索すると色々出てきます。

例として先程のサンプルコードにdocstringを追加しましょう。

from typing import Union


def func(var: Union[str, int]) -> int:
    """
    文字列か数値を受け取る。
    数値はそのまま返し、
    文字列は文字列の長さを返す。

    :param var: 文字列か数値
    :type var: Union[str, int]
    :return: 文字列の長さか数値
    :rtype: int
    """
    if isinstance(var, int):
        return var
    return len(var)


length = func("abc")
print(length)

def func のすぐ下の行からdocstringが記述されています。 まず関数やクラスの説明文を書き、その後に引数などの定義を書きます。 それぞれ次のような意味になります。

  • :param <変数名>: <変数名>という引数の説明
  • :type <変数名>: <変数名>という引数の型
  • :return: 関数の戻り値の説明
  • :rtype: 関数の戻り値の型

これで sphinx-apidoc を実行します。

$ poetry run sphinx-apidoc -f -e -o docs/ src/sample_project/

docs/ 内に新しいファイルが生成されました。

$ ls docs 
_static  _templates  changelog.rst  conf.py  index.rst  modules.rst  sample_project.main.rst  sample_project.rst

sample_project.rstsample_project.main.rstがモジュールごとのファイルとなり、 modules.rstファイルがメインのファイルになります。

しかし、このままでは modules.rst がどこにもリンクの無いページになってしまいますので、 index.rstから飛べるようにリンクを追記しましょう。

Welcome to Sample Project's documentation!
==========================================

.. include:: ../README.rst

Document
--------

.. toctree::
   :maxdepth: 2

   quickstart.rst

API Reference
-------------

.. toctree::
   :maxdepth: 2

   modules.rst

Chagelog
--------

.. toctree::
   :maxdepth: 2

   changelog.rst


Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

上述したようにtoctreeを追加することでリンクを生成します。 実際にビルドしたら以下のようになります。

/images/20220719/docstring_reST.png

コメント書くだけでここまで自動生成してくれるのは便利ですよね。 これが sphinx-apidoc です。

sphinx.ext.napoleon

docstring の書き方には様々な流派があります。 中でもGoogle Styleというのがわかりやすくて良いという記事を見たことがあります。

Google Style で先程のdocstringを記述すると次のようになります。

from typing import Union


def func(var: Union[str, int]) -> int:
    """
    文字列か数値を受け取る。
    数値はそのまま返し、
    文字列は文字列の長さを返す。

    Args:
        var (Union[str, int]): 文字列か数値を指定する

    Returns:
        int: 文字列の長さか数値が返る
    """
    if isinstance(var, int):
        return var
    return len(var)

うーん少し見やすくなった気もしますが、この程度の量だとあまり恩恵を感じられませんね。

Google Styleのdocstringを解釈出来るようにするために sphinx.ext.napoleon という拡張を有効にする必要があります。 有効にしてはいるもののGoogle Styleをあまり使っていないので、これもまた私は使いこなせていませんw

ビルド結果は先程と同様になるので割愛します。

Read the Docsの導入

Read the Docs はドキュメントの自動ホスティングサービスで、 Sphinxと相性がいいので多くのPythonライブラリのドキュメントに利用されています。

ログイン時にGitHubと連携することで簡単にプロジェクトと連携することができます。

Read the Docsの設定は .readthedocs.yaml というファイルで行います。 私のよく使う .readthedocs.yaml ファイルが以下になります。

version: 2

build:
  os: ubuntu-20.04
  tools:
    python: "3.9"

sphinx:
  configuration: docs/conf.py

python:
  install:
    - method: pip
      path: .

install: でビルド前に自身をインストールするように設定しているので、 docs/conf.py 内で sample_project のコードを利用出来るようになります。

ですがこれもまた docs/conf.py 内で sample_project を利用することがほぼ無いので あまり意味はなしていません。 一応docs/conf.py内のreleaseを次のようにしたりしていたのですが、

from sample_project import __version__
release = __version__

そもそもこのreleaseの値がドキュメントのどこに反映されているのかよくわかっていませんw

なのでこのようなことをしない場合は .readthedocs.yaml ファイルは配置しなくても大丈夫だと思います。

Read the Docsの設定画面から簡単にプロジェクトを連携でき、 またRead the Docsでドキュメントをビルド、公開までしてくれるのでとても楽ちんです。

https://mypaceshun-sample-project.readthedocs.io/en/latest/

PyPIへの公開

PyPI とはPythonのパッケージレポジトリ(?)です。 自分の作ったPythonパッケージを公開することが出来ます。 そもそも pip install click とした場合、clickの実体はPyPIから検索されます。 つまり今回であれば sample-project をPyPIに登録すれば pip install sample-projectとするだけで、 どこからでも自分のプログラムをインストールすることが出来るようになります。

今回はサンプルのプロジェクトなのでPyPIには登録しませんがw

またPyPIの性質上すでに登録されているパッケージ名を重複して登録することは出来ないので、 自分が登録しようとしている名前がすでに使われていないか、 コードを書く前に確認したほうがいいかもしれません。

PyPIに公開するにはPyPIのアカウントが必要なので、事前にアカウント登録をしてください。

PyPIへ登録する前にpyproject.toml[tool.poetry] の項目を少し増やします。

[tool.poetry]
name = "sample-project"
version = "0.9.0"
description = "Python Sample Project"
authors = ["KAWAI Shun <your@mail.example.com>"]
license = "MIT"
readme = "README.rst"
repository = "https://github.com/mypaceshun/sample-project"
documentation = "https://mypaceshun-sample-project.readthedocs.io/"
keywords = [ 
  "Python",
  "sample",
]
packages = [ 
  { include = "sample_project", from = "src" }
]
include = [
  "CHANGELOG.rst",
]

readmeで指定したファイルがPyPI内のトップに表示されます。 また、repositorydocumentationを指定することでPyPI内でリンクが生成されます。

PyPIへの登録はPoetryを利用すればあっという間に出来てしまいます。

$ poetry publish --build --dry-run     
Building sample-project (1.0.0)
  - Building sdist
  - Built sample-project-1.0.0.tar.gz
  - Building wheel
  - Built sample_project-1.0.0-py3-none-any.whl

Username: xxxxxx
Password: xxxxxx
Publishing sample-project (1.0.0) to PyPI
 - Uploading sample-project-1.0.0.tar.gz 100%
 - Uploading sample_project-1.0.0-py3-none-any.whl 100%

今回は --dry-dun オプションを付けていますが、これを外せば実際にPyPIに登録されます。 またユーザー名/パスワードで認証していますがアクセストークンを利用することも出来ます。

GitHub ActionsのPYPI_TOKEN という変数にアクセストークンの値を設定し、 先程はコメントアウトされていた以下の設定をコメント解除します。

      #- name: Publish to PyPI
      #  if: startsWith(github.ref, 'refs/tags/v')
      #  env:
      #    POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
      #  run: poetry publish

そうすると、タグをプッシュした際にPyPIへのアップロードまで実施してくれるようになります。

まとめ

長々と書きましたがここまで全て設定すると以下のことが出来るようになっているはずです。

  • poetry run poe format で自動でコードスタイル整形
  • コードをコミット前にリンターを自動で実施
  • コードをGitHubにプッシュするとリンターとテストが自動で実行され、codecovがカバレッジを計測してくれる
  • poetry run poe doc でAPIリファレンス自動生成
  • コードをGitHubにプッシュするとreadthedocsがドキュメントを自動でビルド・公開してくれる
  • タグをプッシュするとパッケージのビルドが自動で実行され、Releaseを作成し、PyPIにアップロードしてくれる

コードを書く以外の自動化出来る箇所はほぼほぼ自動化出来ていると思います。

pyproject.tomlなんかはかなり量が多くなって自分でも秘伝のタレと化していたので、今回整理する意味でもまとめられてよかったです。 現状はこの構成に落ち着いていますが、よりよいサービスやツールがあれば取り入れていきたいと思っているので、 オススメのツールなどあれば教えていただけると嬉しいです。 Pythonの書き方に悩んでいるどこかの誰かの参考になれば幸いです。

(今回サンプル用に作成したsample-projecthttps://github.com/mypaceshun/sample-project/ に置いてあります)


comments powered by Disqus