いものやま。

雑多な知識の寄せ集め

もうPythonで迷わないために。(その4)

前回は開発環境としてJupyterLabを使う話を書いた。

今回はプロジェクトの構成について。

プロジェクト構成

RubyであればBundlerで標準化されているのでプロジェクト構成をどうしようとか迷う必要はないんだけど、Pythonはそうではないので迷う。 ちょっと調べるだけで複数の構成が出てくるんだけど、一般的なプロジェクト構成はおそらく以下のようになる:

  • (プロジェクトルート)/
    • pyproject.toml
    • setup.py
    • setup.cfg
    • (Python仮想環境ルート)/
    • (パッケージルート)/
      • __init__.py
      • (パッケージ、ソース)
    • examples/
      • (サンプルコード)
    • tests/

なお、プロジェクトルートの直下にパッケージルートを置かずにsrc/を一段挟む構成もあって、個人的にはそちらの方がいいと思うんだけど(examples/tests/src/以下と同じディレクトリ構成にできるので統一感がある)、Pythonではプロジェクトルート直下にパッケージルートを置くのが一般的なようなので、悩ましいところ。

pipパッケージとしてビルド可能にする

さて、プロジェクトルート直下にpyproject.tomlsetup.pysetup.cfgというファイルが置かれているけど、これはpipパッケージとしてビルド可能にするためのもの。 ライブラリとして使わないのであれば不要と思うかもしれないけど、これはあった方がいい。 というのも、以下のようなメリットがあるから:

  • 依存するパッケージを賢く指定できる
  • パスを気にしなくてよくなる
  • コマンドラインツールを簡単に作れる

依存するパッケージを賢く指定できる

たとえば、ある依存ライブラリについて、開発時には必要だけど実際に使うときには不要ということがある。 そういう場合、状況に合わせてインストールする依存ライブラリを切り替えたい。

けど、よくあるrequirements.txtによる管理ではそれは難しい。 そこで、公式でない仮想環境のpipenvを導入してる人もいるけど、実はこれはsetup.cfgをちゃんと書けば実現できる。 もちろんsetuptoolsも公式なツールではないけど、事実上の標準といえるので、setuptoolsを使った方がいい。

ちなみに、この方法を使うならrequirements.txtを使う必要はまったくなくなる。 むしろrequirements.txtは何も考えないと依存関係で入ったパッケージまでも並べられてしまって、本当に必要なパッケージ(場合によってはバージョンを固定したい)とそうではないパッケージ(バージョンは依存関係で定まる有効なもので最新なものであればいい)が区別がつかなくて、時間が経つごとに害悪度が増していくので、使わない方がいい。

パスを気にしなくてよくなる

また、コードを理解しやすくするためにサンプルコードを置くのはよくあるけど、このときにちょっと面倒なのがパスを追加すること。 ちゃんとパスを通してやらないとimportがうまくいかずにコードが動いてくれない。

けど、これもpipパッケージとしてビルド可能にして開発用としてインストールすることで、勝手にパスが通ってくれるようになる。

コマンドラインツールを簡単に作れる

さらに、setup.cfgを書くことでコマンドラインツールを簡単に作ることもできる。

せっかく書いたコードだから簡単に使えるといいわけだけど、普通これはなかなか大変。 それが設定を書くだけで使えるようになるんだから、使わない手はない。

pyproject.toml

pyproject.tomlの内容は以下:

[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

これに加えて後述するpysenの設定を書くといい。

setup.py

setup.pyの内容は以下:

from setuptools import setup
setup()

「これだけ?」と思うかもしれないけど、これは設定をsetup.cfgに書くように変わったから。 ただの設定なんだから設定ファイルで指定すればいいのは当たり前なわけで。 いまだにアホのような数の引数をsetup()に渡しているセンスのないコードが巷には溢れかえってるけど。。。

setup.cfg

setup.cfgではパッケージの設定を書いていくことになる。

いろいろ設定はあるんだけど、基本的なものを書くと以下のようになる:

[metadata]
name = <pipパッケージ名>
version = <pipパッケージのバージョン>
description = <pipパッケージの簡単な説明>
license = <ライセンス>

[options]
packages =
    <pipパッケージに含めるパッケージ1>
    <pipパッケージに含めるパッケージ2>
    ...
install_requires =
    <依存するpipパッケージ1>
    <依存するpipパッケージ2>
    ...

[options.extras_require]
<条件名1> =
    <条件名1で追加で依存するpipパッケージ1>
    <条件名1で追加で依存するpipパッケージ2>
    ...
<条件名2> =
    <条件名2で追加で依存するpipパッケージ1>
    <条件名2で追加で依存するpipパッケージ2>
    ...

[options.entry_points]
console_scripts =
    <コマンド名1> = <モジュール>:<main関数>
    <コマンド名2> = <モジュール>:<main関数>
    ...

「pipパッケージに含めるパッケージ」というのは、たとえばhogehoge.hugaを指定すると、プロジェクトのhoge/hoge/huga/の下にあるソースがpipパッケージに含むべきソースとして含まれることになる。

「依存するpipパッケージ」は、たとえばnumpyが必要ならnumpyと書けばいいし、バージョンを指定したいならnumpy == 1.21numpy >= 1.19, < 1.20といった感じで書くといい。

「条件名」というのはdeveloptestといった任意の名前で、pipパッケージをインストールするときに後ろに[<条件名>]をつける(例: pip install hoge[develop]pip install hoge[develop, test])と、指定された条件名で依存するpipパッケージも追加でインストールされるようになる。

コマンドラインツールのは、たとえばhoge.appモジュールでmain()関数を定義していて、do_hogeコマンドでその関数が呼び出されるようにしたければ、do_hoge = hoge.app:mainといった感じに指定しておけばいい。 これだけでコマンドが作れるので便利。 (引数はsys.argvから取得する)

他にも、ソース以外のファイル(csvなど)をpipパッケージに含める方法などもある。 詳細は以下を参照:

pipパッケージのビルド、インストール

こうして準備したものをpipパッケージとしてビルドするには、以下のようにする:

# setuptools, wheel, buildをPython仮想環境にインストールしておく
$ pip install setuptools wheel build

# ビルドを実行
$ python -m build

ビルドが終わるとdist/の下にwheelファイルが作られる。 これをインストールするには以下のようにすればいい:

$ pip install <wheelファイル>

なお、開発中は何度もビルドしていると大変。 そこで、ビルドはせずに以下のようにインストールすると、ローカルの変更がすぐに反映されていい:

# pipパッケージとしてインストールするけどソースは開発中のものがそのまま使われる
$ pip install -e .

ちなみにインストールすると依存するpipパッケージも一緒にインストールされる。 さらにoptions.extras_requireで条件名が設定されてるなら、それを指定することで開発中に追加で必要な依存するpipパッケージをインストールすることもできる:

# options.extras_requireで条件名としてdevelopがあり、追加でインストールする例
$ pip install -e .[develop]

Linter, Formatter

さらに、チームで開発する場合、LinterやFormatterを使った方がいい。

これについてはpysenがお手軽でよさそうだった:

pysenは複数のツールをとりまとめてくれるものになっている。

インストールしたい場合、単に

$ pip install "pysen[lint]"

とするか、あるいはsetup.cfgoptions.extra_require

[options.extra_require]
develop =
    pysen[lint]

のように書いておいてインストールするといい。

そして設定はpyproject.tomlで行う。

以下は設定例:

[tool.pysen]
version = "0.9"

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

blackはFormatter、flake8はコーディング規約であるPEP8に違反しないかチェックするLinter、isortはimportの順番を並び替えてくれるFormatter、mypyは型ヒントから型チェックを行ってくれるLInterとなっている。

Linterでチェックを行いたい場合は以下を実行:

$ pysen run lint

Formatterでコードを整形したい場合は以下を実行:

$ pysen run format

今日の内容をまとめると、以下:

  • プロジェクト構成
    • パッケージルート、examples/tests/をプロジェクトルート直下に用意する
  • pipパッケージとしてビルド可能にする
    • pyproject.tomlsetup.pysetup.cfgを置いてビルド可能にする
    • pipパッケージとしての設定はsetup.cfgに書く
    • requirements.txtを使わない
    • pipパッケージとしてビルドするにはsetuptools、wheel、buildをインストールして$ python -m build
    • ビルドしたパッケージをインストールするには$ pip install <wheelファイル>
    • 開発用としてインストールするには$ pip install -e .
    • 開発時にだけ必要な依存するpipパッケージはsetup.cfgoptions.extra_requireで指定しておく
  • Linter、Formatterとしてpysenを使う
    • インストールは$ pip install "pysen[lint]"もしくはoptions.extra_requireを使う
    • 設定はpyproject.tomlに書く
    • linterを実行するには$ pysen run lint
    • formatterを実行するには$ pysen run format

今日はここまで!