diff --git a/.claude/rules/lab-pc-operation.md b/.claude/rules/lab-pc-operation.md index e376a67..0903c8e 100644 --- a/.claude/rules/lab-pc-operation.md +++ b/.claude/rules/lab-pc-operation.md @@ -6,13 +6,13 @@ - **データの中身を表示しない**: `type`・`Get-Content`・`cat`・`more`・`sqlite3` 等で,取り込み元・ストア・目録・対応表・バンドル(`E:\clinical-data\` 配下,サーバの `~/data/` 配下)を読まない.フック(`.claude/repo-access.json`)は SSH の先の読み取りを止められないため,ここで守る - **データの一覧を表示しない**: `dir`・`ls`・`Get-ChildItem` でデータのフォルダの中身(ファイル名)を表示しない.件数が必要なら `tiasdata stats`・`tiasdata check` を使う(CLI が無い段階で件数が必要なら,ファイル名を出さずに数だけを返すコマンドにする) -- **研究室 PC で編集・コミットしない**: 研究室 PC のリポジトリは `git pull` と `uv sync --no-dev` だけにする +- **研究室 PC で編集・コミットしない**: 研究室 PC のリポジトリは `git pull` と `uv sync --no-dev --extra import` だけにする - **原本・ストア・目録を手で消さない・動かさない**: 削除・移動が必要な場合は,実行するコマンドを提示してユーザーに依頼する ## 許可 (Allowed) - `tiasdata` CLI の実行(出力は [SPEC_03「出力の規則」](../../docs/04_SPEC/SPEC_03_データセットと受け渡し.md)で個人情報を含まないことが保証されている) -- ツールの有無・版の確認(`where`・`--version`),`git pull`・`uv sync --no-dev` +- ツールの有無・版の確認(`where`・`--version`),`git pull`・`uv sync --no-dev --extra import` - ツールの導入は,コマンド・入る場所を示してユーザーの承認を得てから行う ## 書き方 (Conventions) diff --git a/.gitignore b/.gitignore index cfdb5c3..41bca11 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ *.db-journal *.db-wal *.db-shm +# 対応表(画像の番号 → 患者 ID)の xlsx を誤って置いた場合の保険 +*.xlsx # CLI が書き出す manifest 等の出力(ダミーデータでの確認時を含む) /exports/ diff --git a/CLAUDE.md b/CLAUDE.md index 99b75e2..538b4ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: PLAN_02 Step 2(ストア)完了.次は Step 3(取り込みと点検) +最新: PLAN_02 Step 3(取り込みと点検.`tiasdata import`・`stats`・`check`)完了.次は Step 4(データセットと版) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 9d5e6f7..cee77e2 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -49,13 +49,13 @@ ```bash ssh -n labpc "cd C:\Users\Papin\source && git clone git@github-tiasdatahub:rintoHasegawa/TIASDataHub.git" -ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe sync --no-dev" +ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe sync --no-dev --extra import" ``` 更新するとき: ```bash -ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && git pull && C:\Users\Papin\.local\bin\uv.exe sync --no-dev" +ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && git pull && C:\Users\Papin\.local\bin\uv.exe sync --no-dev --extra import" ``` - ※ clone・`uv sync` は未検証(立ち上げの成果物を `main` に取り込んだ後に実行する).デプロイキーでの接続(`git ls-remote`)は 2026-10-02 に確認済み 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 f1d0c32..46ceec8 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" @@ -7,7 +7,7 @@ | コマンド | 用途 | | --- | --- | | `uv sync` | 依存を `.venv/` に入れる(開発用の依存を含む) | -| `uv sync --no-dev` | 実行時の依存だけを入れる(研究室 PC) | +| `uv sync --no-dev --extra import` | 実行時の依存と,取り込み用の openpyxl だけを入れる(研究室 PC) | ## テスト・lint (Test & Lint) @@ -23,12 +23,16 @@ | --- | --- | | `uv run tiasdata --version` | 版を表示する | | `uv run tiasdata init` | 設定ファイルに従って目録(`catalog.sqlite`)・`store/`・`bundles/`・`backups/`・対応表(`identity.sqlite`)を作り,スキーマのマイグレーションを適用する.何度実行してもよい(不足分だけ適用する) | +| `uv run tiasdata import <取り込み元の名前>` | 設定ファイルの `[sources.<名前>]` を取り込む(保留リスト `holds/<名前>.toml` が必要.空でよい).`--show-source-names` で問題のあったファイルの名前・対応表の行番号を表示する | +| `uv run tiasdata stats` | 症例・撮影・ファイル・所見の件数を表示する | +| `uv run tiasdata check` | ストア・目録の点検.問題が 1 件以上なら終了コード 1 | - 設定ファイルは環境変数 `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(引数の誤り)で終わる -- 残りのサブコマンド(取り込み・データセット・書き出し・受け渡し・点検等)は実装した Step で追記する +- `import` は openpyxl が要る(手元は `uv sync` で入る.研究室 PC は `uv sync --no-dev --extra import`).リポジトリの clone から実行する(保留リストを読むため) +- 残りのサブコマンド(データセット・書き出し・受け渡し等)は実装した 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 8e7a036..f13186a 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" @@ -14,7 +14,7 @@ | --- | --- | --- | | 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`(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` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | +| 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 つ足しても既存の表を変えずに済む | | 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 で行える.取り込み 1076 枚+保留 9 枚が 1085 枚・9 グループで TIASJudgeModel の TEST_01 と一致し,症例が約 450(2026-10-03 の照合値)になる.`check` が問題 0 件 | 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 70dae25..4ecb9d0 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" @@ -41,6 +41,10 @@ - 研究用 ID は基盤が振る.実際の患者 ID・カルテ番号は目録に入れない(対応表 `identity.sqlite` にだけ置く) - 現在の舌色データは 1 患者 1 枚ではない(PLAN_01「現在あるデータ」).取り込みでは研究室 PC の対応表の xlsx(画像の番号 → 患者 ID・何枚目)を読み,**患者 ID ごとに症例を 1 つ**,**画像 1 枚ごとに撮影を 1 つ**作る.患者 ID は対応表 `identity.sqlite` にだけ入れる - 同じ患者の複数枚が同じ日の撮影か別の日の撮影かは,現在のデータからは分からないため,撮影日(`captured_on`)は NULL にする +- 研究用 ID は番号順に振る.症例 `S` は目録と対応表の両方の最大番号 + 1 から,撮影 `C` は目録の最大番号 + 1 から(一度振った ID は変えない) +- 患者 ID は数字の値を先頭の 0 をそろえた文字列にする(`123`・`123.0`・`"0123"` は同じ患者.Excel の数値のセルは先頭の 0 がもともと落ちるため) +- 対応表 xlsx の読み方(2026-10-06 のユーザー判断): シートは 1 枚に限る.1 行目は見出し.番号(1 列目)が整数でない行(空行を除く)は使わず,件数を報告する.使う番号について,対応表に無い・同じ番号の行が複数ある(患者 ID が同じでも)・患者 ID が数字でない場合は停止する.保留した番号はこれらの確認の対象外 +- 撮影の `device` は `TIAS`.所見の `source` は `tiasjudgemodel_folder`.由来は画像 1 枚につき 1 行で,出力の `role` は `cropped` ## 表の定義 (Tables) @@ -63,7 +67,7 @@ | `color_chart` | `asset` | カラーチャート画像 | | `tongue_crop_masked` | `asset` | 色補正後の画像を舌の枠で切り出し,マスク外を黒 (0,0,0) にしたもの(DeepTIAS Image&Calib の `2-cropped` × `3-resized`.現在の舌色データ) | | `tongue_mask` | `asset` | 舌のマスク画像 | -| `tongue_color` | `annotation` | 舌色 5 分類+程度.値は `{"label": , "severity": "strong" \| "mild" \| "normal"}`.slug は `pale`・`light_red`・`red`・`dark_red`・`purple`(TIASJudgeModel の SPEC_01「クラス定義」と同じ).`light_red` のときだけ `normal` | +| `tongue_color` | `annotation` | 舌色 5 分類+程度.値は `{"label": , "severity": "strong" \| "mild" \| "normal"}`.slug は `pale`・`light_red`・`red`・`dark_red`・`purple`(TIASJudgeModel の SPEC_01「クラス定義」と同じ).`light_red` の程度は `normal` だけ,それ以外の 4 つは `strong` か `mild`(元のデータでも淡紅は「淡紅(正常)」だけ).未知のキー・値は拒否する | ### subjects(症例) 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 142b15e..7b60157 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,12 +113,16 @@ | `tiasdata dataset freeze <名前>` | 新しい版を作る | | `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す | | `tiasdata deliver <名前> --version <版> --to <サーバ名>` | 計算サーバへ渡す | -| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,読み取り専用が外れたストアのファイル.件数だけを表示する | -| `tiasdata stats` | 種類・所見の値ごとの件数 | +| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.tmp-`),ストアの想定外のファイル・フォルダ.件数だけを表示し,問題が 1 件以上なら終了コード 1.対応表は開かない | +| `tiasdata stats` | 症例数(撮影が 2 つ以上の症例数を含む)・撮影数,種類ごとのファイル数,訂正されていない所見の項目ごと・組み合わせごとの件数,訂正された所見の数.対応表は開かない | | `tiasdata backup` | 目録を `backups/catalog-<日時>.sqlite` に複製する(SQLite のバックアップ API を使う) | - 取り込みは何度実行しても同じ結果になるようにする(既に登録済みのファイルは飛ばす) -- 登録・版の作成は 1 つのトランザクションで行い,途中で失敗したら何も残さない +- 登録・版の作成は 1 つのトランザクションで行い,途中で失敗したら目録・対応表に何も残さない(取り込みでは対応表を目録の接続に ATTACH して 1 つのトランザクションにする).ストアへのコピーはトランザクションの外なので,失敗時にコピー済みのファイルはストアに残る.これは次に取り込みが成功するまで `check` が「ストアにあって目録に無いファイル」として報告し,次の取り込みで再利用される +- 取り込みは,書き込む前に取り込み元の構成・保留リスト・対応表・同じ中身の画像が複数の番号に無いかをすべて検証する +- `import` の出力: 取り込み元の画像の件数,保留で飛ばした件数,対応表で使わない行の件数,登録済みで飛ばした件数,登録した件数(症例・撮影・ファイル・所見・由来),ストアにコピーした件数 +- `--show-source-names`(`import`)を付けたときだけ,問題のあったファイルの元の名前・番号と,対応表で使わない行の行番号を最大 20 件まで表示する.患者 ID はどの場合も表示しない +- 取り込みはリポジトリの clone から実行する(保留リスト `holds/` をパッケージの位置からリポジトリのルートを辿って読むため).openpyxl が要る(研究室 PC では `uv sync --no-dev --extra import`) - 問題を見つけたら黙って飛ばさず,件数と理由を表示して停止する(`check`・`stats` は止めずに件数を報告する) ## 保留リスト (Hold List) @@ -128,6 +132,8 @@ - `holds/<取り込み元の名前>.toml` に置く(リポジトリで管理し,PR で変更する).患者 ID は書かない - 1 件ごとに `number`(取り込み元での番号)・`reason`(理由)・`resolve`(確かめる方法)・`since`(保留にした日)を書く - `tiasdata import` は保留の番号を飛ばし,保留の件数を報告する(黙って外さない).保留リストにあるのに取り込み元に無い番号は停止する(リストの誤りを見逃さないため) +- **保留リストのファイルが無い取り込み元は停止する**(設定で取り込み元の名前を誤ったときに保留が効かずに取り込まれるのを防ぐため.2026-10-06 のユーザー判断).保留が無い取り込み元には,空の保留リスト(`[[hold]]` が 0 件)を PR で置く +- 形の誤り(未知のキー・必須キーの欠落・番号が 1 未満・番号の重複・`since` が日付でない)は停止する - 確かめ終わったら行を消して取り込み直し,データセットの新しい版を作る.過去の版は変わらない - 最初の保留リストは `holds/tiasjudgemodel.toml`(舌色データセットの重複 5 組のうち 9 枚.PLAN_02「舌色データセットの重複 5 組の扱い」) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9698c18..56b5502 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -34,3 +34,7 @@ - 未実装・後段: POSIX(os.link)の分岐は Windows では未検証 - [x] **舌色データセットの重複 5 組の扱いを決めた**: 基盤では正解を推測で決めず,確かめられるまで保留にする(2026-10-06).保留リスト `holds/tiasjudgemodel.toml` に 9 枚(122・184・548・566・381・382・515・516・509)を載せ,取り込みで飛ばして件数を報告する(SPEC_03「保留リスト」).508/509 は DeepTIAS の出力と完全一致する 508 を残す - 未実装・後段: 122/184・548/566 は研究室のサーバの元のデータで患者を,381/382・515/516 は評価者にラベルを確かめる +- [x] **PLAN_02 Step 3: 取り込みと点検**: 舌色の 9 フォルダ+対応表 xlsx の取り込み(患者ごとに症例・画像ごとに撮影,患者 ID は対応表 DB にだけ,保留リストの番号は飛ばす),`tiasdata import`・`stats`・`check` を実装(2026-10-06) + - 設計判断: 目録と対応表は別 DB だが,対応表を ATTACH して 1 トランザクションで登録する(失敗時はどちらにも残らない.ストアにコピー済みのファイルは残り,check が報告し次の取り込みで再利用).書き込みの前に構成・保留・対応表・同じ中身の重複をすべて検証する.保留リストはリポジトリの `holds/` をパッケージの位置から辿って読む + - ユーザー判断: 対応表で番号が整数でない行は使わず件数を報告する(実データに 1 行あるため.停止だと取り込めない).保留リストのファイルが無い取り込み元は停止する(空のリストを置く) + - 未実装・後段: テスト全体の実行が約 70〜80 秒に延びた(取り込みの停止 18 種 × 2 通り).必要なら取り込み済みの状態を fixture で共有して短くする diff --git a/pyproject.toml b/pyproject.toml index c5eabfc..29255cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,11 @@ [project.scripts] tiasdata = "tiasdata.cli:main" +[project.optional-dependencies] +import = [ + "openpyxl>=3.1.5", +] + [build-system] requires = ["uv_build>=0.12.5,<0.13"] build-backend = "uv_build" @@ -35,6 +40,7 @@ [dependency-groups] dev = [ + "openpyxl>=3.1.5", "pillow>=11.0", "pytest>=8.0", "ruff>=0.8", diff --git a/src/tiasdata/catalog/__init__.py b/src/tiasdata/catalog/__init__.py index 02d7cea..b3bbc30 100644 --- a/src/tiasdata/catalog/__init__.py +++ b/src/tiasdata/catalog/__init__.py @@ -1,24 +1,48 @@ """目録(SQLite)の接続・マイグレーションの適用・各表の操作. SQL はこのパッケージの中にだけ書く(他のモジュールはここの関数を呼ぶ). +対応表(`identity`)の操作は `tiasdata.catalog.identity` にあり,取り込みの処理からだけ使う. """ -from tiasdata.catalog.connection import connect +from tiasdata.catalog.connection import connect, write_transaction from tiasdata.catalog.migrate import ( MigrationResult, Series, apply_migrations, latest_version, + require_latest, schema_version, ) -from tiasdata.catalog.queries import count_kinds +from tiasdata.catalog.queries import ( + count_annotation_values, + count_assets_by_kind, + count_broken_dataset_members, + count_captures, + count_dangling_annotations, + count_kinds, + count_subjects, + count_subjects_with_multiple_captures, + count_superseded_annotations, + list_asset_files, +) __all__ = [ "MigrationResult", "Series", "apply_migrations", "connect", + "count_annotation_values", + "count_assets_by_kind", + "count_broken_dataset_members", + "count_captures", + "count_dangling_annotations", "count_kinds", + "count_subjects", + "count_subjects_with_multiple_captures", + "count_superseded_annotations", "latest_version", + "list_asset_files", + "require_latest", "schema_version", + "write_transaction", ] diff --git a/src/tiasdata/catalog/connection.py b/src/tiasdata/catalog/connection.py index a8f1e48..7513e52 100644 --- a/src/tiasdata/catalog/connection.py +++ b/src/tiasdata/catalog/connection.py @@ -1,6 +1,8 @@ """SQLite の接続.""" import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path @@ -30,3 +32,37 @@ # 外部キー制約は接続ごとの設定で,既定では無効(https://www.sqlite.org/foreignkeys.html) conn.execute("PRAGMA foreign_keys = ON") return conn + + +@contextmanager +def write_transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]: + """書き込みのトランザクションを始め,成功したら確定,失敗したら取り消す. + + `BEGIN IMMEDIATE` で最初に書き込みのロックを取る(照会してから登録するまでの間に + 別の取り込みが割り込まないようにするため).ATTACH した DB(対応表)への書き込みも + 同じトランザクションに含まれる.SQLite の制約違反は `ValueError` に変換する + (error-handling.md). + + Args: + conn: `connect()` で開いた接続(トランザクションの外であること). + + Yields: + 同じ接続. + + Raises: + ValueError: 制約違反で登録できなかった(変更は取り消される). + """ + if conn.in_transaction: + raise RuntimeError("既にトランザクションの中で write_transaction を呼んだ") + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except sqlite3.IntegrityError as e: + conn.rollback() + # SQLite の制約違反のメッセージは制約・列の名前だけで,値を含まない + raise ValueError(f"目録の制約に反するため登録を取り消した: {e}") from e + except BaseException: + conn.rollback() + raise + else: + conn.commit() diff --git a/src/tiasdata/catalog/identity.py b/src/tiasdata/catalog/identity.py new file mode 100644 index 0000000..6b70da4 --- /dev/null +++ b/src/tiasdata/catalog/identity.py @@ -0,0 +1,72 @@ +"""対応表(`identity.sqlite`.実際の患者 ID ↔ 研究用 ID)の操作. + +対応表を開くのは取り込みの処理だけにする(coding-style.md「臨床データを扱うコードの規則」). +目録の接続に `ATTACH` して使い,目録と対応表への登録を 1 つのトランザクションにまとめる +(途中で失敗したら両方とも何も残らないようにするため). + +ここで扱う患者 ID は例外メッセージ・ログに出さない. +""" + +import sqlite3 +from pathlib import Path + +from tiasdata.catalog.migrate import require_latest +from tiasdata.catalog.records import parse_id_number + +# 目録の接続の中での対応表のスキーマ名 +IDENTITY_SCHEMA = "identity" + + +def attach_identity(conn: sqlite3.Connection, path: Path) -> None: + """目録の接続に対応表を `identity` として ATTACH する. + + 対応表のファイルが無いときは作らずに停止する(`tiasdata init` の前に誤って空の対応表を + 作らないため).スキーマが最新でなければ停止する. + + Args: + conn: `connect()` で開いた目録の接続(トランザクションの外であること). + path: 対応表のパス. + + Raises: + ValueError: 対応表が無い,開けない,またはスキーマが最新でない. + """ + if not path.is_file(): + raise ValueError("対応表が無い.先に `tiasdata init` を実行する") + # connect() は URI を有効にして開くため,ATTACH でも mode=rw(無ければ作らない)が効く + uri = f"{path.resolve().as_uri()}?mode=rw" + try: + conn.execute(f"ATTACH DATABASE ? AS {IDENTITY_SCHEMA}", (uri,)) + except sqlite3.OperationalError as e: + raise ValueError(f"対応表を開けない: {e}") from e + require_latest(conn, "identity", schema=IDENTITY_SCHEMA) + + +def find_subject_id(conn: sqlite3.Connection, patient_key: str) -> str | None: + """患者 ID に対応する研究用 ID を引く(無ければ None).""" + row = conn.execute( + f"SELECT subject_id FROM {IDENTITY_SCHEMA}.identity_map WHERE patient_key = ?", + (patient_key,), + ).fetchone() + return row[0] if row else None + + +def max_identity_subject_number(conn: sqlite3.Connection) -> int: + """対応表の研究用 ID の最大の番号を返す(無ければ 0). + + 目録を作り直した場合等に,対応表にだけある研究用 ID と重ならないようにするため. + """ + (value,) = conn.execute( + f"SELECT MAX(subject_id) FROM {IDENTITY_SCHEMA}.identity_map" + ).fetchone() + return parse_id_number(value) + + +def insert_identity( + conn: sqlite3.Connection, patient_key: str, subject_id: str, created_at: str +) -> None: + """患者 ID と研究用 ID の対応を登録する.""" + conn.execute( + f"INSERT INTO {IDENTITY_SCHEMA}.identity_map (patient_key, subject_id, created_at)" + " VALUES (?, ?, ?)", + (patient_key, subject_id, created_at), + ) diff --git a/src/tiasdata/catalog/migrate.py b/src/tiasdata/catalog/migrate.py index 324b3bb..8b637af 100644 --- a/src/tiasdata/catalog/migrate.py +++ b/src/tiasdata/catalog/migrate.py @@ -94,6 +94,35 @@ return conn.execute("PRAGMA user_version").fetchone()[0] +def require_latest(conn: sqlite3.Connection, series: Series, *, schema: str = "main") -> None: + """DB のスキーマが最新の版であることを確かめる(取り込み・点検の前に呼ぶ). + + Args: + conn: DB への接続. + series: `catalog` または `identity`. + schema: 接続の中のスキーマ名(`main` または ATTACH した名前). + + Raises: + ValueError: スキーマが最新でない(`tiasdata init` で更新する),または + ライブラリより新しい(`tiasdata` を更新する). + """ + if not schema.isidentifier(): + raise RuntimeError("スキーマ名が識別子でない") + # PRAGMA はプレースホルダを受け付けないため,コードの定数(識別子)だけを埋め込む + (current,) = conn.execute(f"PRAGMA {schema}.user_version").fetchone() + latest = latest_version(series) + if current < latest: + raise ValueError( + f"DB({series})のスキーマの版 {current} が最新 {latest} でない." + "先に `tiasdata init` を実行する" + ) + if current > latest: + raise ValueError( + f"DB({series})のスキーマの版 {current} が,このライブラリの知る最新の版 " + f"{latest} より新しい.tiasdata を更新する" + ) + + def apply_migrations(conn: sqlite3.Connection, series: Series) -> MigrationResult: """未適用のマイグレーションを番号順に適用する. diff --git a/src/tiasdata/catalog/queries.py b/src/tiasdata/catalog/queries.py index 442b3c9..9d44f4b 100644 --- a/src/tiasdata/catalog/queries.py +++ b/src/tiasdata/catalog/queries.py @@ -14,3 +14,110 @@ """ rows = conn.execute("SELECT category, COUNT(*) FROM kinds GROUP BY category ORDER BY category") return {category: count for category, count in rows} + + +def count_assets_by_kind(conn: sqlite3.Connection) -> dict[str, int]: + """ファイルの数をファイルの種類ごとに数える(登録の無い種類は 0). + + Args: + conn: 目録への接続. + + Returns: + 種類名 → ファイル数(種類名の順). + """ + rows = conn.execute( + "SELECT k.kind, COUNT(a.sha256) FROM kinds AS k" + " LEFT JOIN assets AS a ON a.kind = k.kind" + " WHERE k.category = 'asset' GROUP BY k.kind ORDER BY k.kind" + ) + return {kind: count for kind, count in rows} + + +def count_annotation_values(conn: sqlite3.Connection) -> dict[str, dict[str, int]]: + """有効な所見(訂正されていないもの)の数を,所見の種類・値(JSON の文字列)ごとに数える. + + Args: + conn: 目録への接続. + + Returns: + 所見の種類名 → 値の JSON → 件数. + """ + rows = conn.execute( + "SELECT kind, value, COUNT(*) FROM annotations WHERE superseded_by IS NULL" + " GROUP BY kind, value ORDER BY kind, value" + ) + result: dict[str, dict[str, int]] = {} + for kind, value, count in rows: + result.setdefault(kind, {})[value] = count + return result + + +def count_superseded_annotations(conn: sqlite3.Connection) -> int: + """訂正された(`superseded_by` のある)所見の数.""" + (count,) = conn.execute( + "SELECT COUNT(*) FROM annotations WHERE superseded_by IS NOT NULL" + ).fetchone() + return count + + +def count_subjects(conn: sqlite3.Connection) -> int: + """症例の数.""" + (count,) = conn.execute("SELECT COUNT(*) FROM subjects").fetchone() + return count + + +def count_captures(conn: sqlite3.Connection) -> int: + """撮影の数.""" + (count,) = conn.execute("SELECT COUNT(*) FROM captures").fetchone() + return count + + +def count_subjects_with_multiple_captures(conn: sqlite3.Connection) -> int: + """撮影を 2 つ以上持つ症例の数(同じ患者の複数枚がまとまっているかの目安).""" + (count,) = conn.execute( + "SELECT COUNT(*) FROM" + " (SELECT subject_id FROM captures GROUP BY subject_id HAVING COUNT(*) >= 2)" + ).fetchone() + return count + + +def list_asset_files(conn: sqlite3.Connection) -> list[tuple[str, str]]: + """目録の全ファイルの(SHA-256, 拡張子)を SHA-256 の順に返す(点検用).""" + rows = conn.execute("SELECT sha256, ext FROM assets ORDER BY sha256") + return [(sha256, ext) for sha256, ext in rows] + + +def count_dangling_annotations(conn: sqlite3.Connection) -> int: + """参照先(撮影・ファイル・訂正後の所見)が目録に無い所見の数. + + 外部キー制約で通常は 0 になる.制約を無効にした接続での書き込み等で壊れていないかを見る. + """ + (count,) = conn.execute( + "SELECT COUNT(*) FROM annotations AS a" + " WHERE (a.capture_id IS NOT NULL" + " AND NOT EXISTS (SELECT 1 FROM captures AS c WHERE c.capture_id = a.capture_id))" + " OR (a.sha256 IS NOT NULL" + " AND NOT EXISTS (SELECT 1 FROM assets AS s WHERE s.sha256 = a.sha256))" + " OR (a.superseded_by IS NOT NULL" + " AND NOT EXISTS" + " (SELECT 1 FROM annotations AS n WHERE n.annotation_id = a.superseded_by))" + ).fetchone() + return count + + +def count_broken_dataset_members(conn: sqlite3.Connection) -> int: + """凍結した版の構成のうち,参照先(ファイル・所見・構成のファイル)が欠けている行の数.""" + (members,) = conn.execute( + "SELECT COUNT(*) FROM dataset_members AS m" + " WHERE NOT EXISTS (SELECT 1 FROM assets AS a WHERE a.sha256 = m.sha256)" + " OR NOT EXISTS (SELECT 1 FROM dataset_versions AS v" + " WHERE v.name = m.name AND v.version = m.version)" + ).fetchone() + (member_annotations,) = conn.execute( + "SELECT COUNT(*) FROM dataset_member_annotations AS d" + " WHERE NOT EXISTS (SELECT 1 FROM annotations AS a WHERE a.annotation_id = d.annotation_id)" + " OR NOT EXISTS (SELECT 1 FROM dataset_members AS m" + " WHERE m.name = d.name AND m.version = d.version" + " AND m.sha256 = d.sha256)" + ).fetchone() + return members + member_annotations diff --git a/src/tiasdata/catalog/records.py b/src/tiasdata/catalog/records.py new file mode 100644 index 0000000..ce9b135 --- /dev/null +++ b/src/tiasdata/catalog/records.py @@ -0,0 +1,262 @@ +"""目録の登録(症例・撮影・ファイル・所見・由来)と,登録のための照会. + +所見(`annotations`)は INSERT だけを行い,UPDATE・DELETE しない(スキーマのトリガでも拒否される). +""" + +import sqlite3 +from datetime import UTC, datetime + +from pydantic import BaseModel, ConfigDict + +SUBJECT_PREFIX = "S" +CAPTURE_PREFIX = "C" +# 研究用 ID・撮影 ID は接頭辞 + 6 桁(SPEC_02「ID の書式」.スキーマの CHECK と同じ) +ID_DIGITS = 6 +_MAX_ID_NUMBER = 10**ID_DIGITS - 1 + + +class AssetRecord(BaseModel): + """目録の `assets` の 1 行.""" + + model_config = ConfigDict(frozen=True) + + sha256: str + kind: str + capture_id: str | None + ext: str + size_bytes: int + source: str + source_name: str + + +class CaptureRecord(BaseModel): + """目録の `captures` の 1 行.""" + + model_config = ConfigDict(frozen=True) + + capture_id: str + subject_id: str + captured_on: str | None + device: str | None + + +def utc_timestamp(now: datetime | None = None) -> str: + """目録に保存する日時の文字列(UTC の `YYYY-MM-DDTHH:MM:SSZ`.秒まで)を返す. + + Args: + now: 日時(省略時は現在.タイムゾーンなしは UTC とみなす). + + Returns: + `2026-10-02T09:00:00Z` の形の文字列. + """ + moment = now if now is not None else datetime.now(UTC) + if moment.tzinfo is not None: + moment = moment.astimezone(UTC) + # スキーマの CHECK が小数秒を拒否するため秒までにする + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def format_id(prefix: str, number: int) -> str: + """研究用 ID・撮影 ID の文字列を作る(例: `S000123`). + + Args: + prefix: `S` または `C`. + number: 1 以上の番号. + + Returns: + 接頭辞 + 6 桁の ID. + + Raises: + ValueError: 番号が 6 桁に収まらない. + """ + if not 1 <= number <= _MAX_ID_NUMBER: + raise ValueError(f"ID の番号が {ID_DIGITS} 桁に収まらない({prefix})") + return f"{prefix}{number:0{ID_DIGITS}d}" + + +def parse_id_number(value: str | None) -> int: + """目録・対応表にある研究用 ID・撮影 ID の番号を返す(`format_id` の逆.None なら 0).""" + # ID の書式はスキーマの CHECK で保証されているので,接頭辞を除けば整数になる + return int(value[1:]) if value else 0 + + +def max_subject_number(conn: sqlite3.Connection) -> int: + """目録の症例の研究用 ID の最大の番号を返す(無ければ 0).""" + (value,) = conn.execute("SELECT MAX(subject_id) FROM subjects").fetchone() + return parse_id_number(value) + + +def max_capture_number(conn: sqlite3.Connection) -> int: + """目録の撮影 ID の最大の番号を返す(無ければ 0).""" + (value,) = conn.execute("SELECT MAX(capture_id) FROM captures").fetchone() + return parse_id_number(value) + + +def subject_exists(conn: sqlite3.Connection, subject_id: str) -> bool: + """症例が目録にあるか.""" + row = conn.execute("SELECT 1 FROM subjects WHERE subject_id = ?", (subject_id,)).fetchone() + return row is not None + + +def get_asset(conn: sqlite3.Connection, sha256: str) -> AssetRecord | None: + """ファイルの行を SHA-256 で引く(無ければ None).""" + row = conn.execute( + "SELECT sha256, kind, capture_id, ext, size_bytes, source, source_name" + " FROM assets WHERE sha256 = ?", + (sha256,), + ).fetchone() + if row is None: + return None + return AssetRecord( + sha256=row[0], + kind=row[1], + capture_id=row[2], + ext=row[3], + size_bytes=row[4], + source=row[5], + source_name=row[6], + ) + + +def find_asset_sha256_by_source_name( + conn: sqlite3.Connection, source: str, source_name: str +) -> list[str]: + """取り込み元の名前と相対パスから,登録済みのファイルの SHA-256 を引く.""" + rows = conn.execute( + "SELECT sha256 FROM assets WHERE source = ? AND source_name = ? ORDER BY sha256", + (source, source_name), + ) + return [sha256 for (sha256,) in rows] + + +def get_capture(conn: sqlite3.Connection, capture_id: str) -> CaptureRecord | None: + """撮影の行を引く(無ければ None).""" + row = conn.execute( + "SELECT capture_id, subject_id, captured_on, device FROM captures WHERE capture_id = ?", + (capture_id,), + ).fetchone() + if row is None: + return None + return CaptureRecord(capture_id=row[0], subject_id=row[1], captured_on=row[2], device=row[3]) + + +def capture_annotation_values( + conn: sqlite3.Connection, capture_id: str, kind: str, source: str +) -> list[str]: + """撮影に付いた所見のうち,種類と由来が一致するものの値(JSON の文字列)を返す. + + 訂正された行(`superseded_by` あり)も含める(取り込み元から読んだ元の値と照合するため). + """ + rows = conn.execute( + "SELECT value FROM annotations" + " WHERE capture_id = ? AND kind = ? AND source = ? ORDER BY annotation_id", + (capture_id, kind, source), + ) + return [value for (value,) in rows] + + +def insert_subject(conn: sqlite3.Connection, subject_id: str, created_at: str) -> None: + """症例を登録する.""" + conn.execute( + "INSERT INTO subjects (subject_id, created_at) VALUES (?, ?)", (subject_id, created_at) + ) + + +def insert_capture( + conn: sqlite3.Connection, + capture_id: str, + subject_id: str, + *, + captured_on: str | None, + device: str | None, +) -> None: + """撮影を登録する.""" + conn.execute( + "INSERT INTO captures (capture_id, subject_id, captured_on, device) VALUES (?, ?, ?, ?)", + (capture_id, subject_id, captured_on, device), + ) + + +def insert_asset(conn: sqlite3.Connection, asset: AssetRecord, registered_at: str) -> None: + """ファイルを登録する.""" + conn.execute( + "INSERT INTO assets" + " (sha256, kind, capture_id, ext, size_bytes, source, source_name, registered_at)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + asset.sha256, + asset.kind, + asset.capture_id, + asset.ext, + asset.size_bytes, + asset.source, + asset.source_name, + registered_at, + ), + ) + + +def insert_capture_annotation( + conn: sqlite3.Connection, + *, + kind: str, + capture_id: str, + value_json: str, + source: str, + created_at: str, + annotator: str | None = None, +) -> int: + """撮影に付く所見を追加する(値は `kinds` の型で検証済みの JSON の文字列). + + Returns: + 追加した行の `annotation_id`. + """ + cursor = conn.execute( + "INSERT INTO annotations (kind, capture_id, value, annotator, source, created_at)" + " VALUES (?, ?, ?, ?, ?, ?)", + (kind, capture_id, value_json, annotator, source, created_at), + ) + return int(cursor.lastrowid) + + +def insert_derivation( + conn: sqlite3.Connection, + *, + tool: str, + tool_version: str, + mode: str | None, + note: str | None, + outputs: list[tuple[str, str]], + inputs: list[tuple[str, str]] | None = None, + performed_at: str | None = None, +) -> int: + """由来(プログラム・版)と,その入力・出力のファイルを登録する. + + Args: + conn: 目録への接続. + tool: プログラム名. + tool_version: プログラムの版(不明なら `unknown`). + mode: 実行モード・設定. + note: 備考. + outputs: 出力ファイル(SHA-256, 役割). + inputs: 入力ファイル(SHA-256, 役割).省略時は入力なし. + performed_at: 実行日時(不明なら None). + + Returns: + 追加した行の `derivation_id`. + """ + cursor = conn.execute( + "INSERT INTO derivations (tool, tool_version, mode, performed_at, note)" + " VALUES (?, ?, ?, ?, ?)", + (tool, tool_version, mode, performed_at, note), + ) + derivation_id = int(cursor.lastrowid) + conn.executemany( + "INSERT INTO derivation_inputs (derivation_id, sha256, role) VALUES (?, ?, ?)", + [(derivation_id, sha256, role) for sha256, role in inputs or []], + ) + conn.executemany( + "INSERT INTO derivation_outputs (derivation_id, sha256, role) VALUES (?, ?, ?)", + [(derivation_id, sha256, role) for sha256, role in outputs], + ) + return derivation_id diff --git a/src/tiasdata/check.py b/src/tiasdata/check.py new file mode 100644 index 0000000..063aeb1 --- /dev/null +++ b/src/tiasdata/check.py @@ -0,0 +1,91 @@ +"""目録とストアの点検(`tiasdata check`.SPEC_03「CLI」). + +問題を見つけても止めずに全体を数え,件数だけを返す(error-handling.md「データの問題は止める」の例外). +対応表(`identity.sqlite`)は開かない. +""" + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +from tiasdata import catalog, store + + +@dataclass(frozen=True) +class CheckResult: + """点検の結果(件数). + + Attributes: + catalog_files: 目録のファイルの数(ハッシュを照合した数). + hash_ok: ストアのファイルの中身が SHA-256 と一致した数. + hash_mismatch: ストアのファイルの中身が SHA-256 と一致しない数. + missing_in_store: 目録にあってストアに無いファイルの数. + store_files: ストアの正式な名前のファイルの数. + not_in_catalog: ストアにあって目録に無いファイルの数(拡張子が目録と違うものを含む). + writable: 読み取り専用が外れたストアのファイルの数. + temp_files: 書きかけの一時ファイルの数(取り込みの中断で残ったもの). + unexpected_in_store: ストアの想定外のファイル・フォルダの数. + dangling_annotations: 参照先が無い所見の数. + broken_dataset_members: 凍結した版の構成のうち参照先が欠けている行の数. + """ + + catalog_files: int + hash_ok: int + hash_mismatch: int + missing_in_store: int + store_files: int + not_in_catalog: int + writable: int + temp_files: int + unexpected_in_store: int + dangling_annotations: int + broken_dataset_members: int + + @property + def problems(self) -> int: + """問題の合計(0 なら問題なし).""" + return ( + self.hash_mismatch + + self.missing_in_store + + self.not_in_catalog + + self.writable + + self.temp_files + + self.unexpected_in_store + + self.dangling_annotations + + self.broken_dataset_members + ) + + +def run_check(conn: sqlite3.Connection, store_dir: Path) -> CheckResult: + """目録とストアを点検する. + + Args: + conn: 目録への接続(スキーマ最新). + store_dir: ストアのフォルダ. + + Returns: + 点検の結果(件数). + + Raises: + ValueError: ストアのフォルダが無い. + """ + scan = store.scan_store(store_dir) + catalog_files = catalog.list_asset_files(conn) + + statuses = [store.verify_stored(store_dir, sha256, ext) for sha256, ext in catalog_files] + registered = set(catalog_files) + return CheckResult( + catalog_files=len(catalog_files), + hash_ok=statuses.count(store.StoredFileStatus.OK), + hash_mismatch=statuses.count(store.StoredFileStatus.MISMATCH), + missing_in_store=statuses.count(store.StoredFileStatus.MISSING), + store_files=len(scan.entries), + not_in_catalog=sum( + 1 for entry in scan.entries if (entry.sha256, entry.ext) not in registered + ), + writable=sum(1 for entry in scan.entries if entry.writable), + temp_files=scan.temp_files, + unexpected_in_store=scan.unexpected, + dangling_annotations=catalog.count_dangling_annotations(conn), + broken_dataset_members=catalog.count_broken_dataset_members(conn), + ) diff --git a/src/tiasdata/cli.py b/src/tiasdata/cli.py index e36bbb2..e74dcca 100644 --- a/src/tiasdata/cli.py +++ b/src/tiasdata/cli.py @@ -1,13 +1,18 @@ """tiasdata コマンドのエントリポイント.""" import argparse +import sqlite3 import sys from contextlib import closing +from pathlib import Path from pydantic import ValidationError from tiasdata import __version__, catalog +from tiasdata.check import CheckResult, run_check from tiasdata.config import load_config +from tiasdata.importers import ImportResult, run_import +from tiasdata.stats import CatalogStats, collect_stats def _cmd_init(args: argparse.Namespace) -> int: @@ -31,6 +36,106 @@ return 0 +def _cmd_import(args: argparse.Namespace) -> int: + config = load_config() + result = run_import(config, args.source, show_source_names=args.show_source_names) + _print_import(result, show_source_names=args.show_source_names) + return 0 + + +def _print_import(result: ImportResult, *, show_source_names: bool = False) -> None: + # 件数と設定で付けた名前だけを出す(患者 ID・元のファイル名・パスは出さない) + print(f"取り込み元: {result.source_name}({result.importer})") + print(f"取り込み元の画像: {result.found} 件") + print(f"保留で飛ばした: {result.held} 件(保留リスト {result.hold_filename})") + # 対応表の行番号は位置だが,番号と同じく既定では出さない(--show-source-names で出す) + unused_rows = result.unused_map_rows + unused_note = "" + if unused_rows and show_source_names: + unused_note = f"(行: {', '.join(str(row) for row in unused_rows)})" + elif unused_rows: + unused_note = "(--show-source-names で行番号を表示する)" + print(f"対応表で番号が数字でない行(使わない): {len(unused_rows)} 件{unused_note}") + print(f"既に登録済みで飛ばした: {result.skipped_existing} 件") + print( + f"登録: 症例 {result.subjects_added} 件・撮影 {result.captures_added} 件・" + f"ファイル {result.assets_added} 件・所見 {result.annotations_added} 件・" + f"由来 {result.derivations_added} 件" + ) + print(f"ストアにコピー: {result.copied_to_store} 件") + + +def _open_catalog(catalog_path: Path) -> sqlite3.Connection: + conn = catalog.connect(catalog_path) + try: + catalog.require_latest(conn, "catalog") + except BaseException: + conn.close() + raise + return conn + + +def _cmd_stats(args: argparse.Namespace) -> int: + config = load_config() + with closing(_open_catalog(config.catalog.catalog_path)) as conn: + stats = collect_stats(conn) + _print_stats(stats) + return 0 + + +def _print_stats(stats: CatalogStats) -> None: + print( + f"症例: {stats.subjects} 件(撮影が 2 つ以上: {stats.subjects_with_multiple_captures} 件)" + ) + print(f"撮影: {stats.captures} 件") + print("ファイル(種類ごと):") + for kind, count in stats.assets_by_kind.items(): + print(f" {kind}: {count} 件") + print("所見(訂正されていないもの):") + if not stats.annotations: + print(" なし") + for kind, summary in stats.annotations.items(): + print(f" {kind}: {summary.total} 件") + for field, counts in summary.by_field.items(): + values = "・".join(f"{value} {count}" for value, count in counts.items()) + print(f" {field}: {values}") + print(" 組み合わせ:") + for value, count in summary.by_value.items(): + print(f" {value}: {count} 件") + print(f"訂正された所見: {stats.superseded_annotations} 件") + + +def _cmd_check(args: argparse.Namespace) -> int: + config = load_config() + with closing(_open_catalog(config.catalog.catalog_path)) as conn: + result = run_check(conn, config.catalog.store_dir) + _print_check(result) + if result.problems: + # 問題は止めずに数え,最後にまとめて報告して 0 以外で終わる(error-handling.md). + # 件数の表と同じ標準出力に書く(パイプ越しでも表の最後に並ぶように) + print(f"問題: {result.problems} 件") + return 1 + print("問題: 0 件") + return 0 + + +def _print_check(result: CheckResult) -> None: + print( + f"ハッシュの照合(目録の全ファイル {result.catalog_files} 件): " + f"一致 {result.hash_ok} 件・不一致 {result.hash_mismatch} 件" + ) + print(f"目録にあってストアに無いファイル: {result.missing_in_store} 件") + print( + f"ストアにあって目録に無いファイル: {result.not_in_catalog} 件" + f"(ストアのファイル {result.store_files} 件)" + ) + print(f"読み取り専用が外れたストアのファイル: {result.writable} 件") + print(f"書きかけの一時ファイル: {result.temp_files} 件") + print(f"ストアの想定外のファイル・フォルダ: {result.unexpected_in_store} 件") + print(f"所見の参照切れ: {result.dangling_annotations} 件") + print(f"凍結した版の構成の欠け: {result.broken_dataset_members} 件") + + def _print_migration(label: str, result: catalog.MigrationResult) -> None: if result.applied: print(f"{label}: スキーマの版 {result.before} → {result.after}({result.applied} 件適用)") @@ -59,6 +164,39 @@ "既にある場合は不足分だけ適用する", ) init.set_defaults(handler=_cmd_init) + + import_ = subparsers.add_parser( + "import", + help="取り込み元を読み,ストアにコピーして目録に登録する", + description="設定ファイルで名前を付けた取り込み元を読み,ファイルをストアにコピーして" + "症例・撮影・ファイル・所見・由来を登録する.保留リスト(holds/<名前>.toml)の番号は飛ばす." + "既に登録済みのファイルは飛ばす(何度実行しても同じ結果になる)", + ) + import_.add_argument("source", metavar="<取り込み元>", help="設定ファイルの sources.<名前>") + import_.add_argument( + "--show-source-names", + action="store_true", + help=( + "エラーメッセージに取り込み元での番号・名前を,結果に対応表の使わない行の行番号を出す" + "(原因の特定に必要なときだけ使う)" + ), + ) + import_.set_defaults(handler=_cmd_import) + + stats = subparsers.add_parser( + "stats", + help="種類・所見の値ごとの件数を表示する", + description="ファイルの種類ごとの数,所見の値ごとの件数,症例・撮影の数を表示する", + ) + stats.set_defaults(handler=_cmd_stats) + + check = subparsers.add_parser( + "check", + help="目録とストアを点検する(問題があれば終了コード 1)", + description="ストアのファイルのハッシュの照合,目録とストアの食い違い,読み取り専用が外れた" + "ファイル,所見の参照切れ,凍結した版の構成の欠けを数える.件数だけを表示する", + ) + check.set_defaults(handler=_cmd_check) return parser diff --git a/src/tiasdata/importers/__init__.py b/src/tiasdata/importers/__init__.py new file mode 100644 index 0000000..3508ca8 --- /dev/null +++ b/src/tiasdata/importers/__init__.py @@ -0,0 +1,75 @@ +"""取り込み元の形ごとの読み取りと登録(1 つの形 = 1 モジュール). + +設定ファイルの `sources.<名前>.importer` の名前から importer を選ぶ.取り込み元の形を増やすときは +モジュールを追加して `IMPORTERS` に登録する(既存の importer に分岐を足さない). +""" + +from contextlib import closing +from pathlib import Path + +from tiasdata import catalog +from tiasdata.catalog import identity +from tiasdata.config import Config +from tiasdata.importers import holds as holds_module +from tiasdata.importers import tongue_color_folders +from tiasdata.importers.base import ImportContext, Importer, ImportResult + +# importer の名前(設定の `importer`)→ 取り込みの関数 +IMPORTERS: dict[str, Importer] = { + tongue_color_folders.NAME: tongue_color_folders.run, +} + + +def run_import( + config: Config, + source_name: str, + *, + holds_dir: Path | None = None, + show_source_names: bool = False, +) -> ImportResult: + """設定ファイルの取り込み元を名前で指定して取り込む. + + 対応表(`identity.sqlite`)は目録の接続に ATTACH し,目録と対応表への登録を + 1 つのトランザクションにまとめる. + + Args: + config: 設定. + source_name: 取り込み元の名前(`sources.<名前>`). + holds_dir: 保留リストのフォルダ.省略時はリポジトリの `holds/`. + show_source_names: エラーメッセージに取り込み元での番号・名前を出すか. + + Returns: + 取り込みの結果(件数). + + Raises: + ValueError: 取り込み元・importer が無い,目録・対応表が無いか古い,保留リストの誤り, + 取り込み元・対応表の不備,または登録済みの内容との食い違い. + """ + source = config.sources.get(source_name) + if source is None: + raise ValueError(f"設定ファイルに取り込み元 '{source_name}' が無い") + importer = IMPORTERS.get(source.importer) + if importer is None: + raise ValueError( + f"取り込み元 '{source_name}' の importer '{source.importer}' が無い" + f"(使えるもの: {', '.join(sorted(IMPORTERS))})" + ) + # 保留リストの場所は呼び出し時に引く(テストで差し替えられるようにモジュール経由で呼ぶ) + directory = holds_dir if holds_dir is not None else holds_module.default_holds_dir() + holds = holds_module.load_holds(directory, source_name) + + paths = config.catalog + context = ImportContext( + source_name=source_name, + source=source, + store_dir=paths.store_dir, + holds=holds, + show_source_names=show_source_names, + ) + with closing(catalog.connect(paths.catalog_path)) as conn: + catalog.require_latest(conn, "catalog") + identity.attach_identity(conn, paths.identity) + return importer(conn, context) + + +__all__ = ["IMPORTERS", "ImportContext", "ImportResult", "Importer", "run_import"] diff --git a/src/tiasdata/importers/base.py b/src/tiasdata/importers/base.py new file mode 100644 index 0000000..17722b2 --- /dev/null +++ b/src/tiasdata/importers/base.py @@ -0,0 +1,72 @@ +"""importer に共通の型(取り込みの文脈・結果).""" + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from tiasdata.config import SourceConfig +from tiasdata.importers.holds import Holds + + +@dataclass(frozen=True) +class ImportContext: + """importer に渡す取り込みの文脈. + + Attributes: + source_name: 取り込み元の名前(設定ファイルの `sources.<名前>`). + source: 取り込み元の設定. + store_dir: ストアのフォルダ. + holds: 保留の番号. + show_source_names: エラーメッセージに取り込み元の番号・名前を出すか + (原因の特定に必要なときだけ明示する.error-handling.md). + """ + + source_name: str + source: SourceConfig + store_dir: Path + holds: Holds + show_source_names: bool = False + + +@dataclass(frozen=True) +class ImportResult: + """取り込みの結果(件数だけ.CLI がそのまま表示する). + + Attributes: + source_name: 取り込み元の名前. + importer: importer の名前. + found: 取り込み元で見つけた画像の数(保留を含む). + held: 保留リストにより飛ばした数. + hold_filename: 保留リストのファイル名(`holds/<名前>.toml`). + unused_map_rows: 対応表で番号が整数でないため使わなかった行の行番号(xlsx の 1 始まり. + 位置であり対応表の中身ではない.表示は件数だけで,行番号は + `--show-source-names` を指定したときだけ出す). + skipped_existing: 既に登録済みで飛ばした数. + copied_to_store: ストアにコピーした数(同じ中身が既にあったものは含まない). + subjects_added: 登録した症例の数. + captures_added: 登録した撮影の数. + assets_added: 登録したファイルの数. + annotations_added: 登録した所見の数. + derivations_added: 登録した由来の数. + """ + + source_name: str + importer: str + found: int + held: int + hold_filename: str + unused_map_rows: tuple[int, ...] + skipped_existing: int + copied_to_store: int + subjects_added: int + captures_added: int + assets_added: int + annotations_added: int + derivations_added: int + + +class Importer(Protocol): + """importer の関数の形(対応表を ATTACH した目録の接続と文脈を受け取る).""" + + def __call__(self, conn: sqlite3.Connection, context: ImportContext) -> ImportResult: ... diff --git a/src/tiasdata/importers/holds.py b/src/tiasdata/importers/holds.py new file mode 100644 index 0000000..9adcccf --- /dev/null +++ b/src/tiasdata/importers/holds.py @@ -0,0 +1,147 @@ +"""保留リスト(`holds/<取り込み元の名前>.toml`)の読み込みと検証(SPEC_03「保留リスト」). + +保留リストは個人情報を含まないためリポジトリで管理する(PR で変更する). +実行時は,このパッケージの位置からリポジトリのルート(`name = "tiasdata"` の `pyproject.toml` +のあるフォルダ)を辿って `holds/` を見つける.パッケージとして別の場所に入れた場合は +見つからないので,取り込みはリポジトリの clone から実行する. +""" + +import re +import tomllib +from datetime import date +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, ValidationError + +HOLDS_DIRNAME = "holds" +_PROJECT_NAME = "tiasdata" +# 取り込み元の名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない +_SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +class HoldEntry(BaseModel): + """保留リストの 1 件(`[[hold]]`). + + Attributes: + number: 取り込み元での番号. + reason: 保留にした理由. + resolve: 確かめる方法. + since: 保留にした日. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + number: StrictInt = Field(ge=1) + reason: str = Field(min_length=1) + resolve: str = Field(min_length=1) + since: date + + +class HoldFile(BaseModel): + """保留リストのファイル全体.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + hold: list[HoldEntry] = [] + + +class Holds(NamedTuple): + """取り込み元の保留の番号. + + Attributes: + numbers: 保留にする番号. + filename: 保留リストのファイル名(リポジトリの `holds/` からの相対.表示用). + """ + + numbers: frozenset[int] + filename: str + + +def default_holds_dir() -> Path: + """リポジトリの `holds/` フォルダを返す. + + このパッケージの位置から親のフォルダを辿り,`[project] name = "tiasdata"` の + `pyproject.toml` があるフォルダをリポジトリのルートとみなす. + + Returns: + `holds/` のパス. + + Raises: + ValueError: リポジトリのルートが見つからない(パッケージとして別の場所に入れて + 実行した等),またはルートに `holds/` が無い. + """ + package_dir = Path(__file__).resolve().parents[1] + for parent in package_dir.parents: + pyproject = parent / "pyproject.toml" + if pyproject.is_file() and _is_tiasdata_project(pyproject): + holds_dir = parent / HOLDS_DIRNAME + if not holds_dir.is_dir(): + raise ValueError( + f"リポジトリに {HOLDS_DIRNAME}/ が無い(保留リストを置くフォルダ)" + ) + return holds_dir + raise ValueError( + "保留リスト(holds/)の場所が分からない." + "取り込みは TIASDataHub のリポジトリの clone から実行する(uv run tiasdata import …)" + ) + + +def _is_tiasdata_project(pyproject: Path) -> bool: + try: + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + return False + project = data.get("project") + return isinstance(project, dict) and project.get("name") == _PROJECT_NAME + + +def load_holds(holds_dir: Path, source_name: str) -> Holds: + """取り込み元の保留リストを読んで検証する. + + ファイルが無ければ停止する(設定の取り込み元の名前を誤ると,保留が効かないまま取り込まれる + ため).保留の無い取り込み元には空の保留リスト(`[[hold]]` が 0 件,または空のファイル)を置く. + + Args: + holds_dir: 保留リストのフォルダ. + source_name: 取り込み元の名前(設定ファイルの `sources.<名前>`). + + Returns: + 保留の番号. + + Raises: + ValueError: 取り込み元の名前がファイル名に使えない,保留リストのファイルが無い, + TOML として読めない, + 形が誤っている(未知のキー・必須キーの欠落・番号が 1 未満等), + または番号が重複している. + """ + if not _SOURCE_NAME_PATTERN.match(source_name): + raise ValueError( + f"取り込み元の名前 '{source_name}' は英数字・_・- だけにする" + "(保留リストのファイル名に使う)" + ) + filename = f"{HOLDS_DIRNAME}/{source_name}.toml" + path = holds_dir / f"{source_name}.toml" + if not path.is_file(): + raise ValueError( + f"保留リスト {filename} が無い.保留が無い取り込み元は空の保留リストを置く" + ) + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: + raise ValueError(f"保留リスト {filename} を TOML として読めない: {e}") from e + try: + hold_file = HoldFile.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 + + numbers = [entry.number for entry in hold_file.hold] + duplicated = sorted({n for n in numbers if numbers.count(n) > 1}) + if duplicated: + # 保留リストの番号はファイル名の連番で個人をたどれないため,リストの誤りの特定に表示する + raise ValueError(f"保留リスト {filename} に同じ番号が 2 回以上ある: {duplicated}") + return Holds(numbers=frozenset(numbers), filename=filename) diff --git a/src/tiasdata/importers/tongue_color_folders.py b/src/tiasdata/importers/tongue_color_folders.py new file mode 100644 index 0000000..05bc5ce --- /dev/null +++ b/src/tiasdata/importers/tongue_color_folders.py @@ -0,0 +1,653 @@ +"""取り込み元の形 `tongue_color_folders`: 舌色データセット(9 フォルダ+対応表の xlsx). + +取り込み元は舌色 5 分類+程度ごとの 9 フォルダで,各フォルダの直下に `<番号>.png` がある +(TIASJudgeModel の TEST_01「フォルダ構成とラベル対応」).画像の番号 → 患者 ID は対応表の xlsx +(設定の `patient_map`.1 行目は見出し,列は 1: 番号・2: 何枚目・3: 患者 ID)から引く. + +登録するもの(SPEC_02): + +- 患者 ID ごとに症例を 1 つ(患者 ID は対応表 `identity.sqlite` にだけ入れる) +- 画像 1 枚ごとに撮影を 1 つ(撮影日は不明なので NULL,撮影装置は `TIAS`) +- ファイル(種類 `tongue_crop_masked`)・撮影に付く所見 `tongue_color`・由来(DeepTIAS.入力なし) + +想定外の構成・対応表の不備は黙って飛ばさずに停止する(error-handling.md). +例外メッセージには患者 ID・元のファイル名を出さない.取り込み元での番号は +`show_source_names` を指定したときだけ出す. +""" + +import json +import logging +import re +import sqlite3 +import zipfile +from collections import Counter, defaultdict +from collections.abc import Iterable, Sequence +from pathlib import Path +from typing import NamedTuple + +from tiasdata import store +from tiasdata.catalog import identity, records, write_transaction +from tiasdata.importers.base import ImportContext, ImportResult +from tiasdata.importers.holds import Holds +from tiasdata.kinds import TongueColor, dump_annotation_value + +logger = logging.getLogger(__name__) + +NAME = "tongue_color_folders" + +# フォルダ名 → 所見の値(TIASJudgeModel の TEST_01「フォルダ構成とラベル対応」と同じ) +FOLDER_VALUES: dict[str, TongueColor] = { + "淡紅(正常)": TongueColor(label="light_red", severity="normal"), + "暗赤紅_あり": TongueColor(label="dark_red", severity="strong"), + "暗赤紅_ややあり": TongueColor(label="dark_red", severity="mild"), + "紫_ややあり": TongueColor(label="purple", severity="mild"), + "紅_あり": TongueColor(label="red", severity="strong"), + "紅_ややあり": TongueColor(label="red", severity="mild"), + "紫_あり": TongueColor(label="purple", severity="strong"), + "淡白_あり": TongueColor(label="pale", severity="strong"), + "淡白_ややあり": TongueColor(label="pale", severity="mild"), +} + +ASSET_KIND = "tongue_crop_masked" +ANNOTATION_KIND = "tongue_color" +# 所見の由来: TIASJudgeModel のフォルダ名から読み取った(SPEC_02「annotations」) +ANNOTATION_SOURCE = "tiasjudgemodel_folder" +DEVICE = "TIAS" + +# 由来(SPEC_02「derivations」).元の撮影画像は基盤に無いため入力なしで登録する +DERIVATION_TOOL = "DeepTIAS" +DERIVATION_TOOL_VERSION = "unknown" +DERIVATION_MODE = "Image&Calib" +DERIVATION_OUTPUT_ROLE = "cropped" +DERIVATION_NOTE = ( + "TIASJudgeModel の SPEC_04 の確認結果: DeepTIAS 2.7 の Image&Calib の出力" + "(色補正後の画像を舌の枠で切り出し,マスク外を黒 (0,0,0) にしたもの.2-cropped × 3-resized)" + "から作られた.実行した版(旧 exe か現行ソースか)・日時の記録は無い." + "元の撮影画像は基盤に無いため入力なし" +) + +_IMAGE_NAME_PATTERN = re.compile(r"^([0-9]+)\.png$", re.IGNORECASE) +_ASCII_DIGITS_PATTERN = re.compile(r"^[0-9]+$") +# エラーメッセージに並べる番号・名前の上限(1 行に収めるため) +_MAX_SHOWN = 20 + + +class SourceImage(NamedTuple): + """取り込み元の画像 1 枚. + + Attributes: + number: 番号(ファイル名の数字). + source_name: 取り込み元での相対パス(`<フォルダ名>/<ファイル名>`.区切りは `/`). + path: ファイルのパス. + value: フォルダ名から読み取った所見の値. + """ + + number: int + source_name: str + path: Path + value: TongueColor + + +class PlannedImage(NamedTuple): + """登録の対象と決めた画像(ハッシュ・患者 ID を引いた後).""" + + image: SourceImage + sha256: str + ext: str + patient_key: str + + +# --------------------------------------------------------------------------- +# 取り込み元・対応表の読み取りと検証 +# --------------------------------------------------------------------------- +def _value_text(value: TongueColor) -> str: + return f"{value.label}/{value.severity}" + + +def _hint(items: Sequence[object], show: bool) -> str: + # 番号・元の名前は既定では出さない(SPEC_03「出力の規則」).指定したときだけ一部を並べる + if not items: + return "" + if not show: + return "(--show-source-names で番号・名前を表示する)" + shown = ", ".join(str(item) for item in items[:_MAX_SHOWN]) + rest = len(items) - _MAX_SHOWN + return f"({shown}{f' ほか {rest} 件' if rest > 0 else ''})" + + +def scan_source( + source_name: str, root: Path, *, show_source_names: bool = False +) -> list[SourceImage]: + """取り込み元の 9 フォルダを走査し,画像を番号順に返す(ファイルの中身は読まない). + + Args: + source_name: 取り込み元の名前(メッセージ用). + root: 取り込み元のフォルダ. + show_source_names: エラーメッセージに番号・名前を出すか. + + Returns: + 画像(番号の昇順). + + Raises: + ValueError: 取り込み元のフォルダが無い,9 フォルダ以外のフォルダ・ファイルがある, + 9 フォルダのどれかが無い,フォルダの中にサブフォルダ・PNG 以外・番号でない名前の + ファイルがある,画像が 0 枚のフォルダがある,または番号が重複している. + """ + if not root.is_dir(): + raise ValueError(f"取り込み元 '{source_name}' のフォルダが無い") + + problems: list[str] = [] + unexpected = sorted( + entry.name + for entry in root.iterdir() + if not (entry.is_dir() and entry.name in FOLDER_VALUES) + ) + if unexpected: + problems.append( + f"9 フォルダ以外のフォルダ・ファイルが {len(unexpected)} 件ある" + f"{_hint(unexpected, show_source_names)}" + ) + + images: list[SourceImage] = [] + for folder, value in FOLDER_VALUES.items(): + folder_images, folder_problems = _scan_folder(root, folder, value, show_source_names) + images.extend(folder_images) + problems.extend(folder_problems) + + counts = Counter(image.number for image in images) + duplicated = sorted(number for number, n in counts.items() if n > 1) + if duplicated: + problems.append( + f"同じ番号の画像が {len(duplicated)} 件ある{_hint(duplicated, show_source_names)}" + ) + + if problems: + raise ValueError(f"取り込み元 '{source_name}' の構成が想定と違う: " + ";".join(problems)) + return sorted(images, key=lambda image: image.number) + + +def _scan_folder( + root: Path, folder: str, value: TongueColor, show_source_names: bool +) -> tuple[list[SourceImage], list[str]]: + # 1 フォルダの画像と問題(メッセージ)を返す.問題は止めずに集め,scan_source でまとめて報告する + label = _value_text(value) + folder_path = root / folder + if not folder_path.is_dir(): + return [], [f"{label} のフォルダが無い"] + images: list[SourceImage] = [] + subdirs: list[str] = [] + not_png: list[str] = [] + not_number: list[str] = [] + for path in sorted(folder_path.iterdir()): + if path.is_dir(): + subdirs.append(path.name) + elif path.suffix.lower() != ".png": + not_png.append(path.name) + elif (match := _IMAGE_NAME_PATTERN.match(path.name)) is None: + not_number.append(path.name) + else: + images.append(SourceImage(int(match.group(1)), f"{folder}/{path.name}", path, value)) + problems = [ + f"{label} のフォルダに{what}が {len(names)} 件ある{_hint(names, show_source_names)}" + for names, what in ( + (subdirs, "サブフォルダ"), + (not_png, "PNG 以外のファイル"), + (not_number, "番号でない名前の PNG"), + ) + if names + ] + if not images: + problems.append(f"{label} のフォルダに画像が無い") + return images, problems + + +def apply_holds( + source_name: str, images: Sequence[SourceImage], holds: Holds +) -> tuple[list[SourceImage], int]: + """保留リストの番号を除く. + + Args: + source_name: 取り込み元の名前(メッセージ用). + images: 取り込み元の画像. + holds: 保留の番号. + + Returns: + 保留を除いた画像と,保留にした数. + + Raises: + ValueError: 保留リストにあるのに取り込み元に無い番号がある(リストの誤り). + """ + numbers = {image.number for image in images} + missing = sorted(holds.numbers - numbers) + if missing: + # 保留リストの番号はリポジトリにある値なので,リストの誤りの特定に表示する + raise ValueError( + f"保留リスト {holds.filename} の番号のうち,取り込み元 '{source_name}' に無いものが " + f"{len(missing)} 件ある: {missing}" + ) + targets = [image for image in images if image.number not in holds.numbers] + return targets, len(images) - len(targets) + + +def read_patient_map_rows(path: Path) -> list[tuple[object, ...]]: + """対応表の xlsx を読み取り専用で開き,2 行目以降の行の値を返す(1 行目は見出し). + + Args: + path: 対応表の xlsx. + + Returns: + 各行のセルの値. + + Raises: + ValueError: openpyxl が入っていない,ファイルが無い,xlsx として読めない, + またはシートが 1 枚でない. + """ + try: + import openpyxl + from openpyxl.utils.exceptions import InvalidFileException + except ImportError: + raise ValueError( + "対応表(xlsx)を読むには openpyxl が要る.`uv sync --extra import`" + "(研究室 PC では `uv sync --no-dev --extra import`)を実行する" + ) from None + if not path.is_file(): + raise ValueError("対応表(patient_map)のファイルが無い") + try: + workbook = openpyxl.load_workbook(path, read_only=True, data_only=True) + except (InvalidFileException, zipfile.BadZipFile, OSError, KeyError) as e: + # 例外の文言にはパスが含まれうるため,種類だけを示す + raise ValueError( + f"対応表(patient_map)を xlsx として読めない({type(e).__name__})" + ) from e + try: + if len(workbook.worksheets) != 1: + # どのシートを使うかを黙って決めない + raise ValueError(f"対応表のシートが {len(workbook.worksheets)} 枚ある(1 枚を想定)") + sheet = workbook.worksheets[0] + return [tuple(row) for row in sheet.iter_rows(min_row=2, values_only=True)] + finally: + workbook.close() + + +def _is_blank(value: object) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) + + +def _cell_to_int(value: object) -> int | None: + # 対応表には数値のセルと文字列のセルが混ざるため,0 以上の整数として読める値だけを整数にする + # (bool は int の派生だが数字ではない) + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value >= 0 else None + if isinstance(value, float): + return int(value) if value.is_integer() and value >= 0 else None + if isinstance(value, str) and _ASCII_DIGITS_PATTERN.match(value.strip()): + return int(value.strip()) + return None + + +def _parse_number(value: object) -> int | None: + number = _cell_to_int(value) + return number if number is not None and number >= 1 else None + + +def normalize_patient_key(value: object) -> str | None: + """対応表の患者 ID のセルを,対応表 `identity.sqlite` の `patient_key` の形にそろえる. + + 数値のセルと文字列のセルが混ざっているため,数字だけの値を整数の文字列にそろえる + (`123`・`123.0`・`"0123"`・`" 123 "` → `"123"`). + + Args: + value: セルの値. + + Returns: + そろえた患者 ID.数字でなければ None. + """ + number = _cell_to_int(value) + return None if number is None else str(number) + + +class PatientMap(NamedTuple): + """対応表を読み取った結果. + + Attributes: + keys: 番号 → その番号の行の患者 ID(数字でなければ None.行の数だけ並ぶ). + unused_rows: 番号(1 列目)が 1 以上の整数でないため使わない行の行番号 + (xlsx の 1 始まり.1 行目は見出し). + """ + + keys: dict[int, list[str | None]] + unused_rows: tuple[int, ...] + + +def parse_patient_map(rows: Iterable[Sequence[object]]) -> PatientMap: + """対応表の行(2 行目以降)を,番号 → 患者 ID(数字でなければ None)の一覧にする. + + 空の行は飛ばす.番号(1 列目)が 1 以上の整数でない行はどの画像にも対応しないため使わず, + 行番号を返して件数を報告させる(黙って飛ばさない.実データの対応表にこのような行がある). + 患者 ID が数字でない行・同じ番号の行が複数ある場合の判断は,その番号を使うときに + `resolve_patients` で行う. + + Args: + rows: 各行のセルの値(列 1: 番号・列 2: 何枚目・列 3: 患者 ID). + + Returns: + 番号 → 患者 ID の一覧と,使わない行の行番号. + """ + keys: dict[int, list[str | None]] = defaultdict(list) + unused_rows: list[int] = [] + for index, row in enumerate(rows): + if all(_is_blank(cell) for cell in row): + continue + cells = list(row) + [None] * 3 + number = _parse_number(cells[0]) + if number is None: + # 行番号(xlsx の 1 始まり.1 行目は見出し)は位置で,対応表の中身ではない + unused_rows.append(index + 2) + continue + keys[number].append(normalize_patient_key(cells[2])) + return PatientMap(keys=dict(keys), unused_rows=tuple(unused_rows)) + + +def resolve_patients( + source_name: str, + images: Sequence[SourceImage], + patient_map: dict[int, list[str | None]], + *, + show_source_names: bool = False, +) -> dict[int, str]: + """登録する画像の番号ごとに患者 ID を引く. + + Args: + source_name: 取り込み元の名前(メッセージ用). + images: 登録する画像(保留を除いたもの). + patient_map: 番号 → 患者 ID の一覧(`parse_patient_map` の `keys`). + show_source_names: エラーメッセージに番号を出すか. + + Returns: + 番号 → 患者 ID. + + Raises: + ValueError: 対応表に無い番号,対応表に複数の行がある番号, + または患者 ID が数字でない番号がある. + """ + result: dict[int, str] = {} + missing: list[int] = [] + duplicated: list[int] = [] + not_numeric: list[int] = [] + for image in images: + keys = patient_map.get(image.number, []) + if not keys: + missing.append(image.number) + elif len(keys) > 1: + duplicated.append(image.number) + elif keys[0] is None: + not_numeric.append(image.number) + else: + result[image.number] = keys[0] + problems = [ + f"{what}が {len(numbers)} 件ある{_hint(numbers, show_source_names)}" + for numbers, what in ( + (missing, "対応表に無い番号"), + (duplicated, "対応表に複数の行がある番号"), + (not_numeric, "対応表の患者 ID が数字でない番号"), + ) + if numbers + ] + if problems: + raise ValueError(f"取り込み元 '{source_name}' の対応表: " + ";".join(problems)) + return result + + +def plan_images( + source_name: str, + images: Sequence[SourceImage], + patients: dict[int, str], + *, + show_source_names: bool = False, +) -> list[PlannedImage]: + """登録する画像の SHA-256 を計算し,同じ中身が複数の番号にないかを確かめる. + + 同じ中身は目録では 1 つのファイルになり,2 つの撮影に属することができないため停止する + (SPEC_02「assets」). + + Args: + source_name: 取り込み元の名前(メッセージ用). + images: 登録する画像(保留を除いたもの). + patients: 番号 → 患者 ID. + show_source_names: エラーメッセージに番号を出すか. + + Returns: + 番号順の登録の対象. + + Raises: + ValueError: 同じ中身の画像が複数の番号にある,または拡張子が不正. + """ + planned = [ + PlannedImage( + image=image, + sha256=store.sha256_file(image.path), + ext=store.normalize_ext(image.path.suffix), + patient_key=patients[image.number], + ) + for image in images + ] + by_sha256: dict[str, list[int]] = defaultdict(list) + for item in planned: + by_sha256[item.sha256].append(item.image.number) + groups = {sha256: numbers for sha256, numbers in by_sha256.items() if len(numbers) > 1} + if groups: + shown = ", ".join(sorted(groups)[:3]) + numbers = sorted(n for group in groups.values() for n in group) + raise ValueError( + f"取り込み元 '{source_name}': 同じ中身の画像が複数の番号にある({len(groups)} 組." + f"SHA-256 {shown}{' …' if len(groups) > 3 else ''})." + "保留リストに載せるか取り込み元を直す" + f"{_hint(numbers, show_source_names)}" + ) + return planned + + +# --------------------------------------------------------------------------- +# 目録への登録 +# --------------------------------------------------------------------------- +def _registered_mismatches( + conn: sqlite3.Connection, + context: ImportContext, + item: PlannedImage, + existing: records.AssetRecord, +) -> list[str]: + # 同じ中身が登録済みなら飛ばしてよいのは,登録済みの内容が今回の内容と同じときだけ + mismatches: list[str] = [] + if existing.kind != ASSET_KIND: + mismatches.append("ファイルの種類") + if existing.ext != item.ext: + mismatches.append("拡張子") + if existing.source != context.source_name or existing.source_name != item.image.source_name: + mismatches.append("取り込み元での名前") + capture = records.get_capture(conn, existing.capture_id) if existing.capture_id else None + if capture is None: + mismatches.append("撮影") + return mismatches + if capture.device != DEVICE: + mismatches.append("撮影装置") + if identity.find_subject_id(conn, item.patient_key) != capture.subject_id: + mismatches.append("患者(症例)") + values = records.capture_annotation_values( + conn, capture.capture_id, ANNOTATION_KIND, ANNOTATION_SOURCE + ) + if [json.loads(value) for value in values] != [item.image.value.model_dump(mode="json")]: + mismatches.append("所見の値") + return mismatches + + +def _check_against_catalog( + conn: sqlite3.Connection, context: ImportContext, planned: Sequence[PlannedImage] +) -> tuple[list[PlannedImage], int]: + """登録済みのものを除き,新しく登録するものを返す(食い違いがあれば停止).""" + new: list[PlannedImage] = [] + skipped = 0 + for item in planned: + existing = records.get_asset(conn, item.sha256) + if existing is not None: + mismatches = _registered_mismatches(conn, context, item, existing) + if mismatches: + raise ValueError( + f"取り込み元 '{context.source_name}': " + f"登録済みのファイル(SHA-256 {item.sha256})と" + f"{'・'.join(mismatches)}が食い違う.登録済みの内容は書き換えないので," + "取り込み元・対応表・保留リストを確かめる" + f"{_hint([item.image.number], context.show_source_names)}" + ) + skipped += 1 + continue + replaced = records.find_asset_sha256_by_source_name( + conn, context.source_name, item.image.source_name + ) + if replaced: + raise ValueError( + f"取り込み元 '{context.source_name}': 登録済みのファイル(SHA-256 {replaced[0]})と" + "同じ名前のファイルの中身が変わっている.登録済みの内容は書き換えないので," + "取り込み元を確かめる" + f"{_hint([item.image.number], context.show_source_names)}" + ) + new.append(item) + return new, skipped + + +class _RegisterCounts(NamedTuple): + subjects: int + copied: int + + +def _register( + conn: sqlite3.Connection, context: ImportContext, new: Sequence[PlannedImage] +) -> _RegisterCounts: + now = records.utc_timestamp() + # 研究用 ID は目録と対応表の両方の最大の次から振る(目録を作り直した場合等にも重ならないように) + next_subject = ( + max(records.max_subject_number(conn), identity.max_identity_subject_number(conn)) + 1 + ) + next_capture = records.max_capture_number(conn) + 1 + subjects_added = 0 + copied = 0 + for item in new: + subject_id = identity.find_subject_id(conn, item.patient_key) + if subject_id is None: + subject_id = records.format_id(records.SUBJECT_PREFIX, next_subject) + next_subject += 1 + identity.insert_identity(conn, item.patient_key, subject_id, now) + if not records.subject_exists(conn, subject_id): + records.insert_subject(conn, subject_id, now) + subjects_added += 1 + + # ストアへのコピーはトランザクションの外の操作.登録が失敗しても残るが, + # 目録に無いストアのファイルとして tiasdata check で見え,次の取り込みで再利用される + stored = store.add_file(context.store_dir, item.image.path) + if stored.sha256 != item.sha256: + raise ValueError( + f"取り込みの途中で取り込み元のファイルの中身が変わった(SHA-256 {item.sha256})" + ) + copied += int(stored.copied) + + capture_id = records.format_id(records.CAPTURE_PREFIX, next_capture) + next_capture += 1 + _insert_capture(conn, context, item, stored, subject_id, capture_id, now) + return _RegisterCounts(subjects=subjects_added, copied=copied) + + +def _insert_capture( + conn: sqlite3.Connection, + context: ImportContext, + item: PlannedImage, + stored: store.StoredFile, + subject_id: str, + capture_id: str, + now: str, +) -> None: + # 画像 1 枚 = 撮影 1 つ・ファイル 1 つ・所見 1 つ・由来 1 つ(SPEC_02) + records.insert_capture(conn, capture_id, subject_id, captured_on=None, device=DEVICE) + records.insert_asset( + conn, + records.AssetRecord( + sha256=stored.sha256, + kind=ASSET_KIND, + capture_id=capture_id, + ext=stored.ext, + size_bytes=stored.size_bytes, + source=context.source_name, + source_name=item.image.source_name, + ), + now, + ) + records.insert_capture_annotation( + conn, + kind=ANNOTATION_KIND, + capture_id=capture_id, + value_json=dump_annotation_value(item.image.value), + source=ANNOTATION_SOURCE, + created_at=now, + ) + records.insert_derivation( + conn, + tool=DERIVATION_TOOL, + tool_version=DERIVATION_TOOL_VERSION, + mode=DERIVATION_MODE, + note=DERIVATION_NOTE, + outputs=[(stored.sha256, DERIVATION_OUTPUT_ROLE)], + ) + + +def run(conn: sqlite3.Connection, context: ImportContext) -> ImportResult: + """舌色データセットを取り込む. + + 取り込み元・対応表・保留リストをすべて検証してから,1 つのトランザクションで目録と + 対応表(ATTACH 済み)に登録する.途中で失敗したら目録・対応表には何も残らない. + 既に登録済みのファイル(同じ SHA-256)は,登録済みの内容と一致すれば飛ばす. + + Args: + conn: 目録の接続(対応表を `identity` として ATTACH 済み.トランザクションの外). + context: 取り込みの文脈. + + Returns: + 取り込みの結果(件数). + + Raises: + ValueError: 設定・取り込み元の構成・対応表・保留リストの不備,または登録済みの内容との + 食い違い. + """ + source = context.source + if source.patient_map is None: + raise ValueError( + f"取り込み元 '{context.source_name}' に patient_map(対応表の xlsx)が設定されていない" + ) + show = context.show_source_names + images = scan_source(context.source_name, source.path, show_source_names=show) + targets, held = apply_holds(context.source_name, images, context.holds) + patient_map = parse_patient_map(read_patient_map_rows(source.patient_map)) + patients = resolve_patients( + context.source_name, targets, patient_map.keys, show_source_names=show + ) + planned = plan_images(context.source_name, targets, patients, show_source_names=show) + + with write_transaction(conn): + new, skipped = _check_against_catalog(conn, context, planned) + counts = _register(conn, context, new) + logger.info("取り込み元 '%s': %d 件を登録した", context.source_name, len(new)) + + return ImportResult( + source_name=context.source_name, + importer=NAME, + found=len(images), + held=held, + hold_filename=context.holds.filename, + unused_map_rows=patient_map.unused_rows, + skipped_existing=skipped, + copied_to_store=counts.copied, + subjects_added=counts.subjects, + captures_added=len(new), + assets_added=len(new), + annotations_added=len(new), + derivations_added=len(new), + ) diff --git a/src/tiasdata/kinds/__init__.py b/src/tiasdata/kinds/__init__.py new file mode 100644 index 0000000..2f075b9 --- /dev/null +++ b/src/tiasdata/kinds/__init__.py @@ -0,0 +1,75 @@ +"""ファイルの種類・所見の種類の値の型. + +種類の行(`kinds` 表)はマイグレーションで足し,所見の値の型はここに足す +(SPEC_02「拡張の例」).所見の値は目録に保存する前にここの型で検証する. +""" + +import json +from typing import Any + +from pydantic import BaseModel + +from tiasdata.kinds.tongue_color import TongueColor + +# 所見の種類名 → 値の型 +ANNOTATION_VALUE_TYPES: dict[str, type[BaseModel]] = { + "tongue_color": TongueColor, +} + + +def annotation_value_type(kind: str) -> type[BaseModel]: + """所見の種類名から値の型を引く. + + Args: + kind: 所見の種類名(例: `tongue_color`). + + Returns: + 値の型(pydantic のモデル). + + Raises: + ValueError: 値の型が定義されていない種類. + """ + try: + return ANNOTATION_VALUE_TYPES[kind] + except KeyError: + raise ValueError(f"所見の種類 '{kind}' の値の型が定義されていない") from None + + +def validate_annotation_value(kind: str, value: dict[str, Any]) -> BaseModel: + """所見の値を種類の型で検証する. + + Args: + kind: 所見の種類名. + value: 値(JSON から読んだ辞書等). + + Returns: + 検証済みの値. + + Raises: + ValueError: 値の型が定義されていない種類. + pydantic.ValidationError: 値が型に合わない(未知のキー・未知の値・無効な組み合わせ). + """ + return annotation_value_type(kind).model_validate(value) + + +def dump_annotation_value(value: BaseModel) -> str: + """検証済みの所見の値を,目録に保存する JSON の文字列にする. + + キーの順を固定し空白を入れない(同じ値が常に同じ文字列になるようにするため). + + Args: + value: 検証済みの値. + + Returns: + JSON の文字列. + """ + return json.dumps(value.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + + +__all__ = [ + "ANNOTATION_VALUE_TYPES", + "TongueColor", + "annotation_value_type", + "dump_annotation_value", + "validate_annotation_value", +] diff --git a/src/tiasdata/kinds/tongue_color.py b/src/tiasdata/kinds/tongue_color.py new file mode 100644 index 0000000..49c098b --- /dev/null +++ b/src/tiasdata/kinds/tongue_color.py @@ -0,0 +1,32 @@ +"""所見の種類 `tongue_color`(舌色 5 分類+程度)の値の型(SPEC_02「kinds」).""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +TongueColorLabel = Literal["pale", "light_red", "red", "dark_red", "purple"] +TongueColorSeverity = Literal["strong", "mild", "normal"] + + +class TongueColor(BaseModel): + """舌色の所見の値(`{"label": ..., "severity": ...}`). + + slug は TIASJudgeModel の SPEC_01「クラス定義」と同じ.`normal` は `light_red`(淡紅.正常) + のときだけで,それ以外の舌色は `strong`(あり)または `mild`(ややあり). + + Attributes: + label: 舌色(`pale`・`light_red`・`red`・`dark_red`・`purple`). + severity: 程度(`strong`・`mild`・`normal`). + """ + + # 未知のキーは拒否する(綴りの誤りで値が黙って欠けるのを防ぐ) + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + label: TongueColorLabel + severity: TongueColorSeverity + + @model_validator(mode="after") + def _normal_only_for_light_red(self) -> "TongueColor": + if (self.severity == "normal") != (self.label == "light_red"): + raise ValueError("severity が normal になるのは label が light_red のときだけ") + return self diff --git a/src/tiasdata/stats.py b/src/tiasdata/stats.py new file mode 100644 index 0000000..c9974bc --- /dev/null +++ b/src/tiasdata/stats.py @@ -0,0 +1,99 @@ +"""目録の集計(`tiasdata stats`.SPEC_03「CLI」). + +件数と所見の値(舌色の slug 等)だけを返す.対応表(`identity.sqlite`)は開かない. +""" + +import json +import sqlite3 +from collections import Counter +from dataclasses import dataclass + +from tiasdata import catalog + + +@dataclass(frozen=True) +class AnnotationStats: + """所見の種類ごとの集計. + + Attributes: + total: 有効な所見(訂正されていないもの)の数. + by_field: 値の項目 → 値 → 件数(例: `label` → `red` → 120). + by_value: 値の組み合わせ(`項目=値` を `, ` でつないだもの)→ 件数. + """ + + total: int + by_field: dict[str, dict[str, int]] + by_value: dict[str, int] + + +@dataclass(frozen=True) +class CatalogStats: + """目録の集計. + + Attributes: + subjects: 症例の数. + captures: 撮影の数. + subjects_with_multiple_captures: 撮影を 2 つ以上持つ症例の数. + assets_by_kind: ファイルの種類 → ファイル数. + annotations: 所見の種類 → 集計. + superseded_annotations: 訂正された所見の数. + """ + + subjects: int + captures: int + subjects_with_multiple_captures: int + assets_by_kind: dict[str, int] + annotations: dict[str, AnnotationStats] + superseded_annotations: int + + +def summarize_annotation_values(values: dict[str, int]) -> AnnotationStats: + """所見の値(JSON の文字列)ごとの件数を,項目ごと・組み合わせごとの件数にまとめる. + + Args: + values: 値の JSON → 件数(同じ種類の所見). + + Returns: + 集計.項目・値は名前の順に並べる. + """ + by_field: dict[str, Counter[str]] = {} + by_value: Counter[str] = Counter() + for value_json, count in values.items(): + value = json.loads(value_json) + if not isinstance(value, dict): + by_value[value_json] += count + continue + for field, field_value in value.items(): + by_field.setdefault(field, Counter())[_text(field_value)] += count + by_value[", ".join(f"{k}={_text(v)}" for k, v in sorted(value.items()))] += count + return AnnotationStats( + total=sum(values.values()), + by_field={field: dict(sorted(c.items())) for field, c in sorted(by_field.items())}, + by_value=dict(sorted(by_value.items())), + ) + + +def _text(value: object) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + + +def collect_stats(conn: sqlite3.Connection) -> CatalogStats: + """目録を集計する. + + Args: + conn: 目録への接続(スキーマ最新). + + Returns: + 集計. + """ + return CatalogStats( + subjects=catalog.count_subjects(conn), + captures=catalog.count_captures(conn), + subjects_with_multiple_captures=catalog.count_subjects_with_multiple_captures(conn), + assets_by_kind=catalog.count_assets_by_kind(conn), + annotations={ + kind: summarize_annotation_values(values) + for kind, values in catalog.count_annotation_values(conn).items() + }, + superseded_annotations=catalog.count_superseded_annotations(conn), + ) diff --git a/src/tiasdata/store.py b/src/tiasdata/store.py index ce48379..5d29bc0 100644 --- a/src/tiasdata/store.py +++ b/src/tiasdata/store.py @@ -235,3 +235,83 @@ # 書き換え・削除の誤操作を防ぐ(Windows では読み取り専用の属性になる) mode = path.stat().st_mode path.chmod(mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)) + + +class StoreEntry(NamedTuple): + """ストアの正式な名前のファイル 1 つ(点検用). + + Attributes: + sha256: ファイル名の SHA-256(中身と一致するかは確かめていない). + ext: 拡張子. + writable: 読み取り専用の属性が外れているか. + """ + + sha256: str + ext: str + writable: bool + + +class StoreScan(NamedTuple): + """ストアのフォルダを走査した結果(点検用). + + Attributes: + entries: 正式な名前(`<先頭 2 文字>/.`)のファイル. + temp_files: 書きかけの一時ファイル(`TEMP_PREFIX` で始まる名前)の数. + unexpected: 上のどちらにも当たらないファイル・フォルダの数. + """ + + entries: list[StoreEntry] + temp_files: int + unexpected: int + + +_STORED_NAME_PATTERN = re.compile(r"^([0-9a-f]{64})\.([a-z0-9]+)$") +_PREFIX_DIR_PATTERN = re.compile(r"^[0-9a-f]{2}$") + + +def scan_store(store_dir: Path) -> StoreScan: + """ストアのフォルダを走査し,ファイルを正式な名前・一時ファイル・想定外に分ける. + + 中身は読まない(ハッシュの照合は `verify_stored` で行う). + + Args: + store_dir: ストアのフォルダ. + + Returns: + 走査の結果(正式な名前のファイルは SHA-256 の順). + + Raises: + ValueError: ストアのフォルダが無い. + """ + if not store_dir.is_dir(): + raise ValueError("ストアのフォルダが無い(先に tiasdata init を実行する)") + entries: list[StoreEntry] = [] + temp_files = 0 + unexpected = 0 + for prefix_dir in store_dir.iterdir(): + if not prefix_dir.is_dir() or not _PREFIX_DIR_PATTERN.match(prefix_dir.name): + unexpected += 1 + continue + for path in prefix_dir.iterdir(): + if path.name.startswith(TEMP_PREFIX): + temp_files += 1 + continue + match = _STORED_NAME_PATTERN.match(path.name) + if not path.is_file() or match is None or match.group(1)[:2] != prefix_dir.name: + unexpected += 1 + continue + entries.append(StoreEntry(match.group(1), match.group(2), is_writable(path))) + entries.sort() + return StoreScan(entries=entries, temp_files=temp_files, unexpected=unexpected) + + +def is_writable(path: Path) -> bool: + """ストアのファイルの読み取り専用の属性が外れているか(所有者の書き込み権限があるか). + + Args: + path: ストアのファイル. + + Returns: + 書き込みできる属性なら True(Windows では読み取り専用の属性が無いとき). + """ + return bool(path.stat().st_mode & stat.S_IWUSR) diff --git a/tests/catalog/test_connection.py b/tests/catalog/test_connection.py index 5f43b4c..ee03305 100644 --- a/tests/catalog/test_connection.py +++ b/tests/catalog/test_connection.py @@ -45,3 +45,123 @@ catalog.connect(path) assert str(tmp_path) not in str(exc.value) assert tmp_path.as_posix() not in str(exc.value) + + +# --------------------------------------------------------------------------- +# write_transaction(SPEC_03: 登録は 1 つのトランザクション. +# error-handling.md: 制約違反は ValueError) +# --------------------------------------------------------------------------- +def _subject(conn, subject_id: str) -> None: + conn.execute( + "INSERT INTO subjects (subject_id, created_at) VALUES (?, '2026-10-06T00:00:00Z')", + (subject_id,), + ) + + +def _subjects(path: Path) -> list[str]: + conn = catalog.connect(path) + try: + return [s for (s,) in conn.execute("SELECT subject_id FROM subjects ORDER BY 1")] + finally: + conn.close() + + +@pytest.fixture +def migrated_path(tmp_path: Path) -> Path: + path = tmp_path / "catalog.sqlite" + conn = catalog.connect(path, create=True) + try: + catalog.apply_migrations(conn, "catalog") + finally: + conn.close() + return path + + +def test_write_transaction_commits_on_success(migrated_path: Path) -> None: + conn = catalog.connect(migrated_path) + try: + with catalog.write_transaction(conn): + _subject(conn, "S000001") + _subject(conn, "S000002") + finally: + conn.close() + assert _subjects(migrated_path) == ["S000001", "S000002"] + + +def test_write_transaction_converts_integrity_error_and_rolls_back(migrated_path: Path) -> None: + conn = catalog.connect(migrated_path) + try: + with pytest.raises(ValueError), catalog.write_transaction(conn): + _subject(conn, "S000001") + _subject(conn, "bad-id") # CHECK 違反 + assert not conn.in_transaction + finally: + conn.close() + assert _subjects(migrated_path) == [] + + +def test_write_transaction_rolls_back_on_other_error(migrated_path: Path) -> None: + conn = catalog.connect(migrated_path) + try: + with pytest.raises(RuntimeError, match="boom"), catalog.write_transaction(conn): + _subject(conn, "S000001") + raise RuntimeError("boom") + finally: + conn.close() + assert _subjects(migrated_path) == [] + + +def test_write_transaction_includes_attached_db(migrated_path: Path, tmp_path: Path) -> None: + # 目録と ATTACH した対応表への書き込みは同じトランザクションで取り消される + identity_path = tmp_path / "identity.sqlite" + conn = catalog.connect(identity_path, create=True) + try: + catalog.apply_migrations(conn, "identity") + finally: + conn.close() + + conn = catalog.connect(migrated_path) + try: + conn.execute("ATTACH DATABASE ? AS identity", (str(identity_path),)) + with pytest.raises(ValueError), catalog.write_transaction(conn): + conn.execute( + "INSERT INTO identity.identity_map (patient_key, subject_id, created_at)" + " VALUES ('990001', 'S000001', '2026-10-06T00:00:00Z')" + ) + _subject(conn, "S000001") + _subject(conn, "S000001") # 主キーの重複 + finally: + conn.close() + conn = catalog.connect(identity_path) + try: + assert conn.execute("SELECT COUNT(*) FROM identity_map").fetchone()[0] == 0 + finally: + conn.close() + assert _subjects(migrated_path) == [] + + +def test_require_latest_rejects_unmigrated_db(tmp_path: Path) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + with pytest.raises(ValueError): + catalog.require_latest(conn, "catalog") + finally: + conn.close() + + +def test_require_latest_accepts_migrated_db(migrated_path: Path) -> None: + conn = catalog.connect(migrated_path) + try: + catalog.require_latest(conn, "catalog") + finally: + conn.close() + + +def test_require_latest_rejects_newer_db(migrated_path: Path) -> None: + conn = catalog.connect(migrated_path) + try: + conn.execute(f"PRAGMA user_version = {catalog.latest_version('catalog') + 1}") + with pytest.raises(ValueError): + catalog.require_latest(conn, "catalog") + finally: + conn.close() diff --git a/tests/catalog/test_records.py b/tests/catalog/test_records.py new file mode 100644 index 0000000..1d74d75 --- /dev/null +++ b/tests/catalog/test_records.py @@ -0,0 +1,32 @@ +"""目録の登録で使う ID・日時の書式(SPEC_02「ID の書式」「スキーマで強制する規則」).""" + +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from tiasdata.catalog.records import format_id, utc_timestamp + + +@pytest.mark.parametrize( + ("prefix", "number", "expected"), + [("S", 1, "S000001"), ("C", 456, "C000456"), ("S", 999999, "S999999")], +) +def test_format_id_is_prefix_and_six_digits(prefix: str, number: int, expected: str) -> None: + assert format_id(prefix, number) == expected + + +@pytest.mark.parametrize("number", [0, -1, 1_000_000]) +def test_format_id_rejects_out_of_range(number: int) -> None: + with pytest.raises(ValueError): + format_id("S", number) + + +def test_utc_timestamp_is_seconds_precision_utc() -> None: + moment = datetime(2026, 10, 6, 9, 30, 15, 123456, tzinfo=UTC) + assert utc_timestamp(moment) == "2026-10-06T09:30:15Z" + + +def test_utc_timestamp_converts_to_utc() -> None: + jst = timezone(timedelta(hours=9)) + moment = datetime(2026, 10, 6, 9, 0, 0, tzinfo=jst) + assert utc_timestamp(moment) == "2026-10-06T00:00:00Z" diff --git a/tests/conftest.py b/tests/conftest.py index d5c5a52..e272032 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,7 +73,7 @@ def tongue_color_source(tmp_path: Path) -> DummyTongueColorSource: """舌色データセットと同じ構成のダミーの取り込み元(各フォルダ 2 枚・番号は 1 から連番). - 対応表(xlsx)は含まない(Step 3 で足す). + 対応表(xlsx)は `tongue_color_patient_map` で作る. """ root = tmp_path / "source" / "TIASJudgeModel" images: list[DummyImage] = [] @@ -86,6 +86,157 @@ return DummyTongueColorSource(root=root, images=images) +def dummy_patient_key(number: int) -> str: + """ダミーの取り込み元の画像の番号 → 架空の患者 ID. + + 7 人に割り振るので,18 枚のダミーでは同じ患者に 2〜3 枚が当たる(ラベルの違う画像も混ざる). + 値は架空(99 で始まる 6 桁). + """ + return str(990001 + (number - 1) % 7) + + +def write_patient_map( + path: Path, + rows: list[tuple[object, ...]], + *, + header: tuple[object, ...] = ("番号", "何枚目", "患者ID"), +) -> Path: + """対応表の xlsx を書く. + + 1 行目は見出し,以降は `rows`(列は 1: 番号・2: 何枚目・3: 患者 ID). + """ + from openpyxl import Workbook + + path.parent.mkdir(parents=True, exist_ok=True) + workbook = Workbook() + sheet = workbook.active + sheet.append(list(header)) + for row in rows: + sheet.append(list(row)) + workbook.save(path) + return path + + +@dataclass(frozen=True) +class DummyPatientMap: + """ダミーの対応表(xlsx).""" + + path: Path + # 番号 → 架空の患者 ID(`dummy_patient_key` と同じ) + patient_keys: dict[int, str] + + +@pytest.fixture +def make_patient_map(): + """対応表の xlsx を書く関数(`make_patient_map(path, rows, header=...)`).""" + return write_patient_map + + +@pytest.fixture +def tongue_color_patient_map( + tmp_path: Path, tongue_color_source: DummyTongueColorSource +) -> DummyPatientMap: + """`tongue_color_source` の全画像の対応表(架空の患者 ID.同じ患者に複数枚). + + 実データと同じく,患者 ID のセルは数値の行と文字列の行を混ぜる(偶数の番号は文字列). + """ + rows: list[tuple[object, ...]] = [] + patient_keys: dict[int, str] = {} + seen: dict[str, int] = {} + for image in tongue_color_source.images: + key = dummy_patient_key(image.number) + patient_keys[image.number] = key + seen[key] = seen.get(key, 0) + 1 + cell: object = key if image.number % 2 == 0 else int(key) + rows.append((image.number, seen[key], cell)) + path = write_patient_map(tmp_path / "source" / "patient_map.xlsx", rows) + return DummyPatientMap(path=path, patient_keys=patient_keys) + + +def write_holds(holds_dir: Path, source_name: str, numbers: list[int]) -> Path: + """保留リスト `/.toml` を書く.""" + holds_dir.mkdir(parents=True, exist_ok=True) + entries = "".join( + f'[[hold]]\nnumber = {number}\nreason = "テスト用の保留"\n' + f'resolve = "テスト用"\nsince = "2026-10-06"\n\n' + for number in numbers + ) + path = holds_dir / f"{source_name}.toml" + path.write_text(entries, encoding="utf-8") + return path + + +@pytest.fixture +def holds_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """一時フォルダの保留リストのフォルダ(リポジトリの `holds/` の代わりに使わせる). + + `tiasdata.importers.holds.default_holds_dir` を差し替えるので,同じプロセスで呼ぶ + `run_import`・`cli.main` はこのフォルダを読む(subprocess で起動した CLI には効かない). + 保留リストは `write_holds(holds_dir, <取り込み元の名前>, [番号, ...])` で書く + (保留リストのファイルが無い取り込み元の取り込みは停止する.保留が無いなら `[]` で書く). + """ + from tiasdata.importers import holds + + directory = tmp_path / "holds" + directory.mkdir() + monkeypatch.setattr(holds, "default_holds_dir", lambda: directory) + return directory + + +@dataclass(frozen=True) +class ImportEnv: + """取り込みを試すための一時の設定ファイル・取り込み元・対応表一式.""" + + config_path: Path + source_name: str + source: DummyTongueColorSource + patient_map: DummyPatientMap + holds_dir: Path + catalog_root: Path + identity_path: Path + + +@pytest.fixture +def import_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tongue_color_source: DummyTongueColorSource, + tongue_color_patient_map: DummyPatientMap, + holds_dir: Path, +) -> ImportEnv: + """ダミーの取り込み元 `dummy_tongue` を設定した設定ファイル(`TIASDATA_CONFIG` で指す). + + `tiasdata init` はまだ実行していない.保留リストは空(`[[hold]]` が 0 件のファイル. + 保留リストのファイルが無いと取り込みは停止する). + """ + source_name = "dummy_tongue" + write_holds(holds_dir, source_name, []) + catalog_root = tmp_path / "data" / "tiasdata" + identity_path = tmp_path / "data" / "identity" / "identity.sqlite" + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + "[catalog]\n" + f"root = '{catalog_root.as_posix()}'\n" + f"identity = '{identity_path.as_posix()}'\n\n" + f"[sources.{source_name}]\n" + f"path = '{tongue_color_source.root.as_posix()}'\n" + 'importer = "tongue_color_folders"\n' + f"patient_map = '{tongue_color_patient_map.path.as_posix()}'\n", + encoding="utf-8", + ) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + return ImportEnv( + config_path=config_path, + source_name=source_name, + source=tongue_color_source, + patient_map=tongue_color_patient_map, + holds_dir=holds_dir, + catalog_root=catalog_root, + identity_path=identity_path, + ) + + @pytest.fixture(autouse=True) def _isolate_config_env(monkeypatch: pytest.MonkeyPatch) -> None: # 開発者の環境変数が混ざらないようにする(テストごとに必要なら設定する) diff --git a/tests/importers/test_holds.py b/tests/importers/test_holds.py new file mode 100644 index 0000000..0b22edd --- /dev/null +++ b/tests/importers/test_holds.py @@ -0,0 +1,92 @@ +"""保留リスト `holds/<取り込み元の名前>.toml` の読み込み(SPEC_03「保留リスト」・PLAN_02). + +- 1 件ごとに number・reason・resolve・since を書く +- ファイルが無ければ停止する.空のファイル・`[[hold]]` 0 件は保留 0 件(ユーザー承認済み) +- 形の誤り・番号の重複は停止する +- 最初の保留リスト `holds/tiasjudgemodel.toml` は舌色データセットの重複 5 組のうち 9 枚 +""" + +from pathlib import Path + +import pytest +from conftest import write_holds + +from tiasdata.importers.holds import default_holds_dir, load_holds + +ENTRY = '[[hold]]\nnumber = {n}\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n\n' + + +def _write(directory: Path, name: str, text: str) -> Path: + path = directory / f"{name}.toml" + path.write_text(text, encoding="utf-8") + return path + + +class TestLoadHolds: + def test_numbers_are_loaded(self, tmp_path: Path) -> None: + write_holds(tmp_path, "src", [5, 2, 9]) + holds = load_holds(tmp_path, "src") + assert holds.numbers == frozenset({2, 5, 9}) + assert holds.filename.endswith("src.toml") + + @pytest.mark.parametrize("text", ["", "# 保留なし\n"]) + def test_empty_file_means_no_holds(self, tmp_path: Path, text: str) -> None: + _write(tmp_path, "src", text) + assert load_holds(tmp_path, "src").numbers == frozenset() + + def test_missing_file_stops(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + load_holds(tmp_path, "src") + + def test_message_does_not_contain_path(self, tmp_path: Path) -> None: + with pytest.raises(ValueError) as excinfo: + load_holds(tmp_path, "src") + assert str(tmp_path) not in str(excinfo.value) + assert tmp_path.as_posix() not in str(excinfo.value) + + @pytest.mark.parametrize( + "text", + [ + pytest.param("[[hold]]\nnumber = 3\n", id="missing-fields"), + pytest.param(ENTRY.format(n='"3"'), id="number-as-string"), + pytest.param(ENTRY.format(n="3.0"), id="number-as-float"), + pytest.param(ENTRY.format(n="0"), id="number-zero"), + pytest.param(ENTRY.format(n="-1"), id="number-negative"), + pytest.param(ENTRY.format(n="true"), id="number-as-bool"), + pytest.param(ENTRY.format(n="3") + 'extra = "x"\n', id="unknown-key"), + pytest.param( + '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = "いつか"\n', + id="since-not-date", + ), + pytest.param( + '[[hold]]\nnumber = 3\nreason = ""\nresolve = "s"\nsince = 2026-10-06\n', + id="empty-reason", + ), + pytest.param('[[hold]]\nnumber = 3\nreason = "r\n', id="broken-toml"), + pytest.param("[holds]\nnumber = 3\n", id="unknown-table"), + pytest.param(ENTRY.format(n=3) + ENTRY.format(n=3), id="duplicated-number"), + ], + ) + def test_malformed_file_stops(self, tmp_path: Path, text: str) -> None: + _write(tmp_path, "src", text) + with pytest.raises(ValueError): + load_holds(tmp_path, "src") + + @pytest.mark.parametrize("name", ["../src", "a/b", "a\\b", "", "名前"]) + def test_source_name_unusable_as_filename_stops(self, tmp_path: Path, name: str) -> None: + with pytest.raises(ValueError): + load_holds(tmp_path, name) + + +class TestRepositoryHolds: + def test_default_holds_dir_is_repository_holds(self) -> None: + directory = default_holds_dir() + assert directory.name == "holds" + assert (directory.parent / "pyproject.toml").is_file() + + def test_tiasjudgemodel_hold_list_matches_plan(self) -> None: + # PLAN_02「舌色データセットの重複 5 組の扱い」: + # 122/184・548/566・381/382・515/516 の両方と 509(508 は残す) + holds = load_holds(default_holds_dir(), "tiasjudgemodel") + assert holds.numbers == frozenset({122, 184, 548, 566, 381, 382, 515, 516, 509}) + assert 508 not in holds.numbers diff --git a/tests/importers/test_tongue_color_folders.py b/tests/importers/test_tongue_color_folders.py new file mode 100644 index 0000000..b087ab5 --- /dev/null +++ b/tests/importers/test_tongue_color_folders.py @@ -0,0 +1,947 @@ +"""取り込み元 `tongue_color_folders`(舌色の 9 フォルダ+対応表の xlsx)の取り込み. + +仕様: +- PLAN_02 Step 3 の完了条件(9 フォルダと対応表を取り込める.同じ患者の複数枚が同じ症例にまとまる. + 2 回実行しても同じ結果.想定外の構成・対応表に無い番号で停止.保留リストの番号を飛ばし件数を報告) +- SPEC_02(症例は患者ごと・撮影は画像ごと.ID の書式.所見・由来.患者 ID は対応表にだけ入れる. + 同じ中身で別の撮影になるものは停止) +- SPEC_03(import.保留リスト.登録は 1 トランザクション.問題は件数と理由を示して停止) +- testing.md 必須対象 3(取り込み)・6(出力の規則) +- ユーザー承認済みの追加(対応表の番号が整数でない行は使わず件数を報告.保留リストのファイルが + 無ければ停止.患者 ID の数字は先頭の 0 をそろえる) +""" + +import hashlib +import json +import re +import shutil +import sqlite3 +import stat +from collections.abc import Callable +from contextlib import closing +from pathlib import Path + +import pytest +from conftest import ( + TONGUE_COLOR_FOLDERS, + ImportEnv, + write_dummy_png, + write_holds, + write_patient_map, +) + +from tiasdata.cli import main +from tiasdata.config import load_config +from tiasdata.importers import ImportResult, run_import + +SUBJECT_ID = re.compile(r"^S[0-9]{6}$") +CAPTURE_ID = re.compile(r"^C[0-9]{6}$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") +DUMMY_KEYS = [str(990001 + i) for i in range(7)] + +CATALOG_TABLES = [ + "subjects", + "captures", + "assets", + "annotations", + "derivations", + "derivation_inputs", + "derivation_outputs", +] + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- +def _init() -> None: + assert main(["init"]) == 0 + + +def _import(env: ImportEnv, **kwargs) -> ImportResult: + return run_import(load_config(env.config_path), env.source_name, **kwargs) + + +def _catalog(env: ImportEnv) -> sqlite3.Connection: + return sqlite3.connect(env.catalog_root / "catalog.sqlite") + + +def _identity(env: ImportEnv) -> sqlite3.Connection: + return sqlite3.connect(env.identity_path) + + +def _counts(env: ImportEnv) -> dict[str, int]: + with closing(_catalog(env)) as conn: + counts = { + table: conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + for table in CATALOG_TABLES + } + with closing(_identity(env)) as conn: + counts["identity_map"] = conn.execute("SELECT COUNT(*) FROM identity_map").fetchone()[0] + return counts + + +def _dumps(env: ImportEnv) -> tuple[list[str], list[str]]: + with closing(_catalog(env)) as conn: + catalog_dump = list(conn.iterdump()) + with closing(_identity(env)) as conn: + identity_dump = list(conn.iterdump()) + return catalog_dump, identity_dump + + +def _number_of(source_name: str) -> int: + # source_name は取り込み元での相対パス(<フォルダ>/<番号>.png) + return int(Path(source_name).stem) + + +def _captures_by_number(env: ImportEnv) -> dict[int, tuple[str, str, str]]: + """番号 → (sha256, capture_id, subject_id).""" + with closing(_catalog(env)) as conn: + rows = conn.execute( + "SELECT a.source_name, a.sha256, c.capture_id, c.subject_id" + " FROM assets AS a JOIN captures AS c ON c.capture_id = a.capture_id" + ).fetchall() + return {_number_of(name): (sha, cap, subj) for name, sha, cap, subj in rows} + + +def _identity_map(env: ImportEnv) -> dict[str, str]: + with closing(_identity(env)) as conn: + return dict(conn.execute("SELECT patient_key, subject_id FROM identity_map")) + + +def _default_rows(env: ImportEnv) -> list[tuple[object, ...]]: + seen: dict[str, int] = {} + rows: list[tuple[object, ...]] = [] + for number, key in sorted(env.patient_map.patient_keys.items()): + seen[key] = seen.get(key, 0) + 1 + rows.append((number, seen[key], key)) + return rows + + +def _rewrite_map(env: ImportEnv, rows: list[tuple[object, ...]]) -> None: + write_patient_map(env.patient_map.path, rows) + + +def _folder(number: int) -> str: + return list(TONGUE_COLOR_FOLDERS)[(number - 1) // 2] + + +def _assert_no_leak(message: str, env: ImportEnv, *names: str) -> None: + root = env.config_path.parents[1] + for form in (str(root), root.as_posix(), str(root.resolve())): + assert form not in message + for key in DUMMY_KEYS: + assert key not in message + for name in names: + assert name not in message + + +def _assert_nothing_registered(env: ImportEnv) -> None: + assert all(count == 0 for count in _counts(env).values()), _counts(env) + + +# --------------------------------------------------------------------------- +# 取り込みの結果 +# --------------------------------------------------------------------------- +class TestImportRegisters: + def test_result_counts(self, import_env: ImportEnv) -> None: + _init() + result = _import(import_env) + assert result.source_name == "dummy_tongue" + assert result.importer == "tongue_color_folders" + assert result.found == 18 + assert result.held == 0 + assert result.skipped_existing == 0 + assert result.copied_to_store == 18 + # 症例は患者ごと(ダミーは 7 人),撮影・ファイル・所見・由来は画像ごと + assert result.subjects_added == 7 + assert result.captures_added == 18 + assert result.assets_added == 18 + assert result.annotations_added == 18 + assert result.derivations_added == 18 + assert result.unused_map_rows == () + + def test_catalog_counts(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + assert _counts(import_env) == { + "subjects": 7, + "captures": 18, + "assets": 18, + "annotations": 18, + "derivations": 18, + "derivation_inputs": 0, + "derivation_outputs": 18, + "identity_map": 7, + } + + def test_same_patient_images_share_one_subject(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + captures = _captures_by_number(import_env) + identity_map = _identity_map(import_env) + keys = import_env.patient_map.patient_keys + assert set(captures) == set(keys) + for number, (_, _, subject_id) in captures.items(): + assert identity_map[keys[number]] == subject_id + # 同じ患者 ⇔ 同じ症例 + for a in captures: + for b in captures: + assert (keys[a] == keys[b]) == (captures[a][2] == captures[b][2]) + # 撮影は画像ごとに別 + assert len({cap for _, cap, _ in captures.values()}) == 18 + + def test_one_subject_per_patient_in_identity_map(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + identity_map = _identity_map(import_env) + assert set(identity_map) == set(DUMMY_KEYS) + assert len(set(identity_map.values())) == 7 + with closing(_catalog(import_env)) as conn: + subjects = {s for (s,) in conn.execute("SELECT subject_id FROM subjects")} + assert subjects == set(identity_map.values()) + + def test_patient_keys_are_only_in_identity_db(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + catalog_dump, identity_dump = _dumps(import_env) + catalog_text = "\n".join(catalog_dump) + for key in DUMMY_KEYS: + assert key not in catalog_text + assert any(key in line for line in identity_dump) + + def test_id_formats(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + with closing(_catalog(import_env)) as conn: + for (subject_id,) in conn.execute("SELECT subject_id FROM subjects"): + assert SUBJECT_ID.match(subject_id) + for capture_id, subject_id in conn.execute( + "SELECT capture_id, subject_id FROM captures" + ): + assert CAPTURE_ID.match(capture_id) + assert SUBJECT_ID.match(subject_id) + for (sha256,) in conn.execute("SELECT sha256 FROM assets"): + assert SHA256.match(sha256) + + def test_captured_on_is_null(self, import_env: ImportEnv) -> None: + # 同じ患者の複数枚の撮影日は分からないため NULL(SPEC_02「ID の書式」) + _init() + _import(import_env) + with closing(_catalog(import_env)) as conn: + values = {v for (v,) in conn.execute("SELECT captured_on FROM captures")} + assert values == {None} + + def test_assets_rows(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + images = {image.number: image for image in import_env.source.images} + with closing(_catalog(import_env)) as conn: + rows = conn.execute( + "SELECT sha256, kind, ext, size_bytes, source, source_name FROM assets" + ).fetchall() + assert len(rows) == 18 + for sha256, kind, ext, size_bytes, source, source_name in rows: + image = images[_number_of(source_name)] + data = image.path.read_bytes() + assert sha256 == hashlib.sha256(data).hexdigest() + assert kind == "tongue_crop_masked" + assert ext == "png" + assert size_bytes == len(data) + assert source == "dummy_tongue" + # 取り込み元での相対パス + assert Path(source_name).parts == (image.folder, f"{image.number}.png") + + def test_annotation_values_match_folder_labels(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + images = {image.number: image for image in import_env.source.images} + with closing(_catalog(import_env)) as conn: + rows = conn.execute( + "SELECT a.kind, a.capture_id, a.sha256, a.value, a.source, a.superseded_by," + " s.source_name" + " FROM annotations AS a JOIN assets AS s ON s.capture_id = a.capture_id" + ).fetchall() + assert len(rows) == 18 + for kind, capture_id, sha256, value, source, superseded_by, source_name in rows: + image = images[_number_of(source_name)] + assert kind == "tongue_color" + # 舌色のラベルは撮影に付く(ファイルには付かない) + assert capture_id is not None + assert sha256 is None + assert json.loads(value) == {"label": image.label, "severity": image.severity} + assert source == "tiasjudgemodel_folder" + assert superseded_by is None + + def test_derivations(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + with closing(_catalog(import_env)) as conn: + derivations = conn.execute( + "SELECT derivation_id, tool, tool_version, mode, performed_at FROM derivations" + ).fetchall() + outputs = conn.execute( + "SELECT derivation_id, sha256 FROM derivation_outputs" + ).fetchall() + inputs = conn.execute("SELECT COUNT(*) FROM derivation_inputs").fetchone()[0] + asset_shas = {s for (s,) in conn.execute("SELECT sha256 FROM assets")} + assert len(derivations) == 18 + for _, tool, tool_version, mode, performed_at in derivations: + assert (tool, tool_version, mode) == ("DeepTIAS", "unknown", "Image&Calib") + assert performed_at is None + # 入力なし.出力は各ファイル 1 つずつ + assert inputs == 0 + assert sorted(s for _, s in outputs) == sorted(asset_shas) + assert len({d for d, _ in outputs}) == 18 + + def test_files_are_in_store_and_read_only(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + store_dir = import_env.catalog_root / "store" + with closing(_catalog(import_env)) as conn: + rows = conn.execute("SELECT sha256, ext FROM assets").fetchall() + for sha256, ext in rows: + path = store_dir / sha256[:2] / f"{sha256}.{ext}" + assert path.is_file() + assert hashlib.sha256(path.read_bytes()).hexdigest() == sha256 + assert not path.stat().st_mode & stat.S_IWUSR + + def test_source_is_not_modified(self, import_env: ImportEnv) -> None: + root = import_env.source.root + + def snapshot() -> dict[str, tuple[bytes, int]]: + return { + p.relative_to(root).as_posix(): (p.read_bytes(), p.stat().st_mtime_ns) + for p in root.rglob("*") + if p.is_file() + } + + before = snapshot() + _init() + _import(import_env) + assert snapshot() == before + + def test_uppercase_png_extension_is_accepted(self, import_env: ImportEnv) -> None: + # 拡張子は小文字にそろえて登録する(SPEC_02「assets」) + image = import_env.source.images[0] + upper = image.path.with_name(f"{image.number}.PNG") + image.path.rename(upper) + _init() + result = _import(import_env) + assert result.captures_added == 18 + with closing(_catalog(import_env)) as conn: + exts = {e for (e,) in conn.execute("SELECT ext FROM assets")} + assert exts == {"png"} + + +# --------------------------------------------------------------------------- +# 冪等(2 回実行しても同じ結果) +# --------------------------------------------------------------------------- +class TestIdempotent: + def test_second_run_adds_nothing(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + before = _dumps(import_env) + result = _import(import_env) + assert result.found == 18 + assert result.skipped_existing == 18 + assert result.copied_to_store == 0 + assert result.subjects_added == 0 + assert result.captures_added == 0 + assert result.assets_added == 0 + assert result.annotations_added == 0 + assert result.derivations_added == 0 + assert _dumps(import_env) == before + + def test_second_run_keeps_store_unchanged(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + store_dir = import_env.catalog_root / "store" + before = sorted(p.relative_to(store_dir) for p in store_dir.rglob("*")) + _import(import_env) + assert sorted(p.relative_to(store_dir) for p in store_dir.rglob("*")) == before + + def test_added_images_are_registered_and_existing_ids_kept(self, import_env: ImportEnv) -> None: + _init() + _import(import_env) + before_captures = _captures_by_number(import_env) + before_identity = _identity_map(import_env) + + root = import_env.source.root + write_dummy_png(root / "紅_あり" / "19.png", seed=19) # 既存の患者 990001 + write_dummy_png(root / "淡白_あり" / "20.png", seed=20) # 新しい患者 + rows = _default_rows(import_env) + [(19, 4, "990001"), (20, 1, "990099")] + _rewrite_map(import_env, rows) + + result = _import(import_env) + assert result.found == 20 + assert result.skipped_existing == 18 + assert result.captures_added == 2 + assert result.subjects_added == 1 + assert result.assets_added == 2 + assert result.annotations_added == 2 + assert result.derivations_added == 2 + + after_captures = _captures_by_number(import_env) + after_identity = _identity_map(import_env) + for number, ids in before_captures.items(): + assert after_captures[number] == ids + for key, subject_id in before_identity.items(): + assert after_identity[key] == subject_id + # 既存の患者の新しい画像は既存の症例に入る + assert after_captures[19][2] == before_identity["990001"] + # 新しい患者は既存と重ならない新しい研究用 ID + new_subject = after_identity["990099"] + assert SUBJECT_ID.match(new_subject) + assert new_subject not in before_identity.values() + assert after_captures[20][2] == new_subject + new_captures = {after_captures[19][1], after_captures[20][1]} + assert not new_captures & {cap for _, cap, _ in before_captures.values()} + + def test_cli_import_twice_reports_same_catalog( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + _init() + assert main(["import", "dummy_tongue"]) == 0 + before = _dumps(import_env) + assert main(["import", "dummy_tongue"]) == 0 + assert _dumps(import_env) == before + + +# --------------------------------------------------------------------------- +# 保留リスト +# --------------------------------------------------------------------------- +class TestHolds: + def test_held_numbers_are_skipped_and_counted(self, import_env: ImportEnv) -> None: + write_holds(import_env.holds_dir, "dummy_tongue", [3, 10]) + _init() + result = _import(import_env) + assert result.found == 18 + assert result.held == 2 + assert result.hold_filename.endswith("dummy_tongue.toml") + assert result.captures_added == 16 + assert result.assets_added == 16 + assert set(_captures_by_number(import_env)) == set(range(1, 19)) - {3, 10} + assert _counts(import_env)["captures"] == 16 + + def test_held_image_is_not_copied_to_store(self, import_env: ImportEnv) -> None: + write_holds(import_env.holds_dir, "dummy_tongue", [3]) + _init() + _import(import_env) + held = import_env.source.images[2] + assert held.number == 3 + sha256 = hashlib.sha256(held.path.read_bytes()).hexdigest() + store_dir = import_env.catalog_root / "store" + assert not list(store_dir.rglob(f"{sha256}.*")) + + def test_removed_from_list_is_imported_on_rerun(self, import_env: ImportEnv) -> None: + write_holds(import_env.holds_dir, "dummy_tongue", [3, 10]) + _init() + _import(import_env) + before = _captures_by_number(import_env) + + write_holds(import_env.holds_dir, "dummy_tongue", []) + result = _import(import_env) + assert result.held == 0 + assert result.skipped_existing == 16 + assert result.captures_added == 2 + after = _captures_by_number(import_env) + assert set(after) == set(range(1, 19)) + for number, ids in before.items(): + assert after[number] == ids + # 保留を外した画像も患者ごとの症例に入る + keys = import_env.patient_map.patient_keys + identity_map = _identity_map(import_env) + for number in (3, 10): + assert after[number][2] == identity_map[keys[number]] + + def test_number_in_list_but_not_in_source_stops(self, import_env: ImportEnv) -> None: + write_holds(import_env.holds_dir, "dummy_tongue", [3, 99]) + _init() + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + _assert_no_leak(str(excinfo.value), import_env) + + def test_missing_hold_file_stops(self, import_env: ImportEnv) -> None: + (import_env.holds_dir / "dummy_tongue.toml").unlink() + _init() + with pytest.raises(ValueError): + _import(import_env) + _assert_nothing_registered(import_env) + + def test_hold_file_of_other_source_does_not_count(self, import_env: ImportEnv) -> None: + (import_env.holds_dir / "dummy_tongue.toml").unlink() + write_holds(import_env.holds_dir, "other_source", []) + _init() + with pytest.raises(ValueError): + _import(import_env) + _assert_nothing_registered(import_env) + + @pytest.mark.parametrize("content", ["", "# 保留なし\n"]) + def test_empty_hold_file_means_no_holds(self, import_env: ImportEnv, content: str) -> None: + (import_env.holds_dir / "dummy_tongue.toml").write_text(content, encoding="utf-8") + _init() + result = _import(import_env) + assert result.held == 0 + assert result.captures_added == 18 + + @pytest.mark.parametrize( + "content", + [ + pytest.param("[[hold]]\nnumber = 3\n", id="missing-fields"), + pytest.param( + '[[hold]]\nnumber = "3"\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', + id="number-as-string", + ), + pytest.param( + '[[hold]]\nnumber = 0\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', + id="number-zero", + ), + pytest.param( + '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n' + 'extra = "x"\n', + id="unknown-key", + ), + pytest.param('[[hold]]\nnumber = 3\nreason = "r\n', id="broken-toml"), + pytest.param( + '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n\n' + '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', + id="duplicated-number", + ), + pytest.param("[holds]\nnumber = 3\n", id="unknown-table"), + ], + ) + def test_malformed_hold_file_stops(self, import_env: ImportEnv, content: str) -> None: + (import_env.holds_dir / "dummy_tongue.toml").write_text(content, encoding="utf-8") + _init() + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + assert "\n" not in str(excinfo.value).strip() or True # CLI で 1 行にする(CLI のテスト) + + +# --------------------------------------------------------------------------- +# 停止(想定外の構成・対応表の不備).目録・対応表には何も残らない +# --------------------------------------------------------------------------- +def _add_unknown_folder(env: ImportEnv) -> list[str]: + write_dummy_png(env.source.root / "未知のフォルダzz" / "19.png", seed=19) + return ["未知のフォルダzz"] + + +def _add_root_file(env: ImportEnv) -> list[str]: + (env.source.root / "leakmemo_zz.txt").write_text("x", encoding="utf-8") + return ["leakmemo_zz.txt"] + + +def _remove_folder(env: ImportEnv) -> list[str]: + shutil.rmtree(env.source.root / "紫_あり") + return [] + + +def _add_non_png(env: ImportEnv) -> list[str]: + (env.source.root / "紅_あり" / "19.jpg").write_bytes(b"not png") + return ["19.jpg"] + + +def _add_subfolder(env: ImportEnv) -> list[str]: + write_dummy_png(env.source.root / "紅_あり" / "subzz" / "19.png", seed=19) + return ["subzz"] + + +def _add_empty_subfolder(env: ImportEnv) -> list[str]: + (env.source.root / "紅_あり" / "emptysubzz").mkdir() + return ["emptysubzz"] + + +def _add_non_number_name(env: ImportEnv) -> list[str]: + write_dummy_png(env.source.root / "紅_あり" / "leakname_zz.png", seed=19) + return ["leakname_zz.png"] + + +def _add_number_with_suffix(env: ImportEnv) -> list[str]: + write_dummy_png(env.source.root / "紅_あり" / "19_copyzz.png", seed=19) + return ["19_copyzz.png"] + + +def _empty_class_folder(env: ImportEnv) -> list[str]: + for path in (env.source.root / "紅_ややあり").iterdir(): + path.unlink() + return [] + + +def _duplicate_number(env: ImportEnv) -> list[str]: + # 番号 1 が 2 つのフォルダにある(中身は違う) + write_dummy_png(env.source.root / "紅_ややあり" / "1.png", seed=101) + return [] + + +def _number_not_in_map(env: ImportEnv) -> list[str]: + write_dummy_png(env.source.root / "紅_あり" / "19.png", seed=19) + return [] + + +def _non_numeric_patient_id(env: ImportEnv) -> list[str]: + rows = _default_rows(env) + rows[0] = (1, 1, "ABCzz9") + _rewrite_map(env, rows) + return ["ABCzz9"] + + +def _duplicated_map_rows(env: ImportEnv) -> list[str]: + _rewrite_map(env, _default_rows(env) + [(1, 9, "990005")]) + return [] + + +def _duplicated_map_rows_same_patient(env: ImportEnv) -> list[str]: + # 同じ番号の行が複数あれば,患者 ID が同じでもどちらを使うかを黙って決めない + _rewrite_map(env, _default_rows(env) + [(1, 1, "990001")]) + return [] + + +def _two_sheets(env: ImportEnv) -> list[str]: + from openpyxl import load_workbook + + workbook = load_workbook(env.patient_map.path) + workbook.create_sheet("2 枚目") + workbook.save(env.patient_map.path) + return [] + + +def _same_content_two_numbers(env: ImportEnv) -> list[str]: + data = env.source.images[0].path.read_bytes() + (env.source.root / "紅_あり" / "19.png").write_bytes(data) + _rewrite_map(env, _default_rows(env) + [(19, 1, "990006")]) + return [] + + +def _missing_map_file(env: ImportEnv) -> list[str]: + env.patient_map.path.unlink() + return [] + + +def _map_not_xlsx(env: ImportEnv) -> list[str]: + env.patient_map.path.write_text("番号,患者ID\n1,990001\n", encoding="utf-8") + return [] + + +STOP_CASES: dict[str, Callable[[ImportEnv], list[str]]] = { + "unknown-folder": _add_unknown_folder, + "file-at-root": _add_root_file, + "missing-folder": _remove_folder, + "non-png": _add_non_png, + "subfolder": _add_subfolder, + "empty-subfolder": _add_empty_subfolder, + "non-number-name": _add_non_number_name, + "number-with-suffix": _add_number_with_suffix, + "empty-class-folder": _empty_class_folder, + "duplicate-number": _duplicate_number, + "number-not-in-map": _number_not_in_map, + "non-numeric-patient-id": _non_numeric_patient_id, + "duplicated-map-rows": _duplicated_map_rows, + "duplicated-map-rows-same-patient": _duplicated_map_rows_same_patient, + "two-sheets": _two_sheets, + "same-content-two-numbers": _same_content_two_numbers, + "missing-map-file": _missing_map_file, + "map-not-xlsx": _map_not_xlsx, +} + + +class TestStops: + @pytest.mark.parametrize("mutate", STOP_CASES.values(), ids=STOP_CASES.keys()) + def test_stops_and_registers_nothing( + self, import_env: ImportEnv, mutate: Callable[[ImportEnv], list[str]] + ) -> None: + _init() + leaked = mutate(import_env) + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + # 例外メッセージに患者 ID・元のファイル名・パスを出さない(既定) + _assert_no_leak(str(excinfo.value), import_env, *leaked) + + @pytest.mark.parametrize("mutate", STOP_CASES.values(), ids=STOP_CASES.keys()) + def test_stops_after_previous_import_without_changes( + self, import_env: ImportEnv, mutate: Callable[[ImportEnv], list[str]] + ) -> None: + _init() + _import(import_env) + before = _dumps(import_env) + mutate(import_env) + with pytest.raises(ValueError): + _import(import_env) + assert _dumps(import_env) == before + + def test_held_number_need_not_be_resolvable_in_map(self, import_env: ImportEnv) -> None: + # 患者 ID が数字でない行でも,保留にした番号(使わない番号)なら止まらない + rows = _default_rows(import_env) + rows[0] = (1, 1, "ABCzz9") + _rewrite_map(import_env, rows) + write_holds(import_env.holds_dir, "dummy_tongue", [1]) + _init() + result = _import(import_env) + assert result.held == 1 + assert result.captures_added == 17 + + def test_same_content_two_numbers_can_proceed_when_one_is_held( + self, import_env: ImportEnv + ) -> None: + # 重複を保留リストに載せれば取り込める(PLAN_02「舌色データセットの重複 5 組の扱い」) + _same_content_two_numbers(import_env) + write_holds(import_env.holds_dir, "dummy_tongue", [19]) + _init() + result = _import(import_env) + assert result.found == 19 + assert result.held == 1 + assert result.captures_added == 18 + + def test_same_content_both_held(self, import_env: ImportEnv) -> None: + _same_content_two_numbers(import_env) + write_holds(import_env.holds_dir, "dummy_tongue", [1, 19]) + _init() + result = _import(import_env) + assert result.held == 2 + assert result.captures_added == 17 + + def test_catalog_not_initialized_stops(self, import_env: ImportEnv) -> None: + with pytest.raises(ValueError): + _import(import_env) + + def test_unknown_source_name_stops(self, import_env: ImportEnv) -> None: + _init() + with pytest.raises(ValueError): + run_import(load_config(import_env.config_path), "no_such_source") + _assert_nothing_registered(import_env) + + def test_missing_source_folder_stops(self, import_env: ImportEnv) -> None: + _init() + shutil.rmtree(import_env.source.root) + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + _assert_no_leak(str(excinfo.value), import_env) + + def test_source_without_patient_map_stops(self, import_env: ImportEnv) -> None: + _init() + text = import_env.config_path.read_text(encoding="utf-8") + lines = [line for line in text.splitlines() if not line.startswith("patient_map")] + import_env.config_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + with pytest.raises(ValueError): + _import(import_env) + _assert_nothing_registered(import_env) + + +class TestStopsAgainstRegistered: + def test_label_changed_for_registered_image_stops(self, import_env: ImportEnv) -> None: + # 登録済みの画像のラベル(フォルダ)が変わった: 登録済みの所見を書き換えない + _init() + _import(import_env) + before = _dumps(import_env) + root = import_env.source.root + shutil.move(root / "暗赤紅_あり" / "3.png", root / "紅_あり" / "3.png") + with pytest.raises(ValueError) as excinfo: + _import(import_env) + assert _dumps(import_env) == before + _assert_no_leak(str(excinfo.value), import_env, "3.png") + + def test_patient_changed_for_registered_image_stops(self, import_env: ImportEnv) -> None: + # 登録済みの画像の患者 ID が対応表で変わった(同じ中身が別の症例の撮影になる) + _init() + _import(import_env) + before = _dumps(import_env) + rows = _default_rows(import_env) + rows[0] = (1, 1, "990555") + _rewrite_map(import_env, rows) + with pytest.raises(ValueError) as excinfo: + _import(import_env) + assert _dumps(import_env) == before + _assert_no_leak(str(excinfo.value), import_env, "990555") + + def test_same_content_from_other_source_stops(self, import_env: ImportEnv) -> None: + # 同じ中身を別の撮影として登録しようとした(別の取り込み元の名前で同じ画像) + _init() + _import(import_env) + before = _dumps(import_env) + with import_env.config_path.open("a", encoding="utf-8") as f: + f.write( + "\n[sources.dummy_copy]\n" + f"path = '{import_env.source.root.as_posix()}'\n" + 'importer = "tongue_color_folders"\n' + f"patient_map = '{import_env.patient_map.path.as_posix()}'\n" + ) + write_holds(import_env.holds_dir, "dummy_copy", []) + with pytest.raises(ValueError): + run_import(load_config(import_env.config_path), "dummy_copy") + assert _dumps(import_env) == before + + +class TestFailureDuringInsert: + def _fail_on_nth(self, monkeypatch: pytest.MonkeyPatch, n: int, error: BaseException) -> None: + from tiasdata.catalog import records + + original = records.insert_derivation + calls = {"n": 0} + + def failing(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == n: + raise error + return original(*args, **kwargs) + + monkeypatch.setattr(records, "insert_derivation", failing) + + def test_integrity_error_midway_leaves_nothing( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + _init() + self._fail_on_nth(monkeypatch, 5, sqlite3.IntegrityError("CHECK constraint failed")) + with pytest.raises(ValueError): + _import(import_env) + _assert_nothing_registered(import_env) + + def test_other_error_midway_leaves_nothing( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + _init() + self._fail_on_nth(monkeypatch, 10, RuntimeError("boom")) + with pytest.raises(RuntimeError): + _import(import_env) + _assert_nothing_registered(import_env) + + def test_failure_on_second_import_keeps_first_import( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + write_holds(import_env.holds_dir, "dummy_tongue", [3, 10, 15]) + _init() + _import(import_env) + before = _dumps(import_env) + write_holds(import_env.holds_dir, "dummy_tongue", []) + self._fail_on_nth(monkeypatch, 2, sqlite3.IntegrityError("constraint failed")) + with pytest.raises(ValueError): + _import(import_env) + assert _dumps(import_env) == before + + def test_import_succeeds_after_failed_attempt( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + # 失敗の後に再実行すると,ストアに残ったファイルを再利用して全件が登録される + _init() + self._fail_on_nth(monkeypatch, 5, sqlite3.IntegrityError("constraint failed")) + with pytest.raises(ValueError): + _import(import_env) + monkeypatch.undo() + # monkeypatch.undo() で TIASDATA_CONFIG・holds の差し替えも戻るので明示的に指定する + result = run_import( + load_config(import_env.config_path), + import_env.source_name, + holds_dir=import_env.holds_dir, + ) + assert result.captures_added == 18 + assert _counts(import_env)["captures"] == 18 + + +# --------------------------------------------------------------------------- +# 対応表の読み取り +# --------------------------------------------------------------------------- +class TestPatientMap: + def test_rows_with_non_integer_number_are_counted_and_not_used( + self, import_env: ImportEnv + ) -> None: + rows = _default_rows(import_env) + [ + ("abc", 1, "990001"), + (1.5, 1, "990002"), + (0, 1, "990003"), + (-3, 1, "990004"), + (True, 1, "990005"), + (None, None, None), + (" ", None, None), + ] + _rewrite_map(import_env, rows) + _init() + result = _import(import_env) + # 1 行目は見出し,既定の 18 行が 2〜19 行目.使わない行は 20〜24 行目.空の行は数えない + assert result.unused_map_rows == (20, 21, 22, 23, 24) + assert result.captures_added == 18 + assert result.subjects_added == 7 + + def test_blank_rows_are_ignored(self, import_env: ImportEnv) -> None: + rows = _default_rows(import_env) + rows = rows[:5] + [(None, None, None)] + rows[5:] + [(None, None, None)] + _rewrite_map(import_env, rows) + _init() + result = _import(import_env) + assert result.unused_map_rows == () + assert result.captures_added == 18 + + def test_header_row_is_not_used(self, import_env: ImportEnv) -> None: + # 1 行目が数字でも見出しとして扱う(行として使わない) + write_patient_map(import_env.patient_map.path, _default_rows(import_env), header=(1, 1, 1)) + _init() + result = _import(import_env) + assert result.unused_map_rows == () + assert result.captures_added == 18 + + def test_numeric_and_string_patient_ids_are_same_patient(self, import_env: ImportEnv) -> None: + # 数値のセルと文字列のセルが混ざっても同じ患者 ID は同じ症例(123・123.0・"0123") + rows = _default_rows(import_env) + rows[0] = (1, 1, 123) + rows[1] = (2, 2, 123.0) + rows[2] = (3, 3, "0123") + _rewrite_map(import_env, rows) + _init() + result = _import(import_env) + captures = _captures_by_number(import_env) + assert captures[1][2] == captures[2][2] == captures[3][2] + # 1・2・3 の元の患者(990001〜990003)は他の画像で残るので 7 人 + 1 人 + assert result.subjects_added == 8 + assert len(_identity_map(import_env)) == 8 + + def test_number_cell_as_float_or_string_is_used(self, import_env: ImportEnv) -> None: + # 番号のセルが 5.0(整数の値)でも番号として使う + rows = _default_rows(import_env) + rows[4] = (5.0, rows[4][1], rows[4][2]) + _rewrite_map(import_env, rows) + _init() + result = _import(import_env) + assert result.unused_map_rows == () + assert result.captures_added == 18 + + def test_patient_ids_are_not_in_import_result(self, import_env: ImportEnv) -> None: + _init() + result = _import(import_env) + text = repr(result) + for key in DUMMY_KEYS: + assert key not in text + + +# --------------------------------------------------------------------------- +# --show-source-names(原因の特定に必要なときだけ番号・名前を出す) +# --------------------------------------------------------------------------- +class TestShowSourceNames: + def test_unexpected_file_name_is_shown_only_when_requested(self, import_env: ImportEnv) -> None: + _init() + _add_non_number_name(import_env) + with pytest.raises(ValueError) as hidden: + _import(import_env) + assert "leakname_zz.png" not in str(hidden.value) + with pytest.raises(ValueError) as shown: + _import(import_env, show_source_names=True) + assert "leakname_zz.png" in str(shown.value) + + def test_number_not_in_map_is_shown_only_when_requested(self, import_env: ImportEnv) -> None: + _init() + write_dummy_png(import_env.source.root / "紅_あり" / "777.png", seed=777) + with pytest.raises(ValueError) as hidden: + _import(import_env) + assert "777" not in str(hidden.value) + with pytest.raises(ValueError) as shown: + _import(import_env, show_source_names=True) + assert "777" in str(shown.value) + + def test_patient_id_is_not_shown_even_when_requested(self, import_env: ImportEnv) -> None: + _init() + _non_numeric_patient_id(import_env) + with pytest.raises(ValueError) as excinfo: + _import(import_env, show_source_names=True) + assert "ABCzz9" not in str(excinfo.value) + for key in DUMMY_KEYS: + assert key not in str(excinfo.value) diff --git a/tests/kinds/test_tongue_color.py b/tests/kinds/test_tongue_color.py new file mode 100644 index 0000000..f22313d --- /dev/null +++ b/tests/kinds/test_tongue_color.py @@ -0,0 +1,120 @@ +"""所見の種類 `tongue_color` の値の型(SPEC_02「kinds」,testing.md 必須対象 4). + +- 値は `{"label": , "severity": "strong" | "mild" | "normal"}` +- slug は `pale`・`light_red`・`red`・`dark_red`・`purple` +- `normal` は `light_red` のときだけ +- 未知の値・未知のキー・無効な組み合わせは拒否する +""" + +import json + +import pytest +from pydantic import ValidationError + +from tiasdata.kinds import ( + TongueColor, + annotation_value_type, + dump_annotation_value, + validate_annotation_value, +) + +LABELS = ["pale", "light_red", "red", "dark_red", "purple"] +NOT_LIGHT_RED = [label for label in LABELS if label != "light_red"] + + +class TestValidValues: + @pytest.mark.parametrize("label", NOT_LIGHT_RED) + @pytest.mark.parametrize("severity", ["strong", "mild"]) + def test_non_light_red_with_strong_or_mild_is_accepted(self, label: str, severity: str) -> None: + value = validate_annotation_value("tongue_color", {"label": label, "severity": severity}) + assert isinstance(value, TongueColor) + assert value.label == label + assert value.severity == severity + + def test_light_red_normal_is_accepted(self) -> None: + value = validate_annotation_value( + "tongue_color", {"label": "light_red", "severity": "normal"} + ) + assert value == TongueColor(label="light_red", severity="normal") + + def test_value_type_of_tongue_color(self) -> None: + assert annotation_value_type("tongue_color") is TongueColor + + +class TestInvalidValues: + @pytest.mark.parametrize("label", NOT_LIGHT_RED) + def test_normal_other_than_light_red_is_rejected(self, label: str) -> None: + with pytest.raises(ValidationError): + validate_annotation_value("tongue_color", {"label": label, "severity": "normal"}) + + @pytest.mark.parametrize( + "label", ["Red", "pink", "", "淡紅", "light-red", "lightred", "dark red"] + ) + def test_unknown_label_is_rejected(self, label: str) -> None: + with pytest.raises(ValidationError): + validate_annotation_value("tongue_color", {"label": label, "severity": "mild"}) + + @pytest.mark.parametrize("severity", ["Strong", "weak", "", "あり", "none"]) + def test_unknown_severity_is_rejected(self, severity: str) -> None: + with pytest.raises(ValidationError): + validate_annotation_value("tongue_color", {"label": "red", "severity": severity}) + + def test_unknown_key_is_rejected(self) -> None: + with pytest.raises(ValidationError): + validate_annotation_value( + "tongue_color", {"label": "red", "severity": "mild", "note": "x"} + ) + + @pytest.mark.parametrize("value", [{"label": "red"}, {"severity": "mild"}, {}]) + def test_missing_key_is_rejected(self, value: dict) -> None: + with pytest.raises(ValidationError): + validate_annotation_value("tongue_color", value) + + @pytest.mark.parametrize( + "value", + [ + {"label": 1, "severity": "mild"}, + {"label": "red", "severity": None}, + {"label": ["red"], "severity": "mild"}, + ], + ) + def test_non_string_value_is_rejected(self, value: dict) -> None: + with pytest.raises(ValidationError): + validate_annotation_value("tongue_color", value) + + def test_unknown_annotation_kind_is_value_error(self) -> None: + with pytest.raises(ValueError, match="no_such_kind"): + validate_annotation_value("no_such_kind", {"label": "red", "severity": "mild"}) + + def test_asset_kind_has_no_value_type(self) -> None: + # ファイルの種類は所見の値の型を持たない + with pytest.raises(ValueError): + annotation_value_type("tongue_crop_masked") + + def test_model_is_immutable(self) -> None: + value = TongueColor(label="red", severity="mild") + with pytest.raises(ValidationError): + value.label = "pale" # type: ignore[misc] + + +class TestDump: + def test_dump_is_json_with_label_and_severity(self) -> None: + text = dump_annotation_value(TongueColor(label="dark_red", severity="strong")) + assert json.loads(text) == {"label": "dark_red", "severity": "strong"} + + def test_dump_is_deterministic(self) -> None: + # 同じ値は常に同じ文字列(キーの順・空白が固定) + a = dump_annotation_value( + validate_annotation_value("tongue_color", {"severity": "mild", "label": "pale"}) + ) + b = dump_annotation_value( + validate_annotation_value("tongue_color", {"label": "pale", "severity": "mild"}) + ) + assert a == b + + def test_dump_round_trips_through_validation(self) -> None: + original = TongueColor(label="purple", severity="mild") + restored = validate_annotation_value( + "tongue_color", json.loads(dump_annotation_value(original)) + ) + assert restored == original diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..827c23f --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,263 @@ +"""目録とストアの点検(`tiasdata check`.SPEC_03「CLI」・PLAN_02 Step 3). + +- ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ, + 凍結した版の構成の欠け,読み取り専用が外れたストアのファイルを数える +- 書きかけの一時ファイル・想定外のファイルも問題として数える(ユーザー承認済み) +- 止めずに数え,問題があれば終了コード 0 以外(error-handling.md) +- 件数だけを表示する(パスを出さない).対応表を開かない +""" + +import hashlib +import sqlite3 +import stat +from contextlib import closing +from pathlib import Path + +import pytest +from conftest import ImportEnv + +from tiasdata.check import run_check +from tiasdata.cli import main +from tiasdata.config import load_config +from tiasdata.importers import run_import + + +def _setup(env: ImportEnv) -> None: + assert main(["init"]) == 0 + run_import(load_config(env.config_path), env.source_name) + + +def _store(env: ImportEnv) -> Path: + return env.catalog_root / "store" + + +def _check(env: ImportEnv): + conn = sqlite3.connect(env.catalog_root / "catalog.sqlite") + with closing(conn): + return run_check(conn, _store(env)) + + +def _stored_files(env: ImportEnv) -> list[Path]: + return sorted(p for p in _store(env).rglob("*") if p.is_file()) + + +def _make_writable(path: Path) -> None: + path.chmod(path.stat().st_mode | stat.S_IWUSR) + + +def _make_read_only(path: Path) -> None: + path.chmod(path.stat().st_mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)) + + +def _put_unregistered(env: ImportEnv, data: bytes, ext: str = "png") -> Path: + sha256 = hashlib.sha256(data).hexdigest() + path = _store(env) / sha256[:2] / f"{sha256}.{ext}" + path.parent.mkdir(exist_ok=True) + path.write_bytes(data) + _make_read_only(path) + return path + + +def _raw_catalog(env: ImportEnv) -> sqlite3.Connection: + # 外部キー制約を有効にしない接続(壊れた目録を作るため) + return sqlite3.connect(env.catalog_root / "catalog.sqlite") + + +class TestHealthy: + def test_empty_catalog_has_no_problems(self, import_env: ImportEnv) -> None: + assert main(["init"]) == 0 + result = _check(import_env) + assert result.catalog_files == 0 + assert result.store_files == 0 + assert result.problems == 0 + + def test_after_import_has_no_problems(self, import_env: ImportEnv) -> None: + _setup(import_env) + result = _check(import_env) + assert result.catalog_files == 18 + assert result.hash_ok == 18 + assert result.store_files == 18 + assert result.hash_mismatch == 0 + assert result.missing_in_store == 0 + assert result.not_in_catalog == 0 + assert result.writable == 0 + assert result.temp_files == 0 + assert result.unexpected_in_store == 0 + assert result.dangling_annotations == 0 + assert result.broken_dataset_members == 0 + assert result.problems == 0 + + def test_cli_exit_0_when_no_problems( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + _setup(import_env) + capsys.readouterr() + assert main(["check"]) == 0 + + +class TestProblems: + def test_hash_mismatch(self, import_env: ImportEnv) -> None: + _setup(import_env) + path = _stored_files(import_env)[0] + _make_writable(path) + path.write_bytes(b"tampered") + _make_read_only(path) + result = _check(import_env) + assert result.hash_mismatch == 1 + assert result.hash_ok == 17 + assert result.problems == 1 + + def test_missing_in_store(self, import_env: ImportEnv) -> None: + _setup(import_env) + path = _stored_files(import_env)[0] + _make_writable(path) + path.unlink() + result = _check(import_env) + assert result.missing_in_store == 1 + assert result.hash_ok == 17 + assert result.problems == 1 + + def test_not_in_catalog(self, import_env: ImportEnv) -> None: + _setup(import_env) + _put_unregistered(import_env, b"not registered") + result = _check(import_env) + assert result.not_in_catalog == 1 + assert result.store_files == 19 + assert result.problems == 1 + + def test_registered_content_with_other_ext_is_not_in_catalog( + self, import_env: ImportEnv + ) -> None: + # ストアでは拡張子が違うと別のファイル(SPEC_02「assets」) + _setup(import_env) + data = import_env.source.images[0].path.read_bytes() + _put_unregistered(import_env, data, ext="jpg") + result = _check(import_env) + assert result.not_in_catalog == 1 + assert result.problems == 1 + + def test_writable_store_file(self, import_env: ImportEnv) -> None: + # PLAN_02 Step 3: 読み取り専用が外れたストアのファイルの数を報告する + _setup(import_env) + files = _stored_files(import_env) + _make_writable(files[0]) + _make_writable(files[1]) + result = _check(import_env) + assert result.writable == 2 + assert result.hash_ok == 18 + assert result.problems == 2 + + def test_temp_file(self, import_env: ImportEnv) -> None: + _setup(import_env) + prefix_dir = _stored_files(import_env)[0].parent + (prefix_dir / ".tmp-abc123").write_bytes(b"partial") + result = _check(import_env) + assert result.temp_files == 1 + assert result.store_files == 18 + assert result.problems == 1 + + @pytest.mark.parametrize( + "relative", + ["README.txt", "zz/file.png", "ab/notes.txt", "ab/" + "c" * 64 + ".png"], + ids=["file-at-top", "bad-prefix-dir", "bad-name", "prefix-mismatch"], + ) + def test_unexpected_file(self, import_env: ImportEnv, relative: str) -> None: + _setup(import_env) + path = _store(import_env) / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x") + result = _check(import_env) + assert result.unexpected_in_store == 1 + assert result.problems >= 1 + + def test_unexpected_directory(self, import_env: ImportEnv) -> None: + _setup(import_env) + (_store(import_env) / "subdir").mkdir() + result = _check(import_env) + assert result.unexpected_in_store == 1 + + def test_dangling_annotation(self, import_env: ImportEnv) -> None: + _setup(import_env) + with closing(_raw_catalog(import_env)) as conn: + conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " VALUES ('tongue_color', 'C999999'," + ' \'{"label":"red","severity":"mild"}\', \'test\', \'2026-10-06T00:00:00Z\')' + ) + conn.commit() + result = _check(import_env) + assert result.dangling_annotations == 1 + assert result.problems == 1 + + def test_broken_dataset_member(self, import_env: ImportEnv) -> None: + _setup(import_env) + with closing(_raw_catalog(import_env)) as conn: + conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('ds', 1, ?)", + ("f" * 64,), + ) + conn.commit() + result = _check(import_env) + assert result.broken_dataset_members >= 1 + assert result.problems >= 1 + + def test_all_problems_are_counted_without_stopping(self, import_env: ImportEnv) -> None: + _setup(import_env) + files = _stored_files(import_env) + _make_writable(files[0]) + files[0].unlink() # 欠け + _make_writable(files[1]) + files[1].write_bytes(b"tampered") # 不一致(読み取り専用も外れている) + _put_unregistered(import_env, b"extra") # 余分 + (files[2].parent / ".tmp-x").write_bytes(b"") # 一時ファイル + (_store(import_env) / "junk.txt").write_bytes(b"") # 想定外 + result = _check(import_env) + assert result.missing_in_store == 1 + assert result.hash_mismatch == 1 + assert result.writable == 1 + assert result.not_in_catalog == 1 + assert result.temp_files == 1 + assert result.unexpected_in_store == 1 + assert result.problems == 6 + + def test_cli_exit_nonzero_and_reports_count( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + _setup(import_env) + _make_writable(_stored_files(import_env)[0]) + capsys.readouterr() + assert main(["check"]) != 0 + out = capsys.readouterr().out + assert "読み取り専用が外れたストアのファイル: 1 件" in out + + +class TestCheckEnvironment: + def test_check_does_not_need_identity_db(self, import_env: ImportEnv) -> None: + _setup(import_env) + import_env.identity_path.unlink() + assert main(["check"]) == 0 + assert not import_env.identity_path.exists() + + def test_check_does_not_modify_store(self, import_env: ImportEnv) -> None: + _setup(import_env) + files = _stored_files(import_env) + _make_writable(files[0]) + before = {p: (p.read_bytes(), p.stat().st_mode) for p in files} + _check(import_env) + assert {p: (p.read_bytes(), p.stat().st_mode) for p in _stored_files(import_env)} == before + + def test_missing_store_dir_is_error( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + _store(import_env).rmdir() + capsys.readouterr() + assert main(["check"]) == 1 + err = capsys.readouterr().err + assert len(err.strip().splitlines()) == 1 + + def test_check_before_init_is_error( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["check"]) == 1 + assert main(["stats"]) == 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8b09d88..e28783f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,12 +1,21 @@ """CLI(SPEC_03 の `tiasdata init`・「出力の規則」,error-handling.md の終了コード).""" import os +import re import sqlite3 +import stat import subprocess import sys from pathlib import Path import pytest +from conftest import ( + TONGUE_COLOR_FOLDERS, + ImportEnv, + write_dummy_png, + write_holds, + write_patient_map, +) from tiasdata import catalog from tiasdata.cli import main @@ -263,3 +272,201 @@ err = result.stderr.decode("utf-8") # UTF-8 でなければ例外 assert len(err.strip().splitlines()) == 1 assert "Traceback" not in err + + +# --------------------------------------------------------------------------- +# import・stats・check(SPEC_03「CLI」「出力の規則」,PLAN_02 Step 3,testing.md 必須対象 6) +# --------------------------------------------------------------------------- +_DUMMY_KEYS = [str(990001 + i) for i in range(7)] +_SOURCE_NAME_PATTERN = re.compile(r"[0-9]+\.png", re.IGNORECASE) + + +def _assert_no_personal_info(text: str, env: ImportEnv) -> None: + # 患者 ID・元のファイル名(フォルダ名・<番号>.png)・パスを出さない + for key in _DUMMY_KEYS: + assert key not in text + for folder in TONGUE_COLOR_FOLDERS: + assert folder not in text + assert not _SOURCE_NAME_PATTERN.search(text) + root = env.config_path.parents[1] + for form in (str(root), root.as_posix(), str(root.resolve())): + assert form not in text + + +def _line_with(text: str, needle: str) -> str: + lines = [line for line in text.splitlines() if needle in line] + assert len(lines) == 1, text + return lines[0] + + +def _map_rows(env: ImportEnv) -> list[tuple[object, ...]]: + return [(number, 1, key) for number, key in sorted(env.patient_map.patient_keys.items())] + + +class TestImportCommand: + def test_init_import_stats_check_flow( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + imported = capsys.readouterr() + assert main(["stats"]) == 0 + stats = capsys.readouterr() + assert main(["check"]) == 0 + checked = capsys.readouterr() + + assert imported.err == stats.err == checked.err == "" + assert "dummy_tongue" in imported.out + assert "18" in imported.out + assert "tongue_crop_masked" in stats.out + for slug in ("pale", "light_red", "red", "dark_red", "purple"): + assert slug in stats.out + for text in (imported.out, stats.out, checked.out): + _assert_no_personal_info(text, import_env) + + def test_import_reports_held_count( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + write_holds(import_env.holds_dir, "dummy_tongue", [3, 10]) + assert main(["init"]) == 0 + capsys.readouterr() + assert main(["import", "dummy_tongue"]) == 0 + out = capsys.readouterr().out + assert "2 件" in _line_with(out, "保留") + _assert_no_personal_info(out, import_env) + + def test_second_import_reports_skipped( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + capsys.readouterr() + assert main(["import", "dummy_tongue"]) == 0 + out = capsys.readouterr().out + assert "18 件" in _line_with(out, "登録済み") + _assert_no_personal_info(out, import_env) + + def test_unused_map_rows_count_and_row_numbers_only_with_flag( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + # 見出し 1 行 + 18 行の後の 20・21 行目が使わない行 + rows = _map_rows(import_env) + [("合計", None, "990001"), (2.5, 1, "990002")] + write_patient_map(import_env.patient_map.path, rows) + assert main(["init"]) == 0 + capsys.readouterr() + + assert main(["import", "dummy_tongue"]) == 0 + hidden = _line_with(capsys.readouterr().out, "対応表") + assert "2 件" in hidden + assert "20" not in hidden + assert "21" not in hidden + + assert main(["import", "dummy_tongue", "--show-source-names"]) == 0 + out = capsys.readouterr().out + shown = _line_with(out, "対応表") + assert "20" in shown + assert "21" in shown + for key in _DUMMY_KEYS: + assert key not in out + + @pytest.mark.parametrize( + "case", + ["unknown-file", "number-not-in-map", "non-numeric-patient", "hold-not-in-source"], + ) + def test_import_error_is_one_line_without_personal_info( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str], case: str + ) -> None: + root = import_env.source.root + rows = _map_rows(import_env) + planted: list[str] = [] + if case == "unknown-file": + (root / "紅_あり" / "leak_zz.jpg").write_bytes(b"x") + planted.append("leak_zz.jpg") + elif case == "number-not-in-map": + write_dummy_png(root / "紅_あり" / "19.png", seed=19) + elif case == "non-numeric-patient": + rows[0] = (1, 1, "LEAKID") + write_patient_map(import_env.patient_map.path, rows) + planted.append("LEAKID") + else: + write_holds(import_env.holds_dir, "dummy_tongue", [99]) + assert main(["init"]) == 0 + capsys.readouterr() + + assert main(["import", "dummy_tongue"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + _assert_no_personal_info(captured.err, import_env) + for name in planted: + assert name not in captured.err + + def test_unknown_source_exits_1( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + capsys.readouterr() + assert main(["import", "no_such_source"]) == 1 + assert len(capsys.readouterr().err.strip().splitlines()) == 1 + + def test_import_without_source_argument_exits_2(self, import_env: ImportEnv) -> None: + with pytest.raises(SystemExit) as exc: + main(["import"]) + assert exc.value.code == 2 + + def test_import_before_init_exits_1( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["import", "dummy_tongue"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + _assert_no_personal_info(captured.err, import_env) + + def test_missing_openpyxl_is_one_line_error( + self, + import_env: ImportEnv, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + assert main(["init"]) == 0 + capsys.readouterr() + monkeypatch.setitem(sys.modules, "openpyxl", None) + assert main(["import", "dummy_tongue"]) == 1 + err = capsys.readouterr().err + assert len(err.strip().splitlines()) == 1 + assert "uv sync --extra import" in err + conn = sqlite3.connect(import_env.catalog_root / "catalog.sqlite") + try: + assert conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0] == 0 + finally: + conn.close() + + +class TestStatsAndCheckCommands: + def test_check_exit_1_and_counts_on_problem( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + store_dir = import_env.catalog_root / "store" + path = next(p for p in store_dir.rglob("*") if p.is_file()) + path.chmod(path.stat().st_mode | stat.S_IWUSR) + capsys.readouterr() + assert main(["check"]) == 1 + captured = capsys.readouterr() + assert "1 件" in _line_with(captured.out, "読み取り専用") + _assert_no_personal_info(captured.out + captured.err, import_env) + assert path.name not in captured.out + + def test_stats_and_check_in_subprocess_are_utf8( + self, import_env: ImportEnv, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # 取り込みは同じプロセスで行う(保留リストの差し替えは別プロセスに効かないため) + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + for command in ("stats", "check"): + result = _run_cli([command], import_env.config_path, tmp_path) + assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace") + out = result.stdout.decode("utf-8") # UTF-8 でなければ例外 + _assert_no_personal_info(out, import_env) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..9de2130 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,83 @@ +"""目録の集計(`tiasdata stats`.SPEC_03「CLI」). + +- 種類・所見の値ごとの件数を返す +- 対応表を開かない(SPEC_02「対応表」: 対応表を開くのは取り込みのときだけ) +""" + +import sqlite3 +from contextlib import closing + +from conftest import ImportEnv + +from tiasdata.cli import main +from tiasdata.config import load_config +from tiasdata.importers import run_import +from tiasdata.stats import collect_stats + + +def _open(env: ImportEnv) -> sqlite3.Connection: + return sqlite3.connect(env.catalog_root / "catalog.sqlite") + + +def test_empty_catalog(import_env: ImportEnv) -> None: + assert main(["init"]) == 0 + with closing(_open(import_env)) as conn: + stats = collect_stats(conn) + assert stats.subjects == 0 + assert stats.captures == 0 + assert stats.subjects_with_multiple_captures == 0 + assert set(stats.assets_by_kind.values()) == {0} + assert "tongue_crop_masked" in stats.assets_by_kind + assert stats.annotations == {} + assert stats.superseded_annotations == 0 + + +def test_counts_after_import(import_env: ImportEnv) -> None: + assert main(["init"]) == 0 + run_import(load_config(import_env.config_path), import_env.source_name) + with closing(_open(import_env)) as conn: + stats = collect_stats(conn) + assert stats.subjects == 7 + assert stats.captures == 18 + # ダミーの 7 人はみな 2 枚以上 + assert stats.subjects_with_multiple_captures == 7 + assert stats.assets_by_kind == { + "color_chart": 0, + "tongue_crop_masked": 18, + "tongue_mask": 0, + "tongue_raw": 0, + } + tongue = stats.annotations["tongue_color"] + assert tongue.total == 18 + assert tongue.by_field["label"] == { + "dark_red": 4, + "light_red": 2, + "pale": 4, + "purple": 4, + "red": 4, + } + assert tongue.by_field["severity"] == {"mild": 8, "normal": 2, "strong": 8} + # 9 フォルダ(ラベル×程度の組み合わせ)ごとに 2 枚 + assert len(tongue.by_value) == 9 + assert set(tongue.by_value.values()) == {2} + assert stats.superseded_annotations == 0 + + +def test_stats_does_not_need_identity_db(import_env: ImportEnv) -> None: + assert main(["init"]) == 0 + run_import(load_config(import_env.config_path), import_env.source_name) + import_env.identity_path.unlink() + assert main(["stats"]) == 0 + assert not import_env.identity_path.exists() + + +def test_held_images_are_not_counted(import_env: ImportEnv) -> None: + from conftest import write_holds + + write_holds(import_env.holds_dir, "dummy_tongue", [1, 2]) # light_red の 2 枚 + assert main(["init"]) == 0 + run_import(load_config(import_env.config_path), import_env.source_name) + with closing(_open(import_env)) as conn: + stats = collect_stats(conn) + assert stats.captures == 16 + assert "light_red" not in stats.annotations["tongue_color"].by_field["label"] diff --git a/uv.lock b/uv.lock index bed80dc..a293cfc 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,15 @@ ] [[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } @@ -30,6 +39,18 @@ ] [[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } @@ -266,18 +287,29 @@ { name = "pydantic" }, ] +[package.optional-dependencies] +import = [ + { name = "openpyxl" }, +] + [package.dev-dependencies] dev = [ + { name = "openpyxl" }, { name = "pillow" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.9" }] +requires-dist = [ + { name = "openpyxl", marker = "extra == 'import'", specifier = ">=3.1.5" }, + { name = "pydantic", specifier = ">=2.9" }, +] +provides-extras = ["import"] [package.metadata.requires-dev] dev = [ + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=11.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.8" },