diff --git a/CLAUDE.md b/CLAUDE.md index 5d1b14f..99b75e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: PLAN_02 Step 1(設定と目録の土台・`tiasdata init`)完了.次は Step 2(ストア) +最新: PLAN_02 Step 2(ストア)完了.次は Step 3(取り込みと点検) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index b8a0087..e2c4592 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -13,8 +13,8 @@ | Step | 内容 | 完了条件 | | --- | --- | --- | | 1 | **設定と目録の土台**: `config.py`(設定ファイルの読み込み・検証),`catalog/`(接続・マイグレーションの適用),`catalog/migrations/catalog/0001`(SPEC_02 の全表と最初の種類)・`identity/0001`(対応表),`tiasdata init`(2026-10-06 完了) | 一時ディレクトリの設定ファイルから `init` で目録ができ,`PRAGMA user_version` が最新になる.テスト(設定ファイル・マイグレーション)が通る | -| 2 | **ストア**: `store.py`(ハッシュ・コピー・照合),テスト用のダミーデータ(小さい画像・取り込み元のフォルダ)を作る `conftest.py` | 同じ中身が 1 つにまとまり,コピー後の照合・上書きしないことがテストで確かめられる | -| 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check` | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.出力に患者 ID・元のファイル名が出ない | +| 2 | **ストア**: `store.py`(ハッシュ・コピー・照合),テスト用のダミーデータ(小さい画像・取り込み元のフォルダ)を作る `conftest.py`(2026-10-06 完了) | 同じ中身が 1 つにまとまり,コピー後の照合・上書きしないことがテストで確かめられる | +| 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check` | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.出力に患者 ID・元のファイル名が出ない.`check` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | | 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む | | 5 | **バンドル**: `bundle.py`(書き出し・読み込み・検証),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる | | 6 | **研究室 PC での運用開始**: 立ち上げの成果物と Step 1〜5 を `main` に取り込む → 研究室 PC に GitHub から clone・設定ファイル作成・`init` → 舌色データ(1085 枚)を取り込み → `check`・`stats` → 版 1 を作成 → `tiasdata backup` | すべて手元 PC から SSH で行える.件数が 1085 枚・9 グループで TIASJudgeModel の TEST_01 と一致し,症例が約 450(2026-10-03 の照合値)になる.`check` が問題 0 件 | @@ -36,5 +36,6 @@ | DeepTIAS の派生データ | 撮影画像・カラーチャート画像と,DeepTIAS の出力(色補正後の画像・マスク・`analysis.csv`)を由来付きで登録する.旧 exe(2021 年 12 月版)と現行ソースの出力を版で区別する.取り込み元は整理済みの `DeepTIAS_TIASJudgeModel\<番号>\`(382・509・516 は元の出力ではなくコピーである点を由来に記録する) | | 実際の患者 ID を持つデータ | 対応表(`identity.sqlite`)を使う取り込み.同じ患者の複数枚・経時的な撮影.倫理審査の保管ルールの確認が前提 | | 他の所見 | 苔の色・厚さ,問診・体質等の種類を追加する | -| バックアップ | 目録・ストアを別の媒体に退避する方法を決めて手順化する | +| バックアップ | 目録・ストアを別の媒体に退避する方法を決めて手順化する(ストアのファイルは読み取り専用である点に注意) | +| データの削除 | 同意の撤回等でデータを本当に消す必要がある場合の専用コマンド(例: `tiasdata purge`).影響する版を確かめ,削除を目録に記録してから,読み取り専用を外してストアから消す(SPEC_01「ストアの規則」) | | TIASjudge・DeepTIAS からの利用 | 使う側を増やす | diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index 50ea975..73ddc3b 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -28,13 +28,26 @@ | 要素 | 役割 | | --- | --- | | 目録(`catalog.sqlite`) | 症例・撮影・ファイル・ラベル・由来・データセットの版・受け渡しの記録([SPEC_02](SPEC_02_データモデル.md)) | -| ストア(`store/`) | 登録したファイルの実体.中身の SHA-256 をファイル名にし,変更しない | +| ストア(`store/`) | 登録したファイルの実体.中身の SHA-256 をファイル名にし,変更しない(下記「ストアの規則」) | | 対応表(`identity.sqlite`) | 実際の患者 ID ↔ 研究用 ID.目録とは別のファイル・別のフォルダに置く.現在の舌色データは対応表の xlsx から作る | | バンドル | データセットの 1 つの版を,manifest とファイルのコピーにまとめたもの.計算サーバ・Python 以外のツールへ渡す単位([SPEC_03](SPEC_03_データセットと受け渡し.md)) | | ライブラリ(`tiasdata`) | 目録の操作(研究室 PC)と,バンドルの読み込み・検証(どこでも) | | CLI(`tiasdata`) | 取り込み・データセットの版の作成・バンドルの書き出し・受け渡し・点検(研究室 PC) | | データセットの定義(`datasets/*.toml`) | どのファイル・ラベルを集めるかの条件.個人情報を含まないので**リポジトリで管理**し,PR で変更する | +### ストアの規則 (Store Rules) + +`src/tiasdata/store.py` が担う(2026-10-06 実装). + +- 置き場所は `store//.<拡張子(小文字)>`.目録には保存せず,SHA-256 と拡張子から決める +- 取り込みは「一時ファイル(`.tmp-` で始まる名前)へハッシュを計算しながらコピー → 読み直して照合 → 正式な名前に変える」の順で行い,書きかけのファイルを正式な名前で残さない.失敗したら一時ファイルを消す +- 同じ中身が既にあればコピーしない.同じ置き場所に中身の違うファイルがあれば上書きせずに停止する +- 原本(取り込み元)は読むだけで,中身・更新日時・属性を変えない +- **ストアに置いたファイルは読み取り専用にする**(手作業・他のプログラムによる誤った書き換え・削除を防ぐため.2026-10-06 のユーザー判断).同じ中身を再び取り込んで既存のファイルを再利用するときも付け直す.属性が外れたファイルは `tiasdata check` が数を報告する.バックアップや手での削除では,この属性を意識する +- ファイルを直したい・使わないようにしたい場合も,ストアのファイルは書き換えない.直したファイルは新しいファイルとして由来付きで登録し,ラベルは所見の訂正で,除外はデータセットの定義と新しい版で行う.データを本当に消す必要がある場合(同意の撤回等)は,専用のコマンドで影響する版を確かめ,記録を残してから消す(未実装.PLAN_02「マイルストーン 2 以降の候補」) +- store フォルダが無いときは作らずに停止する(`tiasdata init` で作る.設定の誤りで別の場所にストアが黙って作られるのを防ぐため) +- エラーメッセージにはパス・元のファイル名・拡張子を出さず,SHA-256 で示す + ## 配置 (Locations) ### リポジトリ (Repository) diff --git "a/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" "b/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" index 9443b80..70dae25 100644 --- "a/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" +++ "b/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" @@ -97,7 +97,7 @@ | `registered_at` | TEXT | 登録日時 | - ストア上のパスは `store//.` で決まるため,目録には保存しない -- 同じ中身のファイルを 2 度取り込むと,同じ行を指す(重複登録しない).ただし別の撮影・別の種類として登録しようとした場合は停止する +- 同じ中身のファイルを 2 度取り込むと,同じ行を指す(重複登録しない).ただし別の撮影・別の種類・**別の拡張子**として登録しようとした場合は停止する(ストアでは拡張子が違うと別のファイルになるが,assets は SHA-256 で 1 行のため) ### derivations・derivation_inputs・derivation_outputs(由来) diff --git "a/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" "b/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" index 7d016ce..d088f5e 100644 --- "a/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" +++ "b/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" @@ -113,7 +113,7 @@ | `tiasdata dataset freeze <名前>` | 新しい版を作る | | `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す | | `tiasdata deliver <名前> --version <版> --to <サーバ名>` | 計算サーバへ渡す | -| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け.件数だけを表示する | +| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,読み取り専用が外れたストアのファイル.件数だけを表示する | | `tiasdata stats` | 種類・所見の値ごとの件数 | | `tiasdata backup` | 目録を `backups/catalog-<日時>.sqlite` に複製する(SQLite のバックアップ API を使う) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c68a8e4..d73df2e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -28,3 +28,7 @@ - [x] **PLAN_02 Step 1: 設定と目録の土台**: 設定ファイルの読み込み・検証,SQLite の目録と対応表のマイグレーション(SPEC_02 の全表と最初の種類),`tiasdata init` を実装(2026-10-06) - 設計判断: マイグレーションはリポジトリ直下ではなくパッケージ内(`src/tiasdata/catalog/migrations/`)に置く(サブモジュール・パッケージとして使ったときも見つけられるようにするため).目録と対応表は別系列で版を管理する.凍結した版・所見の書き換え禁止,種類の整合,書式(日時は秒まで)を SQL の CHECK・トリガで強制する(SPEC_02「スキーマで強制する規則」) - 失敗パターン: ① SQLite の CHECK は NULL を違反としないため,`value_schema_version >= 1` が NULL ですり抜けた/② INTEGER 以外の主キーに NULL が入った → どちらも `IS NOT NULL`・`NOT NULL` を明示して解決.③ Windows でパイプ・SSH 越しの出力が cp932 で化けた → CLI の入口で標準出力・標準エラーを UTF-8 に切り替えた.④ `ruff format .` が docs/ の Markdown 内のコード例を整形した → `extend-exclude` に docs を追加 +- [x] **PLAN_02 Step 2: ストア**: SHA-256 名のストアへの取り込み(一時ファイル → 照合 → 正式な名前)・点検用の照合と,テスト用のダミーデータ(舌色の 9 フォルダ構成)を実装(2026-10-06) + - 設計判断: ストアのファイルは読み取り専用にする(誤操作対策.「直したい」場合も書き換えず,新しいファイル・所見の訂正・新しい版で扱う.本当に消す場合は将来の専用コマンドで記録を残して消す).store フォルダが無ければ作らずに止める.同じ中身で拡張子が違うファイルは Step 3 の取り込みで止める + - 追加の判断: 既にあるファイルを再利用したとき(同じ中身を再び取り込んだとき)も読み取り専用を付け直す(バックアップからの復元・手作業等で属性が外れたファイルを守り直すため).取り込みで触らないファイルの属性は,Step 3 の `check` で書き込み可能なファイルの数として報告する + - 未実装・後段: POSIX(os.link)の分岐は Windows では未検証 diff --git a/src/tiasdata/store.py b/src/tiasdata/store.py new file mode 100644 index 0000000..ce48379 --- /dev/null +++ b/src/tiasdata/store.py @@ -0,0 +1,237 @@ +"""ストア(登録したファイルの実体)のハッシュ・コピー・照合. + +ストアのファイルは中身の SHA-256 を名前にし,一度置いたら書き換えない・消さない +(SPEC_01 基本方針 2「登録したファイルは変えない」).置き場所は +`store//.<拡張子>` で中身から決まるため,目録には保存しない +(SPEC_02「assets」). + +例外メッセージ・ログにはパス(データの場所)や元のファイル名を出さず,SHA-256 で示す. +""" + +import hashlib +import logging +import os +import re +import stat +import tempfile +from enum import StrEnum +from pathlib import Path +from typing import NamedTuple + +logger = logging.getLogger(__name__) + +# 大きいファイルでもメモリを使い切らないよう分割して読む +CHUNK_SIZE = 1024 * 1024 + +# 書きかけの一時ファイルの名前の接頭辞.正式な名前(.)と区別でき, +# 点検(`tiasdata check`)で書きかけの残りを見分けられるようにする +TEMP_PREFIX = ".tmp-" + +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_EXT_PATTERN = re.compile(r"^[a-z0-9]+$") + + +class StoredFile(NamedTuple): + """ストアへの取り込みの結果. + + Attributes: + sha256: 中身の SHA-256(小文字の 16 進 64 文字). + ext: 拡張子(小文字.先頭の `.` なし). + size_bytes: サイズ. + copied: 今回コピーしたか(同じ中身が既にあったときは False). + """ + + sha256: str + ext: str + size_bytes: int + copied: bool + + +class StoredFileStatus(StrEnum): + """ストアのファイルの照合の結果.""" + + OK = "ok" + MISSING = "missing" + MISMATCH = "mismatch" + + +def normalize_ext(ext: str) -> str: + """拡張子を小文字・先頭の `.` なしの形にそろえる. + + Args: + ext: 拡張子(`.PNG`・`png` 等). + + Returns: + 小文字の拡張子(例: `png`). + + Raises: + ValueError: 空,または英数字以外を含む. + """ + normalized = ext.removeprefix(".").lower() + if not _EXT_PATTERN.match(normalized): + # 拡張子そのものは出さない(元のファイル名の一部のため) + raise ValueError("拡張子が無い,または英数字以外を含むファイルはストアに置けない") + return normalized + + +def store_path(store_dir: Path, sha256: str, ext: str) -> Path: + """ストア上のパスを決める(ファイルには触れない純関数). + + Args: + store_dir: ストアのフォルダ(`/store`). + sha256: 中身の SHA-256(小文字の 16 進 64 文字). + ext: 拡張子(`.PNG` 等も小文字・先頭の `.` なしにそろえる). + + Returns: + `store_dir//.`. + + Raises: + ValueError: SHA-256 の形が誤っている,または拡張子が不正. + """ + if not _SHA256_PATTERN.match(sha256): + raise ValueError("SHA-256 は小文字の 16 進 64 文字で指定する") + return store_dir / sha256[:2] / f"{sha256}.{normalize_ext(ext)}" + + +def sha256_file(path: Path) -> str: + """ファイルの SHA-256 を計算する(読み取り専用で開き,分割して読む). + + Args: + path: 対象のファイル. + + Returns: + SHA-256(小文字の 16 進 64 文字). + """ + digest = hashlib.sha256() + with path.open("rb") as f: + while chunk := f.read(CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def verify_stored(store_dir: Path, sha256: str, ext: str) -> StoredFileStatus: + """ストアのファイルが期待する SHA-256 と一致するかを照合する(点検用). + + Args: + store_dir: ストアのフォルダ. + sha256: 期待する SHA-256. + ext: 拡張子. + + Returns: + 一致すれば `OK`,ファイルが無ければ `MISSING`,中身が違えば `MISMATCH`. + + Raises: + ValueError: SHA-256 の形が誤っている,または拡張子が不正. + """ + path = store_path(store_dir, sha256, ext) + if not path.is_file(): + return StoredFileStatus.MISSING + if sha256_file(path) != sha256: + return StoredFileStatus.MISMATCH + return StoredFileStatus.OK + + +def add_file(store_dir: Path, source: Path) -> StoredFile: + """取り込み元のファイルをストアに置く. + + 取り込み元は読み取り専用で開くだけで,中身・更新日時・属性を変えない. + 同じ中身が既にあれば(同じパスにあり照合が合えば)コピーしない. + 無ければ同じフォルダの一時ファイルへコピーし,書き込んだファイルの SHA-256 を照合してから + 正式な名前に変える(書きかけのファイルが正式な名前で残らないようにするため). + 置いたファイルには読み取り専用の属性を付ける.既存のファイルは上書きしない + (照合が合って再利用するときは,属性だけを読み取り専用に付け直す). + + Args: + store_dir: ストアのフォルダ(`tiasdata init` で作成済みであること). + source: 取り込み元のファイル. + + Returns: + SHA-256・拡張子・サイズ・今回コピーしたか. + + Raises: + ValueError: ストアのフォルダが無い,取り込み元がファイルでない,拡張子が不正, + コピー中に取り込み元が変わった,コピーしたファイルの照合が合わない, + または同じパスに中身の違うファイルが既にある. + """ + if not store_dir.is_dir(): + raise ValueError("ストアのフォルダが無い(先に tiasdata init を実行する)") + if not source.is_file(): + raise ValueError("取り込み元のファイルが無い,またはファイルではない") + ext = normalize_ext(source.suffix) + + sha256 = sha256_file(source) + target = store_path(store_dir, sha256, ext) + if target.exists(): + return _reuse_existing(target, sha256, ext) + + target.parent.mkdir(exist_ok=True) + tmp_path, copied_sha256, size_bytes = _copy_to_temp(source, target.parent) + try: + # 1 回目の計算からコピーまでの間に取り込み元が変わっていないか + if copied_sha256 != sha256: + raise ValueError(f"コピー中に取り込み元の中身が変わった(SHA-256 {sha256})") + # 書き込んだファイルを読み直して照合する(ディスクへの書き込みの誤りを検出するため) + if sha256_file(tmp_path) != sha256: + raise ValueError(f"ストアにコピーしたファイルの照合が合わない(SHA-256 {sha256})") + published = _publish(tmp_path, target) + finally: + tmp_path.unlink(missing_ok=True) + + if not published: + # 照合の間に同じ名前のファイルが置かれた(別の取り込みと同時に実行した等) + return _reuse_existing(target, sha256, ext) + _make_read_only(target) + logger.debug("ストアに追加した: %s", sha256) + return StoredFile(sha256=sha256, ext=ext, size_bytes=size_bytes, copied=True) + + +def _reuse_existing(target: Path, sha256: str, ext: str) -> StoredFile: + # 同じ名前のファイルは同じ中身のはず.違えばストアが壊れているので上書きせず止める + if not target.is_file() or sha256_file(target) != sha256: + raise ValueError( + f"ストアに同じ名前で中身の違うファイルがある(SHA-256 {sha256})." + "上書きせずに停止した.tiasdata check で点検する" + ) + # 復元・手作業等で読み取り専用が外れた正しいファイルを,取り込みのたびに守り直すため + _make_read_only(target) + return StoredFile(sha256=sha256, ext=ext, size_bytes=target.stat().st_size, copied=False) + + +def _copy_to_temp(source: Path, directory: Path) -> tuple[Path, str, int]: + # 一時ファイルは正式な名前と同じフォルダに作る(同じドライブ内の rename にするため) + digest = hashlib.sha256() + size_bytes = 0 + fd, tmp_name = tempfile.mkstemp(prefix=TEMP_PREFIX, dir=directory) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as dst, source.open("rb") as src: + while chunk := src.read(CHUNK_SIZE): + digest.update(chunk) + dst.write(chunk) + size_bytes += len(chunk) + dst.flush() + os.fsync(dst.fileno()) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path, digest.hexdigest(), size_bytes + + +def _publish(tmp_path: Path, target: Path) -> bool: + """一時ファイルを正式な名前にする.既に同じ名前があれば何もせず False を返す.""" + try: + if os.name == "nt": + # Windows の rename は移動先があると FileExistsError になり,上書きしない + os.rename(tmp_path, target) + else: + # POSIX の rename は黙って上書きするため,既存があれば失敗する link を使う + os.link(tmp_path, target) + except FileExistsError: + return False + return True + + +def _make_read_only(path: Path) -> None: + # 書き換え・削除の誤操作を防ぐ(Windows では読み取り専用の属性になる) + mode = path.stat().st_mode + path.chmod(mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)) diff --git a/tests/conftest.py b/tests/conftest.py index cb3298a..d5c5a52 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,89 @@ import sqlite3 from collections.abc import Iterator +from dataclasses import dataclass from pathlib import Path import pytest +from PIL import Image from tiasdata import catalog +# 舌色データセットの 9 フォルダ(フォルダ名 → label・severity). +# TIASJudgeModel の TEST_01「フォルダ構成とラベル対応」と同じ.フォルダの直下に <番号>.png を置く +TONGUE_COLOR_FOLDERS: dict[str, tuple[str, str]] = { + "淡紅(正常)": ("light_red", "normal"), + "暗赤紅_あり": ("dark_red", "strong"), + "暗赤紅_ややあり": ("dark_red", "mild"), + "紫_ややあり": ("purple", "mild"), + "紅_あり": ("red", "strong"), + "紅_ややあり": ("red", "mild"), + "紫_あり": ("purple", "strong"), + "淡白_あり": ("pale", "strong"), + "淡白_ややあり": ("pale", "mild"), +} + + +def write_dummy_png(path: Path, seed: int, size: tuple[int, int] = (8, 6)) -> Path: + """`seed` ごとに中身の違う小さい PNG を書く(同じ `seed` なら同じバイト列).""" + path.parent.mkdir(parents=True, exist_ok=True) + color = (seed * 37 % 256, seed * 59 % 256, seed * 83 % 256) + image = Image.new("RGB", size, color) + # 色だけだと seed が 256 周期で重なるため,1 画素に seed を埋めて中身を一意にする + image.putpixel((0, 0), (seed % 256, seed // 256 % 256, seed // 65536 % 256)) + image.save(path, format="PNG") + return path + + +@dataclass(frozen=True) +class DummyImage: + """ダミーの取り込み元の画像 1 枚.""" + + number: int + folder: str + label: str + severity: str + path: Path + + +@dataclass(frozen=True) +class DummyTongueColorSource: + """ダミーの舌色データセット(9 フォルダ × 数枚).""" + + root: Path + images: list[DummyImage] + + +@pytest.fixture +def make_png(): + """小さい PNG を書く関数(`make_png(path, seed)`).""" + return write_dummy_png + + +@pytest.fixture +def store_dir(tmp_path: Path) -> Path: + """空のストアのフォルダ(`tiasdata init` 後と同じ状態).""" + directory = tmp_path / "tiasdata" / "store" + directory.mkdir(parents=True) + return directory + + +@pytest.fixture +def tongue_color_source(tmp_path: Path) -> DummyTongueColorSource: + """舌色データセットと同じ構成のダミーの取り込み元(各フォルダ 2 枚・番号は 1 から連番). + + 対応表(xlsx)は含まない(Step 3 で足す). + """ + root = tmp_path / "source" / "TIASJudgeModel" + images: list[DummyImage] = [] + number = 1 + for folder, (label, severity) in TONGUE_COLOR_FOLDERS.items(): + for _ in range(2): + path = write_dummy_png(root / folder / f"{number}.png", seed=number) + images.append(DummyImage(number, folder, label, severity, path)) + number += 1 + return DummyTongueColorSource(root=root, images=images) + @pytest.fixture(autouse=True) def _isolate_config_env(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_conftest_fixtures.py b/tests/test_conftest_fixtures.py new file mode 100644 index 0000000..6098f13 --- /dev/null +++ b/tests/test_conftest_fixtures.py @@ -0,0 +1,55 @@ +"""テスト用のダミーデータ(conftest の fixture)の前提を確かめる.""" + +import hashlib +from pathlib import Path + +from conftest import TONGUE_COLOR_FOLDERS, write_dummy_png +from PIL import Image + + +def test_tongue_color_folders_has_nine_entries() -> None: + assert len(TONGUE_COLOR_FOLDERS) == 9 + labels = {label for label, _ in TONGUE_COLOR_FOLDERS.values()} + assert labels == {"light_red", "dark_red", "purple", "red", "pale"} + # normal は light_red だけ(SPEC_02 の組み合わせの制約) + for label, severity in TONGUE_COLOR_FOLDERS.values(): + assert (severity == "normal") == (label == "light_red") + + +def test_tongue_color_source_layout(tongue_color_source) -> None: + root: Path = tongue_color_source.root + folders = sorted(p.name for p in root.iterdir()) + assert folders == sorted(TONGUE_COLOR_FOLDERS) + for folder in TONGUE_COLOR_FOLDERS: + assert len(list((root / folder).iterdir())) == 2 + + images = tongue_color_source.images + assert len(images) == 18 + assert [img.number for img in images] == list(range(1, 19)) + for img in images: + assert img.path == root / img.folder / f"{img.number}.png" + assert img.path.is_file() + assert (img.label, img.severity) == TONGUE_COLOR_FOLDERS[img.folder] + + +def test_tongue_color_source_images_are_distinct_valid_pngs(tongue_color_source) -> None: + hashes = set() + for img in tongue_color_source.images: + hashes.add(hashlib.sha256(img.path.read_bytes()).hexdigest()) + with Image.open(img.path) as opened: + assert opened.format == "PNG" + assert len(hashes) == 18 + + +def test_write_dummy_png_is_deterministic_and_unique(tmp_path: Path) -> None: + a = write_dummy_png(tmp_path / "a" / "1.png", seed=300).read_bytes() + b = write_dummy_png(tmp_path / "b" / "1.png", seed=300).read_bytes() + c = write_dummy_png(tmp_path / "c" / "1.png", seed=300 + 256).read_bytes() + assert a == b + assert a != c + + +def test_store_dir_exists_and_is_empty(store_dir: Path) -> None: + assert store_dir.is_dir() + assert store_dir.name == "store" + assert list(store_dir.iterdir()) == [] diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..a4b87e4 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,621 @@ +"""ストア(SPEC_01 基本方針 2・SPEC_02「assets」,testing.md 必須対象 2). + +- 置き場所は `store//.<拡張子>` で中身から決まる +- 同じ中身は 1 つにまとまる.コピー後にハッシュを照合する.既存のファイルを上書きしない +- 原本(取り込み元)を変更しない.置いたファイルは読み取り専用にする +- 例外は ValueError.メッセージにパス・元のファイル名を出さない +""" + +import hashlib +import os +import stat +from pathlib import Path + +import pytest + +from tiasdata import store +from tiasdata.store import ( + CHUNK_SIZE, + TEMP_PREFIX, + StoredFile, + StoredFileStatus, + add_file, + normalize_ext, + sha256_file, + store_path, + verify_stored, +) + +SHA_A = "ab" + "0" * 62 + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _all_files(directory: Path) -> list[Path]: + return sorted(p for p in directory.rglob("*") if p.is_file()) + + +def _make_writable(path: Path) -> None: + path.chmod(path.stat().st_mode | stat.S_IWUSR) + + +def _write(path: Path, data: bytes) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _assert_no_leak(message: str, tmp_path: Path, *names: str) -> None: + assert str(tmp_path) not in message + assert tmp_path.as_posix() not in message + for name in names: + assert name not in message + + +# ---------------------------------------------------------------- normalize_ext + + +class TestNormalizeExt: + @pytest.mark.parametrize( + ("ext", "expected"), + [("png", "png"), (".png", "png"), (".PNG", "png"), ("JpG", "jpg"), (".tif2", "tif2")], + ) + def test_normalizes_to_lower_without_dot(self, ext: str, expected: str) -> None: + assert normalize_ext(ext) == expected + + @pytest.mark.parametrize("ext", ["", ".", "p-g", ".tar.gz", "pn g", "png/", "画像", "..png"]) + def test_rejects_empty_or_non_alnum(self, ext: str) -> None: + with pytest.raises(ValueError): + normalize_ext(ext) + + +# ---------------------------------------------------------------- store_path + + +class TestStorePath: + def test_layout_is_first_two_chars_then_sha_and_ext(self, tmp_path: Path) -> None: + path = store_path(tmp_path, SHA_A, "png") + assert path == tmp_path / "ab" / f"{SHA_A}.png" + + def test_ext_is_normalized(self, tmp_path: Path) -> None: + assert store_path(tmp_path, SHA_A, ".PNG") == tmp_path / "ab" / f"{SHA_A}.png" + + def test_is_pure_and_creates_nothing(self, tmp_path: Path) -> None: + missing = tmp_path / "no-store" + store_path(missing, SHA_A, "png") + assert not missing.exists() + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize( + "sha", + [ + "", + "ab", + "AB" + "0" * 62, # 大文字 + "a" * 63, + "a" * 65, + "g" * 64, + "../" + "a" * 61, + " " + "a" * 63, + ], + ) + def test_rejects_malformed_sha256(self, tmp_path: Path, sha: str) -> None: + with pytest.raises(ValueError): + store_path(tmp_path, sha, "png") + + def test_rejects_bad_ext(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + store_path(tmp_path, SHA_A, "") + + +# ---------------------------------------------------------------- sha256_file + + +class TestSha256File: + def test_matches_hashlib(self, tmp_path: Path) -> None: + data = b"tongue image dummy" + assert sha256_file(_write(tmp_path / "a.bin", data)) == _sha(data) + + def test_empty_file(self, tmp_path: Path) -> None: + assert sha256_file(_write(tmp_path / "e.bin", b"")) == _sha(b"") + + @pytest.mark.parametrize( + "size", [CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 2 * CHUNK_SIZE + 7] + ) + def test_across_chunk_boundary(self, tmp_path: Path, size: int) -> None: + data = bytes(i % 251 for i in range(size)) + assert sha256_file(_write(tmp_path / "big.bin", data)) == _sha(data) + + def test_does_not_modify_file(self, tmp_path: Path) -> None: + path = _write(tmp_path / "a.bin", b"abc") + before = path.stat() + sha256_file(path) + after = path.stat() + assert (after.st_mtime_ns, after.st_mode, after.st_size) == ( + before.st_mtime_ns, + before.st_mode, + before.st_size, + ) + + +# ---------------------------------------------------------------- add_file: 正常系 + + +class TestAddFile: + def test_first_add_copies_to_sha_named_path(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + data = source.read_bytes() + + result = add_file(store_dir, source) + + assert isinstance(result, StoredFile) + assert result.sha256 == _sha(data) + assert result.ext == "png" + assert result.size_bytes == len(data) + assert result.copied is True + target = store_dir / result.sha256[:2] / f"{result.sha256}.png" + assert target == store_path(store_dir, result.sha256, result.ext) + assert target.read_bytes() == data + assert sha256_file(target) == result.sha256 + assert _all_files(store_dir) == [target] + + def test_second_add_of_same_file_does_not_copy(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + first = add_file(store_dir, source) + target = store_path(store_dir, first.sha256, first.ext) + mtime = target.stat().st_mtime_ns + + second = add_file(store_dir, source) + + assert second == first._replace(copied=False) + assert _all_files(store_dir) == [target] + assert target.stat().st_mtime_ns == mtime + + def test_same_content_elsewhere_is_deduplicated(self, store_dir: Path, make_png) -> None: + a = make_png(store_dir.parent / "src" / "淡紅(正常)" / "1.png", seed=5) + b = make_png(store_dir.parent / "other" / "別名.png", seed=5) + assert a.read_bytes() == b.read_bytes() + + ra = add_file(store_dir, a) + rb = add_file(store_dir, b) + + assert ra.copied is True + assert rb.copied is False + assert ra.sha256 == rb.sha256 + assert len(_all_files(store_dir)) == 1 + + def test_same_content_with_upper_ext_is_deduplicated(self, store_dir: Path, make_png) -> None: + a = make_png(store_dir.parent / "src" / "1.png", seed=3) + b = _write(store_dir.parent / "src2" / "1.PNG", a.read_bytes()) + + ra = add_file(store_dir, a) + rb = add_file(store_dir, b) + + assert rb.ext == "png" + assert rb.copied is False + assert ra.sha256 == rb.sha256 + assert len(_all_files(store_dir)) == 1 + + def test_different_contents_are_stored_separately(self, store_dir: Path, tongue_color_source): + results = [add_file(store_dir, img.path) for img in tongue_color_source.images] + + assert all(r.copied for r in results) + assert len({r.sha256 for r in results}) == len(results) + files = _all_files(store_dir) + assert len(files) == len(results) + for r in results: + assert verify_stored(store_dir, r.sha256, r.ext) is StoredFileStatus.OK + + def test_stored_file_is_read_only(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + result = add_file(store_dir, source) + target = store_path(store_dir, result.sha256, result.ext) + + assert not os.access(target, os.W_OK) + assert not target.stat().st_mode & stat.S_IWUSR + with pytest.raises(PermissionError): + target.write_bytes(b"x") + assert sha256_file(target) == result.sha256 + + def test_no_temp_files_remain(self, store_dir: Path, tongue_color_source) -> None: + for img in tongue_color_source.images: + add_file(store_dir, img.path) + leftovers = [p for p in store_dir.rglob("*") if p.name.startswith(TEMP_PREFIX)] + assert leftovers == [] + + def test_large_file_across_chunks(self, store_dir: Path) -> None: + data = os.urandom(2 * CHUNK_SIZE + 123) + source = _write(store_dir.parent / "src" / "big.bin", data) + + result = add_file(store_dir, source) + + assert result.sha256 == _sha(data) + assert result.size_bytes == len(data) + assert store_path(store_dir, result.sha256, "bin").read_bytes() == data + + def test_empty_file_can_be_stored(self, store_dir: Path) -> None: + source = _write(store_dir.parent / "src" / "empty.png", b"") + result = add_file(store_dir, source) + assert result.sha256 == _sha(b"") + assert result.size_bytes == 0 + assert verify_stored(store_dir, result.sha256, "png") is StoredFileStatus.OK + + +# ---------------------------------------------------------------- add_file: 原本を変えない + + +class TestAddFileKeepsSource: + def test_source_content_mtime_mode_unchanged(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + os.utime(source, ns=(1_600_000_000_000_000_000, 1_600_000_000_000_000_000)) + data = source.read_bytes() + before = source.stat() + + add_file(store_dir, source) + add_file(store_dir, source) # 既にある場合の経路でも + + after = source.stat() + assert source.read_bytes() == data + assert after.st_mtime_ns == before.st_mtime_ns + assert after.st_mode == before.st_mode + assert after.st_size == before.st_size + assert os.access(source, os.W_OK) # 原本に読み取り専用を付けない + + def test_read_only_source_can_be_added_and_stays_read_only( + self, store_dir: Path, make_png + ) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=2) + source.chmod(stat.S_IREAD) + data = source.read_bytes() + before = source.stat() + try: + result = add_file(store_dir, source) + + after = source.stat() + assert result.copied is True + assert source.read_bytes() == data + assert after.st_mode == before.st_mode + assert after.st_mtime_ns == before.st_mtime_ns + assert not os.access(source, os.W_OK) + finally: + _make_writable(source) + + def test_source_folder_gets_no_new_files(self, store_dir: Path, tongue_color_source) -> None: + root = tongue_color_source.root + before = _all_files(root) + for img in tongue_color_source.images: + add_file(store_dir, img.path) + assert _all_files(root) == before + + +# ---------------------------------------------------------------- add_file: 異常系 + + +class TestAddFileErrors: + def test_missing_store_dir_raises_and_creates_nothing(self, tmp_path: Path, make_png) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + missing = tmp_path / "tiasdata" / "store" + + with pytest.raises(ValueError) as exc: + add_file(missing, source) + + assert not missing.exists() + assert not missing.parent.exists() + _assert_no_leak(str(exc.value), tmp_path, "1.png") + + def test_store_dir_is_a_file_raises(self, tmp_path: Path, make_png) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + not_dir = _write(tmp_path / "store", b"") + with pytest.raises(ValueError): + add_file(not_dir, source) + + def test_source_is_directory_raises(self, store_dir: Path, tmp_path: Path) -> None: + folder = tmp_path / "src" / "淡紅(正常).png" + folder.mkdir(parents=True) + with pytest.raises(ValueError) as exc: + add_file(store_dir, folder) + assert _all_files(store_dir) == [] + _assert_no_leak(str(exc.value), tmp_path, "淡紅(正常)") + + def test_source_missing_raises(self, store_dir: Path, tmp_path: Path) -> None: + with pytest.raises(ValueError) as exc: + add_file(store_dir, tmp_path / "src" / "患者ABC123.png") + assert _all_files(store_dir) == [] + _assert_no_leak(str(exc.value), tmp_path, "患者ABC123") + + @pytest.mark.parametrize("name", ["noext", "weird.p-g", "trailing."]) + def test_source_with_bad_ext_raises(self, store_dir: Path, tmp_path: Path, name: str) -> None: + source = _write(tmp_path / "src" / name, b"data") + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + assert list(store_dir.iterdir()) == [] + _assert_no_leak(str(exc.value), tmp_path, name) + assert source.read_bytes() == b"data" + + def test_existing_different_content_is_not_overwritten( + self, store_dir: Path, tmp_path: Path, make_png + ) -> None: + source = make_png(tmp_path / "src" / "患者XYZ999.png", seed=1) + sha = sha256_file(source) + target = store_path(store_dir, sha, "png") + _write(target, b"corrupted content") + target.chmod(stat.S_IREAD) + before = target.stat() + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + assert target.read_bytes() == b"corrupted content" + assert target.stat().st_mtime_ns == before.st_mtime_ns + assert _all_files(store_dir) == [target] + message = str(exc.value) + assert sha in message + _assert_no_leak(message, tmp_path, "患者XYZ999") + + def test_existing_directory_at_target_raises( + self, store_dir: Path, tmp_path: Path, make_png + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + target = store_path(store_dir, sha256_file(source), "png") + target.mkdir(parents=True) + with pytest.raises(ValueError): + add_file(store_dir, source) + assert target.is_dir() + + def test_verify_failure_after_copy_leaves_no_file( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "患者QQQ.png", seed=1) + sha = sha256_file(source) + real = store.sha256_file + + def broken(path: Path) -> str: + # 一時ファイルの読み直しだけ不一致にする(ディスクへの書き込みの誤りを模す) + if path.name.startswith(TEMP_PREFIX): + return "0" * 64 + return real(path) + + monkeypatch.setattr(store, "sha256_file", broken) + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + assert not store_path(store_dir, sha, "png").exists() + assert [p for p in store_dir.rglob("*") if p.is_file()] == [] + _assert_no_leak(str(exc.value), tmp_path, "患者QQQ") + + def test_source_changed_during_copy_stops( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + fake = "cd" + "1" * 62 + real = store.sha256_file + + def first_call_differs(path: Path) -> str: + # 1 回目(取り込み元)の計算の後に中身が変わった状況を模す + if path == source: + return fake + return real(path) + + monkeypatch.setattr(store, "sha256_file", first_call_differs) + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + assert _all_files(store_dir) == [] + _assert_no_leak(str(exc.value), tmp_path, "1.png") + + def test_concurrent_same_content_is_reused( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + data = source.read_bytes() + sha = _sha(data) + target = store_path(store_dir, sha, "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + _write(target, data) # コピーの間に別の取り込みが同じ中身を置いた + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + result = add_file(store_dir, source) + + assert result.copied is False + assert result.sha256 == sha + assert _all_files(store_dir) == [target] + + def test_concurrent_different_content_is_not_overwritten( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + target = store_path(store_dir, sha256_file(source), "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + _write(target, b"other") + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + with pytest.raises(ValueError): + add_file(store_dir, source) + + assert target.read_bytes() == b"other" + assert _all_files(store_dir) == [target] + + +# ---------------------------------------------------------------- add_file: 再利用時の読み取り専用 + + +def _is_read_only(path: Path) -> bool: + return not os.access(path, os.W_OK) and not path.stat().st_mode & stat.S_IWUSR + + +class TestAddFileReuseRestoresReadOnly: + """SPEC_01「ストアの規則」: 再利用するときも読み取り専用を付け直す(中身には触らない).""" + + def test_reuse_restores_read_only_when_removed(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + first = add_file(store_dir, source) + target = store_path(store_dir, first.sha256, first.ext) + data = target.read_bytes() + _make_writable(target) # 復元・手作業等で読み取り専用が外れた状況 + assert os.access(target, os.W_OK) + mtime = target.stat().st_mtime_ns + + try: + second = add_file(store_dir, source) + + assert second == first._replace(copied=False) + assert _is_read_only(target) + assert target.read_bytes() == data + assert sha256_file(target) == first.sha256 + assert target.stat().st_mtime_ns == mtime + assert _all_files(store_dir) == [target] + finally: + _make_writable(target) + + def test_reuse_of_already_read_only_file_keeps_it_read_only( + self, store_dir: Path, make_png + ) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=2) + first = add_file(store_dir, source) + target = store_path(store_dir, first.sha256, first.ext) + assert _is_read_only(target) + + second = add_file(store_dir, source) + third = add_file(store_dir, source) + + assert second.copied is False + assert third.copied is False + assert _is_read_only(target) + assert sha256_file(target) == first.sha256 + assert _all_files(store_dir) == [target] + + def test_reuse_from_other_source_with_same_content_restores_read_only( + self, store_dir: Path, make_png + ) -> None: + a = make_png(store_dir.parent / "src" / "1.png", seed=3) + b = _write(store_dir.parent / "other" / "別名.PNG", a.read_bytes()) + first = add_file(store_dir, a) + target = store_path(store_dir, first.sha256, first.ext) + _make_writable(target) + + try: + result = add_file(store_dir, b) + + assert result.copied is False + assert _is_read_only(target) + finally: + _make_writable(target) + + def test_concurrent_reuse_makes_existing_read_only( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + data = source.read_bytes() + sha = _sha(data) + target = store_path(store_dir, sha, "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + # 別の取り込みが同じ中身を置いた(まだ読み取り専用を付ける前の状態) + _write(target, data) + assert os.access(target, os.W_OK) + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + try: + result = add_file(store_dir, source) + + assert result.copied is False + assert result.sha256 == sha + assert result.size_bytes == len(data) + assert _is_read_only(target) + assert target.read_bytes() == data + assert _all_files(store_dir) == [target] + finally: + if target.exists(): + _make_writable(target) + + def test_mismatch_keeps_existing_content_and_writable_attribute( + self, store_dir: Path, tmp_path: Path, make_png + ) -> None: + source = make_png(tmp_path / "src" / "患者XYZ999.png", seed=1) + sha = sha256_file(source) + target = store_path(store_dir, sha, "png") + _write(target, b"corrupted content") + assert os.access(target, os.W_OK) + before = target.stat() + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + after = target.stat() + assert target.read_bytes() == b"corrupted content" + assert after.st_mode == before.st_mode + assert after.st_mtime_ns == before.st_mtime_ns + assert os.access(target, os.W_OK) # 照合が合わないときは属性も変えない + assert _all_files(store_dir) == [target] + _assert_no_leak(str(exc.value), tmp_path, "患者XYZ999") + + def test_concurrent_mismatch_keeps_existing_writable( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + target = store_path(store_dir, sha256_file(source), "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + _write(target, b"other") + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + with pytest.raises(ValueError): + add_file(store_dir, source) + + assert target.read_bytes() == b"other" + assert os.access(target, os.W_OK) + assert target.stat().st_mode & stat.S_IWUSR + assert _all_files(store_dir) == [target] + + +# ---------------------------------------------------------------- verify_stored + + +class TestVerifyStored: + def test_ok(self, store_dir: Path, make_png) -> None: + result = add_file(store_dir, make_png(store_dir.parent / "s" / "1.png", seed=1)) + assert verify_stored(store_dir, result.sha256, result.ext) is StoredFileStatus.OK + assert verify_stored(store_dir, result.sha256, ".PNG") is StoredFileStatus.OK + + def test_missing(self, store_dir: Path) -> None: + assert verify_stored(store_dir, SHA_A, "png") is StoredFileStatus.MISSING + + def test_missing_when_only_other_ext_exists(self, store_dir: Path, make_png) -> None: + result = add_file(store_dir, make_png(store_dir.parent / "s" / "1.png", seed=1)) + assert verify_stored(store_dir, result.sha256, "jpg") is StoredFileStatus.MISSING + + def test_mismatch(self, store_dir: Path, make_png) -> None: + result = add_file(store_dir, make_png(store_dir.parent / "s" / "1.png", seed=1)) + target = store_path(store_dir, result.sha256, result.ext) + _make_writable(target) + target.write_bytes(b"tampered") + assert verify_stored(store_dir, result.sha256, result.ext) is StoredFileStatus.MISMATCH + + def test_status_values(self) -> None: + assert [s.value for s in StoredFileStatus] == ["ok", "missing", "mismatch"] + + def test_malformed_sha_raises(self, store_dir: Path) -> None: + with pytest.raises(ValueError): + verify_stored(store_dir, "XYZ", "png")