uv へ移行 from pyenv & poetry (docker前提）

# この記事の目的

現在、私の管理するpython project は pyenv & poetry を前提としているが、 `uv` がPython自体のversion管理も可能となったため、うまくいかなかった点もあわせて移行の記録をここに残していくこととする。

## Disclaimer
この記事の内容は、私個人の意見や見解であり、私が所属する組織の公式な立場、方針、意見を反映するものではありません。この記事の内容について、組織はいかなる責任も負いません。

## このタイミングの理由

uv 0.4.0 が 2024-08-30 に公開された。この version から project を [Application と Library を区別](https://docs.astral.sh/uv/concepts/projects/)するようになったことが、自分の Application の利用として Python repository の管理にフィットするからではないからと感じたことから調査を始めた。ちょうど良いタイミングで uv 0.4.1 から `uv export --format requirements-txt` が[有効となった](https://twitter.com/charliermarsh/status/1829620595048468480)。これにより .venv ではなく system Python (on Docker) への install が可能になった。

## 現状

自分のある repository では pyenv & poetry にて以下のような設定をしている

```sh
pyenv versions
  system
  3.10.12
  3.11.6
* 3.11.9 (set by /Users/my_name/my_proj/.python-version)
```

```sh
$ poetry env info

Virtualenv
Python:         3.11.9
Implementation: CPython
Path:           /Users/my_name/my_project/.venv
Executable:     /Users/my_name/my_project/.venv/bin/python
Valid:          True

Base
Platform:   darwin
OS:         posix
Python:     3.11.9
Path:       /Users/my_name/.pyenv/versions/3.11.9
Executable: /Users/my_name/.pyenv/versions/3.11.9/bin/python3.11
```



## Prerequisite

1. uv のインストール

開発環境はMacBook Intel。brewを使いインストールすることにした。現時点(2024-08-30)でのuvのversionは `0.4.1`

```sh
$ brew install uv

$ uv --version
uv 0.4.1 (Homebrew 2024-08-30)
```

## 移行手順

### 1. 別 clone した repository で作業する

バージョン管理をしているとはいえ、既存 repository で git管理外である ./.venv の中身なども破壊されてしまったらもとに戻すのも面倒である。てことでまずは別名で repository clone する。 git clone コマンドでフォルダ名を変更するにはコマンド最後に加える。以下の場合 `uv-my-project` という directory が作成される。（記事を書き終わって振り返ってみると問題なかったので、この手順は自分の中では今後不要と考える。これを読んでいる方はもちろん自己責任でお願いします）

```sh
$ git clone git@github.com:my-account/my-project.git uv-my-project
$ cd uv-my-project
```

### 2. uv で .venv の作成

#### 2.1 既存のpyproject.tomlをバックアップ

既存 pyproject.toml に対して手動で変更することも可能だが、uv で作成する pyproject.toml との矛盾の回避のため、既存は rename して残し `uv init` でゼロから作成する。pyproject.toml 内の project.name が親フォルダ名を参照するため rename された repository名になっていることに注意。

（自分は、以下の全ての作業完了後に問題ないことを確認後 `poetry-pyproject.toml` から、既存の項目の `[tool.ruff]`, `[tool.pyright]`, `[tool.mypy]` などの内容をコピーし、旧ファイルは削除した。）

```sh
$ mv pyproject.toml poetry-pyproject.toml
$ uv init
```

#### 2.2 .venv の作成

（注意）ここは移行手順1で新規にcloneしたrepositoryでの手順のため `.venv` が存在していない前提であるが、既存repositoryで実行している場合も uv 管理の .venv を作り直すため、以下を実行したほうが望ましいと考える。 [uv venv のドキュメント](https://docs.astral.sh/uv/reference/cli/#uv-venv)には以下の記述がある。また [uv python version](https://docs.astral.sh/uv/concepts/python-versions/) の公式ドキュメントも読んでおくことが助けになる。

> If a virtual environment exists at the target path, it will be removed and a new, empty virtual environment will be create


最初に `.python-version` が存在しない場合 pin 指定で .python-version ファイルを作成しておく。
```sh
$ uv python pin 3.11.9
```

これ以降 `uv add` の実行時に .venv が存在しないと自動で .python-version の指定した仮想環境を作成するため、以上で問題ない。

なお、.python-version を作成せず明示的に venv コマンドでインストールも可能だが、.python-version無しでは環境portability性が弱いので私自身は望ましくないと考える。

```sh
$ uv venv --python 3.11.9
```

ここでマニアックな点で一つ注意が必要かもしれない。既存のマシーンに、対象のversionのpython binaryが存在する場合それを使うが、例えば python 3.11.9 が brew 経由でのインストールしたbinaryとpyenv経由でインストールしたバイナリーがある場合、どちらを参照するかによってClang版とGCC版など違いが生まれる可能性がある。以下はWindows WSL2環境での自分の状況である。この場合 uv が参照するのは PATH での参照が先のものであろう。また、過去に [ryeでのPythonインストール](https://rye.astral.sh/guide/faq/#references-to-build-time-paths:~:text=%22/lib/tk8.6%22-,Python%20Interactive%20Prompt%20Input%20Messed%20Up,-The%20Python%20builds)はライセンスの関係からreadlineが含まれていなかったため、uvによる直接ダウンロードするbinaryがどのようなコンパイルから生まれたものかも注意が必要だ（私自身はuvとryeとbinaryが同じかどうかは未確認）

```sh
$ python -VV
Python 3.11.9 (main, Apr 11 2024, 08:53:02) [Clang 17.0.6 ]

$ /home/linuxbrew/.linuxbrew/opt/python@3.11/bin/python3.11 -VV
Python 3.11.9 (main, Apr  2 2024, 08:25:04) [GCC 11.4.0]
```

~~すでに pyenv & poetry で作成済みの .venv が存在していても上記コマンドの実行に問題はなかった。~~ 前述の通り、uvコマンドで .venv の内容は一新される。

余談だが、uv python list を実行すると、私のMacBook Intel環境では以下が表示された。system, pyenv, brew でインストールされた python が確認もできた。

```sh
$ uv python list
cpython-3.12.5-macos-x86_64-none     /usr/local/opt/python@3.12/bin/python3.12 -> ../Frameworks/Python.framework/Versions/3.12/bin/python3.12
cpython-3.12.5-macos-x86_64-none     <download available>
cpython-3.11.9-macos-x86_64-none     /usr/local/opt/python@3.11/bin/python3.11 -> ../Frameworks/Python.framework/Versions/3.11/bin/python3.11
cpython-3.11.9-macos-x86_64-none     /Users/xxx/.pyenv/versions/3.11.9/bin/python3.11
cpython-3.11.9-macos-x86_64-none     /Users/xxx/.pyenv/versions/3.11.9/bin/python3 -> python3.11
cpython-3.11.9-macos-x86_64-none     /Users/xxx/.pyenv/versions/3.11.9/bin/python -> python3.11
cpython-3.11.9-macos-x86_64-none     <download available>
cpython-3.10.14-macos-x86_64-none    <download available>
cpython-3.9.19-macos-x86_64-none     <download available>
cpython-3.9.6-macos-x86_64-none      /Library/Developer/CommandLineTools/usr/bin/python3 -> ../../Library/Frameworks/Python3.framework/Versions/3.9/bin/python3
cpython-3.8.19-macos-x86_64-none     <download available>
pypy-3.7.13-macos-x86_64-none        <download available>
```

## 3. package のインストール

次に package のインストール。これは poetry で作成した `poetry-pyproject.toml` の情報を単純にコピペしようとしたが、poetry の場合は toml の key value で持っている（`streamlit = "~1.37.0"`) が、uvは文字列（`"streamlit==1.37.0"`) で記入する必要がある。

また、poetry は他プログラミング言語でもよく使われる `~`,  `^`, `*` での version range を指定をするが、uv は `~= == != <= >= < > ===` での指定を行う必要がある。uv は [PEP 508](https://docs.astral.sh/uv/concepts/dependencies/#development-dependencies) に準拠しているようだ。

さて、手動で温かみのある編集は時間がかかるので、次の適当に作ったscriptを実行し、変換後のprint結果を pyproject.toml の該当箇所に貼り付ける。

ここで、poetryでは "~1.37.0" などpatch許容指定を含んでいたが、正確な変換は手間がかかるので uv では全て "==" となるように強制変換することとした。自分はversion指定に幅を持たせてメリットを感じたことはほとんどないので良しとする。

<details><summary>converter.py</summary>

```python
import tomllib
import sys


def convert(d: dict[str, str]) -> list[str]:
    ll = [
        # ここを等号とするかはご自由に
        k + "==" + f"{v}".replace("~", "").replace("^", "")
        for k, v in d.items()
        if k != "python"
    ]
    return ll


def main(poetry_pyproject: str) -> None:
    with open(poetry_pyproject, "rb") as f:
        d = tomllib.load(f)
        try:
            deps = d["tool"]["poetry"]["dependencies"]
            print("[project]\ndependencies = [")
            [print(f'    "{x}",') for x in convert(deps)]
            print("]")
        except Exception as _:
            pass

        try:
            dev_deps = d["tool"]["poetry"]["group"]["dev"]["dependencies"]
            print("[tool.uv]\ndev-dependencies = [")
            [print(f'    "{x}",') for x in convert(dev_deps)]
            print("]")
        except Exception as _:
            print("## there is no [tool.poetry.group.dev.dependencies]")


if __name__ == "__main__":
    args = sys.argv
    if len(args) != 2:
        sys.exit("bad arguments")

    main(args[1])

```

</details>


```sh
$ python converter.py poetry-pyproject.toml
[project]
dependencies = [
    "streamlit==1.37.0",
    "watchdog==4.0.2",
    "python-dotenv==1.0.1",
    "loguru==0.7.2",
    "awswrangler==3.9.1",
    "pandas==2.2.2",
    "XlsxWriter==3.2.0",
    "cryptography==43.0.0",
    "openpyxl==3.1.5",
    "pygwalker==0.4.9.7",
]
[tool.uv]
dev-dependencies = [
    "pytest==8.2.2",
    "pandas-stubs==2.2.2",
    "mypy==1.11.2",
    "types-python-dateutil==2.8.19",
    "ruff==0.6.2",
    "ipython==8.26.0",
]
```

この結果を `pyproject.toml` に貼り付ける。

次に `uv sync` を実行する。上記の私の例では次のエラーが出た。

```
$ uv sync
  × No solution found when resolving dependencies for split (python_full_version <
  │ '3.11.9'):
  ╰─▶ Because there is no version of pandas-stubs==2.2.2 and uv-myproject:dev
      depends on pandas-stubs==2.2.2, we can conclude that uv-myproject:dev's
      requirements are unsatisfiable.
      And because your project depends on uv-myproject:dev, we can conclude that
      your project's requirements are unsatisfiable.
```
これは pandas-stub が `pandas-stubs 2.2.2.240807` のように4項目で構成されているためである。したがって、 `"pandas-stubs==2.2.2.*",` と書き直すことで実行できるようになった。

ここまでで `uv.lock` ファイルが作成され、dependency などはここに記述されていることが見て取れる。


## 4. Dockerfile の変更

さて、ここからがメインである。現行の Dockerfile の内容は4年前の自分の記事の構成と基本は変わらない。

https://qiita.com/hkzm/items/b0b0f902d93c84536ede

さて、2024年現在、multi-stage build は常識となってきたが、 --mount=type= も使っていないと後ろ指を刺されるのではと言われているようである。[2024年版のDockerfileの考え方＆書き方](https://future-architect.github.io/articles/20240726a/) を参考にすると良いのではないか。

それらを考慮し以下のように記述した。

**update 1: 2024-09-05 uv cache が効いていなかったので修正**

```dockerfile
# syntax=docker/dockerfile:1

# https://docs.docker.jp/engine/reference/builder.html#:~:text=docker/dockerfile%3A1%20%E3%81%AE%E4%BD%BF%E7%94%A8%E3%82%92%E6%8E%A8%E5%A5%A8%E3%81%97%E3%81%BE%E3%81%99

# see uv docker for https://docs.astral.sh/uv/guides/integration/docker/

# align with `uv python pin`
ARG PY_VERSION="3.11.9"

FROM python:${PY_VERSION}-slim-bookworm AS builder
WORKDIR /src

ENV UV_SYSTEM_PYTHON=1 \
    UV_COMPILE_BYTECODE=1 \
    UV_CACHE_DIR=/root/.cache/uv \
    UV_LINK_MODE=copy
RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=cache,target=${UV_CACHE_DIR} \
    uv export --frozen --no-dev --format requirements-txt > requirements.txt \
    && uv pip install -r requirements.txt 

### for prod
FROM python:${PY_VERSION}-slim-bookworm AS prod
ENV PYTHONUNBUFFERED=1
WORKDIR /src
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
# ↓need to copy cli python pacakge
COPY --from=builder /usr/local/bin/streamlit /usr/local/bin/streamlit
COPY src/ ./
COPY .streamlit/ ./.streamlit/
ENV STREAMLIT_SERVER_PORT=80
EXPOSE 80
CMD ["streamlit", "run", "hello.py"]
```

（追記: multi-stageなしバージョン）
```dockerfile
# syntax=docker/dockerfile:1

# https://docs.docker.jp/engine/reference/builder.html#:~:text=docker/dockerfile%3A1%20%E3%81%AE%E4%BD%BF%E7%94%A8%E3%82%92%E6%8E%A8%E5%A5%A8%E3%81%97%E3%81%BE%E3%81%99

# see uv docker for https://docs.astral.sh/uv/guides/integration/docker/

# align with `uv python pin`
ARG PY_VERSION="3.11.9"

FROM python:${PY_VERSION}-slim-bookworm AS builder
WORKDIR /src

ENV UV_SYSTEM_PYTHON=1 \
    UV_COMPILE_BYTECODE=1 \
    UV_CACHE_DIR=/root/.cache/uv \
    UV_LINK_MODE=copy
RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=cache,target=${UV_CACHE_DIR} \
    uv export --frozen --no-dev --format requirements-txt > requirements.txt \
    && uv pip install -r requirements.txt 

COPY src/ ./
ENV PYTHONUNBUFFERED=1
ENV STREAMLIT_SERVER_PORT=80
EXPOSE 80
CMD ["streamlit", "run", "hello.py"]
```


そんなこんなで docker build は**爆速**になるし ~~image sizeも既存の半分になってしまった。~~ （修正: uvの効果ではなく既存のDockerfileの無駄な書き方の改善によるものでした。）本番反映してしばらく問題がなければ、他のrepositoryも同様に変更することとする。（仕事の息抜きとして）

以下に、このDockerfileを作成する過程で考えたことをつらつら記述する。

- 既存のコードと比較すると multistage-build の前半が本質的に変わっていることがわかる。後半は変更なし
- [公式 の uv-docker-example Dockerfile](https://github.com/astral-sh/uv-docker-example/blob/main/Dockerfile)では `COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv` と `COPY` しているが、自分は `RUN --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv` とRUNにまとめた。それがbestなのかはわかっていない
- 環境変数について
    - `uv sync` で .venv にインストールではなく system にインストールするためには`ENV UV_SYSTEM_PYTHON=true` で良かった。別の方法で`uv sync --python /usr/local/bin/python` というオプション指定もできることがわかった
    - `UV_COMPILE_BYTECODE=1` は、若干runtimeが伸びるが個人的嗜好で入れた
    - [公式](https://github.com/astral-sh/uv-docker-example/blob/main/Dockerfile)ではなかったが `UV_CACHE_DIR` の指定が必要だった。これは `UV_SYSTEM_PYTHON=true` に依存しているからかもしれない
    - `UV_LINK_MODE=copy` を入れないと warning が発生した。これも`UV_SYSTEM_PYTHON=true` に依存しているからかもしれない
- `,sharing=locked`を `--mount=type=cache,target=/root/.cache/uv` につけるべきかわからなかった。[`apt get` では必要らしい](https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#example-cache-apt-packages)のだが、不明だったので記述せず
- ぶっちゃけ `RUN --mount=from=ghcr.io/astral-sh/uv:0.4.1,source=/uv,target=/bin/uv` でイメージに残らないのなら、multi-stage build すら不要なのではないか？この辺は識者のご意見を聞きたい


さて、docker build で cache が効いていることを確認するため、例えば次のように package を変更し docker build のログを見ると、`#14 2.166 Prepared 1 package in 1.60s` と1 package を準備（ダウンロード）している旨のメッセージの確認ができた。にしても `UV_COMPILE_BYTECODE=1` で6秒ぐらいかかっているので、これは入れたほうが良いかは今後も要検討としたい。

```bash
sed -i 's/pandas==2.2.2/pandas==2.2.1/g' pyproject.toml
uv sync
docker biuld . --progress=plain
### 略
#14 [builder 6/6] RUN --mount=type=cache,target=/root/.cache/uv     uv export --frozen --no-dev --format requirements-txt > requirements.txt     && uv pip install -r requirements.txt
#14 0.230 Using Python 3.11.9 interpreter at: /usr/local/bin/python3
#14 0.549 Resolved 95 packages in 289ms
#14 2.166 Prepared 1 package in 1.60s
#14 2.568 Installed 95 packages in 402ms
#14 8.560 Bytecode compiled 6295 files in 5.99s
#14 8.560  + altair==5.4.1
### 略

```

以上


### 余談

qiita のタグ `UV` は Unity モデリング界隈とかぶるので `uv(astral-sh)` を作ってみました。

## 参考


https://docs.astral.sh/uv/guides/integration/docker/

https://github.com/astral-sh/uv-docker-example

https://hynek.me/articles/docker-uv/

https://zenn.dev/shundeveloper/articles/bd90f3677fead1

https://zenn.dev/dena/articles/python_env_with_uv



```

```