2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Python で保存する Excel ファイルのハッシュ値を固定する

Posted at

(結論だけ知りたい方は「解決方法」のセクションまでお進みください)

はじめに

Excel ファイルは、表形式の情報を整理する用途では(特に「テキストファイル」に馴染みのない方たちにとっては)もっとも親しまれている形式です。

いっぽうで各種プログラミング言語から読み書きするためのライブラリも存在しており、こと「非エンジニアに情報を提示する」という目的に絞れば、Excel ファイルで出力するのは悪い選択肢ではありません。

ところが、そのファイルを Git 管理しようとするとさぁ大変。中身が全く同じでも、バイナリレベルでは異なる Excel ファイルが生成されてしまうことがあるのです。おかげで「何も変更していないのにコンフリクトした!」という事象が多々発生します。

分かっています、Excel ファイルを Git 管理しようとするのが正気の沙汰ではないことくらい。でもそういう案件があったんですよ。本当に。そのときに学んだことを供養させてください。

素朴に書き出すと何が問題か

ここでは Python 3 で openpyxl(記事執筆時点で 3.0.6 が最新)を使ってファイルを書き出すことにします。バージョン管理が Mercurial なので一瞬面食らいますが、今でもきちんとメンテされているようですし、Pandas でも採用されているので問題ないでしょう。

こんな感じに、ちょっと間を置いて2回ファイルを書き出してみます。

from time import sleep

from openpyxl import Workbook


if __name__ == "__main__":
    book = Workbook()
    book.active["A1"] = "Hello world"

    book.save("hello1.xlsx")
    sleep(1)
    book.save("hello2.xlsx")

書き出したファイルのハッシュ値を比較してみると、異なっているのが分かります。

$ sha1sum hello*.xlsx
091a1922ac5f2dc58e91ac3bbae32e3ca58c5ca5  hello1.xlsx
ed51a9901eed0a5d2f2c21dd6664bb8a07a9563c  hello2.xlsx

ファイルの中身が同じなら、ハッシュ値も全くおなじになっていてほしいですよね。
それをどう実現するか、というのが本記事の趣旨です。

解決方法:タイムスタンプの固定

種明かしをしてしまえば、ファイルコンテンツに含まれるタイムスタンプを固定するというのが解決方法になります。テキストファイルであれば OS が管理するファイル属性にタイムスタンプが含まれるだけですが、Excel の場合はファイルの中身にもタイムスタンプが書き込まれているんですね。

下記のようにすれば、常に完全に同一な(バイナリレベルで一致する)Excel ファイルが生成できます。

from datetime import datetime
from time import sleep
from zipfile import ZipFile

from openpyxl import Workbook


def save_workbook(book: Workbook, path: str) -> None:
    # タイムスタンプをこの値に固定する
    timestamp = datetime(1980, 1, 1, 00, 00, 00)

    # Excel 管理のタイムスタンプ固定
    book.properties.created = timestamp
    book.properties.modified = timestamp

    # ファイル保存
    book.save(path)

    # ZIP 管理のタイムスタンプ固定
    with ZipFile(path, mode="a") as f:
        for info in f.infolist():
            info.date_time = timestamp.timetuple()[:6]
            f.fp.seek(info.header_offset)
            # ファイルの途中だが、タイムスタンプ変更してもヘッダー長変わらないので OK
            f.fp.write(info.FileHeader())
        f._didModify = True


if __name__ == "__main__":
    book = Workbook()
    book.active["A1"] = "Hello world"

    save_workbook(book, "hello_fixed1.xlsx")
    sleep(1)
    save_workbook(book, "hello_fixed2.xlsx")

固定できていますね!

$ sha1sum hello_fixed*.xlsx
49f5b2f7b9e637ded7c3d81bdf2522b0d14d2e79  hello_fixed1.xlsx
49f5b2f7b9e637ded7c3d81bdf2522b0d14d2e79  hello_fixed2.xlsx

処理内容について、簡単に解説したいと思います。

なお前提知識として「xlsx ファイルは XML ファイルを Zip で固めたもの」というのを知っておくといいでしょう。

$ unzip -d hello_fixed1.xlsx.d hello_fixed1.xlsx
Archive:  hello_fixed1.xlsx
  inflating: hello_fixed1.xlsx.d/docProps/app.xml
  inflating: hello_fixed1.xlsx.d/docProps/core.xml
  inflating: hello_fixed1.xlsx.d/xl/theme/theme1.xml
  inflating: hello_fixed1.xlsx.d/xl/worksheets/sheet1.xml
  inflating: hello_fixed1.xlsx.d/xl/styles.xml
  inflating: hello_fixed1.xlsx.d/_rels/.rels
  inflating: hello_fixed1.xlsx.d/xl/workbook.xml
  inflating: hello_fixed1.xlsx.d/xl/_rels/workbook.xml.rels
  inflating: hello_fixed1.xlsx.d/[Content_Types].xml

参考:

Excel 管理のタイムスタンプ固定

1つ目は Excel のレイヤーで管理されているプロパティで、作成日時と更新日時が記録されています。

技術的に言うと docProps/core.xml というファイルに記載されています。ファイルの中身を覗いてみるとこんな感じ(整形済み)。

<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <dc:creator>openpyxl</dc:creator>
    <dcterms:created xsi:type="dcterms:W3CDTF">1980-01-01T00:00:00Z</dcterms:created>
    <dcterms:modified xsi:type="dcterms:W3CDTF">1980-01-01T00:00:00Z</dcterms:modified>
</cp:coreProperties>

openpyxl ではこれは Workbook#properties.created, Workbook#properties.modified 属性で設定されるようですので(ソース: workbook/workbook.py#L67, packaging/core.py#L76-78)、それを保存直前に上書きしてあげればいいという寸法です。

book.properties.created = timestamp
book.properties.modified = timestamp

book.save(path)
# ...

ZIP 管理のタイムスタンプ固定

上記によって「ZIP 圧縮前」のファイルのレベルでは完全に固定できましたが、最終的な xlsx ファイルはまだ固定しきれていません。

それは ZIP ファイルのレイヤーでもタイムスタンプを保持しているからです。

そのタイムスタンプを固定するために、いったん Workbook#save() したあとに zipfile.Zipfile を使ってファイルを開き直してタイムスタンプを書き換えます(save() をいじって保存処理に手を加えようとしたのですが、思ったより該当処理が深かったため、トリッキーなこの形になりました)。

なお ZIP ファイルの形式は MS-DOS 由来なため、タイムスタンプの起点は1980年1月1日です。それ以前の日付は保持することができないため、固定値を UNIX timestamp の起点である1970年1月1日にするとエラーになるので注意してください。

まずファイルを mode="a" で開きます("w" だと中身が消えてしまいます)。

# ...
book.save(path)

with ZipFile(path, mode="a") as f:

開くと各ファイルのプロパティ情報が zipfile.ZipInfo クラスで保持されているので、この date_time 属性を変更します。

     for info in f.infolist():
        info.date_time = timestamp.timetuple()[:6]

変更した値を反映する必要があるのですが、注意点として、ZIP のファイルプロパティは2箇所に重複して書き込まれています。齟齬がある場合は後者が尊重されるようですが、ファイルを固定するという意味では両方に反映する必要がありますね。

  • ローカルファイルヘッダー……各ファイルエントリーの先頭に書き込まれたプロパティ
  • セントラルディレクトリ……ZIP ファイル全体の末尾に書き込まれたプロパティ

参考:

ローカルファイルヘッダーについては、各ファイルエントリーごとに該当箇所まで seek して write します。

        f.fp.seek(info.header_offset)
        # ファイルの途中だが、タイムスタンプ変更してもヘッダー長変わらないので OK
        f.fp.write(info.FileHeader())

セントラルディレクトリについては ZipFile#_didModifyTrue に設定しておくことで、自動で反映されるようです(ソース: Lib/zipfile.py#L1821-L1825)。

    f._didModify = True

おわりに

以上、ファイルの中身が同じならハッシュ値も同じになるように保存する方法を解説しました。

ただし執筆にあたっては Excel や ZIP の仕様には当たっておらず、実装レベルで試行錯誤した結果となります。書き出しに用いたソフトウェアのバージョンや対象ファイルのサイズなどによってはうまく動かない可能性があることをご理解ください。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?