Pythonの開発用適当ツールの作成・実行はuvを使うのがオススメ

## はじめに

以前に [Goを推奨する記事](https://qiita.com/ssc-ksaitou/items/6c66669f1672806ac9bb) の中で、「ツール用言語としてPythonの体験が依存性管理等で良くない」ということを書いたのですが、 **[uv](https://docs.astral.sh/uv/) ([GitHub](https://github.com/astral-sh/uv)) というRust製のPythonパッケージマネージャーツールで体験がかなり改善しそう**だったので、この記事で試していきたいと思います。

https://qiita.com/ssc-ksaitou/items/6c66669f1672806ac9bb

（この記事のソースコードは https://gitlab.com/ksaitou/2025-03-17_tryout_uv で入手可能です）

## uvのインストール

[インストール](https://docs.astral.sh/uv/getting-started/installation/) ページを見る限り、通常のパッケージマネージャに対応しているようです。

```sh
# macOS
$ brew install uv

# Windows
$ winget install --id=astral-sh.uv  -e
$ scoop install main/uv

# Cargo (Rustのパッケージマネージャ)
$ cargo install --git https://github.com/astral-sh/uv uv
```

## シングルファイルPythonの実行にuvを使う

uvがインストールされた状態で以下の小さなツールのスクリプトを実行してみます。

```python:my-weather-report-tool.py
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "datetime",
#     "requests",
# ]
# ///

import requests
import datetime

report_source = "https://www.jma.go.jp/bosai/forecast/data/forecast/130000.json"

if __name__ == "__main__":
    response = requests.get(report_source)
    data = response.json()
    for area in data[0]['timeSeries'][0]['areas']:
        if area['area']['name'] == '東京地方':
            weather_tokyo = area['weathers']
            break

    today = datetime.datetime.now()
    tomorrow = today + datetime.timedelta(days=1)
    tomorrow_str = tomorrow.strftime("%Y-%m-%d")

    print(f"東京地方の{tomorrow_str}の天気は「{weather_tokyo[1]}」です。");
```

```sh:python3コマンドの代わりにuv runコマンドを使えばOK
$ uv run my-weather-report-tool.py
Installed 9 packages in 31ms

東京地方の2025-03-18の天気は「くもり　昼前　から　昼過ぎ　晴れ　所により　昼過ぎ　から　雨か雪」です。
```

**自動的に依存パッケージのインストールとスクリプトの実行**が行われました。小さなツールの速やかな実行に最適そうです。

### スクリプトのヘッダに記載されている `/// script` 〜 `///` は何？

スクリプトのヘッダに書かれているPythonランタイムや依存性に関わるコメント `/// script` 〜 `///` はuv特有のメタデータではなく、Python Packaging内の [inline-script-metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata) という仕様によるメタデータです。

```python
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "datetime",
#     "requests",
# ]
# ///
```

各セクションを `# /// セクション名` 〜 `# ///` のコメントで挟んで表現します。

今回使っている`script` セクションであれば、スクリプトの依存性やツールバージョンを示すことができます。

inline-script-metadataは以下のコマンドで追記できるほか、手で追記しても構いません。

```sh
# inline-script-metadata を追加する場合
$ uv init my-weather-report-tool.py

# inline-script-metadata 上に依存性を追加する場合（別にinitしていなくてもOK）
$ uv add --script my-weather-report-tool.py datetime requests
```

#### 依存性をコントロールする

依存性について[細かいバージョンを指定すること](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers)も可能です。

```python:inline-script-metadata部分
# dependencies = [
#     "datetime",
#     "requests >= 2.8.1",
# ]
```

単純に構成を保証するためにロックファイルが欲しい場合は以下のコマンドで `スクリプト名.py.lock` を得ることができます。

```sh
$ uv lock --script my-weather-report-tool.py
# => my-weather-report-tool.py.lock が生成される
```

#### Pythonバージョンをコントロールは `.python-version` で行う

仮に特定のバージョンのPythonで実行してほしい場合は以下のように指定したいところですが、単純にPythonランタイムに対する希望を指定するもので、Python(CPython)を自動インストールしてくれるようではないようです。

```python:inline-script-metadata部分
# # ↓ これを指定しても別にCPython 3.11.11 がインストールされるわけではない
# requires-python = "==3.11.11"
```

こういった場合には inline-script-metadata ではなく `.python-version` ファイルを置いておくことにより `uv run` する前に自動的にインストールしてくれます。

```text:.python-version
3.11.11
```

```sh
# uvで実行するだけで勝手に .python-version に基づいてCPythonがインストールされる
$ uv run my-weather-report-tool.py
```

## 普通のPythonプロジェクトにuvを使う

1ファイルで完結するスクリプトではなく、がっつり複数ファイルで構成された普通のPythonプロジェクトを構成したい場合にどうやって使うのか見ていきます。

まずプロジェクトを `uv init` コマンドで作成します。Pythonでは定番のファイルが作成されます。

```sh:Pythonプロジェクトを初期化する
$ uv init mypj
$ ls -a mypj
.python-version README.md       main.py         pyproject.toml
```

基本的には依存性などプロジェクトのメタデータは [`pyproject.toml`](https://packaging.python.org/ja/latest/guides/writing-pyproject-toml/) に記述すればよいです。中身としては先ほどのシングルファイルのヘッダコメントの inline-script-metadata と同じようなものです。

`dependencies` に依存性を追記し `main.py` を実行してみます。

```toml:pyproject.toml
[project]
name = "mypj"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
# `uv add` コマンドでも追加できる
dependencies = ["datetime", "requests"]
```

```python:main.py
import requests
import datetime

report_source = "https://www.jma.go.jp/bosai/forecast/data/forecast/130000.json"

if __name__ == "__main__":
    response = requests.get(report_source)
    data = response.json()
    for area in data[0]['timeSeries'][0]['areas']:
        if area['area']['name'] == '東京地方':
            weather_tokyo = area['weathers']
            break

    today = datetime.datetime.now()
    tomorrow = today + datetime.timedelta(days=1)
    tomorrow_str = tomorrow.strftime("%Y-%m-%d")

    print(f"東京地方の{tomorrow_str}の天気は「{weather_tokyo[1]}」です。");
```

```sh:main.pyを実行してみる
$ uv run main.py 
Using CPython 3.13.2
Creating virtual environment at: .venv
Installed 9 packages in 28ms

東京地方の2025-03-18の天気は「くもり　昼前　から　昼過ぎ　晴れ　所により　昼過ぎ　から　雨か雪」です。
```

以下を一度にやってくれました。

- `.python-version` に基づき CPython をインストール
- `.venv` ディレクトリ（エディタ等が補完などのために使います）を作成
- `uv.lock`（依存性ロックファイル）を作成
- `main.py` を実行
  - スクリプトの実行が不要で依存性だけ持ってきたい場合は `uv sync` コマンドを使えばOK

とにかく一度構成された通常プロジェクトでも、スクリプトの実行には `uv run` を使えば問題なさそうです。

### DockerイメージやCI/CDでuvを実行してみる

それぞれガイドがあるので、それに従えばいいでしょう。（CI/CDはGitLabを例に貼っています）

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

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

一応概要を書いておくと、以下のようになります。

- 公式のDockerイメージが [`ghcr.io/astral-sh/uv`](https://github.com/astral-sh/uv/pkgs/container/uv) としてPythonバージョン等ごとに提供されています。
- 依存性のキャッシングや最適化は各ガイドで考慮されています。以下の例を見るとポイントが分かりやすいと思います。
  - Docker
    ```Dockerfile:Dockerfile
    # キャッシングありでの依存性のみのインストールレイヤーの作成
    RUN --mount=type=cache,target=/root/.cache/uv \
        --mount=type=bind,source=uv.lock,target=uv.lock \
        --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
        uv sync --frozen --no-install-project

    # あらためてプロジェクトソースを追加して依存性インストール
    ADD . /app
    RUN --mount=type=cache,target=/root/.cache/uv \
        uv sync --frozen
    ```
  - CI/CD
    ```yaml:.gitlab-ci.yml
    task-using-uv:
      variables:
        # uv の依存性をインストールするディレクトリを指定 (各uvコマンドで参照される)
        UV_CACHE_DIR: .uv-cache
      cache:
        - key:
            files:
              - uv.lock
          paths:
            - $UV_CACHE_DIR
      script:
        # 依存性をインストール
        - uv sync --frozen
        # プロジェクト内の検証タスク
        # - ...
        # 依存性ディレクトリをキャッシングのために最適化する (ビルドの最後でいいかも)
        - uv cache prune --ci
    ```

## 古いAnsibleしか使えないプロジェクトにuvを使う

塩漬けになっているサーバなど、サーバのOSやPythonバージョンが古すぎるせいで、新しいAnsibleバージョンが使えない場合があります。

たとえば、Python 2.7しか無いCentOS 7 (EOL)のサーバをAnsibleで保守することを考えた場合、 以下のバージョンを最後にAnsibleによるサポートが終了していることがわかります。

> Ansibleバージョン: `2.16`
> クライアントマシン: Python `3.10` - `3.12`
> ターゲットマシン: Python `2.7`, Python `3.6` - `3.12`, Powershell `5.1`
> [Releases and maintenance](https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix)

クライアントマシンのプラットフォームでインストールされているAnsibleバージョンは2025-03現在であれば `2.18` ではないでしょうか？ `2.17` 以降でPython 2.7しかないサーバに繋ぐとエラーで終了してしまいます。

こういったケースのために uv を使って塩漬けPJのAnsible/Pythonのバージョンを下げる手段があります。

### uvでプロジェクト固有バージョンのAnsibleをインストールして利用する

まずは `uv init .` コマンドで既存のAnsibleプロジェクトにPythonプロジェクトファイルを追加しましょう。（別に手でファイルを追加してもいいです）

```sh:既存プロジェクトの初期化
$ uv init .

# 不要ファイルは適宜消す
$ rm main.py

$ ls -a
 .python-version README.md       pyproject.toml  site.yml
```

各ファイルに必要事項を記入していきます。

```toml:pyproject.toml
[project]
name = "myansible"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"

# Ansible動作環境としての条件を指定
requires-python = ">=3.10"
dependencies = ["ansible==9.11.0"]
```

```text:.python-version
3.12
```

`uv venv` / `uv sync` コマンドでAnsibleを実行可能にします。

```sh:Ansible用のvenvの準備
# .venv/ ディレクトリの作成
$ uv venv
Using CPython 3.12.9 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate

# .venv/bin 内に ansible 系のコマンドを準備
$ uv sync
Resolved 11 packages in 82ms
Prepared 6 packages in 613ms
Installed 10 packages in 347ms
 + ansible==9.11.0
 + ansible-core==2.16.14
 + cffi==1.17.1
 + cryptography==44.0.2
 + jinja2==3.1.6
 + markupsafe==3.0.2
 + packaging==24.2
 + pycparser==2.22
 + pyyaml==6.0.2
 + resolvelib==1.0.1
```

venv を有効にします。

```sh:venvの有効化
# `uv venv` で指定されたコマンドを実行
$ source .venv/bin/activate
```

いよいよ `ansible` 系コマンドにパスが通っている状態なので実行してみます。

```sh
(myansible) $ python --version
Python 3.12.9

(myansible) $ ansible --version
ansible [core 2.16.14]
  config file = None
  configured module search path = ['/Users/ksaitou/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/ksaitou/rnd/2025-03-17_tryout_uv/myansible/.venv/lib/python3.12/site-packages/ansible
  ansible collection location = /Users/ksaitou/.ansible/collections:/usr/share/ansible/collections
  executable location = /Users/ksaitou/rnd/2025-03-17_tryout_uv/myansible/.venv/bin/ansible
  python version = 3.12.9 (main, Feb  4 2025, 14:38:38) [Clang 16.0.0 (clang-1600.0.26.6)] (/Users/ksaitou/rnd/2025-03-17_tryout_uv/myansible/.venv/bin/python3)
  jinja version = 3.1.6
  libyaml = True

(myansible) $ ansible-playbook site.yml 
TASK [Say hello] ********************************************
ok: [localhost] => {
    "msg": "Hello"
}
```

プロジェクトでAnsibleを実行する前にvenvの有効化など多少の儀式は必要ですが、古いAnsibleプロジェクトの環境整備にuvが使えることもわかりました。

## まとめ

Pythonのパッケージマネージャツールである **[uv](https://github.com/astral-sh/uv) を入れておけば、事前準備無しで、シングルファイルの実行から通常のPythonプロジェクトのマネージメント、また古いAnsibleプロジェクトの実行まで幅広いタスクをこなせる**ことを確認できました。

とにかくPython製の開発用適当ツールの実行については **`pip3`/`python3` コマンド (あるいは `pip`/`python` コマンド) の存在を一旦忘れて `uv run` だけを使う** という具合にやってしまってもいいかもなあと思っています。