uv workspacesでスッキリ作るPythonモノレポ
この記事で言いたいこと
uvはいいぞ。Pythonでモノレポするならuv workspacesを使おう!
イントロダクション
Python界でデファクトになりつつある新興パッケージ/プロジェクト管理ツールのuv。
uvには,workspacesという,マルチパッケージをサポートするための機能がある。Cargoにある同名の機能からインスパイアされたもので,Rustを使っている人には馴染み深いコンセプトだろう。
これまで,Pythonでモノレポ構成を作るための,これといって決め手となるソリューションはなかったように思う。Bazelは小規模プロジェクトで使うには正直とっつきづらく,Poetryでも頑張ればできると思うのだけれど,Poetryそのものはマルチパッケージ構成をサポートしていないためdependency groupを駆使するなどしなければならず,一筋縄ではいかない。
uvのworkspacesならRust(Cargo)のようにスッキリとモノレポ管理ができるのでは,と思って試したところ,いい感じにできそうだったので,少し詳細に手順と構成をまとめておく。
要件(実現したいこと)
ひとくちにモノレポといっても,その定義は開発チームが実現したいことによってさまざま。この記事で私が実現したい主要な要件は以下の通り。
- 1つのリポジトリで複数のPythonパッケージ(つまり
pyproject.toml
)を管理したい - Pythonバージョンや,
ruff
,pyright
などの開発ツールキットはプロジェクトルートのpyproject.toml
で統一したい - 各サブパッケージで個別に依存ライブラリを管理したい
- サブパッケージ間で依存関係を持たせたい
サンプルコード
workspacesの説明をするためのtoy projectを用意した。
コードは mocobeta/uv-workspaces-eggdishes に置いてある。
機能とパッケージ構成
サンプルコードの機能と構成はこんな感じ。
- 卵料理のレシピを教えてくれるCLIツール
- 拡張性のため,複数の卵料理のレシピと,CLIアプリケーションを個別パッケージで管理する
- コアライブラリパッケージ(
eggdishes-core
)にベースクラスEggDish
とそのデフォルト実装FreshEgg
を配置 - いくつかの拡張ライブラリパッケージ(
eggdishes-*
)にEggDish
のサブクラス(例:BoiledEgg
)を配置して,コアライブラリとは独立して管理する - アプリケーションパッケージ(
eggdishes-main
)にCLIのコードを配置
- コアライブラリパッケージ(
- アプリケーションパッケージから,コアライブラリとその拡張ライブラリのクラスを呼べる
toy projectなのでかなり簡略化しているけれど,現実のプロジェクトでもよくある構成を想定している。
Getting Started
早速リポジトリを作っていく。最初にコアライブラリとアプリケーションの2つのサブパッケージを用意して,workspacesの基本的な使い方を確認してから,そのあとで拡張パッケージを追加していく。
プロジェクトの初期化
ルートプロジェクト(ルートパッケージ)を作って,その下にサブパッケージを作る。
コマンドから想像できる通り,init
する時に与える--package
オプションが,workspacesを構成する「パッケージ」を指定するオプションで,パッケージを指定して操作する時は必ずこのオプションを与える。
この時点で,ディレクトリ構成はこうなっている。(uvバージョンは0.5.29)
ルートディレクトリ直下のpyproject.toml
, eggdishes-core/pyproject.toml
, egggdishes-main/pyproject.toml
の3つのパッケージ設定ファイルができている。
サブパッケージに依存関係を追加する
3rd partyの依存を追加
eggdishes-main
はCLIアプリケーションの想定なので,clickを依存に追加する。
# eggdishes-mainパッケージにclickへの依存を追加
パッケージ間の依存関係の追加
eggdishes-main
からeggdishes-core
のコードを呼べるようにするため,パッケージ間の依存関係を追加する。そのためのコマンドはないのでeggdishes-main/pyproject.toml
を直接編集する。
この段階で,3つのpyproject.toml
はこんな感じになる(一部不要なプロパティを削除したり,編集している)。
# ルートパッケージの設定
# eggdishes-coreパッケージの設定
# eggdishes-mainパッケージの設定
大事なことは,各パッケージはそれぞれでdependency graphを管理できるということ。
ただし,プロジェクト全体では1つのlockfileしか持たないため,全体として整合している必要があり,パッケージ間でコンフリクトする依存関係は書けない。
In a workspace, each package defines its own pyproject.toml, but the workspace shares a single lockfile, ensuring that the workspace operates with a consistent set of dependencies.
https://docs.astral.sh/uv/concepts/projects/workspaces/
この制約があることで,個人的にはモノレポが満たしていてほしい性質が担保される(依存関係がコンフリクトしているモノレポは管理がつらいし,そもそもモノレポ管理の必要性が疑わしい気がする)。
なお,ここではルートパッケージでPythonバージョンを>=3.13
で統一しているが,required-python
はサブパッケージごとに定義できるため,パッケージごとに異なるPythonバージョンを指定することもできる。モノレポ内で,違うPythonバージョンはあまり使いたくないけれど,やむにやまれぬ事情でどうしてもパッケージごとに異なるPythonバージョンを指定したい時はあるかもしれない。
dependency graphの確認
ここまでで,まだアプリケーションコードは1行も書いていないけれど基本となるプロジェクト構成が整ったので,tree
, sync
コマンドでdependency graphを確認しておく。
想定通りの依存グラフができている。
sync
コマンドの動作も確認しておく。sync
は,手元のPython環境をpyproject.toml
と一致させるコマンドだが,ここでは3つのパッケージがあるため,--package
オプションを指定することで環境の切り替えができる。
# eggdishesパッケージ(root)とsync
# 依存ライブラリなし
# eggdishes-coreパッケージとsync
# eggdishes-mainパッケージとsync
click==8.1.8
tree
コマンドの結果と整合している。
アプリのコードを書く
まずはコアライブラリのコードを書いていく。
# eggdishes-core/src/eggdishes_core/lib.py
:
"""Return the recipe for the egg dish."""
pass
# eggdishes-core/src/eggdishes_core/fresh_egg.py
=
return
コードの内容自体はまったく重要ではないのだけど,EggDish
という抽象クラスと,それを拡張したFreshEgg
というデフォルト実装クラスがある。recipe()
メソッドの実装によって振る舞いが変わる。
CLIアプリのほうはこんな感じで。
# eggdishes-main/src/eggdishes_main/__init__.py
# エントリーポイントとなるmainメソッド
# eggdishes-main/src/eggdishes_main/cli.py
pass
# recipe コマンドで,FreshEgg.recipe()の内容を表示する
"""Show the recipe for a fresh egg dish."""
=
1stバージョンが完成
ここまでで,1stバージョンのアプリが動作するようになる。
uv run eggdishes recipe
でテスト実行する。CLIコマンドeggdishes
はeggdishes-main
で定義しているので,sync
でeggdishes-main
とsyncしておくのを忘れないように。
なおここまでのスナップショットを0.1.0としてタグを打っておいたので,全体が見たい場合はこのタグのソースツリーを参照してほしい。
プロジェクトを拡張する
ここまででworkspacesの基本の説明が終わったので,ここからは,いろんな卵料理のレシピを追加するための拡張ライブラリを書いていく。
サブパッケージを追加する
スクランブルエッグ(eggdishes-scrambled
),目玉焼き(eggdishes-sunnysideup
),ポーチドエッグ(eggdishes-poached
),ゆで卵(eggdishes-boiled
)の4つの拡張パッケージを追加する。
$ uv init --package eggdishes-scrambled --lib
$ uv init --package eggdishes-sunnysideup --lib
$ uv init --package eggdishes-poached --lib
$ uv init --package eggdishes-boiled --lib
$ tree -L2
.
├── eggdishes-boiled
│ ├── pyproject.toml
│ ├── README.md
│ └── src
├── eggdishes-core
│ ├── pyproject.toml
│ ├── README.md
│ └── src
├── eggdishes-main
│ ├── pyproject.toml
│ ├── README.md
│ └── src
├── eggdishes-poached
│ ├── pyproject.toml
│ ├── README.md
│ └── src
├── eggdishes-scrambled
│ ├── pyproject.toml
│ ├── README.md
│ └── src
├── eggdishes-sunnysideup
│ ├── pyproject.toml
│ ├── README.md
│ └── src
├── pyproject.toml
├── README.md
└── uv.lock
# workspacesのメンバーに,新規作成したパッケージが追加されている
$ cat ./pyproject.toml
...
[tool.uv.workspace]
members = [
"eggdishes-core",
"eggdishes-main",
"eggdishes-scrambled",
"eggdishes-sunnysideup",
"eggdishes-poached",
"eggdishes-boiled",
]
サブパッケージの依存関係を更新
eggdishes-boiled
はeggdishes-core
に依存するので,依存関係を追加する。
その他の拡張パッケージも同様。
また,eggdishes-main
はすべての拡張パッケージを呼び出すため,eggdishes-main/pyproject.toml
に以下を追加。
"click>=8.1.8",
拡張ライブラリパッケージのコード例
拡張ライブラリに追加するファイルは1つだけで,EggDish
のサブクラスを生やす。
# eggdishes-boiled/src/eggdishes_boiled/boiled_egg.py
=
return
のような感じで,他の拡張パッケージについても,同様にEggDish
の実装クラスをそれぞれ生やしておく。
CLIアプリケーションのほうは,追加した拡張をインポートして,recipe
コマンドの引数に応じて挙動が切り替えられるようにする。
# eggdishes-main/src/eggdishes_main/cli.py
pass
"""Show the recipe for a fresh egg dish."""
= None
=
=
=
=
=
2ndバージョンが完成
2ndバージョンのコードは0.2.0としてタグを打っているので,全体感はこのタグのソースツリーを参照してほしい。
recipe
コマンドの引数に応じて挙動が変わることを確認する。
# 引数に boiled を指定して recipe コマンドを実行
# 引数に scrambled を指定して recipe コマンドを実行
その他の話題
その他,雑多なトピックを補足しておく。
workspacesの標準ディレクトリ構成
プロジェクト内にサブパッケージをどう配置するかについての縛りはないので,自由に配置できる。ただし,workspacesの公式ドキュメンテーションでは,<project-root>/packages/
以下にサブパッケージを配置する例が書かれているので,この記事のようにルートディレクトリ直下にパッケージを置かずに,pakcages/eggdishes-core
のように一段下げて配置するのが標準か推奨構成と思われる。サンプルコードを書いたあとで気づいた。。。
dev dependency
ruff
やpyright
のような開発ツールは,ルートのdev dependency groupに追加すればOK。
"pyright>=1.1.400",
"ruff>=0.11.7",
final dependency graph
ここまでのステップをすべて実行した最終的な依存グラフはこうなる。スッキリ!
)
)
パッケージビルドと配布
PyPIなどに公開する場合は,シングルパッケージの時と同じくbuild
とpublish
で。
# --all-pakcagesオプションを指定してbuildすると,workspaces内のすべてのパッケージが一気にビルドされる
# --packageオプションを指定して,パッケージごとのビルドも可
# 配布 (PyPI等のパッケージインデックスへ公開)
所感
少し丁寧にuv workspacesの機能と動作を試してみて,個人的に理想に近いモノレポ構成が実現できそうな感触をもった。プロジェクトが一定の規模に成長すると,モノレポとして一定のコントロールを効かせながら,かつ実装と依存関係をサブ機能(サブパッケージ)ごとに分けて分割管理したいケースがよくある。今後使う機会が増えそう。