🐍

pycはいつ更新される【Python】

に公開

やったこと

Pythonを書いていた時、コードの修正が実行結果に反映されていないように見える事案がありました(実際は反映されていました)。

__pycache__にimportしたモジュールのコンパイル結果がキャッシュされるのは知っていたので、自分の変更によって中の.pycが本当に更新されているのかが気になり、CPythonでキャッシュの更新を判定するロジックを調査してみました。

pycacheについて

※CPython(Pythonの標準実装)をインタプリタとして使用する前提です。インタプリタに依存する話なので、PyPyやIronPython等の別のPython実装を利用する場合はまた変わると思います。

色々なサイトで解説されていますが、以下の記事が特に参考になりました。

https://zenn.dev/fastriver/articles/python-pyc-binary

pycacheは、メインスクリプトに直接的・間接的にimportされたモジュールのキャッシュです。以下はmain.pyでimportされているcalc.pyのキャッシュになります。

Pythonはスクリプトを一度バイトコードに変換してからPVM(Python Virtual Machine)上で逐次実行する形式になっています。pycacheではこの中間のバイトコード(より正確にはヘッダー情報+バイトコードとメタデータを含むコードオブジェクトがシリアライズされたバイナリ)が保存され、二度目以降の実行でコンパイルの時間を短縮できます。

コードを読む

キャッシュの更新ロジックがどうなっているのかを調べるために、CPythonのコードを読んでみました。

https://github.com/python/cpython

結果、cpython\Lib\importlib\_bootstrap_external.pyのL829にあるSourceLoader.get_codeが関わっていそうです。

    ...
    def get_code(self, fullname):
        """Concrete implementation of InspectLoader.get_code.

        Reading of bytecode requires path_stats to be implemented. To write
        bytecode, set_data must also be implemented.

        """
        ...

InspectLoader.get_codeの実装と書いてあります。InspectLoader.get_codeのdocstringは以下。

class InspectLoader(Loader):

    """Abstract base class for loaders which support inspection about the
    modules they can load.

    This ABC represents one of the optional protocols specified by PEP 302.

    """
    ...

    def get_code(self, fullname):
        """Method which returns the code object for the module.

        The fullname is a str.  Returns a types.CodeType if possible, else
        returns None if a code object does not make sense
        (e.g. built-in module). Raises ImportError if the module cannot be
        found.
        """
        ...    

InspectLoader.get_codeはモジュール名を入力としてそのモジュールのコードオブジェクトを返すメソッドであり、SourceLoader.get_codeではその具体的な処理が記述されているようです。

L891の_compile_bytecodeで、pycの内容をデシリアライズしてコードオブジェクトに変換し返却しているので、ここに至るまでの過程を見てみます。

                    else:
                        _bootstrap._verbose_message('{} matches {}', bytecode_path,
                                                    source_path)
                        return _compile_bytecode(bytes_data, name=fullname,
                                                 bytecode_path=bytecode_path,
                                                 source_path=source_path)

fullnameで入力されたモジュールに関して

  • .pycが存在するか
  • .pycのメタデータを取得できるか
  • データを読み出せるか

をチェックしたのち、L863以降で以下の三つのvalidatorによりキャッシュを検証しています。

  1. _classify_pyc(共通)
  2. _validate_hash_pyc(hash_basedがtrueの場合)
  3. _validate_timestamp_pyc(hash_basedがfalseの場合)

ここでImportErrorEOFErrorが発生した場合に、pycacheの更新が入るようです。すなわち、これらがpycache更新判定のコアのロジックになります。

_classify_pyc

def _classify_pyc(data, name, exc_details):
    """Perform basic validity checking of a pyc header and return the flags field,
    which determines how the pyc should be further validated against the source.

    *data* is the contents of the pyc file. (Only the first 16 bytes are
    required, though.)

    *name* is the name of the module being imported. It is used for logging.

    *exc_details* is a dictionary passed to ImportError if it raised for
    improved debugging.

    ImportError is raised when the magic number is incorrect or when the flags
    field is invalid. EOFError is raised when the data is found to be truncated.

    """
    ...

.pycのヘッダー部分をチェックし、4byteのflagsを返す処理になっています。

チェックとしては、以下を行っています。

  • Magic Numberのチェック:Magic Numberはコンパイルに使ったPythonのバージョンに依存する。現在使っているPythonと整合性が取れていない場合にImportError
  • ヘッダーの長さのチェック:.pycが16バイトより短い場合にEOFError
  • flags部分の形式チェック:flagsは# Only the first two flags are defined.であり、下位2bit以外に1が含まれている場合にImportError

返ったFlagsの値は、もとのget_codeで以下のようにhash_basedの算出で利用されています。

                        flags = _classify_pyc(data, fullname, exc_details)
                        bytes_data = memoryview(data)[16:]
                        hash_based = flags & 0b1 != 0

hash_basedはFlagの下位1bitで示されているようです。このtrue/falseにより次の_validate_hash_pyc_validate_timestamp_pycのどちらを使用するかが決定されています。

_validate_hash_pyc

def _validate_hash_pyc(data, source_hash, name, exc_details):
    """Validate a hash-based pyc by checking the real source hash against the one in
    the pyc header.

    *data* is the contents of the pyc file. (Only the first 16 bytes are
    required.)

    *source_hash* is the importlib.util.source_hash() of the source file.

    *name* is the name of the module being imported. It is used for logging.

    *exc_details* is a dictionary passed to ImportError if it raised for
    improved debugging.

    An ImportError is raised if the bytecode is stale.

    """
    if data[8:16] != source_hash:
        raise ImportError(
            f'hash in bytecode doesn\'t match hash of source {name!r}',
            **exc_details,
        )

どうやらソースからハッシュ値を出してソースコードの変更を検出しているみたいですね。

データの8~16バイト部分、すなわちヘッダーのFlags以降にある残りの8Byteがハッシュ値になっており、そこを比較しているようです。

_validate_timestamp_pyc

def _validate_timestamp_pyc(data, source_mtime, source_size, name,
                            exc_details):
    """Validate a pyc against the source last-modified time.

    *data* is the contents of the pyc file. (Only the first 16 bytes are
    required.)

    *source_mtime* is the last modified timestamp of the source file.

    *source_size* is None or the size of the source file in bytes.

    *name* is the name of the module being imported. It is used for logging.

    *exc_details* is a dictionary passed to ImportError if it raised for
    improved debugging.

    An ImportError is raised if the bytecode is stale.

    """
    if _unpack_uint32(data[8:12]) != (source_mtime & 0xFFFFFFFF):
        message = f'bytecode is stale for {name!r}'
        _bootstrap._verbose_message('{}', message)
        raise ImportError(message, **exc_details)
    if (source_size is not None and
        _unpack_uint32(data[12:16]) != (source_size & 0xFFFFFFFF)):
        raise ImportError(f'bytecode is stale for {name!r}', **exc_details)

タイムスタンプととソースのサイズで変更をチェックしているようです。ヘッダーの8~12バイトにタイムスタンプ情報、12~16バイトにソースのサイズの情報が入っている書き方なので、そもそもhash_basedのtrue/falseでヘッダーの構造が違うようですね。

調べると、Python 3.7の変更にHash-based .pyc Filesの項目がありました。

https://docs.python.org/3.7/whatsnew/3.7.html#pep-552-hash-based-pyc-files

PEP 552 extends the pyc format to allow the hash of the source file to be used for invalidation instead of the source timestamp. Such .pyc files are called “hash-based”. By default, Python still uses timestamp-based invalidation and does not generate hash-based .pyc files at runtime. Hash-based .pyc files may be generated with py_compile or compileall.

ここで変更が入っているみたいです。

結論

pycacheの主な更新判定は以下によって行われるようです。

  • コンパイル時からPythonバージョンが変更されている場合
  • .pycの長さが16バイト未満の場合
  • Flagsヘッダーの形式が不正な場合
  • (Hash-Basedなバージョンで)ソースと.pycでハッシュ値が一致しない場合
  • (それ以外で)ソースと.pycでタイムスタンプとファイルサイズのデータが一致しない場合

結論として、私が可能性として考えて今回の調査の発端にもなった「ファイルを更新したのにpycacheが更新されない」という事象は、基本的には起きなさそうです。まあそうりゃそうだ。

Discussion