Poetryを使ったPythonのモノレポ構成
テラーノベルで機械学習を中心に担当している川尻です。
最近新たにPythonのプロジェクトを立ち上げるときにモノレポ構成にしました。Pythonにはまだデファクトとなるようなモノレポのツールはないですが、poetryだけで新しいツールを使わずに対応できたのでご紹介します。
モノリポにするメリットは、大まかには以下のようになります。
- Linter, CI, IDEなどのよく使うツールの設定が簡単に共通化できる。
- モジュール間の依存関係をシンプルにできる。
Pythonでモノレポをやっている例を検索すると、pantsやbazelなど多言語に対応した専用のツールを使う方法もでてきました。今回は言語はPythonだけでよく、新しい書き方を覚えるのも大変なのでpoetryだけてやる方法としました。
参考に以下のリポジトリにコードを上げました。
ファイル構成
大まかなファイル構成は以下のようになります。個別のプロジェクトはprojects/以下に、共通で使うライブラリはlibs/以下にそれぞれフォルダを作っていきます。
├── poetry.lock
├── poetry.toml
├── pyproject.toml
├── tasks.py
├── projects
│ ├── proj_a
│ │ ├── Dockerfile
│ │ ├── app
│ │ │ └── ...
│ │ ├── pytest.ini # pytestの設定ファイル
│ │ └── tests
│ │ └── ...
│ ├── proj_b
│ └── ...
├── libs
│ └── lib_hello
│ ├── pyproject.toml
│ └── ...
├── .github
│ └── workflows
│ ├── common.yml # 共通のチェック項目
│ ├── proj_a.yml # 各プロジェクトのCI
│ └── proj_b.yml
└── ...
Pythonの依存管理: poetry
依存パッケージのバージョン管理はルートのpoetryですべて管理することにして、プロジェクトごとの依存はpoetryのグループ機能を使って分けます。これによって、共通で依存するパッケージは同じバージョンに揃えられてメンテナンスが簡単になり、依存パッケージも最新の状態に保たれやすくなります。また全体でバージョンが揃っていることで、Linterをすべてのプロジェクトに一括して実行することができます。[1]
今回は、各プロジェクトのフォルダ名と一致したグループを作る想定で説明します。また、開発やCIで使うパッケージはdev、特別な環境でしかインストールしたくないパッケージはtrainと特殊なグループを分けて用意します。
グループに依存パッケージを追加する場合は、--group
オプションをつけて追加します。
poetry add fastapi --group proj_a
また、リポジトリ内のライブラリーは --editable
オプションを付けて追加します。
poetry add --editable libs/lib_hello --group proj_a
そうするとpyproject.tomlの関連する部分は以下のようになります。
[tool.poetry.dependencies] # mainには基本は追加しない
python = "^3.11"
[tool.poetry.group.dev.dependencies] # 開発やCIなどで使うもの
black = "^23.7.0"
... 省略
types-pyyaml = "^6.0.12.12"
[tool.poetry.group.train] # 特殊な環境でしか使わないものはオプショナル。後述
optional = true
[tool.poetry.group.train.dependencies]
jupyter = "^1.0.0"
[tool.poetry.group.proj_a.dependencies] # proj_aの依存パッケージ
fastapi = "^0.104.0"
lib-hello = { path = "libs/lib_hello", develop = true } # プロジェクト内の依存
uvicorn = "^0.23.2"
[tool.poetry.group.proj_b.dependencies]
flask = "^3.0.0"
lib-hello = { path = "libs/lib_hello", develop = true }
...
開発環境にインストールする場合は、通常のコマンドで実行します。すると、開発用devと各プロジェクトの依存パッケージがインストールされて、オプショナルに設定したtrainはスキップされます。
poetry install
CIなどで特定のプロジェクトの依存パッケージだけインストールしたい場合は、--only
オプションを使います。
poetry install --only dev --only proj_a
一度、poetry installで全部インストールしてしまったのを特定のプロジェクトのパッケージのみにしたい場合は、--sync
オプションでいらないパッケージを削除できます。
poetry install --sync --only dev --only proj_a
また、trainのようなオプショナルなパッケージもインストールしたい場合は、--with
をつけます。
poetry install --with train
Dockerイメージのビルド
プロダクション用にDockerイメージをビルドする際は、poetry export
でrequirements.txtを書き出して、pipでインストールします。ただし、ローカルのパッケージへのパスが絶対パスで出力されてしまうので、Dockerfileの書き方に工夫が必要です。Dockefileのマルチステージビルド機能を使って、requirements.txtを書き出すフェーズと本番イメージのフェーズで作業ディレクトリを同じパスにしておきます。具体的には以下のようなDockerfileになります。
FROM python:3.11 as requirements-stage # requirements.txtを書き出すようステージ
WORKDIR /work
RUN pip install poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export --without-hashes --output requirements.txt --only proj_a
FROM python:3.11-slim # 本番用ステージ
ENV PYTHONUNBUFFERED True
WORKDIR /work # requirements-stageのworkdirと揃える。
RUN pip install --no-cache-dir --upgrade pip
COPY /work/requirements.txt requirements.txt
COPY libs/lib_hello libs/lib_hello # 依存しているパッケージもコピーしておく
RUN pip install --no-cache-dir -r requirements.txt
COPY projects/proj_a projects/proj_a
WORKDIR /work/projects/proj_a
CMD exec uvicorn --port $PORT --host 0.0.0.0 app.main:app
ビルドするときは、projects/proj_aからは以下のようなコマンドになります。
docker build --file=Dockerfile ../../
制約
モノレポ構成にしたことで一部ツールで少し制約があり注意が必要なところがあるので説明します。
各プロジェクトのフォルダにpyproject.tomlを作れない
最近、各種ツールの設定は各ツールで決められた設定ファイル以外に、pyproject.tomlに書けるようになってきて、pyproject.tomlに設定をまとめられるようになってきています。
├── pyproject.toml # poetryなど共通設定
└─── projects
└─── proj_a
└── pyproject.toml # プロジェクト固有の設定
しかし、各プロジェクトのディレクトリにpyproject.tomlをおくと、poetryコマンドを実行したときに作業ディレクトリのpyproject.tomlを見に行ってしまい、以下のようにpoetryの項目がないとエラーとなってしまいます。ローカルの開発ではpoetryで作成したvirtualenv環境に入って作業するので問題はないですが、CIでpoetry run
経由で実行する際に実行できません。
$ cd projects/proj_a
$ poetry run pytest
[tool.poetry] section not found in /workspaces/python-monorepo/projects/proj_a/pyproject.toml
そのため、各プロジェクトのフォルダにはpyproject.tomlは置かずに、各ツールで指定された設定ファイルを置くようにしてください。上記のリポジトリの例では、pytestの設定をpytest.iniに書いています。
├── pyproject.toml # poetryなど共通設定
└─── projects
└─── proj_a
└── pyteset.ini # プロジェクト固有のpytestの設定
mypyのエラーを消すために__init__.pyを配置する
mypyの仕様で、複数の同じ名前のPythonモジュールがトップの名前空間にあるとエラーになってしまいます。例えば以下のようにproj_bとproj_cに両方に同じ名前のtasks.pyというファイルがある構成になっているとエラーになります。
projects/
├── proj_b
│ └── tasks.py
└── proj_c
└── tasks.py
以下のmypyドキュメントに説明が書いてあり、修正方法がいくつか書いてあります。
簡単な解決方法は、mypyにパッケージとして認識させるためにプロジェクト内の適当な位置に__init__.pyファイルを置く方法です。先程の例では、プロジェクトのトップに__init__.pyファイルを置くことでエラーが出なくなります。
projects/
├── proj_b
│ ├── __init__.py
│ └── tasks.py
└── proj_c
├── __init__.py
└── tasks.py
mypy以外では必要ないのに__init__.py
を置くのが気持ち悪いという場合は、各プロジェクト内でパッケージの階層を一個深くする方法もあります。
proj_a/
└── proj_a
├── __init__.py
└── tasks.py
CIでの依存チェック
プロジェクトの数が増えてくると、一部しか変更していないのにすべてのCIを実行するのは、リソースを消費して、時間もかかるために避けたいです。以下のようにGithub Actionsでは特定のパスに変更があったときだけワークフローをトリガーする機能があります。
しかし、各プロジェクトから依存関係のあるリポジトリ内のライブラリが追加されたときに一緒にトリガーに追加し忘れると、CIが正しく実行されません。そこで、ローカルのライブラリに対する依存がトリガーに入っているか確認するスクリプト(プロジェクトルートのtasks.py)を作成しました。pyproject.tomlと.github/workflows/以下のファイルを読み込んで、ローカルへの依存が一致しているかチェックしています。Github Actions以外のCIの場合もyaml等で書かれることが多いので、同様のコードを書くのは簡単かと思います。[2]
VS CodeのMulti-root Workspaces
IDEでモノレポにすると階層が深くなって、目的のファイルを開いたり、作業中のプロジェクトのディレクトリへの移動などが面倒になることがあると思います。VSCodeにはMulti-root Workspacesという機能があって、ワークスペースを分けることができます。以下のような簡単なファイルを作成して開くだけです。
そうすると以下のように、左のExplorerのところに複数のワークスペースが作られます。また、ターミナルを開くときにもワークスペースを選択できるようになるため、作業したいプロジェクトへすぐにアクセスできるようになります。
各ワークスペースごとに別々の設定ファイル(.vscode/settings.json
)を置くことも可能です。詳細は公式ドキュメントを読んでください。
まとめ
パッケージ管理から、CIやVS Codeまでうまくモノレポに対応できて満足しています。今後、poetryのissueでもモノレポ対応について議論が進んでいるので、今後機能が拡充すると嬉しいです。[3]
Discussion