diff --git a/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index 579fa61..d1fb390 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -18,7 +18,9 @@ ├── kinds/ # ファイルの種類・所見の種類の値の型 ├── importers/ # 取り込み元の形ごとの読み取り(1 つの形 = 1 モジュール) ├── datasets.py # データセットの定義の評価・版の作成 -├── bundle.py # バンドルの書き出し・読み込み・検証 +├── manifest.py # manifest.csv の生成・ハッシュ等の純関数(他の tiasdata のモジュールを import しない) +├── bundle.py # バンドルの読み込み・検証(目録に依存しない) +├── export.py # バンドルの書き出し ├── deliver.py # 計算サーバへの受け渡し(scp・ssh) └── cli.py # CLI エントリポイント @@ -30,7 +32,7 @@ - **1 モジュール 1 責務**: 新しい機能は責務に合うモジュールに置く.大きくなったらサブパッケージに分ける(既存モジュールを肥大化させない) - **依存方向**: `cli` → `datasets`・`bundle`・`deliver`・`importers` → `catalog`・`store`・`kinds`・`config` の一方向を保つ.下位から上位を import しない -- **バンドルの読み込みは目録に依存しない**: `bundle.py` の読み込み側(`load`・`open_bundle`)は `catalog`・`store`・`config` を import しない(目録の無い計算サーバで使うため.書き出し側は別関数に分ける) +- **バンドルの読み込みは目録に依存しない**: `bundle.py`(`load`・`open_bundle`)は `catalog`・`store`・`config`・`export`・`datasets` を import しない(目録の無い計算サーバで使うため).書き出しは `export.py` に置く.読み込み側と目録側で共有する純関数(ハッシュ・名前の形・検証エラーの整形)は `manifest.py` に置く.`import tiasdata` で読み込まれるモジュールを増やさない - **公開 API は `__init__.py` で export したものだけ**: 使う側は `tiasdata.<名前>` で使う.内部モジュールを直接 import させない - **取り込み元を増やすとき**: `importers/` にモジュールを追加し,設定ファイルの `importer` の名前から選べるようにする.既存の importer に分岐を足さない - **種類を増やすとき**: `src/tiasdata/catalog/migrations/catalog/` に `kinds` の行を追加する SQL と,`kinds/` に値の型を追加する([SPEC_02「拡張の例」](../../docs/04_SPEC/SPEC_02_データモデル.md)) diff --git a/CLAUDE.md b/CLAUDE.md index aeb3921..b851cc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: PLAN_02 Step 4(データセットと版.`tiasdata dataset list`・`show`・`freeze`)完了.次は Step 5(バンドル) +最新: PLAN_02 Step 5(バンドル.`tiasdata export`・`tiasdata.load`)完了.次は Step 1〜5 を main に取り込み,Step 6(研究室 PC での運用開始) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index 007654b..b8c885d 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -29,13 +29,15 @@ | `uv run tiasdata dataset list` | データセットと版の一覧 | | `uv run tiasdata dataset show <名前>` | 版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | | `uv run tiasdata dataset freeze <名前> [--note TEXT]` | `datasets/<名前>.toml` を目録に当てはめて新しい版を作る(構成が同じなら作らない).**リポジトリに未コミットの変更があると止まる**(研究室 PC は pull するだけなので通常は問題ない) | +| `uv run tiasdata export <名前> --version <版>` | 凍結した版をバンドル(`bundles/<名前>/v<版>/`)に書き出す.既にあれば照合して使う | - 設定ファイルは環境変数 `TIASDATA_CONFIG` のパス,無ければ `~/.config/tiasdata/config.toml`(キーは [SPEC_01「設定ファイル」](../04_SPEC/SPEC_01_アーキテクチャ設計.md)) - 手元で試すときは一時フォルダに設定ファイルを作って指す(例: `TIASDATA_CONFIG="$TMPDIR/tiasdata-try/config.toml" uv run tiasdata init`.臨床データは使わない) - 研究室 PC では `ssh labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe run tiasdata init"` の形で実行する([ENV_02](ENV_02_環境構築手順.md)) - 失敗時は標準エラーに 1 行を出し,終了コード 1(入力・データの問題)または 2(引数の誤り)で終わる - `import` は openpyxl が要る(手元は `uv sync` で入る.研究室 PC は `uv sync --no-dev --extra import`).リポジトリの clone から実行する(保留リストを読むため) -- 残りのサブコマンド(書き出し・受け渡し・バックアップ等)は実装した Step で追記する +- 使う側(Python)は `tiasdata.load("<名前>", version=<版>)` でバンドルを開く.置き場所は環境変数 `TIASDATA_BUNDLES`(既定 `~/data/tiasdata`) +- 残りのサブコマンド(受け渡し・バックアップ等)は実装した Step で追記する ## 依存の追加 (Dependencies) 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 bdf587e..c08860e 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" @@ -16,7 +16,7 @@ | 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`(2026-10-06 完了) | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.保留リストの番号を飛ばし,保留の件数を報告する.出力に患者 ID・元のファイル名が出ない.`check` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | | 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む(2026-10-06 完了) | -| 5 | **バンドル**: `bundle.py`(書き出し・読み込み・検証),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる | +| 5 | **バンドル**: `bundle.py`(読み込み・検証)・`export.py`(書き出し),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる(2026-10-08 完了) | | 6 | **研究室 PC での運用開始**: 立ち上げの成果物と Step 1〜5 を `main` に取り込む → 研究室 PC に GitHub から clone・設定ファイル作成・`init` → 舌色データ(1085 枚)を取り込み → `check`・`stats` → 版 1 を作成 → `tiasdata backup` | すべて手元 PC から SSH で行える.取り込み 1076 枚+保留 9 枚が 1085 枚・9 グループで TIASJudgeModel の TEST_01 と一致し,症例が約 450(2026-10-03 の照合値)になる.`check` が問題 0 件 | | 7 | **計算サーバへの受け渡し**: `deliver.py`(tar・scp・展開・`sha256sum -c`・記録),`tiasdata deliver` | 版 1 を Newton・Archimedes に渡し,両方で照合が通り,`deliveries` に記録される.データが手元 PC を通らない | | 8 | **TIASJudgeModel の切り替え**: TIASJudgeModel にサブモジュールを追加し,`tiasdata.load("tongue_color", version=1)` から現行の manifest の形に変換して学習・評価に使う(TIASJudgeModel 側の作業ブランチで行う) | 変換した manifest の件数・`label`・`severity` の内訳が,現行の manifest から保留の 9 枚を除いたものと一致し,`patient_id` が患者単位(約 450 人)になる.患者単位の分割で交差検証をやり直し,現行(画像単位)の成績との差を記録する.実験記録にバンドルの指紋が残る.研究室 PC の固定パスを書かずに済む | 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 beb7fbf..062e1ed 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" @@ -64,7 +64,8 @@ │ ├── manifest.py # manifest.csv の生成(純関数.catalog に依存しない) │ ├── repo.py # リポジトリのルートと git の状態 │ ├── datasets.py # データセットの定義の評価・版の作成 -│ ├── bundle.py # バンドルの書き出し・読み込み・検証 +│ ├── bundle.py # バンドルの読み込み・検証(目録に依存しない.計算サーバで使う) +│ ├── export.py # バンドルの書き出し(目録・ストアを使う) │ ├── deliver.py # 計算サーバへの受け渡し │ └── cli.py # エントリポイント ├── datasets/ # データセットの定義(TOML) 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 1f6302c..5b67a18 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" @@ -51,6 +51,18 @@ └── files/. # ストアからのコピー ``` +- `dataset.json` の項目: `format`(形式の版.今は 1.読み込み側は知らない形式を拒否し,未知のキーは無視する)・`name`・`version`・`created_at`(版を作った日時)・`hub_commit`・`files`(件数)・`manifest_sha256`.**書き出した日時は入れない**(同じ版から常に同じバイト列にし,既存のバンドルをバイト単位で照合できるようにするため) +- `SHA256SUMS` の 1 行は ` files/.`(空白 2 つ・LF・パスの順) +- バンドルのファイルは読み取り専用にしない(作り直せて,受け渡した後に消す前提のため.2026-10-08 のユーザー判断).改変は `verify()` と書き出し直しの照合で見つける + +### 書き出し (Export) + +`tiasdata export <名前> --version <版>`(`src/tiasdata/export.py`). + +1. 版の記録から manifest を作り直し,記録した SHA-256 と一致しなければ停止する(目録が書き換えられた版を書き出さない) +2. `bundles/<名前>/.tmp-v<版>-…/` に `files/`(ストアからコピーし,読み直して照合)・`manifest.csv`・`SHA256SUMS`・`dataset.json` を書き,開き直して形を確かめてから `v<版>` に名前を変える(書きかけを正式な名前で残さない.失敗したら一時フォルダを消す) +3. 既に `v<版>` があれば,`dataset.json`・`manifest.csv`・`SHA256SUMS` を版の記録から作ったものとバイト比較し,`verify()` を通して使う.食い違えば上書きせずに停止する(消して書き出し直すのは人間の作業) + ### manifest.csv の列 | 列 | 内容 | @@ -104,7 +116,12 @@ bundle.fingerprint # {"name", "version", "manifest_sha256"}.実験記録に残す ``` -- `load()` は版の指定を必須にする +- `load(name, *, version)`: 版の指定は**キーワードで必須**(`load("tongue_color", 1)` は誤り).置き場所は環境変数 `TIASDATA_BUNDLES`,無ければ `~/data/tiasdata`.`dataset.json` の名前・版が指定と違えば停止する +- `bundle.rows` は `BundleRow`(`path`・`sha256`・`kind`・`subject_id`・`capture_id`・`annotations`)の並び.`annotations` は manifest の列 `<所見>.<項目>` を所見ごとにまとめたもので,値は文字列か None(空欄).文字列以外の型には戻さない +- `bundle.file_path(row)` が画像等の絶対パスを返す.`bundle.info`(`BundleInfo`)・`bundle.root`・`bundle.name`・`bundle.version`・`bundle.manifest_sha256` も使える +- `bundle.verify()` は問題が無ければ None を返し,欠け・改変・`files/` の余分・`SHA256SUMS` と manifest の食い違い・開いた後の `manifest.csv` の書き換えを ValueError で知らせる +- 読み込み側(`tiasdata.bundle`)は標準ライブラリ・pydantic・`tiasdata.manifest` だけを使い,目録(catalog・store・config)を読み込まない.`import tiasdata` で読み込まれるのは `tiasdata`・`tiasdata.bundle`・`tiasdata.manifest` だけ +- エラーメッセージにバンドルの場所(パス)を出さない(研究室 PC・計算サーバの出力も Claude が読むため.名前・版・`<名前>/v<版>/` の形・環境変数名で示す) - `tiasdata.open_bundle(path)` でパスを直接指定して開くこともできる(テスト・Python 以外との突き合わせ用) - 読み込み時に `manifest.csv` の SHA-256 が `dataset.json` と一致しなければ停止する @@ -118,7 +135,7 @@ | `tiasdata import <取り込み元>` | 取り込み元を読み,ファイルをストアにコピーして症例・撮影・ファイル・所見・由来を登録する(取り込み元の形ごとに importer を用意する.最初は舌色の 9 フォルダ `tongue_color_folders`).保留リストの番号は飛ばし,件数を報告する | | `tiasdata dataset list` / `show <名前>` | データセットと版の一覧・件数.定義だけあって版の無いものは「版なし」,定義ファイルを消したものは「(定義ファイルなし)」.show は版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | | `tiasdata dataset freeze <名前> [--note TEXT]` | 新しい版を作る(作った版の番号と件数,または「構成・定義が同じなので版を作らなかった」) | -| `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す | +| `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す(上記「書き出し」).書き出した件数・合計の大きさ・manifest の SHA-256,または「既にあったバンドルを照合して使った」を表示する | | `tiasdata deliver <名前> --version <版> --to <サーバ名>` | 計算サーバへ渡す | | `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,凍結した版の manifest の不一致(全版について記録した定義と構成から manifest を作り直し,記録した SHA-256 と照合する.ファイルの種類や撮影の症例が書き換えられた場合に見つかる),読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.tmp-`),ストアの想定外のファイル・フォルダ.件数だけを表示し,問題が 1 件以上なら終了コード 1.対応表は開かない | | `tiasdata stats` | 症例数(撮影が 2 つ以上の症例数を含む)・撮影数,種類ごとのファイル数,訂正されていない所見の項目ごと・組み合わせごとの件数,訂正された所見の数.対応表は開かない | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fb84a76..a70cc01 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -41,3 +41,6 @@ - [x] **PLAN_02 Step 4: データセットと版**: データセットの定義(`datasets/tongue_color.toml`),定義の当てはめと版の作成(freeze),manifest.csv の生成(純関数),`tiasdata dataset list`・`show`・`freeze`,check での全版の manifest の照合を実装(2026-10-06) - 設計判断: 版は「定義の全文+採用したファイルと所見の行」が直前と同じなら作らない(同じ値への訂正でも新しい版).版の行は manifest の SHA-256 を計算してから 1 トランザクションで INSERT(凍結のトリガがあるため UPDATE しない).未コミットの変更があると作らない.種類との照合は作るときだけ(過去の版の作り直しに使わない) - 未決: 版が参照するファイルの種類(`assets.kind`)や撮影の症例(`captures.subject_id`)は書き換えられ,書き換えると過去の版の manifest が変わる(check は不一致として見つける).書き換えをトリガで禁止し,症例の誤りは訂正の行で直す仕組みにするかを決める(保留中の 122/184 の患者の確定で関係する) +- [x] **PLAN_02 Step 5: バンドル**: 凍結した版のバンドルの書き出し(`tiasdata export`.`export.py`)と,目録に依存しない読み込み・検証(`tiasdata.load`・`open_bundle`・`Bundle.verify`・`fingerprint`.`bundle.py`)を実装(2026-10-08) + - 設計判断: 読み込みと書き出しをモジュールで分け,`import tiasdata` で目録まわり(catalog・store・config・sqlite3)を読み込まない(計算サーバで使うため).共有する純関数は `manifest.py` に集めた.`dataset.json` に書き出した日時を入れず同じ版から同じバイト列にする(既存のバンドルをバイト単位で照合できる).バンドルは読み取り専用にしない(作り直せて消す前提).`load` の版はキーワード必須.エラーにバンドルの場所を出さない + - 未実装・後段: `BundleRow.annotations` の入れ子の値が書き換えられる(使う側の誤りを防ぐなら読み取り専用にする).書き出しに失敗すると空の `bundles/<名前>/` が残る(害はない) diff --git a/src/tiasdata/__init__.py b/src/tiasdata/__init__.py index 3ad8978..2568dd6 100644 --- a/src/tiasdata/__init__.py +++ b/src/tiasdata/__init__.py @@ -1,3 +1,12 @@ -"""舌診研究の共有データ基盤 TIASDataHub のアクセス用ライブラリ.""" +"""舌診研究の共有データ基盤 TIASDataHub のアクセス用ライブラリ. + +使う側(計算サーバ等)は `tiasdata.load(name, version=...)` でバンドルを開く. +ここで import するのはバンドルの読み込みだけにする(目録・ストア・設定を計算サーバに +持ち込まないため.SPEC_01「依存の向き」). +""" + +from tiasdata.bundle import Bundle, BundleInfo, BundleRow, load, open_bundle __version__ = "0.1.0" + +__all__ = ["Bundle", "BundleInfo", "BundleRow", "__version__", "load", "open_bundle"] diff --git a/src/tiasdata/bundle.py b/src/tiasdata/bundle.py new file mode 100644 index 0000000..fa9f5a0 --- /dev/null +++ b/src/tiasdata/bundle.py @@ -0,0 +1,528 @@ +"""バンドル(データセットの 1 つの版を manifest とファイルのコピーにまとめたもの)の読み込み・検証. + +バンドルの形(SPEC_03「バンドル」):: + + <名前>/v<版>/ + ├── dataset.json # 名前・版・作成日時・hub_commit・件数・manifest.csv の SHA-256 + ├── manifest.csv # 1 行 1 ファイル + ├── SHA256SUMS # files/ の照合用(sha256sum -c で検証できる形式) + └── files/. # ストアからのコピー + +このモジュールは目録の無い計算サーバで使うため,`catalog`・`store`・`config` を import しない +(標準ライブラリ・pydantic・`tiasdata.manifest` だけを使う.SPEC_01「依存の向き」). +書き出し(目録・ストアを使う側)は `tiasdata.export` にあり,ここの形の組み立て +(`build_dataset_json`・`build_sha256sums`)を共有する. + +例外メッセージにはバンドルのパスを出さず,データセット名・版・件数・SHA-256 で示す +(SPEC_03「出力の規則」.計算サーバの出力も Claude が読みうるため,データの場所を出さない). +""" + +import csv +import io +import json +import os +import re +from collections.abc import Iterable, Sequence +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from tiasdata.manifest import ( + BASE_COLUMNS, + DATASET_NAME_PATTERN, + FILES_DIRNAME, + MANIFEST_ENCODING, + bundle_file_path, + manifest_sha256, + sha256_file, + validation_error_details, +) + +BUNDLES_ENV_VAR = "TIASDATA_BUNDLES" +DATASET_JSON = "dataset.json" +MANIFEST_CSV = "manifest.csv" +SHA256SUMS = "SHA256SUMS" +# dataset.json の形の版(形を変えたら上げ,読み込み側が知らない形を黙って読まないようにする) +BUNDLE_FORMAT = 1 + +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_FILE_PATH_PATTERN = re.compile(rf"^{FILES_DIRNAME}/([0-9a-f]{{64}})\.([a-z0-9]+)$") +_ANNOTATION_COLUMN_PATTERN = re.compile(r"^(\w+)\.(\w+)$", re.ASCII) +# `sha256sum` の出力の形(` <パス>`.2 つ目の空白の代わりに `*` は二進モードの印) +_SHA256SUMS_LINE_PATTERN = re.compile(r"^([0-9a-f]{64}) [ *](.+)$") +# エラーメッセージに出す例の最大件数と,SHA-256 の桁数(位置の特定には先頭で足りる) +_MAX_EXAMPLES = 5 +_SHORT_SHA256 = 12 + + +class BundleInfo(BaseModel): + """`dataset.json` の内容. + + Attributes: + format: バンドルの形の版. + name: データセット名. + version: 版. + created_at: 版を作った日時(UTC の ISO 8601).書き出した日時ではない. + hub_commit: 版を作ったときの本リポジトリのコミット. + files: ファイルの数(manifest.csv の行数). + manifest_sha256: manifest.csv の SHA-256. + """ + + # 後で項目を足しても古い読み込み側が読めるよう,未知のキーは無視する + model_config = ConfigDict(frozen=True, extra="ignore") + + format: int = Field(strict=True) + name: str = Field(pattern=DATASET_NAME_PATTERN.pattern) + version: int = Field(ge=1, strict=True) + created_at: str + hub_commit: str + files: int = Field(ge=0, strict=True) + manifest_sha256: str = Field(pattern=_SHA256_PATTERN.pattern) + + +class BundleRow(BaseModel): + """manifest.csv の 1 行(バンドルの 1 ファイル). + + Attributes: + path: バンドルのルートからの相対パス(`files/.`.区切りは `/`). + 絶対パスは `Bundle.file_path(row)` で得る. + sha256: ファイルの SHA-256. + kind: ファイルの種類. + subject_id: 研究用の症例 ID(分割の単位にする). + capture_id: 撮影 ID. + annotations: 所見の値(所見の種類 → 項目 → 値).manifest の `<所見>.<項目>` の列を + まとめたもの.値は manifest の文字列のまま(文字列以外の値は JSON の表記)で, + 空の欄は None. + """ + + model_config = ConfigDict(frozen=True) + + path: str + sha256: str + kind: str + subject_id: str + capture_id: str + annotations: dict[str, dict[str, str | None]] + + +class Bundle: + """開いたバンドル(`open_bundle`・`load` で作る). + + 開いた時点で dataset.json と manifest.csv の SHA-256 の一致を確かめてある. + ファイルの中身の照合は時間がかかるため `verify()` で別に行う. + """ + + def __init__(self, root: Path, info: BundleInfo, rows: Sequence[BundleRow]) -> None: + """`open_bundle` から呼ぶ(使う側は直接作らない).""" + self._root = root + self._info = info + self._rows = tuple(rows) + + def __repr__(self) -> str: + # パスを出さない(ログに残っても場所が分からないように) + return f"Bundle(name={self.name!r}, version={self.version}, files={len(self._rows)})" + + @property + def root(self) -> Path: + """バンドルのフォルダ(絶対パス).""" + return self._root + + @property + def info(self) -> BundleInfo: + """dataset.json の内容.""" + return self._info + + @property + def name(self) -> str: + """データセット名.""" + return self._info.name + + @property + def version(self) -> int: + """版.""" + return self._info.version + + @property + def manifest_sha256(self) -> str: + """manifest.csv の SHA-256.""" + return self._info.manifest_sha256 + + @property + def rows(self) -> tuple[BundleRow, ...]: + """manifest.csv の行(`subject_id`・`capture_id`・`sha256` の順).""" + return self._rows + + @property + def fingerprint(self) -> dict[str, str | int]: + """バンドルの指紋(`{"name", "version", "manifest_sha256"}`).実験記録に残す.""" + return { + "name": self._info.name, + "version": self._info.version, + "manifest_sha256": self._info.manifest_sha256, + } + + def file_path(self, row: BundleRow) -> Path: + """行のファイルの絶対パス(画像を開くため). + + Args: + row: このバンドルの行. + + Returns: + `<バンドルのフォルダ>/files/.`. + """ + return self._root / row.path + + def verify(self) -> None: + """全ファイルの SHA-256 を照合する(時間がかかる). + + manifest.csv の SHA-256(dataset.json と一致するか),SHA256SUMS と manifest の一致, + files/ の各ファイルの有無・中身,files/ に manifest に無いファイルが無いかを確かめる. + + Raises: + ValueError: manifest.csv が変わった,SHA256SUMS が無い・形が誤っている・manifest と + 食い違う,ファイルの欠け・中身の改変,または manifest に無いファイルがある. + """ + label = _label(self._info) + data = _read_bytes(self._root / MANIFEST_CSV, label, MANIFEST_CSV) + if manifest_sha256(data) != self._info.manifest_sha256: + raise ValueError(f"{label}: manifest.csv の SHA-256 が dataset.json と一致しない") + + sums = parse_sha256sums(_read_bytes(self._root / SHA256SUMS, label, SHA256SUMS), label) + expected = {(row.sha256, row.path) for row in self._rows} + if set(sums) != expected or len(sums) != len(expected): + raise ValueError(f"{label}: SHA256SUMS の行が manifest.csv と一致しない") + + missing: list[str] = [] + mismatched: list[str] = [] + for row in self._rows: + path = self.file_path(row) + if not path.is_file(): + missing.append(row.sha256[:_SHORT_SHA256]) + elif sha256_file(path) != row.sha256: + mismatched.append(row.sha256[:_SHORT_SHA256]) + unexpected = _count_unexpected_files(self._root, {row.path for row in self._rows}) + + if missing or mismatched or unexpected: + details = [ + f"欠け {len(missing)} 件" + _examples(missing), + f"中身の不一致 {len(mismatched)} 件" + _examples(mismatched), + f"manifest に無いファイル {unexpected} 件", + ] + raise ValueError( + f"{label}: ファイルの照合に失敗した({len(self._rows)} 件中.{'・'.join(details)})" + ) + + +# --------------------------------------------------------------------------- +# 形の組み立て(書き出し側と共有する純関数) +# --------------------------------------------------------------------------- +def build_dataset_json(info: BundleInfo) -> bytes: + """dataset.json のバイト列を作る(UTF-8・改行 LF・末尾に改行). + + 書き出した日時は入れない(同じ版から同じバイト列になるようにするため). + + Args: + info: dataset.json の内容. + + Returns: + dataset.json の中身. + """ + text = json.dumps(info.model_dump(), ensure_ascii=False, indent=2) + "\n" + return text.encode("utf-8") + + +def build_sha256sums(files: Iterable[tuple[str, str]]) -> bytes: + """SHA256SUMS のバイト列を作る(`sha256sum -c` で検証できる形.改行 LF). + + 各行は ` <バンドルのルートからの相対パス>`(間は空白 2 つ)で,パスの順に並べる. + + Args: + files: (SHA-256, 相対パス). + + Returns: + SHA256SUMS の中身. + """ + lines = [f"{sha256} {path}\n" for sha256, path in sorted(files, key=lambda f: f[1])] + return "".join(lines).encode("utf-8") + + +def parse_sha256sums(data: bytes, label: str = "バンドル") -> list[tuple[str, str]]: + """SHA256SUMS を読む. + + Args: + data: SHA256SUMS の中身. + label: エラーメッセージで対象を示す名前. + + Returns: + (SHA-256, 相対パス)の並び(ファイルの順). + + Raises: + ValueError: UTF-8 でない,または形が誤っている行がある. + """ + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise ValueError(f"{label}: SHA256SUMS が UTF-8 でない") from e + entries: list[tuple[str, str]] = [] + for number, line in enumerate(text.split("\n"), start=1): + if not line: + continue + match = _SHA256SUMS_LINE_PATTERN.match(line) + if match is None: + raise ValueError(f"{label}: SHA256SUMS の {number} 行目の形が誤っている") + entries.append((match.group(1), match.group(2))) + return entries + + +def check_dataset_name(name: str) -> None: + """データセット名がパスに使える形かを確かめる. + + Args: + name: データセット名. + + Raises: + ValueError: 英小文字で始まり英小文字・数字・`_` だけの名前でない. + """ + if not isinstance(name, str) or not DATASET_NAME_PATTERN.match(name): + raise ValueError( + f"データセット名 {name!r} は英小文字で始まり,英小文字・数字・_ だけにする" + ) + + +def check_version(version: int) -> None: + """版が 1 以上の整数かを確かめる. + + Args: + version: 版. + + Raises: + ValueError: 整数でない(真偽値を含む),または 1 未満. + """ + if isinstance(version, bool) or not isinstance(version, int) or version < 1: + raise ValueError(f"版は 1 以上の整数で指定する(指定: {version!r})") + + +def bundle_relative_dir(name: str, version: int) -> Path: + """バンドルの置き場所からの相対パス(`<名前>/v<版>`). + + Args: + name: データセット名. + version: 版. + + Returns: + `<名前>/v<版>`. + + Raises: + ValueError: 名前・版の形が誤っている. + """ + check_dataset_name(name) + check_version(version) + return Path(name) / f"v{version}" + + +# --------------------------------------------------------------------------- +# 読み込み +# --------------------------------------------------------------------------- +def default_bundles_dir() -> Path: + """バンドルの置き場所を返す. + + 環境変数 `TIASDATA_BUNDLES` があればそのパス,無ければ `~/data/tiasdata` + (SPEC_01「使う側からの利用」). + + Returns: + バンドルの置き場所(存在するかは確かめない). + """ + env = os.environ.get(BUNDLES_ENV_VAR) + if env: + return Path(env).expanduser() + return Path.home() / "data" / "tiasdata" + + +def load(name: str, *, version: int) -> Bundle: + """データセットの版を指定してバンドルを開く(`TIASDATA_BUNDLES/<名前>/v<版>/`). + + 版の省略は許さない(暗黙に最新を使うと再現性が崩れるため.SPEC_01). + + Args: + name: データセット名(例: `tongue_color`). + version: 版(1 以上.キーワードで指定する). + + Returns: + 開いたバンドル. + + Raises: + ValueError: 名前・版の形が誤っている,バンドルが見つからない,形が誤っている, + manifest の SHA-256 が一致しない,または dataset.json の名前・版が指定と違う. + """ + root = default_bundles_dir() / bundle_relative_dir(name, version) + if not root.is_dir(): + raise ValueError( + f"データセット '{name}' の版 {version} のバンドルが無い" + f"(環境変数 {BUNDLES_ENV_VAR} の場所(既定 ~/data/tiasdata)の {name}/v{version}/)" + ) + bundle = open_bundle(root) + if bundle.name != name or bundle.version != version: + raise ValueError( + f"{name}/v{version}/ のバンドルの dataset.json がデータセット '{bundle.name}' の" + f"版 {bundle.version} になっている" + ) + return bundle + + +def open_bundle(path: str | os.PathLike[str]) -> Bundle: + """パスを指定してバンドルを開く. + + dataset.json と manifest.csv を読み,manifest.csv の SHA-256 が dataset.json と + 一致することを確かめる(一致しなければ停止).ファイルの中身は照合しない(`verify()`). + + Args: + path: バンドルのフォルダ(`<名前>/v<版>/`). + + Returns: + 開いたバンドル. + + Raises: + ValueError: フォルダ・dataset.json・manifest.csv が無い,形が誤っている, + または manifest.csv の SHA-256 が dataset.json と一致しない. + """ + root = Path(path).expanduser().resolve() + if not root.is_dir(): + raise ValueError("バンドルのフォルダが無い") + info = _parse_info(_read_bytes(root / DATASET_JSON, "バンドル", DATASET_JSON)) + label = _label(info) + data = _read_bytes(root / MANIFEST_CSV, label, MANIFEST_CSV) + if manifest_sha256(data) != info.manifest_sha256: + raise ValueError( + f"{label}: manifest.csv の SHA-256 が dataset.json と一致しない" + "(manifest.csv が書き換えられたか,別の版のもの)" + ) + rows = parse_manifest(data, label) + if len(rows) != info.files: + raise ValueError( + f"{label}: manifest.csv の行数 {len(rows)} が dataset.json の件数 {info.files} と" + "一致しない" + ) + return Bundle(root, info, rows) + + +def parse_manifest(data: bytes, label: str = "バンドル") -> list[BundleRow]: + """manifest.csv を読んで行にする. + + Args: + data: manifest.csv の中身(UTF-8・BOM なし). + label: エラーメッセージで対象を示す名前. + + Returns: + manifest の行(ファイルの順). + + Raises: + ValueError: UTF-8 でない,見出しが誤っている,列の数が合わない行・パスの形が誤っている + 行・SHA-256 の重複がある. + """ + try: + text = data.decode(MANIFEST_ENCODING) + except UnicodeDecodeError as e: + raise ValueError(f"{label}: manifest.csv が UTF-8 でない") from e + reader = csv.reader(io.StringIO(text, newline="")) + header = next(reader, None) + if header is None or tuple(header[: len(BASE_COLUMNS)]) != BASE_COLUMNS: + raise ValueError( + f"{label}: manifest.csv の見出しが {', '.join(BASE_COLUMNS)} で始まっていない" + ) + annotation_columns = _parse_annotation_columns(header[len(BASE_COLUMNS) :], label) + + rows: list[BundleRow] = [] + seen: set[str] = set() + for number, record in enumerate(reader, start=2): + if len(record) != len(header): + raise ValueError(f"{label}: manifest.csv の {number} 行目の列の数が見出しと違う") + path, sha256, kind, subject_id, capture_id = record[: len(BASE_COLUMNS)] + match = _FILE_PATH_PATTERN.match(path) + if ( + not _SHA256_PATTERN.match(sha256) + or match is None + or path != bundle_file_path(sha256, match.group(2)) + ): + raise ValueError( + f"{label}: manifest.csv の {number} 行目の path・sha256 の形が誤っている" + f"(path は {FILES_DIRNAME}/.<拡張子>)" + ) + if sha256 in seen: + raise ValueError(f"{label}: manifest.csv に SHA-256 {sha256} が 2 回以上ある") + seen.add(sha256) + annotations: dict[str, dict[str, str | None]] = {} + for (kind_name, field), cell in zip( + annotation_columns, record[len(BASE_COLUMNS) :], strict=True + ): + annotations.setdefault(kind_name, {})[field] = cell if cell != "" else None + rows.append( + BundleRow( + path=path, + sha256=sha256, + kind=kind, + subject_id=subject_id, + capture_id=capture_id, + annotations=annotations, + ) + ) + return rows + + +def _parse_annotation_columns(columns: Sequence[str], label: str) -> list[tuple[str, str]]: + parsed: list[tuple[str, str]] = [] + for column in columns: + match = _ANNOTATION_COLUMN_PATTERN.match(column) + if match is None: + raise ValueError(f"{label}: manifest.csv の列 '{column}' が <所見>.<項目> の形でない") + parsed.append((match.group(1), match.group(2))) + if len(set(parsed)) != len(parsed): + raise ValueError(f"{label}: manifest.csv に同じ所見の列が 2 回以上ある") + return parsed + + +def _parse_info(data: bytes) -> BundleInfo: + try: + raw = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise ValueError(f"バンドルの {DATASET_JSON} を JSON として読めない: {e}") from e + if not isinstance(raw, dict): + raise ValueError(f"バンドルの {DATASET_JSON} が JSON のオブジェクトでない") + try: + info = BundleInfo.model_validate(raw) + except ValidationError as e: + raise ValueError( + f"バンドルの {DATASET_JSON} の形が誤っている: {validation_error_details(e)}" + ) from e + if info.format != BUNDLE_FORMAT: + raise ValueError( + f"バンドルの形の版 {info.format} は読めない(読めるのは {BUNDLE_FORMAT})." + "tiasdata を更新する" + ) + return info + + +def _read_bytes(path: Path, label: str, filename: str) -> bytes: + # Windows ではフォルダを読むと PermissionError になるため,先にファイルかを確かめる + if not path.is_file(): + raise ValueError(f"{label}: {filename} が無い,またはファイルでない") + return path.read_bytes() + + +def _count_unexpected_files(root: Path, expected_paths: set[str]) -> int: + files_dir = root / FILES_DIRNAME + if not files_dir.is_dir(): + return 0 + return sum( + 1 for path in files_dir.iterdir() if f"{FILES_DIRNAME}/{path.name}" not in expected_paths + ) + + +def _label(info: BundleInfo) -> str: + return f"バンドル {info.name} v{info.version}" + + +def _examples(items: Sequence[str]) -> str: + if not items: + return "" + shown = ", ".join(items[:_MAX_EXAMPLES]) + return f"(例: {shown}{' …' if len(items) > _MAX_EXAMPLES else ''})" diff --git a/src/tiasdata/cli.py b/src/tiasdata/cli.py index cc962c0..6a2eb69 100644 --- a/src/tiasdata/cli.py +++ b/src/tiasdata/cli.py @@ -6,10 +6,11 @@ from pydantic import ValidationError -from tiasdata import __version__, catalog, datasets +from tiasdata import __version__, catalog, datasets, export from tiasdata.check import CheckResult, run_check from tiasdata.config import load_config from tiasdata.importers import ImportResult, run_import +from tiasdata.manifest import validation_error_details from tiasdata.stats import CatalogStats, collect_stats @@ -194,6 +195,22 @@ return 0 +def _cmd_export(args: argparse.Namespace) -> int: + config = load_config() + result = export.run_export(config, args.name, args.dataset_version) + # パスは出さない(SPEC_03「出力の規則」).書き出したか,既にあったものを照合して使ったか + if result.reused: + print( + f"データセット {result.name} の版 {result.version}: 既にあったバンドルを照合して使った" + "(上書きしていない)" + ) + else: + print(f"データセット {result.name} の版 {result.version} のバンドルを書き出した") + print(f"ファイル {result.files} 件(合計 {result.size_bytes} バイト)") + print(f"manifest の SHA-256: {result.manifest_sha256}") + return 0 + + def _print_migration(label: str, result: catalog.MigrationResult) -> None: if result.applied: print(f"{label}: スキーマの版 {result.before} → {result.after}({result.applied} 件適用)") @@ -203,11 +220,7 @@ def _format_validation_error(e: ValidationError) -> str: # 入力値(パス等)を出さず,キーの位置と理由だけを 1 行にまとめる - details = "; ".join( - f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" - for error in e.errors(include_input=False, include_url=False) - ) - return f"設定ファイルの検証に失敗した({e.error_count()} 件): {details}" + return f"設定ファイルの検証に失敗した({e.error_count()} 件): {validation_error_details(e)}" def _build_parser() -> argparse.ArgumentParser: @@ -290,6 +303,24 @@ dataset_freeze.add_argument("name", metavar="<名前>", help="データセット名") dataset_freeze.add_argument("--note", metavar="TEXT", help="版の備考") dataset_freeze.set_defaults(handler=_cmd_dataset_freeze) + + export_ = subparsers.add_parser( + "export", + help="凍結した版のバンドルを bundles/ に書き出す", + description="凍結した版の manifest.csv・dataset.json・SHA256SUMS・files/ を " + "bundles/<名前>/v<版>/ に書き出す.構成から作り直した manifest が版の記録と一致しなければ" + "停止する.既にあれば上書きせず,照合して使う(食い違えば停止する)", + ) + export_.add_argument("name", metavar="<名前>", help="データセット名") + export_.add_argument( + "--version", + dest="dataset_version", + metavar="<版>", + type=int, + required=True, + help="書き出す版(必須)", + ) + export_.set_defaults(handler=_cmd_export) return parser diff --git a/src/tiasdata/datasets.py b/src/tiasdata/datasets.py index d67b24e..0ff1f7e 100644 --- a/src/tiasdata/datasets.py +++ b/src/tiasdata/datasets.py @@ -9,7 +9,6 @@ """ import json -import re import sqlite3 import tomllib from collections import defaultdict @@ -31,12 +30,16 @@ VersionSummary, ) from tiasdata.config import Config -from tiasdata.manifest import ManifestEntry, build_manifest, manifest_sha256 +from tiasdata.manifest import ( + DATASET_NAME_PATTERN, + ManifestEntry, + build_manifest, + manifest_sha256, + validation_error_details, +) DATASETS_DIRNAME = "datasets" DEFINITION_SUFFIX = ".toml" -# データセット名(SPEC_02「ID の書式」.スキーマの CHECK と同じ).定義ファイル名にも使う -_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") # エラーメッセージに出す例の最大件数 _MAX_EXAMPLES = 5 # エラーメッセージでファイルを示す SHA-256 の桁数(位置の特定には先頭で足りる) @@ -85,7 +88,7 @@ columns: manifest に出す所見の列(所見の種類 → 項目.この順に並べる). """ - name: str = Field(pattern=_NAME_PATTERN.pattern) + name: str = Field(pattern=DATASET_NAME_PATTERN.pattern) description: str = Field(min_length=1) select: SelectSpec columns: dict[str, list[str]] = {} @@ -121,7 +124,7 @@ def _check_name(name: str) -> None: # 名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない - if not _NAME_PATTERN.match(name): + if not DATASET_NAME_PATTERN.match(name): raise ValueError( f"データセット名 '{name}' は英小文字で始まり,英小文字・数字・_ だけにする" ) @@ -149,11 +152,9 @@ try: definition = DatasetDefinition.model_validate(data) except ValidationError as e: - details = "; ".join( - f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" - for error in e.errors(include_input=False, include_url=False) - ) - raise ValueError(f"データセットの定義 {filename} の形が誤っている: {details}") from e + raise ValueError( + f"データセットの定義 {filename} の形が誤っている: {validation_error_details(e)}" + ) from e if definition.name != expected_name: raise ValueError( f"データセットの定義 {filename} の name '{definition.name}' がファイル名と一致しない" @@ -207,7 +208,7 @@ return sorted( path.stem for path in datasets_dir.glob(f"*{DEFINITION_SUFFIX}") - if path.is_file() and _NAME_PATTERN.match(path.stem) + if path.is_file() and DATASET_NAME_PATTERN.match(path.stem) ) diff --git a/src/tiasdata/export.py b/src/tiasdata/export.py new file mode 100644 index 0000000..3aed4ad --- /dev/null +++ b/src/tiasdata/export.py @@ -0,0 +1,248 @@ +"""バンドルの書き出し(`tiasdata export`.SPEC_03「バンドル」). + +目録・ストア・設定を使うため,読み込み側(`tiasdata.bundle`)とはモジュールを分ける +(`import tiasdata` で目録の依存を計算サーバに持ち込まないため.SPEC_01「依存の向き」). +形の組み立て・読み込み・照合は `tiasdata.bundle` のものを使う. + +書き出しは「一時フォルダ(`.tmp-` で始まる名前)に作る → 開いて形を確かめる → 正式な名前に +変える」の順で行い,書きかけのバンドルを正式な名前で残さない.既にあるバンドルは上書きせず, +版の記録と照合して使う(食い違えば停止する). + +出力・例外メッセージにはパスを出さない(SPEC_03「出力の規則」). +""" + +import os +import shutil +import sqlite3 +import tempfile +from contextlib import closing +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from tiasdata import bundle, catalog, datasets, store +from tiasdata.catalog import versions +from tiasdata.config import Config +from tiasdata.manifest import FILES_DIRNAME, bundle_file_path, manifest_sha256, sha256_file + +# 書きかけのバンドルのフォルダ名の接頭辞(ストアの一時ファイルと同じ印) +TEMP_PREFIX = store.TEMP_PREFIX + + +class ExportResult(BaseModel): + """書き出しの結果. + + Attributes: + name: データセット名. + version: 版. + files: ファイルの数. + size_bytes: ファイルのサイズの合計. + manifest_sha256: manifest.csv の SHA-256. + reused: 既にあったバンドルを照合して使ったか(False なら今回書き出した). + """ + + model_config = ConfigDict(frozen=True) + + name: str + version: int + files: int + size_bytes: int + manifest_sha256: str + reused: bool + + +class _Planned(BaseModel): + # 書き出す(または照合する)バンドルの中身.ファイルはストアからコピーする + model_config = ConfigDict(frozen=True) + + info: bundle.BundleInfo + manifest: bytes + dataset_json: bytes + sha256sums: bytes + files: list[tuple[str, str]] # (SHA-256, 拡張子) + + +def _plan(conn: sqlite3.Connection, name: str, version: int) -> _Planned: + record = versions.get_version(conn, name, version) + if record is None: + raise ValueError(f"データセット '{name}' の版 {version} が無い") + # 版の構成から作り直し,記録した SHA-256 と照合してから書き出す(目録の書き換え等で + # 版を作ったときと違う manifest を配らないため) + data = datasets.rebuild_manifest(conn, name, version) + digest = manifest_sha256(data) + if digest != record.manifest_sha256: + raise ValueError( + f"データセット '{name}' の版 {version}: 構成から作り直した manifest の SHA-256 が" + "版の記録と一致しない.書き出さずに停止した.tiasdata check で点検する" + ) + files = [(m.sha256, m.ext) for m in versions.version_members(conn, name, version)] + info = bundle.BundleInfo( + format=bundle.BUNDLE_FORMAT, + name=name, + version=version, + created_at=record.created_at, + hub_commit=record.hub_commit, + files=len(files), + manifest_sha256=digest, + ) + return _Planned( + info=info, + manifest=data, + dataset_json=bundle.build_dataset_json(info), + sha256sums=bundle.build_sha256sums( + (sha256, bundle_file_path(sha256, ext)) for sha256, ext in files + ), + files=files, + ) + + +def export_bundle( + conn: sqlite3.Connection, *, bundles_dir: Path, store_dir: Path, name: str, version: int +) -> ExportResult: + """凍結した版のバンドルを `bundles_dir/<名前>/v<版>/` に書き出す. + + 既にあれば上書きせず,dataset.json・manifest.csv・SHA256SUMS が版の記録から作ったものと + バイト単位で同じで,ファイルの照合(`Bundle.verify`)が通ることを確かめて使う. + + Args: + conn: 目録への接続(スキーマ最新). + bundles_dir: バンドルの置き場所(`/bundles`.`tiasdata init` で作成済み). + store_dir: ストアのフォルダ. + name: データセット名. + version: 版. + + Returns: + 書き出しの結果. + + Raises: + ValueError: 名前・版の形が誤っている,版が無い,作り直した manifest が記録と一致しない, + バンドルの置き場所が無い,ストアのファイルが無い・照合が合わない, + または既にあるバンドルが版の記録と食い違う. + """ + relative = bundle.bundle_relative_dir(name, version) + if not bundles_dir.is_dir(): + raise ValueError("バンドルの置き場所(bundles)が無い(先に tiasdata init を実行する)") + planned = _plan(conn, name, version) + target = bundles_dir / relative + if target.exists(): + return _reuse_existing(target, planned) + + parent = target.parent + parent.mkdir(exist_ok=True) + work = Path(tempfile.mkdtemp(prefix=f"{TEMP_PREFIX}{target.name}-", dir=parent)) + try: + size_bytes = _write_bundle(work, planned, store_dir) + # 書いたものを読み込み側で開き直し,使う側と同じ規則で読めることを確かめる + bundle.open_bundle(work) + published = _publish(work, target) + except BaseException: + shutil.rmtree(work, ignore_errors=True) + raise + if not published: + # 照合の間に同じ版が書き出された(同時に実行した等) + shutil.rmtree(work, ignore_errors=True) + return _reuse_existing(target, planned) + return _result(planned, size_bytes, reused=False) + + +def _write_bundle(work: Path, planned: _Planned, store_dir: Path) -> int: + files_dir = work / FILES_DIRNAME + files_dir.mkdir() + size_bytes = 0 + for sha256, ext in planned.files: + source = store.store_path(store_dir, sha256, ext) + if not source.is_file(): + raise ValueError( + f"ストアにファイルが無い(SHA-256 {sha256}).tiasdata check で点検する" + ) + destination = work / bundle_file_path(sha256, ext) + # copyfile は中身だけを写す(ストアの読み取り専用の属性をバンドルに持ち込まない. + # バンドルは再生成でき,受け渡し後に消すため) + shutil.copyfile(source, destination) + # 書き込んだファイルを読み直して照合する(ストアの破損・書き込みの誤りを検出するため) + if sha256_file(destination) != sha256: + raise ValueError( + f"バンドルにコピーしたファイルの照合が合わない(SHA-256 {sha256})." + "tiasdata check でストアを点検する" + ) + size_bytes += destination.stat().st_size + (work / bundle.MANIFEST_CSV).write_bytes(planned.manifest) + (work / bundle.SHA256SUMS).write_bytes(planned.sha256sums) + (work / bundle.DATASET_JSON).write_bytes(planned.dataset_json) + return size_bytes + + +def _publish(work: Path, target: Path) -> bool: + """一時フォルダを正式な名前にする.既に同じ名前があれば何もせず False を返す.""" + if target.exists(): + return False + try: + # Windows は移動先があると FileExistsError.POSIX は空でないフォルダへの rename が失敗する + os.rename(work, target) + except FileExistsError: + return False + except OSError: + if target.exists(): + return False + raise + return True + + +def _reuse_existing(target: Path, planned: _Planned) -> ExportResult: + label = f"データセット '{planned.info.name}' の版 {planned.info.version}" + message = f"{label}: 既にあるバンドルが版の記録と食い違う.上書きせずに停止した" + if not target.is_dir(): + raise ValueError(f"{message}(フォルダでない)") + for filename, expected in ( + (bundle.DATASET_JSON, planned.dataset_json), + (bundle.MANIFEST_CSV, planned.manifest), + (bundle.SHA256SUMS, planned.sha256sums), + ): + path = target / filename + if not path.is_file() or path.read_bytes() != expected: + raise ValueError(f"{message}({filename} が無い,または違う).消してから書き出し直す") + try: + existing = bundle.open_bundle(target) + existing.verify() + except ValueError as e: + raise ValueError(f"{message}({e}).消してから書き出し直す") from e + size_bytes = sum(existing.file_path(row).stat().st_size for row in existing.rows) + return _result(planned, size_bytes, reused=True) + + +def _result(planned: _Planned, size_bytes: int, *, reused: bool) -> ExportResult: + return ExportResult( + name=planned.info.name, + version=planned.info.version, + files=planned.info.files, + size_bytes=size_bytes, + manifest_sha256=planned.info.manifest_sha256, + reused=reused, + ) + + +def run_export(config: Config, name: str, version: int) -> ExportResult: + """設定の目録・ストアから,凍結した版のバンドルを `bundles/` に書き出す. + + Args: + config: 設定. + name: データセット名. + version: 版. + + Returns: + 書き出しの結果. + + Raises: + ValueError: 目録が無いか古い,または書き出せない(`export_bundle` を参照). + """ + # 名前・版の形の誤りを,目録が無い・古い等のエラーより先に知らせる(引数を先に直せるように) + bundle.bundle_relative_dir(name, version) + paths = config.catalog + with closing(catalog.connect_latest(paths.catalog_path)) as conn: + return export_bundle( + conn, + bundles_dir=paths.bundles_dir, + store_dir=paths.store_dir, + name=name, + version=version, + ) diff --git a/src/tiasdata/manifest.py b/src/tiasdata/manifest.py index d76077b..decf7e4 100644 --- a/src/tiasdata/manifest.py +++ b/src/tiasdata/manifest.py @@ -3,21 +3,32 @@ 版の作成(`datasets`)とバンドルの書き出し・読み込み(`bundle`)が同じ形を使うため, 目録(`catalog`)・ストア・設定に依存しない純関数だけを置く(計算サーバで import しても 目録を引き込まないようにするため.SPEC_01「依存の向き」). + +バンドルの読み込み(`bundle`)は目録側のモジュールを import できないため,読み込み側と +目録側(`datasets`・`store`・`cli`)が共に使う小さな純関数(データセット名の形・ +ファイルの SHA-256・pydantic の検証エラーの整形)もここに置く. """ import csv import hashlib import io import json +import re from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path from typing import Any -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, ValidationError # 先頭の固定の列(この後に `<所見>.<項目>` の列が定義の `[columns]` の順で並ぶ) BASE_COLUMNS: tuple[str, ...] = ("path", "sha256", "kind", "subject_id", "capture_id") FILES_DIRNAME = "files" MANIFEST_ENCODING = "utf-8" +# データセット名(SPEC_02「ID の書式」.スキーマの CHECK と同じ).定義ファイル名・ +# バンドルのパスにも使うため,区切り等を含む名前を拒否する +DATASET_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +# 大きいファイルでもメモリを使い切らないよう分割して読む +CHUNK_SIZE = 1024 * 1024 class ManifestEntry(BaseModel): @@ -114,3 +125,36 @@ def manifest_sha256(data: bytes) -> str: """manifest.csv のバイト列の SHA-256(版の指紋.16 進 64 文字の小文字).""" return hashlib.sha256(data).hexdigest() + + +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 validation_error_details(e: ValidationError) -> str: + """pydantic の検証エラーを 1 行にまとめる(`<キーの位置>: <理由>` を `; ` でつなぐ). + + 入力値(パス・患者 ID 等を含みうる)は出さない(SPEC_03「出力の規則」). + + Args: + e: 検証エラー. + + Returns: + キーの位置と理由だけを並べた 1 行. + """ + return "; ".join( + f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" + for error in e.errors(include_input=False, include_url=False) + ) diff --git a/src/tiasdata/store.py b/src/tiasdata/store.py index 5d29bc0..5727ba3 100644 --- a/src/tiasdata/store.py +++ b/src/tiasdata/store.py @@ -18,10 +18,11 @@ from pathlib import Path from typing import NamedTuple -logger = logging.getLogger(__name__) +# CHUNK_SIZE・sha256_file はバンドルの読み込み側と共有する(目録に依存しない manifest に置く). +# `tiasdata.store` からも従来どおり使えるようにここで import する +from tiasdata.manifest import CHUNK_SIZE, sha256_file -# 大きいファイルでもメモリを使い切らないよう分割して読む -CHUNK_SIZE = 1024 * 1024 +logger = logging.getLogger(__name__) # 書きかけの一時ファイルの名前の接頭辞.正式な名前(.)と区別でき, # 点検(`tiasdata check`)で書きかけの残りを見分けられるようにする @@ -93,22 +94,6 @@ 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 と一致するかを照合する(点検用). diff --git a/tests/test_bundle.py b/tests/test_bundle.py new file mode 100644 index 0000000..07202c4 --- /dev/null +++ b/tests/test_bundle.py @@ -0,0 +1,713 @@ +"""バンドルの読み込み・検証(SPEC_03「バンドル」「ライブラリの API」,SPEC_01「使う側からの利用」). + +必須対象 5(バンドル)の読み込み側: 目録なしで `open_bundle`・`load` で読めること, +manifest.csv の SHA-256 の不一致・ファイルの欠け・中身の改変を検出すること. + +バンドルは実装の組み立て関数を使わず,仕様の形(dataset.json・manifest.csv・SHA256SUMS・ +files/.)どおりにテストの中で手で作る(読み込み側を仕様の形に対して確かめるため). +書き出しからの流れは `test_export.py`・`test_cli.py` で確かめる.臨床データは使わない. +""" + +import hashlib +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +import tiasdata +from tiasdata import bundle as bundle_module +from tiasdata.bundle import Bundle, BundleRow, load, open_bundle + +NAME = "tongue_color" +CREATED_AT = "2026-10-06T09:00:00Z" +COMMIT = "0123456789abcdef0123456789abcdef01234567" +HEADER = "path,sha256,kind,subject_id,capture_id,tongue_color.label,tongue_color.severity" +# 架空の患者 ID(エラーメッセージに出ないことを確かめるため,バンドルの外のフォルダ名に仕込む) +LEAK_KEY = "990077" + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +@dataclass +class Item: + """手で作るバンドルの 1 ファイル.""" + + content: bytes + subject_id: str + capture_id: str + label: str = "red" + severity: str = "mild" + ext: str = "png" + path: str | None = None # manifest の path を差し替えるとき + sha256: str | None = None # manifest の sha256 を差し替えるとき + + @property + def real_sha256(self) -> str: + return _sha(self.content) + + @property + def manifest_sha256_cell(self) -> str: + return self.real_sha256 if self.sha256 is None else self.sha256 + + @property + def manifest_path(self) -> str: + return self.path or f"files/{self.real_sha256}.{self.ext}" + + +def default_items() -> list[Item]: + # subject_id・capture_id・sha256 の順に並ぶように並べてある + return [ + Item(b"dummy-image-1", "S000001", "C000001", "red", "mild"), + Item(b"dummy-image-2", "S000001", "C000002", "light_red", "normal"), + Item(b"dummy-image-3", "S000002", "C000003", "pale", "strong"), + ] + + +@dataclass +class Built: + root: Path + items: list[Item] + manifest: bytes + info: dict = field(default_factory=dict) + + +def build_manifest_bytes(items: list[Item], header: str = HEADER) -> bytes: + lines = [header] + for item in items: + lines.append( + ",".join( + [ + item.manifest_path, + item.manifest_sha256_cell, + "tongue_crop_masked", + item.subject_id, + item.capture_id, + item.label, + item.severity, + ] + ) + ) + return ("\n".join(lines) + "\n").encode("utf-8") + + +def write_bundle( + root: Path, + items: list[Item] | None = None, + *, + name: str = NAME, + version: int = 1, + manifest: bytes | None = None, + info_overrides: dict | None = None, + write_files: bool = True, +) -> Built: + """仕様の形どおりにバンドルを書く(改行は LF.bytes で書いて変換させない).""" + items = default_items() if items is None else items + root.mkdir(parents=True, exist_ok=True) + data = build_manifest_bytes(items) if manifest is None else manifest + info = { + "format": 1, + "name": name, + "version": version, + "created_at": CREATED_AT, + "hub_commit": COMMIT, + "files": len(items), + "manifest_sha256": _sha(data), + } + info.update(info_overrides or {}) + (root / "dataset.json").write_bytes(json.dumps(info, indent=2).encode("utf-8") + b"\n") + (root / "manifest.csv").write_bytes(data) + sums = "".join( + f"{item.real_sha256} files/{item.real_sha256}.{item.ext}\n" + for item in sorted(items, key=lambda i: f"files/{i.real_sha256}.{i.ext}") + ) + (root / "SHA256SUMS").write_bytes(sums.encode("utf-8")) + if write_files: + (root / "files").mkdir(exist_ok=True) + for item in items: + (root / "files" / f"{item.real_sha256}.{item.ext}").write_bytes(item.content) + return Built(root=root, items=items, manifest=data, info=info) + + +@pytest.fixture +def bundles_root(tmp_path: Path) -> Path: + # パスに架空の患者 ID を含め,エラーメッセージにパスが出ればすぐ分かるようにする + return tmp_path / f"患者{LEAK_KEY}" / "bundles" + + +@pytest.fixture +def built(bundles_root: Path) -> Built: + return write_bundle(bundles_root / NAME / "v1") + + +def _assert_safe_message(message: str, tmp_path: Path) -> None: + # エラーメッセージにバンドルのパス・データの場所・患者 ID を出さない(SPEC_03「出力の規則」) + for form in (str(tmp_path), tmp_path.as_posix(), str(tmp_path.resolve())): + assert form not in message, message + assert LEAK_KEY not in message, message + assert "bundles" not in message, message + + +def _open_error(path: Path, tmp_path: Path) -> str: + with pytest.raises(ValueError) as exc: + open_bundle(path) + message = str(exc.value) + _assert_safe_message(message, tmp_path) + return message + + +def _verify_error(b: Bundle, tmp_path: Path) -> str: + with pytest.raises(ValueError) as exc: + b.verify() + message = str(exc.value) + _assert_safe_message(message, tmp_path) + return message + + +# --------------------------------------------------------------------------- +# 目録なしで読めること(SPEC_01「依存の向き」) +# --------------------------------------------------------------------------- +_NO_CATALOG_SCRIPT = """ +import sys +import tiasdata +b = tiasdata.open_bundle(sys.argv[1]) +b.verify() +b2 = tiasdata.load("tongue_color", version=1) +assert b2.fingerprint == b.fingerprint +for row in b.rows: + assert b.file_path(row).is_file() +heavy = sorted( + m for m in sys.modules + if m in ("sqlite3", "_sqlite3", "tiasdata.catalog", "tiasdata.store", "tiasdata.config", + "tiasdata.datasets", "tiasdata.export", "tiasdata.cli", "openpyxl") + or m.startswith(("tiasdata.catalog.", "tiasdata.importers")) +) +print("LOADED=" + ",".join(heavy)) +print("ROWS=" + str(len(b.rows))) +""" + + +class TestWithoutCatalog: + def test_import_tiasdata_does_not_load_catalog_store_config_or_sqlite(self) -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys, tiasdata\n" + "names = ('sqlite3', '_sqlite3', 'tiasdata.catalog', 'tiasdata.store'," + " 'tiasdata.config', 'tiasdata.export', 'tiasdata.datasets')\n" + "print(','.join(n for n in names if n in sys.modules))\n" + "print(sorted(n for n in tiasdata.__all__))", + ], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stderr + loaded, exported = result.stdout.splitlines() + assert loaded == "" + for api in ("load", "open_bundle", "Bundle", "BundleRow", "BundleInfo"): + assert api in exported + + def test_open_verify_and_load_in_fresh_process_without_catalog( + self, built: Built, bundles_root: Path, tmp_path: Path + ) -> None: + env = {k: v for k, v in os.environ.items() if k != "TIASDATA_CONFIG"} + env["TIASDATA_BUNDLES"] = str(bundles_root) + # 目録・設定ファイルが無い場所で動かす + result = subprocess.run( + [sys.executable, "-c", _NO_CATALOG_SCRIPT, str(built.root)], + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["LOADED=", "ROWS=3"] + + def test_public_api_is_exported_from_package(self) -> None: + assert tiasdata.load is load + assert tiasdata.open_bundle is open_bundle + assert tiasdata.Bundle is Bundle + assert tiasdata.BundleRow is BundleRow + + +# --------------------------------------------------------------------------- +# open_bundle(正常系) +# --------------------------------------------------------------------------- +class TestOpenBundle: + def test_rows_follow_manifest(self, built: Built) -> None: + b = open_bundle(built.root) + assert len(b.rows) == 3 + for row, item in zip(b.rows, built.items, strict=True): + assert isinstance(row, BundleRow) + assert row.path == f"files/{item.real_sha256}.png" + assert row.sha256 == item.real_sha256 + assert row.kind == "tongue_crop_masked" + assert row.subject_id == item.subject_id + assert row.capture_id == item.capture_id + assert row.annotations == { + "tongue_color": {"label": item.label, "severity": item.severity} + } + + def test_accepts_str_path(self, built: Built) -> None: + assert len(open_bundle(str(built.root)).rows) == 3 + + def test_info_and_fingerprint(self, built: Built) -> None: + b = open_bundle(built.root) + assert b.name == NAME + assert b.version == 1 + assert b.manifest_sha256 == _sha(built.manifest) + assert b.info.created_at == CREATED_AT + assert b.info.hub_commit == COMMIT + assert b.info.files == 3 + assert b.fingerprint == { + "name": NAME, + "version": 1, + "manifest_sha256": _sha(built.manifest), + } + + def test_fingerprint_is_a_fresh_dict(self, built: Built) -> None: + b = open_bundle(built.root) + first = b.fingerprint + first["version"] = 99 + assert b.fingerprint["version"] == 1 + + def test_file_path_points_to_file_in_bundle(self, built: Built) -> None: + b = open_bundle(built.root) + for row, item in zip(b.rows, built.items, strict=True): + path = b.file_path(row) + assert path.is_absolute() + assert path.read_bytes() == item.content + assert path.resolve().is_relative_to(built.root.resolve()) + + def test_rows_are_immutable(self, built: Built) -> None: + b = open_bundle(built.root) + with pytest.raises(TypeError): + b.rows[0] = b.rows[1] # type: ignore[index] + with pytest.raises(Exception): # noqa: B017,PT011 pydantic の frozen + b.rows[0].subject_id = "S999999" # type: ignore[misc] + + def test_repr_has_no_path(self, built: Built, tmp_path: Path) -> None: + _assert_safe_message(repr(open_bundle(built.root)), tmp_path) + + def test_empty_annotation_cell_is_none(self, bundles_root: Path) -> None: + items = default_items() + items[1].severity = "" + b = open_bundle(write_bundle(bundles_root / NAME / "v1", items).root) + assert b.rows[1].annotations == {"tongue_color": {"label": "light_red", "severity": None}} + assert b.rows[0].annotations["tongue_color"]["severity"] == "mild" + + def test_annotation_values_are_strings(self, bundles_root: Path) -> None: + # 文字列以外の値も manifest の表記(文字列)のまま返す(ユーザー判断⑤) + items = default_items() + items[0].severity = "3" + b = open_bundle(write_bundle(bundles_root / NAME / "v1", items).root) + assert b.rows[0].annotations["tongue_color"]["severity"] == "3" + + def test_annotations_grouped_by_kind(self, bundles_root: Path) -> None: + item = Item(b"x", "S000001", "C000001") + header = ( + "path,sha256,kind,subject_id,capture_id," + "tongue_color.label,tongue_coating.thickness,tongue_color.severity" + ) + line = f"files/{item.real_sha256}.png,{item.real_sha256},k,S000001,C000001,red,,mild" + data = f"{header}\n{line}\n".encode() + b = open_bundle(write_bundle(bundles_root / NAME / "v1", [item], manifest=data).root) + assert b.rows[0].annotations == { + "tongue_color": {"label": "red", "severity": "mild"}, + "tongue_coating": {"thickness": None}, + } + + def test_manifest_without_annotation_columns(self, bundles_root: Path) -> None: + item = Item(b"x", "S000001", "C000001") + data = ( + "path,sha256,kind,subject_id,capture_id\n" + f"files/{item.real_sha256}.png,{item.real_sha256},k,S000001,C000001\n" + ).encode() + b = open_bundle(write_bundle(bundles_root / NAME / "v1", [item], manifest=data).root) + assert b.rows[0].annotations == {} + + def test_csv_quoted_values(self, bundles_root: Path) -> None: + # 値に , や " を含む所見(JSON の表記等)も CSV として読む + item = Item(b"x", "S000001", "C000001") + header = "path,sha256,kind,subject_id,capture_id,tongue_color.note" + line = f'files/{item.real_sha256}.png,{item.real_sha256},k,S000001,C000001,"[1,""a""]"' + data = f"{header}\n{line}\n".encode() + b = open_bundle(write_bundle(bundles_root / NAME / "v1", [item], manifest=data).root) + assert b.rows[0].annotations == {"tongue_color": {"note": '[1,"a"]'}} + + def test_unknown_keys_in_dataset_json_are_ignored(self, bundles_root: Path) -> None: + built = write_bundle(bundles_root / NAME / "v1", info_overrides={"future_key": "x"}) + assert open_bundle(built.root).version == 1 + + def test_zero_rows_bundle_opens(self, bundles_root: Path) -> None: + built = write_bundle(bundles_root / NAME / "v1", [], manifest=(HEADER + "\n").encode()) + b = open_bundle(built.root) + assert b.rows == () + b.verify() + + def test_sha256sums_with_binary_marker_verifies(self, built: Built) -> None: + # `sha256sum -b` の形(` *<パス>`)も受け付ける + text = (built.root / "SHA256SUMS").read_bytes().decode() + (built.root / "SHA256SUMS").write_bytes(text.replace(" files/", " *files/").encode()) + open_bundle(built.root).verify() + + +# --------------------------------------------------------------------------- +# open_bundle(開けないもの) +# --------------------------------------------------------------------------- +class TestOpenBundleRejects: + def test_missing_folder(self, bundles_root: Path, tmp_path: Path) -> None: + _open_error(bundles_root / NAME / "v9", tmp_path) + + def test_path_is_file(self, built: Built, tmp_path: Path) -> None: + _open_error(built.root / "manifest.csv", tmp_path) + + @pytest.mark.parametrize("filename", ["dataset.json", "manifest.csv"]) + def test_missing_metadata_file(self, built: Built, tmp_path: Path, filename: str) -> None: + (built.root / filename).unlink() + _open_error(built.root, tmp_path) + + def test_metadata_file_is_directory(self, built: Built, tmp_path: Path) -> None: + (built.root / "manifest.csv").unlink() + (built.root / "manifest.csv").mkdir() + _open_error(built.root, tmp_path) + + def test_tampered_manifest_stops(self, built: Built, tmp_path: Path) -> None: + # 所見の値を書き換えると SHA-256 が dataset.json と一致しない + data = built.manifest.replace(b",pale,strong", b",red,strong") + assert data != built.manifest + (built.root / "manifest.csv").write_bytes(data) + message = _open_error(built.root, tmp_path) + assert "SHA-256" in message + + def test_manifest_with_crlf_stops(self, built: Built, tmp_path: Path) -> None: + # 改行を CRLF に変えたもの(Windows での変換等)も別の中身として止める + (built.root / "manifest.csv").write_bytes(built.manifest.replace(b"\n", b"\r\n")) + _open_error(built.root, tmp_path) + + def test_manifest_sha256_in_dataset_json_wrong( + self, bundles_root: Path, tmp_path: Path + ) -> None: + built = write_bundle( + bundles_root / NAME / "v1", info_overrides={"manifest_sha256": "0" * 64} + ) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "overrides", + [ + {"format": 2}, + {"format": "1"}, + {"files": 2}, + {"files": 4}, + {"version": 0}, + {"version": "1"}, + {"name": "Bad-Name"}, + {"name": "../x"}, + {"manifest_sha256": "XYZ"}, + ], + ids=lambda o: "-".join(f"{k}={v}" for k, v in o.items()), + ) + def test_invalid_dataset_json_values( + self, bundles_root: Path, tmp_path: Path, overrides: dict + ) -> None: + built = write_bundle(bundles_root / NAME / "v1", info_overrides=overrides) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "key", ["format", "name", "version", "created_at", "hub_commit", "files", "manifest_sha256"] + ) + def test_missing_dataset_json_key(self, built: Built, tmp_path: Path, key: str) -> None: + info = dict(built.info) + del info[key] + (built.root / "dataset.json").write_bytes(json.dumps(info).encode()) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize("content", [b"{not json", b"[1, 2]", b"\xff\xfe", b""]) + def test_dataset_json_not_object(self, built: Built, tmp_path: Path, content: bytes) -> None: + (built.root / "dataset.json").write_bytes(content) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "bad_path", + [ + "../{sha}.png", + "files/../../{sha}.png", + "files/sub/{sha}.png", + "files\\{sha}.png", + "/files/{sha}.png", + "C:/files/{sha}.png", + "{sha}.png", + "files/{sha}", + "files/{sha}.PNG", + "files/{other}.png", + "other/{sha}.png", + ], + ) + def test_invalid_path_shape(self, bundles_root: Path, tmp_path: Path, bad_path: str) -> None: + items = default_items() + items[0].path = bad_path.format(sha=items[0].real_sha256, other=items[1].real_sha256) + built = write_bundle(bundles_root / NAME / "v1", items) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize("bad_sha", ["", "abc", "A" * 64, "g" * 64]) + def test_invalid_sha256_cell(self, bundles_root: Path, tmp_path: Path, bad_sha: str) -> None: + items = default_items() + items[0].sha256 = bad_sha + built = write_bundle(bundles_root / NAME / "v1", items) + _open_error(built.root, tmp_path) + + def test_duplicate_sha256_rows(self, bundles_root: Path, tmp_path: Path) -> None: + items = default_items() + items.append(Item(items[0].content, "S000003", "C000009")) + built = write_bundle(bundles_root / NAME / "v1", items) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "header", + [ + "sha256,path,kind,subject_id,capture_id,tongue_color.label,tongue_color.severity", + "path,sha256,kind,subject_id,tongue_color.label,tongue_color.severity", + "path,sha256,kind,subject_id,capture_id,label,tongue_color.severity", + "path,sha256,kind,subject_id,capture_id,tongue_color.label,tongue_color.label", + ], + ) + def test_invalid_header(self, bundles_root: Path, tmp_path: Path, header: str) -> None: + items = default_items() + data = build_manifest_bytes(items, header=header) + built = write_bundle(bundles_root / NAME / "v1", items, manifest=data) + _open_error(built.root, tmp_path) + + def test_empty_manifest(self, bundles_root: Path, tmp_path: Path) -> None: + built = write_bundle(bundles_root / NAME / "v1", [], manifest=b"") + _open_error(built.root, tmp_path) + + def test_row_with_wrong_column_count(self, bundles_root: Path, tmp_path: Path) -> None: + items = default_items() + data = build_manifest_bytes(items).replace(b",pale,strong", b",pale") + built = write_bundle(bundles_root / NAME / "v1", items, manifest=data) + _open_error(built.root, tmp_path) + + def test_manifest_not_utf8(self, bundles_root: Path, tmp_path: Path) -> None: + items = default_items() + data = build_manifest_bytes(items).replace(b"red,mild", b"\x82\xa0,mild") + built = write_bundle(bundles_root / NAME / "v1", items, manifest=data) + _open_error(built.root, tmp_path) + + +# --------------------------------------------------------------------------- +# verify +# --------------------------------------------------------------------------- +class TestVerify: + def test_intact_bundle_returns_none(self, built: Built) -> None: + assert open_bundle(built.root).verify() is None + + def test_detects_modified_file(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + target = b.file_path(b.rows[1]) + target.write_bytes(target.read_bytes() + b"tampered") + message = _verify_error(b, tmp_path) + assert b.rows[1].sha256[:12] in message + + def test_detects_missing_file(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + b.file_path(b.rows[0]).unlink() + message = _verify_error(b, tmp_path) + assert b.rows[0].sha256[:12] in message + + def test_detects_missing_files_folder(self, built: Built, tmp_path: Path) -> None: + shutil.rmtree(built.root / "files") + _verify_error(open_bundle(built.root), tmp_path) + + def test_detects_extra_file(self, built: Built, tmp_path: Path) -> None: + (built.root / "files" / f"{_sha(b'extra')}.png").write_bytes(b"extra") + _verify_error(open_bundle(built.root), tmp_path) + + def test_detects_manifest_changed_after_open(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + (built.root / "manifest.csv").write_bytes(built.manifest.replace(b"pale", b"red")) + _verify_error(b, tmp_path) + + def test_detects_missing_sha256sums(self, built: Built, tmp_path: Path) -> None: + (built.root / "SHA256SUMS").unlink() + _verify_error(open_bundle(built.root), tmp_path) + + @pytest.mark.parametrize( + "change", + ["drop_line", "duplicate_line", "extra_line", "wrong_hash", "bad_format"], + ) + def test_detects_sha256sums_not_matching_manifest( + self, built: Built, tmp_path: Path, change: str + ) -> None: + path = built.root / "SHA256SUMS" + lines = path.read_bytes().decode().splitlines(keepends=True) + if change == "drop_line": + lines = lines[1:] + elif change == "duplicate_line": + lines = [*lines, lines[0]] + elif change == "extra_line": + extra = _sha(b"extra") + lines = [*lines, f"{extra} files/{extra}.png\n"] + elif change == "wrong_hash": + sha = lines[0][:64] + lines[0] = lines[0].replace(sha, "0" * 64, 1) + else: + lines[0] = lines[0].replace(" ", "\t", 1) + path.write_bytes("".join(lines).encode()) + _verify_error(open_bundle(built.root), tmp_path) + + def test_sha256sums_order_does_not_matter(self, built: Built) -> None: + path = built.root / "SHA256SUMS" + lines = path.read_bytes().decode().splitlines(keepends=True) + path.write_bytes("".join(reversed(lines)).encode()) + open_bundle(built.root).verify() + + def test_verify_message_counts(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + b.file_path(b.rows[0]).unlink() + b.file_path(b.rows[1]).write_bytes(b"changed") + message = _verify_error(b, tmp_path) + assert "欠け 1 件" in message + assert "不一致 1 件" in message + + @pytest.mark.skipif(shutil.which("sha256sum") is None, reason="sha256sum が無い") + def test_sha256sums_checks_with_sha256sum_command(self, built: Built) -> None: + # サーバで `sha256sum -c SHA256SUMS` が通る形であること(SPEC_03「受け渡し」手順 4) + result = subprocess.run( + ["sha256sum", "-c", "--quiet", "SHA256SUMS"], + cwd=built.root, + capture_output=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# load +# --------------------------------------------------------------------------- +class TestLoad: + def test_load_from_env_var( + self, built: Built, bundles_root: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + b = load(NAME, version=1) + assert b.fingerprint == open_bundle(built.root).fingerprint + assert b.root.resolve() == built.root.resolve() + assert len(b.rows) == 3 + + def test_load_default_is_home_data_tiasdata( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + write_bundle(home / "data" / "tiasdata" / NAME / "v2", version=2) + monkeypatch.delenv("TIASDATA_BUNDLES", raising=False) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + b = load(NAME, version=2) + assert b.version == 2 + assert b.root.resolve() == (home / "data" / "tiasdata" / NAME / "v2").resolve() + + def test_empty_env_var_falls_back_to_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + write_bundle(home / "data" / "tiasdata" / NAME / "v1") + monkeypatch.setenv("TIASDATA_BUNDLES", "") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + assert load(NAME, version=1).version == 1 + + def test_version_is_keyword_only( + self, built: Built, bundles_root: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(TypeError): + load(NAME, 1) # type: ignore[misc] + with pytest.raises(TypeError): + load(NAME) # type: ignore[call-arg] + + @pytest.mark.parametrize("version", [0, -1, True, 1.0, "1", None]) + def test_invalid_version( + self, + built: Built, + bundles_root: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + version: object, + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=version) # type: ignore[arg-type] + _assert_safe_message(str(exc.value), tmp_path) + + @pytest.mark.parametrize("name", ["", "Tongue", "../tongue_color", "a/b", "a\\b", "1abc", "."]) + def test_invalid_name( + self, bundles_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(name, version=1) + _assert_safe_message(str(exc.value), tmp_path) + + def test_missing_bundle( + self, built: Built, bundles_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=2) + _assert_safe_message(str(exc.value), tmp_path) + with pytest.raises(ValueError): + load("other", version=1) + + @pytest.mark.parametrize(("json_name", "json_version"), [("other", 1), (NAME, 2)]) + def test_dataset_json_disagrees_with_request( + self, + bundles_root: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + json_name: str, + json_version: int, + ) -> None: + # 置き場所のフォルダ名と dataset.json の名前・版が違う(別の版のフォルダを写した等) + write_bundle(bundles_root / NAME / "v1", name=json_name, version=json_version) + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=1) + _assert_safe_message(str(exc.value), tmp_path) + + def test_load_rejects_tampered_manifest( + self, built: Built, bundles_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (built.root / "manifest.csv").write_bytes(built.manifest + b"\n") + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=1) + _assert_safe_message(str(exc.value), tmp_path) + + +# --------------------------------------------------------------------------- +# SHA256SUMS の読み書き(`sha256sum -c` の形) +# --------------------------------------------------------------------------- +class TestSha256sumsFormat: + def test_build_is_two_spaces_lf_sorted_by_path(self) -> None: + a, b = "a" * 64, "b" * 64 + data = bundle_module.build_sha256sums([(b, f"files/{b}.png"), (a, f"files/{a}.jpg")]) + assert data == f"{a} files/{a}.jpg\n{b} files/{b}.png\n".encode() + assert b"\r" not in data + + def test_parse_roundtrip(self) -> None: + a = "a" * 64 + entries = [(a, f"files/{a}.png")] + assert bundle_module.parse_sha256sums(bundle_module.build_sha256sums(entries)) == entries diff --git a/tests/test_cli.py b/tests/test_cli.py index c876aac..2bf6105 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -677,3 +677,108 @@ assert "tongue_color(定義ファイルなし): 版 1 件" in capsys.readouterr().out assert main(["dataset", "show", "tongue_color"]) == 0 assert "版: 1 件" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# export(SPEC_03「CLI」「バンドル」,testing.md「CLI のエントリポイント」・必須対象 5・6) +# --------------------------------------------------------------------------- +class TestExportCommand: + def test_init_import_freeze_export_flow( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + from tiasdata import open_bundle + + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + assert main(["dataset", "freeze", "tongue_color"]) == 0 + [(_, _, _, digest, _)] = _versions(import_env) + capsys.readouterr() + + assert main(["export", "tongue_color", "--version", "1"]) == 0 + captured = capsys.readouterr() + assert captured.err == "" + assert "書き出した" in captured.out + assert "ファイル 18 件" in captured.out + assert digest in captured.out + _assert_no_personal_info(captured.out, import_env) + + target = import_env.catalog_root / "bundles" / "tongue_color" / "v1" + bundle = open_bundle(target) + assert bundle.verify() is None + assert bundle.fingerprint == { + "name": "tongue_color", + "version": 1, + "manifest_sha256": digest, + } + assert len(bundle.rows) == 18 + assert (target / "manifest.csv").read_bytes() == _rebuild(import_env, 1) + # 取り込んだ画像がそのままの中身で入っている + sources = { + hashlib.sha256(image.path.read_bytes()).hexdigest(): image + for image in import_env.source.images + } + for row in bundle.rows: + image = sources[row.sha256] + assert bundle.file_path(row).read_bytes() == image.path.read_bytes() + assert row.annotations["tongue_color"] == { + "label": image.label, + "severity": image.severity, + } + # 患者 ID・元のファイル名がバンドルのメタデータに入っていない + # (SHA-256 の 16 進が偶然 <数字>.png・患者 ID に見えないよう SHA-256 を除いて調べる) + for filename in ("dataset.json", "manifest.csv", "SHA256SUMS"): + text = (target / filename).read_bytes().decode("utf-8") + _assert_no_personal_info(re.sub(r"[0-9a-f]{64}", "", text), import_env) + before = {p: p.read_bytes() for p in target.rglob("*") if p.is_file()} + + # 2 回目は照合して使う(上書きしない) + assert main(["export", "tongue_color", "--version", "1"]) == 0 + out = capsys.readouterr().out + assert "照合して使った" in out + _assert_no_personal_info(out, import_env) + assert {p: p.read_bytes() for p in target.rglob("*") if p.is_file()} == before + + # 書き出したバンドルは check の対象外で,問題を増やさない + assert main(["check"]) == 0 + capsys.readouterr() + + # 改変した既存のバンドルは上書きせず終了コード 1 + first = sorted((target / "files").iterdir())[0] + first.write_bytes(b"tampered") + assert main(["export", "tongue_color", "--version", "1"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + _assert_one_line_error(captured.err, import_env) + assert first.read_bytes() == b"tampered" + + # 存在しない版・データセット・不正な名前は終了コード 1 + for args in ( + ["tongue_color", "--version", "2"], + ["no_such", "--version", "1"], + ["../tongue_color", "--version", "1"], + ["tongue_color", "--version", "0"], + ): + assert main(["export", *args]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + _assert_one_line_error(captured.err, import_env) + + def test_export_before_init_exits_1( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["export", "tongue_color", "--version", "1"]) == 1 + _assert_one_line_error(capsys.readouterr().err, import_env) + + @pytest.mark.parametrize( + "args", + [ + ["export", "tongue_color"], + ["export", "--version", "1"], + ["export", "tongue_color", "--version", "abc"], + ["export", "tongue_color", "--version"], + ], + ) + def test_export_argument_errors_exit_2(self, config_env: Path, args: list[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(args) + assert exc.value.code == 2 diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..11b12d7 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,518 @@ +"""バンドルの書き出し(SPEC_03「バンドル」「CLI」の export・「計算サーバへの受け渡し」手順 1). + +必須対象 5(バンドル)の書き出し側: 書き出したバンドルを `open_bundle` で読めること, +同じ版から同じ manifest.csv(バイト列)が得られること,既にあるバンドルを上書きせず照合すること. + +目録はダミーの行を直接登録して作り,ストアには小さいダミーのファイルを置く(取り込みを繰り返さず +速くするため.取り込みからの流れは `test_cli.py` の統合テストで確かめる).臨床データは使わない. +""" + +import hashlib +import json +import os +import shutil +import sqlite3 +import stat +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from tiasdata import kinds, store +from tiasdata.bundle import open_bundle +from tiasdata.datasets import freeze_version, load_definition, rebuild_manifest +from tiasdata.export import export_bundle +from tiasdata.repo import RepoState + +NAME = "tongue_color" +COMMIT = "0123456789abcdef0123456789abcdef01234567" +NOW = datetime(2026, 10, 6, 9, 0, 0, tzinfo=UTC) +# 元のファイル名・フォルダ名に仕込む架空の患者 ID(バンドル・メッセージに出ないことを確かめる) +LEAK_KEY = "990077" + +TONGUE_COLOR_TOML = """\ +name = "tongue_color" +description = "舌色 5 分類+程度(テスト)" + +[select] +asset_kind = "tongue_crop_masked" +require = ["tongue_color"] + +[columns] +tongue_color = ["label", "severity"] +""" + +DATASET_JSON_KEYS = { + "format", + "name", + "version", + "created_at", + "hub_commit", + "files", + "manifest_sha256", +} + + +def _tc(label: str, severity: str) -> str: + return kinds.dump_annotation_value( + kinds.validate_annotation_value("tongue_color", {"label": label, "severity": severity}) + ) + + +@dataclass +class Env: + conn: sqlite3.Connection + root: Path + store_dir: Path + bundles_dir: Path + datasets_dir: Path + contents: dict[str, bytes] # SHA-256 → 中身 + + def add(self, n: int, subject_id: str, label: str = "red", severity: str = "mild") -> int: + """撮影 1 つ・ファイル 1 つ(ストアに実体あり)・撮影に付いた舌色の所見 1 つを足す.""" + capture_id = f"C{n:06d}" + source = self.root / "source" / f"患者{LEAK_KEY}" / f"{n}_original.png" + source.parent.mkdir(parents=True, exist_ok=True) + content = f"dummy-image-{n}".encode() * (n + 1) + source.write_bytes(content) + stored = store.add_file(self.store_dir, source) + self.contents[stored.sha256] = content + self.conn.execute( + "INSERT OR IGNORE INTO subjects (subject_id, created_at) VALUES (?, ?)", + (subject_id, "2026-10-01T00:00:00Z"), + ) + self.conn.execute( + "INSERT INTO captures (capture_id, subject_id) VALUES (?, ?)", (capture_id, subject_id) + ) + self.conn.execute( + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source, source_name," + " registered_at) VALUES (?, 'tongue_crop_masked', ?, 'png', ?, 'dummy_src', ?," + " '2026-10-01T00:00:00Z')", + (stored.sha256, capture_id, len(content), f"患者{LEAK_KEY}/{n}_original.png"), + ) + cursor = self.conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " VALUES ('tongue_color', ?, ?, 'dummy_src', '2026-10-01T00:00:00Z')", + (capture_id, _tc(label, severity)), + ) + self.conn.commit() + return int(cursor.lastrowid) + + def freeze(self, now: datetime = NOW): + loaded = load_definition(self.datasets_dir, NAME) + return freeze_version( + self.conn, loaded, repo_state=RepoState(commit=COMMIT, uncommitted=0), now=now + ) + + def export(self, version: int = 1, *, bundles_dir: Path | None = None, name: str = NAME): + return export_bundle( + self.conn, + bundles_dir=self.bundles_dir if bundles_dir is None else bundles_dir, + store_dir=self.store_dir, + name=name, + version=version, + ) + + def target(self, version: int = 1) -> Path: + return self.bundles_dir / NAME / f"v{version}" + + +@pytest.fixture +def env(tmp_path: Path, catalog_conn: sqlite3.Connection) -> Env: + """版 1(症例 2・撮影 3・ファイル 3)を凍結した目録と,実体のあるストア.""" + catalog_conn.execute("PRAGMA synchronous = OFF") + # パスに架空の患者 ID を含め,メッセージにパスが出ればすぐ分かるようにする + root = tmp_path / f"患者{LEAK_KEY}" + store_dir = root / "store" + bundles_dir = root / "bundles" + datasets_dir = root / "datasets" + for d in (store_dir, bundles_dir, datasets_dir): + d.mkdir(parents=True) + (datasets_dir / f"{NAME}.toml").write_bytes(TONGUE_COLOR_TOML.encode("utf-8")) + e = Env(catalog_conn, root, store_dir, bundles_dir, datasets_dir, {}) + e.add(3, "S000001", "red", "mild") + e.add(4, "S000001", "light_red", "normal") + e.add(1, "S000002", "pale", "strong") + result = e.freeze() + assert result.version == 1 + return e + + +def _record(conn: sqlite3.Connection, version: int = 1) -> tuple[str, str, str]: + return conn.execute( + "SELECT manifest_sha256, created_at, hub_commit FROM dataset_versions" + " WHERE name = ? AND version = ?", + (NAME, version), + ).fetchone() + + +def _tree(path: Path) -> dict[str, bytes]: + return { + p.relative_to(path).as_posix(): p.read_bytes() + for p in sorted(path.rglob("*")) + if p.is_file() + } + + +def _temp_entries(bundles_dir: Path) -> list[Path]: + return [p for p in bundles_dir.rglob("*") if p.name.startswith(".tmp-")] + + +def _assert_safe_message(message: str, env: Env) -> None: + # 例外メッセージにバンドル・ストアのパス・患者 ID・元のファイル名を出さない + for form in (str(env.root), env.root.as_posix(), str(env.root.resolve())): + assert form not in message, message + assert LEAK_KEY not in message, message + assert "_original" not in message, message + + +def _export_error(env: Env, **kwargs) -> str: + with pytest.raises(ValueError) as exc: + env.export(**kwargs) + message = str(exc.value) + _assert_safe_message(message, env) + return message + + +def _make_writable(path: Path) -> None: + os.chmod(path, stat.S_IWRITE | stat.S_IREAD) + + +# --------------------------------------------------------------------------- +# 書き出したバンドルの形 +# --------------------------------------------------------------------------- +class TestExportedBundle: + def test_layout(self, env: Env) -> None: + result = env.export() + target = env.target() + assert target.is_dir() + assert sorted(p.name for p in target.iterdir()) == [ + "SHA256SUMS", + "dataset.json", + "files", + "manifest.csv", + ] + assert sorted(p.name for p in (target / "files").iterdir()) == sorted( + f"{sha}.png" for sha in env.contents + ) + assert result.reused is False + assert result.name == NAME + assert result.version == 1 + assert result.files == 3 + assert result.size_bytes == sum(len(c) for c in env.contents.values()) + + def test_files_match_store_contents(self, env: Env) -> None: + env.export() + for sha, content in env.contents.items(): + path = env.target() / "files" / f"{sha}.png" + assert path.read_bytes() == content + assert hashlib.sha256(path.read_bytes()).hexdigest() == sha + + def test_manifest_matches_version_record(self, env: Env) -> None: + result = env.export() + data = (env.target() / "manifest.csv").read_bytes() + digest, _, _ = _record(env.conn) + assert hashlib.sha256(data).hexdigest() == digest + assert result.manifest_sha256 == digest + assert data == rebuild_manifest(env.conn, NAME, 1) + assert b"\r" not in data + assert not data.startswith(b"\xef\xbb\xbf") + + def test_dataset_json_items(self, env: Env) -> None: + env.export() + raw = (env.target() / "dataset.json").read_bytes() + info = json.loads(raw.decode("utf-8")) + digest, created_at, hub_commit = _record(env.conn) + assert set(info) == DATASET_JSON_KEYS + assert info == { + "format": 1, + "name": NAME, + "version": 1, + "created_at": created_at, # 版の作成日時(書き出した日時ではない) + "hub_commit": hub_commit, + "files": 3, + "manifest_sha256": digest, + } + assert hub_commit == COMMIT + assert b"\r" not in raw + + def test_sha256sums_format(self, env: Env) -> None: + env.export() + raw = (env.target() / "SHA256SUMS").read_bytes() + assert b"\r" not in raw + assert raw.endswith(b"\n") + lines = raw.decode("utf-8").splitlines() + assert len(lines) == 3 + entries = set() + for line in lines: + sha, sep, path = line[:64], line[64:66], line[66:] + assert sep == " " # sha256sum の形(間は空白 2 つ) + assert path == f"files/{sha}.png" + entries.add(sha) + assert entries == set(env.contents) + + def test_no_personal_info_in_bundle_metadata(self, env: Env) -> None: + env.export() + for filename in ("dataset.json", "manifest.csv", "SHA256SUMS"): + text = (env.target() / filename).read_bytes().decode("utf-8") + assert LEAK_KEY not in text + assert "_original" not in text + assert str(env.root) not in text and env.root.as_posix() not in text + + def test_opens_and_verifies_with_reader(self, env: Env) -> None: + env.export() + b = open_bundle(env.target()) + assert b.verify() is None + digest, _, _ = _record(env.conn) + assert b.fingerprint == {"name": NAME, "version": 1, "manifest_sha256": digest} + assert [(r.subject_id, r.capture_id) for r in b.rows] == [ + ("S000001", "C000003"), + ("S000001", "C000004"), + ("S000002", "C000001"), + ] + assert b.rows[1].annotations == { + "tongue_color": {"label": "light_red", "severity": "normal"} + } + for row in b.rows: + assert b.file_path(row).read_bytes() == env.contents[row.sha256] + + def test_bundle_files_are_not_read_only(self, env: Env) -> None: + # ストアは読み取り専用だが,バンドルには持ち込まない(ユーザー判断①) + env.export() + for path in env.target().rglob("*"): + if path.is_file(): + assert os.access(path, os.W_OK), path.name + assert path.stat().st_mode & stat.S_IWRITE + # 書き出した後に消せる(受け渡し後に消すため) + shutil.rmtree(env.target()) + assert not env.target().exists() + + def test_no_temp_folder_left_after_success(self, env: Env) -> None: + env.export() + assert _temp_entries(env.bundles_dir) == [] + assert sorted(p.name for p in (env.bundles_dir / NAME).iterdir()) == ["v1"] + + def test_store_is_unchanged(self, env: Env) -> None: + before = _tree(env.store_dir) + env.export() + assert _tree(env.store_dir) == before + + +# --------------------------------------------------------------------------- +# 同じ版から同じバンドル(PLAN_02 Step 5 の完了条件) +# --------------------------------------------------------------------------- +class TestReproducible: + def test_same_version_gives_same_bytes(self, env: Env, tmp_path: Path) -> None: + env.export() + other = tmp_path / "other_bundles" + other.mkdir() + env.export(bundles_dir=other) + assert _tree(env.target()) == _tree(other / NAME / "v1") + + def test_old_version_unchanged_after_new_data_and_correction( + self, env: Env, tmp_path: Path + ) -> None: + env.export() + first = _tree(env.target()) + # 新しいデータの追加と所見の訂正の後に版 2 を作る + new_id = env.add(9, "S000003", "purple", "strong") + old_id = env.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000003'" + ).fetchone()[0] + cursor = env.conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " VALUES ('tongue_color', 'C000003', ?, 'dummy_src', '2026-10-02T00:00:00Z')", + (_tc("dark_red", "strong"),), + ) + env.conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (cursor.lastrowid, old_id), + ) + env.conn.commit() + assert new_id + assert env.freeze(datetime(2026, 10, 7, tzinfo=UTC)).version == 2 + + other = tmp_path / "other_bundles" + other.mkdir() + env.export(1, bundles_dir=other) + assert _tree(other / NAME / "v1") == first + assert open_bundle(other / NAME / "v1").rows[0].annotations["tongue_color"]["label"] == ( + "red" + ) + + env.export(2) + v2 = open_bundle(env.target(2)) + v2.verify() + assert len(v2.rows) == 4 + assert v2.rows[0].annotations["tongue_color"]["label"] == "dark_red" + assert ( + json.loads((env.target(2) / "dataset.json").read_bytes())["created_at"] + != (json.loads(first["dataset.json"])["created_at"]) + ) + + +# --------------------------------------------------------------------------- +# 既にあるバンドル(上書きしない・照合して使う) +# --------------------------------------------------------------------------- +class TestExistingBundle: + def test_reuses_intact_bundle(self, env: Env) -> None: + env.export() + before = _tree(env.target()) + mtimes = {p.name: p.stat().st_mtime_ns for p in env.target().rglob("*") if p.is_file()} + result = env.export() + assert result.reused is True + assert result.files == 3 + assert result.size_bytes == sum(len(c) for c in env.contents.values()) + assert _tree(env.target()) == before + assert { + p.name: p.stat().st_mtime_ns for p in env.target().rglob("*") if p.is_file() + } == mtimes + assert _temp_entries(env.bundles_dir) == [] + + @pytest.mark.parametrize( + "tamper", + [ + "modify_file", + "remove_file", + "extra_file", + "modify_manifest", + "remove_manifest", + "modify_dataset_json", + "remove_dataset_json", + "modify_sha256sums", + "remove_sha256sums", + "remove_files_folder", + ], + ) + def test_mismatching_bundle_stops_without_overwrite(self, env: Env, tamper: str) -> None: + env.export() + target = env.target() + first_file = sorted((target / "files").iterdir())[0] + if tamper == "modify_file": + first_file.write_bytes(b"tampered") + elif tamper == "remove_file": + first_file.unlink() + elif tamper == "extra_file": + extra = hashlib.sha256(b"extra").hexdigest() + (target / "files" / f"{extra}.png").write_bytes(b"extra") + elif tamper == "modify_manifest": + path = target / "manifest.csv" + path.write_bytes(path.read_bytes().replace(b"pale", b"red")) + elif tamper == "modify_dataset_json": + path = target / "dataset.json" + info = json.loads(path.read_bytes()) + info["hub_commit"] = "f" * 40 + path.write_bytes(json.dumps(info, indent=2).encode() + b"\n") + elif tamper == "modify_sha256sums": + path = target / "SHA256SUMS" + path.write_bytes(path.read_bytes().replace(b" ", b" *", 1)) + elif tamper == "remove_files_folder": + shutil.rmtree(target / "files") + else: + filename = { + "remove_manifest": "manifest.csv", + "remove_dataset_json": "dataset.json", + "remove_sha256sums": "SHA256SUMS", + }[tamper] + (target / filename).unlink() + before = _tree(target) + + message = _export_error(env) + assert "上書き" in message + assert _tree(target) == before # 上書き・修復しない + assert _temp_entries(env.bundles_dir) == [] + + def test_existing_empty_folder_stops(self, env: Env) -> None: + env.target().mkdir(parents=True) + _export_error(env) + assert list(env.target().iterdir()) == [] + + def test_existing_path_is_file_stops(self, env: Env) -> None: + env.target().parent.mkdir(parents=True) + env.target().write_bytes(b"not a bundle") + _export_error(env) + assert env.target().read_bytes() == b"not a bundle" + + def test_existing_bundle_of_other_version_stops(self, env: Env) -> None: + # v1 の場所に別の版のバンドルが置かれている + env.add(9, "S000003") + assert env.freeze(datetime(2026, 10, 7, tzinfo=UTC)).version == 2 + env.export(2) + shutil.copytree(env.target(2), env.target(1)) + before = _tree(env.target(1)) + _export_error(env, version=1) + assert _tree(env.target(1)) == before + + +# --------------------------------------------------------------------------- +# 書き出せないもの +# --------------------------------------------------------------------------- +class TestExportStops: + def test_missing_version(self, env: Env) -> None: + _export_error(env, version=2) + assert not (env.bundles_dir / NAME / "v2").exists() + + def test_unknown_dataset(self, env: Env) -> None: + _export_error(env, name="no_such") + assert not (env.bundles_dir / "no_such").exists() + + @pytest.mark.parametrize("name", ["", "Bad", "../tongue_color", "a/b", "a\\b", "."]) + def test_invalid_name(self, env: Env, name: str) -> None: + _export_error(env, name=name) + assert list(env.bundles_dir.iterdir()) == [] + + @pytest.mark.parametrize("version", [0, -1, True]) + def test_invalid_version(self, env: Env, version: int) -> None: + _export_error(env, version=version) + assert list(env.bundles_dir.iterdir()) == [] + + def test_missing_bundles_dir(self, env: Env, tmp_path: Path) -> None: + _export_error(env, bundles_dir=env.root / "no_bundles") + assert not (env.root / "no_bundles").exists() + + def test_missing_store_file_leaves_nothing(self, env: Env) -> None: + victim = next(iter(env.contents)) + path = store.store_path(env.store_dir, victim, "png") + _make_writable(path) + path.unlink() + message = _export_error(env) + assert victim in message or victim[:12] in message + assert not env.target().exists() + assert _temp_entries(env.bundles_dir) == [] + + def test_corrupted_store_file_leaves_nothing(self, env: Env) -> None: + victim = next(iter(env.contents)) + path = store.store_path(env.store_dir, victim, "png") + _make_writable(path) + path.write_bytes(b"corrupted") + _export_error(env) + assert not env.target().exists() + assert _temp_entries(env.bundles_dir) == [] + + def test_catalog_changed_after_freeze_stops(self, env: Env) -> None: + # 版の記録と構成から作り直した manifest が食い違う(目録の書き換え) + env.conn.execute("UPDATE assets SET kind = 'tongue_raw' WHERE capture_id = 'C000003'") + env.conn.commit() + message = _export_error(env) + assert "check" in message + assert not env.target().exists() + assert _temp_entries(env.bundles_dir) == [] + + def test_failure_keeps_other_versions(self, env: Env) -> None: + env.export() + before = _tree(env.target()) + _export_error(env, version=5) + assert _tree(env.target()) == before + + def test_catalog_not_modified(self, env: Env) -> None: + def dump() -> list: + return [ + env.conn.execute(f"SELECT * FROM {t} ORDER BY 1").fetchall() + for t in ("dataset_versions", "dataset_members", "assets", "annotations") + ] + + before = dump() + env.export() + env.export() + assert dump() == before