diff --git a/CLAUDE.md b/CLAUDE.md index 538b4ca..aeb3921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: PLAN_02 Step 3(取り込みと点検.`tiasdata import`・`stats`・`check`)完了.次は Step 4(データセットと版) +最新: PLAN_02 Step 4(データセットと版.`tiasdata dataset list`・`show`・`freeze`)完了.次は Step 5(バンドル) ※ 本欄は**最新ステップ 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_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 46ceec8..007654b 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" @@ -25,14 +25,17 @@ | `uv run tiasdata init` | 設定ファイルに従って目録(`catalog.sqlite`)・`store/`・`bundles/`・`backups/`・対応表(`identity.sqlite`)を作り,スキーマのマイグレーションを適用する.何度実行してもよい(不足分だけ適用する) | | `uv run tiasdata import <取り込み元の名前>` | 設定ファイルの `[sources.<名前>]` を取り込む(保留リスト `holds/<名前>.toml` が必要.空でよい).`--show-source-names` で問題のあったファイルの名前・対応表の行番号を表示する | | `uv run tiasdata stats` | 症例・撮影・ファイル・所見の件数を表示する | -| `uv run tiasdata check` | ストア・目録の点検.問題が 1 件以上なら終了コード 1 | +| `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 するだけなので通常は問題ない) | - 設定ファイルは環境変数 `TIASDATA_CONFIG` のパス,無ければ `~/.config/tiasdata/config.toml`(キーは [SPEC_01「設定ファイル」](../04_SPEC/SPEC_01_アーキテクチャ設計.md)) - 手元で試すときは一時フォルダに設定ファイルを作って指す(例: `TIASDATA_CONFIG="$TMPDIR/tiasdata-try/config.toml" uv run tiasdata init`.臨床データは使わない) - 研究室 PC では `ssh labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe run tiasdata init"` の形で実行する([ENV_02](ENV_02_環境構築手順.md)) - 失敗時は標準エラーに 1 行を出し,終了コード 1(入力・データの問題)または 2(引数の誤り)で終わる - `import` は openpyxl が要る(手元は `uv sync` で入る.研究室 PC は `uv sync --no-dev --extra import`).リポジトリの clone から実行する(保留リストを読むため) -- 残りのサブコマンド(データセット・書き出し・受け渡し等)は実装した Step で追記する +- 残りのサブコマンド(書き出し・受け渡し・バックアップ等)は実装した Step で追記する ## 依存の追加 (Dependencies) diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index f13186a..bdf587e 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" @@ -15,7 +15,7 @@ | 1 | **設定と目録の土台**: `config.py`(設定ファイルの読み込み・検証),`catalog/`(接続・マイグレーションの適用),`catalog/migrations/catalog/0001`(SPEC_02 の全表と最初の種類)・`identity/0001`(対応表),`tiasdata init`(2026-10-06 完了) | 一時ディレクトリの設定ファイルから `init` で目録ができ,`PRAGMA user_version` が最新になる.テスト(設定ファイル・マイグレーション)が通る | | 2 | **ストア**: `store.py`(ハッシュ・コピー・照合),テスト用のダミーデータ(小さい画像・取り込み元のフォルダ)を作る `conftest.py`(2026-10-06 完了) | 同じ中身が 1 つにまとまり,コピー後の照合・上書きしないことがテストで確かめられる | | 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check`(2026-10-06 完了) | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.保留リストの番号を飛ばし,保留の件数を報告する.出力に患者 ID・元のファイル名が出ない.`check` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | -| 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む | +| 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む(2026-10-06 完了) | | 5 | **バンドル**: `bundle.py`(書き出し・読み込み・検証),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる | | 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 を通らない | 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 372a868..beb7fbf 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" @@ -61,6 +61,8 @@ │ ├── store.py # ストア(ハッシュの計算・コピー・検証) │ ├── kinds/ # ファイルの種類・所見の種類の定義(値の検証用の型) │ ├── importers/ # 取り込み元ごとの読み取り(例: 舌色の 9 フォルダ) +│ ├── manifest.py # manifest.csv の生成(純関数.catalog に依存しない) +│ ├── repo.py # リポジトリのルートと git の状態 │ ├── datasets.py # データセットの定義の評価・版の作成 │ ├── bundle.py # バンドルの書き出し・読み込み・検証 │ ├── deliver.py # 計算サーバへの受け渡し 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 7b60157..1f6302c 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) @@ -109,11 +116,11 @@ | --- | --- | | `tiasdata init` | 設定ファイルに従って目録・ストアを作り,マイグレーションを適用する | | `tiasdata import <取り込み元>` | 取り込み元を読み,ファイルをストアにコピーして症例・撮影・ファイル・所見・由来を登録する(取り込み元の形ごとに importer を用意する.最初は舌色の 9 フォルダ `tongue_color_folders`).保留リストの番号は飛ばし,件数を報告する | -| `tiasdata dataset list` / `show <名前>` | データセットと版の一覧・件数 | -| `tiasdata dataset freeze <名前>` | 新しい版を作る | +| `tiasdata dataset list` / `show <名前>` | データセットと版の一覧・件数.定義だけあって版の無いものは「版なし」,定義ファイルを消したものは「(定義ファイルなし)」.show は版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | +| `tiasdata dataset freeze <名前> [--note TEXT]` | 新しい版を作る(作った版の番号と件数,または「構成・定義が同じなので版を作らなかった」) | | `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す | | `tiasdata deliver <名前> --version <版> --to <サーバ名>` | 計算サーバへ渡す | -| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.tmp-`),ストアの想定外のファイル・フォルダ.件数だけを表示し,問題が 1 件以上なら終了コード 1.対応表は開かない | +| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,凍結した版の manifest の不一致(全版について記録した定義と構成から manifest を作り直し,記録した SHA-256 と照合する.ファイルの種類や撮影の症例が書き換えられた場合に見つかる),読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.tmp-`),ストアの想定外のファイル・フォルダ.件数だけを表示し,問題が 1 件以上なら終了コード 1.対応表は開かない | | `tiasdata stats` | 症例数(撮影が 2 つ以上の症例数を含む)・撮影数,種類ごとのファイル数,訂正されていない所見の項目ごと・組み合わせごとの件数,訂正された所見の数.対応表は開かない | | `tiasdata backup` | 目録を `backups/catalog-<日時>.sqlite` に複製する(SQLite のバックアップ API を使う) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 56b5502..fb84a76 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -38,3 +38,6 @@ - 設計判断: 目録と対応表は別 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 の患者の確定で関係する) diff --git a/src/tiasdata/catalog/__init__.py b/src/tiasdata/catalog/__init__.py index b3bbc30..c0a429e 100644 --- a/src/tiasdata/catalog/__init__.py +++ b/src/tiasdata/catalog/__init__.py @@ -9,6 +9,7 @@ MigrationResult, Series, apply_migrations, + connect_latest, latest_version, require_latest, schema_version, @@ -31,6 +32,7 @@ "Series", "apply_migrations", "connect", + "connect_latest", "count_annotation_values", "count_assets_by_kind", "count_broken_dataset_members", diff --git a/src/tiasdata/catalog/migrate.py b/src/tiasdata/catalog/migrate.py index 8b637af..ee4ef69 100644 --- a/src/tiasdata/catalog/migrate.py +++ b/src/tiasdata/catalog/migrate.py @@ -9,8 +9,11 @@ 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"] @@ -123,6 +126,27 @@ ) +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: """未適用のマイグレーションを番号順に適用する. 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 index 063aeb1..c4227c1 100644 --- a/src/tiasdata/check.py +++ b/src/tiasdata/check.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path -from tiasdata import catalog, store +from tiasdata import catalog, datasets, store @dataclass(frozen=True) @@ -27,6 +27,9 @@ unexpected_in_store: ストアの想定外のファイル・フォルダの数. dangling_annotations: 参照先が無い所見の数. broken_dataset_members: 凍結した版の構成のうち参照先が欠けている行の数. + dataset_versions: 凍結した版の数(manifest を作り直して照合した数). + manifest_mismatch: 構成から作り直した manifest の SHA-256 が版の記録と一致しない + (または作り直せない)版の数. """ catalog_files: int @@ -40,6 +43,8 @@ unexpected_in_store: int dangling_annotations: int broken_dataset_members: int + dataset_versions: int + manifest_mismatch: int @property def problems(self) -> int: @@ -53,6 +58,7 @@ + self.unexpected_in_store + self.dangling_annotations + self.broken_dataset_members + + self.manifest_mismatch ) @@ -74,6 +80,7 @@ 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), @@ -88,4 +95,6 @@ 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 e74dcca..cc962c0 100644 --- a/src/tiasdata/cli.py +++ b/src/tiasdata/cli.py @@ -1,14 +1,12 @@ """tiasdata コマンドのエントリポイント.""" import argparse -import sqlite3 import sys from contextlib import closing -from pathlib import Path from pydantic import ValidationError -from tiasdata import __version__, catalog +from tiasdata import __version__, catalog, datasets from tiasdata.check import CheckResult, run_check from tiasdata.config import load_config from tiasdata.importers import ImportResult, run_import @@ -65,19 +63,9 @@ print(f"ストアにコピー: {result.copied_to_store} 件") -def _open_catalog(catalog_path: Path) -> sqlite3.Connection: - conn = catalog.connect(catalog_path) - try: - catalog.require_latest(conn, "catalog") - except BaseException: - conn.close() - raise - return conn - - def _cmd_stats(args: argparse.Namespace) -> int: config = load_config() - with closing(_open_catalog(config.catalog.catalog_path)) as conn: + with closing(catalog.connect_latest(config.catalog.catalog_path)) as conn: stats = collect_stats(conn) _print_stats(stats) return 0 @@ -107,7 +95,7 @@ def _cmd_check(args: argparse.Namespace) -> int: config = load_config() - with closing(_open_catalog(config.catalog.catalog_path)) as conn: + 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: @@ -134,6 +122,76 @@ 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 _print_migration(label: str, result: catalog.MigrationResult) -> None: @@ -197,9 +255,49 @@ "ファイル,所見の参照切れ,凍結した版の構成の欠けを数える.件数だけを表示する", ) 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) 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): diff --git a/src/tiasdata/datasets.py b/src/tiasdata/datasets.py new file mode 100644 index 0000000..d67b24e --- /dev/null +++ b/src/tiasdata/datasets.py @@ -0,0 +1,734 @@ +"""データセットの定義の読み込み・評価と,版の作成(SPEC_03「データセットの定義」「版の作成」). + +定義(`datasets/<名前>.toml`)は個人情報を含まないためリポジトリで管理する(PR で変更する). +実行時は,このパッケージの位置からリポジトリのルートを辿って `datasets/` を見つける +(`importers.holds` の保留リストと同じ.版の作成はリポジトリの clone から実行する). + +版とその構成は凍結される(UPDATE・DELETE しない).構成(ファイルと採用した所見の ID)と +定義の内容が直前の版と同じなら新しい版を作らない. +""" + +import json +import re +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 ManifestEntry, build_manifest, manifest_sha256 + +DATASETS_DIRNAME = "datasets" +DEFINITION_SUFFIX = ".toml" +# データセット名(SPEC_02「ID の書式」.スキーマの CHECK と同じ).定義ファイル名にも使う +_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +# エラーメッセージに出す例の最大件数 +_MAX_EXAMPLES = 5 +# エラーメッセージでファイルを示す SHA-256 の桁数(位置の特定には先頭で足りる) +_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=_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 _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: + 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 + 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 _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/importers/__init__.py b/src/tiasdata/importers/__init__.py index 3508ca8..f1c02af 100644 --- a/src/tiasdata/importers/__init__.py +++ b/src/tiasdata/importers/__init__.py @@ -66,8 +66,7 @@ holds=holds, show_source_names=show_source_names, ) - with closing(catalog.connect(paths.catalog_path)) as conn: - catalog.require_latest(conn, "catalog") + with closing(catalog.connect_latest(paths.catalog_path)) as conn: identity.attach_identity(conn, paths.identity) return importer(conn, context) diff --git a/src/tiasdata/importers/holds.py b/src/tiasdata/importers/holds.py index 9adcccf..b2aedfc 100644 --- a/src/tiasdata/importers/holds.py +++ b/src/tiasdata/importers/holds.py @@ -1,9 +1,9 @@ """保留リスト(`holds/<取り込み元の名前>.toml`)の読み込みと検証(SPEC_03「保留リスト」). 保留リストは個人情報を含まないためリポジトリで管理する(PR で変更する). -実行時は,このパッケージの位置からリポジトリのルート(`name = "tiasdata"` の `pyproject.toml` -のあるフォルダ)を辿って `holds/` を見つける.パッケージとして別の場所に入れた場合は -見つからないので,取り込みはリポジトリの clone から実行する. +実行時は,リポジトリのルート(`repo.find_repo_root()`)の `holds/` を見つける. +パッケージとして別の場所に入れた場合は見つからないので,取り込みはリポジトリの clone から +実行する. """ import re @@ -14,8 +14,9 @@ from pydantic import BaseModel, ConfigDict, Field, StrictInt, ValidationError +from tiasdata import repo + HOLDS_DIRNAME = "holds" -_PROJECT_NAME = "tiasdata" # 取り込み元の名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない _SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -61,8 +62,7 @@ def default_holds_dir() -> Path: """リポジトリの `holds/` フォルダを返す. - このパッケージの位置から親のフォルダを辿り,`[project] name = "tiasdata"` の - `pyproject.toml` があるフォルダをリポジトリのルートとみなす. + リポジトリのルートは `repo.find_repo_root()` で見つける. Returns: `holds/` のパス. @@ -71,29 +71,18 @@ ValueError: リポジトリのルートが見つからない(パッケージとして別の場所に入れて 実行した等),またはルートに `holds/` が無い. """ - package_dir = Path(__file__).resolve().parents[1] - for parent in package_dir.parents: - pyproject = parent / "pyproject.toml" - if pyproject.is_file() and _is_tiasdata_project(pyproject): - holds_dir = parent / HOLDS_DIRNAME - if not holds_dir.is_dir(): - raise ValueError( - f"リポジトリに {HOLDS_DIRNAME}/ が無い(保留リストを置くフォルダ)" - ) - return holds_dir - raise ValueError( - "保留リスト(holds/)の場所が分からない." - "取り込みは TIASDataHub のリポジトリの clone から実行する(uv run tiasdata import …)" - ) - - -def _is_tiasdata_project(pyproject: Path) -> bool: try: - data = tomllib.loads(pyproject.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): - return False - project = data.get("project") - return isinstance(project, dict) and project.get("name") == _PROJECT_NAME + 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: diff --git a/src/tiasdata/manifest.py b/src/tiasdata/manifest.py new file mode 100644 index 0000000..d76077b --- /dev/null +++ b/src/tiasdata/manifest.py @@ -0,0 +1,116 @@ +"""`manifest.csv`(バンドルの 1 行 1 ファイルの表)の組み立て(SPEC_03「manifest.csv の列」). + +版の作成(`datasets`)とバンドルの書き出し・読み込み(`bundle`)が同じ形を使うため, +目録(`catalog`)・ストア・設定に依存しない純関数だけを置く(計算サーバで import しても +目録を引き込まないようにするため.SPEC_01「依存の向き」). +""" + +import csv +import hashlib +import io +import json +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from pydantic import BaseModel, ConfigDict + +# 先頭の固定の列(この後に `<所見>.<項目>` の列が定義の `[columns]` の順で並ぶ) +BASE_COLUMNS: tuple[str, ...] = ("path", "sha256", "kind", "subject_id", "capture_id") +FILES_DIRNAME = "files" +MANIFEST_ENCODING = "utf-8" + + +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() 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/tests/conftest.py b/tests/conftest.py index e272032..bcb816d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -263,3 +263,37 @@ 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/test_check.py b/tests/test_check.py index 827c23f..f22728f 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -261,3 +261,43 @@ ) -> 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 index e28783f..c876aac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ """CLI(SPEC_03 の `tiasdata init`・「出力の規則」,error-handling.md の終了コード).""" +import hashlib import os import re import sqlite3 @@ -470,3 +471,209 @@ 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 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_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