雑記: Pipfile.lock の冒頭のハッシュ値の話

【この記事の内容】Pipfile.lock の冒頭のハッシュ値は、Pipfile のファイルハッシュ値ではなく、Pipfile の内容をキーでソートした JSON 文字列を UTF-8 形式でバイト列にしたもののハッシュ値というだけです。


例えば以下のような Pipfile を記述します。「toml(バージョン任意)と、transformers(バージョン 4.11.0)をインストールしてほしい」という指示になると思います。


[[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [packages] toml = "*" transformers = "==4.11.0" [requires] python_version = "3.7"

この Pipfile を用意した場所で pipenv install と実行すると指示通りにパッケージがインストールされ、具体的にどんなバージョンのどんなパッケージをインストールしたかが Pipfile.lock なるロックファイルに書き出されると思います。


{ "_meta": { "hash": { "sha256": "f520c9e18ab7cc36c8372db18726c3fc971f2194ad3fb15f5da73d32759b0855" }, "pipfile-spec": 6, "requires": { "python_version": "3.7" }, ... }, "default": { ... "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], "index": "pypi", "version": "==0.10.2" }, ...

この Pipfile.lock で個々のパッケージ(ここでは toml )の箇所に記録されているハッシュ値は、「このファイルからインストールしたよ」というハッシュ値だと思います。実際、toml-0.10.2-py2.py3-none-any.whl と toml-0.10.2.tar.gz の SHA256 値と一致しています(https://pypi.org/project/toml/#files の Hashes の view で確認できます)。

他方、Pipfile.lock の冒頭の _meta フィールドにあるハッシュ値(f520c9e...)は Pipfile のハッシュ値だと思います。Pipfile と Pipfile.lock がどちらもある場所で pipenv install を実行したとき、pipenv さんは Pipfile が Pipfile.lock 冒頭のハッシュ値に合致するか確認して、合致していたら「このロックファイルは既に Pipfile の指示にしたがった結果やから、ロックファイル通りの環境を維持(復元)やな」となるし、合致していなかったら「指示が新しくなっとるから改めて指示にしたがって環境構築やな」となると思います。

だから Pipfile の SHA256 値と合致しているんだろうなあと思ったら別に合致していないことがわかります。以下の値は f520c9e... になっていません。

$ sha256sum Pipfile
776c284b1781786406b8d94fb33f53c0d4be0715077893b7e137c99c5e8ef5da  Pipfile

そうなると f520c9e... とは何なのか気になると思います。pipenv のリポジトリをみると以下で Pipfile のハッシュ値を生成していそうな気がします。
https://github.com/pypa/pipenv/blob/v2021.5.29/pipenv/project.py#L1081-L1084

    def calculate_pipfile_hash(self):
        # Update the lockfile if it is out-of-date.
        p = pipfile.load(self.pipfile_location, inject_env=False)
        return p.hash

pipfile.load という関数は以下にあります。
https://github.com/pypa/pipenv/blob/v2021.5.29/pipenv/patched/pipfile/api.py#L222-L230

def load(pipfile_path=None, inject_env=True):
    """Loads a pipfile from a given path.
    If none is provided, one will try to be found.
    """

    if pipfile_path is None:
        pipfile_path = Pipfile.find()

    return Pipfile.load(filename=pipfile_path, inject_env=inject_env)

実際、上のリンク先の api.py を手元にとってきて以下を実行すると、 f520c9e... が出力されます(toml パッケージが必要なのでいましがた構築した pipenv 環境で実行します)。

from api import load

p = load('./Pipfile')
print(p.hash)
$ pipenv run python hoge.py
f520c9e18ab7cc36c8372db18726c3fc971f2194ad3fb15f5da73d32759b0855

api.py から Pipfile をハッシュ値にする処理だけ抜き出すと以下であることがわかります。何のことはない、Pipfile を dict 型として読み込んで、キーでソートした JSON 文字列にダンプし、その文字列を UTF-8 形式でバイト列にしたもののハッシュ値をとっているだけです。考えてみればこれは当然で、Pipfile は TOML 形式で記述されているので、ファイルハッシュ値をとってしまうと、意味のない空行や空白の挿入/削除、意味のないパッケージの順序の入れ替えでもハッシュ値が変わってしまいます。そのような意味のない差異を吸収する必要があるわけです。

import toml
import json
import hashlib

with open('./Pipfile') as f:
    content = f.read()

config = toml.loads(content)
if 'dev-packages' not in config:
    config['dev-packages'] = {}

data = {
    '_meta': {
        'sources': config['source'],
        'requires': config['requires']
    },
    'default': config['packages'],
    'develop': config['dev-packages'],
}

content = json.dumps(data, sort_keys=True, separators=(",", ":"))
hash_value = hashlib.sha256(content.encode("utf8")).hexdigest()
print(hash_value)
$ pipenv run python fuga.py
f520c9e18ab7cc36c8372db18726c3fc971f2194ad3fb15f5da73d32759b0855

試しに Pipfile に意味のない空行や空白の挿入/削除、意味のないパッケージの順序の入れ替えをして pipenv install を実行してみます。期待通り、Pipfile.lock は更新されず(更新されるときは Pipfile.lock out of date, updating to ... などというメッセージになります)、Pipfile.lock 通りの環境がインストールされます(以下の実行結果の 9b0855 はハッシュ値の下6桁です)。

$ pipenv install
Installing dependencies from Pipfile.lock (9b0855)...
  🐍   ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 0/0 — 00:00:00
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run.