uv workspaces運用の課題と対策

こんにちは。Data Management Group(以下DMG)の宮崎です。主にデータパイプライン開発やWebサーバサイド開発に携わりつつ、CIやデータ基盤の改善にも取り組んでいます。

estieはRustの活用が目立つ会社ですが、DMGではPythonの利用シーンも多いです。今回はDMGでのPython活用状況をお伝えすることも兼ねて、uvのworkspaces機能について書いていきたいと思います。

この記事で言いたいこと

伝えたいことに対して前振りが長くなってしまったので、先に結論を書きますと

「uv workspaces使うならパッケージごとの依存関係漏れを検知する仕組みもセットで考えよう!」

というものです。「どういうこと?」となった方は続きもご覧いただければと思います。

DMGのPythonユースケースについて

背景情報として、DMGの主要なPythonユースケースを書き出すと以下のようになります。

  • データパイプライン上のETL, ELT
    • Dockerコンテナで動作するジョブやdbt Python modelなど
  • 機械学習モデルの学習・推論
    • 推論処理を提供するHTTP APIも

dbt Python modelがやや特殊な存在なものの、それ以外はよくあるユースケースかと思います。また、いずれのユースケースでもuvを用いてパッケージ管理を行っています。

uv workspacesについて

uvのバージョンは0.7.13を前提とします。

ここから本題になりますが、uvのmulti package管理機能としてworkspacesというものがあります。

Using workspaces | uv

コードベースを複数のpackageに分割しつつ依存関係は共通で管理できる機能で、Cargo workspace(Rust)やBun workspaces(TypeScript)など、他言語のpackage managerが持っている機能と同様のものになります。

使ったことないという方は、以下のコマンドを手元のマシンで動かしていただければ、何ができるのかおおよそ把握できるのではないかと思います。

cd uv-workspaces
uv init --bare
uv init --package lib-pack --lib
uv init --package app-pack --app
uv add --dev ruff
uv add --package lib-pack attrs
uv add --package app-pack pydantic
uv remove --package app-pack pydantic
uv sync --all-packages

なぜworkspacesを使うか

前述の通り様々な言語/パッケージマネージャでworkspaces相当の機能が実装されているわけですが、なぜこれらの機能が提供されるのか?という点を簡単に整理すると

  • 部分的にコードを再利用・配布可能にする
  • レイヤー違反の機械的な検知
  • 差分ビルドによるビルド時間最適化
  • テストスコープ最適化
  • 論理的な単位でパッケージを区切ることで人間がコードを認識しやすくなる
    • あくまでより強く境界を意識できる、という程度のものですが

こんなところでしょうか。

一方で、シングルパッケージ運用と比較し多少なりとも運用コストが増加しますが、このデメリットは実用上は気にならない程度という認識です。

Pythonでworkspacesを利用する上での課題

前述のように、workspaces機能には明確なメリットがある一方、デメリットらしいデメリットがないということで、多くのシーンで使われていると思います。私個人の経験を振り返ると、Rustでの開発はCargo workspace利用が基本でした。

必然、uv workspacesも利用しようとなるのですが1つ問題点がありました。それは「あるパッケージが、他のパッケージで宣言された依存関係をimportしていないことを保証できない」ことです。

例えば、下記のコマンドを実行した場合にpydanticをimportできるのはapp-packだけになって欲しいですが、開発マシンにはpydanticを認識した1つのPython環境があるだけなのでlib-packでimportしてもエラーは発生しないわけですね。

uv init --package lib-pack --lib
uv init --package app-pack --app
uv add --package app-pack pydantic

uvの問題ではなく、Pythonがパッケージを認識する方法の仕様上そうなる、というものです。

この問題点は先ほどのuv公式ドキュメントの最下部でも触れられています。

ではどうするか

解決のアプローチを大別すると

  • 静的解析で検出したライブラリをpyproject.tomlで宣言したライブラリと照合
  • workspace memberごとに独立したPython環境でプログラムを実行

の2通りが考えられます。

前者について、最も手軽なのは有志作成のツールに頼ることでしょう。無論、ツールの信頼性やメンテされなくなった場合にどうするのかなど考慮事項はありますが。

ツールの一例を挙げると、不足した依存関係を検知する機能を持ったdeptryというコマンドラインツールがあります。

GitHub - fpgmaas/deptry: Find unused, missing and transitive dependencies in a Python project.

なお、私が知る限りではこの用途で最も人気があったtachは最近UNMAINTAINED宣言されてしまいました。

GitHub - gauge-sh/tach: A Python tool to visualize + enforce dependencies, using modular architecture 🌎 Open source 🐍 Installable via pip 🔧 Able to be adopted incrementally - ⚡ Implemented with no runtime impact ♾️ Interoperable with your existing systems 🦀 Written in rust

後者の具体的な方法としては、CIの中でworkspace memberごとに独立したPython環境でテストを実行することが考えられます。「静的解析と比較して検知できるタイミングが遅い」、「環境を多数用意する分CI実行コストは高い」「テストカバレッジが低いと依存関係漏れの検知精度も下がる」、といったあたりが弱点でしょうか。追加の依存関係なしで解決できるのは魅力的です。

さて、選択肢が揃ったところで実際のところどうしたのか?ですが、私の担当したプロジェクトでは、PullRequest上ではdeptryを利用し、リリースのGitHub Actionsの中でworkspace memberごとの独立環境テストを実行する、という形をとりました。PullRequestでは素早くフィードバックが欲しいので静的解析を行う一方、Pythonには動的importのような実行してみないとわからない要素があるため、重めのCIを許容できるリリース時には配布はなくともパッケージごとのテストを行うことにしました。

最後に

estieではRustだけでなく、Pythonで堅牢な開発を行うにはどうすれば良いか考えてくださる方も大歓迎です。気になった点があればぜひカジュアルにお話ししましょう。

hrmos.co

© 2019- estie, inc.