🐍

Poetryを使ったPythonのモノレポ構成

テラーノベルで機械学習を中心に担当している川尻です。

最近新たにPythonのプロジェクトを立ち上げるときにモノレポ構成にしました。Pythonにはまだデファクトとなるようなモノレポのツールはないですが、poetryだけで新しいツールを使わずに対応できたのでご紹介します。

モノリポにするメリットは、大まかには以下のようになります。

  • Linter, CI, IDEなどのよく使うツールの設定が簡単に共通化できる。
  • モジュール間の依存関係をシンプルにできる。

Pythonでモノレポをやっている例を検索すると、pantsやbazelなど多言語に対応した専用のツールを使う方法もでてきました。今回は言語はPythonだけでよく、新しい書き方を覚えるのも大変なのでpoetryだけてやる方法としました。

参考に以下のリポジトリにコードを上げました。
https://github.com/filmapp/python-monorepo

ファイル構成

大まかなファイル構成は以下のようになります。個別のプロジェクトは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の関連する部分は以下のようになります。

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オプションを使います。

CI
poetry install --only dev --only proj_a

一度、poetry installで全部インストールしてしまったのを特定のプロジェクトのパッケージのみにしたい場合は、--syncオプションでいらないパッケージを削除できます。

poetry install --sync --only dev --only proj_a

また、trainのようなオプショナルなパッケージもインストールしたい場合は、--withをつけます。

poetry install --with train

https://python-poetry.org/docs/master/managing-dependencies

Dockerイメージのビルド

プロダクション用にDockerイメージをビルドする際は、poetry exportでrequirements.txtを書き出して、pipでインストールします。ただし、ローカルのパッケージへのパスが絶対パスで出力されてしまうので、Dockerfileの書き方に工夫が必要です。Dockefileのマルチステージビルド機能を使って、requirements.txtを書き出すフェーズと本番イメージのフェーズで作業ディレクトリを同じパスにしておきます。具体的には以下のようなDockerfileになります。

projects/proj_a/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 --from=requirements-stage /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ドキュメントに説明が書いてあり、修正方法がいくつか書いてあります。

https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules

簡単な解決方法は、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では特定のパスに変更があったときだけワークフローをトリガーする機能があります。

https://github.com/filmapp/python-monorepo/blob/0304b7b3f49554a65aeab57bdd5b6ebb991cf0c9/.github/workflows/proj_a.yml#L1-L9

しかし、各プロジェクトから依存関係のあるリポジトリ内のライブラリが追加されたときに一緒にトリガーに追加し忘れると、CIが正しく実行されません。そこで、ローカルのライブラリに対する依存がトリガーに入っているか確認するスクリプト(プロジェクトルートのtasks.py)を作成しました。pyproject.tomlと.github/workflows/以下のファイルを読み込んで、ローカルへの依存が一致しているかチェックしています。Github Actions以外のCIの場合もyaml等で書かれることが多いので、同様のコードを書くのは簡単かと思います。[2]

VS CodeのMulti-root Workspaces

IDEでモノレポにすると階層が深くなって、目的のファイルを開いたり、作業中のプロジェクトのディレクトリへの移動などが面倒になることがあると思います。VSCodeにはMulti-root Workspacesという機能があって、ワークスペースを分けることができます。以下のような簡単なファイルを作成して開くだけです。

https://github.com/filmapp/python-monorepo/blob/main/monorepo.code-workspace

そうすると以下のように、左のExplorerのところに複数のワークスペースが作られます。また、ターミナルを開くときにもワークスペースを選択できるようになるため、作業したいプロジェクトへすぐにアクセスできるようになります。
explorer
terminal

各ワークスペースごとに別々の設定ファイル(.vscode/settings.json)を置くことも可能です。詳細は公式ドキュメントを読んでください。
https://code.visualstudio.com/docs/editor/multi-root-workspaces

まとめ

パッケージ管理から、CIやVS Codeまでうまくモノレポに対応できて満足しています。今後、poetryのissueでもモノレポ対応について議論が進んでいるので、今後機能が拡充すると嬉しいです。[3]
https://github.com/python-poetry/poetry/issues/936

脚注
  1. もしプロジェクト間でコンフリクトしてしまった場合は、個別にpyproject.tomlファイルを配置して管理することも可能です。 ↩︎

  2. python 3.11からtomllibが標準で入ったので、追加のパッケージなしでpyproject.tomlファイルを読み込めて便利ですね。 ↩︎

  3. issueはクローズされていますが、部分的なissueを切り出したためで議論は継続しています。 ↩︎

テラーノベル テックブログ

Discussion