クラウドMacでPython wheelを検証する:タグとネイティブ依存

クラウドMacでPython wheelを検証する:タグとネイティブ依存

WindowsでPythonプロジェクトを編集し、ネイティブ拡張を含むパッケージのビルドをクラウドMacに任せる。ログにはwheelの生成成功と出ているのに、同僚がインストールするとアーキテクチャの不一致や共有ライブラリが見つからないエラーが起きる。こうした問題で重要なのは、アーカイブを生成できたかどうかではありません。中に何が入っているか、依存先がどこを指しているか、そしてテスト時にソースディレクトリを意図せず参照していないかです。

ここでは、pyproject.toml を含む既存のプロジェクトを出発点に、タグの確認、ネイティブライブラリの確認、隔離環境へのインストールという3段階の受け入れ検証を行います。リポジトリへのアップロードは扱いません。また、手元の環境で動いたことを、すべてのシステムで互換性がある証拠とはしません。

一、まずインタープリターと検証範囲を固定する

リモートMacのプロジェクトルートで、次のコマンドを実行します。プロジェクトの要件を満たす、インストール済みのPythonを使用してください。検証の途中で、ついでにインタープリターをアップグレードしてはいけません。

python3 -m venv .wheel-build
.wheel-build/bin/python -m pip --version
.wheel-build/bin/python -c \
'import platform, sys; print(sys.version); print(platform.machine()); print(sys.executable)'
sw_vers
xcodebuild -version

出力をコミットIDとともに保存します。インタープリターのバージョン、プロセスのアーキテクチャ、OSのバージョンは、いずれも検証の前提条件です。Apple Silicon搭載機でも、現在のPythonプロセスがarm64で動作しているとは限りません。

サポートするPythonのバージョン、macOSの最低バージョン、ネイティブ拡張の有無を明確にしてください。純粋なPythonパッケージでもネイティブパッケージに依存する場合があるため、最上位のプロジェクトだけを調べればよいわけではありません。

wheelのファイル名に含まれるプラットフォームタグは、互換性の宣言であって変換機能ではありません。arm64のファイル名をuniversal2に変えても、バイナリに別のアーキテクチャが追加されるわけではありません。

二、対象を明確にしたビルドから始める

まず、dist に前回のパッケージが残っていないことを確認し、今回の検証対象を生成します。ビルドバックエンドとそのバージョンは、プロジェクトで採用している制約管理の仕組みで固定してください。デフォルトのビルド分離だけでは、すべてのビルド依存関係が自動的に固定されるわけではありません。

mkdir -p dist
find dist -maxdepth 1 -name '*.whl' -print
.wheel-build/bin/python -m pip wheel \
  --no-deps --wheel-dir dist .

最初の確認で古いファイルが表示された場合は、先に専用のアーカイブディレクトリへ移し、今回の検証対象と混在させないでください。--no-deps は、実行時依存パッケージのwheelを生成しないという指定にすぎません。ビルド中の依存パッケージのダウンロードを禁止するものではありません。

次の確認では、ディレクトリ内にwheelがちょうど1つだけあることを要求し、内部のメタデータを出力します。

.wheel-build/bin/python - <<'PY'
from pathlib import Path
from zipfile import ZipFile

wheels = list(Path("dist").glob("*.whl"))
if len(wheels) != 1:
    raise SystemExit("Expected exactly one wheel")
wheel = wheels[0]
print(wheel.name)
with ZipFile(wheel) as archive:
    metadata = [
        name for name in archive.namelist()
        if name.endswith(".dist-info/WHEEL")
    ]
    if len(metadata) != 1:
        raise SystemExit("Invalid wheel metadata")
    print(archive.read(metadata[0]).decode("utf-8"))
PY

Tag とファイル名の宣言が、配布対象の条件に合っているか確認します。py3-none-any は通常、プラットフォームに依存しないパッケージに使われます。パッケージ内にネイティブ実行ファイルが含まれているなら、タグをそのまま信用するのではなく、パッケージング方法を見直す必要があります。

三、ネイティブライブラリがビルドマシンに依存していないか調べる

wheelを一時ディレクトリに展開し、拡張モジュールと共有ライブラリを調べます。以下のコードは検査用であり、パッケージ内のプログラムは実行しません。

inspect_dir="$(mktemp -d)"
.wheel-build/bin/python - "$inspect_dir" <<'PY'
from pathlib import Path
from zipfile import ZipFile
import sys

wheel, = Path("dist").glob("*.whl")
with ZipFile(wheel) as archive:
    archive.extractall(sys.argv[1])
PY
find "$inspect_dir" -type f \
  \( -name '*.so' -o -name '*.dylib' \) \
  -exec file {} \; -exec otool -L {} \;

この展開手順は、自分のプロジェクトで生成した信頼できる成果物に対して使用してください。出所不明のアーカイブを、そのまま作業ディレクトリへ展開してはいけません。

特に確認するのは、ユーザーのホームディレクトリ、ビルド用ワークスペース、ローカルのパッケージマネージャーのプレフィックスです。これらの絶対パスは、別のマシンには存在しないことがよくあります。システムライブラリのパスを一律に誤りと判断してはいけません。一方、@rpath や @loader_path が使われているだけで正しいとも限りません。実際に同梱されているライブラリと、その読み込みパスの設定も確認する必要があります。

修正したら、必ず再ビルドして検証し直します。展開先のファイルだけを修正し、未修正の元のwheelを配布しないでください。確認が終わったら、今回作成した一時ディレクトリを削除します。

四、ソースディレクトリの外で実際にインストールする

まず、wheelとすべての実行時依存パッケージを専用ディレクトリに用意します。この手順ではネットワーク接続が発生する場合があります。依存パッケージのバージョンはプロジェクトのリリース用制約で管理し、最終的にダウンロードしたファイルの一覧を保存してください。

mkdir -p wheelhouse
.wheel-build/bin/python -m pip download \
  --only-binary=:all: --dest wheelhouse dist/*.whl

いずれかの依存パッケージに適合するバイナリパッケージがなければ、この段階で失敗させるべきです。黙ってソースからのビルドに切り替えてはいけません。それでは検証対象が変わってしまいます。

次はソースツリーの外に環境を作成します。実行時には、プロジェクトの実際のインポート名を入力してください。配布パッケージ名と異なる場合があります。

read -r -p "Import module: " module
wheelhouse="$(pwd)/wheelhouse"
wheel="$(find "$(pwd)/dist" -maxdepth 1 -name '*.whl' -print)"
sandbox="$(mktemp -d)"
python3 -m venv "$sandbox/venv"
(
  cd "$sandbox"
  ./venv/bin/python -m pip install \
    --no-index --find-links "$wheelhouse" "$wheel"
  ./venv/bin/python -m pip check
  ./venv/bin/python -I - "$module" <<'PY'
import importlib
import sys

module = importlib.import_module(sys.argv[1])
print(getattr(module, "__file__", None))
print(list(getattr(module, "__path__", [])))
PY
)

インポート元がソースツリーではなく、新しい環境内にあることを確認します。pip check が確認するのは、宣言された依存関係の整合性だけです。ネイティブライブラリの読み込みは検証せず、遅延読み込みされる機能も呼び出しません。そのため、拡張モジュールを実際に使用する処理も一度実行してください。たとえば、固定の小さなサンプルを処理し、結果をアサーションで確認します。

五、合格条件をリリース記録に残す

毎回、少なくともコミットID、インタープリター情報、wheelのファイル名、ハッシュ値、依存パッケージの一覧、業務処理のスモークテスト結果を保存します。shasum -a 256 dist/*.whl でハッシュ値を記録し、配布するファイルが、直前にテストしたものと同一であることを確認してください。

検証に失敗したら、問題の層ごとに対処します。タグが合わなければビルド対象の設定に戻り、共有ライブラリが不足していれば依存ライブラリの収集を見直します。ソースディレクトリ内でしかインポートできない場合は、パッケージの検出ルールとリソースの宣言を確認してください。検索パスを広げて、ファイルの欠落を隠してはいけません。

最後に、サポートを宣言している最低バージョンのOSと、ほかの対象インタープリターでも、インストールと実行を繰り返します。新しいOSで一度成功しても、その組み合わせで合格したことしか証明できません。より広い互換性を示すタグを付けるよりも、検証範囲を明記するほうが確実です。

よくある質問

Apple Siliconでビルドできればuniversal2にできますか?

できません。対象のネイティブバイナリにarm64とx86_64が含まれ、それぞれの実行検証が必要です。ファイル名の変更では対応できません。

なぜソースツリーの外でインポートするのですか?

ソース側のモジュールが読み込まれると、wheelの同梱漏れを見逃すためです。一時ディレクトリの新しい仮想環境にインストールして検証します。

専用のクラウドMacに作業を任せよう

専用の物理 Mac mini をレンタルして、用途に合わせてロケーションと利用期間を選べます。利用開始後は管理画面で接続情報を確認できます。ご利用中に問題が発生した場合は、管理画面からサポートチケットをお送りください。

クラウドMacを選ぶ

用途に合わせてロケーションを選ぶ

構成と利用期間を確認して、専用の物理 Mac mini をお申し込みください。