uv 0.12 へ上げるとき、依存解決より先に CI を見たほうがいい。uv init の既定がパッケージ構成へ戻ったためだ。以前の main.py 前提を残すと、ローカルでは気付かず、CI だけが古い入口を実行する。
7月28日公開の uv 0.12.0 では、uv init example が src/example と uv_build を持つパッケージを既定で作る。さらに uv run example 用の project.scripts も入る。既存プロジェクトは勝手に書き換わらないが、テンプレートを更新したリポジトリや、新旧の手順が混ざった monorepo は要注意だ。
自分はこういう移行で、README より先に .github/workflows を開く。uv run main.py が一行残っているだけで、実行対象が古いままになるからだ。今回も疑似プロジェクトを二つ作って検査し、古い入口を含む方だけを失敗にできることを確認した。
uv 0.12 の uv init で何が変わるか
0.11 系までの既定では、main.py を置いた「パッケージ化しない」構成が作られていた。0.12 では次のような構成が既定になる。
| 観点 | 以前の既定 | uv 0.12 の既定 |
|---|---|---|
| 実装の置き場 | main.py |
src/<package>/ |
| build system | なし | uv_build |
| 実行入口 | uv run main.py |
uv run <project.scripts の名前> |
| テストからの import | プロジェクトを自動では install しない | パッケージとして import できる |
既存のスクリプト型プロジェクトを保ちたいなら、uv init --no-package を選べばよい。問題になるのは、パッケージ構成なのに実行コマンドだけ main.py のまま残るケースだ。uv run main.py は「パッケージの console script を実行する」意味ではない。ファイルを直接指定している。
CI を直す前に、まず旧入口を参照している場所だけを洗い出す。
main.py 前提を止める小さな検査
次のスクリプトは、uv_build を使い、かつ src/<正規化したプロジェクト名>/__init__.py があるリポジトリだけを対象にする。GitHub Actions、Makefile、Dockerfile、scripts/ の中にある python main.py または uv run main.py を見つけたら終了コード 1 を返す。Python 3.11 以降で動く。
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
import tomllib
def find_old_entrypoints(root: Path) -> list[str]:
pyproject = root / "pyproject.toml"
if not pyproject.is_file():
return []
config = tomllib.loads(pyproject.read_text(encoding="utf-8"))
name = config.get("project", {}).get("name")
backend = config.get("build-system", {}).get("build-backend", "")
if not name or "uv_build" not in backend:
return []
package = re.sub(r"[-.]", "_", name)
if not (root / "src" / package / "__init__.py").is_file():
return []
candidates = [root / "Makefile", root / "Dockerfile"]
candidates += list((root / ".github" / "workflows").glob("*.y*ml"))
if (root / "scripts").is_dir():
candidates += list((root / "scripts").glob("*"))
old_entry = re.compile(
r"(?<![\w./-])(?:python(?:3)?|uv\s+run)\s+(?:\./)?main\.py\b"
)
hits: list[str] = []
for path in candidates:
if not path.is_file():
continue
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if old_entry.search(line):
hits.append(f"{path.relative_to(root)}:{line_no}: {line.strip()}")
return hits
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("root", nargs="?", type=Path, default=Path("."))
args = parser.parse_args()
hits = find_old_entrypoints(args.root.resolve())
if not hits:
return
print("uv 0.12 packaged project still runs main.py:", file=sys.stderr)
print(*hits, sep="\n", file=sys.stderr)
raise SystemExit(1)
if __name__ == "__main__":
main()
たとえば workflow に次がある場合、出力はこうなる。
- run: uv run main.py
uv 0.12 packaged project still runs main.py:
.github/workflows/test.yml:2: - run: uv run main.py
同じ構成で uv run demo-app だけを置いた fixture では、何も出力せず終了した。検査対象を uv_build と src/ の両方で絞っているので、従来の単発スクリプトまで一律に止めない。
CI の直し方は二択
パッケージとして育てるなら、pyproject.toml の [project.scripts] に入口を置き、workflow は uv run <script-name> に寄せる。コードを import してテストするなら、この形のほうがパスの扱いも揃う。
単発の運用スクリプトなら、無理にパッケージ化する理由はない。uv init --no-package で作り、main.py を入口として明示したほうが読み手にも意図が伝わる。ここを曖昧にしたまま両方のコマンドを置くと、CI と開発者の端末で別の入口が育つ。
おわりに
uv 0.12 の変更は、新規プロジェクトを普通の Python パッケージとして始めやすくするものだ。ただ、main.py から src/ への移動はディレクトリ名の差では終わらない。実行入口、テストの import、CI のコマンドまで一緒に決め直す境目になる。
アップグレード時は、まず uv.lock を更新する前に入口を一つにする。この検査を CI に一度入れておけば、新しいテンプレートと昔の workflow が静かに混ざる事故は防げる。
