diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index 2a8c52d..af11bee 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -1,10 +1,10 @@ --- -paths: ["src/**", "migrations/**", "datasets/**"] +paths: ["src/**", "datasets/**"] --- # コーディング規約 (Coding Standards) -Python コード(`src/**`)・マイグレーション(`migrations/**`)・データセットの定義(`datasets/**`)を編集するときは本ルールに従うこと. +Python コード(`src/**`)・マイグレーション(`src/tiasdata/catalog/migrations/**`)・データセットの定義(`datasets/**`)を編集するときは本ルールに従うこと. ## 基本方針 (Basic Policy) @@ -48,7 +48,7 @@ ## マイグレーション (Migrations) -- スキーマの変更は `migrations/NNNN_<内容>.sql`(4 桁の連番)を追加して行う.**適用済みのマイグレーションファイルは編集しない** +- スキーマの変更は `src/tiasdata/catalog/migrations/<系列>/NNNN_<内容>.sql`(系列は `catalog`・`identity`.4 桁の連番)を追加して行う.ファイルの中に `BEGIN`・`COMMIT`・`PRAGMA user_version` を書かない(適用側が囲む).**適用済みのマイグレーションファイルは編集しない** - 1 つのファイルは 1 つのトランザクションで適用できる内容にする - 種類(`kinds`)の追加もマイグレーションで行い,同時に `src/tiasdata/kinds/` に値の型を追加する - 表・列を変えたら [SPEC_02 データモデル](../../docs/04_SPEC/SPEC_02_データモデル.md)を先に更新する 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/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index 037db8b..d1fb390 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -1,5 +1,5 @@ --- -paths: ["src/**", "tests/**", "migrations/**", "datasets/**"] +paths: ["src/**", "tests/**", "datasets/**"] --- # プロジェクト構成ルール (Project Structure) @@ -13,15 +13,17 @@ ├── __init__.py # 公開 API(load・open_bundle・Bundle・Catalog)を再 export する ├── config.py # 設定ファイルの読み込み・検証 ├── catalog/ # 目録(SQLite)の接続・マイグレーションの適用・各表の操作(SQL はここだけ) +│ └── migrations/ # スキーマ変更(catalog/・identity/ の 2 系列.NNNN_<内容>.sql) ├── store.py # ストア(ハッシュ・コピー・照合) ├── kinds/ # ファイルの種類・所見の種類の値の型 ├── importers/ # 取り込み元の形ごとの読み取り(1 つの形 = 1 モジュール) ├── datasets.py # データセットの定義の評価・版の作成 -├── bundle.py # バンドルの書き出し・読み込み・検証 +├── manifest.py # manifest.csv の生成・ハッシュ等の純関数(他の tiasdata のモジュールを import しない) +├── bundle.py # バンドルの読み込み・検証(目録に依存しない) +├── export.py # バンドルの書き出し ├── deliver.py # 計算サーバへの受け渡し(scp・ssh) └── cli.py # CLI エントリポイント -migrations/ # 目録のスキーマ変更(NNNN_<内容>.sql) datasets/ # データセットの定義(<名前>.toml) tests/ # src/tiasdata/ の構造をミラーする ``` @@ -30,10 +32,10 @@ - **1 モジュール 1 責務**: 新しい機能は責務に合うモジュールに置く.大きくなったらサブパッケージに分ける(既存モジュールを肥大化させない) - **依存方向**: `cli` → `datasets`・`bundle`・`deliver`・`importers` → `catalog`・`store`・`kinds`・`config` の一方向を保つ.下位から上位を import しない -- **バンドルの読み込みは目録に依存しない**: `bundle.py` の読み込み側(`load`・`open_bundle`)は `catalog`・`store`・`config` を import しない(目録の無い計算サーバで使うため.書き出し側は別関数に分ける) +- **バンドルの読み込みは目録に依存しない**: `bundle.py`(`load`・`open_bundle`)は `catalog`・`store`・`config`・`export`・`datasets` を import しない(目録の無い計算サーバで使うため).書き出しは `export.py` に置く.読み込み側と目録側で共有する純関数(ハッシュ・名前の形・検証エラーの整形)は `manifest.py` に置く.`import tiasdata` で読み込まれるモジュールを増やさない - **公開 API は `__init__.py` で export したものだけ**: 使う側は `tiasdata.<名前>` で使う.内部モジュールを直接 import させない - **取り込み元を増やすとき**: `importers/` にモジュールを追加し,設定ファイルの `importer` の名前から選べるようにする.既存の importer に分岐を足さない -- **種類を増やすとき**: `migrations/` に `kinds` の行を追加する SQL と,`kinds/` に値の型を追加する([SPEC_02「拡張の例」](../../docs/04_SPEC/SPEC_02_データモデル.md)) +- **種類を増やすとき**: `src/tiasdata/catalog/migrations/catalog/` に `kinds` の行を追加する SQL と,`kinds/` に値の型を追加する([SPEC_02「拡張の例」](../../docs/04_SPEC/SPEC_02_データモデル.md)) - **副作用は縁に寄せる**: ファイルのコピー・SSH・DB への書き込みは `store`・`deliver`・`catalog` に閉じ込め,定義の評価・manifest の行の組み立て・照合の判定は入力 → 出力の純関数として書く(テスト容易性のため) - **データを置かない**: 臨床データ・目録・対応表・バンドルをリポジトリの配下に置かない(`.gitignore` は保険.[ENV_02「臨床データの取り扱い」](../../docs/02_ENV/ENV_02_環境構築手順.md)) - **テストの配置**: `tests/` は `src/tiasdata/` の構造をミラーする(詳細は `.claude/rules/testing.md`) 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 1f3ff21..b851cc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: プロジェクト立ち上げ完了.次は PLAN_02 Step 1(設定と目録の土台) +最新: PLAN_02 Step 5(バンドル.`tiasdata export`・`tiasdata.load`)完了.次は Step 1〜5 を main に取り込み,Step 6(研究室 PC での運用開始) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/datasets/tongue_color.toml b/datasets/tongue_color.toml new file mode 100644 index 0000000..d66c9a7 --- /dev/null +++ b/datasets/tongue_color.toml @@ -0,0 +1,9 @@ +name = "tongue_color" +description = "舌色 5 分類+程度(TIASJudgeModel の学習データ)" + +[select] +asset_kind = "tongue_crop_masked" # 集めるファイルの種類 +require = ["tongue_color"] # この所見を持つものだけ(撮影に付いた所見を含む) + +[columns] # manifest に出す所見の列 +tongue_color = ["label", "severity"] diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index c1c236b..a3140ca 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -23,7 +23,7 @@ | --- | --- | --- | | 目録 | **SQLite**(Python 標準の `sqlite3`) | サーバを立てずに DB ファイル 1 つで済む.1 人で使うため同時書き込みの心配がない.C#(DeepTIAS)からも `Microsoft.Data.Sqlite` で読める | | DB へのアクセス | 標準の `sqlite3` で SQL を直接書く(ORM は使わない) | テーブル数が少なく,SQL がそのまま仕様として読める.依存を増やさない | -| スキーマの変更 | 連番の SQL ファイル(`migrations/0001_*.sql` …)を順に適用し,適用済みの版を DB の `PRAGMA user_version` に記録する | データの種類を足すたびに表・列が増えるため,変更履歴をファイルで残す.Alembic 等は 1 ファイルの SQLite には過大 | +| スキーマの変更 | 連番の SQL ファイル(パッケージ内の `src/tiasdata/catalog/migrations/<系列>/0001_*.sql` …)を順に適用し,適用済みの版を DB の `PRAGMA user_version` に記録する | データの種類を足すたびに表・列が増えるため,変更履歴をファイルで残す.Alembic 等は 1 ファイルの SQLite には過大 | | データ本体 | **ファイルのまま**データ置き場に置き,目録にはデータ置き場からの相対パスを記録する | 画像を DB に入れない(DB が肥大化し,サーバへのコピーや他ツールからの参照がしにくくなる) | | 同一性の確認 | ファイルの **SHA-256**(標準の `hashlib`) | データセットの版にハッシュを含め,ファイルが差し替わったことを検出する.再現性の根拠にする | | 置き場所の設定 | 設定ファイル(TOML.標準の `tomllib` で読む)または環境変数で,DB ファイルとデータ置き場のパスを指定する | コードにパスを書かない.設定ファイルはリポジトリの外(研究室 PC のユーザー領域)に置く.具体的なキー・既定の場所は仕様設計で決める | 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 fdbbbf0..b8c885d 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -7,7 +7,7 @@ | コマンド | 用途 | | --- | --- | | `uv sync` | 依存を `.venv/` に入れる(開発用の依存を含む) | -| `uv sync --no-dev` | 実行時の依存だけを入れる(研究室 PC) | +| `uv sync --no-dev --extra import` | 実行時の依存と,取り込み用の openpyxl だけを入れる(研究室 PC) | ## テスト・lint (Test & Lint) @@ -15,15 +15,29 @@ | --- | --- | | `uv run pytest` | テストを実行する(ダミーデータのみ) | | `uv run ruff check .` | lint | -| `uv run ruff format .` | 整形(確認だけなら `uv run ruff format --check .`) | +| `uv run ruff format .` | 整形(確認だけなら `uv run ruff format --check .`).`docs/` は `pyproject.toml` で対象外にしている(Markdown のコード例を整形させないため) | ## CLI | コマンド | 用途 | | --- | --- | | `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` | ストア・目録の点検(凍結した版の manifest の照合を含む).問題が 1 件以上なら終了コード 1 | +| `uv run tiasdata dataset list` | データセットと版の一覧 | +| `uv run tiasdata dataset show <名前>` | 版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | +| `uv run tiasdata dataset freeze <名前> [--note TEXT]` | `datasets/<名前>.toml` を目録に当てはめて新しい版を作る(構成が同じなら作らない).**リポジトリに未コミットの変更があると止まる**(研究室 PC は pull するだけなので通常は問題ない) | +| `uv run tiasdata export <名前> --version <版>` | 凍結した版をバンドル(`bundles/<名前>/v<版>/`)に書き出す.既にあれば照合して使う | -- サブコマンド(登録・データセットの定義・manifest の書き出し等)は実装フェーズで追加し,ここに追記する +- 設定ファイルは環境変数 `TIASDATA_CONFIG` のパス,無ければ `~/.config/tiasdata/config.toml`(キーは [SPEC_01「設定ファイル」](../04_SPEC/SPEC_01_アーキテクチャ設計.md)) +- 手元で試すときは一時フォルダに設定ファイルを作って指す(例: `TIASDATA_CONFIG="$TMPDIR/tiasdata-try/config.toml" uv run tiasdata init`.臨床データは使わない) +- 研究室 PC では `ssh labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe run tiasdata init"` の形で実行する([ENV_02](ENV_02_環境構築手順.md)) +- 失敗時は標準エラーに 1 行を出し,終了コード 1(入力・データの問題)または 2(引数の誤り)で終わる +- `import` は openpyxl が要る(手元は `uv sync` で入る.研究室 PC は `uv sync --no-dev --extra import`).リポジトリの clone から実行する(保留リストを読むため) +- 使う側(Python)は `tiasdata.load("<名前>", version=<版>)` でバンドルを開く.置き場所は環境変数 `TIASDATA_BUNDLES`(既定 `~/data/tiasdata`) +- 残りのサブコマンド(受け渡し・バックアップ等)は実装した Step で追記する ## 依存の追加 (Dependencies) diff --git "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" index 00c5fef..33ea87a 100644 --- "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" +++ "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" @@ -53,7 +53,7 @@ - ラベルは現在フォルダ名で表現されている.基盤に登録するときは,フォルダ名から読み取ったラベルを目録の項目として記録する - 画像の番号(ファイル名)と,DeepTIAS の出力側の患者 ID・何枚目の画像かの対応は,研究室 PC の対応表(`E:\clinical-data\DeepTIAS後_ID_番号.xlsx`)にある.2026-10-03 に件数だけを照合し,1085 枚が約 450 人分(2 枚以上の患者が 239 人・874 枚)であることが分かった(TIASJudgeModel の「1 患者 1 枚」の前提は誤りで,ユーザーも確認済み).患者単位で扱うには,この対応表を取り込みに使う必要がある - DeepTIAS の出力(元は患者 ID のフォルダ `DeepTIAS後_亥鼻_村上_2025.08修正`)は,2026-10-03 に舌色データセットで使った画像の分だけを番号ごとのフォルダへ移し,残り(使わない出力 約 8GB)を削除した(元のデータは研究室のサーバにある).各番号の画像は `2-cropped` × `3-resized`(127 超)で作り直したものと画素で一致する(完全一致 1073・違う画素 1% 未満 12).経緯は `docs/PROGRESS.md` -- 舌色データセットには**同じ写真の重複**が 5 組ある(2026-10-03 確認).ファイルが同一で対応表の患者 ID が違う 2 組(122/184・548/566.ラベルは同じ),ほぼ同じ画像(違う画素約 0.7%)が連番で 2 回登録された 3 組(381/382・508/509・515/516.うち 381/382 は程度,515/516 は舌色のラベルが違う).382・509・516 のフォルダの DeepTIAS 出力は,それぞれ 381・508・515 の出力のコピー(元の出力は見つからなかった) +- 舌色データセットには**同じ写真の重複**が 5 組ある(2026-10-03 確認).ファイルが同一で対応表の患者 ID が違う 2 組(122/184・548/566.ラベルは同じ),ほぼ同じ画像(違う画素約 0.7%)が連番で 2 回登録された 3 組(381/382・508/509・515/516.うち 381/382 は程度,515/516 は舌色のラベルが違う).382・509・516 のフォルダの DeepTIAS 出力は,それぞれ 381・508・515 の出力のコピー(元の出力は見つからなかった).扱いは,確かめられるまで 9 枚を保留にする(2026-10-06.`holds/tiasjudgemodel.toml`・PLAN_02) ### 表せるようにする関係 (Relationships) 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 32f3090..c08860e 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -12,17 +12,21 @@ | Step | 内容 | 完了条件 | | --- | --- | --- | -| 1 | **設定と目録の土台**: `config.py`(設定ファイルの読み込み・検証),`catalog/`(接続・マイグレーションの適用),`migrations/0001`(SPEC_02 の全表と最初の種類),`tiasdata init` | 一時ディレクトリの設定ファイルから `init` で目録ができ,`PRAGMA user_version` が最新になる.テスト(設定ファイル・マイグレーション)が通る | -| 2 | **ストア**: `store.py`(ハッシュ・コピー・照合),テスト用のダミーデータ(小さい画像・取り込み元のフォルダ)を作る `conftest.py` | 同じ中身が 1 つにまとまり,コピー後の照合・上書きしないことがテストで確かめられる | -| 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check` | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.出力に患者 ID・元のファイル名が出ない | -| 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む | -| 5 | **バンドル**: `bundle.py`(書き出し・読み込み・検証),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる | -| 6 | **研究室 PC での運用開始**: 立ち上げの成果物と Step 1〜5 を `main` に取り込む → 研究室 PC に GitHub から clone・設定ファイル作成・`init` → 舌色データ(1085 枚)を取り込み → `check`・`stats` → 版 1 を作成 → `tiasdata backup` | すべて手元 PC から SSH で行える.件数が 1085 枚・9 グループで TIASJudgeModel の TEST_01 と一致し,症例が約 450(2026-10-03 の照合値)になる.`check` が問題 0 件 | +| 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`(2026-10-06 完了) | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.保留リストの番号を飛ばし,保留の件数を報告する.出力に患者 ID・元のファイル名が出ない.`check` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | +| 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む(2026-10-06 完了) | +| 5 | **バンドル**: `bundle.py`(読み込み・検証)・`export.py`(書き出し),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる(2026-10-08 完了) | +| 6 | **研究室 PC での運用開始**: 立ち上げの成果物と Step 1〜5 を `main` に取り込む → 研究室 PC に GitHub から clone・設定ファイル作成・`init` → 舌色データ(1085 枚)を取り込み → `check`・`stats` → 版 1 を作成 → `tiasdata backup` | すべて手元 PC から SSH で行える.取り込み 1076 枚+保留 9 枚が 1085 枚・9 グループで TIASJudgeModel の TEST_01 と一致し,症例が約 450(2026-10-03 の照合値)になる.`check` が問題 0 件 | | 7 | **計算サーバへの受け渡し**: `deliver.py`(tar・scp・展開・`sha256sum -c`・記録),`tiasdata deliver` | 版 1 を Newton・Archimedes に渡し,両方で照合が通り,`deliveries` に記録される.データが手元 PC を通らない | -| 8 | **TIASJudgeModel の切り替え**: TIASJudgeModel にサブモジュールを追加し,`tiasdata.load("tongue_color", version=1)` から現行の manifest の形に変換して学習・評価に使う(TIASJudgeModel 側の作業ブランチで行う) | 変換した manifest の件数・`label`・`severity` の内訳が現行の manifest と一致し,`patient_id` が患者単位(約 450 人)になる.患者単位の分割で交差検証をやり直し,現行(画像単位)の成績との差を記録する.実験記録にバンドルの指紋が残る.研究室 PC の固定パスを書かずに済む | +| 8 | **TIASJudgeModel の切り替え**: TIASJudgeModel にサブモジュールを追加し,`tiasdata.load("tongue_color", version=1)` から現行の manifest の形に変換して学習・評価に使う(TIASJudgeModel 側の作業ブランチで行う) | 変換した manifest の件数・`label`・`severity` の内訳が,現行の manifest から保留の 9 枚を除いたものと一致し,`patient_id` が患者単位(約 450 人)になる.患者単位の分割で交差検証をやり直し,現行(画像単位)の成績との差を記録する.実験記録にバンドルの指紋が残る.研究室 PC の固定パスを書かずに済む | - Step 6 の前に,立ち上げの成果物(`chore/project-setup`)を `main` に取り込む -- **Step 3 までに決めること**: 舌色データセットの重複 5 組(PLAN_01「現在あるデータ」)の扱い.特にファイルが同一の 2 組(122/184・548/566)は,SPEC_02 の「同じ中身を別の撮影として登録しようとしたら停止する」に当たり,このままでは取り込みが止まる(どちらを残すか・両方を別の撮影として許すか・除外するか).ラベルが違う 2 組(381/382・515/516)の正解の扱いも決める +- **舌色データセットの重複 5 組の扱い**(2026-10-06 のユーザー判断): 基盤では正解を推測で決めず,確かめられるまで**保留**にする.保留リスト `holds/tiasjudgemodel.toml`(SPEC_03「保留リスト」)に 9 枚を載せ,取り込みで飛ばす + - ファイルが同一で患者 ID が食い違う 2 組(122/184・548/566): 両方とも保留.研究室のサーバの元のデータでどちらの患者かを確かめる + - ラベルが違う 2 組(381/382・515/516): 両方とも保留.評価者に正しいラベルを確かめる + - ラベルが同じ重複の 1 組(508/509): DeepTIAS の出力と完全一致する 508 を残し,509 だけ保留 + - 確かめ終わったらリストから外して取り込み直し,データセットの新しい版を作る - Step 6・7 はコード(Step 7 の `deliver`)と運用(研究室 PC・サーバでの実行)が混ざる.運用の手順と結果は ENV_02・ENV_03 に記録する - Step 8 の終了時点で PLAN_01 の成功基準 1〜7 を確認する @@ -36,5 +40,6 @@ | DeepTIAS の派生データ | 撮影画像・カラーチャート画像と,DeepTIAS の出力(色補正後の画像・マスク・`analysis.csv`)を由来付きで登録する.旧 exe(2021 年 12 月版)と現行ソースの出力を版で区別する.取り込み元は整理済みの `DeepTIAS_TIASJudgeModel\<番号>\`(382・509・516 は元の出力ではなくコピーである点を由来に記録する) | | 実際の患者 ID を持つデータ | 対応表(`identity.sqlite`)を使う取り込み.同じ患者の複数枚・経時的な撮影.倫理審査の保管ルールの確認が前提 | | 他の所見 | 苔の色・厚さ,問診・体質等の種類を追加する | -| バックアップ | 目録・ストアを別の媒体に退避する方法を決めて手順化する | +| バックアップ | 目録・ストアを別の媒体に退避する方法を決めて手順化する(ストアのファイルは読み取り専用である点に注意) | +| データの削除 | 同意の撤回等でデータを本当に消す必要がある場合の専用コマンド(例: `tiasdata purge`).影響する版を確かめ,削除を目録に記録してから,読み取り専用を外してストアから消す(SPEC_01「ストアの規則」) | | TIASjudge・DeepTIAS からの利用 | 使う側を増やす | diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index a27be95..062e1ed 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -28,13 +28,26 @@ | 要素 | 役割 | | --- | --- | | 目録(`catalog.sqlite`) | 症例・撮影・ファイル・ラベル・由来・データセットの版・受け渡しの記録([SPEC_02](SPEC_02_データモデル.md)) | -| ストア(`store/`) | 登録したファイルの実体.中身の SHA-256 をファイル名にし,変更しない | +| ストア(`store/`) | 登録したファイルの実体.中身の SHA-256 をファイル名にし,変更しない(下記「ストアの規則」) | | 対応表(`identity.sqlite`) | 実際の患者 ID ↔ 研究用 ID.目録とは別のファイル・別のフォルダに置く.現在の舌色データは対応表の xlsx から作る | | バンドル | データセットの 1 つの版を,manifest とファイルのコピーにまとめたもの.計算サーバ・Python 以外のツールへ渡す単位([SPEC_03](SPEC_03_データセットと受け渡し.md)) | | ライブラリ(`tiasdata`) | 目録の操作(研究室 PC)と,バンドルの読み込み・検証(どこでも) | | CLI(`tiasdata`) | 取り込み・データセットの版の作成・バンドルの書き出し・受け渡し・点検(研究室 PC) | | データセットの定義(`datasets/*.toml`) | どのファイル・ラベルを集めるかの条件.個人情報を含まないので**リポジトリで管理**し,PR で変更する | +### ストアの規則 (Store Rules) + +`src/tiasdata/store.py` が担う(2026-10-06 実装). + +- 置き場所は `store//.<拡張子(小文字)>`.目録には保存せず,SHA-256 と拡張子から決める +- 取り込みは「一時ファイル(`.tmp-` で始まる名前)へハッシュを計算しながらコピー → 読み直して照合 → 正式な名前に変える」の順で行い,書きかけのファイルを正式な名前で残さない.失敗したら一時ファイルを消す +- 同じ中身が既にあればコピーしない.同じ置き場所に中身の違うファイルがあれば上書きせずに停止する +- 原本(取り込み元)は読むだけで,中身・更新日時・属性を変えない +- **ストアに置いたファイルは読み取り専用にする**(手作業・他のプログラムによる誤った書き換え・削除を防ぐため.2026-10-06 のユーザー判断).同じ中身を再び取り込んで既存のファイルを再利用するときも付け直す.属性が外れたファイルは `tiasdata check` が数を報告する.バックアップや手での削除では,この属性を意識する +- ファイルを直したい・使わないようにしたい場合も,ストアのファイルは書き換えない.直したファイルは新しいファイルとして由来付きで登録し,ラベルは所見の訂正で,除外はデータセットの定義と新しい版で行う.データを本当に消す必要がある場合(同意の撤回等)は,専用のコマンドで影響する版を確かめ,記録を残してから消す(未実装.PLAN_02「マイルストーン 2 以降の候補」) +- store フォルダが無いときは作らずに停止する(`tiasdata init` で作る.設定の誤りで別の場所にストアが黙って作られるのを防ぐため) +- エラーメッセージにはパス・元のファイル名・拡張子を出さず,SHA-256 で示す + ## 配置 (Locations) ### リポジトリ (Repository) @@ -44,19 +57,25 @@ ├── src/tiasdata/ │ ├── config.py # 設定ファイルの読み込み・検証(pydantic) │ ├── catalog/ # 目録(SQLite)の接続・マイグレーション・各表の操作 +│ │ └── migrations/ # スキーマ変更の SQL(catalog/・identity/ の 2 系列.NNNN_<内容>.sql) │ ├── store.py # ストア(ハッシュの計算・コピー・検証) │ ├── kinds/ # ファイルの種類・所見の種類の定義(値の検証用の型) │ ├── importers/ # 取り込み元ごとの読み取り(例: 舌色の 9 フォルダ) +│ ├── manifest.py # manifest.csv の生成(純関数.catalog に依存しない) +│ ├── repo.py # リポジトリのルートと git の状態 │ ├── datasets.py # データセットの定義の評価・版の作成 -│ ├── bundle.py # バンドルの書き出し・読み込み・検証 +│ ├── bundle.py # バンドルの読み込み・検証(目録に依存しない.計算サーバで使う) +│ ├── export.py # バンドルの書き出し(目録・ストアを使う) │ ├── deliver.py # 計算サーバへの受け渡し │ └── cli.py # エントリポイント -├── migrations/ # 目録のスキーマ変更(0001_*.sql …) ├── datasets/ # データセットの定義(TOML) +├── holds/ # 取り込み元ごとの保留リスト(TOML.SPEC_03「保留リスト」) ├── tests/ # ダミーデータのみで動くテスト └── docs/ ``` +マイグレーションの SQL はリポジトリ直下ではなくパッケージの中(`src/tiasdata/catalog/migrations/`)に置き,`importlib.resources` で読む(サブモジュール・パッケージとして使われたときも見つけられるようにするため.2026-10-06 の判断). + 依存の向きは `cli` → `datasets`・`bundle`・`deliver`・`importers` → `catalog`・`store`・`kinds`・`config` とする.**`bundle` の読み込み側は `catalog` に依存しない**(目録の無い計算サーバで使うため). ### 研究室 PC (Lab PC) @@ -84,7 +103,9 @@ 研究室 PC のユーザー領域に置き(リポジトリには入れない),pydantic で検証する(未知のキーは拒否する). - 場所: 環境変数 `TIASDATA_CONFIG` があればそのパス,無ければ `%USERPROFILE%\.config\tiasdata\config.toml`(Linux では `~/.config/tiasdata/config.toml`) -- 相対パスは設定ファイルのあるフォルダを基準にする.`~` を展開する +- 相対パスは設定ファイルのあるフォルダを基準にする.`~` を展開する(`servers.*.root` はサーバ側のパスなので展開しない) +- `catalog.identity`(対応表)は `catalog.root` の外に置く.中にある設定は検証エラーにする(対応表を目録とは別のフォルダに分けるため) +- `catalog.root` の下の `catalog.sqlite`・`store/`・`bundles/`・`backups/` は固定の名前で,設定には書かない ```toml [catalog] 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 d39dbe0..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" @@ -1,6 +1,17 @@ # データモデル (Data Model) -目録(`catalog.sqlite`)の表の定義.スキーマは `migrations/` の連番 SQL で管理し,適用済みの版を `PRAGMA user_version` に記録する([ENV_01](../02_ENV/ENV_01_技術スタック.md)).外部キー制約を有効にする(`PRAGMA foreign_keys = ON`). +目録(`catalog.sqlite`)の表の定義.スキーマはパッケージ内の `src/tiasdata/catalog/migrations/catalog/` の連番 SQL で管理し,適用済みの版を `PRAGMA user_version` に記録する([ENV_01](../02_ENV/ENV_01_技術スタック.md)).対応表(`identity.sqlite`)は別の系列 `migrations/identity/` で同様に管理する.外部キー制約を有効にする(`PRAGMA foreign_keys = ON`). + +## スキーマで強制する規則 (Enforced by Schema) + +以下は SQL の制約(CHECK・トリガ)で強制し,コードの誤りでも破れないようにする(2026-10-06 の判断). + +- **書式**: 研究用 ID(`S`・`C` + 6 桁),SHA-256(小文字 64 桁),拡張子,種類名・データセット名(英小文字で始まる snake_case),日時は UTC の `YYYY-MM-DDTHH:MM:SSZ`(**秒まで.小数秒は拒否**),撮影日は `YYYY-MM-DD` +- **主キー**: ID・名前の主キーは NULL を許さない(SQLite は INTEGER 以外の主キーに NULL を入れられるため `NOT NULL` を明示する) +- **種類の整合**: `assets.kind` はファイルの種類,`annotations.kind` は所見の種類だけを指せる.所見の種類は `value_schema_version`(1 以上)を必ず持ち,ファイルの種類は持たない +- **所見は書き換えない**: `annotations` は UPDATE・DELETE を拒否する.例外は訂正のときに `superseded_by` を NULL から一度だけ埋めること(自分より後の行だけを指せる).値は JSON として正しいこと +- **凍結した版は書き換えない**: `dataset_versions`・`dataset_members`・`dataset_member_annotations` は UPDATE・DELETE を拒否する.そのため版の行は,`manifest.csv` の SHA-256 を計算してから INSERT する +- **対応表**: 1 つの研究用 ID は 1 人の患者にだけ対応する(`identity_map.subject_id` は一意) ## 全体像 (Overview) @@ -30,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) @@ -52,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(症例) @@ -86,7 +101,7 @@ | `registered_at` | TEXT | 登録日時 | - ストア上のパスは `store//.` で決まるため,目録には保存しない -- 同じ中身のファイルを 2 度取り込むと,同じ行を指す(重複登録しない).ただし別の撮影・別の種類として登録しようとした場合は停止する +- 同じ中身のファイルを 2 度取り込むと,同じ行を指す(重複登録しない).ただし別の撮影・別の種類・**別の拡張子**として登録しようとした場合は停止する(ストアでは拡張子が違うと別のファイルになるが,assets は SHA-256 で 1 行のため) ### derivations・derivation_inputs・derivation_outputs(由来) @@ -154,7 +169,7 @@ ## 対応表 (Identity Map) -`identity.sqlite`(目録とは別のファイル・別のフォルダ).実際の患者 ID が分かるデータを取り込むときに使う(現在の舌色データも対象). +`identity.sqlite`(目録とは別のファイル・別のフォルダ)の `identity_map` 表.実際の患者 ID が分かるデータを取り込むときに使う(現在の舌色データも対象). | 列 | 内容 | | --- | --- | 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 69f89fa..5b67a18 100644 --- "a/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" +++ "b/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" @@ -31,6 +31,13 @@ - 本リポジトリに未コミットの変更がある状態では版を作らない(定義とコードの版を記録できなくなるため) - 行の順序は `subject_id`・`capture_id`・`sha256` の順に並べる(同じ構成なら同じ `manifest.csv` になるようにする) +- 未コミットの変更の確認は,目録を見る前に行う(`git status --porcelain --untracked-files=all` が空でなければ停止).`hub_commit` は `git rev-parse HEAD` +- **構成の比較**: 定義の全文が同じで,かつ採用したファイルと所見(所見の行の ID)の組が同じなら版を作らない.そのため,同じ値への訂正でも所見の行が変わるので新しい版になる(manifest の SHA-256 は直前の版と同じになる).定義は説明だけの変更でも新しい版になる +- 条件に合うファイルが 0 件なら停止する.撮影にひも付かないファイルが入る場合も停止する(`subject_id`・`capture_id` を決められないため) +- 所見の列は定義の `[columns]` に書いた順に並べる.値は文字列ならそのまま,無ければ空,それ以外は JSON の表記 +- 定義の `description` は必須.目録の `datasets.description` は最初の版のときの値のままにする(版ごとの定義の全文は `dataset_versions.definition` に残る) +- 種類との照合(定義の種類・項目が kinds と値の型にあるか)は版を作るときだけ行う.過去の版の manifest の作り直しには使わない(種類の定義が後で変わっても作り直せるようにするため) +- 版の備考(`--note`)は `dataset show` で表示する.**備考に患者 ID 等の個人情報を書かない** ## バンドル (Bundle) @@ -44,6 +51,18 @@ └── files/. # ストアからのコピー ``` +- `dataset.json` の項目: `format`(形式の版.今は 1.読み込み側は知らない形式を拒否し,未知のキーは無視する)・`name`・`version`・`created_at`(版を作った日時)・`hub_commit`・`files`(件数)・`manifest_sha256`.**書き出した日時は入れない**(同じ版から常に同じバイト列にし,既存のバンドルをバイト単位で照合できるようにするため) +- `SHA256SUMS` の 1 行は ` files/.`(空白 2 つ・LF・パスの順) +- バンドルのファイルは読み取り専用にしない(作り直せて,受け渡した後に消す前提のため.2026-10-08 のユーザー判断).改変は `verify()` と書き出し直しの照合で見つける + +### 書き出し (Export) + +`tiasdata export <名前> --version <版>`(`src/tiasdata/export.py`). + +1. 版の記録から manifest を作り直し,記録した SHA-256 と一致しなければ停止する(目録が書き換えられた版を書き出さない) +2. `bundles/<名前>/.tmp-v<版>-…/` に `files/`(ストアからコピーし,読み直して照合)・`manifest.csv`・`SHA256SUMS`・`dataset.json` を書き,開き直して形を確かめてから `v<版>` に名前を変える(書きかけを正式な名前で残さない.失敗したら一時フォルダを消す) +3. 既に `v<版>` があれば,`dataset.json`・`manifest.csv`・`SHA256SUMS` を版の記録から作ったものとバイト比較し,`verify()` を通して使う.食い違えば上書きせずに停止する(消して書き出し直すのは人間の作業) + ### manifest.csv の列 | 列 | 内容 | @@ -97,7 +116,12 @@ bundle.fingerprint # {"name", "version", "manifest_sha256"}.実験記録に残す ``` -- `load()` は版の指定を必須にする +- `load(name, *, version)`: 版の指定は**キーワードで必須**(`load("tongue_color", 1)` は誤り).置き場所は環境変数 `TIASDATA_BUNDLES`,無ければ `~/data/tiasdata`.`dataset.json` の名前・版が指定と違えば停止する +- `bundle.rows` は `BundleRow`(`path`・`sha256`・`kind`・`subject_id`・`capture_id`・`annotations`)の並び.`annotations` は manifest の列 `<所見>.<項目>` を所見ごとにまとめたもので,値は文字列か None(空欄).文字列以外の型には戻さない +- `bundle.file_path(row)` が画像等の絶対パスを返す.`bundle.info`(`BundleInfo`)・`bundle.root`・`bundle.name`・`bundle.version`・`bundle.manifest_sha256` も使える +- `bundle.verify()` は問題が無ければ None を返し,欠け・改変・`files/` の余分・`SHA256SUMS` と manifest の食い違い・開いた後の `manifest.csv` の書き換えを ValueError で知らせる +- 読み込み側(`tiasdata.bundle`)は標準ライブラリ・pydantic・`tiasdata.manifest` だけを使い,目録(catalog・store・config)を読み込まない.`import tiasdata` で読み込まれるのは `tiasdata`・`tiasdata.bundle`・`tiasdata.manifest` だけ +- エラーメッセージにバンドルの場所(パス)を出さない(研究室 PC・計算サーバの出力も Claude が読むため.名前・版・`<名前>/v<版>/` の形・環境変数名で示す) - `tiasdata.open_bundle(path)` でパスを直接指定して開くこともできる(テスト・Python 以外との突き合わせ用) - 読み込み時に `manifest.csv` の SHA-256 が `dataset.json` と一致しなければ停止する @@ -108,19 +132,35 @@ | コマンド | 役割 | | --- | --- | | `tiasdata init` | 設定ファイルに従って目録・ストアを作り,マイグレーションを適用する | -| `tiasdata import <取り込み元>` | 取り込み元を読み,ファイルをストアにコピーして症例・撮影・ファイル・所見・由来を登録する(取り込み元の形ごとに importer を用意する.最初は舌色の 9 フォルダ `tongue_color_folders`) | -| `tiasdata dataset list` / `show <名前>` | データセットと版の一覧・件数 | -| `tiasdata dataset freeze <名前>` | 新しい版を作る | -| `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す | +| `tiasdata import <取り込み元>` | 取り込み元を読み,ファイルをストアにコピーして症例・撮影・ファイル・所見・由来を登録する(取り込み元の形ごとに importer を用意する.最初は舌色の 9 フォルダ `tongue_color_folders`).保留リストの番号は飛ばし,件数を報告する | +| `tiasdata dataset list` / `show <名前>` | データセットと版の一覧・件数.定義だけあって版の無いものは「版なし」,定義ファイルを消したものは「(定義ファイルなし)」.show は版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | +| `tiasdata dataset freeze <名前> [--note TEXT]` | 新しい版を作る(作った版の番号と件数,または「構成・定義が同じなので版を作らなかった」) | +| `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す(上記「書き出し」).書き出した件数・合計の大きさ・manifest の SHA-256,または「既にあったバンドルを照合して使った」を表示する | | `tiasdata deliver <名前> --version <版> --to <サーバ名>` | 計算サーバへ渡す | -| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け.件数だけを表示する | -| `tiasdata stats` | 種類・所見の値ごとの件数 | +| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,凍結した版の manifest の不一致(全版について記録した定義と構成から manifest を作り直し,記録した SHA-256 と照合する.ファイルの種類や撮影の症例が書き換えられた場合に見つかる),読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.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) + +取り込み元のデータに,基盤では正解を決められない問題(同じ写真の重複・患者 ID やラベルの食い違い等)があるとき,確かめられるまで取り込まずに**保留**にする.推測で片方を選んで版に固定しないため. + +- `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 組の扱い」) + ## 出力の規則 (Output Rules) CLI の出力・ログ・エラーメッセージに出してよいもの・出さないもの.Claude が SSH 経由で CLI を実行し,その出力を読むことを前提にする. @@ -133,3 +173,6 @@ | 設定ファイルで付けた名前(取り込み元・サーバ) | — | - 元のファイル名(`source_name`)は個人をたどれる情報を含まないが,既定では表示しない(エラーの特定に必要な場合だけ表示する) +- 目録・ストア・対応表・取り込み元のパスは表示しない.例外として,「設定ファイルが見つからない」エラーでは設定ファイル自身のパスを表示する(データの場所ではなく,原因の特定に必要なため) +- 設定ファイルの検証エラーは「キーの位置: 理由」だけを 1 行で示し,入力された値(パス等)は表示しない +- 出力は常に UTF-8 で書き出す(Windows でパイプ・SSH 越しに cp932 で書き出されて手元で文字化けするのを防ぐため) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 56bb1e6..a70cc01 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -25,3 +25,22 @@ - [x] **研究室 PC の臨床データを E ドライブへ移した**: 研究室 PC に E ドライブ(内蔵 SSD・約 465GB)ができたため,`C:\Users\Papin\clinical-data\` 全体(26,268 ファイル・45.30GB)を `E:\clinical-data\` へコピーし,SHA-256 と構成の照合の後に C 側を削除した(2026-10-06.C の空き 53.5GB).docs・`.claude/repo-access.json`・`lab-pc-operation.md` のパスを書き換えた - 失敗パターン: C 側の削除が読み取り専用の属性が付いたフォルダで止まった(`shutil.rmtree` の WinError 5).属性を外して再試行する `onexc` で解決.コピー済み・照合済みだったためデータの欠損は無し - 未実装・後段: TIASJudgeModel の手順書の原本のパスが古い.E ドライブの暗号化(BitLocker)は未定(ENV_03「人間が確認すべき事項」) +- [x] **PLAN_02 Step 1: 設定と目録の土台**: 設定ファイルの読み込み・検証,SQLite の目録と対応表のマイグレーション(SPEC_02 の全表と最初の種類),`tiasdata init` を実装(2026-10-06) + - 設計判断: マイグレーションはリポジトリ直下ではなくパッケージ内(`src/tiasdata/catalog/migrations/`)に置く(サブモジュール・パッケージとして使ったときも見つけられるようにするため).目録と対応表は別系列で版を管理する.凍結した版・所見の書き換え禁止,種類の整合,書式(日時は秒まで)を SQL の CHECK・トリガで強制する(SPEC_02「スキーマで強制する規則」) + - 失敗パターン: ① SQLite の CHECK は NULL を違反としないため,`value_schema_version >= 1` が NULL ですり抜けた/② INTEGER 以外の主キーに NULL が入った → どちらも `IS NOT NULL`・`NOT NULL` を明示して解決.③ Windows でパイプ・SSH 越しの出力が cp932 で化けた → CLI の入口で標準出力・標準エラーを UTF-8 に切り替えた.④ `ruff format .` が docs/ の Markdown 内のコード例を整形した → `extend-exclude` に docs を追加 +- [x] **PLAN_02 Step 2: ストア**: SHA-256 名のストアへの取り込み(一時ファイル → 照合 → 正式な名前)・点検用の照合と,テスト用のダミーデータ(舌色の 9 フォルダ構成)を実装(2026-10-06) + - 設計判断: ストアのファイルは読み取り専用にする(誤操作対策.「直したい」場合も書き換えず,新しいファイル・所見の訂正・新しい版で扱う.本当に消す場合は将来の専用コマンドで記録を残して消す).store フォルダが無ければ作らずに止める.同じ中身で拡張子が違うファイルは Step 3 の取り込みで止める + - 追加の判断: 既にあるファイルを再利用したとき(同じ中身を再び取り込んだとき)も読み取り専用を付け直す(バックアップからの復元・手作業等で属性が外れたファイルを守り直すため).取り込みで触らないファイルの属性は,Step 3 の `check` で書き込み可能なファイルの数として報告する + - 未実装・後段: POSIX(os.link)の分岐は Windows では未検証 +- [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 で共有して短くする +- [x] **PLAN_02 Step 4: データセットと版**: データセットの定義(`datasets/tongue_color.toml`),定義の当てはめと版の作成(freeze),manifest.csv の生成(純関数),`tiasdata dataset list`・`show`・`freeze`,check での全版の manifest の照合を実装(2026-10-06) + - 設計判断: 版は「定義の全文+採用したファイルと所見の行」が直前と同じなら作らない(同じ値への訂正でも新しい版).版の行は manifest の SHA-256 を計算してから 1 トランザクションで INSERT(凍結のトリガがあるため UPDATE しない).未コミットの変更があると作らない.種類との照合は作るときだけ(過去の版の作り直しに使わない) + - 未決: 版が参照するファイルの種類(`assets.kind`)や撮影の症例(`captures.subject_id`)は書き換えられ,書き換えると過去の版の manifest が変わる(check は不一致として見つける).書き換えをトリガで禁止し,症例の誤りは訂正の行で直す仕組みにするかを決める(保留中の 122/184 の患者の確定で関係する) +- [x] **PLAN_02 Step 5: バンドル**: 凍結した版のバンドルの書き出し(`tiasdata export`.`export.py`)と,目録に依存しない読み込み・検証(`tiasdata.load`・`open_bundle`・`Bundle.verify`・`fingerprint`.`bundle.py`)を実装(2026-10-08) + - 設計判断: 読み込みと書き出しをモジュールで分け,`import tiasdata` で目録まわり(catalog・store・config・sqlite3)を読み込まない(計算サーバで使うため).共有する純関数は `manifest.py` に集めた.`dataset.json` に書き出した日時を入れず同じ版から同じバイト列にする(既存のバンドルをバイト単位で照合できる).バンドルは読み取り専用にしない(作り直せて消す前提).`load` の版はキーワード必須.エラーにバンドルの場所を出さない + - 未実装・後段: `BundleRow.annotations` の入れ子の値が書き換えられる(使う側の誤りを防ぐなら読み取り専用にする).書き出しに失敗すると空の `bundles/<名前>/` が残る(害はない) diff --git a/holds/tiasjudgemodel.toml b/holds/tiasjudgemodel.toml new file mode 100644 index 0000000..59cac3e --- /dev/null +++ b/holds/tiasjudgemodel.toml @@ -0,0 +1,60 @@ +# 取り込み元 tiasjudgemodel(舌色データセット)の保留リスト +# +# ここに載せた番号(画像のファイル名の番号)は取り込みで飛ばし,件数を報告する(SPEC_03「保留リスト」). +# 確かめ終わったら行を消して取り込み直し,データセットの新しい版を作る.過去の版は変わらない. +# 患者 ID は書かない(番号はファイル名の連番で,個人をたどれる情報を含まない). +# 経緯は docs/03_PLAN/PLAN_01_要件定義書.md「現在あるデータ」・docs/PROGRESS.md(2026-10-03) + +[[hold]] +number = 122 +reason = "184 と同じ写真(ファイルが同一).対応表では 184 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[hold]] +number = 184 +reason = "122 と同じ写真(ファイルが同一).対応表では 122 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[hold]] +number = 548 +reason = "566 と同じ写真(ファイルが同一).対応表では 566 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[hold]] +number = 566 +reason = "548 と同じ写真(ファイルが同一).対応表では 548 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[hold]] +number = 381 +reason = "382 とほぼ同じ画像(違う画素 約 0.7%)で,程度のラベルが違う(381: 紅_ややあり / 382: 紅_あり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[hold]] +number = 382 +reason = "381 とほぼ同じ画像(違う画素 約 0.7%)で,程度のラベルが違う(381: 紅_ややあり / 382: 紅_あり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[hold]] +number = 515 +reason = "516 とほぼ同じ画像(違う画素 約 0.7%)で,舌色のラベルが違う(515: 紫_ややあり / 516: 暗赤紅_ややあり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[hold]] +number = 516 +reason = "515 とほぼ同じ画像(違う画素 約 0.7%)で,舌色のラベルが違う(515: 紫_ややあり / 516: 暗赤紅_ややあり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[hold]] +number = 509 +reason = "508 と同じ写真を処理し直した重複(違う画素 約 0.7%.ラベルは同じ).DeepTIAS の出力と完全一致するのは 508" +resolve = "不要(508 を残す).重複なので保留のままでよい" +since = "2026-10-06" diff --git a/pyproject.toml b/pyproject.toml index dce91df..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" @@ -19,8 +24,9 @@ line-length = 100 target-version = "py312" # .claude/ はテンプレート所有のファイル(フック・テンプレートのテスト)であり, -# このプロジェクトの規約で整形しないため lint 対象から外す -extend-exclude = [".claude"] +# このプロジェクトの規約で整形しないため lint 対象から外す. +# docs/ は Markdown 内のコード例を ruff format に整形させないため外す +extend-exclude = [".claude", "docs"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "N"] @@ -34,6 +40,7 @@ [dependency-groups] dev = [ + "openpyxl>=3.1.5", "pillow>=11.0", "pytest>=8.0", "ruff>=0.8", diff --git a/src/tiasdata/__init__.py b/src/tiasdata/__init__.py index 3ad8978..2568dd6 100644 --- a/src/tiasdata/__init__.py +++ b/src/tiasdata/__init__.py @@ -1,3 +1,12 @@ -"""舌診研究の共有データ基盤 TIASDataHub のアクセス用ライブラリ.""" +"""舌診研究の共有データ基盤 TIASDataHub のアクセス用ライブラリ. + +使う側(計算サーバ等)は `tiasdata.load(name, version=...)` でバンドルを開く. +ここで import するのはバンドルの読み込みだけにする(目録・ストア・設定を計算サーバに +持ち込まないため.SPEC_01「依存の向き」). +""" + +from tiasdata.bundle import Bundle, BundleInfo, BundleRow, load, open_bundle __version__ = "0.1.0" + +__all__ = ["Bundle", "BundleInfo", "BundleRow", "__version__", "load", "open_bundle"] diff --git a/src/tiasdata/bundle.py b/src/tiasdata/bundle.py new file mode 100644 index 0000000..fa9f5a0 --- /dev/null +++ b/src/tiasdata/bundle.py @@ -0,0 +1,528 @@ +"""バンドル(データセットの 1 つの版を manifest とファイルのコピーにまとめたもの)の読み込み・検証. + +バンドルの形(SPEC_03「バンドル」):: + + <名前>/v<版>/ + ├── dataset.json # 名前・版・作成日時・hub_commit・件数・manifest.csv の SHA-256 + ├── manifest.csv # 1 行 1 ファイル + ├── SHA256SUMS # files/ の照合用(sha256sum -c で検証できる形式) + └── files/. # ストアからのコピー + +このモジュールは目録の無い計算サーバで使うため,`catalog`・`store`・`config` を import しない +(標準ライブラリ・pydantic・`tiasdata.manifest` だけを使う.SPEC_01「依存の向き」). +書き出し(目録・ストアを使う側)は `tiasdata.export` にあり,ここの形の組み立て +(`build_dataset_json`・`build_sha256sums`)を共有する. + +例外メッセージにはバンドルのパスを出さず,データセット名・版・件数・SHA-256 で示す +(SPEC_03「出力の規則」.計算サーバの出力も Claude が読みうるため,データの場所を出さない). +""" + +import csv +import io +import json +import os +import re +from collections.abc import Iterable, Sequence +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from tiasdata.manifest import ( + BASE_COLUMNS, + DATASET_NAME_PATTERN, + FILES_DIRNAME, + MANIFEST_ENCODING, + bundle_file_path, + manifest_sha256, + sha256_file, + validation_error_details, +) + +BUNDLES_ENV_VAR = "TIASDATA_BUNDLES" +DATASET_JSON = "dataset.json" +MANIFEST_CSV = "manifest.csv" +SHA256SUMS = "SHA256SUMS" +# dataset.json の形の版(形を変えたら上げ,読み込み側が知らない形を黙って読まないようにする) +BUNDLE_FORMAT = 1 + +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_FILE_PATH_PATTERN = re.compile(rf"^{FILES_DIRNAME}/([0-9a-f]{{64}})\.([a-z0-9]+)$") +_ANNOTATION_COLUMN_PATTERN = re.compile(r"^(\w+)\.(\w+)$", re.ASCII) +# `sha256sum` の出力の形(` <パス>`.2 つ目の空白の代わりに `*` は二進モードの印) +_SHA256SUMS_LINE_PATTERN = re.compile(r"^([0-9a-f]{64}) [ *](.+)$") +# エラーメッセージに出す例の最大件数と,SHA-256 の桁数(位置の特定には先頭で足りる) +_MAX_EXAMPLES = 5 +_SHORT_SHA256 = 12 + + +class BundleInfo(BaseModel): + """`dataset.json` の内容. + + Attributes: + format: バンドルの形の版. + name: データセット名. + version: 版. + created_at: 版を作った日時(UTC の ISO 8601).書き出した日時ではない. + hub_commit: 版を作ったときの本リポジトリのコミット. + files: ファイルの数(manifest.csv の行数). + manifest_sha256: manifest.csv の SHA-256. + """ + + # 後で項目を足しても古い読み込み側が読めるよう,未知のキーは無視する + model_config = ConfigDict(frozen=True, extra="ignore") + + format: int = Field(strict=True) + name: str = Field(pattern=DATASET_NAME_PATTERN.pattern) + version: int = Field(ge=1, strict=True) + created_at: str + hub_commit: str + files: int = Field(ge=0, strict=True) + manifest_sha256: str = Field(pattern=_SHA256_PATTERN.pattern) + + +class BundleRow(BaseModel): + """manifest.csv の 1 行(バンドルの 1 ファイル). + + Attributes: + path: バンドルのルートからの相対パス(`files/.`.区切りは `/`). + 絶対パスは `Bundle.file_path(row)` で得る. + sha256: ファイルの SHA-256. + kind: ファイルの種類. + subject_id: 研究用の症例 ID(分割の単位にする). + capture_id: 撮影 ID. + annotations: 所見の値(所見の種類 → 項目 → 値).manifest の `<所見>.<項目>` の列を + まとめたもの.値は manifest の文字列のまま(文字列以外の値は JSON の表記)で, + 空の欄は None. + """ + + model_config = ConfigDict(frozen=True) + + path: str + sha256: str + kind: str + subject_id: str + capture_id: str + annotations: dict[str, dict[str, str | None]] + + +class Bundle: + """開いたバンドル(`open_bundle`・`load` で作る). + + 開いた時点で dataset.json と manifest.csv の SHA-256 の一致を確かめてある. + ファイルの中身の照合は時間がかかるため `verify()` で別に行う. + """ + + def __init__(self, root: Path, info: BundleInfo, rows: Sequence[BundleRow]) -> None: + """`open_bundle` から呼ぶ(使う側は直接作らない).""" + self._root = root + self._info = info + self._rows = tuple(rows) + + def __repr__(self) -> str: + # パスを出さない(ログに残っても場所が分からないように) + return f"Bundle(name={self.name!r}, version={self.version}, files={len(self._rows)})" + + @property + def root(self) -> Path: + """バンドルのフォルダ(絶対パス).""" + return self._root + + @property + def info(self) -> BundleInfo: + """dataset.json の内容.""" + return self._info + + @property + def name(self) -> str: + """データセット名.""" + return self._info.name + + @property + def version(self) -> int: + """版.""" + return self._info.version + + @property + def manifest_sha256(self) -> str: + """manifest.csv の SHA-256.""" + return self._info.manifest_sha256 + + @property + def rows(self) -> tuple[BundleRow, ...]: + """manifest.csv の行(`subject_id`・`capture_id`・`sha256` の順).""" + return self._rows + + @property + def fingerprint(self) -> dict[str, str | int]: + """バンドルの指紋(`{"name", "version", "manifest_sha256"}`).実験記録に残す.""" + return { + "name": self._info.name, + "version": self._info.version, + "manifest_sha256": self._info.manifest_sha256, + } + + def file_path(self, row: BundleRow) -> Path: + """行のファイルの絶対パス(画像を開くため). + + Args: + row: このバンドルの行. + + Returns: + `<バンドルのフォルダ>/files/.`. + """ + return self._root / row.path + + def verify(self) -> None: + """全ファイルの SHA-256 を照合する(時間がかかる). + + manifest.csv の SHA-256(dataset.json と一致するか),SHA256SUMS と manifest の一致, + files/ の各ファイルの有無・中身,files/ に manifest に無いファイルが無いかを確かめる. + + Raises: + ValueError: manifest.csv が変わった,SHA256SUMS が無い・形が誤っている・manifest と + 食い違う,ファイルの欠け・中身の改変,または manifest に無いファイルがある. + """ + label = _label(self._info) + data = _read_bytes(self._root / MANIFEST_CSV, label, MANIFEST_CSV) + if manifest_sha256(data) != self._info.manifest_sha256: + raise ValueError(f"{label}: manifest.csv の SHA-256 が dataset.json と一致しない") + + sums = parse_sha256sums(_read_bytes(self._root / SHA256SUMS, label, SHA256SUMS), label) + expected = {(row.sha256, row.path) for row in self._rows} + if set(sums) != expected or len(sums) != len(expected): + raise ValueError(f"{label}: SHA256SUMS の行が manifest.csv と一致しない") + + missing: list[str] = [] + mismatched: list[str] = [] + for row in self._rows: + path = self.file_path(row) + if not path.is_file(): + missing.append(row.sha256[:_SHORT_SHA256]) + elif sha256_file(path) != row.sha256: + mismatched.append(row.sha256[:_SHORT_SHA256]) + unexpected = _count_unexpected_files(self._root, {row.path for row in self._rows}) + + if missing or mismatched or unexpected: + details = [ + f"欠け {len(missing)} 件" + _examples(missing), + f"中身の不一致 {len(mismatched)} 件" + _examples(mismatched), + f"manifest に無いファイル {unexpected} 件", + ] + raise ValueError( + f"{label}: ファイルの照合に失敗した({len(self._rows)} 件中.{'・'.join(details)})" + ) + + +# --------------------------------------------------------------------------- +# 形の組み立て(書き出し側と共有する純関数) +# --------------------------------------------------------------------------- +def build_dataset_json(info: BundleInfo) -> bytes: + """dataset.json のバイト列を作る(UTF-8・改行 LF・末尾に改行). + + 書き出した日時は入れない(同じ版から同じバイト列になるようにするため). + + Args: + info: dataset.json の内容. + + Returns: + dataset.json の中身. + """ + text = json.dumps(info.model_dump(), ensure_ascii=False, indent=2) + "\n" + return text.encode("utf-8") + + +def build_sha256sums(files: Iterable[tuple[str, str]]) -> bytes: + """SHA256SUMS のバイト列を作る(`sha256sum -c` で検証できる形.改行 LF). + + 各行は ` <バンドルのルートからの相対パス>`(間は空白 2 つ)で,パスの順に並べる. + + Args: + files: (SHA-256, 相対パス). + + Returns: + SHA256SUMS の中身. + """ + lines = [f"{sha256} {path}\n" for sha256, path in sorted(files, key=lambda f: f[1])] + return "".join(lines).encode("utf-8") + + +def parse_sha256sums(data: bytes, label: str = "バンドル") -> list[tuple[str, str]]: + """SHA256SUMS を読む. + + Args: + data: SHA256SUMS の中身. + label: エラーメッセージで対象を示す名前. + + Returns: + (SHA-256, 相対パス)の並び(ファイルの順). + + Raises: + ValueError: UTF-8 でない,または形が誤っている行がある. + """ + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise ValueError(f"{label}: SHA256SUMS が UTF-8 でない") from e + entries: list[tuple[str, str]] = [] + for number, line in enumerate(text.split("\n"), start=1): + if not line: + continue + match = _SHA256SUMS_LINE_PATTERN.match(line) + if match is None: + raise ValueError(f"{label}: SHA256SUMS の {number} 行目の形が誤っている") + entries.append((match.group(1), match.group(2))) + return entries + + +def check_dataset_name(name: str) -> None: + """データセット名がパスに使える形かを確かめる. + + Args: + name: データセット名. + + Raises: + ValueError: 英小文字で始まり英小文字・数字・`_` だけの名前でない. + """ + if not isinstance(name, str) or not DATASET_NAME_PATTERN.match(name): + raise ValueError( + f"データセット名 {name!r} は英小文字で始まり,英小文字・数字・_ だけにする" + ) + + +def check_version(version: int) -> None: + """版が 1 以上の整数かを確かめる. + + Args: + version: 版. + + Raises: + ValueError: 整数でない(真偽値を含む),または 1 未満. + """ + if isinstance(version, bool) or not isinstance(version, int) or version < 1: + raise ValueError(f"版は 1 以上の整数で指定する(指定: {version!r})") + + +def bundle_relative_dir(name: str, version: int) -> Path: + """バンドルの置き場所からの相対パス(`<名前>/v<版>`). + + Args: + name: データセット名. + version: 版. + + Returns: + `<名前>/v<版>`. + + Raises: + ValueError: 名前・版の形が誤っている. + """ + check_dataset_name(name) + check_version(version) + return Path(name) / f"v{version}" + + +# --------------------------------------------------------------------------- +# 読み込み +# --------------------------------------------------------------------------- +def default_bundles_dir() -> Path: + """バンドルの置き場所を返す. + + 環境変数 `TIASDATA_BUNDLES` があればそのパス,無ければ `~/data/tiasdata` + (SPEC_01「使う側からの利用」). + + Returns: + バンドルの置き場所(存在するかは確かめない). + """ + env = os.environ.get(BUNDLES_ENV_VAR) + if env: + return Path(env).expanduser() + return Path.home() / "data" / "tiasdata" + + +def load(name: str, *, version: int) -> Bundle: + """データセットの版を指定してバンドルを開く(`TIASDATA_BUNDLES/<名前>/v<版>/`). + + 版の省略は許さない(暗黙に最新を使うと再現性が崩れるため.SPEC_01). + + Args: + name: データセット名(例: `tongue_color`). + version: 版(1 以上.キーワードで指定する). + + Returns: + 開いたバンドル. + + Raises: + ValueError: 名前・版の形が誤っている,バンドルが見つからない,形が誤っている, + manifest の SHA-256 が一致しない,または dataset.json の名前・版が指定と違う. + """ + root = default_bundles_dir() / bundle_relative_dir(name, version) + if not root.is_dir(): + raise ValueError( + f"データセット '{name}' の版 {version} のバンドルが無い" + f"(環境変数 {BUNDLES_ENV_VAR} の場所(既定 ~/data/tiasdata)の {name}/v{version}/)" + ) + bundle = open_bundle(root) + if bundle.name != name or bundle.version != version: + raise ValueError( + f"{name}/v{version}/ のバンドルの dataset.json がデータセット '{bundle.name}' の" + f"版 {bundle.version} になっている" + ) + return bundle + + +def open_bundle(path: str | os.PathLike[str]) -> Bundle: + """パスを指定してバンドルを開く. + + dataset.json と manifest.csv を読み,manifest.csv の SHA-256 が dataset.json と + 一致することを確かめる(一致しなければ停止).ファイルの中身は照合しない(`verify()`). + + Args: + path: バンドルのフォルダ(`<名前>/v<版>/`). + + Returns: + 開いたバンドル. + + Raises: + ValueError: フォルダ・dataset.json・manifest.csv が無い,形が誤っている, + または manifest.csv の SHA-256 が dataset.json と一致しない. + """ + root = Path(path).expanduser().resolve() + if not root.is_dir(): + raise ValueError("バンドルのフォルダが無い") + info = _parse_info(_read_bytes(root / DATASET_JSON, "バンドル", DATASET_JSON)) + label = _label(info) + data = _read_bytes(root / MANIFEST_CSV, label, MANIFEST_CSV) + if manifest_sha256(data) != info.manifest_sha256: + raise ValueError( + f"{label}: manifest.csv の SHA-256 が dataset.json と一致しない" + "(manifest.csv が書き換えられたか,別の版のもの)" + ) + rows = parse_manifest(data, label) + if len(rows) != info.files: + raise ValueError( + f"{label}: manifest.csv の行数 {len(rows)} が dataset.json の件数 {info.files} と" + "一致しない" + ) + return Bundle(root, info, rows) + + +def parse_manifest(data: bytes, label: str = "バンドル") -> list[BundleRow]: + """manifest.csv を読んで行にする. + + Args: + data: manifest.csv の中身(UTF-8・BOM なし). + label: エラーメッセージで対象を示す名前. + + Returns: + manifest の行(ファイルの順). + + Raises: + ValueError: UTF-8 でない,見出しが誤っている,列の数が合わない行・パスの形が誤っている + 行・SHA-256 の重複がある. + """ + try: + text = data.decode(MANIFEST_ENCODING) + except UnicodeDecodeError as e: + raise ValueError(f"{label}: manifest.csv が UTF-8 でない") from e + reader = csv.reader(io.StringIO(text, newline="")) + header = next(reader, None) + if header is None or tuple(header[: len(BASE_COLUMNS)]) != BASE_COLUMNS: + raise ValueError( + f"{label}: manifest.csv の見出しが {', '.join(BASE_COLUMNS)} で始まっていない" + ) + annotation_columns = _parse_annotation_columns(header[len(BASE_COLUMNS) :], label) + + rows: list[BundleRow] = [] + seen: set[str] = set() + for number, record in enumerate(reader, start=2): + if len(record) != len(header): + raise ValueError(f"{label}: manifest.csv の {number} 行目の列の数が見出しと違う") + path, sha256, kind, subject_id, capture_id = record[: len(BASE_COLUMNS)] + match = _FILE_PATH_PATTERN.match(path) + if ( + not _SHA256_PATTERN.match(sha256) + or match is None + or path != bundle_file_path(sha256, match.group(2)) + ): + raise ValueError( + f"{label}: manifest.csv の {number} 行目の path・sha256 の形が誤っている" + f"(path は {FILES_DIRNAME}/.<拡張子>)" + ) + if sha256 in seen: + raise ValueError(f"{label}: manifest.csv に SHA-256 {sha256} が 2 回以上ある") + seen.add(sha256) + annotations: dict[str, dict[str, str | None]] = {} + for (kind_name, field), cell in zip( + annotation_columns, record[len(BASE_COLUMNS) :], strict=True + ): + annotations.setdefault(kind_name, {})[field] = cell if cell != "" else None + rows.append( + BundleRow( + path=path, + sha256=sha256, + kind=kind, + subject_id=subject_id, + capture_id=capture_id, + annotations=annotations, + ) + ) + return rows + + +def _parse_annotation_columns(columns: Sequence[str], label: str) -> list[tuple[str, str]]: + parsed: list[tuple[str, str]] = [] + for column in columns: + match = _ANNOTATION_COLUMN_PATTERN.match(column) + if match is None: + raise ValueError(f"{label}: manifest.csv の列 '{column}' が <所見>.<項目> の形でない") + parsed.append((match.group(1), match.group(2))) + if len(set(parsed)) != len(parsed): + raise ValueError(f"{label}: manifest.csv に同じ所見の列が 2 回以上ある") + return parsed + + +def _parse_info(data: bytes) -> BundleInfo: + try: + raw = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise ValueError(f"バンドルの {DATASET_JSON} を JSON として読めない: {e}") from e + if not isinstance(raw, dict): + raise ValueError(f"バンドルの {DATASET_JSON} が JSON のオブジェクトでない") + try: + info = BundleInfo.model_validate(raw) + except ValidationError as e: + raise ValueError( + f"バンドルの {DATASET_JSON} の形が誤っている: {validation_error_details(e)}" + ) from e + if info.format != BUNDLE_FORMAT: + raise ValueError( + f"バンドルの形の版 {info.format} は読めない(読めるのは {BUNDLE_FORMAT})." + "tiasdata を更新する" + ) + return info + + +def _read_bytes(path: Path, label: str, filename: str) -> bytes: + # Windows ではフォルダを読むと PermissionError になるため,先にファイルかを確かめる + if not path.is_file(): + raise ValueError(f"{label}: {filename} が無い,またはファイルでない") + return path.read_bytes() + + +def _count_unexpected_files(root: Path, expected_paths: set[str]) -> int: + files_dir = root / FILES_DIRNAME + if not files_dir.is_dir(): + return 0 + return sum( + 1 for path in files_dir.iterdir() if f"{FILES_DIRNAME}/{path.name}" not in expected_paths + ) + + +def _label(info: BundleInfo) -> str: + return f"バンドル {info.name} v{info.version}" + + +def _examples(items: Sequence[str]) -> str: + if not items: + return "" + shown = ", ".join(items[:_MAX_EXAMPLES]) + return f"(例: {shown}{' …' if len(items) > _MAX_EXAMPLES else ''})" diff --git a/src/tiasdata/catalog/__init__.py b/src/tiasdata/catalog/__init__.py new file mode 100644 index 0000000..c0a429e --- /dev/null +++ b/src/tiasdata/catalog/__init__.py @@ -0,0 +1,50 @@ +"""目録(SQLite)の接続・マイグレーションの適用・各表の操作. + +SQL はこのパッケージの中にだけ書く(他のモジュールはここの関数を呼ぶ). +対応表(`identity`)の操作は `tiasdata.catalog.identity` にあり,取り込みの処理からだけ使う. +""" + +from tiasdata.catalog.connection import connect, write_transaction +from tiasdata.catalog.migrate import ( + MigrationResult, + Series, + apply_migrations, + connect_latest, + latest_version, + require_latest, + schema_version, +) +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", + "connect_latest", + "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 new file mode 100644 index 0000000..7513e52 --- /dev/null +++ b/src/tiasdata/catalog/connection.py @@ -0,0 +1,68 @@ +"""SQLite の接続.""" + +import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + + +def connect(path: Path, *, create: bool = False) -> sqlite3.Connection: + """SQLite の DB を開き,外部キー制約を有効にする. + + Args: + path: DB ファイルのパス. + create: True なら DB ファイルが無いときに作る(親フォルダは作らない). + False なら DB ファイルが無いときに停止する(`tiasdata init` の前に誤って空の目録を + 作らないため). + + Returns: + `PRAGMA foreign_keys = ON` を有効にした接続. + + Raises: + ValueError: `create` が False で DB ファイルが無い,または DB を開けない. + """ + mode = "rwc" if create else "rw" + uri = f"{path.resolve().as_uri()}?mode={mode}" + try: + conn = sqlite3.connect(uri, uri=True) + except sqlite3.OperationalError as e: + if not create and not path.exists(): + raise ValueError("目録が無い.先に `tiasdata init` を実行する") from e + raise ValueError(f"DB を開けない: {e}") from e + # 外部キー制約は接続ごとの設定で,既定では無効(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 new file mode 100644 index 0000000..ee4ef69 --- /dev/null +++ b/src/tiasdata/catalog/migrate.py @@ -0,0 +1,188 @@ +"""マイグレーション(連番の SQL ファイル)の適用. + +適用済みの版を DB の `PRAGMA user_version` に記録し,それより新しいファイルだけを番号順に適用する. +マイグレーションはパッケージ内(`catalog/migrations/<系列>/NNNN_<内容>.sql`)に置き, +`importlib.resources` で読む(editable install でもパッケージとして入れても +同じように見つかるようにするため). +""" + +import re +import sqlite3 +from importlib import resources +from pathlib import Path +from typing import Literal, NamedTuple + +from tiasdata.catalog.connection import connect + +# 目録(catalog.sqlite)と対応表(identity.sqlite)は別の DB なので,版の系列も分ける +Series = Literal["catalog", "identity"] + +_FILENAME_PATTERN = re.compile(r"^(\d{4})_[a-z0-9_]+\.sql$") + + +class Migration(NamedTuple): + """1 つのマイグレーションファイル.""" + + version: int + name: str + sql: str + + +class MigrationResult(NamedTuple): + """マイグレーションの適用結果.""" + + before: int + after: int + + @property + def applied(self) -> int: + """今回適用したファイルの数.""" + return self.after - self.before + + +def load_migrations(series: Series) -> list[Migration]: + """系列のマイグレーションを番号順に読む. + + Args: + series: `catalog`(目録)または `identity`(対応表). + + Returns: + 版の昇順に並べたマイグレーション. + + Raises: + RuntimeError: ファイル名の形が誤っている,または番号が 1 から連続していない + (パッケージに同梱するファイルの不備なので内部論理エラーとして扱う). + """ + directory = resources.files("tiasdata.catalog") / "migrations" / series + migrations: list[Migration] = [] + for entry in directory.iterdir(): + if not entry.name.endswith(".sql"): + continue + match = _FILENAME_PATTERN.match(entry.name) + if match is None: + raise RuntimeError( + f"マイグレーションのファイル名が NNNN_<内容>.sql でない: {entry.name}" + ) + migrations.append( + Migration(int(match.group(1)), entry.name, entry.read_text(encoding="utf-8")) + ) + migrations.sort(key=lambda m: m.version) + expected = list(range(1, len(migrations) + 1)) + if [m.version for m in migrations] != expected: + raise RuntimeError(f"マイグレーション({series})の番号が 1 から連続していない") + return migrations + + +def latest_version(series: Series) -> int: + """系列の最新の版(同梱したマイグレーションの最大番号)を返す. + + Args: + series: `catalog` または `identity`. + + Returns: + 最新の版.マイグレーションが無ければ 0. + """ + return len(load_migrations(series)) + + +def schema_version(conn: sqlite3.Connection) -> int: + """DB に適用済みのスキーマの版(`PRAGMA user_version`)を返す. + + Args: + conn: DB への接続. + + Returns: + 適用済みの版.未適用なら 0. + """ + 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 connect_latest(catalog_path: Path) -> sqlite3.Connection: + """既存の目録を開き,スキーマが最新の版であることを確かめる(読み書きの前に呼ぶ). + + Args: + catalog_path: 目録(`catalog.sqlite`)のパス. + + Returns: + `connect()` で開いた接続(呼び出し側で閉じる). + + Raises: + ValueError: 目録が無い・開けない,またはスキーマが最新でない(`require_latest` を参照). + """ + conn = connect(catalog_path) + try: + require_latest(conn, "catalog") + except BaseException: + conn.close() + raise + return conn + + +def apply_migrations(conn: sqlite3.Connection, series: Series) -> MigrationResult: + """未適用のマイグレーションを番号順に適用する. + + 1 ファイルを 1 トランザクションで適用し,同じトランザクションの中で `user_version` を + そのファイルの番号に更新する.途中のファイルで失敗したらそのファイルの変更は残らない + (それより前のファイルは適用済みのまま).適用済みの DB に再度呼んでも何もしない. + + Args: + conn: `connect()` で開いた接続. + series: `catalog`(目録)または `identity`(対応表). + + Returns: + 適用前と適用後の版. + + Raises: + ValueError: DB の版がこのライブラリの知る最新の版より新しい(ライブラリが古い). + sqlite3.Error: SQL の実行に失敗した(同梱するマイグレーションの不備). + """ + migrations = load_migrations(series) + before = schema_version(conn) + if before > len(migrations): + raise ValueError( + f"DB({series})のスキーマの版 {before} が,このライブラリの知る最新の版 " + f"{len(migrations)} より新しい.tiasdata を更新する" + ) + for migration in migrations[before:]: + # executescript は直前のトランザクションを確定してから,スクリプトを自動コミットで流す. + # 1 ファイルを 1 トランザクションにするため BEGIN・COMMIT で囲む. + # PRAGMA はプレースホルダを受け付けないため版を埋め込む(値はファイル名から得た整数のみ) + script = ( + f"BEGIN;\n{migration.sql}\nPRAGMA user_version = {int(migration.version)};\nCOMMIT;" + ) + try: + conn.executescript(script) + except sqlite3.Error: + if conn.in_transaction: + conn.rollback() + raise + return MigrationResult(before, schema_version(conn)) diff --git a/src/tiasdata/catalog/migrations/catalog/0001_initial.sql b/src/tiasdata/catalog/migrations/catalog/0001_initial.sql new file mode 100644 index 0000000..675b6b6 --- /dev/null +++ b/src/tiasdata/catalog/migrations/catalog/0001_initial.sql @@ -0,0 +1,285 @@ +-- 目録(catalog.sqlite)の最初のスキーマ(SPEC_02「表の定義」). +-- apply_migrations() が BEGIN・COMMIT で囲み,PRAGMA user_version を更新する. +-- このファイルに BEGIN・COMMIT・PRAGMA user_version を書かない. +-- 適用済みのマイグレーションは編集しない(変更は 0002_*.sql を追加して行う). +-- +-- 書式の制約(GLOB): +-- 日時 YYYY-MM-DDTHH:MM:SSZ(UTC) +-- 日付 YYYY-MM-DD +-- SHA-256 16 進 64 文字(小文字) +-- 研究用 ID S + 6 桁,撮影 ID C + 6 桁 +-- 種類名・データセット名 英小文字で始まり,英小文字・数字・_ だけ +-- +-- NULL の扱い: SQLite の CHECK は結果が NULL のとき違反としない.また INTEGER 以外の +-- PRIMARY KEY は NULL を受け入れる.そのため NULL を許さない列は NOT NULL を明示し, +-- CHECK の条件で NULL になりうる列は IS NOT NULL を明示する. + +-- --------------------------------------------------------------------------- +-- 種類の定義 +-- --------------------------------------------------------------------------- +CREATE TABLE kinds ( + kind TEXT NOT NULL PRIMARY KEY + CHECK (kind GLOB '[a-z]*' AND kind NOT GLOB '*[^a-z0-9_]*'), + category TEXT NOT NULL CHECK (category IN ('asset', 'annotation')), + description TEXT NOT NULL, + value_schema_version INTEGER, + -- 値の型の版は所見の種類だけが持つ + CHECK ( + (category = 'asset' AND value_schema_version IS NULL) + OR (category = 'annotation' AND value_schema_version IS NOT NULL AND value_schema_version >= 1) + ) +); + +-- --------------------------------------------------------------------------- +-- 症例・撮影・ファイル +-- --------------------------------------------------------------------------- +CREATE TABLE subjects ( + subject_id TEXT NOT NULL PRIMARY KEY + CHECK (subject_id GLOB 'S[0-9][0-9][0-9][0-9][0-9][0-9]'), + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + note TEXT +); + +CREATE TABLE captures ( + capture_id TEXT NOT NULL PRIMARY KEY + CHECK (capture_id GLOB 'C[0-9][0-9][0-9][0-9][0-9][0-9]'), + subject_id TEXT NOT NULL REFERENCES subjects (subject_id), + captured_on TEXT + CHECK (captured_on IS NULL OR captured_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + device TEXT, + note TEXT +); + +CREATE INDEX captures_subject_id ON captures (subject_id); + +CREATE TABLE assets ( + sha256 TEXT NOT NULL PRIMARY KEY + CHECK (length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'), + kind TEXT NOT NULL REFERENCES kinds (kind), + capture_id TEXT REFERENCES captures (capture_id), + ext TEXT NOT NULL CHECK (ext <> '' AND ext NOT GLOB '*[^a-z0-9]*'), + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + source TEXT NOT NULL, + source_name TEXT NOT NULL, + registered_at TEXT NOT NULL + CHECK (registered_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z') +); + +CREATE INDEX assets_kind ON assets (kind); +CREATE INDEX assets_capture_id ON assets (capture_id); + +-- ファイルの種類は kinds.category = 'asset' のものに限る(CHECK は他の表を参照できないためトリガで守る) +CREATE TRIGGER assets_kind_category_insert +BEFORE INSERT ON assets +WHEN (SELECT category FROM kinds WHERE kind = NEW.kind) IS NOT 'asset' +BEGIN + SELECT RAISE(ABORT, 'assets.kind must be a kind with category asset'); +END; + +CREATE TRIGGER assets_kind_category_update +BEFORE UPDATE OF kind ON assets +WHEN (SELECT category FROM kinds WHERE kind = NEW.kind) IS NOT 'asset' +BEGIN + SELECT RAISE(ABORT, 'assets.kind must be a kind with category asset'); +END; + +-- --------------------------------------------------------------------------- +-- 由来 +-- --------------------------------------------------------------------------- +CREATE TABLE derivations ( + derivation_id INTEGER PRIMARY KEY, + tool TEXT NOT NULL, + tool_version TEXT NOT NULL, + mode TEXT, + performed_at TEXT + CHECK (performed_at IS NULL OR performed_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + note TEXT +); + +CREATE TABLE derivation_inputs ( + derivation_id INTEGER NOT NULL REFERENCES derivations (derivation_id), + sha256 TEXT NOT NULL REFERENCES assets (sha256), + role TEXT NOT NULL, + PRIMARY KEY (derivation_id, sha256, role) +); + +CREATE INDEX derivation_inputs_sha256 ON derivation_inputs (sha256); + +CREATE TABLE derivation_outputs ( + derivation_id INTEGER NOT NULL REFERENCES derivations (derivation_id), + sha256 TEXT NOT NULL REFERENCES assets (sha256), + role TEXT NOT NULL, + PRIMARY KEY (derivation_id, sha256, role) +); + +CREATE INDEX derivation_outputs_sha256 ON derivation_outputs (sha256); + +-- --------------------------------------------------------------------------- +-- 所見 +-- --------------------------------------------------------------------------- +CREATE TABLE annotations ( + annotation_id INTEGER PRIMARY KEY, + kind TEXT NOT NULL REFERENCES kinds (kind), + capture_id TEXT REFERENCES captures (capture_id), + sha256 TEXT REFERENCES assets (sha256), + value TEXT NOT NULL CHECK (json_valid(value)), + annotator TEXT, + source TEXT NOT NULL, + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + -- 訂正は後から追加した行を指す + superseded_by INTEGER REFERENCES annotations (annotation_id) + CHECK (superseded_by IS NULL OR superseded_by > annotation_id), + -- 撮影に付くかファイルに付くかのどちらか一方だけ + CHECK ((capture_id IS NULL) <> (sha256 IS NULL)) +); + +CREATE INDEX annotations_kind ON annotations (kind); +CREATE INDEX annotations_capture_id ON annotations (capture_id); +CREATE INDEX annotations_sha256 ON annotations (sha256); + +CREATE TRIGGER annotations_kind_category_insert +BEFORE INSERT ON annotations +WHEN (SELECT category FROM kinds WHERE kind = NEW.kind) IS NOT 'annotation' +BEGIN + SELECT RAISE(ABORT, 'annotations.kind must be a kind with category annotation'); +END; + +-- 所見の行は書き換えない.許すのは superseded_by を NULL から一度だけ埋めることだけ +-- (過去の版が古い行を参照し続けられるようにするため.SPEC_02「annotations」) +CREATE TRIGGER annotations_no_update +BEFORE UPDATE ON annotations +WHEN OLD.superseded_by IS NOT NULL + OR NEW.superseded_by IS NULL + OR NEW.annotation_id IS NOT OLD.annotation_id + OR NEW.kind IS NOT OLD.kind + OR NEW.capture_id IS NOT OLD.capture_id + OR NEW.sha256 IS NOT OLD.sha256 + OR NEW.value IS NOT OLD.value + OR NEW.annotator IS NOT OLD.annotator + OR NEW.source IS NOT OLD.source + OR NEW.created_at IS NOT OLD.created_at +BEGIN + SELECT RAISE(ABORT, 'annotations are immutable except setting superseded_by once'); +END; + +CREATE TRIGGER annotations_no_delete +BEFORE DELETE ON annotations +BEGIN + SELECT RAISE(ABORT, 'annotations must not be deleted'); +END; + +-- --------------------------------------------------------------------------- +-- データセット +-- --------------------------------------------------------------------------- +CREATE TABLE datasets ( + name TEXT NOT NULL PRIMARY KEY + CHECK (name GLOB '[a-z]*' AND name NOT GLOB '*[^a-z0-9_]*'), + description TEXT, + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z') +); + +CREATE TABLE dataset_versions ( + name TEXT NOT NULL REFERENCES datasets (name), + version INTEGER NOT NULL CHECK (version >= 1), + definition TEXT NOT NULL, + hub_commit TEXT NOT NULL, + manifest_sha256 TEXT NOT NULL + CHECK (length(manifest_sha256) = 64 AND manifest_sha256 NOT GLOB '*[^0-9a-f]*'), + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + note TEXT, + PRIMARY KEY (name, version) +); + +CREATE TABLE dataset_members ( + name TEXT NOT NULL, + version INTEGER NOT NULL, + sha256 TEXT NOT NULL REFERENCES assets (sha256), + PRIMARY KEY (name, version, sha256), + FOREIGN KEY (name, version) REFERENCES dataset_versions (name, version) +); + +CREATE INDEX dataset_members_sha256 ON dataset_members (sha256); + +CREATE TABLE dataset_member_annotations ( + name TEXT NOT NULL, + version INTEGER NOT NULL, + sha256 TEXT NOT NULL, + annotation_id INTEGER NOT NULL REFERENCES annotations (annotation_id), + PRIMARY KEY (name, version, sha256, annotation_id), + FOREIGN KEY (name, version, sha256) REFERENCES dataset_members (name, version, sha256) +); + +CREATE INDEX dataset_member_annotations_annotation_id + ON dataset_member_annotations (annotation_id); + +-- 凍結した版とその構成は書き換え・削除しない(SPEC_02「datasets」) +CREATE TRIGGER dataset_versions_no_update +BEFORE UPDATE ON dataset_versions +BEGIN + SELECT RAISE(ABORT, 'dataset_versions are frozen'); +END; + +CREATE TRIGGER dataset_versions_no_delete +BEFORE DELETE ON dataset_versions +BEGIN + SELECT RAISE(ABORT, 'dataset_versions are frozen'); +END; + +CREATE TRIGGER dataset_members_no_update +BEFORE UPDATE ON dataset_members +BEGIN + SELECT RAISE(ABORT, 'dataset_members are frozen'); +END; + +CREATE TRIGGER dataset_members_no_delete +BEFORE DELETE ON dataset_members +BEGIN + SELECT RAISE(ABORT, 'dataset_members are frozen'); +END; + +CREATE TRIGGER dataset_member_annotations_no_update +BEFORE UPDATE ON dataset_member_annotations +BEGIN + SELECT RAISE(ABORT, 'dataset_member_annotations are frozen'); +END; + +CREATE TRIGGER dataset_member_annotations_no_delete +BEFORE DELETE ON dataset_member_annotations +BEGIN + SELECT RAISE(ABORT, 'dataset_member_annotations are frozen'); +END; + +-- --------------------------------------------------------------------------- +-- 受け渡しの記録 +-- --------------------------------------------------------------------------- +CREATE TABLE deliveries ( + delivery_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + version INTEGER NOT NULL, + server TEXT NOT NULL, + path TEXT NOT NULL, + delivered_at TEXT NOT NULL + CHECK (delivered_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + verified INTEGER NOT NULL CHECK (verified IN (0, 1)), + FOREIGN KEY (name, version) REFERENCES dataset_versions (name, version) +); + +CREATE INDEX deliveries_name_version ON deliveries (name, version); + +-- --------------------------------------------------------------------------- +-- 最初に用意する種類(SPEC_02「kinds」) +-- --------------------------------------------------------------------------- +INSERT INTO kinds (kind, category, description, value_schema_version) VALUES + ('tongue_raw', 'asset', '撮影した舌画像(補正前)', NULL), + ('color_chart', 'asset', 'カラーチャート画像', NULL), + ('tongue_crop_masked', 'asset', + '色補正後の画像を舌の枠で切り出し,マスク外を黒 (0,0,0) にしたもの(DeepTIAS Image&Calib の 2-cropped × 3-resized)', + NULL), + ('tongue_mask', 'asset', '舌のマスク画像', NULL), + ('tongue_color', 'annotation', + '舌色 5 分類+程度.値は {"label": pale|light_red|red|dark_red|purple, "severity": strong|mild|normal}.light_red のときだけ normal', + 1); diff --git a/src/tiasdata/catalog/migrations/identity/0001_initial.sql b/src/tiasdata/catalog/migrations/identity/0001_initial.sql new file mode 100644 index 0000000..ecfd561 --- /dev/null +++ b/src/tiasdata/catalog/migrations/identity/0001_initial.sql @@ -0,0 +1,15 @@ +-- 対応表(identity.sqlite)の最初のスキーマ(SPEC_02「対応表」). +-- 目録とは別の DB なので,版(PRAGMA user_version)の系列も目録とは別にする. +-- apply_migrations() が BEGIN・COMMIT で囲み,PRAGMA user_version を更新する. +-- 適用済みのマイグレーションは編集しない. + +CREATE TABLE identity_map ( + -- 実際の患者 ID(取り込み元が持つもの) + -- INTEGER 以外の PRIMARY KEY は NULL を受け入れ,CHECK も NULL を違反としないため NOT NULL を明示する + patient_key TEXT NOT NULL PRIMARY KEY CHECK (patient_key <> ''), + -- 研究用 ID.1 つの症例は 1 人の患者にだけ対応する + subject_id TEXT NOT NULL UNIQUE + CHECK (subject_id GLOB 'S[0-9][0-9][0-9][0-9][0-9][0-9]'), + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z') +); diff --git a/src/tiasdata/catalog/queries.py b/src/tiasdata/catalog/queries.py new file mode 100644 index 0000000..9d44f4b --- /dev/null +++ b/src/tiasdata/catalog/queries.py @@ -0,0 +1,123 @@ +"""目録の集計(読み取りのみ).""" + +import sqlite3 + + +def count_kinds(conn: sqlite3.Connection) -> dict[str, int]: + """種類の定義の件数を分類(`asset`・`annotation`)ごとに数える. + + Args: + conn: 目録への接続(マイグレーション適用済み). + + Returns: + 分類 → 件数. + """ + 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/catalog/versions.py b/src/tiasdata/catalog/versions.py new file mode 100644 index 0000000..da5e33b --- /dev/null +++ b/src/tiasdata/catalog/versions.py @@ -0,0 +1,290 @@ +"""データセットと版(`datasets`・`dataset_versions`・`dataset_members`・`dataset_member_annotations`). + +版とその構成は INSERT だけを行い,UPDATE・DELETE しない(スキーマのトリガでも拒否される. +SPEC_02「スキーマで強制する規則」).そのため版の行は manifest の SHA-256 を計算してから +構成と一緒に INSERT する. +""" + +import sqlite3 + +from pydantic import BaseModel, ConfigDict + + +class _Row(BaseModel): + model_config = ConfigDict(frozen=True) + + +class MemberAssetRow(_Row): + """版に入れる(入れた)ファイルと,その撮影・症例. + + Attributes: + sha256: ファイルの SHA-256. + ext: 拡張子. + kind: ファイルの種類. + capture_id: 撮影 ID(撮影にひも付かないファイルは None). + subject_id: 研究用の症例 ID(撮影が無ければ None). + """ + + sha256: str + ext: str + kind: str + capture_id: str | None + subject_id: str | None + + +class AnnotationRow(_Row): + """所見の行(版の評価に使う列だけ). + + Attributes: + annotation_id: 所見の ID. + kind: 所見の種類. + capture_id: 撮影に付く所見の撮影 ID. + sha256: ファイルに付く所見のファイルの SHA-256. + value: 値(JSON の文字列). + """ + + annotation_id: int + kind: str + capture_id: str | None + sha256: str | None + value: str + + +class MemberAnnotationRow(_Row): + """版の構成で,ファイルに対して採用した所見.""" + + member_sha256: str + annotation: AnnotationRow + + +class DatasetRecord(_Row): + """`datasets` の 1 行.""" + + name: str + description: str | None + created_at: str + + +class VersionRecord(_Row): + """`dataset_versions` の 1 行.""" + + name: str + version: int + definition: str + hub_commit: str + manifest_sha256: str + created_at: str + note: str | None + + +class VersionSummary(_Row): + """版の行と構成の件数. + + Attributes: + record: 版の行. + members: 構成のファイルの数. + member_annotations: 採用した所見の数. + """ + + record: VersionRecord + members: int + member_annotations: int + + +_VERSION_COLUMNS = "name, version, definition, hub_commit, manifest_sha256, created_at, note" + + +def _version(row: tuple) -> VersionRecord: + return VersionRecord( + name=row[0], + version=row[1], + definition=row[2], + hub_commit=row[3], + manifest_sha256=row[4], + created_at=row[5], + note=row[6], + ) + + +def _annotation(row: tuple) -> AnnotationRow: + return AnnotationRow( + annotation_id=row[0], kind=row[1], capture_id=row[2], sha256=row[3], value=row[4] + ) + + +def kind_categories(conn: sqlite3.Connection) -> dict[str, str]: + """種類名 → 分類(`asset`・`annotation`).""" + rows = conn.execute("SELECT kind, category FROM kinds ORDER BY kind") + return {kind: category for kind, category in rows} + + +def list_assets_of_kind(conn: sqlite3.Connection, kind: str) -> list[MemberAssetRow]: + """種類が一致するファイルと,その撮影・症例を SHA-256 の順に返す.""" + rows = conn.execute( + "SELECT a.sha256, a.ext, a.kind, a.capture_id, c.subject_id FROM assets AS a" + " LEFT JOIN captures AS c ON c.capture_id = a.capture_id" + " WHERE a.kind = ? ORDER BY a.sha256", + (kind,), + ) + return [ + MemberAssetRow(sha256=r[0], ext=r[1], kind=r[2], capture_id=r[3], subject_id=r[4]) + for r in rows + ] + + +def list_valid_annotations(conn: sqlite3.Connection, kind: str) -> list[AnnotationRow]: + """種類が一致する有効な所見(訂正されていないもの)を ID の順に返す.""" + rows = conn.execute( + "SELECT annotation_id, kind, capture_id, sha256, value FROM annotations" + " WHERE kind = ? AND superseded_by IS NULL ORDER BY annotation_id", + (kind,), + ) + return [_annotation(r) for r in rows] + + +def get_dataset(conn: sqlite3.Connection, name: str) -> DatasetRecord | None: + """データセットの行を引く(無ければ None).""" + row = conn.execute( + "SELECT name, description, created_at FROM datasets WHERE name = ?", (name,) + ).fetchone() + if row is None: + return None + return DatasetRecord(name=row[0], description=row[1], created_at=row[2]) + + +def list_datasets(conn: sqlite3.Connection) -> list[DatasetRecord]: + """データセットの行を名前の順に返す.""" + rows = conn.execute("SELECT name, description, created_at FROM datasets ORDER BY name") + return [DatasetRecord(name=r[0], description=r[1], created_at=r[2]) for r in rows] + + +def insert_dataset( + conn: sqlite3.Connection, name: str, description: str | None, created_at: str +) -> None: + """データセットの行を追加する.""" + conn.execute( + "INSERT INTO datasets (name, description, created_at) VALUES (?, ?, ?)", + (name, description, created_at), + ) + + +def get_version(conn: sqlite3.Connection, name: str, version: int) -> VersionRecord | None: + """版の行を引く(無ければ None).""" + row = conn.execute( + f"SELECT {_VERSION_COLUMNS} FROM dataset_versions WHERE name = ? AND version = ?", + (name, version), + ).fetchone() + return None if row is None else _version(row) + + +def get_latest_version(conn: sqlite3.Connection, name: str) -> VersionRecord | None: + """データセットの最新の版の行を引く(版が無ければ None).""" + row = conn.execute( + f"SELECT {_VERSION_COLUMNS} FROM dataset_versions WHERE name = ?" + " ORDER BY version DESC LIMIT 1", + (name,), + ).fetchone() + return None if row is None else _version(row) + + +def list_versions(conn: sqlite3.Connection, name: str | None = None) -> list[VersionSummary]: + """版の行と構成の件数を,名前・版の順に返す. + + Args: + conn: 目録への接続. + name: データセット名(省略時はすべてのデータセット). + + Returns: + 版ごとの行と件数. + """ + rows = conn.execute( + "SELECT v.name, v.version, v.definition, v.hub_commit, v.manifest_sha256," + " v.created_at, v.note," + " (SELECT COUNT(*) FROM dataset_members AS m" + " WHERE m.name = v.name AND m.version = v.version)," + " (SELECT COUNT(*) FROM dataset_member_annotations AS d" + " WHERE d.name = v.name AND d.version = v.version)" + " FROM dataset_versions AS v" + " WHERE ? IS NULL OR v.name = ?" + " ORDER BY v.name, v.version", + (name, name), + ) + return [ + VersionSummary(record=_version(r[:7]), members=r[7], member_annotations=r[8]) for r in rows + ] + + +def version_members(conn: sqlite3.Connection, name: str, version: int) -> list[MemberAssetRow]: + """版の構成のファイルと,その撮影・症例を SHA-256 の順に返す. + + 目録にファイルが無い構成の行は含めない(`check` が manifest の不一致として見つける). + """ + rows = conn.execute( + "SELECT a.sha256, a.ext, a.kind, a.capture_id, c.subject_id" + " FROM dataset_members AS m" + " JOIN assets AS a ON a.sha256 = m.sha256" + " LEFT JOIN captures AS c ON c.capture_id = a.capture_id" + " WHERE m.name = ? AND m.version = ? ORDER BY a.sha256", + (name, version), + ) + return [ + MemberAssetRow(sha256=r[0], ext=r[1], kind=r[2], capture_id=r[3], subject_id=r[4]) + for r in rows + ] + + +def version_member_annotations( + conn: sqlite3.Connection, name: str, version: int +) -> list[MemberAnnotationRow]: + """版の構成で採用した所見を,ファイルの SHA-256・所見の ID の順に返す. + + 訂正された所見(`superseded_by` あり)も版を作った時点の値のまま返す. + """ + rows = conn.execute( + "SELECT d.sha256, a.annotation_id, a.kind, a.capture_id, a.sha256, a.value" + " FROM dataset_member_annotations AS d" + " JOIN annotations AS a ON a.annotation_id = d.annotation_id" + " WHERE d.name = ? AND d.version = ? ORDER BY d.sha256, a.annotation_id", + (name, version), + ) + return [MemberAnnotationRow(member_sha256=r[0], annotation=_annotation(r[1:])) for r in rows] + + +def insert_version( + conn: sqlite3.Connection, + record: VersionRecord, + members: list[str], + member_annotations: list[tuple[str, int]], +) -> None: + """版の行と構成を追加する(呼び出し側のトランザクションの中で呼ぶ). + + Args: + conn: 目録への接続. + record: 版の行(manifest の SHA-256 を計算済み). + members: 構成のファイルの SHA-256. + member_annotations: 採用した所見(ファイルの SHA-256, 所見の ID). + """ + conn.execute( + f"INSERT INTO dataset_versions ({_VERSION_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + record.name, + record.version, + record.definition, + record.hub_commit, + record.manifest_sha256, + record.created_at, + record.note, + ), + ) + conn.executemany( + "INSERT INTO dataset_members (name, version, sha256) VALUES (?, ?, ?)", + [(record.name, record.version, sha256) for sha256 in members], + ) + conn.executemany( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES (?, ?, ?, ?)", + [ + (record.name, record.version, sha256, annotation_id) + for sha256, annotation_id in member_annotations + ], + ) diff --git a/src/tiasdata/check.py b/src/tiasdata/check.py new file mode 100644 index 0000000..c4227c1 --- /dev/null +++ b/src/tiasdata/check.py @@ -0,0 +1,100 @@ +"""目録とストアの点検(`tiasdata check`.SPEC_03「CLI」). + +問題を見つけても止めずに全体を数え,件数だけを返す(error-handling.md「データの問題は止める」の例外). +対応表(`identity.sqlite`)は開かない. +""" + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +from tiasdata import catalog, datasets, 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: 凍結した版の構成のうち参照先が欠けている行の数. + dataset_versions: 凍結した版の数(manifest を作り直して照合した数). + manifest_mismatch: 構成から作り直した manifest の SHA-256 が版の記録と一致しない + (または作り直せない)版の数. + """ + + 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 + dataset_versions: int + manifest_mismatch: 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 + + self.manifest_mismatch + ) + + +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) + dataset_versions, manifest_mismatch = datasets.count_manifest_mismatches(conn) + 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), + dataset_versions=dataset_versions, + manifest_mismatch=manifest_mismatch, + ) diff --git a/src/tiasdata/cli.py b/src/tiasdata/cli.py index c858750..6a2eb69 100644 --- a/src/tiasdata/cli.py +++ b/src/tiasdata/cli.py @@ -1,16 +1,369 @@ -"""tiasdata コマンドのエントリポイント(サブコマンドは実装フェーズで追加する).""" +"""tiasdata コマンドのエントリポイント.""" import argparse +import sys +from contextlib import closing -from tiasdata import __version__ +from pydantic import ValidationError + +from tiasdata import __version__, catalog, datasets, export +from tiasdata.check import CheckResult, run_check +from tiasdata.config import load_config +from tiasdata.importers import ImportResult, run_import +from tiasdata.manifest import validation_error_details +from tiasdata.stats import CatalogStats, collect_stats + + +def _cmd_init(args: argparse.Namespace) -> int: + config = load_config() + paths = config.catalog + for directory in (paths.root, paths.store_dir, paths.bundles_dir, paths.backups_dir): + directory.mkdir(parents=True, exist_ok=True) + with closing(catalog.connect(paths.catalog_path, create=True)) as conn: + catalog_result = catalog.apply_migrations(conn, "catalog") + kinds = catalog.count_kinds(conn) + + paths.identity.parent.mkdir(parents=True, exist_ok=True) + with closing(catalog.connect(paths.identity, create=True)) as conn: + identity_result = catalog.apply_migrations(conn, "identity") + + # 出力にはデータの場所(パス)を出さない.件数と版だけを示す(SPEC_03「出力の規則」) + _print_migration("目録", catalog_result) + _print_migration("対応表", identity_result) + kinds_summary = "・".join(f"{category} {count} 件" for category, count in kinds.items()) + print(f"種類: {kinds_summary}") + 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 _cmd_stats(args: argparse.Namespace) -> int: + config = load_config() + with closing(catalog.connect_latest(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(catalog.connect_latest(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} 件") + print( + f"凍結した版の manifest の不一致: {result.manifest_mismatch} 件" + f"(版 {result.dataset_versions} 件)" + ) + + +# manifest の SHA-256 は一覧では先頭だけを出す(照合には `show` の全桁を使う) +_SHORT_SHA256 = 12 + + +def _print_version_line(summary: datasets.VersionSummary, *, full: bool) -> None: + record = summary.record + digest = record.manifest_sha256 if full else record.manifest_sha256[:_SHORT_SHA256] + line = ( + f" v{record.version}: ファイル {summary.members} 件・所見 {summary.member_annotations} 件" + f"・作成 {record.created_at}・hub_commit {record.hub_commit[:_SHORT_SHA256]}" + f"・manifest {digest}" + ) + if full and record.note: + line += f"・備考 {' '.join(record.note.splitlines())}" + print(line) + + +def _cmd_dataset_list(args: argparse.Namespace) -> int: + config = load_config() + overviews = datasets.run_list(config) + print(f"データセット: {len(overviews)} 件") + for overview in overviews: + defined = "" if overview.defined else "(定義ファイルなし)" + if not overview.versions: + print(f"{overview.name}{defined}: 版なし") + continue + latest = overview.versions[-1] + print( + f"{overview.name}{defined}: 版 {len(overview.versions)} 件" + f"(最新 v{latest.record.version}・ファイル {latest.members} 件)" + ) + return 0 + + +def _cmd_dataset_show(args: argparse.Namespace) -> int: + config = load_config() + overview = datasets.run_show(config, args.name) + defined = "" if overview.defined else "(定義ファイルなし)" + print(f"データセット: {overview.name}{defined}") + if overview.description: + print(f"説明: {overview.description}") + print(f"版: {len(overview.versions)} 件") + for summary in overview.versions: + _print_version_line(summary, full=True) + return 0 + + +def _cmd_dataset_freeze(args: argparse.Namespace) -> int: + config = load_config() + result = datasets.run_freeze(config, args.name, note=args.note) + if result.created: + print(f"データセット {result.name} の版 {result.version} を作った") + else: + print( + f"データセット {result.name}: 直前の版 {result.version} と構成・定義が同じなので" + "版を作らなかった" + ) + print( + f"ファイル {result.members} 件・所見 {result.member_annotations} 件" + f"(種類が一致したファイル {result.candidates} 件・必須の所見が無く除いた " + f"{result.candidates - result.members} 件)" + ) + print(f"manifest の SHA-256: {result.manifest_sha256}") + return 0 + + +def _cmd_export(args: argparse.Namespace) -> int: + config = load_config() + result = export.run_export(config, args.name, args.dataset_version) + # パスは出さない(SPEC_03「出力の規則」).書き出したか,既にあったものを照合して使ったか + if result.reused: + print( + f"データセット {result.name} の版 {result.version}: 既にあったバンドルを照合して使った" + "(上書きしていない)" + ) + else: + print(f"データセット {result.name} の版 {result.version} のバンドルを書き出した") + print(f"ファイル {result.files} 件(合計 {result.size_bytes} バイト)") + print(f"manifest の SHA-256: {result.manifest_sha256}") + return 0 + + +def _print_migration(label: str, result: catalog.MigrationResult) -> None: + if result.applied: + print(f"{label}: スキーマの版 {result.before} → {result.after}({result.applied} 件適用)") + else: + print(f"{label}: スキーマの版 {result.after}(最新.適用なし)") + + +def _format_validation_error(e: ValidationError) -> str: + # 入力値(パス等)を出さず,キーの位置と理由だけを 1 行にまとめる + return f"設定ファイルの検証に失敗した({e.error_count()} 件): {validation_error_details(e)}" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="tiasdata", description="舌診研究の共有データ基盤") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + subparsers = parser.add_subparsers(dest="command", metavar="<コマンド>") + + init = subparsers.add_parser( + "init", + help="設定ファイルに従って目録・ストアを作り,マイグレーションを適用する", + description="設定ファイルに従って目録・ストア・対応表を作り,未適用のマイグレーションを適用する." + "既にある場合は不足分だけ適用する", + ) + 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) + + dataset = subparsers.add_parser( + "dataset", + help="データセットと版の一覧・版の作成", + description="データセット(datasets/<名前>.toml の定義)の版の一覧を表示し,新しい版を作る", + ) + dataset_commands = dataset.add_subparsers(dest="dataset_command", metavar="<操作>") + dataset.set_defaults(handler=lambda _args: _print_help(dataset)) + + dataset_list = dataset_commands.add_parser( + "list", + help="データセットと版の一覧・件数", + description="目録のデータセットと定義ファイルのあるデータセットの,版の数・最新の版の件数を表示する", + ) + dataset_list.set_defaults(handler=_cmd_dataset_list) + + dataset_show = dataset_commands.add_parser( + "show", + help="データセットの版ごとの件数・作成日時・hub_commit・manifest の SHA-256", + description="データセットの版ごとの件数・作成日時・hub_commit・manifest の SHA-256・" + "備考を表示する", + ) + dataset_show.add_argument("name", metavar="<名前>", help="データセット名") + dataset_show.set_defaults(handler=_cmd_dataset_show) + + dataset_freeze = dataset_commands.add_parser( + "freeze", + help="新しい版を作る(構成と定義が直前の版と同じなら作らない)", + description="datasets/<名前>.toml の定義を目録に当てはめて新しい版を作る." + "構成(ファイルと採用した所見)と定義が直前の版と同じなら作らない." + "本リポジトリに未コミットの変更があると停止する", + ) + dataset_freeze.add_argument("name", metavar="<名前>", help="データセット名") + dataset_freeze.add_argument("--note", metavar="TEXT", help="版の備考") + dataset_freeze.set_defaults(handler=_cmd_dataset_freeze) + + export_ = subparsers.add_parser( + "export", + help="凍結した版のバンドルを bundles/ に書き出す", + description="凍結した版の manifest.csv・dataset.json・SHA256SUMS・files/ を " + "bundles/<名前>/v<版>/ に書き出す.構成から作り直した manifest が版の記録と一致しなければ" + "停止する.既にあれば上書きせず,照合して使う(食い違えば停止する)", + ) + export_.add_argument("name", metavar="<名前>", help="データセット名") + export_.add_argument( + "--version", + dest="dataset_version", + metavar="<版>", + type=int, + required=True, + help="書き出す版(必須)", + ) + export_.set_defaults(handler=_cmd_export) + return parser + + +def _print_help(parser: argparse.ArgumentParser) -> int: + parser.print_help() + return 0 + + +def _use_utf8_output() -> None: + # Windows ではパイプ・SSH 越しの出力が cp932 になり,手元の UTF-8 端末で化けるため + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8") def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="tiasdata", description="舌診研究の共有データ基盤") - parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - parser.parse_args(argv) - parser.print_help() - return 0 + """CLI を実行する. + + Args: + argv: 引数(省略時は `sys.argv[1:]`). + + Returns: + 終了コード(0: 成功,1: 入力・データの問題). + """ + _use_utf8_output() + parser = _build_parser() + args = parser.parse_args(argv) + handler = getattr(args, "handler", None) + if handler is None: + parser.print_help() + return 0 + try: + return handler(args) + except ValidationError as e: + # ValidationError は ValueError の派生だが,既定の表示は複数行で入力値を含むため + # 個別に整形する + print(f"エラー: {_format_validation_error(e)}", file=sys.stderr) + return 1 + except ValueError as e: + message = " ".join(str(e).splitlines()) + print(f"エラー: {message}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/src/tiasdata/config.py b/src/tiasdata/config.py new file mode 100644 index 0000000..eb38a1e --- /dev/null +++ b/src/tiasdata/config.py @@ -0,0 +1,174 @@ +"""設定ファイル(TOML)の読み込みと検証. + +データの場所(目録・ストア・取り込み元・計算サーバ)はコードに書かず, +研究室 PC のユーザー領域に置いた設定ファイルから受け取る(SPEC_01「設定ファイル」). +""" + +import os +import tomllib +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator, model_validator + +CONFIG_ENV_VAR = "TIASDATA_CONFIG" + +CATALOG_FILENAME = "catalog.sqlite" + + +def default_config_path() -> Path: + """設定ファイルの場所を返す. + + 環境変数 `TIASDATA_CONFIG` があればそのパス,無ければ `~/.config/tiasdata/config.toml` + (Windows でも同じ相対位置.`%USERPROFILE%\\.config\\tiasdata\\config.toml`). + + Returns: + 設定ファイルのパス(存在するかは確かめない). + """ + env = os.environ.get(CONFIG_ENV_VAR) + if env: + return Path(env).expanduser() + return Path.home() / ".config" / "tiasdata" / "config.toml" + + +def _resolve_path(value: Path, info: ValidationInfo) -> Path: + # 相対パスは設定ファイルのあるフォルダを基準にする(作業フォルダに依存させないため) + path = value.expanduser() + base_dir = info.context.get("base_dir") if info.context else None + if base_dir is not None and not path.is_absolute(): + path = Path(base_dir) / path + return path + + +class _StrictModel(BaseModel): + # 未知のキーは拒否する(綴りの誤りで設定が黙って無視されるのを防ぐ) + model_config = ConfigDict(extra="forbid", frozen=True) + + +class CatalogConfig(_StrictModel): + """`[catalog]`: 目録・ストア・対応表の場所. + + Attributes: + root: `catalog.sqlite`・`store/`・`bundles/`・`backups/` を置くフォルダ. + identity: 対応表(`identity.sqlite`)のパス.`root` とは別のフォルダに置く. + """ + + root: Path + identity: Path + + @field_validator("root", "identity") + @classmethod + def _resolve(cls, value: Path, info: ValidationInfo) -> Path: + return _resolve_path(value, info) + + @model_validator(mode="after") + def _identity_outside_root(self) -> "CatalogConfig": + # 対応表は目録とは別のフォルダに置く(SPEC_01「構成要素」). + # 目録のバックアップ・受け渡しに対応表が紛れ込まないようにするため + root = self.root.resolve() + identity_dir = self.identity.resolve().parent + if identity_dir == root or root in identity_dir.parents: + raise ValueError("catalog.identity は catalog.root の外のフォルダに置く") + return self + + @property + def catalog_path(self) -> Path: + """目録(`catalog.sqlite`)のパス.""" + return self.root / CATALOG_FILENAME + + @property + def store_dir(self) -> Path: + """ストアのフォルダ.""" + return self.root / "store" + + @property + def bundles_dir(self) -> Path: + """書き出したバンドルのフォルダ.""" + return self.root / "bundles" + + @property + def backups_dir(self) -> Path: + """目録のバックアップのフォルダ.""" + return self.root / "backups" + + +class SourceConfig(_StrictModel): + """`[sources.<名前>]`: 取り込み元. + + Attributes: + path: 取り込み元のフォルダ(読むだけ). + importer: 取り込み元の形(`importers/` のモジュール名.例: `tongue_color_folders`). + patient_map: 画像の番号 → 患者 ID の対応表のファイル(任意). + """ + + path: Path + importer: str + patient_map: Path | None = None + + @field_validator("path", "patient_map") + @classmethod + def _resolve(cls, value: Path | None, info: ValidationInfo) -> Path | None: + if value is None: + return None + return _resolve_path(value, info) + + +class ServerConfig(_StrictModel): + """`[servers.<名前>]`: 計算サーバ(受け渡し先). + + Attributes: + host: SSH の接続先(`user@host`). + key: SSH の秘密鍵のパス(研究室 PC 上). + root: サーバ上のバンドルの置き場所.サーバ側で解釈するため `~` は展開しない. + """ + + host: str + key: Path + root: str + + @field_validator("key") + @classmethod + def _resolve(cls, value: Path, info: ValidationInfo) -> Path: + return _resolve_path(value, info) + + +class Config(_StrictModel): + """設定ファイル全体. + + Attributes: + catalog: 目録・ストア・対応表の場所. + sources: 取り込み元(名前 → 設定). + servers: 計算サーバ(名前 → 設定). + """ + + catalog: CatalogConfig + sources: dict[str, SourceConfig] = {} + servers: dict[str, ServerConfig] = {} + + +def load_config(path: Path | None = None) -> Config: + """設定ファイルを読み込んで検証する. + + Args: + path: 設定ファイルのパス.省略時は `default_config_path()`. + + Returns: + 検証済みの設定.相対パスは設定ファイルのあるフォルダ基準の絶対パスに,`~` は展開済み. + + Raises: + ValueError: 設定ファイルが無い,または TOML として読めない. + pydantic.ValidationError: 未知のキー・必須キーの欠落・値の形の誤り. + """ + config_path = path if path is not None else default_config_path() + try: + raw = config_path.read_bytes() + except FileNotFoundError: + raise ValueError( + f"設定ファイルが見つからない: {config_path}" + f"(環境変数 {CONFIG_ENV_VAR} で場所を指定できる)" + ) from None + try: + data = tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: + raise ValueError(f"設定ファイルを TOML として読めない: {e}") from e + base_dir = config_path.expanduser().resolve().parent + return Config.model_validate(data, context={"base_dir": base_dir}) diff --git a/src/tiasdata/datasets.py b/src/tiasdata/datasets.py new file mode 100644 index 0000000..0ff1f7e --- /dev/null +++ b/src/tiasdata/datasets.py @@ -0,0 +1,735 @@ +"""データセットの定義の読み込み・評価と,版の作成(SPEC_03「データセットの定義」「版の作成」). + +定義(`datasets/<名前>.toml`)は個人情報を含まないためリポジトリで管理する(PR で変更する). +実行時は,このパッケージの位置からリポジトリのルートを辿って `datasets/` を見つける +(`importers.holds` の保留リストと同じ.版の作成はリポジトリの clone から実行する). + +版とその構成は凍結される(UPDATE・DELETE しない).構成(ファイルと採用した所見の ID)と +定義の内容が直前の版と同じなら新しい版を作らない. +""" + +import json +import sqlite3 +import tomllib +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence +from contextlib import closing +from datetime import datetime +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from tiasdata import catalog, kinds, repo +from tiasdata.catalog import versions +from tiasdata.catalog.records import utc_timestamp +from tiasdata.catalog.versions import ( + AnnotationRow, + MemberAnnotationRow, + MemberAssetRow, + VersionRecord, + VersionSummary, +) +from tiasdata.config import Config +from tiasdata.manifest import ( + DATASET_NAME_PATTERN, + ManifestEntry, + build_manifest, + manifest_sha256, + validation_error_details, +) + +DATASETS_DIRNAME = "datasets" +DEFINITION_SUFFIX = ".toml" +# エラーメッセージに出す例の最大件数 +_MAX_EXAMPLES = 5 +# エラーメッセージでファイルを示す SHA-256 の桁数(位置の特定には先頭で足りる) +_SHORT_SHA256 = 12 + + +# --------------------------------------------------------------------------- +# 定義 +# --------------------------------------------------------------------------- +class _StrictModel(BaseModel): + # 未知のキーは拒否する(綴りの誤りで条件・列が黙って無視されるのを防ぐ) + model_config = ConfigDict(extra="forbid", frozen=True) + + +def _unique(values: list[str], label: str) -> list[str]: + duplicated = sorted({v for v in values if values.count(v) > 1}) + if duplicated: + raise ValueError(f"{label}に同じ名前が 2 回以上ある: {', '.join(duplicated)}") + return values + + +class SelectSpec(_StrictModel): + """`[select]`: 集めるファイルの条件. + + Attributes: + asset_kind: 集めるファイルの種類. + require: この所見(撮影に付いたものを含む)を持つファイルだけを集める. + """ + + asset_kind: str = Field(min_length=1) + require: list[str] = [] + + @field_validator("require") + @classmethod + def _unique_require(cls, value: list[str]) -> list[str]: + return _unique(value, "require ") + + +class DatasetDefinition(_StrictModel): + """データセットの定義(`datasets/<名前>.toml`). + + Attributes: + name: データセット名(定義ファイル名と同じ). + description: 説明. + select: 集めるファイルの条件. + columns: manifest に出す所見の列(所見の種類 → 項目.この順に並べる). + """ + + name: str = Field(pattern=DATASET_NAME_PATTERN.pattern) + description: str = Field(min_length=1) + select: SelectSpec + columns: dict[str, list[str]] = {} + + @field_validator("columns") + @classmethod + def _non_empty_fields(cls, value: dict[str, list[str]]) -> dict[str, list[str]]: + for kind, fields in value.items(): + if not fields: + raise ValueError(f"columns.{kind} の項目が空") + _unique(fields, f"columns.{kind} ") + return value + + @property + def annotation_kinds(self) -> list[str]: + """評価に使う所見の種類(`require` のあとに `columns` の種類.重複なし).""" + return list(dict.fromkeys([*self.select.require, *self.columns])) + + +class LoadedDefinition(BaseModel): + """読み込んだ定義と,版に記録する定義ファイルの内容. + + Attributes: + definition: 検証済みの定義. + text: 定義ファイルの内容(そのまま.版の `definition` に保存する). + """ + + model_config = ConfigDict(frozen=True) + + definition: DatasetDefinition + text: str + + +def _check_name(name: str) -> None: + # 名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない + if not DATASET_NAME_PATTERN.match(name): + raise ValueError( + f"データセット名 '{name}' は英小文字で始まり,英小文字・数字・_ だけにする" + ) + + +def parse_definition(text: str, expected_name: str) -> DatasetDefinition: + """定義ファイルの内容を読んで形を検証する(目録・種類の照合はしない). + + Args: + text: 定義ファイルの内容(TOML). + expected_name: 定義ファイル名から得たデータセット名(`name` と一致すること). + + Returns: + 検証済みの定義. + + Raises: + ValueError: TOML として読めない,形が誤っている(未知のキー・必須キーの欠落・ + 項目の重複等),または `name` が定義ファイル名と一致しない. + """ + filename = f"{DATASETS_DIRNAME}/{expected_name}{DEFINITION_SUFFIX}" + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as e: + raise ValueError(f"データセットの定義 {filename} を TOML として読めない: {e}") from e + try: + definition = DatasetDefinition.model_validate(data) + except ValidationError as e: + raise ValueError( + f"データセットの定義 {filename} の形が誤っている: {validation_error_details(e)}" + ) from e + if definition.name != expected_name: + raise ValueError( + f"データセットの定義 {filename} の name '{definition.name}' がファイル名と一致しない" + ) + return definition + + +def check_definition_kinds(definition: DatasetDefinition, categories: Mapping[str, str]) -> None: + """定義の種類が目録の種類と所見の値の型に合うかを確かめる. + + Args: + definition: 定義. + categories: 目録の種類名 → 分類(`asset`・`annotation`). + + Raises: + ValueError: `asset_kind` がファイルの種類でない,`require`・`columns` の種類が所見の + 種類でない(または値の型が無い),`columns` の項目が値の型に無い. + """ + prefix = f"データセットの定義 {DATASETS_DIRNAME}/{definition.name}{DEFINITION_SUFFIX}" + asset_kind = definition.select.asset_kind + if categories.get(asset_kind) != "asset": + raise ValueError(f"{prefix}: select.asset_kind '{asset_kind}' はファイルの種類にない") + for kind in definition.annotation_kinds: + if categories.get(kind) != "annotation": + raise ValueError(f"{prefix}: '{kind}' は所見の種類にない") + value_type = kinds.annotation_value_type(kind) + unknown = [f for f in definition.columns.get(kind, []) if f not in value_type.model_fields] + if unknown: + raise ValueError( + f"{prefix}: columns.{kind} の項目 {', '.join(unknown)} は所見の値に無い" + f"(あるもの: {', '.join(value_type.model_fields)})" + ) + + +def default_datasets_dir() -> Path: + """リポジトリの `datasets/` フォルダを返す. + + Raises: + ValueError: リポジトリのルートが見つからない,またはルートに `datasets/` が無い. + """ + directory = repo.find_repo_root() / DATASETS_DIRNAME + if not directory.is_dir(): + raise ValueError( + f"リポジトリに {DATASETS_DIRNAME}/ が無い(データセットの定義を置くフォルダ)" + ) + return directory + + +def list_definition_names(datasets_dir: Path) -> list[str]: + """定義ファイルのあるデータセット名を名前の順に返す(名前に使えないファイル名は除く).""" + return sorted( + path.stem + for path in datasets_dir.glob(f"*{DEFINITION_SUFFIX}") + if path.is_file() and DATASET_NAME_PATTERN.match(path.stem) + ) + + +def load_definition(datasets_dir: Path, name: str) -> LoadedDefinition: + """定義ファイルを読んで形を検証する. + + Args: + datasets_dir: 定義のフォルダ. + name: データセット名. + + Returns: + 検証済みの定義と,定義ファイルの内容. + + Raises: + ValueError: 名前が使えない,定義ファイルが無い・読めない,または形が誤っている. + """ + _check_name(name) + filename = f"{DATASETS_DIRNAME}/{name}{DEFINITION_SUFFIX}" + path = datasets_dir / f"{name}{DEFINITION_SUFFIX}" + if not path.is_file(): + raise ValueError(f"データセットの定義 {filename} が無い") + try: + # 改行を変換せずにそのまま読む(版に定義の内容をそのまま記録するため) + text = path.read_bytes().decode("utf-8") + except UnicodeDecodeError as e: + raise ValueError(f"データセットの定義 {filename} が UTF-8 でない") from e + return LoadedDefinition(definition=parse_definition(text, name), text=text) + + +# --------------------------------------------------------------------------- +# 目録への当てはめ +# --------------------------------------------------------------------------- +# 構成の比較用の値(ファイルの SHA-256 の組, (ファイルの SHA-256, 所見の ID) の組) +type CompositionKey = tuple[tuple[str, ...], tuple[tuple[str, int], ...]] + + +class Composition(BaseModel): + """定義を目録に当てはめた結果(版の構成). + + Attributes: + candidates: 種類が一致したファイルの数. + members: 版に入れるファイル(SHA-256 の順). + member_annotations: 採用した所見(ファイルの SHA-256・所見の ID の順). + """ + + model_config = ConfigDict(frozen=True) + + candidates: int + members: list[MemberAssetRow] + member_annotations: list[MemberAnnotationRow] + + @property + def excluded(self) -> int: + """必須の所見が無いため版に入れなかったファイルの数.""" + return self.candidates - len(self.members) + + def key(self) -> CompositionKey: + """構成の比較に使う値(ファイルの SHA-256 と,採用した所見の ID).""" + return composition_key( + [m.sha256 for m in self.members], _annotation_ids(self.member_annotations) + ) + + +def composition_key( + members: Iterable[str], member_annotations: Iterable[tuple[str, int]] +) -> CompositionKey: + """構成を順序によらない比較用の値にする.""" + return tuple(sorted(members)), tuple(sorted(member_annotations)) + + +def _annotation_ids(member_annotations: Iterable[MemberAnnotationRow]) -> list[tuple[str, int]]: + return [(a.member_sha256, a.annotation.annotation_id) for a in member_annotations] + + +def _examples(items: Sequence[str]) -> str: + shown = ", ".join(items[:_MAX_EXAMPLES]) + return shown + (" …" if len(items) > _MAX_EXAMPLES else "") + + +def _asset_label(asset: MemberAssetRow) -> str: + # 位置は研究用の撮影 ID か SHA-256 の先頭で示す(元のファイル名は出さない) + return asset.capture_id if asset.capture_id is not None else asset.sha256[:_SHORT_SHA256] + + +def evaluate_definition( + definition: DatasetDefinition, + assets: Sequence[MemberAssetRow], + annotations: Mapping[str, Sequence[AnnotationRow]], +) -> Composition: + """定義を目録の行に当てはめて,版の構成を決める(純関数). + + ファイルごとに,その撮影に付いた所見とファイルに付いた所見のうち有効なもの(訂正されて + いないもの)を種類ごとに集める.`require` の種類がすべてあるファイルを版に入れ, + `require`・`columns` の種類の所見を採用する. + + Args: + definition: 定義. + assets: 種類が `select.asset_kind` のファイル. + annotations: 所見の種類 → 有効な所見(`definition.annotation_kinds` の種類). + + Returns: + 版の構成. + + Raises: + ValueError: 版に入れるファイルに同じ種類の有効な所見が 2 つ以上ある(どちらを使うかを + 黙って決めない.SPEC_03),または版に入れるファイルが撮影にひも付いていない. + """ + by_capture: dict[tuple[str, str], list[AnnotationRow]] = defaultdict(list) + by_file: dict[tuple[str, str], list[AnnotationRow]] = defaultdict(list) + for kind in definition.annotation_kinds: + for row in annotations.get(kind, []): + if row.capture_id is not None: + by_capture[(kind, row.capture_id)].append(row) + if row.sha256 is not None: + by_file[(kind, row.sha256)].append(row) + + members: list[MemberAssetRow] = [] + member_annotations: list[MemberAnnotationRow] = [] + conflicts: list[str] = [] + no_capture: list[str] = [] + for asset in sorted(assets, key=lambda a: a.sha256): + found = { + kind: [ + *(by_capture.get((kind, asset.capture_id), []) if asset.capture_id else []), + *by_file.get((kind, asset.sha256), []), + ] + for kind in definition.annotation_kinds + } + if any(not found[kind] for kind in definition.select.require): + continue + duplicated = [kind for kind, rows in found.items() if len(rows) > 1] + if duplicated: + conflicts.extend(f"{_asset_label(asset)} の {kind}" for kind in duplicated) + continue + if asset.capture_id is None or asset.subject_id is None: + no_capture.append(asset.sha256[:_SHORT_SHA256]) + continue + members.append(asset) + member_annotations.extend( + MemberAnnotationRow(member_sha256=asset.sha256, annotation=rows[0]) + for rows in found.values() + if rows + ) + + if conflicts: + raise ValueError( + f"データセット '{definition.name}': 同じ種類の有効な所見が 2 つ以上あるファイルが " + f"{len(conflicts)} 件ある(例: {_examples(conflicts)})." + "所見を訂正して 1 つにしてから版を作る" + ) + if no_capture: + raise ValueError( + f"データセット '{definition.name}': 撮影にひも付いていないファイルが " + f"{len(no_capture)} 件ある(例: {_examples(no_capture)})." + "manifest の subject_id・capture_id を決められない" + ) + member_annotations.sort(key=lambda a: (a.member_sha256, a.annotation.annotation_id)) + return Composition( + candidates=len(assets), members=members, member_annotations=member_annotations + ) + + +def manifest_entries( + members: Iterable[MemberAssetRow], member_annotations: Iterable[MemberAnnotationRow] +) -> list[ManifestEntry]: + """版の構成から manifest の行の元を作る(純関数.版の作成と作り直しで同じものを使う). + + Args: + members: 版のファイル. + member_annotations: 採用した所見. + + Returns: + manifest の行の元. + + Raises: + ValueError: ファイルが撮影にひも付いていない,1 つのファイルに同じ種類の所見を + 2 つ以上採用している,または所見の値が JSON のオブジェクトでない. + """ + values: dict[str, dict[str, dict]] = defaultdict(dict) + for item in member_annotations: + annotation = item.annotation + if annotation.kind in values[item.member_sha256]: + raise ValueError( + f"ファイル {item.member_sha256[:_SHORT_SHA256]} に所見 {annotation.kind} が" + " 2 つ以上ある" + ) + value = json.loads(annotation.value) + if not isinstance(value, dict): + raise ValueError(f"所見 {annotation.annotation_id} の値が JSON のオブジェクトでない") + values[item.member_sha256][annotation.kind] = value + + entries: list[ManifestEntry] = [] + for member in members: + if member.capture_id is None or member.subject_id is None: + raise ValueError(f"ファイル {member.sha256[:_SHORT_SHA256]} が撮影にひも付いていない") + entries.append( + ManifestEntry( + sha256=member.sha256, + ext=member.ext, + kind=member.kind, + subject_id=member.subject_id, + capture_id=member.capture_id, + annotations=values.get(member.sha256, {}), + ) + ) + return entries + + +# --------------------------------------------------------------------------- +# 版の作成 +# --------------------------------------------------------------------------- +class FreezeResult(BaseModel): + """版の作成の結果. + + Attributes: + name: データセット名. + version: 作った版(`created` が False なら,構成が同じだった直前の版). + created: 新しい版を作ったか. + candidates: 種類が一致したファイルの数. + members: 版のファイルの数. + member_annotations: 採用した所見の数. + manifest_sha256: 版の manifest.csv の SHA-256. + """ + + model_config = ConfigDict(frozen=True) + + name: str + version: int + created: bool + candidates: int + members: int + member_annotations: int + manifest_sha256: str + + +def collect_composition(conn: sqlite3.Connection, definition: DatasetDefinition) -> Composition: + """目録の現在の状態に定義を当てはめる(読み取りのみ). + + Args: + conn: 目録への接続. + definition: 形を検証済みの定義. + + Returns: + 版の構成. + + Raises: + ValueError: 定義の種類が目録の種類と合わない,または当てはめで問題が見つかった + (`evaluate_definition` を参照). + """ + check_definition_kinds(definition, versions.kind_categories(conn)) + assets = versions.list_assets_of_kind(conn, definition.select.asset_kind) + annotations = { + kind: versions.list_valid_annotations(conn, kind) for kind in definition.annotation_kinds + } + return evaluate_definition(definition, assets, annotations) + + +def freeze_version( + conn: sqlite3.Connection, + loaded: LoadedDefinition, + *, + repo_state: repo.RepoState, + note: str | None = None, + now: datetime | None = None, +) -> FreezeResult: + """定義を目録に当てはめて新しい版を作る(構成と定義が直前の版と同じなら作らない). + + 版の行・構成の行は 1 つのトランザクションで INSERT する(凍結のトリガがあるため, + manifest の SHA-256 を先に計算してから INSERT する). + + Args: + conn: 目録への接続(トランザクションの外). + loaded: 読み込んだ定義. + repo_state: 本リポジトリの git の状態(`repo.read_repo_state()`). + note: 版の備考. + now: 作成日時(省略時は現在). + + Returns: + 版の作成の結果. + + Raises: + ValueError: 本リポジトリに未コミットの変更がある,定義の種類が目録と合わない, + 当てはめで問題が見つかった,条件に合うファイルが 0 件,または登録に失敗した. + """ + definition = loaded.definition + if repo_state.uncommitted: + # 定義とコードの版をコミットで記録できなくなるため(SPEC_03「版の作成」) + raise ValueError( + f"本リポジトリに未コミットの変更が {repo_state.uncommitted} 件ある." + "コミットしてから版を作る" + ) + with catalog.write_transaction(conn): + composition = collect_composition(conn, definition) + if not composition.members: + raise ValueError( + f"データセット '{definition.name}': 条件に合うファイルが 0 件" + f"(種類が一致したファイル {composition.candidates} 件)" + ) + data = build_manifest( + manifest_entries(composition.members, composition.member_annotations), + definition.columns, + ) + digest = manifest_sha256(data) + + latest = versions.get_latest_version(conn, definition.name) + if ( + latest is not None + and latest.definition == loaded.text + and _stored_composition_key(conn, latest) == composition.key() + ): + return _freeze_result(latest, composition, created=False) + + created_at = utc_timestamp(now) + if versions.get_dataset(conn, definition.name) is None: + versions.insert_dataset(conn, definition.name, definition.description, created_at) + record = VersionRecord( + name=definition.name, + version=1 if latest is None else latest.version + 1, + definition=loaded.text, + hub_commit=repo_state.commit, + manifest_sha256=digest, + created_at=created_at, + note=note or None, + ) + versions.insert_version( + conn, + record, + [m.sha256 for m in composition.members], + _annotation_ids(composition.member_annotations), + ) + return _freeze_result(record, composition, created=True) + + +def _stored_composition_key(conn: sqlite3.Connection, record: VersionRecord) -> CompositionKey: + return composition_key( + [m.sha256 for m in versions.version_members(conn, record.name, record.version)], + _annotation_ids(versions.version_member_annotations(conn, record.name, record.version)), + ) + + +def _freeze_result( + record: VersionRecord, composition: Composition, *, created: bool +) -> FreezeResult: + return FreezeResult( + name=record.name, + version=record.version, + created=created, + candidates=composition.candidates, + members=len(composition.members), + member_annotations=len(composition.member_annotations), + manifest_sha256=record.manifest_sha256, + ) + + +def rebuild_manifest(conn: sqlite3.Connection, name: str, version: int) -> bytes: + """凍結した版の構成から manifest.csv を作り直す(版を作ったときと同じバイト列になる). + + 版に記録した定義(`[columns]`)と,版の構成で採用した所見(訂正された後も古い行)を使う. + 現在の種類の定義・所見の訂正には左右されない. + + Args: + conn: 目録への接続. + name: データセット名. + version: 版. + + Returns: + manifest.csv の中身. + + Raises: + ValueError: 版が無い,記録した定義が読めない,または構成が壊れている. + """ + record = versions.get_version(conn, name, version) + if record is None: + raise ValueError(f"データセット '{name}' の版 {version} が無い") + definition = parse_definition(record.definition, name) + entries = manifest_entries( + versions.version_members(conn, name, version), + versions.version_member_annotations(conn, name, version), + ) + return build_manifest(entries, definition.columns) + + +def count_manifest_mismatches(conn: sqlite3.Connection) -> tuple[int, int]: + """凍結したすべての版について,構成から作り直した manifest の SHA-256 を記録と照合する. + + 点検(`check`)用.作り直せない版(構成が壊れている等)も不一致として数える + (止めずに全体を数える.error-handling.md の例外). + + Args: + conn: 目録への接続. + + Returns: + (照合した版の数, 一致しなかった版の数). + """ + summaries = versions.list_versions(conn) + mismatched = 0 + for summary in summaries: + record = summary.record + try: + data = rebuild_manifest(conn, record.name, record.version) + except ValueError: + mismatched += 1 + continue + if manifest_sha256(data) != record.manifest_sha256: + mismatched += 1 + return len(summaries), mismatched + + +# --------------------------------------------------------------------------- +# CLI から使う入口 +# --------------------------------------------------------------------------- +class DatasetOverview(BaseModel): + """データセットの一覧の 1 件. + + Attributes: + name: データセット名. + description: 説明(目録の `datasets` の行.版が無ければ定義ファイルの説明). + defined: 定義ファイルがあるか. + versions: 版(版の順). + """ + + model_config = ConfigDict(frozen=True) + + name: str + description: str | None + defined: bool + versions: list[VersionSummary] + + +def _resolve_datasets_dir(datasets_dir: Path | None) -> Path: + # 既定の場所は呼び出し時に引く(テストで差し替えられるようにモジュールの属性として呼ぶ) + return datasets_dir if datasets_dir is not None else default_datasets_dir() + + +def _overview( + conn: sqlite3.Connection, name: str, datasets_dir: Path, defined: bool +) -> DatasetOverview: + record = versions.get_dataset(conn, name) + description = record.description if record is not None else None + if record is None and defined: + description = load_definition(datasets_dir, name).definition.description + return DatasetOverview( + name=name, + description=description, + defined=defined, + versions=versions.list_versions(conn, name), + ) + + +def run_list(config: Config, *, datasets_dir: Path | None = None) -> list[DatasetOverview]: + """目録のデータセットと,定義ファイルだけがある(版の無い)データセットの一覧. + + Args: + config: 設定. + datasets_dir: 定義のフォルダ(省略時はリポジトリの `datasets/`). + + Returns: + データセットの一覧(名前の順). + + Raises: + ValueError: 目録・定義のフォルダが無い,または定義ファイルが誤っている. + """ + directory = _resolve_datasets_dir(datasets_dir) + defined = set(list_definition_names(directory)) + with closing(catalog.connect_latest(config.catalog.catalog_path)) as conn: + names = sorted(defined | {d.name for d in versions.list_datasets(conn)}) + return [_overview(conn, name, directory, name in defined) for name in names] + + +def run_show(config: Config, name: str, *, datasets_dir: Path | None = None) -> DatasetOverview: + """1 つのデータセットの版の一覧. + + Args: + config: 設定. + name: データセット名. + datasets_dir: 定義のフォルダ(省略時はリポジトリの `datasets/`). + + Returns: + データセットと版. + + Raises: + ValueError: 名前が使えない,目録にも定義にも無い,または目録・定義のフォルダが無い. + """ + _check_name(name) + directory = _resolve_datasets_dir(datasets_dir) + defined = name in list_definition_names(directory) + with closing(catalog.connect_latest(config.catalog.catalog_path)) as conn: + if not defined and versions.get_dataset(conn, name) is None: + raise ValueError(f"データセット '{name}' は目録にも定義({DATASETS_DIRNAME}/)にも無い") + return _overview(conn, name, directory, defined) + + +def run_freeze( + config: Config, + name: str, + *, + note: str | None = None, + datasets_dir: Path | None = None, + repo_state: repo.RepoState | None = None, + now: datetime | None = None, +) -> FreezeResult: + """データセットの定義を読み,目録に当てはめて新しい版を作る. + + Args: + config: 設定. + name: データセット名. + note: 版の備考. + datasets_dir: 定義のフォルダ(省略時はリポジトリの `datasets/`). + repo_state: 本リポジトリの git の状態(省略時は `repo.read_repo_state()` で調べる). + now: 作成日時(省略時は現在). + + Returns: + 版の作成の結果. + + Raises: + ValueError: 定義が無い・誤っている,git の状態を調べられない,未コミットの変更がある, + 目録が無いか古い,または版を作れない(`freeze_version` を参照). + """ + directory = _resolve_datasets_dir(datasets_dir) + loaded = load_definition(directory, name) + # git の状態は呼び出し時に引く(テストで差し替えられるようにモジュール経由で呼ぶ) + state = repo_state if repo_state is not None else repo.read_repo_state() + with closing(catalog.connect_latest(config.catalog.catalog_path)) as conn: + return freeze_version(conn, loaded, repo_state=state, note=note, now=now) diff --git a/src/tiasdata/export.py b/src/tiasdata/export.py new file mode 100644 index 0000000..3aed4ad --- /dev/null +++ b/src/tiasdata/export.py @@ -0,0 +1,248 @@ +"""バンドルの書き出し(`tiasdata export`.SPEC_03「バンドル」). + +目録・ストア・設定を使うため,読み込み側(`tiasdata.bundle`)とはモジュールを分ける +(`import tiasdata` で目録の依存を計算サーバに持ち込まないため.SPEC_01「依存の向き」). +形の組み立て・読み込み・照合は `tiasdata.bundle` のものを使う. + +書き出しは「一時フォルダ(`.tmp-` で始まる名前)に作る → 開いて形を確かめる → 正式な名前に +変える」の順で行い,書きかけのバンドルを正式な名前で残さない.既にあるバンドルは上書きせず, +版の記録と照合して使う(食い違えば停止する). + +出力・例外メッセージにはパスを出さない(SPEC_03「出力の規則」). +""" + +import os +import shutil +import sqlite3 +import tempfile +from contextlib import closing +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from tiasdata import bundle, catalog, datasets, store +from tiasdata.catalog import versions +from tiasdata.config import Config +from tiasdata.manifest import FILES_DIRNAME, bundle_file_path, manifest_sha256, sha256_file + +# 書きかけのバンドルのフォルダ名の接頭辞(ストアの一時ファイルと同じ印) +TEMP_PREFIX = store.TEMP_PREFIX + + +class ExportResult(BaseModel): + """書き出しの結果. + + Attributes: + name: データセット名. + version: 版. + files: ファイルの数. + size_bytes: ファイルのサイズの合計. + manifest_sha256: manifest.csv の SHA-256. + reused: 既にあったバンドルを照合して使ったか(False なら今回書き出した). + """ + + model_config = ConfigDict(frozen=True) + + name: str + version: int + files: int + size_bytes: int + manifest_sha256: str + reused: bool + + +class _Planned(BaseModel): + # 書き出す(または照合する)バンドルの中身.ファイルはストアからコピーする + model_config = ConfigDict(frozen=True) + + info: bundle.BundleInfo + manifest: bytes + dataset_json: bytes + sha256sums: bytes + files: list[tuple[str, str]] # (SHA-256, 拡張子) + + +def _plan(conn: sqlite3.Connection, name: str, version: int) -> _Planned: + record = versions.get_version(conn, name, version) + if record is None: + raise ValueError(f"データセット '{name}' の版 {version} が無い") + # 版の構成から作り直し,記録した SHA-256 と照合してから書き出す(目録の書き換え等で + # 版を作ったときと違う manifest を配らないため) + data = datasets.rebuild_manifest(conn, name, version) + digest = manifest_sha256(data) + if digest != record.manifest_sha256: + raise ValueError( + f"データセット '{name}' の版 {version}: 構成から作り直した manifest の SHA-256 が" + "版の記録と一致しない.書き出さずに停止した.tiasdata check で点検する" + ) + files = [(m.sha256, m.ext) for m in versions.version_members(conn, name, version)] + info = bundle.BundleInfo( + format=bundle.BUNDLE_FORMAT, + name=name, + version=version, + created_at=record.created_at, + hub_commit=record.hub_commit, + files=len(files), + manifest_sha256=digest, + ) + return _Planned( + info=info, + manifest=data, + dataset_json=bundle.build_dataset_json(info), + sha256sums=bundle.build_sha256sums( + (sha256, bundle_file_path(sha256, ext)) for sha256, ext in files + ), + files=files, + ) + + +def export_bundle( + conn: sqlite3.Connection, *, bundles_dir: Path, store_dir: Path, name: str, version: int +) -> ExportResult: + """凍結した版のバンドルを `bundles_dir/<名前>/v<版>/` に書き出す. + + 既にあれば上書きせず,dataset.json・manifest.csv・SHA256SUMS が版の記録から作ったものと + バイト単位で同じで,ファイルの照合(`Bundle.verify`)が通ることを確かめて使う. + + Args: + conn: 目録への接続(スキーマ最新). + bundles_dir: バンドルの置き場所(`/bundles`.`tiasdata init` で作成済み). + store_dir: ストアのフォルダ. + name: データセット名. + version: 版. + + Returns: + 書き出しの結果. + + Raises: + ValueError: 名前・版の形が誤っている,版が無い,作り直した manifest が記録と一致しない, + バンドルの置き場所が無い,ストアのファイルが無い・照合が合わない, + または既にあるバンドルが版の記録と食い違う. + """ + relative = bundle.bundle_relative_dir(name, version) + if not bundles_dir.is_dir(): + raise ValueError("バンドルの置き場所(bundles)が無い(先に tiasdata init を実行する)") + planned = _plan(conn, name, version) + target = bundles_dir / relative + if target.exists(): + return _reuse_existing(target, planned) + + parent = target.parent + parent.mkdir(exist_ok=True) + work = Path(tempfile.mkdtemp(prefix=f"{TEMP_PREFIX}{target.name}-", dir=parent)) + try: + size_bytes = _write_bundle(work, planned, store_dir) + # 書いたものを読み込み側で開き直し,使う側と同じ規則で読めることを確かめる + bundle.open_bundle(work) + published = _publish(work, target) + except BaseException: + shutil.rmtree(work, ignore_errors=True) + raise + if not published: + # 照合の間に同じ版が書き出された(同時に実行した等) + shutil.rmtree(work, ignore_errors=True) + return _reuse_existing(target, planned) + return _result(planned, size_bytes, reused=False) + + +def _write_bundle(work: Path, planned: _Planned, store_dir: Path) -> int: + files_dir = work / FILES_DIRNAME + files_dir.mkdir() + size_bytes = 0 + for sha256, ext in planned.files: + source = store.store_path(store_dir, sha256, ext) + if not source.is_file(): + raise ValueError( + f"ストアにファイルが無い(SHA-256 {sha256}).tiasdata check で点検する" + ) + destination = work / bundle_file_path(sha256, ext) + # copyfile は中身だけを写す(ストアの読み取り専用の属性をバンドルに持ち込まない. + # バンドルは再生成でき,受け渡し後に消すため) + shutil.copyfile(source, destination) + # 書き込んだファイルを読み直して照合する(ストアの破損・書き込みの誤りを検出するため) + if sha256_file(destination) != sha256: + raise ValueError( + f"バンドルにコピーしたファイルの照合が合わない(SHA-256 {sha256})." + "tiasdata check でストアを点検する" + ) + size_bytes += destination.stat().st_size + (work / bundle.MANIFEST_CSV).write_bytes(planned.manifest) + (work / bundle.SHA256SUMS).write_bytes(planned.sha256sums) + (work / bundle.DATASET_JSON).write_bytes(planned.dataset_json) + return size_bytes + + +def _publish(work: Path, target: Path) -> bool: + """一時フォルダを正式な名前にする.既に同じ名前があれば何もせず False を返す.""" + if target.exists(): + return False + try: + # Windows は移動先があると FileExistsError.POSIX は空でないフォルダへの rename が失敗する + os.rename(work, target) + except FileExistsError: + return False + except OSError: + if target.exists(): + return False + raise + return True + + +def _reuse_existing(target: Path, planned: _Planned) -> ExportResult: + label = f"データセット '{planned.info.name}' の版 {planned.info.version}" + message = f"{label}: 既にあるバンドルが版の記録と食い違う.上書きせずに停止した" + if not target.is_dir(): + raise ValueError(f"{message}(フォルダでない)") + for filename, expected in ( + (bundle.DATASET_JSON, planned.dataset_json), + (bundle.MANIFEST_CSV, planned.manifest), + (bundle.SHA256SUMS, planned.sha256sums), + ): + path = target / filename + if not path.is_file() or path.read_bytes() != expected: + raise ValueError(f"{message}({filename} が無い,または違う).消してから書き出し直す") + try: + existing = bundle.open_bundle(target) + existing.verify() + except ValueError as e: + raise ValueError(f"{message}({e}).消してから書き出し直す") from e + size_bytes = sum(existing.file_path(row).stat().st_size for row in existing.rows) + return _result(planned, size_bytes, reused=True) + + +def _result(planned: _Planned, size_bytes: int, *, reused: bool) -> ExportResult: + return ExportResult( + name=planned.info.name, + version=planned.info.version, + files=planned.info.files, + size_bytes=size_bytes, + manifest_sha256=planned.info.manifest_sha256, + reused=reused, + ) + + +def run_export(config: Config, name: str, version: int) -> ExportResult: + """設定の目録・ストアから,凍結した版のバンドルを `bundles/` に書き出す. + + Args: + config: 設定. + name: データセット名. + version: 版. + + Returns: + 書き出しの結果. + + Raises: + ValueError: 目録が無いか古い,または書き出せない(`export_bundle` を参照). + """ + # 名前・版の形の誤りを,目録が無い・古い等のエラーより先に知らせる(引数を先に直せるように) + bundle.bundle_relative_dir(name, version) + paths = config.catalog + with closing(catalog.connect_latest(paths.catalog_path)) as conn: + return export_bundle( + conn, + bundles_dir=paths.bundles_dir, + store_dir=paths.store_dir, + name=name, + version=version, + ) diff --git a/src/tiasdata/importers/__init__.py b/src/tiasdata/importers/__init__.py new file mode 100644 index 0000000..f1c02af --- /dev/null +++ b/src/tiasdata/importers/__init__.py @@ -0,0 +1,74 @@ +"""取り込み元の形ごとの読み取りと登録(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_latest(paths.catalog_path)) as conn: + 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..b2aedfc --- /dev/null +++ b/src/tiasdata/importers/holds.py @@ -0,0 +1,136 @@ +"""保留リスト(`holds/<取り込み元の名前>.toml`)の読み込みと検証(SPEC_03「保留リスト」). + +保留リストは個人情報を含まないためリポジトリで管理する(PR で変更する). +実行時は,リポジトリのルート(`repo.find_repo_root()`)の `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 + +from tiasdata import repo + +HOLDS_DIRNAME = "holds" +# 取り込み元の名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない +_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/` フォルダを返す. + + リポジトリのルートは `repo.find_repo_root()` で見つける. + + Returns: + `holds/` のパス. + + Raises: + ValueError: リポジトリのルートが見つからない(パッケージとして別の場所に入れて + 実行した等),またはルートに `holds/` が無い. + """ + try: + root = repo.find_repo_root() + except ValueError as e: + # 取り込みの文脈で対処が分かるように,保留リストの場所として言い直す + raise ValueError( + "保留リスト(holds/)の場所が分からない." + "取り込みは TIASDataHub のリポジトリの clone から実行する(uv run tiasdata import …)" + ) from e + holds_dir = root / HOLDS_DIRNAME + if not holds_dir.is_dir(): + raise ValueError(f"リポジトリに {HOLDS_DIRNAME}/ が無い(保留リストを置くフォルダ)") + return holds_dir + + +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/manifest.py b/src/tiasdata/manifest.py new file mode 100644 index 0000000..decf7e4 --- /dev/null +++ b/src/tiasdata/manifest.py @@ -0,0 +1,160 @@ +"""`manifest.csv`(バンドルの 1 行 1 ファイルの表)の組み立て(SPEC_03「manifest.csv の列」). + +版の作成(`datasets`)とバンドルの書き出し・読み込み(`bundle`)が同じ形を使うため, +目録(`catalog`)・ストア・設定に依存しない純関数だけを置く(計算サーバで import しても +目録を引き込まないようにするため.SPEC_01「依存の向き」). + +バンドルの読み込み(`bundle`)は目録側のモジュールを import できないため,読み込み側と +目録側(`datasets`・`store`・`cli`)が共に使う小さな純関数(データセット名の形・ +ファイルの SHA-256・pydantic の検証エラーの整形)もここに置く. +""" + +import csv +import hashlib +import io +import json +import re +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, ValidationError + +# 先頭の固定の列(この後に `<所見>.<項目>` の列が定義の `[columns]` の順で並ぶ) +BASE_COLUMNS: tuple[str, ...] = ("path", "sha256", "kind", "subject_id", "capture_id") +FILES_DIRNAME = "files" +MANIFEST_ENCODING = "utf-8" +# データセット名(SPEC_02「ID の書式」.スキーマの CHECK と同じ).定義ファイル名・ +# バンドルのパスにも使うため,区切り等を含む名前を拒否する +DATASET_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +# 大きいファイルでもメモリを使い切らないよう分割して読む +CHUNK_SIZE = 1024 * 1024 + + +class ManifestEntry(BaseModel): + """manifest の 1 行の元になる情報(版に含まれる 1 ファイル). + + Attributes: + sha256: ファイルの SHA-256. + ext: 拡張子(小文字.`.` なし). + kind: ファイルの種類. + subject_id: 研究用の症例 ID. + capture_id: 撮影 ID. + annotations: 採用した所見(所見の種類 → 値の辞書).無い種類は含めない. + """ + + model_config = ConfigDict(frozen=True) + + sha256: str + ext: str + kind: str + subject_id: str + capture_id: str + annotations: dict[str, dict[str, Any]] = {} + + +def bundle_file_path(sha256: str, ext: str) -> str: + """バンドルのルートからのファイルの相対パス(`files/.`.区切りは `/`).""" + return f"{FILES_DIRNAME}/{sha256}.{ext}" + + +def manifest_columns(columns: Mapping[str, Sequence[str]]) -> list[str]: + """manifest の列名を返す. + + Args: + columns: 所見の種類 → 出す項目(データセットの定義の `[columns]`.この順に並べる). + + Returns: + 固定の列のあとに `<所見>.<項目>` を並べた列名. + """ + return [ + *BASE_COLUMNS, + *(f"{kind}.{field}" for kind, fields in columns.items() for field in fields), + ] + + +def _cell(value: object) -> str: + # 文字列はそのまま,無い値は空,それ以外(数値・真偽値等)は JSON の表記にする + if value is None: + return "" + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sort_key(entry: ManifestEntry) -> tuple[str, str, str]: + return (entry.subject_id, entry.capture_id, entry.sha256) + + +def build_manifest(entries: Iterable[ManifestEntry], columns: Mapping[str, Sequence[str]]) -> bytes: + """manifest.csv のバイト列を作る. + + 行は `subject_id`・`capture_id`・`sha256` の順に並べ,UTF-8(BOM なし)・改行 LF で書く + (同じ構成なら同じバイト列になるようにするため.SPEC_03「版の作成」). + 患者 ID・元のファイル名は入力に含まれないので出ない. + + Args: + entries: 版に含まれるファイル. + columns: 所見の種類 → 出す項目(定義の `[columns]`). + + Returns: + manifest.csv の中身. + """ + buffer = io.StringIO(newline="") + writer = csv.writer(buffer, lineterminator="\n") + writer.writerow(manifest_columns(columns)) + for entry in sorted(entries, key=_sort_key): + values = [ + _cell(entry.annotations.get(kind, {}).get(field)) + for kind, fields in columns.items() + for field in fields + ] + writer.writerow( + [ + bundle_file_path(entry.sha256, entry.ext), + entry.sha256, + entry.kind, + entry.subject_id, + entry.capture_id, + *values, + ] + ) + return buffer.getvalue().encode(MANIFEST_ENCODING) + + +def manifest_sha256(data: bytes) -> str: + """manifest.csv のバイト列の SHA-256(版の指紋.16 進 64 文字の小文字).""" + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + """ファイルの SHA-256 を計算する(読み取り専用で開き,分割して読む). + + Args: + path: 対象のファイル. + + Returns: + SHA-256(小文字の 16 進 64 文字). + """ + digest = hashlib.sha256() + with path.open("rb") as f: + while chunk := f.read(CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def validation_error_details(e: ValidationError) -> str: + """pydantic の検証エラーを 1 行にまとめる(`<キーの位置>: <理由>` を `; ` でつなぐ). + + 入力値(パス・患者 ID 等を含みうる)は出さない(SPEC_03「出力の規則」). + + Args: + e: 検証エラー. + + Returns: + キーの位置と理由だけを並べた 1 行. + """ + return "; ".join( + f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" + for error in e.errors(include_input=False, include_url=False) + ) diff --git a/src/tiasdata/repo.py b/src/tiasdata/repo.py new file mode 100644 index 0000000..8cc2d2b --- /dev/null +++ b/src/tiasdata/repo.py @@ -0,0 +1,97 @@ +"""本リポジトリ(TIASDataHub の clone)の場所と git の状態. + +データセットの定義(`datasets/`)はリポジトリで管理し,版には定義とコードの版(コミット)を +記録する(SPEC_03「版の作成」).そのため版の作成はリポジトリの clone から実行し, +未コミットの変更が無いことを確かめてから行う. +""" + +import subprocess +import tomllib +from pathlib import Path +from typing import NamedTuple + +_PROJECT_NAME = "tiasdata" + + +class RepoState(NamedTuple): + """リポジトリの git の状態. + + Attributes: + commit: `HEAD` のコミット(`git rev-parse HEAD`). + uncommitted: 未コミットの変更(未追跡のファイルを含む.無視するファイルは除く)の数. + """ + + commit: str + uncommitted: int + + +def find_repo_root() -> Path: + """本リポジトリのルートを返す. + + このパッケージの位置から親のフォルダを辿り,`[project] name = "tiasdata"` の + `pyproject.toml` があるフォルダをルートとみなす(`importers.holds`・`datasets` が使う). + + Returns: + リポジトリのルート. + + Raises: + ValueError: ルートが見つからない(パッケージとして別の場所に入れて実行した等). + """ + package_dir = Path(__file__).resolve().parent + for parent in package_dir.parents: + pyproject = parent / "pyproject.toml" + if pyproject.is_file() and _is_tiasdata_project(pyproject): + return parent + raise ValueError( + "TIASDataHub のリポジトリの場所が分からない." + "データセットの操作はリポジトリの clone から実行する(uv run tiasdata dataset …)" + ) + + +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 _git(root: Path, *args: str) -> str: + try: + completed = subprocess.run( + ["git", "-C", str(root), *args], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + except OSError as e: + raise ValueError(f"git を実行できない(git {args[0]}): {e.strerror}") from e + if completed.returncode != 0: + # git のエラーメッセージはリポジトリのパスを含みうるため出さない + raise ValueError( + f"git {args[0]} が失敗した(終了コード {completed.returncode})." + "リポジトリの clone から実行しているか確かめる" + ) + return completed.stdout + + +def read_repo_state(root: Path | None = None) -> RepoState: + """リポジトリの `HEAD` のコミットと未コミットの変更の数を返す. + + Args: + root: リポジトリのルート(省略時は `find_repo_root()`). + + Returns: + git の状態. + + Raises: + ValueError: リポジトリが見つからない,または git を実行できない. + """ + directory = root if root is not None else find_repo_root() + commit = _git(directory, "rev-parse", "HEAD").strip() + # 未追跡のファイルも数える(定義ファイルを追加しただけでコミットしていない場合を止めるため) + status = _git(directory, "status", "--porcelain", "--untracked-files=all") + uncommitted = sum(1 for line in status.splitlines() if line.strip()) + return RepoState(commit=commit, uncommitted=uncommitted) 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 new file mode 100644 index 0000000..5727ba3 --- /dev/null +++ b/src/tiasdata/store.py @@ -0,0 +1,302 @@ +"""ストア(登録したファイルの実体)のハッシュ・コピー・照合. + +ストアのファイルは中身の SHA-256 を名前にし,一度置いたら書き換えない・消さない +(SPEC_01 基本方針 2「登録したファイルは変えない」).置き場所は +`store//.<拡張子>` で中身から決まるため,目録には保存しない +(SPEC_02「assets」). + +例外メッセージ・ログにはパス(データの場所)や元のファイル名を出さず,SHA-256 で示す. +""" + +import hashlib +import logging +import os +import re +import stat +import tempfile +from enum import StrEnum +from pathlib import Path +from typing import NamedTuple + +# CHUNK_SIZE・sha256_file はバンドルの読み込み側と共有する(目録に依存しない manifest に置く). +# `tiasdata.store` からも従来どおり使えるようにここで import する +from tiasdata.manifest import CHUNK_SIZE, sha256_file + +logger = logging.getLogger(__name__) + +# 書きかけの一時ファイルの名前の接頭辞.正式な名前(.)と区別でき, +# 点検(`tiasdata check`)で書きかけの残りを見分けられるようにする +TEMP_PREFIX = ".tmp-" + +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_EXT_PATTERN = re.compile(r"^[a-z0-9]+$") + + +class StoredFile(NamedTuple): + """ストアへの取り込みの結果. + + Attributes: + sha256: 中身の SHA-256(小文字の 16 進 64 文字). + ext: 拡張子(小文字.先頭の `.` なし). + size_bytes: サイズ. + copied: 今回コピーしたか(同じ中身が既にあったときは False). + """ + + sha256: str + ext: str + size_bytes: int + copied: bool + + +class StoredFileStatus(StrEnum): + """ストアのファイルの照合の結果.""" + + OK = "ok" + MISSING = "missing" + MISMATCH = "mismatch" + + +def normalize_ext(ext: str) -> str: + """拡張子を小文字・先頭の `.` なしの形にそろえる. + + Args: + ext: 拡張子(`.PNG`・`png` 等). + + Returns: + 小文字の拡張子(例: `png`). + + Raises: + ValueError: 空,または英数字以外を含む. + """ + normalized = ext.removeprefix(".").lower() + if not _EXT_PATTERN.match(normalized): + # 拡張子そのものは出さない(元のファイル名の一部のため) + raise ValueError("拡張子が無い,または英数字以外を含むファイルはストアに置けない") + return normalized + + +def store_path(store_dir: Path, sha256: str, ext: str) -> Path: + """ストア上のパスを決める(ファイルには触れない純関数). + + Args: + store_dir: ストアのフォルダ(`/store`). + sha256: 中身の SHA-256(小文字の 16 進 64 文字). + ext: 拡張子(`.PNG` 等も小文字・先頭の `.` なしにそろえる). + + Returns: + `store_dir//.`. + + Raises: + ValueError: SHA-256 の形が誤っている,または拡張子が不正. + """ + if not _SHA256_PATTERN.match(sha256): + raise ValueError("SHA-256 は小文字の 16 進 64 文字で指定する") + return store_dir / sha256[:2] / f"{sha256}.{normalize_ext(ext)}" + + +def verify_stored(store_dir: Path, sha256: str, ext: str) -> StoredFileStatus: + """ストアのファイルが期待する SHA-256 と一致するかを照合する(点検用). + + Args: + store_dir: ストアのフォルダ. + sha256: 期待する SHA-256. + ext: 拡張子. + + Returns: + 一致すれば `OK`,ファイルが無ければ `MISSING`,中身が違えば `MISMATCH`. + + Raises: + ValueError: SHA-256 の形が誤っている,または拡張子が不正. + """ + path = store_path(store_dir, sha256, ext) + if not path.is_file(): + return StoredFileStatus.MISSING + if sha256_file(path) != sha256: + return StoredFileStatus.MISMATCH + return StoredFileStatus.OK + + +def add_file(store_dir: Path, source: Path) -> StoredFile: + """取り込み元のファイルをストアに置く. + + 取り込み元は読み取り専用で開くだけで,中身・更新日時・属性を変えない. + 同じ中身が既にあれば(同じパスにあり照合が合えば)コピーしない. + 無ければ同じフォルダの一時ファイルへコピーし,書き込んだファイルの SHA-256 を照合してから + 正式な名前に変える(書きかけのファイルが正式な名前で残らないようにするため). + 置いたファイルには読み取り専用の属性を付ける.既存のファイルは上書きしない + (照合が合って再利用するときは,属性だけを読み取り専用に付け直す). + + Args: + store_dir: ストアのフォルダ(`tiasdata init` で作成済みであること). + source: 取り込み元のファイル. + + Returns: + SHA-256・拡張子・サイズ・今回コピーしたか. + + Raises: + ValueError: ストアのフォルダが無い,取り込み元がファイルでない,拡張子が不正, + コピー中に取り込み元が変わった,コピーしたファイルの照合が合わない, + または同じパスに中身の違うファイルが既にある. + """ + if not store_dir.is_dir(): + raise ValueError("ストアのフォルダが無い(先に tiasdata init を実行する)") + if not source.is_file(): + raise ValueError("取り込み元のファイルが無い,またはファイルではない") + ext = normalize_ext(source.suffix) + + sha256 = sha256_file(source) + target = store_path(store_dir, sha256, ext) + if target.exists(): + return _reuse_existing(target, sha256, ext) + + target.parent.mkdir(exist_ok=True) + tmp_path, copied_sha256, size_bytes = _copy_to_temp(source, target.parent) + try: + # 1 回目の計算からコピーまでの間に取り込み元が変わっていないか + if copied_sha256 != sha256: + raise ValueError(f"コピー中に取り込み元の中身が変わった(SHA-256 {sha256})") + # 書き込んだファイルを読み直して照合する(ディスクへの書き込みの誤りを検出するため) + if sha256_file(tmp_path) != sha256: + raise ValueError(f"ストアにコピーしたファイルの照合が合わない(SHA-256 {sha256})") + published = _publish(tmp_path, target) + finally: + tmp_path.unlink(missing_ok=True) + + if not published: + # 照合の間に同じ名前のファイルが置かれた(別の取り込みと同時に実行した等) + return _reuse_existing(target, sha256, ext) + _make_read_only(target) + logger.debug("ストアに追加した: %s", sha256) + return StoredFile(sha256=sha256, ext=ext, size_bytes=size_bytes, copied=True) + + +def _reuse_existing(target: Path, sha256: str, ext: str) -> StoredFile: + # 同じ名前のファイルは同じ中身のはず.違えばストアが壊れているので上書きせず止める + if not target.is_file() or sha256_file(target) != sha256: + raise ValueError( + f"ストアに同じ名前で中身の違うファイルがある(SHA-256 {sha256})." + "上書きせずに停止した.tiasdata check で点検する" + ) + # 復元・手作業等で読み取り専用が外れた正しいファイルを,取り込みのたびに守り直すため + _make_read_only(target) + return StoredFile(sha256=sha256, ext=ext, size_bytes=target.stat().st_size, copied=False) + + +def _copy_to_temp(source: Path, directory: Path) -> tuple[Path, str, int]: + # 一時ファイルは正式な名前と同じフォルダに作る(同じドライブ内の rename にするため) + digest = hashlib.sha256() + size_bytes = 0 + fd, tmp_name = tempfile.mkstemp(prefix=TEMP_PREFIX, dir=directory) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as dst, source.open("rb") as src: + while chunk := src.read(CHUNK_SIZE): + digest.update(chunk) + dst.write(chunk) + size_bytes += len(chunk) + dst.flush() + os.fsync(dst.fileno()) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path, digest.hexdigest(), size_bytes + + +def _publish(tmp_path: Path, target: Path) -> bool: + """一時ファイルを正式な名前にする.既に同じ名前があれば何もせず False を返す.""" + try: + if os.name == "nt": + # Windows の rename は移動先があると FileExistsError になり,上書きしない + os.rename(tmp_path, target) + else: + # POSIX の rename は黙って上書きするため,既存があれば失敗する link を使う + os.link(tmp_path, target) + except FileExistsError: + return False + return True + + +def _make_read_only(path: Path) -> None: + # 書き換え・削除の誤操作を防ぐ(Windows では読み取り専用の属性になる) + mode = path.stat().st_mode + path.chmod(mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)) + + +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 new file mode 100644 index 0000000..ee03305 --- /dev/null +++ b/tests/catalog/test_connection.py @@ -0,0 +1,167 @@ +"""目録への接続(SPEC_02 冒頭: 外部キー制約を有効にする).""" + +from pathlib import Path + +import pytest + +from tiasdata import catalog + + +def test_connect_enables_foreign_keys(tmp_path: Path) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + finally: + conn.close() + + +def test_connect_enables_foreign_keys_on_every_connection(tmp_path: Path) -> None: + path = tmp_path / "db.sqlite" + catalog.connect(path, create=True).close() + conn = catalog.connect(path) + try: + assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + finally: + conn.close() + + +def test_connect_with_create_makes_db_file(tmp_path: Path) -> None: + path = tmp_path / "db.sqlite" + catalog.connect(path, create=True).close() + assert path.is_file() + + +def test_connect_without_create_refuses_missing_db(tmp_path: Path) -> None: + path = tmp_path / "missing.sqlite" + with pytest.raises(ValueError): + catalog.connect(path) + # 空の目録を誤って作らない + assert not path.exists() + + +def test_connect_missing_db_message_does_not_contain_path(tmp_path: Path) -> None: + path = tmp_path / "missing.sqlite" + with pytest.raises(ValueError) as exc: + 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_migrate.py b/tests/catalog/test_migrate.py new file mode 100644 index 0000000..dd95c5b --- /dev/null +++ b/tests/catalog/test_migrate.py @@ -0,0 +1,165 @@ +"""マイグレーションの適用(testing.md 必須対象 8).""" + +import re +import sqlite3 +from pathlib import Path + +import pytest + +from tiasdata import catalog +from tiasdata.catalog import migrate +from tiasdata.catalog.migrate import Migration, MigrationResult + + +def _tables(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ) + return {name for (name,) in rows} + + +def _schema(conn: sqlite3.Connection) -> list[tuple]: + return conn.execute("SELECT type, name, sql FROM sqlite_master ORDER BY type, name").fetchall() + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_apply_to_empty_db_reaches_latest_version(tmp_path: Path, series: str) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + result = catalog.apply_migrations(conn, series) + latest = catalog.latest_version(series) + assert latest >= 1 + assert result == MigrationResult(0, latest) + assert result.applied == latest + assert conn.execute("PRAGMA user_version").fetchone()[0] == latest + assert catalog.schema_version(conn) == latest + finally: + conn.close() + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_reapply_changes_nothing(tmp_path: Path, series: str) -> None: + path = tmp_path / "db.sqlite" + conn = catalog.connect(path, create=True) + catalog.apply_migrations(conn, series) + schema_before = _schema(conn) + conn.close() + + # 別の接続から再適用しても何も変わらない + conn = catalog.connect(path) + try: + result = catalog.apply_migrations(conn, series) + latest = catalog.latest_version(series) + assert result == MigrationResult(latest, latest) + assert result.applied == 0 + assert _schema(conn) == schema_before + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + finally: + conn.close() + + +def test_reapply_keeps_kinds_rows_and_data(catalog_conn: sqlite3.Connection) -> None: + catalog_conn.execute( + "INSERT INTO subjects (subject_id, created_at) VALUES ('S000001', '2026-10-06T00:00:00Z')" + ) + catalog_conn.commit() + catalog.apply_migrations(catalog_conn, "catalog") + assert catalog_conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5 + assert catalog_conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1 + + +def test_catalog_and_identity_are_separate_series(tmp_path: Path) -> None: + cat = catalog.connect(tmp_path / "catalog.sqlite", create=True) + ident = catalog.connect(tmp_path / "identity.sqlite", create=True) + try: + catalog.apply_migrations(cat, "catalog") + catalog.apply_migrations(ident, "identity") + # 目録に対応表の表は無く,対応表に目録の表は無い + assert "identity_map" not in _tables(cat) + assert _tables(ident) == {"identity_map"} + finally: + cat.close() + ident.close() + + +def test_db_newer_than_library_is_rejected(tmp_path: Path) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + catalog.apply_migrations(conn, "catalog") + newer = catalog.latest_version("catalog") + 1 + conn.execute(f"PRAGMA user_version = {newer}") + with pytest.raises(ValueError): + catalog.apply_migrations(conn, "catalog") + # 版は書き換わらない + assert catalog.schema_version(conn) == newer + finally: + conn.close() + + +def test_failed_migration_leaves_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # 1 ファイル目は正常,2 ファイル目は途中で失敗するマイグレーション + good = Migration(1, "0001_good.sql", "CREATE TABLE a (x INTEGER);") + bad = Migration( + 2, + "0002_bad.sql", + "CREATE TABLE b (x INTEGER);\nINSERT INTO a VALUES (1);\nTHIS IS NOT SQL;", + ) + monkeypatch.setattr(migrate, "load_migrations", lambda series: [good, bad]) + + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + with pytest.raises(sqlite3.Error): + catalog.apply_migrations(conn, "catalog") + # 失敗したファイルの変更は残らず,前のファイルまでは適用済み + assert catalog.schema_version(conn) == 1 + assert _tables(conn) == {"a"} + assert conn.execute("SELECT COUNT(*) FROM a").fetchone()[0] == 0 + assert not conn.in_transaction + finally: + conn.close() + + # 別の接続から見ても同じ(確定した内容だけが残っている) + conn = catalog.connect(tmp_path / "db.sqlite") + try: + assert catalog.schema_version(conn) == 1 + assert _tables(conn) == {"a"} + finally: + conn.close() + + +def test_failed_first_migration_leaves_empty_db( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bad = Migration(1, "0001_bad.sql", "CREATE TABLE a (x INTEGER);\nTHIS IS NOT SQL;") + monkeypatch.setattr(migrate, "load_migrations", lambda series: [bad]) + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + with pytest.raises(sqlite3.Error): + catalog.apply_migrations(conn, "catalog") + assert catalog.schema_version(conn) == 0 + assert _tables(conn) == set() + finally: + conn.close() + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_bundled_migrations_are_numbered_from_one(series: str) -> None: + migrations = migrate.load_migrations(series) + assert [m.version for m in migrations] == list(range(1, len(migrations) + 1)) + assert catalog.latest_version(series) == len(migrations) + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_bundled_migrations_do_not_manage_transaction_or_version(series: str) -> None: + # apply_migrations() が BEGIN・COMMIT・user_version を付けるため,ファイル側に書かない + for m in migrate.load_migrations(series): + code = "\n".join( + line for line in m.sql.splitlines() if not line.lstrip().startswith("--") + ).upper() + assert "USER_VERSION" not in code, m.name + # トリガ本体の BEGIN … END; は対象外(トランザクションの文だけを見る) + pattern = ( + r"^\s*(BEGIN(\s+(DEFERRED|IMMEDIATE|EXCLUSIVE))?(\s+TRANSACTION)?" + r"|COMMIT(\s+TRANSACTION)?|END\s+TRANSACTION|ROLLBACK)\s*;" + ) + assert re.search(pattern, code, re.M) is None, m.name diff --git a/tests/catalog/test_queries.py b/tests/catalog/test_queries.py new file mode 100644 index 0000000..4158592 --- /dev/null +++ b/tests/catalog/test_queries.py @@ -0,0 +1,10 @@ +"""目録の集計.""" + +import sqlite3 + +from tiasdata import catalog + + +def test_count_kinds_after_initial_migration(catalog_conn: sqlite3.Connection) -> None: + # SPEC_02「kinds」: 最初に用意する種類は asset 4 件・annotation 1 件 + assert catalog.count_kinds(catalog_conn) == {"annotation": 1, "asset": 4} 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/catalog/test_schema.py b/tests/catalog/test_schema.py new file mode 100644 index 0000000..2b5daf0 --- /dev/null +++ b/tests/catalog/test_schema.py @@ -0,0 +1,806 @@ +"""目録・対応表のスキーマ(SPEC_02 データモデル). + +マイグレーション適用後の DB に対して,表の構成と制約(外部キー・CHECK・トリガ)を確かめる. +""" + +import json +import sqlite3 + +import pytest + +TS = "2026-10-06T00:00:00Z" +SHA_A = "a" * 64 +SHA_B = "b" * 64 +SHA_M = "c" * 64 + +EXPECTED_COLUMNS = { + "kinds": {"kind", "category", "description", "value_schema_version"}, + "subjects": {"subject_id", "created_at", "note"}, + "captures": {"capture_id", "subject_id", "captured_on", "device", "note"}, + "assets": { + "sha256", + "kind", + "capture_id", + "ext", + "size_bytes", + "source", + "source_name", + "registered_at", + }, + "derivations": {"derivation_id", "tool", "tool_version", "mode", "performed_at", "note"}, + "derivation_inputs": {"derivation_id", "sha256", "role"}, + "derivation_outputs": {"derivation_id", "sha256", "role"}, + "annotations": { + "annotation_id", + "kind", + "capture_id", + "sha256", + "value", + "annotator", + "source", + "created_at", + "superseded_by", + }, + "datasets": {"name", "description", "created_at"}, + "dataset_versions": { + "name", + "version", + "definition", + "hub_commit", + "manifest_sha256", + "created_at", + "note", + }, + "dataset_members": {"name", "version", "sha256"}, + "dataset_member_annotations": {"name", "version", "sha256", "annotation_id"}, + "deliveries": { + "delivery_id", + "name", + "version", + "server", + "path", + "delivered_at", + "verified", + }, +} + +TONGUE_COLOR = json.dumps({"label": "red", "severity": "mild"}) + + +# --------------------------------------------------------------------------- +# ダミーの行を入れる補助 +# --------------------------------------------------------------------------- +def _subject(conn: sqlite3.Connection, subject_id: str = "S000001") -> None: + conn.execute("INSERT INTO subjects (subject_id, created_at) VALUES (?, ?)", (subject_id, TS)) + + +def _capture( + conn: sqlite3.Connection, capture_id: str = "C000001", subject_id: str = "S000001" +) -> None: + conn.execute( + "INSERT INTO captures (capture_id, subject_id) VALUES (?, ?)", (capture_id, subject_id) + ) + + +def _asset( + conn: sqlite3.Connection, + sha256: str = SHA_A, + kind: str = "tongue_crop_masked", + capture_id: str | None = "C000001", + ext: str = "png", + registered_at: str = TS, +) -> None: + conn.execute( + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source, source_name," + " registered_at) VALUES (?, ?, ?, ?, 10, 'dummy_source', 'dummy.png', ?)", + (sha256, kind, capture_id, ext, registered_at), + ) + + +def _annotation( + conn: sqlite3.Connection, + *, + capture_id: str | None = "C000001", + sha256: str | None = None, + kind: str = "tongue_color", + value: str = TONGUE_COLOR, + created_at: str = TS, +) -> int: + cur = conn.execute( + "INSERT INTO annotations (kind, capture_id, sha256, value, source, created_at)" + " VALUES (?, ?, ?, ?, 'dummy_source', ?)", + (kind, capture_id, sha256, value, created_at), + ) + return cur.lastrowid + + +def _base(conn: sqlite3.Connection) -> None: + """症例 1・撮影 1・ファイル 1 を入れる.""" + _subject(conn) + _capture(conn) + _asset(conn) + + +def _frozen_version(conn: sqlite3.Connection) -> int: + """データセットの版 1 を作り,ファイル 1 件と所見 1 件を構成に入れる.""" + _base(conn) + annotation_id = _annotation(conn) + conn.execute( + "INSERT INTO datasets (name, description, created_at) VALUES ('tongue_color', 'd', ?)", + (TS,), + ) + conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit, manifest_sha256," + " created_at) VALUES ('tongue_color', 1, 'name = \"tongue_color\"', 'abc123', ?, ?)", + (SHA_M, TS), + ) + conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('tongue_color', 1, ?)", + (SHA_A,), + ) + conn.execute( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES ('tongue_color', 1, ?, ?)", + (SHA_A, annotation_id), + ) + conn.commit() + return annotation_id + + +# --------------------------------------------------------------------------- +# 表の構成 +# --------------------------------------------------------------------------- +class TestTables: + def test_catalog_has_the_13_tables(self, catalog_conn: sqlite3.Connection) -> None: + rows = catalog_conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ) + assert {name for (name,) in rows} == set(EXPECTED_COLUMNS) + assert len(EXPECTED_COLUMNS) == 13 + + @pytest.mark.parametrize("table", sorted(EXPECTED_COLUMNS)) + def test_table_has_spec_columns(self, catalog_conn: sqlite3.Connection, table: str) -> None: + columns = {row[1] for row in catalog_conn.execute(f"PRAGMA table_info({table})")} + assert columns == EXPECTED_COLUMNS[table] + + def test_identity_map_columns(self, identity_conn: sqlite3.Connection) -> None: + columns = {row[1] for row in identity_conn.execute("PRAGMA table_info(identity_map)")} + assert columns == {"patient_key", "subject_id", "created_at"} + + def test_catalog_has_no_patient_key_column(self, catalog_conn: sqlite3.Connection) -> None: + # 実際の患者 ID は目録に入れない(対応表にだけ置く) + for table in EXPECTED_COLUMNS: + columns = {row[1] for row in catalog_conn.execute(f"PRAGMA table_info({table})")} + assert "patient_key" not in columns + + def test_dataset_versions_primary_key_is_name_and_version( + self, catalog_conn: sqlite3.Connection + ) -> None: + pk = { + row[1]: row[5] + for row in catalog_conn.execute("PRAGMA table_info(dataset_versions)") + if row[5] > 0 + } + assert set(pk) == {"name", "version"} + + +# --------------------------------------------------------------------------- +# kinds +# --------------------------------------------------------------------------- +class TestKinds: + def test_initial_kinds(self, catalog_conn: sqlite3.Connection) -> None: + rows = catalog_conn.execute( + "SELECT kind, category, value_schema_version FROM kinds" + ).fetchall() + assert {(k, c) for k, c, _ in rows} == { + ("tongue_raw", "asset"), + ("color_chart", "asset"), + ("tongue_crop_masked", "asset"), + ("tongue_mask", "asset"), + ("tongue_color", "annotation"), + } + + def test_only_annotation_kinds_have_value_schema_version( + self, catalog_conn: sqlite3.Connection + ) -> None: + for _kind, category, version in catalog_conn.execute( + "SELECT kind, category, value_schema_version FROM kinds" + ): + if category == "annotation": + assert version is not None and version >= 1 + else: + assert version is None + + def test_unknown_category_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES ('x', 'other', 'd')" + ) + + def test_asset_kind_with_value_schema_version_is_rejected( + self, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description, value_schema_version)" + " VALUES ('x', 'asset', 'd', 1)" + ) + + def test_annotation_kind_without_value_schema_version_is_rejected( + self, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES ('x', 'annotation', 'd')" + ) + + @pytest.mark.parametrize("kind", ["Tongue", "1abc", "tongue-color", "_x", ""]) + def test_invalid_kind_name_is_rejected( + self, catalog_conn: sqlite3.Connection, kind: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES (?, 'asset', 'd')", + (kind,), + ) + + def test_new_kind_can_be_added_as_row(self, catalog_conn: sqlite3.Connection) -> None: + # 拡張は行の追加で行う(SPEC_02「拡張の例」) + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description, value_schema_version)" + " VALUES ('coating_color', 'annotation', 'd', 1)" + ) + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES ('analysis_csv', 'asset', 'd')" + ) + + +# --------------------------------------------------------------------------- +# ID・値の書式 +# --------------------------------------------------------------------------- +class TestFormats: + def test_valid_rows_are_accepted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn) + catalog_conn.execute( + "UPDATE captures SET captured_on = '2026-10-01', device = 'TIAS'" + " WHERE capture_id = 'C000001'" + ) + + @pytest.mark.parametrize("subject_id", ["S12345", "S0000001", "s000001", "X000001", "S00000a"]) + def test_invalid_subject_id_is_rejected( + self, catalog_conn: sqlite3.Connection, subject_id: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + _subject(catalog_conn, subject_id) + + @pytest.mark.parametrize("capture_id", ["C12345", "C0000001", "c000001", "S000001"]) + def test_invalid_capture_id_is_rejected( + self, catalog_conn: sqlite3.Connection, capture_id: str + ) -> None: + _subject(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _capture(catalog_conn, capture_id) + + @pytest.mark.parametrize( + "sha256", ["A" * 64, "a" * 63, "a" * 65, "g" * 64, ""], ids=["upper", "63", "65", "g", "e"] + ) + def test_invalid_sha256_is_rejected( + self, catalog_conn: sqlite3.Connection, sha256: str + ) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, sha256=sha256) + + @pytest.mark.parametrize("ext", ["PNG", ".png", "", "p g"]) + def test_invalid_ext_is_rejected(self, catalog_conn: sqlite3.Connection, ext: str) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, ext=ext) + + @pytest.mark.parametrize( + "created_at", + [ + "2026-10-06 00:00:00", + "2026-10-06T00:00:00", + "2026-10-06T00:00:00+09:00", + "2026-10-06T00:00:00.123Z", + "2026-10-06", + ], + ) + def test_invalid_datetime_is_rejected( + self, catalog_conn: sqlite3.Connection, created_at: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO subjects (subject_id, created_at) VALUES ('S000001', ?)", + (created_at,), + ) + + @pytest.mark.parametrize("captured_on", ["2026/10/01", "2026-10-01T00:00:00Z", "20261001"]) + def test_invalid_captured_on_is_rejected( + self, catalog_conn: sqlite3.Connection, captured_on: str + ) -> None: + _subject(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO captures (capture_id, subject_id, captured_on)" + " VALUES ('C000001', 'S000001', ?)", + (captured_on,), + ) + + def test_captured_on_may_be_null(self, catalog_conn: sqlite3.Connection) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + row = catalog_conn.execute("SELECT captured_on FROM captures").fetchone() + assert row[0] is None + + @pytest.mark.parametrize("name", ["Tongue", "1tongue", "tongue-color", "_tongue", ""]) + def test_invalid_dataset_name_is_rejected( + self, catalog_conn: sqlite3.Connection, name: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO datasets (name, created_at) VALUES (?, ?)", (name, TS) + ) + + def test_dataset_version_starts_from_one(self, catalog_conn: sqlite3.Connection) -> None: + catalog_conn.execute("INSERT INTO datasets (name, created_at) VALUES ('ds', ?)", (TS,)) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit," + " manifest_sha256, created_at) VALUES ('ds', 0, '', 'c', ?, ?)", + (SHA_M, TS), + ) + + @pytest.mark.parametrize("verified", [2, -1]) + def test_delivery_verified_must_be_boolean( + self, catalog_conn: sqlite3.Connection, verified: int + ) -> None: + _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO deliveries (name, version, server, path, delivered_at, verified)" + " VALUES ('tongue_color', 1, 'newton', '~/data/tiasdata/tongue_color/v1', ?, ?)", + (TS, verified), + ) + + def test_delivery_is_recorded(self, catalog_conn: sqlite3.Connection) -> None: + _frozen_version(catalog_conn) + catalog_conn.execute( + "INSERT INTO deliveries (name, version, server, path, delivered_at, verified)" + " VALUES ('tongue_color', 1, 'newton', '~/data/tiasdata/tongue_color/v1', ?, 1)", + (TS,), + ) + + +# --------------------------------------------------------------------------- +# ID・名前の列は NULL を受け入れない +# (SQLite では INTEGER 以外の PRIMARY KEY が NULL を受け入れるため,NOT NULL が要る) +# --------------------------------------------------------------------------- +class TestPrimaryKeyNotNull: + @pytest.mark.parametrize( + ("needs_subject", "sql", "params"), + [ + ( + False, + "INSERT INTO kinds (kind, category, description) VALUES (?, 'asset', 'd')", + (None,), + ), + (False, "INSERT INTO subjects (subject_id, created_at) VALUES (?, ?)", (None, TS)), + ( + True, + "INSERT INTO captures (capture_id, subject_id) VALUES (?, 'S000001')", + (None,), + ), + ( + False, + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source," + " source_name, registered_at) VALUES (?, 'color_chart', NULL, 'png', 10," + " 'dummy_source', 'dummy.png', ?)", + (None, TS), + ), + (False, "INSERT INTO datasets (name, created_at) VALUES (?, ?)", (None, TS)), + ], + ids=[ + "kinds.kind", + "subjects.subject_id", + "captures.capture_id", + "assets.sha256", + "datasets.name", + ], + ) + def test_null_key_is_rejected( + self, + catalog_conn: sqlite3.Connection, + needs_subject: bool, + sql: str, + params: tuple, + ) -> None: + if needs_subject: + _subject(catalog_conn) + table = sql.split()[2] + before = catalog_conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): + catalog_conn.execute(sql, params) + assert catalog_conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == before + + def test_null_patient_key_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): + identity_conn.execute( + "INSERT INTO identity_map (patient_key, subject_id, created_at) VALUES (?, ?, ?)", + (None, "S000001", TS), + ) + assert identity_conn.execute("SELECT COUNT(*) FROM identity_map").fetchone()[0] == 0 + + +# --------------------------------------------------------------------------- +# 外部キー +# --------------------------------------------------------------------------- +class TestForeignKeys: + def test_capture_requires_existing_subject(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _capture(catalog_conn, subject_id="S999999") + + def test_asset_requires_existing_capture(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, capture_id="C999999") + + def test_asset_without_capture_is_allowed(self, catalog_conn: sqlite3.Connection) -> None: + _asset(catalog_conn, capture_id=None, kind="color_chart") + + def test_asset_requires_existing_kind(self, catalog_conn: sqlite3.Connection) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, kind="no_such_kind") + + def test_annotation_requires_existing_capture(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id="C999999") + + def test_annotation_requires_existing_asset(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id=None, sha256=SHA_B) + + def test_derivation_links_require_existing_rows(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + cur = catalog_conn.execute( + "INSERT INTO derivations (tool, tool_version, mode) VALUES" + " ('DeepTIAS', 'unknown', 'Image&Calib')" + ) + derivation_id = cur.lastrowid + catalog_conn.execute( + "INSERT INTO derivation_outputs (derivation_id, sha256, role) VALUES (?, ?, 'cropped')", + (derivation_id, SHA_A), + ) + for table in ("derivation_inputs", "derivation_outputs"): + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + f"INSERT INTO {table} (derivation_id, sha256, role) VALUES (?, ?, 'x')", + (derivation_id, SHA_B), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + f"INSERT INTO {table} (derivation_id, sha256, role) VALUES (9999, ?, 'x')", + (SHA_A,), + ) + + def test_dataset_version_requires_existing_dataset( + self, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit," + " manifest_sha256, created_at) VALUES ('nope', 1, '', 'c', ?, ?)", + (SHA_M, TS), + ) + + def test_dataset_member_requires_existing_version_and_asset( + self, catalog_conn: sqlite3.Connection + ) -> None: + _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('tongue_color', 2, ?)", + (SHA_A,), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('tongue_color', 1, ?)", + (SHA_B,), + ) + + def test_member_annotation_requires_member_and_annotation( + self, catalog_conn: sqlite3.Connection + ) -> None: + annotation_id = _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES ('tongue_color', 1, ?, ?)", + (SHA_B, annotation_id), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES ('tongue_color', 1, ?, 9999)", + (SHA_A,), + ) + + def test_delivery_requires_existing_version(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO deliveries (name, version, server, path, delivered_at, verified)" + " VALUES ('nope', 1, 'newton', 'p', ?, 0)", + (TS,), + ) + + def test_subject_with_captures_cannot_be_deleted( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute("DELETE FROM subjects WHERE subject_id = 'S000001'") + + +# --------------------------------------------------------------------------- +# 種類の分類の整合 +# --------------------------------------------------------------------------- +class TestKindCategory: + def test_asset_with_annotation_kind_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, kind="tongue_color") + + def test_asset_kind_cannot_be_changed_to_annotation_kind( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute("UPDATE assets SET kind = 'tongue_color'") + + def test_annotation_with_asset_kind_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, kind="tongue_crop_masked") + + +# --------------------------------------------------------------------------- +# 所見(撮影かファイルのどちらか一方・書き換えない・訂正は superseded_by) +# --------------------------------------------------------------------------- +class TestAnnotations: + def test_annotation_on_capture_is_accepted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn, capture_id="C000001") + + def test_annotation_on_asset_is_accepted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn, capture_id=None, sha256=SHA_A) + + def test_annotation_on_both_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id="C000001", sha256=SHA_A) + + def test_annotation_on_neither_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id=None, sha256=None) + + def test_annotation_value_must_be_json(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, value="{label: red") + + def test_annotation_created_at_format(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, created_at="2026-10-06 00:00:00") + + @pytest.mark.parametrize( + "assignment", + [ + 'value = \'{"label": "pale", "severity": "mild"}\'', + "annotator = 'A'", + "source = 'other'", + "created_at = '2026-10-07T00:00:00Z'", + "kind = 'tongue_color'", + "capture_id = NULL, sha256 = '" + SHA_A + "'", + ], + ids=["value", "annotator", "source", "created_at", "same_kind", "target"], + ) + def test_annotation_cannot_be_updated( + self, catalog_conn: sqlite3.Connection, assignment: str + ) -> None: + _base(catalog_conn) + annotation_id = _annotation(catalog_conn) + catalog_conn.commit() + before = catalog_conn.execute("SELECT * FROM annotations").fetchall() + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + f"UPDATE annotations SET {assignment} WHERE annotation_id = ?", (annotation_id,) + ) + assert catalog_conn.execute("SELECT * FROM annotations").fetchall() == before + + def test_annotation_cannot_be_deleted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute("DELETE FROM annotations") + assert catalog_conn.execute("SELECT COUNT(*) FROM annotations").fetchone()[0] == 1 + + def test_correction_sets_superseded_by_once(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + old_id = _annotation(catalog_conn) + new_value = json.dumps({"label": "pale", "severity": "strong"}) + new_id = _annotation(catalog_conn, value=new_value) + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", (new_id, old_id) + ) + old = catalog_conn.execute( + "SELECT value, superseded_by FROM annotations WHERE annotation_id = ?", (old_id,) + ).fetchone() + # 古い行は値を保ったまま残る + assert old == (TONGUE_COLOR, new_id) + + # 2 度目(別の行への付け替え・NULL に戻す)は拒否する + third_id = _annotation(catalog_conn, value=new_value) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (third_id, old_id), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = NULL WHERE annotation_id = ?", (old_id,) + ) + + def test_superseded_by_must_point_to_later_row(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + first = _annotation(catalog_conn) + second = _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (first, second), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (second, second), + ) + + def test_superseded_by_must_point_to_existing_row( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + annotation_id = _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = 9999 WHERE annotation_id = ?", + (annotation_id,), + ) + + def test_correction_and_value_change_together_is_rejected( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + old_id = _annotation(catalog_conn) + new_id = _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ?, value = '{}' WHERE annotation_id = ?", + (new_id, old_id), + ) + + +# --------------------------------------------------------------------------- +# 凍結した版 +# --------------------------------------------------------------------------- +class TestFrozenVersions: + @pytest.mark.parametrize( + "statement", + [ + "UPDATE dataset_versions SET note = 'changed'", + "UPDATE dataset_versions SET manifest_sha256 = '" + "d" * 64 + "'", + "DELETE FROM dataset_versions", + "UPDATE dataset_members SET sha256 = '" + SHA_B + "'", + "DELETE FROM dataset_members", + "UPDATE dataset_member_annotations SET annotation_id = annotation_id", + "DELETE FROM dataset_member_annotations", + ], + ) + def test_frozen_rows_cannot_be_changed( + self, catalog_conn: sqlite3.Connection, statement: str + ) -> None: + _frozen_version(catalog_conn) + tables = ("dataset_versions", "dataset_members", "dataset_member_annotations") + before = {t: catalog_conn.execute(f"SELECT * FROM {t}").fetchall() for t in tables} + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute(statement) + after = {t: catalog_conn.execute(f"SELECT * FROM {t}").fetchall() for t in tables} + assert after == before + + def test_new_version_can_be_added(self, catalog_conn: sqlite3.Connection) -> None: + _frozen_version(catalog_conn) + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit, manifest_sha256," + " created_at) VALUES ('tongue_color', 2, 'def', 'abc124', ?, ?)", + (SHA_M, TS), + ) + + def test_duplicate_version_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit," + " manifest_sha256, created_at) VALUES ('tongue_color', 1, 'x', 'y', ?, ?)", + (SHA_M, TS), + ) + + def test_annotation_used_by_version_survives_correction( + self, catalog_conn: sqlite3.Connection + ) -> None: + # 訂正しても過去の版が古い行(古い値)を参照し続けられる + old_id = _frozen_version(catalog_conn) + new_id = _annotation( + catalog_conn, value=json.dumps({"label": "pale", "severity": "strong"}) + ) + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", (new_id, old_id) + ) + row = catalog_conn.execute( + "SELECT a.value FROM dataset_member_annotations m" + " JOIN annotations a ON a.annotation_id = m.annotation_id" + " WHERE m.name = 'tongue_color' AND m.version = 1" + ).fetchone() + assert row == (TONGUE_COLOR,) + + +# --------------------------------------------------------------------------- +# 対応表 +# --------------------------------------------------------------------------- +class TestIdentityMap: + def _insert(self, conn: sqlite3.Connection, patient_key: str, subject_id: str) -> None: + conn.execute( + "INSERT INTO identity_map (patient_key, subject_id, created_at) VALUES (?, ?, ?)", + (patient_key, subject_id, TS), + ) + + def test_mapping_is_accepted(self, identity_conn: sqlite3.Connection) -> None: + self._insert(identity_conn, "DUMMY-P1", "S000001") + self._insert(identity_conn, "DUMMY-P2", "S000002") + + def test_same_patient_twice_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + self._insert(identity_conn, "DUMMY-P1", "S000001") + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "DUMMY-P1", "S000002") + + def test_subject_shared_by_two_patients_is_rejected( + self, identity_conn: sqlite3.Connection + ) -> None: + self._insert(identity_conn, "DUMMY-P1", "S000001") + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "DUMMY-P2", "S000001") + + @pytest.mark.parametrize("subject_id", ["S1", "C000001", "s000001"]) + def test_invalid_subject_id_is_rejected( + self, identity_conn: sqlite3.Connection, subject_id: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "DUMMY-P1", subject_id) + + def test_empty_patient_key_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "", "S000001") + + def test_invalid_created_at_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + identity_conn.execute( + "INSERT INTO identity_map (patient_key, subject_id, created_at)" + " VALUES ('DUMMY-P1', 'S000001', '2026-10-06')" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bcb816d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,299 @@ +"""テスト共通の fixture(ダミーデータのみ.臨床データは使わない).""" + +import sqlite3 +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import pytest +from PIL import Image + +from tiasdata import catalog + +# 舌色データセットの 9 フォルダ(フォルダ名 → label・severity). +# TIASJudgeModel の TEST_01「フォルダ構成とラベル対応」と同じ.フォルダの直下に <番号>.png を置く +TONGUE_COLOR_FOLDERS: dict[str, tuple[str, str]] = { + "淡紅(正常)": ("light_red", "normal"), + "暗赤紅_あり": ("dark_red", "strong"), + "暗赤紅_ややあり": ("dark_red", "mild"), + "紫_ややあり": ("purple", "mild"), + "紅_あり": ("red", "strong"), + "紅_ややあり": ("red", "mild"), + "紫_あり": ("purple", "strong"), + "淡白_あり": ("pale", "strong"), + "淡白_ややあり": ("pale", "mild"), +} + + +def write_dummy_png(path: Path, seed: int, size: tuple[int, int] = (8, 6)) -> Path: + """`seed` ごとに中身の違う小さい PNG を書く(同じ `seed` なら同じバイト列).""" + path.parent.mkdir(parents=True, exist_ok=True) + color = (seed * 37 % 256, seed * 59 % 256, seed * 83 % 256) + image = Image.new("RGB", size, color) + # 色だけだと seed が 256 周期で重なるため,1 画素に seed を埋めて中身を一意にする + image.putpixel((0, 0), (seed % 256, seed // 256 % 256, seed // 65536 % 256)) + image.save(path, format="PNG") + return path + + +@dataclass(frozen=True) +class DummyImage: + """ダミーの取り込み元の画像 1 枚.""" + + number: int + folder: str + label: str + severity: str + path: Path + + +@dataclass(frozen=True) +class DummyTongueColorSource: + """ダミーの舌色データセット(9 フォルダ × 数枚).""" + + root: Path + images: list[DummyImage] + + +@pytest.fixture +def make_png(): + """小さい PNG を書く関数(`make_png(path, seed)`).""" + return write_dummy_png + + +@pytest.fixture +def store_dir(tmp_path: Path) -> Path: + """空のストアのフォルダ(`tiasdata init` 後と同じ状態).""" + directory = tmp_path / "tiasdata" / "store" + directory.mkdir(parents=True) + return directory + + +@pytest.fixture +def tongue_color_source(tmp_path: Path) -> DummyTongueColorSource: + """舌色データセットと同じ構成のダミーの取り込み元(各フォルダ 2 枚・番号は 1 から連番). + + 対応表(xlsx)は `tongue_color_patient_map` で作る. + """ + root = tmp_path / "source" / "TIASJudgeModel" + images: list[DummyImage] = [] + number = 1 + for folder, (label, severity) in TONGUE_COLOR_FOLDERS.items(): + for _ in range(2): + path = write_dummy_png(root / folder / f"{number}.png", seed=number) + images.append(DummyImage(number, folder, label, severity, path)) + number += 1 + return DummyTongueColorSource(root=root, images=images) + + +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: + # 開発者の環境変数が混ざらないようにする(テストごとに必要なら設定する) + monkeypatch.delenv("TIASDATA_CONFIG", raising=False) + + +@pytest.fixture +def catalog_conn(tmp_path: Path) -> Iterator[sqlite3.Connection]: + """マイグレーション適用済みの目録(tmp_path に作る).""" + conn = catalog.connect(tmp_path / "catalog.sqlite", create=True) + catalog.apply_migrations(conn, "catalog") + try: + yield conn + finally: + conn.close() + + +@pytest.fixture +def identity_conn(tmp_path: Path) -> Iterator[sqlite3.Connection]: + """マイグレーション適用済みの対応表(tmp_path に作る).""" + conn = catalog.connect(tmp_path / "identity.sqlite", create=True) + catalog.apply_migrations(conn, "identity") + try: + yield conn + finally: + conn.close() + + +@dataclass +class DatasetsEnv: + """データセットの定義のフォルダと,差し替えた本リポジトリの git の状態. + + `repo_state` を書き換えると,以後の `tiasdata dataset freeze` が読む状態が変わる. + """ + + datasets_dir: Path + repo_state: object + + +@pytest.fixture +def datasets_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DatasetsEnv: + """一時フォルダの `datasets/`(リポジトリの `datasets/tongue_color.toml` を複製)と, + 未コミットの変更が無い git の状態を差し替えた環境. + + `tiasdata.datasets.default_datasets_dir`・`tiasdata.repo.read_repo_state` を差し替えるので, + 同じプロセスで呼ぶ `cli.main` に効く(subprocess で起動した CLI には効かない). + """ + from tiasdata import datasets, repo + + directory = tmp_path / "datasets" + directory.mkdir() + source = Path(__file__).resolve().parents[1] / "datasets" / "tongue_color.toml" + (directory / "tongue_color.toml").write_bytes(source.read_bytes()) + env = DatasetsEnv( + datasets_dir=directory, + repo_state=repo.RepoState(commit="0123456789abcdef0123456789abcdef01234567", uncommitted=0), + ) + monkeypatch.setattr(datasets, "default_datasets_dir", lambda: directory) + monkeypatch.setattr(repo, "read_repo_state", lambda root=None: env.repo_state) + return env 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_bundle.py b/tests/test_bundle.py new file mode 100644 index 0000000..07202c4 --- /dev/null +++ b/tests/test_bundle.py @@ -0,0 +1,713 @@ +"""バンドルの読み込み・検証(SPEC_03「バンドル」「ライブラリの API」,SPEC_01「使う側からの利用」). + +必須対象 5(バンドル)の読み込み側: 目録なしで `open_bundle`・`load` で読めること, +manifest.csv の SHA-256 の不一致・ファイルの欠け・中身の改変を検出すること. + +バンドルは実装の組み立て関数を使わず,仕様の形(dataset.json・manifest.csv・SHA256SUMS・ +files/.)どおりにテストの中で手で作る(読み込み側を仕様の形に対して確かめるため). +書き出しからの流れは `test_export.py`・`test_cli.py` で確かめる.臨床データは使わない. +""" + +import hashlib +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +import tiasdata +from tiasdata import bundle as bundle_module +from tiasdata.bundle import Bundle, BundleRow, load, open_bundle + +NAME = "tongue_color" +CREATED_AT = "2026-10-06T09:00:00Z" +COMMIT = "0123456789abcdef0123456789abcdef01234567" +HEADER = "path,sha256,kind,subject_id,capture_id,tongue_color.label,tongue_color.severity" +# 架空の患者 ID(エラーメッセージに出ないことを確かめるため,バンドルの外のフォルダ名に仕込む) +LEAK_KEY = "990077" + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +@dataclass +class Item: + """手で作るバンドルの 1 ファイル.""" + + content: bytes + subject_id: str + capture_id: str + label: str = "red" + severity: str = "mild" + ext: str = "png" + path: str | None = None # manifest の path を差し替えるとき + sha256: str | None = None # manifest の sha256 を差し替えるとき + + @property + def real_sha256(self) -> str: + return _sha(self.content) + + @property + def manifest_sha256_cell(self) -> str: + return self.real_sha256 if self.sha256 is None else self.sha256 + + @property + def manifest_path(self) -> str: + return self.path or f"files/{self.real_sha256}.{self.ext}" + + +def default_items() -> list[Item]: + # subject_id・capture_id・sha256 の順に並ぶように並べてある + return [ + Item(b"dummy-image-1", "S000001", "C000001", "red", "mild"), + Item(b"dummy-image-2", "S000001", "C000002", "light_red", "normal"), + Item(b"dummy-image-3", "S000002", "C000003", "pale", "strong"), + ] + + +@dataclass +class Built: + root: Path + items: list[Item] + manifest: bytes + info: dict = field(default_factory=dict) + + +def build_manifest_bytes(items: list[Item], header: str = HEADER) -> bytes: + lines = [header] + for item in items: + lines.append( + ",".join( + [ + item.manifest_path, + item.manifest_sha256_cell, + "tongue_crop_masked", + item.subject_id, + item.capture_id, + item.label, + item.severity, + ] + ) + ) + return ("\n".join(lines) + "\n").encode("utf-8") + + +def write_bundle( + root: Path, + items: list[Item] | None = None, + *, + name: str = NAME, + version: int = 1, + manifest: bytes | None = None, + info_overrides: dict | None = None, + write_files: bool = True, +) -> Built: + """仕様の形どおりにバンドルを書く(改行は LF.bytes で書いて変換させない).""" + items = default_items() if items is None else items + root.mkdir(parents=True, exist_ok=True) + data = build_manifest_bytes(items) if manifest is None else manifest + info = { + "format": 1, + "name": name, + "version": version, + "created_at": CREATED_AT, + "hub_commit": COMMIT, + "files": len(items), + "manifest_sha256": _sha(data), + } + info.update(info_overrides or {}) + (root / "dataset.json").write_bytes(json.dumps(info, indent=2).encode("utf-8") + b"\n") + (root / "manifest.csv").write_bytes(data) + sums = "".join( + f"{item.real_sha256} files/{item.real_sha256}.{item.ext}\n" + for item in sorted(items, key=lambda i: f"files/{i.real_sha256}.{i.ext}") + ) + (root / "SHA256SUMS").write_bytes(sums.encode("utf-8")) + if write_files: + (root / "files").mkdir(exist_ok=True) + for item in items: + (root / "files" / f"{item.real_sha256}.{item.ext}").write_bytes(item.content) + return Built(root=root, items=items, manifest=data, info=info) + + +@pytest.fixture +def bundles_root(tmp_path: Path) -> Path: + # パスに架空の患者 ID を含め,エラーメッセージにパスが出ればすぐ分かるようにする + return tmp_path / f"患者{LEAK_KEY}" / "bundles" + + +@pytest.fixture +def built(bundles_root: Path) -> Built: + return write_bundle(bundles_root / NAME / "v1") + + +def _assert_safe_message(message: str, tmp_path: Path) -> None: + # エラーメッセージにバンドルのパス・データの場所・患者 ID を出さない(SPEC_03「出力の規則」) + for form in (str(tmp_path), tmp_path.as_posix(), str(tmp_path.resolve())): + assert form not in message, message + assert LEAK_KEY not in message, message + assert "bundles" not in message, message + + +def _open_error(path: Path, tmp_path: Path) -> str: + with pytest.raises(ValueError) as exc: + open_bundle(path) + message = str(exc.value) + _assert_safe_message(message, tmp_path) + return message + + +def _verify_error(b: Bundle, tmp_path: Path) -> str: + with pytest.raises(ValueError) as exc: + b.verify() + message = str(exc.value) + _assert_safe_message(message, tmp_path) + return message + + +# --------------------------------------------------------------------------- +# 目録なしで読めること(SPEC_01「依存の向き」) +# --------------------------------------------------------------------------- +_NO_CATALOG_SCRIPT = """ +import sys +import tiasdata +b = tiasdata.open_bundle(sys.argv[1]) +b.verify() +b2 = tiasdata.load("tongue_color", version=1) +assert b2.fingerprint == b.fingerprint +for row in b.rows: + assert b.file_path(row).is_file() +heavy = sorted( + m for m in sys.modules + if m in ("sqlite3", "_sqlite3", "tiasdata.catalog", "tiasdata.store", "tiasdata.config", + "tiasdata.datasets", "tiasdata.export", "tiasdata.cli", "openpyxl") + or m.startswith(("tiasdata.catalog.", "tiasdata.importers")) +) +print("LOADED=" + ",".join(heavy)) +print("ROWS=" + str(len(b.rows))) +""" + + +class TestWithoutCatalog: + def test_import_tiasdata_does_not_load_catalog_store_config_or_sqlite(self) -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys, tiasdata\n" + "names = ('sqlite3', '_sqlite3', 'tiasdata.catalog', 'tiasdata.store'," + " 'tiasdata.config', 'tiasdata.export', 'tiasdata.datasets')\n" + "print(','.join(n for n in names if n in sys.modules))\n" + "print(sorted(n for n in tiasdata.__all__))", + ], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stderr + loaded, exported = result.stdout.splitlines() + assert loaded == "" + for api in ("load", "open_bundle", "Bundle", "BundleRow", "BundleInfo"): + assert api in exported + + def test_open_verify_and_load_in_fresh_process_without_catalog( + self, built: Built, bundles_root: Path, tmp_path: Path + ) -> None: + env = {k: v for k, v in os.environ.items() if k != "TIASDATA_CONFIG"} + env["TIASDATA_BUNDLES"] = str(bundles_root) + # 目録・設定ファイルが無い場所で動かす + result = subprocess.run( + [sys.executable, "-c", _NO_CATALOG_SCRIPT, str(built.root)], + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["LOADED=", "ROWS=3"] + + def test_public_api_is_exported_from_package(self) -> None: + assert tiasdata.load is load + assert tiasdata.open_bundle is open_bundle + assert tiasdata.Bundle is Bundle + assert tiasdata.BundleRow is BundleRow + + +# --------------------------------------------------------------------------- +# open_bundle(正常系) +# --------------------------------------------------------------------------- +class TestOpenBundle: + def test_rows_follow_manifest(self, built: Built) -> None: + b = open_bundle(built.root) + assert len(b.rows) == 3 + for row, item in zip(b.rows, built.items, strict=True): + assert isinstance(row, BundleRow) + assert row.path == f"files/{item.real_sha256}.png" + assert row.sha256 == item.real_sha256 + assert row.kind == "tongue_crop_masked" + assert row.subject_id == item.subject_id + assert row.capture_id == item.capture_id + assert row.annotations == { + "tongue_color": {"label": item.label, "severity": item.severity} + } + + def test_accepts_str_path(self, built: Built) -> None: + assert len(open_bundle(str(built.root)).rows) == 3 + + def test_info_and_fingerprint(self, built: Built) -> None: + b = open_bundle(built.root) + assert b.name == NAME + assert b.version == 1 + assert b.manifest_sha256 == _sha(built.manifest) + assert b.info.created_at == CREATED_AT + assert b.info.hub_commit == COMMIT + assert b.info.files == 3 + assert b.fingerprint == { + "name": NAME, + "version": 1, + "manifest_sha256": _sha(built.manifest), + } + + def test_fingerprint_is_a_fresh_dict(self, built: Built) -> None: + b = open_bundle(built.root) + first = b.fingerprint + first["version"] = 99 + assert b.fingerprint["version"] == 1 + + def test_file_path_points_to_file_in_bundle(self, built: Built) -> None: + b = open_bundle(built.root) + for row, item in zip(b.rows, built.items, strict=True): + path = b.file_path(row) + assert path.is_absolute() + assert path.read_bytes() == item.content + assert path.resolve().is_relative_to(built.root.resolve()) + + def test_rows_are_immutable(self, built: Built) -> None: + b = open_bundle(built.root) + with pytest.raises(TypeError): + b.rows[0] = b.rows[1] # type: ignore[index] + with pytest.raises(Exception): # noqa: B017,PT011 pydantic の frozen + b.rows[0].subject_id = "S999999" # type: ignore[misc] + + def test_repr_has_no_path(self, built: Built, tmp_path: Path) -> None: + _assert_safe_message(repr(open_bundle(built.root)), tmp_path) + + def test_empty_annotation_cell_is_none(self, bundles_root: Path) -> None: + items = default_items() + items[1].severity = "" + b = open_bundle(write_bundle(bundles_root / NAME / "v1", items).root) + assert b.rows[1].annotations == {"tongue_color": {"label": "light_red", "severity": None}} + assert b.rows[0].annotations["tongue_color"]["severity"] == "mild" + + def test_annotation_values_are_strings(self, bundles_root: Path) -> None: + # 文字列以外の値も manifest の表記(文字列)のまま返す(ユーザー判断⑤) + items = default_items() + items[0].severity = "3" + b = open_bundle(write_bundle(bundles_root / NAME / "v1", items).root) + assert b.rows[0].annotations["tongue_color"]["severity"] == "3" + + def test_annotations_grouped_by_kind(self, bundles_root: Path) -> None: + item = Item(b"x", "S000001", "C000001") + header = ( + "path,sha256,kind,subject_id,capture_id," + "tongue_color.label,tongue_coating.thickness,tongue_color.severity" + ) + line = f"files/{item.real_sha256}.png,{item.real_sha256},k,S000001,C000001,red,,mild" + data = f"{header}\n{line}\n".encode() + b = open_bundle(write_bundle(bundles_root / NAME / "v1", [item], manifest=data).root) + assert b.rows[0].annotations == { + "tongue_color": {"label": "red", "severity": "mild"}, + "tongue_coating": {"thickness": None}, + } + + def test_manifest_without_annotation_columns(self, bundles_root: Path) -> None: + item = Item(b"x", "S000001", "C000001") + data = ( + "path,sha256,kind,subject_id,capture_id\n" + f"files/{item.real_sha256}.png,{item.real_sha256},k,S000001,C000001\n" + ).encode() + b = open_bundle(write_bundle(bundles_root / NAME / "v1", [item], manifest=data).root) + assert b.rows[0].annotations == {} + + def test_csv_quoted_values(self, bundles_root: Path) -> None: + # 値に , や " を含む所見(JSON の表記等)も CSV として読む + item = Item(b"x", "S000001", "C000001") + header = "path,sha256,kind,subject_id,capture_id,tongue_color.note" + line = f'files/{item.real_sha256}.png,{item.real_sha256},k,S000001,C000001,"[1,""a""]"' + data = f"{header}\n{line}\n".encode() + b = open_bundle(write_bundle(bundles_root / NAME / "v1", [item], manifest=data).root) + assert b.rows[0].annotations == {"tongue_color": {"note": '[1,"a"]'}} + + def test_unknown_keys_in_dataset_json_are_ignored(self, bundles_root: Path) -> None: + built = write_bundle(bundles_root / NAME / "v1", info_overrides={"future_key": "x"}) + assert open_bundle(built.root).version == 1 + + def test_zero_rows_bundle_opens(self, bundles_root: Path) -> None: + built = write_bundle(bundles_root / NAME / "v1", [], manifest=(HEADER + "\n").encode()) + b = open_bundle(built.root) + assert b.rows == () + b.verify() + + def test_sha256sums_with_binary_marker_verifies(self, built: Built) -> None: + # `sha256sum -b` の形(` *<パス>`)も受け付ける + text = (built.root / "SHA256SUMS").read_bytes().decode() + (built.root / "SHA256SUMS").write_bytes(text.replace(" files/", " *files/").encode()) + open_bundle(built.root).verify() + + +# --------------------------------------------------------------------------- +# open_bundle(開けないもの) +# --------------------------------------------------------------------------- +class TestOpenBundleRejects: + def test_missing_folder(self, bundles_root: Path, tmp_path: Path) -> None: + _open_error(bundles_root / NAME / "v9", tmp_path) + + def test_path_is_file(self, built: Built, tmp_path: Path) -> None: + _open_error(built.root / "manifest.csv", tmp_path) + + @pytest.mark.parametrize("filename", ["dataset.json", "manifest.csv"]) + def test_missing_metadata_file(self, built: Built, tmp_path: Path, filename: str) -> None: + (built.root / filename).unlink() + _open_error(built.root, tmp_path) + + def test_metadata_file_is_directory(self, built: Built, tmp_path: Path) -> None: + (built.root / "manifest.csv").unlink() + (built.root / "manifest.csv").mkdir() + _open_error(built.root, tmp_path) + + def test_tampered_manifest_stops(self, built: Built, tmp_path: Path) -> None: + # 所見の値を書き換えると SHA-256 が dataset.json と一致しない + data = built.manifest.replace(b",pale,strong", b",red,strong") + assert data != built.manifest + (built.root / "manifest.csv").write_bytes(data) + message = _open_error(built.root, tmp_path) + assert "SHA-256" in message + + def test_manifest_with_crlf_stops(self, built: Built, tmp_path: Path) -> None: + # 改行を CRLF に変えたもの(Windows での変換等)も別の中身として止める + (built.root / "manifest.csv").write_bytes(built.manifest.replace(b"\n", b"\r\n")) + _open_error(built.root, tmp_path) + + def test_manifest_sha256_in_dataset_json_wrong( + self, bundles_root: Path, tmp_path: Path + ) -> None: + built = write_bundle( + bundles_root / NAME / "v1", info_overrides={"manifest_sha256": "0" * 64} + ) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "overrides", + [ + {"format": 2}, + {"format": "1"}, + {"files": 2}, + {"files": 4}, + {"version": 0}, + {"version": "1"}, + {"name": "Bad-Name"}, + {"name": "../x"}, + {"manifest_sha256": "XYZ"}, + ], + ids=lambda o: "-".join(f"{k}={v}" for k, v in o.items()), + ) + def test_invalid_dataset_json_values( + self, bundles_root: Path, tmp_path: Path, overrides: dict + ) -> None: + built = write_bundle(bundles_root / NAME / "v1", info_overrides=overrides) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "key", ["format", "name", "version", "created_at", "hub_commit", "files", "manifest_sha256"] + ) + def test_missing_dataset_json_key(self, built: Built, tmp_path: Path, key: str) -> None: + info = dict(built.info) + del info[key] + (built.root / "dataset.json").write_bytes(json.dumps(info).encode()) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize("content", [b"{not json", b"[1, 2]", b"\xff\xfe", b""]) + def test_dataset_json_not_object(self, built: Built, tmp_path: Path, content: bytes) -> None: + (built.root / "dataset.json").write_bytes(content) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "bad_path", + [ + "../{sha}.png", + "files/../../{sha}.png", + "files/sub/{sha}.png", + "files\\{sha}.png", + "/files/{sha}.png", + "C:/files/{sha}.png", + "{sha}.png", + "files/{sha}", + "files/{sha}.PNG", + "files/{other}.png", + "other/{sha}.png", + ], + ) + def test_invalid_path_shape(self, bundles_root: Path, tmp_path: Path, bad_path: str) -> None: + items = default_items() + items[0].path = bad_path.format(sha=items[0].real_sha256, other=items[1].real_sha256) + built = write_bundle(bundles_root / NAME / "v1", items) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize("bad_sha", ["", "abc", "A" * 64, "g" * 64]) + def test_invalid_sha256_cell(self, bundles_root: Path, tmp_path: Path, bad_sha: str) -> None: + items = default_items() + items[0].sha256 = bad_sha + built = write_bundle(bundles_root / NAME / "v1", items) + _open_error(built.root, tmp_path) + + def test_duplicate_sha256_rows(self, bundles_root: Path, tmp_path: Path) -> None: + items = default_items() + items.append(Item(items[0].content, "S000003", "C000009")) + built = write_bundle(bundles_root / NAME / "v1", items) + _open_error(built.root, tmp_path) + + @pytest.mark.parametrize( + "header", + [ + "sha256,path,kind,subject_id,capture_id,tongue_color.label,tongue_color.severity", + "path,sha256,kind,subject_id,tongue_color.label,tongue_color.severity", + "path,sha256,kind,subject_id,capture_id,label,tongue_color.severity", + "path,sha256,kind,subject_id,capture_id,tongue_color.label,tongue_color.label", + ], + ) + def test_invalid_header(self, bundles_root: Path, tmp_path: Path, header: str) -> None: + items = default_items() + data = build_manifest_bytes(items, header=header) + built = write_bundle(bundles_root / NAME / "v1", items, manifest=data) + _open_error(built.root, tmp_path) + + def test_empty_manifest(self, bundles_root: Path, tmp_path: Path) -> None: + built = write_bundle(bundles_root / NAME / "v1", [], manifest=b"") + _open_error(built.root, tmp_path) + + def test_row_with_wrong_column_count(self, bundles_root: Path, tmp_path: Path) -> None: + items = default_items() + data = build_manifest_bytes(items).replace(b",pale,strong", b",pale") + built = write_bundle(bundles_root / NAME / "v1", items, manifest=data) + _open_error(built.root, tmp_path) + + def test_manifest_not_utf8(self, bundles_root: Path, tmp_path: Path) -> None: + items = default_items() + data = build_manifest_bytes(items).replace(b"red,mild", b"\x82\xa0,mild") + built = write_bundle(bundles_root / NAME / "v1", items, manifest=data) + _open_error(built.root, tmp_path) + + +# --------------------------------------------------------------------------- +# verify +# --------------------------------------------------------------------------- +class TestVerify: + def test_intact_bundle_returns_none(self, built: Built) -> None: + assert open_bundle(built.root).verify() is None + + def test_detects_modified_file(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + target = b.file_path(b.rows[1]) + target.write_bytes(target.read_bytes() + b"tampered") + message = _verify_error(b, tmp_path) + assert b.rows[1].sha256[:12] in message + + def test_detects_missing_file(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + b.file_path(b.rows[0]).unlink() + message = _verify_error(b, tmp_path) + assert b.rows[0].sha256[:12] in message + + def test_detects_missing_files_folder(self, built: Built, tmp_path: Path) -> None: + shutil.rmtree(built.root / "files") + _verify_error(open_bundle(built.root), tmp_path) + + def test_detects_extra_file(self, built: Built, tmp_path: Path) -> None: + (built.root / "files" / f"{_sha(b'extra')}.png").write_bytes(b"extra") + _verify_error(open_bundle(built.root), tmp_path) + + def test_detects_manifest_changed_after_open(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + (built.root / "manifest.csv").write_bytes(built.manifest.replace(b"pale", b"red")) + _verify_error(b, tmp_path) + + def test_detects_missing_sha256sums(self, built: Built, tmp_path: Path) -> None: + (built.root / "SHA256SUMS").unlink() + _verify_error(open_bundle(built.root), tmp_path) + + @pytest.mark.parametrize( + "change", + ["drop_line", "duplicate_line", "extra_line", "wrong_hash", "bad_format"], + ) + def test_detects_sha256sums_not_matching_manifest( + self, built: Built, tmp_path: Path, change: str + ) -> None: + path = built.root / "SHA256SUMS" + lines = path.read_bytes().decode().splitlines(keepends=True) + if change == "drop_line": + lines = lines[1:] + elif change == "duplicate_line": + lines = [*lines, lines[0]] + elif change == "extra_line": + extra = _sha(b"extra") + lines = [*lines, f"{extra} files/{extra}.png\n"] + elif change == "wrong_hash": + sha = lines[0][:64] + lines[0] = lines[0].replace(sha, "0" * 64, 1) + else: + lines[0] = lines[0].replace(" ", "\t", 1) + path.write_bytes("".join(lines).encode()) + _verify_error(open_bundle(built.root), tmp_path) + + def test_sha256sums_order_does_not_matter(self, built: Built) -> None: + path = built.root / "SHA256SUMS" + lines = path.read_bytes().decode().splitlines(keepends=True) + path.write_bytes("".join(reversed(lines)).encode()) + open_bundle(built.root).verify() + + def test_verify_message_counts(self, built: Built, tmp_path: Path) -> None: + b = open_bundle(built.root) + b.file_path(b.rows[0]).unlink() + b.file_path(b.rows[1]).write_bytes(b"changed") + message = _verify_error(b, tmp_path) + assert "欠け 1 件" in message + assert "不一致 1 件" in message + + @pytest.mark.skipif(shutil.which("sha256sum") is None, reason="sha256sum が無い") + def test_sha256sums_checks_with_sha256sum_command(self, built: Built) -> None: + # サーバで `sha256sum -c SHA256SUMS` が通る形であること(SPEC_03「受け渡し」手順 4) + result = subprocess.run( + ["sha256sum", "-c", "--quiet", "SHA256SUMS"], + cwd=built.root, + capture_output=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# load +# --------------------------------------------------------------------------- +class TestLoad: + def test_load_from_env_var( + self, built: Built, bundles_root: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + b = load(NAME, version=1) + assert b.fingerprint == open_bundle(built.root).fingerprint + assert b.root.resolve() == built.root.resolve() + assert len(b.rows) == 3 + + def test_load_default_is_home_data_tiasdata( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + write_bundle(home / "data" / "tiasdata" / NAME / "v2", version=2) + monkeypatch.delenv("TIASDATA_BUNDLES", raising=False) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + b = load(NAME, version=2) + assert b.version == 2 + assert b.root.resolve() == (home / "data" / "tiasdata" / NAME / "v2").resolve() + + def test_empty_env_var_falls_back_to_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + write_bundle(home / "data" / "tiasdata" / NAME / "v1") + monkeypatch.setenv("TIASDATA_BUNDLES", "") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + assert load(NAME, version=1).version == 1 + + def test_version_is_keyword_only( + self, built: Built, bundles_root: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(TypeError): + load(NAME, 1) # type: ignore[misc] + with pytest.raises(TypeError): + load(NAME) # type: ignore[call-arg] + + @pytest.mark.parametrize("version", [0, -1, True, 1.0, "1", None]) + def test_invalid_version( + self, + built: Built, + bundles_root: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + version: object, + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=version) # type: ignore[arg-type] + _assert_safe_message(str(exc.value), tmp_path) + + @pytest.mark.parametrize("name", ["", "Tongue", "../tongue_color", "a/b", "a\\b", "1abc", "."]) + def test_invalid_name( + self, bundles_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(name, version=1) + _assert_safe_message(str(exc.value), tmp_path) + + def test_missing_bundle( + self, built: Built, bundles_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=2) + _assert_safe_message(str(exc.value), tmp_path) + with pytest.raises(ValueError): + load("other", version=1) + + @pytest.mark.parametrize(("json_name", "json_version"), [("other", 1), (NAME, 2)]) + def test_dataset_json_disagrees_with_request( + self, + bundles_root: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + json_name: str, + json_version: int, + ) -> None: + # 置き場所のフォルダ名と dataset.json の名前・版が違う(別の版のフォルダを写した等) + write_bundle(bundles_root / NAME / "v1", name=json_name, version=json_version) + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=1) + _assert_safe_message(str(exc.value), tmp_path) + + def test_load_rejects_tampered_manifest( + self, built: Built, bundles_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (built.root / "manifest.csv").write_bytes(built.manifest + b"\n") + monkeypatch.setenv("TIASDATA_BUNDLES", str(bundles_root)) + with pytest.raises(ValueError) as exc: + load(NAME, version=1) + _assert_safe_message(str(exc.value), tmp_path) + + +# --------------------------------------------------------------------------- +# SHA256SUMS の読み書き(`sha256sum -c` の形) +# --------------------------------------------------------------------------- +class TestSha256sumsFormat: + def test_build_is_two_spaces_lf_sorted_by_path(self) -> None: + a, b = "a" * 64, "b" * 64 + data = bundle_module.build_sha256sums([(b, f"files/{b}.png"), (a, f"files/{a}.jpg")]) + assert data == f"{a} files/{a}.jpg\n{b} files/{b}.png\n".encode() + assert b"\r" not in data + + def test_parse_roundtrip(self) -> None: + a = "a" * 64 + entries = [(a, f"files/{a}.png")] + assert bundle_module.parse_sha256sums(bundle_module.build_sha256sums(entries)) == entries diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..f22728f --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,303 @@ +"""目録とストアの点検(`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 + + +class TestDatasetVersions: + """凍結した版の manifest を構成から作り直して記録と照合する(SPEC_03「CLI」の check).""" + + def test_versions_without_mismatch(self, import_env: ImportEnv, datasets_env) -> None: + _setup(import_env) + assert main(["dataset", "freeze", "tongue_color"]) == 0 + result = _check(import_env) + assert result.dataset_versions == 1 + assert result.manifest_mismatch == 0 + assert result.problems == 0 + + def test_no_versions(self, import_env: ImportEnv) -> None: + _setup(import_env) + result = _check(import_env) + assert result.dataset_versions == 0 + assert result.manifest_mismatch == 0 + + def test_changed_member_is_counted_and_reported( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + # 版の行はトリガで書き換えられないので,構成が参照するファイルの種類を書き換えて + # 作り直した manifest が記録と食い違う状態を作る + _setup(import_env) + assert main(["dataset", "freeze", "tongue_color"]) == 0 + with closing(_raw_catalog(import_env)) as conn: + conn.execute( + "UPDATE assets SET kind = 'tongue_raw'" + " WHERE sha256 = (SELECT MIN(sha256) FROM dataset_members)" + ) + conn.commit() + result = _check(import_env) + assert result.dataset_versions == 1 + assert result.manifest_mismatch == 1 + assert result.problems == 1 + capsys.readouterr() + assert main(["check"]) == 1 + out = capsys.readouterr().out + assert "凍結した版の manifest の不一致: 1 件(版 1 件)" in out diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2bf6105 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,784 @@ +"""CLI(SPEC_03 の `tiasdata init`・「出力の規則」,error-handling.md の終了コード).""" + +import hashlib +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 + + +def _write_config(tmp_path: Path, extra: str = "") -> Path: + # 相対パスで書き,設定ファイルのあるフォルダ基準で解決させる + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + '[catalog]\nroot = "../data/tiasdata"\nidentity = "../data/identity/identity.sqlite"\n' + + extra, + encoding="utf-8", + ) + return config_path + + +def _paths(tmp_path: Path) -> dict[str, Path]: + root = tmp_path / "data" / "tiasdata" + return { + "root": root, + "catalog": root / "catalog.sqlite", + "store": root / "store", + "bundles": root / "bundles", + "backups": root / "backups", + "identity": tmp_path / "data" / "identity" / "identity.sqlite", + } + + +def _assert_no_paths(text: str, tmp_path: Path) -> None: + # 出力にデータ・設定ファイルの場所を出さない(SPEC_03「出力の規則」) + for form in (str(tmp_path), tmp_path.as_posix(), str(tmp_path.resolve())): + assert form not in text + assert "catalog.sqlite" not in text + assert "identity.sqlite" not in text + assert "config.toml" not in text + + +@pytest.fixture +def config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + config_path = _write_config(tmp_path) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + return config_path + + +# --------------------------------------------------------------------------- +# init +# --------------------------------------------------------------------------- +class TestInit: + def test_init_creates_catalog_store_and_identity( + self, tmp_path: Path, config_env: Path + ) -> None: + assert main(["init"]) == 0 + p = _paths(tmp_path) + assert p["catalog"].is_file() + assert p["store"].is_dir() + assert p["bundles"].is_dir() + assert p["backups"].is_dir() + assert p["identity"].is_file() + + def test_init_applies_latest_migrations(self, tmp_path: Path, config_env: Path) -> None: + assert main(["init"]) == 0 + p = _paths(tmp_path) + with sqlite3.connect(p["catalog"]) as conn: + assert catalog.schema_version(conn) == catalog.latest_version("catalog") + assert conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5 + with sqlite3.connect(p["identity"]) as conn: + assert catalog.schema_version(conn) == catalog.latest_version("identity") + + def test_init_twice_gives_same_result( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + p = _paths(tmp_path) + assert main(["init"]) == 0 + capsys.readouterr() + with sqlite3.connect(p["catalog"]) as conn: + schema = conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall() + kinds = conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall() + # 何度実行しても同じ(既存の行を消さない) + with sqlite3.connect(p["catalog"]) as conn: + conn.execute( + "INSERT INTO subjects (subject_id, created_at)" + " VALUES ('S000001', '2026-10-06T00:00:00Z')" + ) + + assert main(["init"]) == 0 + out = capsys.readouterr().out + with sqlite3.connect(p["catalog"]) as conn: + assert conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall() == schema + assert conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall() == kinds + assert conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1 + assert catalog.schema_version(conn) == catalog.latest_version("catalog") + assert "種類" in out + + def test_init_reports_versions_and_kind_counts( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + out = capsys.readouterr().out + latest = catalog.latest_version("catalog") + assert f"{latest}" in out + assert "asset 4" in out + assert "annotation 1" in out + + def test_init_output_contains_no_paths( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["init"]) == 0 + captured = capsys.readouterr() + _assert_no_paths(captured.out + captured.err, tmp_path) + + def test_init_rejects_catalog_newer_than_library( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + p = _paths(tmp_path) + newer = catalog.latest_version("catalog") + 1 + with sqlite3.connect(p["catalog"]) as conn: + conn.execute(f"PRAGMA user_version = {newer}") + capsys.readouterr() + + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + _assert_no_paths(captured.err, tmp_path) + + +# --------------------------------------------------------------------------- +# 入力の問題(終了コード 1・標準エラーに 1 行) +# --------------------------------------------------------------------------- +class TestInputErrors: + def test_missing_config_exits_1_with_one_line( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + monkeypatch.setenv("TIASDATA_CONFIG", str(tmp_path / "missing.toml")) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + # 何も作らない + assert not (tmp_path / "data").exists() + + def test_broken_toml_exits_1_with_one_line( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text("[catalog\nroot = ", encoding="utf-8") + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + + def test_unknown_key_exits_1_without_input_values( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + secret = "DUMMY-SECRET-VALUE-12345" + config_path = _write_config(tmp_path, f'stroe = "{secret}"\n') + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + # キーの位置は出すが,入力値・パスは出さない + assert "stroe" in captured.err + assert secret not in captured.err + _assert_no_paths(captured.err, tmp_path) + assert not (tmp_path / "data").exists() + + def test_missing_required_key_exits_1( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir() + config_path.write_text('[catalog]\nroot = "../data/tiasdata"\n', encoding="utf-8") + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + assert "identity" in captured.err + _assert_no_paths(captured.err, tmp_path) + + def test_multiple_validation_errors_fit_in_one_line( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir() + config_path.write_text( + 'x = 1\n[catalog]\nfoo = "a"\n[servers.s]\nhost = "h"\n', encoding="utf-8" + ) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + assert len(capsys.readouterr().err.strip().splitlines()) == 1 + + +# --------------------------------------------------------------------------- +# 引数 +# --------------------------------------------------------------------------- +class TestArguments: + def test_unknown_command_exits_2(self) -> None: + with pytest.raises(SystemExit) as exc: + main(["no-such-command"]) + assert exc.value.code == 2 + + def test_unknown_option_exits_2(self, config_env: Path) -> None: + with pytest.raises(SystemExit) as exc: + main(["init", "--no-such-option"]) + assert exc.value.code == 2 + + def test_no_command_prints_help_and_exits_0(self, capsys: pytest.CaptureFixture[str]) -> None: + assert main([]) == 0 + out = capsys.readouterr().out + assert "init" in out + + +# --------------------------------------------------------------------------- +# 別プロセスでの実行(出力の文字コード) +# --------------------------------------------------------------------------- +def _run_cli(args: list[str], config_path: Path, cwd: Path) -> subprocess.CompletedProcess[bytes]: + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")} + env["TIASDATA_CONFIG"] = str(config_path) + # パイプにつないだときの既定の文字コード(Windows では cp932)に任せる + return subprocess.run( + [sys.executable, "-m", "tiasdata.cli", *args], + capture_output=True, + env=env, + cwd=cwd, + check=False, + timeout=60, + ) + + +class TestSubprocess: + def test_init_stdout_is_utf8(self, tmp_path: Path) -> None: + config_path = _write_config(tmp_path) + result = _run_cli(["init"], config_path, tmp_path) + assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace") + out = result.stdout.decode("utf-8") # UTF-8 でなければ例外 + assert "種類" in out + _assert_no_paths(out, tmp_path) + + def test_init_twice_in_separate_processes(self, tmp_path: Path) -> None: + config_path = _write_config(tmp_path) + assert _run_cli(["init"], config_path, tmp_path).returncode == 0 + second = _run_cli(["init"], config_path, tmp_path) + assert second.returncode == 0 + assert second.stderr == b"" + + def test_error_stderr_is_utf8_one_line(self, tmp_path: Path) -> None: + config_path = _write_config(tmp_path, 'stroe = "x"\n') + result = _run_cli(["init"], config_path, tmp_path) + assert result.returncode == 1 + 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) + + +# --------------------------------------------------------------------------- +# dataset list / show / freeze +# --------------------------------------------------------------------------- +def _versions(env: ImportEnv) -> list[tuple]: + conn = sqlite3.connect(env.catalog_root / "catalog.sqlite") + try: + return conn.execute( + "SELECT name, version, hub_commit, manifest_sha256, note FROM dataset_versions" + " ORDER BY name, version" + ).fetchall() + finally: + conn.close() + + +def _rebuild(env: ImportEnv, version: int) -> bytes: + from tiasdata.datasets import rebuild_manifest + + conn = catalog.connect(env.catalog_root / "catalog.sqlite") + try: + return rebuild_manifest(conn, "tongue_color", version) + finally: + conn.close() + + +def _assert_one_line_error(err: str, env: ImportEnv) -> None: + assert len(err.strip().splitlines()) == 1, err + assert err.startswith("エラー: ") + assert "Traceback" not in err + _assert_no_personal_info(err, env) + + +class TestDatasetCommands: + def test_list_freeze_show_flow( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + capsys.readouterr() + + # 版の無い定義も「版なし」で表示する + assert main(["dataset", "list"]) == 0 + out = capsys.readouterr().out + assert "データセット: 1 件" in out + assert "tongue_color: 版なし" in out + _assert_no_personal_info(out, import_env) + + assert main(["dataset", "freeze", "tongue_color", "--note", "最初の版"]) == 0 + out = capsys.readouterr().out + assert "版 1 を作った" in out + assert "ファイル 18 件・所見 18 件" in out + _assert_no_personal_info(out, import_env) + [(name, version, hub_commit, digest, note)] = _versions(import_env) + assert (name, version, note) == ("tongue_color", 1, "最初の版") + assert hub_commit == datasets_env.repo_state.commit + assert digest in out + + # 構成が同じなら作らない + assert main(["dataset", "freeze", "tongue_color"]) == 0 + out = capsys.readouterr().out + assert "作らなかった" in out + assert len(_versions(import_env)) == 1 + + assert main(["dataset", "show", "tongue_color"]) == 0 + out = capsys.readouterr().out + assert "データセット: tongue_color" in out + assert "版: 1 件" in out + line = _line_with(out, "v1:") + assert "ファイル 18 件" in line + assert "所見 18 件" in line + assert "備考 最初の版" in line + assert digest in line + _assert_no_personal_info(out, import_env) + + assert main(["dataset", "list"]) == 0 + out = capsys.readouterr().out + assert "tongue_color: 版 1 件(最新 v1・ファイル 18 件)" in out + _assert_no_personal_info(out, import_env) + + def test_new_import_creates_new_version_and_keeps_old( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + # 必須対象 1 を取り込みから通しで: 新しい画像を取り込んで版 2 を作っても版 1 は変わらない + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + assert main(["dataset", "freeze", "tongue_color"]) == 0 + manifest_v1 = _rebuild(import_env, 1) + [v1] = _versions(import_env) + + write_dummy_png(import_env.source.root / "紅_あり" / "19.png", seed=19) + write_patient_map(import_env.patient_map.path, [*_map_rows(import_env), (19, 2, "990003")]) + assert main(["import", "dummy_tongue"]) == 0 + capsys.readouterr() + assert main(["dataset", "freeze", "tongue_color"]) == 0 + out = capsys.readouterr().out + assert "版 2 を作った" in out + assert "ファイル 19 件" in out + + versions_now = _versions(import_env) + assert versions_now[0] == v1 + assert _rebuild(import_env, 1) == manifest_v1 + assert hashlib.sha256(manifest_v1).hexdigest() == v1[3] + assert _rebuild(import_env, 2).count(b"\n") == 20 + capsys.readouterr() + assert main(["check"]) == 0 + assert "凍結した版の manifest の不一致: 0 件(版 2 件)" in capsys.readouterr().out + + def test_freeze_with_uncommitted_changes_exits_1( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + from tiasdata.repo import RepoState + + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + datasets_env.repo_state = RepoState(commit="a" * 40, uncommitted=3) + capsys.readouterr() + assert main(["dataset", "freeze", "tongue_color"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "未コミット" in captured.err + _assert_one_line_error(captured.err, import_env) + assert _versions(import_env) == [] + + def test_freeze_with_zero_files_exits_1( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + capsys.readouterr() + assert main(["dataset", "freeze", "tongue_color"]) == 1 + captured = capsys.readouterr() + assert "0 件" in captured.err + _assert_one_line_error(captured.err, import_env) + + def test_freeze_duplicate_annotation_exits_1_without_personal_info( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + conn = sqlite3.connect(import_env.catalog_root / "catalog.sqlite") + try: + conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " SELECT kind, capture_id, value, 'test', created_at FROM annotations" + " ORDER BY annotation_id LIMIT 1" + ) + conn.commit() + finally: + conn.close() + capsys.readouterr() + assert main(["dataset", "freeze", "tongue_color"]) == 1 + captured = capsys.readouterr() + assert "2 つ以上" in captured.err + _assert_one_line_error(captured.err, import_env) + assert _versions(import_env) == [] + + @pytest.mark.parametrize("command", ["show", "freeze"]) + @pytest.mark.parametrize("name", ["no_such", "../tongue_color", "Bad"]) + def test_unknown_or_invalid_name_exits_1( + self, + import_env: ImportEnv, + datasets_env, + capsys: pytest.CaptureFixture[str], + command: str, + name: str, + ) -> None: + assert main(["init"]) == 0 + capsys.readouterr() + assert main(["dataset", command, name]) == 1 + _assert_one_line_error(capsys.readouterr().err, import_env) + + def test_invalid_definition_exits_1( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + (datasets_env.datasets_dir / "tongue_color.toml").write_bytes( + b'name = "tongue_color"\n[select]\nasset_kind = "tongue_crop_masked"\nfoo = 1\n' + ) + capsys.readouterr() + assert main(["dataset", "freeze", "tongue_color"]) == 1 + _assert_one_line_error(capsys.readouterr().err, import_env) + + def test_dataset_before_init_exits_1( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + for args in (["list"], ["show", "tongue_color"], ["freeze", "tongue_color"]): + assert main(["dataset", *args]) == 1 + _assert_one_line_error(capsys.readouterr().err, import_env) + + def test_freeze_without_name_exits_2(self, import_env: ImportEnv, datasets_env) -> None: + with pytest.raises(SystemExit) as exc: + main(["dataset", "freeze"]) + assert exc.value.code == 2 + + def test_list_keeps_dataset_whose_definition_was_removed( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + assert main(["dataset", "freeze", "tongue_color"]) == 0 + (datasets_env.datasets_dir / "tongue_color.toml").unlink() + capsys.readouterr() + assert main(["dataset", "list"]) == 0 + assert "tongue_color(定義ファイルなし): 版 1 件" in capsys.readouterr().out + assert main(["dataset", "show", "tongue_color"]) == 0 + assert "版: 1 件" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# export(SPEC_03「CLI」「バンドル」,testing.md「CLI のエントリポイント」・必須対象 5・6) +# --------------------------------------------------------------------------- +class TestExportCommand: + def test_init_import_freeze_export_flow( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + from tiasdata import open_bundle + + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + assert main(["dataset", "freeze", "tongue_color"]) == 0 + [(_, _, _, digest, _)] = _versions(import_env) + capsys.readouterr() + + assert main(["export", "tongue_color", "--version", "1"]) == 0 + captured = capsys.readouterr() + assert captured.err == "" + assert "書き出した" in captured.out + assert "ファイル 18 件" in captured.out + assert digest in captured.out + _assert_no_personal_info(captured.out, import_env) + + target = import_env.catalog_root / "bundles" / "tongue_color" / "v1" + bundle = open_bundle(target) + assert bundle.verify() is None + assert bundle.fingerprint == { + "name": "tongue_color", + "version": 1, + "manifest_sha256": digest, + } + assert len(bundle.rows) == 18 + assert (target / "manifest.csv").read_bytes() == _rebuild(import_env, 1) + # 取り込んだ画像がそのままの中身で入っている + sources = { + hashlib.sha256(image.path.read_bytes()).hexdigest(): image + for image in import_env.source.images + } + for row in bundle.rows: + image = sources[row.sha256] + assert bundle.file_path(row).read_bytes() == image.path.read_bytes() + assert row.annotations["tongue_color"] == { + "label": image.label, + "severity": image.severity, + } + # 患者 ID・元のファイル名がバンドルのメタデータに入っていない + # (SHA-256 の 16 進が偶然 <数字>.png・患者 ID に見えないよう SHA-256 を除いて調べる) + for filename in ("dataset.json", "manifest.csv", "SHA256SUMS"): + text = (target / filename).read_bytes().decode("utf-8") + _assert_no_personal_info(re.sub(r"[0-9a-f]{64}", "", text), import_env) + before = {p: p.read_bytes() for p in target.rglob("*") if p.is_file()} + + # 2 回目は照合して使う(上書きしない) + assert main(["export", "tongue_color", "--version", "1"]) == 0 + out = capsys.readouterr().out + assert "照合して使った" in out + _assert_no_personal_info(out, import_env) + assert {p: p.read_bytes() for p in target.rglob("*") if p.is_file()} == before + + # 書き出したバンドルは check の対象外で,問題を増やさない + assert main(["check"]) == 0 + capsys.readouterr() + + # 改変した既存のバンドルは上書きせず終了コード 1 + first = sorted((target / "files").iterdir())[0] + first.write_bytes(b"tampered") + assert main(["export", "tongue_color", "--version", "1"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + _assert_one_line_error(captured.err, import_env) + assert first.read_bytes() == b"tampered" + + # 存在しない版・データセット・不正な名前は終了コード 1 + for args in ( + ["tongue_color", "--version", "2"], + ["no_such", "--version", "1"], + ["../tongue_color", "--version", "1"], + ["tongue_color", "--version", "0"], + ): + assert main(["export", *args]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + _assert_one_line_error(captured.err, import_env) + + def test_export_before_init_exits_1( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["export", "tongue_color", "--version", "1"]) == 1 + _assert_one_line_error(capsys.readouterr().err, import_env) + + @pytest.mark.parametrize( + "args", + [ + ["export", "tongue_color"], + ["export", "--version", "1"], + ["export", "tongue_color", "--version", "abc"], + ["export", "tongue_color", "--version"], + ], + ) + def test_export_argument_errors_exit_2(self, config_env: Path, args: list[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(args) + assert exc.value.code == 2 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..861b4ae --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,238 @@ +"""設定ファイルの読み込み・検証(SPEC_01「設定ファイル」,testing.md 必須対象 7).""" + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from tiasdata.config import default_config_path, load_config + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _minimal(tmp_path: Path, extra: str = "") -> str: + root = (tmp_path / "data" / "tiasdata").as_posix() + identity = (tmp_path / "data" / "identity" / "identity.sqlite").as_posix() + return f'[catalog]\nroot = "{root}"\nidentity = "{identity}"\n{extra}' + + +FULL_EXTRA = """ +[sources.tiasjudgemodel] +path = "src_a" +importer = "tongue_color_folders" +patient_map = "map.xlsx" + +[servers.newton] +host = "user@example.invalid" +key = "keys/id_ed25519" +root = "~/data/tiasdata" +""" + + +class TestLoadValid: + def test_loads_minimal_config(self, tmp_path: Path) -> None: + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path))) + assert config.catalog.root == tmp_path / "data" / "tiasdata" + assert config.catalog.identity == tmp_path / "data" / "identity" / "identity.sqlite" + assert config.sources == {} + assert config.servers == {} + + def test_loads_sources_and_servers_by_name(self, tmp_path: Path) -> None: + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, FULL_EXTRA))) + assert set(config.sources) == {"tiasjudgemodel"} + assert config.sources["tiasjudgemodel"].importer == "tongue_color_folders" + assert set(config.servers) == {"newton"} + assert config.servers["newton"].host == "user@example.invalid" + + def test_patient_map_is_optional(self, tmp_path: Path) -> None: + extra = '[sources.a]\npath = "x"\nimporter = "tongue_color_folders"\n' + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, extra))) + assert config.sources["a"].patient_map is None + + def test_catalog_layout_under_root(self, tmp_path: Path) -> None: + catalog = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path))).catalog + assert catalog.catalog_path == catalog.root / "catalog.sqlite" + assert catalog.store_dir == catalog.root / "store" + assert catalog.bundles_dir == catalog.root / "bundles" + assert catalog.backups_dir == catalog.root / "backups" + + +class TestPathResolution: + def test_relative_paths_are_relative_to_config_folder( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + conf_dir = tmp_path / "conf" + text = '[catalog]\nroot = "data/tiasdata"\nidentity = "identity/identity.sqlite"\n' + config_path = _write(conf_dir / "config.toml", text + FULL_EXTRA) + # 作業フォルダに依存しないこと + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + config = load_config(config_path) + + base = conf_dir.resolve() + assert config.catalog.root == base / "data" / "tiasdata" + assert config.catalog.identity == base / "identity" / "identity.sqlite" + assert config.sources["tiasjudgemodel"].path == base / "src_a" + assert config.sources["tiasjudgemodel"].patient_map == base / "map.xlsx" + assert config.servers["newton"].key == base / "keys" / "id_ed25519" + + def test_tilde_is_expanded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + text = ( + '[catalog]\nroot = "~/tiasdata"\nidentity = "~/identity/identity.sqlite"\n' + '[sources.a]\npath = "~/src"\nimporter = "tongue_color_folders"\n' + 'patient_map = "~/map.xlsx"\n' + '[servers.s]\nhost = "u@h"\nkey = "~/.ssh/key"\nroot = "~/data/tiasdata"\n' + ) + config = load_config(_write(tmp_path / "conf" / "config.toml", text)) + assert config.catalog.root == home / "tiasdata" + assert config.catalog.identity == home / "identity" / "identity.sqlite" + assert config.sources["a"].path == home / "src" + assert config.sources["a"].patient_map == home / "map.xlsx" + assert config.servers["s"].key == home / ".ssh" / "key" + + def test_server_root_is_not_expanded(self, tmp_path: Path) -> None: + # サーバ上のパスはサーバ側で解釈するため,手元で展開・解決しない + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, FULL_EXTRA))) + assert config.servers["newton"].root == "~/data/tiasdata" + + def test_resolved_paths_are_absolute(self, tmp_path: Path) -> None: + text = '[catalog]\nroot = "r"\nidentity = "i/identity.sqlite"\n' + config = load_config(_write(tmp_path / "config.toml", text)) + assert config.catalog.root.is_absolute() + assert config.catalog.identity.is_absolute() + + +class TestValidationErrors: + @pytest.mark.parametrize( + "extra", + [ + "unknown_top = 1\n", + "[unknown_section]\nx = 1\n", + '[sources.a]\npath = "x"\nimporter = "i"\ntypo = "y"\n', + '[servers.s]\nhost = "h"\nkey = "k"\nroot = "r"\nport = 22\n', + ], + ids=["top_level", "section", "source", "server"], + ) + def test_unknown_key_is_rejected(self, tmp_path: Path, extra: str) -> None: + # 未知のキーはトップレベルに置くと [catalog] の中に入ってしまうので, + # トップレベル用は [catalog] より前に書く + if extra.startswith("unknown_top"): + text = extra + _minimal(tmp_path) + else: + text = _minimal(tmp_path, extra) + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + def test_unknown_key_in_catalog_is_rejected(self, tmp_path: Path) -> None: + text = _minimal(tmp_path) + 'stroe = "typo"\n' + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + def test_missing_catalog_section_is_rejected(self, tmp_path: Path) -> None: + text = '[sources.a]\npath = "x"\nimporter = "i"\n' + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + @pytest.mark.parametrize("missing", ["root", "identity"]) + def test_missing_catalog_key_is_rejected(self, tmp_path: Path, missing: str) -> None: + values = {"root": '"r"', "identity": '"i/identity.sqlite"'} + lines = [f"{k} = {v}" for k, v in values.items() if k != missing] + text = "[catalog]\n" + "\n".join(lines) + "\n" + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + @pytest.mark.parametrize("missing", ["path", "importer"]) + def test_missing_source_key_is_rejected(self, tmp_path: Path, missing: str) -> None: + values = {"path": '"x"', "importer": '"tongue_color_folders"'} + lines = [f"{k} = {v}" for k, v in values.items() if k != missing] + extra = "[sources.a]\n" + "\n".join(lines) + "\n" + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, extra))) + + @pytest.mark.parametrize("missing", ["host", "key", "root"]) + def test_missing_server_key_is_rejected(self, tmp_path: Path, missing: str) -> None: + values = {"host": '"u@h"', "key": '"k"', "root": '"~/data"'} + lines = [f"{k} = {v}" for k, v in values.items() if k != missing] + extra = "[servers.s]\n" + "\n".join(lines) + "\n" + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, extra))) + + @pytest.mark.parametrize( + "identity", + ["data/tiasdata/identity.sqlite", "data/tiasdata/sub/identity.sqlite"], + ids=["directly_in_root", "under_root"], + ) + def test_identity_inside_catalog_root_is_rejected(self, tmp_path: Path, identity: str) -> None: + # 対応表は目録とは別のフォルダに置く(SPEC_01「構成要素」) + text = f'[catalog]\nroot = "data/tiasdata"\nidentity = "{identity}"\n' + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + +class TestFileErrors: + def test_missing_file_raises_value_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="設定ファイル"): + load_config(tmp_path / "nope.toml") + + def test_missing_file_is_not_validation_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError) as exc: + load_config(tmp_path / "nope.toml") + assert not isinstance(exc.value, ValidationError) + + def test_broken_toml_raises_value_error(self, tmp_path: Path) -> None: + path = _write(tmp_path / "config.toml", "[catalog\nroot = \n") + with pytest.raises(ValueError) as exc: + load_config(path) + assert not isinstance(exc.value, ValidationError) + + def test_non_utf8_file_raises_value_error(self, tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_bytes('[catalog]\nroot = "日本語"\n'.encode("cp932")) + with pytest.raises(ValueError): + load_config(path) + + +class TestLocation: + def test_env_var_takes_precedence( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config_path = _write(tmp_path / "custom" / "my.toml", _minimal(tmp_path)) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert default_config_path() == config_path + assert load_config().catalog.root == tmp_path / "data" / "tiasdata" + + def test_default_location_without_env_var( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + assert default_config_path() == home / ".config" / "tiasdata" / "config.toml" + + def test_load_without_path_uses_default_location( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + _write(home / ".config" / "tiasdata" / "config.toml", _minimal(tmp_path)) + assert load_config().catalog.root == tmp_path / "data" / "tiasdata" + + def test_env_var_with_tilde_is_expanded( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("TIASDATA_CONFIG", "~/conf/config.toml") + assert default_config_path() == home / "conf" / "config.toml" diff --git a/tests/test_conftest_fixtures.py b/tests/test_conftest_fixtures.py new file mode 100644 index 0000000..6098f13 --- /dev/null +++ b/tests/test_conftest_fixtures.py @@ -0,0 +1,55 @@ +"""テスト用のダミーデータ(conftest の fixture)の前提を確かめる.""" + +import hashlib +from pathlib import Path + +from conftest import TONGUE_COLOR_FOLDERS, write_dummy_png +from PIL import Image + + +def test_tongue_color_folders_has_nine_entries() -> None: + assert len(TONGUE_COLOR_FOLDERS) == 9 + labels = {label for label, _ in TONGUE_COLOR_FOLDERS.values()} + assert labels == {"light_red", "dark_red", "purple", "red", "pale"} + # normal は light_red だけ(SPEC_02 の組み合わせの制約) + for label, severity in TONGUE_COLOR_FOLDERS.values(): + assert (severity == "normal") == (label == "light_red") + + +def test_tongue_color_source_layout(tongue_color_source) -> None: + root: Path = tongue_color_source.root + folders = sorted(p.name for p in root.iterdir()) + assert folders == sorted(TONGUE_COLOR_FOLDERS) + for folder in TONGUE_COLOR_FOLDERS: + assert len(list((root / folder).iterdir())) == 2 + + images = tongue_color_source.images + assert len(images) == 18 + assert [img.number for img in images] == list(range(1, 19)) + for img in images: + assert img.path == root / img.folder / f"{img.number}.png" + assert img.path.is_file() + assert (img.label, img.severity) == TONGUE_COLOR_FOLDERS[img.folder] + + +def test_tongue_color_source_images_are_distinct_valid_pngs(tongue_color_source) -> None: + hashes = set() + for img in tongue_color_source.images: + hashes.add(hashlib.sha256(img.path.read_bytes()).hexdigest()) + with Image.open(img.path) as opened: + assert opened.format == "PNG" + assert len(hashes) == 18 + + +def test_write_dummy_png_is_deterministic_and_unique(tmp_path: Path) -> None: + a = write_dummy_png(tmp_path / "a" / "1.png", seed=300).read_bytes() + b = write_dummy_png(tmp_path / "b" / "1.png", seed=300).read_bytes() + c = write_dummy_png(tmp_path / "c" / "1.png", seed=300 + 256).read_bytes() + assert a == b + assert a != c + + +def test_store_dir_exists_and_is_empty(store_dir: Path) -> None: + assert store_dir.is_dir() + assert store_dir.name == "store" + assert list(store_dir.iterdir()) == [] diff --git a/tests/test_datasets.py b/tests/test_datasets.py new file mode 100644 index 0000000..1a3ce66 --- /dev/null +++ b/tests/test_datasets.py @@ -0,0 +1,916 @@ +"""データセットの定義・版の作成(SPEC_03「データセットの定義」「版の作成」,SPEC_02「datasets」). + +必須対象 1(版の凍結.最重要): 所見を訂正・データを追加した後も,過去の版から同じ構成・ +同じ manifest.csv(SHA-256 一致)が得られること.構成が同じなら新しい版を作らないこと. + +目録はダミーの行を直接登録して作る(取り込みを繰り返さず速くするため.取り込みからの流れは +`test_cli.py` の統合テストで確かめる).臨床データは使わない. +""" + +import csv +import hashlib +import io +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from pydantic import BaseModel, ConfigDict + +from tiasdata import catalog, kinds +from tiasdata.catalog import versions +from tiasdata.datasets import ( + DatasetDefinition, + check_definition_kinds, + collect_composition, + count_manifest_mismatches, + freeze_version, + list_definition_names, + load_definition, + parse_definition, + rebuild_manifest, +) +from tiasdata.manifest import manifest_sha256 +from tiasdata.repo import RepoState + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMMIT = "0123456789abcdef0123456789abcdef01234567" +CLEAN = RepoState(commit=COMMIT, uncommitted=0) +NOW = datetime(2026, 10, 6, 9, 0, 0, tzinfo=UTC) +# 元のファイル名に仕込む架空の患者 ID(manifest・メッセージに出ないことを確かめる) +LEAK_KEY = "990077" + +TONGUE_COLOR_TOML = """\ +name = "tongue_color" +description = "舌色 5 分類+程度(テスト)" + +[select] +asset_kind = "tongue_crop_masked" +require = ["tongue_color"] + +[columns] +tongue_color = ["label", "severity"] +""" + +FROZEN_TABLES = ("datasets", "dataset_versions", "dataset_members", "dataset_member_annotations") + + +# --------------------------------------------------------------------------- +# ダミーの目録 +# --------------------------------------------------------------------------- +def _sha(seed: object) -> str: + return hashlib.sha256(f"dummy-{seed}".encode()).hexdigest() + + +def _tc(label: str, severity: str) -> str: + return kinds.dump_annotation_value( + kinds.validate_annotation_value("tongue_color", {"label": label, "severity": severity}) + ) + + +class Cat: + """ダミーの目録を作る補助.""" + + def __init__(self, conn: sqlite3.Connection) -> None: + self.conn = conn + + def subject(self, subject_id: str) -> None: + self.conn.execute( + "INSERT OR IGNORE INTO subjects (subject_id, created_at) VALUES (?, ?)", + (subject_id, "2026-10-01T00:00:00Z"), + ) + self.conn.commit() + + def capture(self, capture_id: str, subject_id: str) -> None: + self.subject(subject_id) + self.conn.execute( + "INSERT INTO captures (capture_id, subject_id) VALUES (?, ?)", (capture_id, subject_id) + ) + self.conn.commit() + + def asset( + self, + seed: object, + capture_id: str | None, + *, + kind: str = "tongue_crop_masked", + ext: str = "png", + ) -> str: + sha = _sha(seed) + self.conn.execute( + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source, source_name," + " registered_at) VALUES (?, ?, ?, ?, 10, 'dummy_src', ?, '2026-10-01T00:00:00Z')", + (sha, kind, capture_id, ext, f"患者{LEAK_KEY}/{seed}_original.png"), + ) + self.conn.commit() + return sha + + def annotate( + self, + value: str, + *, + kind: str = "tongue_color", + capture_id: str | None = None, + sha256: str | None = None, + ) -> int: + cursor = self.conn.execute( + "INSERT INTO annotations (kind, capture_id, sha256, value, source, created_at)" + " VALUES (?, ?, ?, ?, 'dummy_src', '2026-10-01T00:00:00Z')", + (kind, capture_id, sha256, value), + ) + self.conn.commit() + return int(cursor.lastrowid) + + def correct(self, old_id: int, value: str) -> int: + """所見を訂正する(新しい行を足し,古い行の superseded_by を埋める).""" + row = self.conn.execute( + "SELECT kind, capture_id, sha256 FROM annotations WHERE annotation_id = ?", (old_id,) + ).fetchone() + new_id = self.annotate(value, kind=row[0], capture_id=row[1], sha256=row[2]) + self.conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", (new_id, old_id) + ) + self.conn.commit() + return new_id + + def tagged( + self, n: int, subject_id: str, label: str = "red", severity: str = "mild" + ) -> tuple[str, int]: + """撮影 1 つ・ファイル 1 つ・撮影に付いた舌色の所見 1 つを足す.""" + capture_id = f"C{n:06d}" + self.capture(capture_id, subject_id) + sha = self.asset(n, capture_id) + annotation_id = self.annotate(_tc(label, severity), capture_id=capture_id) + return sha, annotation_id + + +@pytest.fixture +def cat(catalog_conn: sqlite3.Connection) -> Cat: + """撮影 4 つ(症例・撮影・SHA-256 の順が食い違う)を登録した目録. + + - C000003(S000001)・C000004(S000001)・C000001(S000002): 舌色あり + - C000002(S000003): 舌色なし(必須の所見が無いので版に入らない) + """ + # テストの速さのため同期を省く(目録の中身・制約には影響しない) + catalog_conn.execute("PRAGMA synchronous = OFF") + c = Cat(catalog_conn) + c.tagged(3, "S000001", "red", "mild") + c.tagged(4, "S000001", "light_red", "normal") + c.tagged(1, "S000002", "pale", "strong") + c.capture("C000002", "S000003") + c.asset(2, "C000002") + return c + + +def _loaded(tmp_path: Path, text: str = TONGUE_COLOR_TOML, name: str = "tongue_color"): + directory = tmp_path / "datasets" + directory.mkdir(exist_ok=True) + # 改行を変換しないように bytes で書く + (directory / f"{name}.toml").write_bytes(text.encode("utf-8")) + return load_definition(directory, name) + + +def _freeze(conn: sqlite3.Connection, loaded, **kwargs): + kwargs.setdefault("repo_state", CLEAN) + kwargs.setdefault("now", NOW) + return freeze_version(conn, loaded, **kwargs) + + +def _snapshot(conn: sqlite3.Connection, name: str, version: int) -> dict: + return { + "version": conn.execute( + "SELECT * FROM dataset_versions WHERE name = ? AND version = ?", (name, version) + ).fetchall(), + "members": conn.execute( + "SELECT sha256 FROM dataset_members WHERE name = ? AND version = ? ORDER BY sha256", + (name, version), + ).fetchall(), + "member_annotations": conn.execute( + "SELECT sha256, annotation_id FROM dataset_member_annotations" + " WHERE name = ? AND version = ? ORDER BY sha256, annotation_id", + (name, version), + ).fetchall(), + } + + +def _count(conn: sqlite3.Connection, table: str) -> int: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + + +def _rows(data: bytes) -> list[dict[str, str]]: + return list(csv.DictReader(io.StringIO(data.decode("utf-8"), newline=""))) + + +# --------------------------------------------------------------------------- +# 定義の形 +# --------------------------------------------------------------------------- +class TestParseDefinition: + def test_repository_definition_is_valid(self) -> None: + text = (REPO_ROOT / "datasets" / "tongue_color.toml").read_bytes().decode("utf-8") + definition = parse_definition(text, "tongue_color") + assert definition.name == "tongue_color" + assert definition.select.asset_kind == "tongue_crop_masked" + assert definition.select.require == ["tongue_color"] + assert definition.columns == {"tongue_color": ["label", "severity"]} + assert definition.description + + def test_repository_definition_uses_lf(self) -> None: + assert b"\r" not in (REPO_ROOT / "datasets" / "tongue_color.toml").read_bytes() + + def test_columns_keep_order(self) -> None: + text = TONGUE_COLOR_TOML.replace( + 'tongue_color = ["label", "severity"]', 'tongue_color = ["severity", "label"]' + ) + assert parse_definition(text, "tongue_color").columns == { + "tongue_color": ["severity", "label"] + } + + @pytest.mark.parametrize( + "text", + [ + pytest.param(TONGUE_COLOR_TOML + "\nunknown = 1\n", id="unknown-top-key"), + pytest.param( + TONGUE_COLOR_TOML.replace("[select]\n", "[select]\nfilter = 1\n"), + id="unknown-select-key", + ), + pytest.param( + TONGUE_COLOR_TOML.replace('description = "舌色 5 分類+程度(テスト)"\n', ""), + id="missing-description", + ), + pytest.param( + TONGUE_COLOR_TOML.replace('"舌色 5 分類+程度(テスト)"', '""'), + id="empty-description", + ), + pytest.param( + TONGUE_COLOR_TOML.replace('asset_kind = "tongue_crop_masked"\n', ""), + id="missing-asset-kind", + ), + pytest.param('name = "tongue_color"\ndescription = "x"\n', id="missing-select"), + pytest.param( + TONGUE_COLOR_TOML.replace('["label", "severity"]', "[]"), id="empty-columns-fields" + ), + pytest.param( + TONGUE_COLOR_TOML.replace('["label", "severity"]', '["label", "label"]'), + id="duplicated-column-field", + ), + pytest.param( + TONGUE_COLOR_TOML.replace('["tongue_color"]', '["tongue_color", "tongue_color"]'), + id="duplicated-require", + ), + pytest.param("name = [", id="broken-toml"), + ], + ) + def test_invalid_definition_is_value_error(self, text: str) -> None: + with pytest.raises(ValueError): + parse_definition(text, "tongue_color") + + def test_name_must_match_file_name(self) -> None: + with pytest.raises(ValueError, match="一致しない"): + parse_definition(TONGUE_COLOR_TOML, "other_name") + + def test_message_is_one_line(self) -> None: + text = TONGUE_COLOR_TOML + "\nunknown = 1\nfoo = 2\n" + with pytest.raises(ValueError) as exc: + parse_definition(text, "tongue_color") + assert "\n" not in str(exc.value) + + +class TestLoadDefinition: + def test_keeps_text_as_is(self, tmp_path: Path) -> None: + loaded = _loaded(tmp_path) + assert loaded.text == TONGUE_COLOR_TOML + assert loaded.definition.name == "tongue_color" + + def test_missing_file(self, tmp_path: Path) -> None: + (tmp_path / "datasets").mkdir() + with pytest.raises(ValueError, match="無い"): + load_definition(tmp_path / "datasets", "tongue_color") + + @pytest.mark.parametrize("name", ["../tongue_color", "Tongue", "1abc", "a-b", ""]) + def test_invalid_name(self, tmp_path: Path, name: str) -> None: + _loaded(tmp_path) + with pytest.raises(ValueError): + load_definition(tmp_path / "datasets", name) + + def test_list_definition_names(self, tmp_path: Path) -> None: + _loaded(tmp_path) + _loaded(tmp_path, TONGUE_COLOR_TOML.replace('"tongue_color"\n', '"abc"\n', 1), "abc") + (tmp_path / "datasets" / "Bad-Name.toml").write_bytes(b"") + (tmp_path / "datasets" / "readme.md").write_bytes(b"") + assert list_definition_names(tmp_path / "datasets") == ["abc", "tongue_color"] + + +class TestCheckDefinitionKinds: + @pytest.mark.parametrize( + ("old", "new"), + [ + pytest.param('"tongue_crop_masked"', '"no_such_asset"', id="unknown-asset-kind"), + pytest.param( + 'asset_kind = "tongue_crop_masked"', + 'asset_kind = "tongue_color"', + id="asset-kind-is-annotation", + ), + pytest.param( + 'require = ["tongue_color"]', 'require = ["no_such"]', id="unknown-require" + ), + pytest.param( + 'require = ["tongue_color"]', 'require = ["tongue_raw"]', id="require-is-asset" + ), + pytest.param('["label", "severity"]', '["label", "colour"]', id="unknown-field"), + pytest.param("tongue_color = [", "no_such = [", id="unknown-column-kind"), + ], + ) + def test_rejects_unknown_kinds_and_fields( + self, catalog_conn: sqlite3.Connection, old: str, new: str + ) -> None: + definition = parse_definition(TONGUE_COLOR_TOML.replace(old, new), "tongue_color") + with pytest.raises(ValueError): + check_definition_kinds(definition, versions.kind_categories(catalog_conn)) + + def test_accepts_repository_definition(self, catalog_conn: sqlite3.Connection) -> None: + definition = parse_definition(TONGUE_COLOR_TOML, "tongue_color") + check_definition_kinds(definition, versions.kind_categories(catalog_conn)) + + def test_unknown_kind_stops_freeze_without_rows(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path, TONGUE_COLOR_TOML.replace('"tongue_crop_masked"', '"no_such"')) + with pytest.raises(ValueError): + _freeze(cat.conn, loaded) + assert all(_count(cat.conn, t) == 0 for t in FROZEN_TABLES) + + +# --------------------------------------------------------------------------- +# 版の作成 +# --------------------------------------------------------------------------- +class TestFreeze: + def test_creates_first_version(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path) + result = _freeze(cat.conn, loaded, note="最初の版") + assert result.created + assert result.version == 1 + assert result.candidates == 4 + assert result.members == 3 + assert result.member_annotations == 3 + + record = versions.get_version(cat.conn, "tongue_color", 1) + assert record is not None + assert record.definition == TONGUE_COLOR_TOML + assert record.hub_commit == COMMIT + assert record.created_at == "2026-10-06T09:00:00Z" + assert record.note == "最初の版" + assert record.manifest_sha256 == result.manifest_sha256 + # 記録した SHA-256 は構成から作った manifest のもの + assert ( + manifest_sha256(rebuild_manifest(cat.conn, "tongue_color", 1)) == record.manifest_sha256 + ) + + members = {m.sha256 for m in versions.version_members(cat.conn, "tongue_color", 1)} + assert members == {_sha(1), _sha(3), _sha(4)} + dataset = versions.get_dataset(cat.conn, "tongue_color") + assert dataset is not None + assert dataset.description == "舌色 5 分類+程度(テスト)" + + def test_manifest_content(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + data = rebuild_manifest(cat.conn, "tongue_color", 1) + assert not data.startswith(b"\xef\xbb\xbf") + assert b"\r" not in data + text = data.decode("utf-8") + assert text.splitlines()[0] == ( + "path,sha256,kind,subject_id,capture_id,tongue_color.label,tongue_color.severity" + ) + rows = _rows(data) + # subject_id・capture_id・sha256 の順 + assert [(r["subject_id"], r["capture_id"]) for r in rows] == [ + ("S000001", "C000003"), + ("S000001", "C000004"), + ("S000002", "C000001"), + ] + assert rows[0]["path"] == f"files/{_sha(3)}.png" + assert rows[0]["sha256"] == _sha(3) + assert rows[0]["kind"] == "tongue_crop_masked" + assert (rows[0]["tongue_color.label"], rows[0]["tongue_color.severity"]) == ("red", "mild") + assert (rows[1]["tongue_color.label"], rows[1]["tongue_color.severity"]) == ( + "light_red", + "normal", + ) + # 元のファイル名・患者 ID は出さない + assert LEAK_KEY not in text + assert "original" not in text + assert "dummy_src" not in text + + def test_columns_follow_definition_order(self, tmp_path: Path, cat: Cat) -> None: + text = TONGUE_COLOR_TOML.replace('["label", "severity"]', '["severity", "label"]') + _freeze(cat.conn, _loaded(tmp_path, text)) + header = rebuild_manifest(cat.conn, "tongue_color", 1).decode().splitlines()[0] + assert header.endswith("tongue_color.severity,tongue_color.label") + + def test_same_composition_does_not_create_version(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path) + first = _freeze(cat.conn, loaded) + before = {t: _count(cat.conn, t) for t in FROZEN_TABLES} + second = _freeze( + cat.conn, + loaded, + repo_state=RepoState("f" * 40, 0), + note="x", + now=datetime(2027, 1, 1, tzinfo=UTC), + ) + assert not second.created + assert second.version == 1 + assert second.manifest_sha256 == first.manifest_sha256 + assert {t: _count(cat.conn, t) for t in FROZEN_TABLES} == before + + def test_file_without_required_annotation_is_not_a_change( + self, tmp_path: Path, cat: Cat + ) -> None: + loaded = _loaded(tmp_path) + _freeze(cat.conn, loaded) + cat.capture("C000005", "S000004") + cat.asset(5, "C000005") # 舌色なし + cat.asset(6, "C000005", kind="tongue_raw") # 種類が違う + result = _freeze(cat.conn, loaded) + assert not result.created + assert result.candidates == 5 + + def test_definition_change_creates_version_even_if_composition_same( + self, tmp_path: Path, cat: Cat + ) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + changed = TONGUE_COLOR_TOML.replace("(テスト)", "(説明だけ変更)") + result = _freeze(cat.conn, _loaded(tmp_path, changed)) + assert result.created + assert result.version == 2 + assert versions.get_version(cat.conn, "tongue_color", 2).definition == changed + # 構成は同じ + assert ( + _snapshot(cat.conn, "tongue_color", 1)["members"] + == _snapshot(cat.conn, "tongue_color", 2)["members"] + ) + # datasets の説明は最初の版のまま + assert versions.get_dataset(cat.conn, "tongue_color").description == ( + "舌色 5 分類+程度(テスト)" + ) + # 同じ定義でもう一度は作らない + assert not _freeze(cat.conn, _loaded(tmp_path, changed)).created + + def test_definition_whitespace_change_creates_version(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + assert _freeze(cat.conn, _loaded(tmp_path, TONGUE_COLOR_TOML + "\n")).created + + def test_empty_note_is_stored_as_none(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path), note="") + assert versions.get_version(cat.conn, "tongue_color", 1).note is None + + def test_versions_are_numbered_per_dataset(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + other = TONGUE_COLOR_TOML.replace('name = "tongue_color"', 'name = "other_set"') + result = _freeze(cat.conn, _loaded(tmp_path, other, "other_set")) + assert result.created + assert result.version == 1 + + def test_datasets_dir_with_crlf_definition_is_recorded_as_is( + self, tmp_path: Path, cat: Cat + ) -> None: + crlf = TONGUE_COLOR_TOML.replace("\n", "\r\n") + _freeze(cat.conn, _loaded(tmp_path, crlf)) + assert versions.get_version(cat.conn, "tongue_color", 1).definition == crlf + + +class TestFrozenVersion: + """必須対象 1: 過去の版は所見の訂正・データの追加の後も変わらない(最重要).""" + + def test_freeze_keeps_past_version_unchanged(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path) + v1 = _freeze(cat.conn, loaded) + snapshot = _snapshot(cat.conn, "tongue_color", 1) + manifest_v1 = rebuild_manifest(cat.conn, "tongue_color", 1) + + # 所見の訂正(C000003: red/mild → dark_red/strong) + old_id = cat.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000003'" + ).fetchone()[0] + cat.correct(old_id, _tc("dark_red", "strong")) + # 新しい画像(既存の症例の新しい撮影)と,既存の撮影に後から舌色を付ける + cat.tagged(7, "S000002", "purple", "mild") + cat.annotate(_tc("pale", "mild"), capture_id="C000002") + + v2 = _freeze(cat.conn, loaded) + assert v2.created + assert v2.version == 2 + assert v2.members == 5 + + # 版 1 は構成・manifest のバイト列・SHA-256 が変わらない + assert _snapshot(cat.conn, "tongue_color", 1) == snapshot + rebuilt = rebuild_manifest(cat.conn, "tongue_color", 1) + assert rebuilt == manifest_v1 + assert manifest_sha256(rebuilt) == v1.manifest_sha256 + assert ( + versions.get_version(cat.conn, "tongue_color", 1).manifest_sha256 == v1.manifest_sha256 + ) + # 版 1 は古い値を返す + row_v1 = {r["capture_id"]: r for r in _rows(rebuilt)} + assert row_v1["C000003"]["tongue_color.label"] == "red" + assert "C000007" not in row_v1 + assert "C000002" not in row_v1 + assert old_id in [a for (_, a) in snapshot["member_annotations"]] + + # 版 2 には変更が反映される + manifest_v2 = rebuild_manifest(cat.conn, "tongue_color", 2) + assert manifest_sha256(manifest_v2) == v2.manifest_sha256 + row_v2 = {r["capture_id"]: r for r in _rows(manifest_v2)} + assert ( + row_v2["C000003"]["tongue_color.label"], + row_v2["C000003"]["tongue_color.severity"], + ) == ( + "dark_red", + "strong", + ) + assert row_v2["C000007"]["tongue_color.label"] == "purple" + assert row_v2["C000002"]["tongue_color.label"] == "pale" + ids_v2 = [a for (_, a) in _snapshot(cat.conn, "tongue_color", 2)["member_annotations"]] + assert old_id not in ids_v2 + + # 変更が無ければ版 3 は作らない + assert not _freeze(cat.conn, loaded).created + assert _count(cat.conn, "dataset_versions") == 2 + assert count_manifest_mismatches(cat.conn) == (2, 0) + + def test_correction_alone_creates_new_version(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path) + v1 = _freeze(cat.conn, loaded) + old_id = cat.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000001'" + ).fetchone()[0] + cat.correct(old_id, _tc("pale", "mild")) + v2 = _freeze(cat.conn, loaded) + assert v2.created + assert v2.manifest_sha256 != v1.manifest_sha256 + assert manifest_sha256(rebuild_manifest(cat.conn, "tongue_color", 1)) == v1.manifest_sha256 + + def test_correction_to_same_value_still_changes_composition( + self, tmp_path: Path, cat: Cat + ) -> None: + # 構成は「ファイルと採用した所見(の行)」で比べる.値が同じでも行が変われば新しい版 + loaded = _loaded(tmp_path) + v1 = _freeze(cat.conn, loaded) + old_id = cat.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000001'" + ).fetchone()[0] + cat.correct(old_id, _tc("pale", "strong")) + v2 = _freeze(cat.conn, loaded) + assert v2.created + # manifest の中身は同じ + assert v2.manifest_sha256 == v1.manifest_sha256 + + def test_past_version_survives_definition_change(self, tmp_path: Path, cat: Cat) -> None: + v1 = _freeze(cat.conn, _loaded(tmp_path)) + manifest_v1 = rebuild_manifest(cat.conn, "tongue_color", 1) + changed = TONGUE_COLOR_TOML.replace('["label", "severity"]', '["severity"]') + _freeze(cat.conn, _loaded(tmp_path, changed)) + assert rebuild_manifest(cat.conn, "tongue_color", 1) == manifest_v1 + assert manifest_sha256(manifest_v1) == v1.manifest_sha256 + assert ( + rebuild_manifest(cat.conn, "tongue_color", 2) + .decode() + .splitlines()[0] + .endswith("capture_id,tongue_color.severity") + ) + + @pytest.mark.parametrize( + "sql", + [ + "UPDATE dataset_versions SET note = 'x'", + "UPDATE dataset_versions SET manifest_sha256 = '" + "0" * 64 + "'", + "DELETE FROM dataset_versions", + "UPDATE dataset_members SET sha256 = sha256", + "DELETE FROM dataset_members", + "UPDATE dataset_member_annotations SET annotation_id = annotation_id", + "DELETE FROM dataset_member_annotations", + ], + ) + def test_frozen_rows_cannot_be_updated_or_deleted( + self, tmp_path: Path, cat: Cat, sql: str + ) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + snapshot = _snapshot(cat.conn, "tongue_color", 1) + with pytest.raises(sqlite3.DatabaseError): + cat.conn.execute(sql) + cat.conn.rollback() + assert _snapshot(cat.conn, "tongue_color", 1) == snapshot + + def test_annotation_used_by_version_cannot_be_rewritten(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + with pytest.raises(sqlite3.DatabaseError): + cat.conn.execute(f"UPDATE annotations SET value = '{_tc('purple', 'mild')}'") + cat.conn.rollback() + + +class TestFreezeStops: + def _assert_nothing_created(self, conn: sqlite3.Connection) -> None: + assert all(_count(conn, t) == 0 for t in FROZEN_TABLES) + + def test_uncommitted_changes_stop(self, tmp_path: Path, cat: Cat) -> None: + with pytest.raises(ValueError, match="未コミット"): + _freeze(cat.conn, _loaded(tmp_path), repo_state=RepoState(COMMIT, 2)) + self._assert_nothing_created(cat.conn) + assert not cat.conn.in_transaction + + def test_uncommitted_is_checked_before_catalog( + self, tmp_path: Path, catalog_conn: sqlite3.Connection + ) -> None: + # 目録に条件に合うファイルが無くても,まず未コミットの変更で止まる + with pytest.raises(ValueError, match="未コミット"): + _freeze(catalog_conn, _loaded(tmp_path), repo_state=RepoState(COMMIT, 1)) + + def test_uncommitted_after_version_does_not_create(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path) + _freeze(cat.conn, loaded) + cat.tagged(9, "S000009") + with pytest.raises(ValueError, match="未コミット"): + _freeze(cat.conn, loaded, repo_state=RepoState(COMMIT, 1)) + assert _count(cat.conn, "dataset_versions") == 1 + + def test_zero_files_stops(self, tmp_path: Path, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(ValueError, match="0 件"): + _freeze(catalog_conn, _loaded(tmp_path)) + self._assert_nothing_created(catalog_conn) + + def test_zero_files_with_required_annotation_stops( + self, tmp_path: Path, catalog_conn: sqlite3.Connection + ) -> None: + c = Cat(catalog_conn) + c.capture("C000001", "S000001") + c.asset(1, "C000001") + with pytest.raises(ValueError, match="0 件"): + _freeze(catalog_conn, _loaded(tmp_path)) + self._assert_nothing_created(catalog_conn) + + def test_file_without_capture_stops(self, tmp_path: Path, cat: Cat) -> None: + sha = cat.asset(20, None) + cat.annotate(_tc("red", "mild"), sha256=sha) # ファイルに付いた所見で条件に合う + with pytest.raises(ValueError, match="撮影") as exc: + _freeze(cat.conn, _loaded(tmp_path)) + assert LEAK_KEY not in str(exc.value) + assert "original" not in str(exc.value) + self._assert_nothing_created(cat.conn) + + def test_file_without_capture_and_no_require_stops(self, tmp_path: Path, cat: Cat) -> None: + cat.asset(21, None) + text = TONGUE_COLOR_TOML.replace('require = ["tongue_color"]', "require = []") + with pytest.raises(ValueError, match="撮影"): + _freeze(cat.conn, _loaded(tmp_path, text)) + self._assert_nothing_created(cat.conn) + + def test_file_without_capture_not_matching_is_ignored(self, tmp_path: Path, cat: Cat) -> None: + cat.asset(22, None) # 必須の所見が無いので版に入らない + assert _freeze(cat.conn, _loaded(tmp_path)).members == 3 + + def test_two_valid_capture_annotations_stop(self, tmp_path: Path, cat: Cat) -> None: + cat.annotate(_tc("purple", "strong"), capture_id="C000003") + with pytest.raises(ValueError, match="2 つ以上") as exc: + _freeze(cat.conn, _loaded(tmp_path)) + assert "C000003" in str(exc.value) + assert LEAK_KEY not in str(exc.value) + self._assert_nothing_created(cat.conn) + + def test_capture_and_file_annotations_are_counted_together( + self, tmp_path: Path, cat: Cat + ) -> None: + cat.annotate(_tc("purple", "strong"), sha256=_sha(4)) + with pytest.raises(ValueError, match="2 つ以上"): + _freeze(cat.conn, _loaded(tmp_path)) + self._assert_nothing_created(cat.conn) + + def test_two_file_annotations_stop(self, tmp_path: Path, cat: Cat) -> None: + sha = cat.asset(23, "C000002") + cat.annotate(_tc("red", "mild"), sha256=sha) + cat.annotate(_tc("red", "strong"), sha256=sha) + with pytest.raises(ValueError, match="2 つ以上"): + _freeze(cat.conn, _loaded(tmp_path)) + + def test_superseded_annotation_is_not_counted(self, tmp_path: Path, cat: Cat) -> None: + old_id = cat.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000003'" + ).fetchone()[0] + cat.correct(old_id, _tc("purple", "strong")) + result = _freeze(cat.conn, _loaded(tmp_path)) + assert result.created + assert result.members == 3 + + def test_duplicate_after_version_does_not_change_past(self, tmp_path: Path, cat: Cat) -> None: + loaded = _loaded(tmp_path) + v1 = _freeze(cat.conn, loaded) + cat.annotate(_tc("purple", "strong"), capture_id="C000003") + with pytest.raises(ValueError, match="2 つ以上"): + _freeze(cat.conn, loaded) + assert _count(cat.conn, "dataset_versions") == 1 + assert manifest_sha256(rebuild_manifest(cat.conn, "tongue_color", 1)) == v1.manifest_sha256 + + def test_catalog_is_not_in_transaction_after_failure( + self, tmp_path: Path, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(ValueError): + _freeze(catalog_conn, _loaded(tmp_path)) + assert not catalog_conn.in_transaction + + +class TestRebuild: + def test_unknown_version(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + with pytest.raises(ValueError): + rebuild_manifest(cat.conn, "tongue_color", 2) + with pytest.raises(ValueError): + rebuild_manifest(cat.conn, "no_such", 1) + + def test_rebuild_is_deterministic(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + assert rebuild_manifest(cat.conn, "tongue_color", 1) == rebuild_manifest( + cat.conn, "tongue_color", 1 + ) + + def test_composition_is_independent_of_insert_order( + self, tmp_path: Path, catalog_conn: sqlite3.Connection, tmp_path_factory + ) -> None: + # 登録の順が違っても同じ構成なら同じ manifest になる + other_path = tmp_path_factory.mktemp("other") / "catalog.sqlite" + other = catalog.connect(other_path, create=True) + try: + catalog.apply_migrations(other, "catalog") + a, b = Cat(catalog_conn), Cat(other) + for n, s in [(3, "S000001"), (1, "S000002"), (4, "S000001")]: + a.tagged(n, s) + for n, s in [(4, "S000001"), (1, "S000002"), (3, "S000001")]: + b.tagged(n, s) + loaded = _loaded(tmp_path) + assert ( + _freeze(catalog_conn, loaded).manifest_sha256 + == _freeze(other, loaded).manifest_sha256 + ) + finally: + other.close() + + +class TestCountManifestMismatches: + def test_no_versions(self, catalog_conn: sqlite3.Connection) -> None: + assert count_manifest_mismatches(catalog_conn) == (0, 0) + + def test_after_freeze_and_corrections(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + old_id = cat.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000004'" + ).fetchone()[0] + cat.correct(old_id, _tc("red", "mild")) + assert count_manifest_mismatches(cat.conn) == (1, 0) + + def test_asset_kind_changed_is_mismatch(self, tmp_path: Path, cat: Cat) -> None: + # assets は凍結の対象外なので,参照先を書き換えると作り直した manifest が変わる + _freeze(cat.conn, _loaded(tmp_path)) + cat.conn.execute("UPDATE assets SET kind = 'tongue_raw' WHERE sha256 = ?", (_sha(3),)) + cat.conn.commit() + assert count_manifest_mismatches(cat.conn) == (1, 1) + + def test_capture_subject_changed_is_mismatch(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + cat.subject("S000099") + cat.conn.execute("UPDATE captures SET subject_id = 'S000099' WHERE capture_id = 'C000001'") + cat.conn.commit() + assert count_manifest_mismatches(cat.conn) == (1, 1) + + def test_tampered_manifest_sha256_is_mismatch(self, tmp_path: Path, cat: Cat) -> None: + # トリガを外して記録を壊す(点検が記録との不一致を見つけることを確かめるため) + _freeze(cat.conn, _loaded(tmp_path)) + cat.conn.execute("DROP TRIGGER dataset_versions_no_update") + cat.conn.execute("UPDATE dataset_versions SET manifest_sha256 = ?", ("0" * 64,)) + cat.conn.commit() + assert count_manifest_mismatches(cat.conn) == (1, 1) + + def test_unparsable_recorded_definition_is_mismatch(self, tmp_path: Path, cat: Cat) -> None: + _freeze(cat.conn, _loaded(tmp_path)) + cat.conn.execute("DROP TRIGGER dataset_versions_no_update") + cat.conn.execute("UPDATE dataset_versions SET definition = 'name = ['") + cat.conn.commit() + assert count_manifest_mismatches(cat.conn) == (1, 1) + + +# --------------------------------------------------------------------------- +# 拡張: 所見の種類を足しても既存の表を変えずに済む(PLAN_02 Step 4 の完了条件) +# --------------------------------------------------------------------------- +class TongueCoating(BaseModel): + """テスト用のダミーの所見の種類(舌苔).""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + thickness: str + moist: str | None = None + + +COATING_TOML = """\ +name = "coating" +description = "舌苔(テスト用のダミーの種類)" + +[select] +asset_kind = "tongue_crop_masked" +require = ["tongue_coating"] + +[columns] +tongue_coating = ["thickness", "moist"] +tongue_color = ["label"] +""" + + +def _schema(conn: sqlite3.Connection) -> dict: + tables = [ + r[0] + for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + ] + return { + "sql": conn.execute( + "SELECT type, name, sql FROM sqlite_master ORDER BY type, name" + ).fetchall(), + "columns": {t: conn.execute(f"PRAGMA table_info({t})").fetchall() for t in tables}, + } + + +class TestNewAnnotationKind: + def test_dataset_with_new_annotation_kind( + self, tmp_path: Path, cat: Cat, monkeypatch: pytest.MonkeyPatch + ) -> None: + conn = cat.conn + tc = _freeze(conn, _loaded(tmp_path)) + manifest_tc = rebuild_manifest(conn, "tongue_color", 1) + schema = _schema(conn) + + # 種類の行と値の型を足す(表の構造は変えない) + conn.execute( + "INSERT INTO kinds (kind, category, description, value_schema_version)" + " VALUES ('tongue_coating', 'annotation', 'テスト用の舌苔', 1)" + ) + conn.commit() + monkeypatch.setitem(kinds.ANNOTATION_VALUE_TYPES, "tongue_coating", TongueCoating) + + # 撮影に付く苔の所見(C000003・C000002),ファイルに付く苔の所見(C000001 のファイル) + cat.annotate( + '{"moist":"wet","thickness":"thick"}', kind="tongue_coating", capture_id="C000003" + ) + cat.annotate('{"thickness":"thin"}', kind="tongue_coating", capture_id="C000002") + cat.annotate('{"moist":null,"thickness":"none"}', kind="tongue_coating", sha256=_sha(1)) + + result = _freeze(conn, _loaded(tmp_path, COATING_TOML, "coating")) + assert result.created + assert result.members == 3 + # 舌色は columns だけ(必須ではない)なので,あるファイルだけ採用する + assert result.member_annotations == 3 + 2 + + rows = {r["capture_id"]: r for r in _rows(rebuild_manifest(conn, "coating", 1))} + assert list(next(iter(rows.values())).keys()) == [ + "path", + "sha256", + "kind", + "subject_id", + "capture_id", + "tongue_coating.thickness", + "tongue_coating.moist", + "tongue_color.label", + ] + assert ( + rows["C000003"]["tongue_coating.thickness"], + rows["C000003"]["tongue_coating.moist"], + ) == ( + "thick", + "wet", + ) + assert rows["C000003"]["tongue_color.label"] == "red" + # 値が無い所見・項目は空 + assert rows["C000002"]["tongue_coating.moist"] == "" + assert rows["C000002"]["tongue_color.label"] == "" + assert rows["C000001"]["tongue_coating.moist"] == "" + assert rows["C000001"]["tongue_coating.thickness"] == "none" + + # 既存の表の構造は変わらず,既存のデータセットの版も変わらない + assert _schema(conn) == schema + assert rebuild_manifest(conn, "tongue_color", 1) == manifest_tc + assert not _freeze(conn, _loaded(tmp_path)).created + assert count_manifest_mismatches(conn) == (2, 0) + assert manifest_sha256(manifest_tc) == tc.manifest_sha256 + + def test_new_kind_without_value_type_is_rejected(self, tmp_path: Path, cat: Cat) -> None: + cat.conn.execute( + "INSERT INTO kinds (kind, category, description, value_schema_version)" + " VALUES ('tongue_coating', 'annotation', 'テスト用', 1)" + ) + cat.conn.commit() + with pytest.raises(ValueError): + _freeze(cat.conn, _loaded(tmp_path, COATING_TOML, "coating")) + assert _count(cat.conn, "dataset_versions") == 0 + + +# --------------------------------------------------------------------------- +# 純関数の当てはめ +# --------------------------------------------------------------------------- +def test_collect_composition_does_not_write(tmp_path: Path, cat: Cat) -> None: + definition: DatasetDefinition = _loaded(tmp_path).definition + composition = collect_composition(cat.conn, definition) + assert composition.candidates == 4 + assert len(composition.members) == 3 + assert composition.excluded == 1 + assert all(_count(cat.conn, t) == 0 for t in FROZEN_TABLES) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..11b12d7 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,518 @@ +"""バンドルの書き出し(SPEC_03「バンドル」「CLI」の export・「計算サーバへの受け渡し」手順 1). + +必須対象 5(バンドル)の書き出し側: 書き出したバンドルを `open_bundle` で読めること, +同じ版から同じ manifest.csv(バイト列)が得られること,既にあるバンドルを上書きせず照合すること. + +目録はダミーの行を直接登録して作り,ストアには小さいダミーのファイルを置く(取り込みを繰り返さず +速くするため.取り込みからの流れは `test_cli.py` の統合テストで確かめる).臨床データは使わない. +""" + +import hashlib +import json +import os +import shutil +import sqlite3 +import stat +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from tiasdata import kinds, store +from tiasdata.bundle import open_bundle +from tiasdata.datasets import freeze_version, load_definition, rebuild_manifest +from tiasdata.export import export_bundle +from tiasdata.repo import RepoState + +NAME = "tongue_color" +COMMIT = "0123456789abcdef0123456789abcdef01234567" +NOW = datetime(2026, 10, 6, 9, 0, 0, tzinfo=UTC) +# 元のファイル名・フォルダ名に仕込む架空の患者 ID(バンドル・メッセージに出ないことを確かめる) +LEAK_KEY = "990077" + +TONGUE_COLOR_TOML = """\ +name = "tongue_color" +description = "舌色 5 分類+程度(テスト)" + +[select] +asset_kind = "tongue_crop_masked" +require = ["tongue_color"] + +[columns] +tongue_color = ["label", "severity"] +""" + +DATASET_JSON_KEYS = { + "format", + "name", + "version", + "created_at", + "hub_commit", + "files", + "manifest_sha256", +} + + +def _tc(label: str, severity: str) -> str: + return kinds.dump_annotation_value( + kinds.validate_annotation_value("tongue_color", {"label": label, "severity": severity}) + ) + + +@dataclass +class Env: + conn: sqlite3.Connection + root: Path + store_dir: Path + bundles_dir: Path + datasets_dir: Path + contents: dict[str, bytes] # SHA-256 → 中身 + + def add(self, n: int, subject_id: str, label: str = "red", severity: str = "mild") -> int: + """撮影 1 つ・ファイル 1 つ(ストアに実体あり)・撮影に付いた舌色の所見 1 つを足す.""" + capture_id = f"C{n:06d}" + source = self.root / "source" / f"患者{LEAK_KEY}" / f"{n}_original.png" + source.parent.mkdir(parents=True, exist_ok=True) + content = f"dummy-image-{n}".encode() * (n + 1) + source.write_bytes(content) + stored = store.add_file(self.store_dir, source) + self.contents[stored.sha256] = content + self.conn.execute( + "INSERT OR IGNORE INTO subjects (subject_id, created_at) VALUES (?, ?)", + (subject_id, "2026-10-01T00:00:00Z"), + ) + self.conn.execute( + "INSERT INTO captures (capture_id, subject_id) VALUES (?, ?)", (capture_id, subject_id) + ) + self.conn.execute( + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source, source_name," + " registered_at) VALUES (?, 'tongue_crop_masked', ?, 'png', ?, 'dummy_src', ?," + " '2026-10-01T00:00:00Z')", + (stored.sha256, capture_id, len(content), f"患者{LEAK_KEY}/{n}_original.png"), + ) + cursor = self.conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " VALUES ('tongue_color', ?, ?, 'dummy_src', '2026-10-01T00:00:00Z')", + (capture_id, _tc(label, severity)), + ) + self.conn.commit() + return int(cursor.lastrowid) + + def freeze(self, now: datetime = NOW): + loaded = load_definition(self.datasets_dir, NAME) + return freeze_version( + self.conn, loaded, repo_state=RepoState(commit=COMMIT, uncommitted=0), now=now + ) + + def export(self, version: int = 1, *, bundles_dir: Path | None = None, name: str = NAME): + return export_bundle( + self.conn, + bundles_dir=self.bundles_dir if bundles_dir is None else bundles_dir, + store_dir=self.store_dir, + name=name, + version=version, + ) + + def target(self, version: int = 1) -> Path: + return self.bundles_dir / NAME / f"v{version}" + + +@pytest.fixture +def env(tmp_path: Path, catalog_conn: sqlite3.Connection) -> Env: + """版 1(症例 2・撮影 3・ファイル 3)を凍結した目録と,実体のあるストア.""" + catalog_conn.execute("PRAGMA synchronous = OFF") + # パスに架空の患者 ID を含め,メッセージにパスが出ればすぐ分かるようにする + root = tmp_path / f"患者{LEAK_KEY}" + store_dir = root / "store" + bundles_dir = root / "bundles" + datasets_dir = root / "datasets" + for d in (store_dir, bundles_dir, datasets_dir): + d.mkdir(parents=True) + (datasets_dir / f"{NAME}.toml").write_bytes(TONGUE_COLOR_TOML.encode("utf-8")) + e = Env(catalog_conn, root, store_dir, bundles_dir, datasets_dir, {}) + e.add(3, "S000001", "red", "mild") + e.add(4, "S000001", "light_red", "normal") + e.add(1, "S000002", "pale", "strong") + result = e.freeze() + assert result.version == 1 + return e + + +def _record(conn: sqlite3.Connection, version: int = 1) -> tuple[str, str, str]: + return conn.execute( + "SELECT manifest_sha256, created_at, hub_commit FROM dataset_versions" + " WHERE name = ? AND version = ?", + (NAME, version), + ).fetchone() + + +def _tree(path: Path) -> dict[str, bytes]: + return { + p.relative_to(path).as_posix(): p.read_bytes() + for p in sorted(path.rglob("*")) + if p.is_file() + } + + +def _temp_entries(bundles_dir: Path) -> list[Path]: + return [p for p in bundles_dir.rglob("*") if p.name.startswith(".tmp-")] + + +def _assert_safe_message(message: str, env: Env) -> None: + # 例外メッセージにバンドル・ストアのパス・患者 ID・元のファイル名を出さない + for form in (str(env.root), env.root.as_posix(), str(env.root.resolve())): + assert form not in message, message + assert LEAK_KEY not in message, message + assert "_original" not in message, message + + +def _export_error(env: Env, **kwargs) -> str: + with pytest.raises(ValueError) as exc: + env.export(**kwargs) + message = str(exc.value) + _assert_safe_message(message, env) + return message + + +def _make_writable(path: Path) -> None: + os.chmod(path, stat.S_IWRITE | stat.S_IREAD) + + +# --------------------------------------------------------------------------- +# 書き出したバンドルの形 +# --------------------------------------------------------------------------- +class TestExportedBundle: + def test_layout(self, env: Env) -> None: + result = env.export() + target = env.target() + assert target.is_dir() + assert sorted(p.name for p in target.iterdir()) == [ + "SHA256SUMS", + "dataset.json", + "files", + "manifest.csv", + ] + assert sorted(p.name for p in (target / "files").iterdir()) == sorted( + f"{sha}.png" for sha in env.contents + ) + assert result.reused is False + assert result.name == NAME + assert result.version == 1 + assert result.files == 3 + assert result.size_bytes == sum(len(c) for c in env.contents.values()) + + def test_files_match_store_contents(self, env: Env) -> None: + env.export() + for sha, content in env.contents.items(): + path = env.target() / "files" / f"{sha}.png" + assert path.read_bytes() == content + assert hashlib.sha256(path.read_bytes()).hexdigest() == sha + + def test_manifest_matches_version_record(self, env: Env) -> None: + result = env.export() + data = (env.target() / "manifest.csv").read_bytes() + digest, _, _ = _record(env.conn) + assert hashlib.sha256(data).hexdigest() == digest + assert result.manifest_sha256 == digest + assert data == rebuild_manifest(env.conn, NAME, 1) + assert b"\r" not in data + assert not data.startswith(b"\xef\xbb\xbf") + + def test_dataset_json_items(self, env: Env) -> None: + env.export() + raw = (env.target() / "dataset.json").read_bytes() + info = json.loads(raw.decode("utf-8")) + digest, created_at, hub_commit = _record(env.conn) + assert set(info) == DATASET_JSON_KEYS + assert info == { + "format": 1, + "name": NAME, + "version": 1, + "created_at": created_at, # 版の作成日時(書き出した日時ではない) + "hub_commit": hub_commit, + "files": 3, + "manifest_sha256": digest, + } + assert hub_commit == COMMIT + assert b"\r" not in raw + + def test_sha256sums_format(self, env: Env) -> None: + env.export() + raw = (env.target() / "SHA256SUMS").read_bytes() + assert b"\r" not in raw + assert raw.endswith(b"\n") + lines = raw.decode("utf-8").splitlines() + assert len(lines) == 3 + entries = set() + for line in lines: + sha, sep, path = line[:64], line[64:66], line[66:] + assert sep == " " # sha256sum の形(間は空白 2 つ) + assert path == f"files/{sha}.png" + entries.add(sha) + assert entries == set(env.contents) + + def test_no_personal_info_in_bundle_metadata(self, env: Env) -> None: + env.export() + for filename in ("dataset.json", "manifest.csv", "SHA256SUMS"): + text = (env.target() / filename).read_bytes().decode("utf-8") + assert LEAK_KEY not in text + assert "_original" not in text + assert str(env.root) not in text and env.root.as_posix() not in text + + def test_opens_and_verifies_with_reader(self, env: Env) -> None: + env.export() + b = open_bundle(env.target()) + assert b.verify() is None + digest, _, _ = _record(env.conn) + assert b.fingerprint == {"name": NAME, "version": 1, "manifest_sha256": digest} + assert [(r.subject_id, r.capture_id) for r in b.rows] == [ + ("S000001", "C000003"), + ("S000001", "C000004"), + ("S000002", "C000001"), + ] + assert b.rows[1].annotations == { + "tongue_color": {"label": "light_red", "severity": "normal"} + } + for row in b.rows: + assert b.file_path(row).read_bytes() == env.contents[row.sha256] + + def test_bundle_files_are_not_read_only(self, env: Env) -> None: + # ストアは読み取り専用だが,バンドルには持ち込まない(ユーザー判断①) + env.export() + for path in env.target().rglob("*"): + if path.is_file(): + assert os.access(path, os.W_OK), path.name + assert path.stat().st_mode & stat.S_IWRITE + # 書き出した後に消せる(受け渡し後に消すため) + shutil.rmtree(env.target()) + assert not env.target().exists() + + def test_no_temp_folder_left_after_success(self, env: Env) -> None: + env.export() + assert _temp_entries(env.bundles_dir) == [] + assert sorted(p.name for p in (env.bundles_dir / NAME).iterdir()) == ["v1"] + + def test_store_is_unchanged(self, env: Env) -> None: + before = _tree(env.store_dir) + env.export() + assert _tree(env.store_dir) == before + + +# --------------------------------------------------------------------------- +# 同じ版から同じバンドル(PLAN_02 Step 5 の完了条件) +# --------------------------------------------------------------------------- +class TestReproducible: + def test_same_version_gives_same_bytes(self, env: Env, tmp_path: Path) -> None: + env.export() + other = tmp_path / "other_bundles" + other.mkdir() + env.export(bundles_dir=other) + assert _tree(env.target()) == _tree(other / NAME / "v1") + + def test_old_version_unchanged_after_new_data_and_correction( + self, env: Env, tmp_path: Path + ) -> None: + env.export() + first = _tree(env.target()) + # 新しいデータの追加と所見の訂正の後に版 2 を作る + new_id = env.add(9, "S000003", "purple", "strong") + old_id = env.conn.execute( + "SELECT annotation_id FROM annotations WHERE capture_id = 'C000003'" + ).fetchone()[0] + cursor = env.conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " VALUES ('tongue_color', 'C000003', ?, 'dummy_src', '2026-10-02T00:00:00Z')", + (_tc("dark_red", "strong"),), + ) + env.conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (cursor.lastrowid, old_id), + ) + env.conn.commit() + assert new_id + assert env.freeze(datetime(2026, 10, 7, tzinfo=UTC)).version == 2 + + other = tmp_path / "other_bundles" + other.mkdir() + env.export(1, bundles_dir=other) + assert _tree(other / NAME / "v1") == first + assert open_bundle(other / NAME / "v1").rows[0].annotations["tongue_color"]["label"] == ( + "red" + ) + + env.export(2) + v2 = open_bundle(env.target(2)) + v2.verify() + assert len(v2.rows) == 4 + assert v2.rows[0].annotations["tongue_color"]["label"] == "dark_red" + assert ( + json.loads((env.target(2) / "dataset.json").read_bytes())["created_at"] + != (json.loads(first["dataset.json"])["created_at"]) + ) + + +# --------------------------------------------------------------------------- +# 既にあるバンドル(上書きしない・照合して使う) +# --------------------------------------------------------------------------- +class TestExistingBundle: + def test_reuses_intact_bundle(self, env: Env) -> None: + env.export() + before = _tree(env.target()) + mtimes = {p.name: p.stat().st_mtime_ns for p in env.target().rglob("*") if p.is_file()} + result = env.export() + assert result.reused is True + assert result.files == 3 + assert result.size_bytes == sum(len(c) for c in env.contents.values()) + assert _tree(env.target()) == before + assert { + p.name: p.stat().st_mtime_ns for p in env.target().rglob("*") if p.is_file() + } == mtimes + assert _temp_entries(env.bundles_dir) == [] + + @pytest.mark.parametrize( + "tamper", + [ + "modify_file", + "remove_file", + "extra_file", + "modify_manifest", + "remove_manifest", + "modify_dataset_json", + "remove_dataset_json", + "modify_sha256sums", + "remove_sha256sums", + "remove_files_folder", + ], + ) + def test_mismatching_bundle_stops_without_overwrite(self, env: Env, tamper: str) -> None: + env.export() + target = env.target() + first_file = sorted((target / "files").iterdir())[0] + if tamper == "modify_file": + first_file.write_bytes(b"tampered") + elif tamper == "remove_file": + first_file.unlink() + elif tamper == "extra_file": + extra = hashlib.sha256(b"extra").hexdigest() + (target / "files" / f"{extra}.png").write_bytes(b"extra") + elif tamper == "modify_manifest": + path = target / "manifest.csv" + path.write_bytes(path.read_bytes().replace(b"pale", b"red")) + elif tamper == "modify_dataset_json": + path = target / "dataset.json" + info = json.loads(path.read_bytes()) + info["hub_commit"] = "f" * 40 + path.write_bytes(json.dumps(info, indent=2).encode() + b"\n") + elif tamper == "modify_sha256sums": + path = target / "SHA256SUMS" + path.write_bytes(path.read_bytes().replace(b" ", b" *", 1)) + elif tamper == "remove_files_folder": + shutil.rmtree(target / "files") + else: + filename = { + "remove_manifest": "manifest.csv", + "remove_dataset_json": "dataset.json", + "remove_sha256sums": "SHA256SUMS", + }[tamper] + (target / filename).unlink() + before = _tree(target) + + message = _export_error(env) + assert "上書き" in message + assert _tree(target) == before # 上書き・修復しない + assert _temp_entries(env.bundles_dir) == [] + + def test_existing_empty_folder_stops(self, env: Env) -> None: + env.target().mkdir(parents=True) + _export_error(env) + assert list(env.target().iterdir()) == [] + + def test_existing_path_is_file_stops(self, env: Env) -> None: + env.target().parent.mkdir(parents=True) + env.target().write_bytes(b"not a bundle") + _export_error(env) + assert env.target().read_bytes() == b"not a bundle" + + def test_existing_bundle_of_other_version_stops(self, env: Env) -> None: + # v1 の場所に別の版のバンドルが置かれている + env.add(9, "S000003") + assert env.freeze(datetime(2026, 10, 7, tzinfo=UTC)).version == 2 + env.export(2) + shutil.copytree(env.target(2), env.target(1)) + before = _tree(env.target(1)) + _export_error(env, version=1) + assert _tree(env.target(1)) == before + + +# --------------------------------------------------------------------------- +# 書き出せないもの +# --------------------------------------------------------------------------- +class TestExportStops: + def test_missing_version(self, env: Env) -> None: + _export_error(env, version=2) + assert not (env.bundles_dir / NAME / "v2").exists() + + def test_unknown_dataset(self, env: Env) -> None: + _export_error(env, name="no_such") + assert not (env.bundles_dir / "no_such").exists() + + @pytest.mark.parametrize("name", ["", "Bad", "../tongue_color", "a/b", "a\\b", "."]) + def test_invalid_name(self, env: Env, name: str) -> None: + _export_error(env, name=name) + assert list(env.bundles_dir.iterdir()) == [] + + @pytest.mark.parametrize("version", [0, -1, True]) + def test_invalid_version(self, env: Env, version: int) -> None: + _export_error(env, version=version) + assert list(env.bundles_dir.iterdir()) == [] + + def test_missing_bundles_dir(self, env: Env, tmp_path: Path) -> None: + _export_error(env, bundles_dir=env.root / "no_bundles") + assert not (env.root / "no_bundles").exists() + + def test_missing_store_file_leaves_nothing(self, env: Env) -> None: + victim = next(iter(env.contents)) + path = store.store_path(env.store_dir, victim, "png") + _make_writable(path) + path.unlink() + message = _export_error(env) + assert victim in message or victim[:12] in message + assert not env.target().exists() + assert _temp_entries(env.bundles_dir) == [] + + def test_corrupted_store_file_leaves_nothing(self, env: Env) -> None: + victim = next(iter(env.contents)) + path = store.store_path(env.store_dir, victim, "png") + _make_writable(path) + path.write_bytes(b"corrupted") + _export_error(env) + assert not env.target().exists() + assert _temp_entries(env.bundles_dir) == [] + + def test_catalog_changed_after_freeze_stops(self, env: Env) -> None: + # 版の記録と構成から作り直した manifest が食い違う(目録の書き換え) + env.conn.execute("UPDATE assets SET kind = 'tongue_raw' WHERE capture_id = 'C000003'") + env.conn.commit() + message = _export_error(env) + assert "check" in message + assert not env.target().exists() + assert _temp_entries(env.bundles_dir) == [] + + def test_failure_keeps_other_versions(self, env: Env) -> None: + env.export() + before = _tree(env.target()) + _export_error(env, version=5) + assert _tree(env.target()) == before + + def test_catalog_not_modified(self, env: Env) -> None: + def dump() -> list: + return [ + env.conn.execute(f"SELECT * FROM {t} ORDER BY 1").fetchall() + for t in ("dataset_versions", "dataset_members", "assets", "annotations") + ] + + before = dump() + env.export() + env.export() + assert dump() == before diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..40f1e52 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,190 @@ +"""manifest.csv の組み立て(SPEC_03「manifest.csv の列」「版の作成」の行の順序). + +- 列は path・sha256・kind・subject_id・capture_id のあとに `<所見>.<項目>` + (定義の `[columns]` の順) +- path は `files/.`(区切りは `/`) +- UTF-8(BOM なし)・改行 LF +- 行は subject_id・capture_id・sha256 の順(同じ構成なら同じバイト列) +- 所見の値は文字列ならそのまま,無ければ空(ユーザー承認済み) +""" + +import csv +import hashlib +import io + +from tiasdata.manifest import ( + BASE_COLUMNS, + ManifestEntry, + build_manifest, + bundle_file_path, + manifest_columns, + manifest_sha256, +) + +_COLUMNS = {"tongue_color": ["label", "severity"]} + + +def _sha(seed: str) -> str: + return hashlib.sha256(seed.encode()).hexdigest() + + +def _entry( + seed: str, + subject_id: str, + capture_id: str, + annotations: dict | None = None, + *, + ext: str = "png", + kind: str = "tongue_crop_masked", +) -> ManifestEntry: + return ManifestEntry( + sha256=_sha(seed), + ext=ext, + kind=kind, + subject_id=subject_id, + capture_id=capture_id, + annotations=annotations or {}, + ) + + +def _rows(data: bytes) -> list[list[str]]: + return list(csv.reader(io.StringIO(data.decode("utf-8"), newline=""))) + + +class TestColumns: + def test_base_columns(self) -> None: + assert BASE_COLUMNS == ("path", "sha256", "kind", "subject_id", "capture_id") + + def test_annotation_columns_follow_definition_order(self) -> None: + columns = {"tongue_color": ["severity", "label"], "coating": ["thickness"]} + assert manifest_columns(columns) == [ + "path", + "sha256", + "kind", + "subject_id", + "capture_id", + "tongue_color.severity", + "tongue_color.label", + "coating.thickness", + ] + + def test_no_annotation_columns(self) -> None: + assert manifest_columns({}) == list(BASE_COLUMNS) + + def test_header_row_matches_columns(self) -> None: + data = build_manifest([_entry("a", "S000001", "C000001")], _COLUMNS) + assert _rows(data)[0] == manifest_columns(_COLUMNS) + + +class TestPath: + def test_bundle_file_path(self) -> None: + sha = _sha("x") + assert bundle_file_path(sha, "png") == f"files/{sha}.png" + + def test_row_path_and_sha256(self) -> None: + entry = _entry("a", "S000001", "C000001", ext="jpg") + row = _rows(build_manifest([entry], {}))[1] + assert row == [ + f"files/{entry.sha256}.jpg", + entry.sha256, + "tongue_crop_masked", + "S000001", + "C000001", + ] + + +class TestEncoding: + def test_utf8_without_bom_and_lf_only(self) -> None: + entries = [ + _entry( + "a", "S000001", "C000001", {"tongue_color": {"label": "red", "severity": "mild"}} + ), + _entry( + "b", "S000002", "C000002", {"tongue_color": {"label": "pale", "severity": "strong"}} + ), + ] + data = build_manifest(entries, _COLUMNS) + assert not data.startswith(b"\xef\xbb\xbf") + assert b"\r" not in data + assert data.endswith(b"\n") + assert data.count(b"\n") == 3 + data.decode("utf-8") + + def test_non_ascii_value_is_utf8(self) -> None: + entry = _entry("a", "S000001", "C000001", {"note": {"text": "淡紅"}}) + data = build_manifest([entry], {"note": ["text"]}) + assert "淡紅".encode() in data + assert not data.startswith(b"\xef\xbb\xbf") + + +class TestOrder: + def test_rows_sorted_by_subject_capture_sha256(self) -> None: + # 入力の順・SHA-256 の順と,症例・撮影の順が食い違うようにする + e1 = _entry("z1", "S000002", "C000001") + e2 = _entry("z2", "S000001", "C000009") + e3 = _entry("z3", "S000001", "C000003") + e4 = _entry("z4", "S000001", "C000003") + data = build_manifest([e1, e2, e3, e4], {}) + got = [(r[3], r[4], r[1]) for r in _rows(data)[1:]] + expected = sorted((e.subject_id, e.capture_id, e.sha256) for e in (e1, e2, e3, e4)) + assert got == expected + assert got[0][0] == "S000001" + assert got[-1][0] == "S000002" + + def test_same_entries_in_any_order_give_same_bytes(self) -> None: + entries = [ + _entry( + str(i), + f"S00000{i % 3 + 1}", + f"C00000{i}", + {"tongue_color": {"label": "red", "severity": "mild"}}, + ) + for i in range(1, 7) + ] + a = build_manifest(entries, _COLUMNS) + b = build_manifest(list(reversed(entries)), _COLUMNS) + assert a == b + assert manifest_sha256(a) == manifest_sha256(b) + + +class TestValues: + def test_string_values_as_is(self) -> None: + entry = _entry( + "a", "S000001", "C000001", {"tongue_color": {"label": "dark_red", "severity": "strong"}} + ) + row = _rows(build_manifest([entry], _COLUMNS))[1] + assert row[5:] == ["dark_red", "strong"] + + def test_missing_annotation_is_empty(self) -> None: + entry = _entry("a", "S000001", "C000001") + data = build_manifest([entry], _COLUMNS) + assert _rows(data)[1][5:] == ["", ""] + + def test_missing_field_and_none_are_empty(self) -> None: + entry = _entry("a", "S000001", "C000001", {"coating": {"thickness": "thin", "moist": None}}) + row = _rows(build_manifest([entry], {"coating": ["moist", "thickness", "color"]}))[1] + assert row[5:] == ["", "thin", ""] + + def test_annotation_not_in_columns_is_not_output(self) -> None: + entry = _entry( + "a", + "S000001", + "C000001", + {"tongue_color": {"label": "red", "severity": "mild"}, "x": {"y": "secret"}}, + ) + data = build_manifest([entry], _COLUMNS) + assert b"secret" not in data + assert len(_rows(data)[1]) == len(BASE_COLUMNS) + 2 + + +class TestSha256: + def test_manifest_sha256_is_sha256_of_bytes(self) -> None: + data = build_manifest([_entry("a", "S000001", "C000001")], {}) + digest = manifest_sha256(data) + assert digest == hashlib.sha256(data).hexdigest() + assert len(digest) == 64 + assert digest == digest.lower() + + def test_empty_entries_gives_header_only(self) -> None: + data = build_manifest([], _COLUMNS) + assert data == (",".join(manifest_columns(_COLUMNS)) + "\n").encode() diff --git a/tests/test_repo.py b/tests/test_repo.py new file mode 100644 index 0000000..97cdf81 --- /dev/null +++ b/tests/test_repo.py @@ -0,0 +1,99 @@ +"""本リポジトリの場所と git の状態. + +SPEC_03「版の作成」: 未コミットの変更がある状態では版を作らない. + +git の状態は `tmp_path` に作った使い捨ての git リポジトリで確かめる +(本リポジトリの状態に依存しない). +""" + +import subprocess +import tomllib +from pathlib import Path + +import pytest + +from tiasdata.repo import find_repo_root, read_repo_state + + +def _git(root: Path, *args: str) -> str: + completed = subprocess.run( + [ + "git", + "-c", + "user.name=test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + "-C", + str(root), + *args, + ], + capture_output=True, + text=True, + check=True, + ) + return completed.stdout + + +@pytest.fixture +def git_repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + root.mkdir() + _git(root, "init", "-q") + (root / "a.txt").write_bytes(b"a\n") + (root / ".gitignore").write_bytes(b"ignored.txt\n") + _git(root, "add", "a.txt", ".gitignore") + _git(root, "commit", "-q", "-m", "init") + return root + + +def test_find_repo_root_is_tiasdata_project() -> None: + root = find_repo_root() + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + assert data["project"]["name"] == "tiasdata" + assert (root / "datasets").is_dir() + + +def test_clean_repo(git_repo: Path) -> None: + state = read_repo_state(git_repo) + assert state.commit == _git(git_repo, "rev-parse", "HEAD").strip() + assert len(state.commit) == 40 + assert state.uncommitted == 0 + + +def test_modified_file_is_uncommitted(git_repo: Path) -> None: + (git_repo / "a.txt").write_bytes(b"changed\n") + assert read_repo_state(git_repo).uncommitted == 1 + + +def test_untracked_file_is_uncommitted(git_repo: Path) -> None: + # 定義ファイルを追加しただけでコミットしていない場合も止める + (git_repo / "datasets").mkdir() + (git_repo / "datasets" / "new.toml").write_bytes(b"") + (git_repo / "b.txt").write_bytes(b"") + assert read_repo_state(git_repo).uncommitted == 2 + + +def test_staged_file_is_uncommitted(git_repo: Path) -> None: + (git_repo / "c.txt").write_bytes(b"c\n") + _git(git_repo, "add", "c.txt") + assert read_repo_state(git_repo).uncommitted == 1 + + +def test_ignored_file_is_not_counted(git_repo: Path) -> None: + (git_repo / "ignored.txt").write_bytes(b"x") + assert read_repo_state(git_repo).uncommitted == 0 + + +def test_not_a_git_repo_is_value_error_without_path(tmp_path: Path) -> None: + directory = tmp_path / "not_repo_zz" + directory.mkdir() + with pytest.raises(ValueError) as exc: + read_repo_state(directory) + message = str(exc.value) + assert "not_repo_zz" not in message + assert str(tmp_path) not in message + assert "\n" not in message 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/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..a4b87e4 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,621 @@ +"""ストア(SPEC_01 基本方針 2・SPEC_02「assets」,testing.md 必須対象 2). + +- 置き場所は `store//.<拡張子>` で中身から決まる +- 同じ中身は 1 つにまとまる.コピー後にハッシュを照合する.既存のファイルを上書きしない +- 原本(取り込み元)を変更しない.置いたファイルは読み取り専用にする +- 例外は ValueError.メッセージにパス・元のファイル名を出さない +""" + +import hashlib +import os +import stat +from pathlib import Path + +import pytest + +from tiasdata import store +from tiasdata.store import ( + CHUNK_SIZE, + TEMP_PREFIX, + StoredFile, + StoredFileStatus, + add_file, + normalize_ext, + sha256_file, + store_path, + verify_stored, +) + +SHA_A = "ab" + "0" * 62 + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _all_files(directory: Path) -> list[Path]: + return sorted(p for p in directory.rglob("*") if p.is_file()) + + +def _make_writable(path: Path) -> None: + path.chmod(path.stat().st_mode | stat.S_IWUSR) + + +def _write(path: Path, data: bytes) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _assert_no_leak(message: str, tmp_path: Path, *names: str) -> None: + assert str(tmp_path) not in message + assert tmp_path.as_posix() not in message + for name in names: + assert name not in message + + +# ---------------------------------------------------------------- normalize_ext + + +class TestNormalizeExt: + @pytest.mark.parametrize( + ("ext", "expected"), + [("png", "png"), (".png", "png"), (".PNG", "png"), ("JpG", "jpg"), (".tif2", "tif2")], + ) + def test_normalizes_to_lower_without_dot(self, ext: str, expected: str) -> None: + assert normalize_ext(ext) == expected + + @pytest.mark.parametrize("ext", ["", ".", "p-g", ".tar.gz", "pn g", "png/", "画像", "..png"]) + def test_rejects_empty_or_non_alnum(self, ext: str) -> None: + with pytest.raises(ValueError): + normalize_ext(ext) + + +# ---------------------------------------------------------------- store_path + + +class TestStorePath: + def test_layout_is_first_two_chars_then_sha_and_ext(self, tmp_path: Path) -> None: + path = store_path(tmp_path, SHA_A, "png") + assert path == tmp_path / "ab" / f"{SHA_A}.png" + + def test_ext_is_normalized(self, tmp_path: Path) -> None: + assert store_path(tmp_path, SHA_A, ".PNG") == tmp_path / "ab" / f"{SHA_A}.png" + + def test_is_pure_and_creates_nothing(self, tmp_path: Path) -> None: + missing = tmp_path / "no-store" + store_path(missing, SHA_A, "png") + assert not missing.exists() + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize( + "sha", + [ + "", + "ab", + "AB" + "0" * 62, # 大文字 + "a" * 63, + "a" * 65, + "g" * 64, + "../" + "a" * 61, + " " + "a" * 63, + ], + ) + def test_rejects_malformed_sha256(self, tmp_path: Path, sha: str) -> None: + with pytest.raises(ValueError): + store_path(tmp_path, sha, "png") + + def test_rejects_bad_ext(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + store_path(tmp_path, SHA_A, "") + + +# ---------------------------------------------------------------- sha256_file + + +class TestSha256File: + def test_matches_hashlib(self, tmp_path: Path) -> None: + data = b"tongue image dummy" + assert sha256_file(_write(tmp_path / "a.bin", data)) == _sha(data) + + def test_empty_file(self, tmp_path: Path) -> None: + assert sha256_file(_write(tmp_path / "e.bin", b"")) == _sha(b"") + + @pytest.mark.parametrize( + "size", [CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 2 * CHUNK_SIZE + 7] + ) + def test_across_chunk_boundary(self, tmp_path: Path, size: int) -> None: + data = bytes(i % 251 for i in range(size)) + assert sha256_file(_write(tmp_path / "big.bin", data)) == _sha(data) + + def test_does_not_modify_file(self, tmp_path: Path) -> None: + path = _write(tmp_path / "a.bin", b"abc") + before = path.stat() + sha256_file(path) + after = path.stat() + assert (after.st_mtime_ns, after.st_mode, after.st_size) == ( + before.st_mtime_ns, + before.st_mode, + before.st_size, + ) + + +# ---------------------------------------------------------------- add_file: 正常系 + + +class TestAddFile: + def test_first_add_copies_to_sha_named_path(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + data = source.read_bytes() + + result = add_file(store_dir, source) + + assert isinstance(result, StoredFile) + assert result.sha256 == _sha(data) + assert result.ext == "png" + assert result.size_bytes == len(data) + assert result.copied is True + target = store_dir / result.sha256[:2] / f"{result.sha256}.png" + assert target == store_path(store_dir, result.sha256, result.ext) + assert target.read_bytes() == data + assert sha256_file(target) == result.sha256 + assert _all_files(store_dir) == [target] + + def test_second_add_of_same_file_does_not_copy(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + first = add_file(store_dir, source) + target = store_path(store_dir, first.sha256, first.ext) + mtime = target.stat().st_mtime_ns + + second = add_file(store_dir, source) + + assert second == first._replace(copied=False) + assert _all_files(store_dir) == [target] + assert target.stat().st_mtime_ns == mtime + + def test_same_content_elsewhere_is_deduplicated(self, store_dir: Path, make_png) -> None: + a = make_png(store_dir.parent / "src" / "淡紅(正常)" / "1.png", seed=5) + b = make_png(store_dir.parent / "other" / "別名.png", seed=5) + assert a.read_bytes() == b.read_bytes() + + ra = add_file(store_dir, a) + rb = add_file(store_dir, b) + + assert ra.copied is True + assert rb.copied is False + assert ra.sha256 == rb.sha256 + assert len(_all_files(store_dir)) == 1 + + def test_same_content_with_upper_ext_is_deduplicated(self, store_dir: Path, make_png) -> None: + a = make_png(store_dir.parent / "src" / "1.png", seed=3) + b = _write(store_dir.parent / "src2" / "1.PNG", a.read_bytes()) + + ra = add_file(store_dir, a) + rb = add_file(store_dir, b) + + assert rb.ext == "png" + assert rb.copied is False + assert ra.sha256 == rb.sha256 + assert len(_all_files(store_dir)) == 1 + + def test_different_contents_are_stored_separately(self, store_dir: Path, tongue_color_source): + results = [add_file(store_dir, img.path) for img in tongue_color_source.images] + + assert all(r.copied for r in results) + assert len({r.sha256 for r in results}) == len(results) + files = _all_files(store_dir) + assert len(files) == len(results) + for r in results: + assert verify_stored(store_dir, r.sha256, r.ext) is StoredFileStatus.OK + + def test_stored_file_is_read_only(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + result = add_file(store_dir, source) + target = store_path(store_dir, result.sha256, result.ext) + + assert not os.access(target, os.W_OK) + assert not target.stat().st_mode & stat.S_IWUSR + with pytest.raises(PermissionError): + target.write_bytes(b"x") + assert sha256_file(target) == result.sha256 + + def test_no_temp_files_remain(self, store_dir: Path, tongue_color_source) -> None: + for img in tongue_color_source.images: + add_file(store_dir, img.path) + leftovers = [p for p in store_dir.rglob("*") if p.name.startswith(TEMP_PREFIX)] + assert leftovers == [] + + def test_large_file_across_chunks(self, store_dir: Path) -> None: + data = os.urandom(2 * CHUNK_SIZE + 123) + source = _write(store_dir.parent / "src" / "big.bin", data) + + result = add_file(store_dir, source) + + assert result.sha256 == _sha(data) + assert result.size_bytes == len(data) + assert store_path(store_dir, result.sha256, "bin").read_bytes() == data + + def test_empty_file_can_be_stored(self, store_dir: Path) -> None: + source = _write(store_dir.parent / "src" / "empty.png", b"") + result = add_file(store_dir, source) + assert result.sha256 == _sha(b"") + assert result.size_bytes == 0 + assert verify_stored(store_dir, result.sha256, "png") is StoredFileStatus.OK + + +# ---------------------------------------------------------------- add_file: 原本を変えない + + +class TestAddFileKeepsSource: + def test_source_content_mtime_mode_unchanged(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + os.utime(source, ns=(1_600_000_000_000_000_000, 1_600_000_000_000_000_000)) + data = source.read_bytes() + before = source.stat() + + add_file(store_dir, source) + add_file(store_dir, source) # 既にある場合の経路でも + + after = source.stat() + assert source.read_bytes() == data + assert after.st_mtime_ns == before.st_mtime_ns + assert after.st_mode == before.st_mode + assert after.st_size == before.st_size + assert os.access(source, os.W_OK) # 原本に読み取り専用を付けない + + def test_read_only_source_can_be_added_and_stays_read_only( + self, store_dir: Path, make_png + ) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=2) + source.chmod(stat.S_IREAD) + data = source.read_bytes() + before = source.stat() + try: + result = add_file(store_dir, source) + + after = source.stat() + assert result.copied is True + assert source.read_bytes() == data + assert after.st_mode == before.st_mode + assert after.st_mtime_ns == before.st_mtime_ns + assert not os.access(source, os.W_OK) + finally: + _make_writable(source) + + def test_source_folder_gets_no_new_files(self, store_dir: Path, tongue_color_source) -> None: + root = tongue_color_source.root + before = _all_files(root) + for img in tongue_color_source.images: + add_file(store_dir, img.path) + assert _all_files(root) == before + + +# ---------------------------------------------------------------- add_file: 異常系 + + +class TestAddFileErrors: + def test_missing_store_dir_raises_and_creates_nothing(self, tmp_path: Path, make_png) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + missing = tmp_path / "tiasdata" / "store" + + with pytest.raises(ValueError) as exc: + add_file(missing, source) + + assert not missing.exists() + assert not missing.parent.exists() + _assert_no_leak(str(exc.value), tmp_path, "1.png") + + def test_store_dir_is_a_file_raises(self, tmp_path: Path, make_png) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + not_dir = _write(tmp_path / "store", b"") + with pytest.raises(ValueError): + add_file(not_dir, source) + + def test_source_is_directory_raises(self, store_dir: Path, tmp_path: Path) -> None: + folder = tmp_path / "src" / "淡紅(正常).png" + folder.mkdir(parents=True) + with pytest.raises(ValueError) as exc: + add_file(store_dir, folder) + assert _all_files(store_dir) == [] + _assert_no_leak(str(exc.value), tmp_path, "淡紅(正常)") + + def test_source_missing_raises(self, store_dir: Path, tmp_path: Path) -> None: + with pytest.raises(ValueError) as exc: + add_file(store_dir, tmp_path / "src" / "患者ABC123.png") + assert _all_files(store_dir) == [] + _assert_no_leak(str(exc.value), tmp_path, "患者ABC123") + + @pytest.mark.parametrize("name", ["noext", "weird.p-g", "trailing."]) + def test_source_with_bad_ext_raises(self, store_dir: Path, tmp_path: Path, name: str) -> None: + source = _write(tmp_path / "src" / name, b"data") + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + assert list(store_dir.iterdir()) == [] + _assert_no_leak(str(exc.value), tmp_path, name) + assert source.read_bytes() == b"data" + + def test_existing_different_content_is_not_overwritten( + self, store_dir: Path, tmp_path: Path, make_png + ) -> None: + source = make_png(tmp_path / "src" / "患者XYZ999.png", seed=1) + sha = sha256_file(source) + target = store_path(store_dir, sha, "png") + _write(target, b"corrupted content") + target.chmod(stat.S_IREAD) + before = target.stat() + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + assert target.read_bytes() == b"corrupted content" + assert target.stat().st_mtime_ns == before.st_mtime_ns + assert _all_files(store_dir) == [target] + message = str(exc.value) + assert sha in message + _assert_no_leak(message, tmp_path, "患者XYZ999") + + def test_existing_directory_at_target_raises( + self, store_dir: Path, tmp_path: Path, make_png + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + target = store_path(store_dir, sha256_file(source), "png") + target.mkdir(parents=True) + with pytest.raises(ValueError): + add_file(store_dir, source) + assert target.is_dir() + + def test_verify_failure_after_copy_leaves_no_file( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "患者QQQ.png", seed=1) + sha = sha256_file(source) + real = store.sha256_file + + def broken(path: Path) -> str: + # 一時ファイルの読み直しだけ不一致にする(ディスクへの書き込みの誤りを模す) + if path.name.startswith(TEMP_PREFIX): + return "0" * 64 + return real(path) + + monkeypatch.setattr(store, "sha256_file", broken) + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + assert not store_path(store_dir, sha, "png").exists() + assert [p for p in store_dir.rglob("*") if p.is_file()] == [] + _assert_no_leak(str(exc.value), tmp_path, "患者QQQ") + + def test_source_changed_during_copy_stops( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + fake = "cd" + "1" * 62 + real = store.sha256_file + + def first_call_differs(path: Path) -> str: + # 1 回目(取り込み元)の計算の後に中身が変わった状況を模す + if path == source: + return fake + return real(path) + + monkeypatch.setattr(store, "sha256_file", first_call_differs) + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + assert _all_files(store_dir) == [] + _assert_no_leak(str(exc.value), tmp_path, "1.png") + + def test_concurrent_same_content_is_reused( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + data = source.read_bytes() + sha = _sha(data) + target = store_path(store_dir, sha, "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + _write(target, data) # コピーの間に別の取り込みが同じ中身を置いた + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + result = add_file(store_dir, source) + + assert result.copied is False + assert result.sha256 == sha + assert _all_files(store_dir) == [target] + + def test_concurrent_different_content_is_not_overwritten( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + target = store_path(store_dir, sha256_file(source), "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + _write(target, b"other") + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + with pytest.raises(ValueError): + add_file(store_dir, source) + + assert target.read_bytes() == b"other" + assert _all_files(store_dir) == [target] + + +# ---------------------------------------------------------------- add_file: 再利用時の読み取り専用 + + +def _is_read_only(path: Path) -> bool: + return not os.access(path, os.W_OK) and not path.stat().st_mode & stat.S_IWUSR + + +class TestAddFileReuseRestoresReadOnly: + """SPEC_01「ストアの規則」: 再利用するときも読み取り専用を付け直す(中身には触らない).""" + + def test_reuse_restores_read_only_when_removed(self, store_dir: Path, make_png) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=1) + first = add_file(store_dir, source) + target = store_path(store_dir, first.sha256, first.ext) + data = target.read_bytes() + _make_writable(target) # 復元・手作業等で読み取り専用が外れた状況 + assert os.access(target, os.W_OK) + mtime = target.stat().st_mtime_ns + + try: + second = add_file(store_dir, source) + + assert second == first._replace(copied=False) + assert _is_read_only(target) + assert target.read_bytes() == data + assert sha256_file(target) == first.sha256 + assert target.stat().st_mtime_ns == mtime + assert _all_files(store_dir) == [target] + finally: + _make_writable(target) + + def test_reuse_of_already_read_only_file_keeps_it_read_only( + self, store_dir: Path, make_png + ) -> None: + source = make_png(store_dir.parent / "src" / "1.png", seed=2) + first = add_file(store_dir, source) + target = store_path(store_dir, first.sha256, first.ext) + assert _is_read_only(target) + + second = add_file(store_dir, source) + third = add_file(store_dir, source) + + assert second.copied is False + assert third.copied is False + assert _is_read_only(target) + assert sha256_file(target) == first.sha256 + assert _all_files(store_dir) == [target] + + def test_reuse_from_other_source_with_same_content_restores_read_only( + self, store_dir: Path, make_png + ) -> None: + a = make_png(store_dir.parent / "src" / "1.png", seed=3) + b = _write(store_dir.parent / "other" / "別名.PNG", a.read_bytes()) + first = add_file(store_dir, a) + target = store_path(store_dir, first.sha256, first.ext) + _make_writable(target) + + try: + result = add_file(store_dir, b) + + assert result.copied is False + assert _is_read_only(target) + finally: + _make_writable(target) + + def test_concurrent_reuse_makes_existing_read_only( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + data = source.read_bytes() + sha = _sha(data) + target = store_path(store_dir, sha, "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + # 別の取り込みが同じ中身を置いた(まだ読み取り専用を付ける前の状態) + _write(target, data) + assert os.access(target, os.W_OK) + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + try: + result = add_file(store_dir, source) + + assert result.copied is False + assert result.sha256 == sha + assert result.size_bytes == len(data) + assert _is_read_only(target) + assert target.read_bytes() == data + assert _all_files(store_dir) == [target] + finally: + if target.exists(): + _make_writable(target) + + def test_mismatch_keeps_existing_content_and_writable_attribute( + self, store_dir: Path, tmp_path: Path, make_png + ) -> None: + source = make_png(tmp_path / "src" / "患者XYZ999.png", seed=1) + sha = sha256_file(source) + target = store_path(store_dir, sha, "png") + _write(target, b"corrupted content") + assert os.access(target, os.W_OK) + before = target.stat() + + with pytest.raises(ValueError) as exc: + add_file(store_dir, source) + + after = target.stat() + assert target.read_bytes() == b"corrupted content" + assert after.st_mode == before.st_mode + assert after.st_mtime_ns == before.st_mtime_ns + assert os.access(target, os.W_OK) # 照合が合わないときは属性も変えない + assert _all_files(store_dir) == [target] + _assert_no_leak(str(exc.value), tmp_path, "患者XYZ999") + + def test_concurrent_mismatch_keeps_existing_writable( + self, store_dir: Path, tmp_path: Path, make_png, monkeypatch: pytest.MonkeyPatch + ) -> None: + source = make_png(tmp_path / "src" / "1.png", seed=1) + target = store_path(store_dir, sha256_file(source), "png") + real_copy = store._copy_to_temp + + def copy_then_race(src: Path, directory: Path): + result = real_copy(src, directory) + _write(target, b"other") + return result + + monkeypatch.setattr(store, "_copy_to_temp", copy_then_race) + + with pytest.raises(ValueError): + add_file(store_dir, source) + + assert target.read_bytes() == b"other" + assert os.access(target, os.W_OK) + assert target.stat().st_mode & stat.S_IWUSR + assert _all_files(store_dir) == [target] + + +# ---------------------------------------------------------------- verify_stored + + +class TestVerifyStored: + def test_ok(self, store_dir: Path, make_png) -> None: + result = add_file(store_dir, make_png(store_dir.parent / "s" / "1.png", seed=1)) + assert verify_stored(store_dir, result.sha256, result.ext) is StoredFileStatus.OK + assert verify_stored(store_dir, result.sha256, ".PNG") is StoredFileStatus.OK + + def test_missing(self, store_dir: Path) -> None: + assert verify_stored(store_dir, SHA_A, "png") is StoredFileStatus.MISSING + + def test_missing_when_only_other_ext_exists(self, store_dir: Path, make_png) -> None: + result = add_file(store_dir, make_png(store_dir.parent / "s" / "1.png", seed=1)) + assert verify_stored(store_dir, result.sha256, "jpg") is StoredFileStatus.MISSING + + def test_mismatch(self, store_dir: Path, make_png) -> None: + result = add_file(store_dir, make_png(store_dir.parent / "s" / "1.png", seed=1)) + target = store_path(store_dir, result.sha256, result.ext) + _make_writable(target) + target.write_bytes(b"tampered") + assert verify_stored(store_dir, result.sha256, result.ext) is StoredFileStatus.MISMATCH + + def test_status_values(self) -> None: + assert [s.value for s in StoredFileStatus] == ["ok", "missing", "mismatch"] + + def test_malformed_sha_raises(self, store_dir: Path) -> None: + with pytest.raises(ValueError): + verify_stored(store_dir, "XYZ", "png") 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" },