diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index 2a8c52d..af11bee 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -1,10 +1,10 @@ --- -paths: ["src/**", "migrations/**", "datasets/**"] +paths: ["src/**", "datasets/**"] --- # コーディング規約 (Coding Standards) -Python コード(`src/**`)・マイグレーション(`migrations/**`)・データセットの定義(`datasets/**`)を編集するときは本ルールに従うこと. +Python コード(`src/**`)・マイグレーション(`src/tiasdata/catalog/migrations/**`)・データセットの定義(`datasets/**`)を編集するときは本ルールに従うこと. ## 基本方針 (Basic Policy) @@ -48,7 +48,7 @@ ## マイグレーション (Migrations) -- スキーマの変更は `migrations/NNNN_<内容>.sql`(4 桁の連番)を追加して行う.**適用済みのマイグレーションファイルは編集しない** +- スキーマの変更は `src/tiasdata/catalog/migrations/<系列>/NNNN_<内容>.sql`(系列は `catalog`・`identity`.4 桁の連番)を追加して行う.ファイルの中に `BEGIN`・`COMMIT`・`PRAGMA user_version` を書かない(適用側が囲む).**適用済みのマイグレーションファイルは編集しない** - 1 つのファイルは 1 つのトランザクションで適用できる内容にする - 種類(`kinds`)の追加もマイグレーションで行い,同時に `src/tiasdata/kinds/` に値の型を追加する - 表・列を変えたら [SPEC_02 データモデル](../../docs/04_SPEC/SPEC_02_データモデル.md)を先に更新する diff --git a/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index 037db8b..579fa61 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -1,5 +1,5 @@ --- -paths: ["src/**", "tests/**", "migrations/**", "datasets/**"] +paths: ["src/**", "tests/**", "datasets/**"] --- # プロジェクト構成ルール (Project Structure) @@ -13,6 +13,7 @@ ├── __init__.py # 公開 API(load・open_bundle・Bundle・Catalog)を再 export する ├── config.py # 設定ファイルの読み込み・検証 ├── catalog/ # 目録(SQLite)の接続・マイグレーションの適用・各表の操作(SQL はここだけ) +│ └── migrations/ # スキーマ変更(catalog/・identity/ の 2 系列.NNNN_<内容>.sql) ├── store.py # ストア(ハッシュ・コピー・照合) ├── kinds/ # ファイルの種類・所見の種類の値の型 ├── importers/ # 取り込み元の形ごとの読み取り(1 つの形 = 1 モジュール) @@ -21,7 +22,6 @@ ├── deliver.py # 計算サーバへの受け渡し(scp・ssh) └── cli.py # CLI エントリポイント -migrations/ # 目録のスキーマ変更(NNNN_<内容>.sql) datasets/ # データセットの定義(<名前>.toml) tests/ # src/tiasdata/ の構造をミラーする ``` @@ -33,7 +33,7 @@ - **バンドルの読み込みは目録に依存しない**: `bundle.py` の読み込み側(`load`・`open_bundle`)は `catalog`・`store`・`config` を import しない(目録の無い計算サーバで使うため.書き出し側は別関数に分ける) - **公開 API は `__init__.py` で export したものだけ**: 使う側は `tiasdata.<名前>` で使う.内部モジュールを直接 import させない - **取り込み元を増やすとき**: `importers/` にモジュールを追加し,設定ファイルの `importer` の名前から選べるようにする.既存の importer に分岐を足さない -- **種類を増やすとき**: `migrations/` に `kinds` の行を追加する SQL と,`kinds/` に値の型を追加する([SPEC_02「拡張の例」](../../docs/04_SPEC/SPEC_02_データモデル.md)) +- **種類を増やすとき**: `src/tiasdata/catalog/migrations/catalog/` に `kinds` の行を追加する SQL と,`kinds/` に値の型を追加する([SPEC_02「拡張の例」](../../docs/04_SPEC/SPEC_02_データモデル.md)) - **副作用は縁に寄せる**: ファイルのコピー・SSH・DB への書き込みは `store`・`deliver`・`catalog` に閉じ込め,定義の評価・manifest の行の組み立て・照合の判定は入力 → 出力の純関数として書く(テスト容易性のため) - **データを置かない**: 臨床データ・目録・対応表・バンドルをリポジトリの配下に置かない(`.gitignore` は保険.[ENV_02「臨床データの取り扱い」](../../docs/02_ENV/ENV_02_環境構築手順.md)) - **テストの配置**: `tests/` は `src/tiasdata/` の構造をミラーする(詳細は `.claude/rules/testing.md`) diff --git a/CLAUDE.md b/CLAUDE.md index 1f3ff21..5d1b14f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: プロジェクト立ち上げ完了.次は PLAN_02 Step 1(設定と目録の土台) +最新: PLAN_02 Step 1(設定と目録の土台・`tiasdata init`)完了.次は Step 2(ストア) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index c1c236b..a3140ca 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -23,7 +23,7 @@ | --- | --- | --- | | 目録 | **SQLite**(Python 標準の `sqlite3`) | サーバを立てずに DB ファイル 1 つで済む.1 人で使うため同時書き込みの心配がない.C#(DeepTIAS)からも `Microsoft.Data.Sqlite` で読める | | DB へのアクセス | 標準の `sqlite3` で SQL を直接書く(ORM は使わない) | テーブル数が少なく,SQL がそのまま仕様として読める.依存を増やさない | -| スキーマの変更 | 連番の SQL ファイル(`migrations/0001_*.sql` …)を順に適用し,適用済みの版を DB の `PRAGMA user_version` に記録する | データの種類を足すたびに表・列が増えるため,変更履歴をファイルで残す.Alembic 等は 1 ファイルの SQLite には過大 | +| スキーマの変更 | 連番の SQL ファイル(パッケージ内の `src/tiasdata/catalog/migrations/<系列>/0001_*.sql` …)を順に適用し,適用済みの版を DB の `PRAGMA user_version` に記録する | データの種類を足すたびに表・列が増えるため,変更履歴をファイルで残す.Alembic 等は 1 ファイルの SQLite には過大 | | データ本体 | **ファイルのまま**データ置き場に置き,目録にはデータ置き場からの相対パスを記録する | 画像を DB に入れない(DB が肥大化し,サーバへのコピーや他ツールからの参照がしにくくなる) | | 同一性の確認 | ファイルの **SHA-256**(標準の `hashlib`) | データセットの版にハッシュを含め,ファイルが差し替わったことを検出する.再現性の根拠にする | | 置き場所の設定 | 設定ファイル(TOML.標準の `tomllib` で読む)または環境変数で,DB ファイルとデータ置き場のパスを指定する | コードにパスを書かない.設定ファイルはリポジトリの外(研究室 PC のユーザー領域)に置く.具体的なキー・既定の場所は仕様設計で決める | diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index fdbbbf0..f1d0c32 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" @@ -15,15 +15,20 @@ | --- | --- | | `uv run pytest` | テストを実行する(ダミーデータのみ) | | `uv run ruff check .` | lint | -| `uv run ruff format .` | 整形(確認だけなら `uv run ruff format --check .`) | +| `uv run ruff format .` | 整形(確認だけなら `uv run ruff format --check .`).`docs/` は `pyproject.toml` で対象外にしている(Markdown のコード例を整形させないため) | ## CLI | コマンド | 用途 | | --- | --- | | `uv run tiasdata --version` | 版を表示する | +| `uv run tiasdata init` | 設定ファイルに従って目録(`catalog.sqlite`)・`store/`・`bundles/`・`backups/`・対応表(`identity.sqlite`)を作り,スキーマのマイグレーションを適用する.何度実行してもよい(不足分だけ適用する) | -- サブコマンド(登録・データセットの定義・manifest の書き出し等)は実装フェーズで追加し,ここに追記する +- 設定ファイルは環境変数 `TIASDATA_CONFIG` のパス,無ければ `~/.config/tiasdata/config.toml`(キーは [SPEC_01「設定ファイル」](../04_SPEC/SPEC_01_アーキテクチャ設計.md)) +- 手元で試すときは一時フォルダに設定ファイルを作って指す(例: `TIASDATA_CONFIG="$TMPDIR/tiasdata-try/config.toml" uv run tiasdata init`.臨床データは使わない) +- 研究室 PC では `ssh labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe run tiasdata init"` の形で実行する([ENV_02](ENV_02_環境構築手順.md)) +- 失敗時は標準エラーに 1 行を出し,終了コード 1(入力・データの問題)または 2(引数の誤り)で終わる +- 残りのサブコマンド(取り込み・データセット・書き出し・受け渡し・点検等)は実装した 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 32f3090..b8a0087 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -12,7 +12,7 @@ | Step | 内容 | 完了条件 | | --- | --- | --- | -| 1 | **設定と目録の土台**: `config.py`(設定ファイルの読み込み・検証),`catalog/`(接続・マイグレーションの適用),`migrations/0001`(SPEC_02 の全表と最初の種類),`tiasdata init` | 一時ディレクトリの設定ファイルから `init` で目録ができ,`PRAGMA user_version` が最新になる.テスト(設定ファイル・マイグレーション)が通る | +| 1 | **設定と目録の土台**: `config.py`(設定ファイルの読み込み・検証),`catalog/`(接続・マイグレーションの適用),`catalog/migrations/catalog/0001`(SPEC_02 の全表と最初の種類)・`identity/0001`(対応表),`tiasdata init`(2026-10-06 完了) | 一時ディレクトリの設定ファイルから `init` で目録ができ,`PRAGMA user_version` が最新になる.テスト(設定ファイル・マイグレーション)が通る | | 2 | **ストア**: `store.py`(ハッシュ・コピー・照合),テスト用のダミーデータ(小さい画像・取り込み元のフォルダ)を作る `conftest.py` | 同じ中身が 1 つにまとまり,コピー後の照合・上書きしないことがテストで確かめられる | | 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check` | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.出力に患者 ID・元のファイル名が出ない | | 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む | diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index a27be95..50ea975 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" @@ -44,6 +44,7 @@ ├── src/tiasdata/ │ ├── config.py # 設定ファイルの読み込み・検証(pydantic) │ ├── catalog/ # 目録(SQLite)の接続・マイグレーション・各表の操作 +│ │ └── migrations/ # スキーマ変更の SQL(catalog/・identity/ の 2 系列.NNNN_<内容>.sql) │ ├── store.py # ストア(ハッシュの計算・コピー・検証) │ ├── kinds/ # ファイルの種類・所見の種類の定義(値の検証用の型) │ ├── importers/ # 取り込み元ごとの読み取り(例: 舌色の 9 フォルダ) @@ -51,12 +52,13 @@ │ ├── bundle.py # バンドルの書き出し・読み込み・検証 │ ├── deliver.py # 計算サーバへの受け渡し │ └── cli.py # エントリポイント -├── migrations/ # 目録のスキーマ変更(0001_*.sql …) ├── datasets/ # データセットの定義(TOML) ├── tests/ # ダミーデータのみで動くテスト └── docs/ ``` +マイグレーションの SQL はリポジトリ直下ではなくパッケージの中(`src/tiasdata/catalog/migrations/`)に置き,`importlib.resources` で読む(サブモジュール・パッケージとして使われたときも見つけられるようにするため.2026-10-06 の判断). + 依存の向きは `cli` → `datasets`・`bundle`・`deliver`・`importers` → `catalog`・`store`・`kinds`・`config` とする.**`bundle` の読み込み側は `catalog` に依存しない**(目録の無い計算サーバで使うため). ### 研究室 PC (Lab PC) @@ -84,7 +86,9 @@ 研究室 PC のユーザー領域に置き(リポジトリには入れない),pydantic で検証する(未知のキーは拒否する). - 場所: 環境変数 `TIASDATA_CONFIG` があればそのパス,無ければ `%USERPROFILE%\.config\tiasdata\config.toml`(Linux では `~/.config/tiasdata/config.toml`) -- 相対パスは設定ファイルのあるフォルダを基準にする.`~` を展開する +- 相対パスは設定ファイルのあるフォルダを基準にする.`~` を展開する(`servers.*.root` はサーバ側のパスなので展開しない) +- `catalog.identity`(対応表)は `catalog.root` の外に置く.中にある設定は検証エラーにする(対応表を目録とは別のフォルダに分けるため) +- `catalog.root` の下の `catalog.sqlite`・`store/`・`bundles/`・`backups/` は固定の名前で,設定には書かない ```toml [catalog] diff --git "a/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" "b/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" index d39dbe0..9443b80 100644 --- "a/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" +++ "b/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" @@ -1,6 +1,17 @@ # データモデル (Data Model) -目録(`catalog.sqlite`)の表の定義.スキーマは `migrations/` の連番 SQL で管理し,適用済みの版を `PRAGMA user_version` に記録する([ENV_01](../02_ENV/ENV_01_技術スタック.md)).外部キー制約を有効にする(`PRAGMA foreign_keys = ON`). +目録(`catalog.sqlite`)の表の定義.スキーマはパッケージ内の `src/tiasdata/catalog/migrations/catalog/` の連番 SQL で管理し,適用済みの版を `PRAGMA user_version` に記録する([ENV_01](../02_ENV/ENV_01_技術スタック.md)).対応表(`identity.sqlite`)は別の系列 `migrations/identity/` で同様に管理する.外部キー制約を有効にする(`PRAGMA foreign_keys = ON`). + +## スキーマで強制する規則 (Enforced by Schema) + +以下は SQL の制約(CHECK・トリガ)で強制し,コードの誤りでも破れないようにする(2026-10-06 の判断). + +- **書式**: 研究用 ID(`S`・`C` + 6 桁),SHA-256(小文字 64 桁),拡張子,種類名・データセット名(英小文字で始まる snake_case),日時は UTC の `YYYY-MM-DDTHH:MM:SSZ`(**秒まで.小数秒は拒否**),撮影日は `YYYY-MM-DD` +- **主キー**: ID・名前の主キーは NULL を許さない(SQLite は INTEGER 以外の主キーに NULL を入れられるため `NOT NULL` を明示する) +- **種類の整合**: `assets.kind` はファイルの種類,`annotations.kind` は所見の種類だけを指せる.所見の種類は `value_schema_version`(1 以上)を必ず持ち,ファイルの種類は持たない +- **所見は書き換えない**: `annotations` は UPDATE・DELETE を拒否する.例外は訂正のときに `superseded_by` を NULL から一度だけ埋めること(自分より後の行だけを指せる).値は JSON として正しいこと +- **凍結した版は書き換えない**: `dataset_versions`・`dataset_members`・`dataset_member_annotations` は UPDATE・DELETE を拒否する.そのため版の行は,`manifest.csv` の SHA-256 を計算してから INSERT する +- **対応表**: 1 つの研究用 ID は 1 人の患者にだけ対応する(`identity_map.subject_id` は一意) ## 全体像 (Overview) @@ -154,7 +165,7 @@ ## 対応表 (Identity Map) -`identity.sqlite`(目録とは別のファイル・別のフォルダ).実際の患者 ID が分かるデータを取り込むときに使う(現在の舌色データも対象). +`identity.sqlite`(目録とは別のファイル・別のフォルダ)の `identity_map` 表.実際の患者 ID が分かるデータを取り込むときに使う(現在の舌色データも対象). | 列 | 内容 | | --- | --- | diff --git "a/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" "b/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" index 69f89fa..7d016ce 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" @@ -133,3 +133,6 @@ | 設定ファイルで付けた名前(取り込み元・サーバ) | — | - 元のファイル名(`source_name`)は個人をたどれる情報を含まないが,既定では表示しない(エラーの特定に必要な場合だけ表示する) +- 目録・ストア・対応表・取り込み元のパスは表示しない.例外として,「設定ファイルが見つからない」エラーでは設定ファイル自身のパスを表示する(データの場所ではなく,原因の特定に必要なため) +- 設定ファイルの検証エラーは「キーの位置: 理由」だけを 1 行で示し,入力された値(パス等)は表示しない +- 出力は常に UTF-8 で書き出す(Windows でパイプ・SSH 越しに cp932 で書き出されて手元で文字化けするのを防ぐため) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 56bb1e6..c68a8e4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -25,3 +25,6 @@ - [x] **研究室 PC の臨床データを E ドライブへ移した**: 研究室 PC に E ドライブ(内蔵 SSD・約 465GB)ができたため,`C:\Users\Papin\clinical-data\` 全体(26,268 ファイル・45.30GB)を `E:\clinical-data\` へコピーし,SHA-256 と構成の照合の後に C 側を削除した(2026-10-06.C の空き 53.5GB).docs・`.claude/repo-access.json`・`lab-pc-operation.md` のパスを書き換えた - 失敗パターン: C 側の削除が読み取り専用の属性が付いたフォルダで止まった(`shutil.rmtree` の WinError 5).属性を外して再試行する `onexc` で解決.コピー済み・照合済みだったためデータの欠損は無し - 未実装・後段: TIASJudgeModel の手順書の原本のパスが古い.E ドライブの暗号化(BitLocker)は未定(ENV_03「人間が確認すべき事項」) +- [x] **PLAN_02 Step 1: 設定と目録の土台**: 設定ファイルの読み込み・検証,SQLite の目録と対応表のマイグレーション(SPEC_02 の全表と最初の種類),`tiasdata init` を実装(2026-10-06) + - 設計判断: マイグレーションはリポジトリ直下ではなくパッケージ内(`src/tiasdata/catalog/migrations/`)に置く(サブモジュール・パッケージとして使ったときも見つけられるようにするため).目録と対応表は別系列で版を管理する.凍結した版・所見の書き換え禁止,種類の整合,書式(日時は秒まで)を SQL の CHECK・トリガで強制する(SPEC_02「スキーマで強制する規則」) + - 失敗パターン: ① SQLite の CHECK は NULL を違反としないため,`value_schema_version >= 1` が NULL ですり抜けた/② INTEGER 以外の主キーに NULL が入った → どちらも `IS NOT NULL`・`NOT NULL` を明示して解決.③ Windows でパイプ・SSH 越しの出力が cp932 で化けた → CLI の入口で標準出力・標準エラーを UTF-8 に切り替えた.④ `ruff format .` が docs/ の Markdown 内のコード例を整形した → `extend-exclude` に docs を追加 diff --git a/pyproject.toml b/pyproject.toml index dce91df..c5eabfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,9 @@ line-length = 100 target-version = "py312" # .claude/ はテンプレート所有のファイル(フック・テンプレートのテスト)であり, -# このプロジェクトの規約で整形しないため lint 対象から外す -extend-exclude = [".claude"] +# このプロジェクトの規約で整形しないため lint 対象から外す. +# docs/ は Markdown 内のコード例を ruff format に整形させないため外す +extend-exclude = [".claude", "docs"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "N"] diff --git a/src/tiasdata/catalog/__init__.py b/src/tiasdata/catalog/__init__.py new file mode 100644 index 0000000..02d7cea --- /dev/null +++ b/src/tiasdata/catalog/__init__.py @@ -0,0 +1,24 @@ +"""目録(SQLite)の接続・マイグレーションの適用・各表の操作. + +SQL はこのパッケージの中にだけ書く(他のモジュールはここの関数を呼ぶ). +""" + +from tiasdata.catalog.connection import connect +from tiasdata.catalog.migrate import ( + MigrationResult, + Series, + apply_migrations, + latest_version, + schema_version, +) +from tiasdata.catalog.queries import count_kinds + +__all__ = [ + "MigrationResult", + "Series", + "apply_migrations", + "connect", + "count_kinds", + "latest_version", + "schema_version", +] diff --git a/src/tiasdata/catalog/connection.py b/src/tiasdata/catalog/connection.py new file mode 100644 index 0000000..a8f1e48 --- /dev/null +++ b/src/tiasdata/catalog/connection.py @@ -0,0 +1,32 @@ +"""SQLite の接続.""" + +import sqlite3 +from pathlib import Path + + +def connect(path: Path, *, create: bool = False) -> sqlite3.Connection: + """SQLite の DB を開き,外部キー制約を有効にする. + + Args: + path: DB ファイルのパス. + create: True なら DB ファイルが無いときに作る(親フォルダは作らない). + False なら DB ファイルが無いときに停止する(`tiasdata init` の前に誤って空の目録を + 作らないため). + + Returns: + `PRAGMA foreign_keys = ON` を有効にした接続. + + Raises: + ValueError: `create` が False で DB ファイルが無い,または DB を開けない. + """ + mode = "rwc" if create else "rw" + uri = f"{path.resolve().as_uri()}?mode={mode}" + try: + conn = sqlite3.connect(uri, uri=True) + except sqlite3.OperationalError as e: + if not create and not path.exists(): + raise ValueError("目録が無い.先に `tiasdata init` を実行する") from e + raise ValueError(f"DB を開けない: {e}") from e + # 外部キー制約は接続ごとの設定で,既定では無効(https://www.sqlite.org/foreignkeys.html) + conn.execute("PRAGMA foreign_keys = ON") + return conn diff --git a/src/tiasdata/catalog/migrate.py b/src/tiasdata/catalog/migrate.py new file mode 100644 index 0000000..324b3bb --- /dev/null +++ b/src/tiasdata/catalog/migrate.py @@ -0,0 +1,135 @@ +"""マイグレーション(連番の SQL ファイル)の適用. + +適用済みの版を DB の `PRAGMA user_version` に記録し,それより新しいファイルだけを番号順に適用する. +マイグレーションはパッケージ内(`catalog/migrations/<系列>/NNNN_<内容>.sql`)に置き, +`importlib.resources` で読む(editable install でもパッケージとして入れても +同じように見つかるようにするため). +""" + +import re +import sqlite3 +from importlib import resources +from typing import Literal, NamedTuple + +# 目録(catalog.sqlite)と対応表(identity.sqlite)は別の DB なので,版の系列も分ける +Series = Literal["catalog", "identity"] + +_FILENAME_PATTERN = re.compile(r"^(\d{4})_[a-z0-9_]+\.sql$") + + +class Migration(NamedTuple): + """1 つのマイグレーションファイル.""" + + version: int + name: str + sql: str + + +class MigrationResult(NamedTuple): + """マイグレーションの適用結果.""" + + before: int + after: int + + @property + def applied(self) -> int: + """今回適用したファイルの数.""" + return self.after - self.before + + +def load_migrations(series: Series) -> list[Migration]: + """系列のマイグレーションを番号順に読む. + + Args: + series: `catalog`(目録)または `identity`(対応表). + + Returns: + 版の昇順に並べたマイグレーション. + + Raises: + RuntimeError: ファイル名の形が誤っている,または番号が 1 から連続していない + (パッケージに同梱するファイルの不備なので内部論理エラーとして扱う). + """ + directory = resources.files("tiasdata.catalog") / "migrations" / series + migrations: list[Migration] = [] + for entry in directory.iterdir(): + if not entry.name.endswith(".sql"): + continue + match = _FILENAME_PATTERN.match(entry.name) + if match is None: + raise RuntimeError( + f"マイグレーションのファイル名が NNNN_<内容>.sql でない: {entry.name}" + ) + migrations.append( + Migration(int(match.group(1)), entry.name, entry.read_text(encoding="utf-8")) + ) + migrations.sort(key=lambda m: m.version) + expected = list(range(1, len(migrations) + 1)) + if [m.version for m in migrations] != expected: + raise RuntimeError(f"マイグレーション({series})の番号が 1 から連続していない") + return migrations + + +def latest_version(series: Series) -> int: + """系列の最新の版(同梱したマイグレーションの最大番号)を返す. + + Args: + series: `catalog` または `identity`. + + Returns: + 最新の版.マイグレーションが無ければ 0. + """ + return len(load_migrations(series)) + + +def schema_version(conn: sqlite3.Connection) -> int: + """DB に適用済みのスキーマの版(`PRAGMA user_version`)を返す. + + Args: + conn: DB への接続. + + Returns: + 適用済みの版.未適用なら 0. + """ + return conn.execute("PRAGMA user_version").fetchone()[0] + + +def apply_migrations(conn: sqlite3.Connection, series: Series) -> MigrationResult: + """未適用のマイグレーションを番号順に適用する. + + 1 ファイルを 1 トランザクションで適用し,同じトランザクションの中で `user_version` を + そのファイルの番号に更新する.途中のファイルで失敗したらそのファイルの変更は残らない + (それより前のファイルは適用済みのまま).適用済みの DB に再度呼んでも何もしない. + + Args: + conn: `connect()` で開いた接続. + series: `catalog`(目録)または `identity`(対応表). + + Returns: + 適用前と適用後の版. + + Raises: + ValueError: DB の版がこのライブラリの知る最新の版より新しい(ライブラリが古い). + sqlite3.Error: SQL の実行に失敗した(同梱するマイグレーションの不備). + """ + migrations = load_migrations(series) + before = schema_version(conn) + if before > len(migrations): + raise ValueError( + f"DB({series})のスキーマの版 {before} が,このライブラリの知る最新の版 " + f"{len(migrations)} より新しい.tiasdata を更新する" + ) + for migration in migrations[before:]: + # executescript は直前のトランザクションを確定してから,スクリプトを自動コミットで流す. + # 1 ファイルを 1 トランザクションにするため BEGIN・COMMIT で囲む. + # PRAGMA はプレースホルダを受け付けないため版を埋め込む(値はファイル名から得た整数のみ) + script = ( + f"BEGIN;\n{migration.sql}\nPRAGMA user_version = {int(migration.version)};\nCOMMIT;" + ) + try: + conn.executescript(script) + except sqlite3.Error: + if conn.in_transaction: + conn.rollback() + raise + return MigrationResult(before, schema_version(conn)) diff --git a/src/tiasdata/catalog/migrations/catalog/0001_initial.sql b/src/tiasdata/catalog/migrations/catalog/0001_initial.sql new file mode 100644 index 0000000..675b6b6 --- /dev/null +++ b/src/tiasdata/catalog/migrations/catalog/0001_initial.sql @@ -0,0 +1,285 @@ +-- 目録(catalog.sqlite)の最初のスキーマ(SPEC_02「表の定義」). +-- apply_migrations() が BEGIN・COMMIT で囲み,PRAGMA user_version を更新する. +-- このファイルに BEGIN・COMMIT・PRAGMA user_version を書かない. +-- 適用済みのマイグレーションは編集しない(変更は 0002_*.sql を追加して行う). +-- +-- 書式の制約(GLOB): +-- 日時 YYYY-MM-DDTHH:MM:SSZ(UTC) +-- 日付 YYYY-MM-DD +-- SHA-256 16 進 64 文字(小文字) +-- 研究用 ID S + 6 桁,撮影 ID C + 6 桁 +-- 種類名・データセット名 英小文字で始まり,英小文字・数字・_ だけ +-- +-- NULL の扱い: SQLite の CHECK は結果が NULL のとき違反としない.また INTEGER 以外の +-- PRIMARY KEY は NULL を受け入れる.そのため NULL を許さない列は NOT NULL を明示し, +-- CHECK の条件で NULL になりうる列は IS NOT NULL を明示する. + +-- --------------------------------------------------------------------------- +-- 種類の定義 +-- --------------------------------------------------------------------------- +CREATE TABLE kinds ( + kind TEXT NOT NULL PRIMARY KEY + CHECK (kind GLOB '[a-z]*' AND kind NOT GLOB '*[^a-z0-9_]*'), + category TEXT NOT NULL CHECK (category IN ('asset', 'annotation')), + description TEXT NOT NULL, + value_schema_version INTEGER, + -- 値の型の版は所見の種類だけが持つ + CHECK ( + (category = 'asset' AND value_schema_version IS NULL) + OR (category = 'annotation' AND value_schema_version IS NOT NULL AND value_schema_version >= 1) + ) +); + +-- --------------------------------------------------------------------------- +-- 症例・撮影・ファイル +-- --------------------------------------------------------------------------- +CREATE TABLE subjects ( + subject_id TEXT NOT NULL PRIMARY KEY + CHECK (subject_id GLOB 'S[0-9][0-9][0-9][0-9][0-9][0-9]'), + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + note TEXT +); + +CREATE TABLE captures ( + capture_id TEXT NOT NULL PRIMARY KEY + CHECK (capture_id GLOB 'C[0-9][0-9][0-9][0-9][0-9][0-9]'), + subject_id TEXT NOT NULL REFERENCES subjects (subject_id), + captured_on TEXT + CHECK (captured_on IS NULL OR captured_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + device TEXT, + note TEXT +); + +CREATE INDEX captures_subject_id ON captures (subject_id); + +CREATE TABLE assets ( + sha256 TEXT NOT NULL PRIMARY KEY + CHECK (length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'), + kind TEXT NOT NULL REFERENCES kinds (kind), + capture_id TEXT REFERENCES captures (capture_id), + ext TEXT NOT NULL CHECK (ext <> '' AND ext NOT GLOB '*[^a-z0-9]*'), + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + source TEXT NOT NULL, + source_name TEXT NOT NULL, + registered_at TEXT NOT NULL + CHECK (registered_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z') +); + +CREATE INDEX assets_kind ON assets (kind); +CREATE INDEX assets_capture_id ON assets (capture_id); + +-- ファイルの種類は kinds.category = 'asset' のものに限る(CHECK は他の表を参照できないためトリガで守る) +CREATE TRIGGER assets_kind_category_insert +BEFORE INSERT ON assets +WHEN (SELECT category FROM kinds WHERE kind = NEW.kind) IS NOT 'asset' +BEGIN + SELECT RAISE(ABORT, 'assets.kind must be a kind with category asset'); +END; + +CREATE TRIGGER assets_kind_category_update +BEFORE UPDATE OF kind ON assets +WHEN (SELECT category FROM kinds WHERE kind = NEW.kind) IS NOT 'asset' +BEGIN + SELECT RAISE(ABORT, 'assets.kind must be a kind with category asset'); +END; + +-- --------------------------------------------------------------------------- +-- 由来 +-- --------------------------------------------------------------------------- +CREATE TABLE derivations ( + derivation_id INTEGER PRIMARY KEY, + tool TEXT NOT NULL, + tool_version TEXT NOT NULL, + mode TEXT, + performed_at TEXT + CHECK (performed_at IS NULL OR performed_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + note TEXT +); + +CREATE TABLE derivation_inputs ( + derivation_id INTEGER NOT NULL REFERENCES derivations (derivation_id), + sha256 TEXT NOT NULL REFERENCES assets (sha256), + role TEXT NOT NULL, + PRIMARY KEY (derivation_id, sha256, role) +); + +CREATE INDEX derivation_inputs_sha256 ON derivation_inputs (sha256); + +CREATE TABLE derivation_outputs ( + derivation_id INTEGER NOT NULL REFERENCES derivations (derivation_id), + sha256 TEXT NOT NULL REFERENCES assets (sha256), + role TEXT NOT NULL, + PRIMARY KEY (derivation_id, sha256, role) +); + +CREATE INDEX derivation_outputs_sha256 ON derivation_outputs (sha256); + +-- --------------------------------------------------------------------------- +-- 所見 +-- --------------------------------------------------------------------------- +CREATE TABLE annotations ( + annotation_id INTEGER PRIMARY KEY, + kind TEXT NOT NULL REFERENCES kinds (kind), + capture_id TEXT REFERENCES captures (capture_id), + sha256 TEXT REFERENCES assets (sha256), + value TEXT NOT NULL CHECK (json_valid(value)), + annotator TEXT, + source TEXT NOT NULL, + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + -- 訂正は後から追加した行を指す + superseded_by INTEGER REFERENCES annotations (annotation_id) + CHECK (superseded_by IS NULL OR superseded_by > annotation_id), + -- 撮影に付くかファイルに付くかのどちらか一方だけ + CHECK ((capture_id IS NULL) <> (sha256 IS NULL)) +); + +CREATE INDEX annotations_kind ON annotations (kind); +CREATE INDEX annotations_capture_id ON annotations (capture_id); +CREATE INDEX annotations_sha256 ON annotations (sha256); + +CREATE TRIGGER annotations_kind_category_insert +BEFORE INSERT ON annotations +WHEN (SELECT category FROM kinds WHERE kind = NEW.kind) IS NOT 'annotation' +BEGIN + SELECT RAISE(ABORT, 'annotations.kind must be a kind with category annotation'); +END; + +-- 所見の行は書き換えない.許すのは superseded_by を NULL から一度だけ埋めることだけ +-- (過去の版が古い行を参照し続けられるようにするため.SPEC_02「annotations」) +CREATE TRIGGER annotations_no_update +BEFORE UPDATE ON annotations +WHEN OLD.superseded_by IS NOT NULL + OR NEW.superseded_by IS NULL + OR NEW.annotation_id IS NOT OLD.annotation_id + OR NEW.kind IS NOT OLD.kind + OR NEW.capture_id IS NOT OLD.capture_id + OR NEW.sha256 IS NOT OLD.sha256 + OR NEW.value IS NOT OLD.value + OR NEW.annotator IS NOT OLD.annotator + OR NEW.source IS NOT OLD.source + OR NEW.created_at IS NOT OLD.created_at +BEGIN + SELECT RAISE(ABORT, 'annotations are immutable except setting superseded_by once'); +END; + +CREATE TRIGGER annotations_no_delete +BEFORE DELETE ON annotations +BEGIN + SELECT RAISE(ABORT, 'annotations must not be deleted'); +END; + +-- --------------------------------------------------------------------------- +-- データセット +-- --------------------------------------------------------------------------- +CREATE TABLE datasets ( + name TEXT NOT NULL PRIMARY KEY + CHECK (name GLOB '[a-z]*' AND name NOT GLOB '*[^a-z0-9_]*'), + description TEXT, + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z') +); + +CREATE TABLE dataset_versions ( + name TEXT NOT NULL REFERENCES datasets (name), + version INTEGER NOT NULL CHECK (version >= 1), + definition TEXT NOT NULL, + hub_commit TEXT NOT NULL, + manifest_sha256 TEXT NOT NULL + CHECK (length(manifest_sha256) = 64 AND manifest_sha256 NOT GLOB '*[^0-9a-f]*'), + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + note TEXT, + PRIMARY KEY (name, version) +); + +CREATE TABLE dataset_members ( + name TEXT NOT NULL, + version INTEGER NOT NULL, + sha256 TEXT NOT NULL REFERENCES assets (sha256), + PRIMARY KEY (name, version, sha256), + FOREIGN KEY (name, version) REFERENCES dataset_versions (name, version) +); + +CREATE INDEX dataset_members_sha256 ON dataset_members (sha256); + +CREATE TABLE dataset_member_annotations ( + name TEXT NOT NULL, + version INTEGER NOT NULL, + sha256 TEXT NOT NULL, + annotation_id INTEGER NOT NULL REFERENCES annotations (annotation_id), + PRIMARY KEY (name, version, sha256, annotation_id), + FOREIGN KEY (name, version, sha256) REFERENCES dataset_members (name, version, sha256) +); + +CREATE INDEX dataset_member_annotations_annotation_id + ON dataset_member_annotations (annotation_id); + +-- 凍結した版とその構成は書き換え・削除しない(SPEC_02「datasets」) +CREATE TRIGGER dataset_versions_no_update +BEFORE UPDATE ON dataset_versions +BEGIN + SELECT RAISE(ABORT, 'dataset_versions are frozen'); +END; + +CREATE TRIGGER dataset_versions_no_delete +BEFORE DELETE ON dataset_versions +BEGIN + SELECT RAISE(ABORT, 'dataset_versions are frozen'); +END; + +CREATE TRIGGER dataset_members_no_update +BEFORE UPDATE ON dataset_members +BEGIN + SELECT RAISE(ABORT, 'dataset_members are frozen'); +END; + +CREATE TRIGGER dataset_members_no_delete +BEFORE DELETE ON dataset_members +BEGIN + SELECT RAISE(ABORT, 'dataset_members are frozen'); +END; + +CREATE TRIGGER dataset_member_annotations_no_update +BEFORE UPDATE ON dataset_member_annotations +BEGIN + SELECT RAISE(ABORT, 'dataset_member_annotations are frozen'); +END; + +CREATE TRIGGER dataset_member_annotations_no_delete +BEFORE DELETE ON dataset_member_annotations +BEGIN + SELECT RAISE(ABORT, 'dataset_member_annotations are frozen'); +END; + +-- --------------------------------------------------------------------------- +-- 受け渡しの記録 +-- --------------------------------------------------------------------------- +CREATE TABLE deliveries ( + delivery_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + version INTEGER NOT NULL, + server TEXT NOT NULL, + path TEXT NOT NULL, + delivered_at TEXT NOT NULL + CHECK (delivered_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'), + verified INTEGER NOT NULL CHECK (verified IN (0, 1)), + FOREIGN KEY (name, version) REFERENCES dataset_versions (name, version) +); + +CREATE INDEX deliveries_name_version ON deliveries (name, version); + +-- --------------------------------------------------------------------------- +-- 最初に用意する種類(SPEC_02「kinds」) +-- --------------------------------------------------------------------------- +INSERT INTO kinds (kind, category, description, value_schema_version) VALUES + ('tongue_raw', 'asset', '撮影した舌画像(補正前)', NULL), + ('color_chart', 'asset', 'カラーチャート画像', NULL), + ('tongue_crop_masked', 'asset', + '色補正後の画像を舌の枠で切り出し,マスク外を黒 (0,0,0) にしたもの(DeepTIAS Image&Calib の 2-cropped × 3-resized)', + NULL), + ('tongue_mask', 'asset', '舌のマスク画像', NULL), + ('tongue_color', 'annotation', + '舌色 5 分類+程度.値は {"label": pale|light_red|red|dark_red|purple, "severity": strong|mild|normal}.light_red のときだけ normal', + 1); diff --git a/src/tiasdata/catalog/migrations/identity/0001_initial.sql b/src/tiasdata/catalog/migrations/identity/0001_initial.sql new file mode 100644 index 0000000..ecfd561 --- /dev/null +++ b/src/tiasdata/catalog/migrations/identity/0001_initial.sql @@ -0,0 +1,15 @@ +-- 対応表(identity.sqlite)の最初のスキーマ(SPEC_02「対応表」). +-- 目録とは別の DB なので,版(PRAGMA user_version)の系列も目録とは別にする. +-- apply_migrations() が BEGIN・COMMIT で囲み,PRAGMA user_version を更新する. +-- 適用済みのマイグレーションは編集しない. + +CREATE TABLE identity_map ( + -- 実際の患者 ID(取り込み元が持つもの) + -- INTEGER 以外の PRIMARY KEY は NULL を受け入れ,CHECK も NULL を違反としないため NOT NULL を明示する + patient_key TEXT NOT NULL PRIMARY KEY CHECK (patient_key <> ''), + -- 研究用 ID.1 つの症例は 1 人の患者にだけ対応する + subject_id TEXT NOT NULL UNIQUE + CHECK (subject_id GLOB 'S[0-9][0-9][0-9][0-9][0-9][0-9]'), + created_at TEXT NOT NULL + CHECK (created_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z') +); diff --git a/src/tiasdata/catalog/queries.py b/src/tiasdata/catalog/queries.py new file mode 100644 index 0000000..442b3c9 --- /dev/null +++ b/src/tiasdata/catalog/queries.py @@ -0,0 +1,16 @@ +"""目録の集計(読み取りのみ).""" + +import sqlite3 + + +def count_kinds(conn: sqlite3.Connection) -> dict[str, int]: + """種類の定義の件数を分類(`asset`・`annotation`)ごとに数える. + + Args: + conn: 目録への接続(マイグレーション適用済み). + + Returns: + 分類 → 件数. + """ + rows = conn.execute("SELECT category, COUNT(*) FROM kinds GROUP BY category ORDER BY category") + return {category: count for category, count in rows} diff --git a/src/tiasdata/cli.py b/src/tiasdata/cli.py index c858750..e36bbb2 100644 --- a/src/tiasdata/cli.py +++ b/src/tiasdata/cli.py @@ -1,16 +1,102 @@ -"""tiasdata コマンドのエントリポイント(サブコマンドは実装フェーズで追加する).""" +"""tiasdata コマンドのエントリポイント.""" import argparse +import sys +from contextlib import closing -from tiasdata import __version__ +from pydantic import ValidationError + +from tiasdata import __version__, catalog +from tiasdata.config import load_config + + +def _cmd_init(args: argparse.Namespace) -> int: + config = load_config() + paths = config.catalog + for directory in (paths.root, paths.store_dir, paths.bundles_dir, paths.backups_dir): + directory.mkdir(parents=True, exist_ok=True) + with closing(catalog.connect(paths.catalog_path, create=True)) as conn: + catalog_result = catalog.apply_migrations(conn, "catalog") + kinds = catalog.count_kinds(conn) + + paths.identity.parent.mkdir(parents=True, exist_ok=True) + with closing(catalog.connect(paths.identity, create=True)) as conn: + identity_result = catalog.apply_migrations(conn, "identity") + + # 出力にはデータの場所(パス)を出さない.件数と版だけを示す(SPEC_03「出力の規則」) + _print_migration("目録", catalog_result) + _print_migration("対応表", identity_result) + kinds_summary = "・".join(f"{category} {count} 件" for category, count in kinds.items()) + print(f"種類: {kinds_summary}") + return 0 + + +def _print_migration(label: str, result: catalog.MigrationResult) -> None: + if result.applied: + print(f"{label}: スキーマの版 {result.before} → {result.after}({result.applied} 件適用)") + else: + print(f"{label}: スキーマの版 {result.after}(最新.適用なし)") + + +def _format_validation_error(e: ValidationError) -> str: + # 入力値(パス等)を出さず,キーの位置と理由だけを 1 行にまとめる + details = "; ".join( + f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" + for error in e.errors(include_input=False, include_url=False) + ) + return f"設定ファイルの検証に失敗した({e.error_count()} 件): {details}" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="tiasdata", description="舌診研究の共有データ基盤") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + subparsers = parser.add_subparsers(dest="command", metavar="<コマンド>") + + init = subparsers.add_parser( + "init", + help="設定ファイルに従って目録・ストアを作り,マイグレーションを適用する", + description="設定ファイルに従って目録・ストア・対応表を作り,未適用のマイグレーションを適用する." + "既にある場合は不足分だけ適用する", + ) + init.set_defaults(handler=_cmd_init) + return parser + + +def _use_utf8_output() -> None: + # Windows ではパイプ・SSH 越しの出力が cp932 になり,手元の UTF-8 端末で化けるため + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8") def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="tiasdata", description="舌診研究の共有データ基盤") - parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - parser.parse_args(argv) - parser.print_help() - return 0 + """CLI を実行する. + + Args: + argv: 引数(省略時は `sys.argv[1:]`). + + Returns: + 終了コード(0: 成功,1: 入力・データの問題). + """ + _use_utf8_output() + parser = _build_parser() + args = parser.parse_args(argv) + handler = getattr(args, "handler", None) + if handler is None: + parser.print_help() + return 0 + try: + return handler(args) + except ValidationError as e: + # ValidationError は ValueError の派生だが,既定の表示は複数行で入力値を含むため + # 個別に整形する + print(f"エラー: {_format_validation_error(e)}", file=sys.stderr) + return 1 + except ValueError as e: + message = " ".join(str(e).splitlines()) + print(f"エラー: {message}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/src/tiasdata/config.py b/src/tiasdata/config.py new file mode 100644 index 0000000..eb38a1e --- /dev/null +++ b/src/tiasdata/config.py @@ -0,0 +1,174 @@ +"""設定ファイル(TOML)の読み込みと検証. + +データの場所(目録・ストア・取り込み元・計算サーバ)はコードに書かず, +研究室 PC のユーザー領域に置いた設定ファイルから受け取る(SPEC_01「設定ファイル」). +""" + +import os +import tomllib +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator, model_validator + +CONFIG_ENV_VAR = "TIASDATA_CONFIG" + +CATALOG_FILENAME = "catalog.sqlite" + + +def default_config_path() -> Path: + """設定ファイルの場所を返す. + + 環境変数 `TIASDATA_CONFIG` があればそのパス,無ければ `~/.config/tiasdata/config.toml` + (Windows でも同じ相対位置.`%USERPROFILE%\\.config\\tiasdata\\config.toml`). + + Returns: + 設定ファイルのパス(存在するかは確かめない). + """ + env = os.environ.get(CONFIG_ENV_VAR) + if env: + return Path(env).expanduser() + return Path.home() / ".config" / "tiasdata" / "config.toml" + + +def _resolve_path(value: Path, info: ValidationInfo) -> Path: + # 相対パスは設定ファイルのあるフォルダを基準にする(作業フォルダに依存させないため) + path = value.expanduser() + base_dir = info.context.get("base_dir") if info.context else None + if base_dir is not None and not path.is_absolute(): + path = Path(base_dir) / path + return path + + +class _StrictModel(BaseModel): + # 未知のキーは拒否する(綴りの誤りで設定が黙って無視されるのを防ぐ) + model_config = ConfigDict(extra="forbid", frozen=True) + + +class CatalogConfig(_StrictModel): + """`[catalog]`: 目録・ストア・対応表の場所. + + Attributes: + root: `catalog.sqlite`・`store/`・`bundles/`・`backups/` を置くフォルダ. + identity: 対応表(`identity.sqlite`)のパス.`root` とは別のフォルダに置く. + """ + + root: Path + identity: Path + + @field_validator("root", "identity") + @classmethod + def _resolve(cls, value: Path, info: ValidationInfo) -> Path: + return _resolve_path(value, info) + + @model_validator(mode="after") + def _identity_outside_root(self) -> "CatalogConfig": + # 対応表は目録とは別のフォルダに置く(SPEC_01「構成要素」). + # 目録のバックアップ・受け渡しに対応表が紛れ込まないようにするため + root = self.root.resolve() + identity_dir = self.identity.resolve().parent + if identity_dir == root or root in identity_dir.parents: + raise ValueError("catalog.identity は catalog.root の外のフォルダに置く") + return self + + @property + def catalog_path(self) -> Path: + """目録(`catalog.sqlite`)のパス.""" + return self.root / CATALOG_FILENAME + + @property + def store_dir(self) -> Path: + """ストアのフォルダ.""" + return self.root / "store" + + @property + def bundles_dir(self) -> Path: + """書き出したバンドルのフォルダ.""" + return self.root / "bundles" + + @property + def backups_dir(self) -> Path: + """目録のバックアップのフォルダ.""" + return self.root / "backups" + + +class SourceConfig(_StrictModel): + """`[sources.<名前>]`: 取り込み元. + + Attributes: + path: 取り込み元のフォルダ(読むだけ). + importer: 取り込み元の形(`importers/` のモジュール名.例: `tongue_color_folders`). + patient_map: 画像の番号 → 患者 ID の対応表のファイル(任意). + """ + + path: Path + importer: str + patient_map: Path | None = None + + @field_validator("path", "patient_map") + @classmethod + def _resolve(cls, value: Path | None, info: ValidationInfo) -> Path | None: + if value is None: + return None + return _resolve_path(value, info) + + +class ServerConfig(_StrictModel): + """`[servers.<名前>]`: 計算サーバ(受け渡し先). + + Attributes: + host: SSH の接続先(`user@host`). + key: SSH の秘密鍵のパス(研究室 PC 上). + root: サーバ上のバンドルの置き場所.サーバ側で解釈するため `~` は展開しない. + """ + + host: str + key: Path + root: str + + @field_validator("key") + @classmethod + def _resolve(cls, value: Path, info: ValidationInfo) -> Path: + return _resolve_path(value, info) + + +class Config(_StrictModel): + """設定ファイル全体. + + Attributes: + catalog: 目録・ストア・対応表の場所. + sources: 取り込み元(名前 → 設定). + servers: 計算サーバ(名前 → 設定). + """ + + catalog: CatalogConfig + sources: dict[str, SourceConfig] = {} + servers: dict[str, ServerConfig] = {} + + +def load_config(path: Path | None = None) -> Config: + """設定ファイルを読み込んで検証する. + + Args: + path: 設定ファイルのパス.省略時は `default_config_path()`. + + Returns: + 検証済みの設定.相対パスは設定ファイルのあるフォルダ基準の絶対パスに,`~` は展開済み. + + Raises: + ValueError: 設定ファイルが無い,または TOML として読めない. + pydantic.ValidationError: 未知のキー・必須キーの欠落・値の形の誤り. + """ + config_path = path if path is not None else default_config_path() + try: + raw = config_path.read_bytes() + except FileNotFoundError: + raise ValueError( + f"設定ファイルが見つからない: {config_path}" + f"(環境変数 {CONFIG_ENV_VAR} で場所を指定できる)" + ) from None + try: + data = tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: + raise ValueError(f"設定ファイルを TOML として読めない: {e}") from e + base_dir = config_path.expanduser().resolve().parent + return Config.model_validate(data, context={"base_dir": base_dir}) diff --git a/tests/catalog/test_connection.py b/tests/catalog/test_connection.py new file mode 100644 index 0000000..5f43b4c --- /dev/null +++ b/tests/catalog/test_connection.py @@ -0,0 +1,47 @@ +"""目録への接続(SPEC_02 冒頭: 外部キー制約を有効にする).""" + +from pathlib import Path + +import pytest + +from tiasdata import catalog + + +def test_connect_enables_foreign_keys(tmp_path: Path) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + finally: + conn.close() + + +def test_connect_enables_foreign_keys_on_every_connection(tmp_path: Path) -> None: + path = tmp_path / "db.sqlite" + catalog.connect(path, create=True).close() + conn = catalog.connect(path) + try: + assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + finally: + conn.close() + + +def test_connect_with_create_makes_db_file(tmp_path: Path) -> None: + path = tmp_path / "db.sqlite" + catalog.connect(path, create=True).close() + assert path.is_file() + + +def test_connect_without_create_refuses_missing_db(tmp_path: Path) -> None: + path = tmp_path / "missing.sqlite" + with pytest.raises(ValueError): + catalog.connect(path) + # 空の目録を誤って作らない + assert not path.exists() + + +def test_connect_missing_db_message_does_not_contain_path(tmp_path: Path) -> None: + path = tmp_path / "missing.sqlite" + with pytest.raises(ValueError) as exc: + catalog.connect(path) + assert str(tmp_path) not in str(exc.value) + assert tmp_path.as_posix() not in str(exc.value) diff --git a/tests/catalog/test_migrate.py b/tests/catalog/test_migrate.py new file mode 100644 index 0000000..dd95c5b --- /dev/null +++ b/tests/catalog/test_migrate.py @@ -0,0 +1,165 @@ +"""マイグレーションの適用(testing.md 必須対象 8).""" + +import re +import sqlite3 +from pathlib import Path + +import pytest + +from tiasdata import catalog +from tiasdata.catalog import migrate +from tiasdata.catalog.migrate import Migration, MigrationResult + + +def _tables(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ) + return {name for (name,) in rows} + + +def _schema(conn: sqlite3.Connection) -> list[tuple]: + return conn.execute("SELECT type, name, sql FROM sqlite_master ORDER BY type, name").fetchall() + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_apply_to_empty_db_reaches_latest_version(tmp_path: Path, series: str) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + result = catalog.apply_migrations(conn, series) + latest = catalog.latest_version(series) + assert latest >= 1 + assert result == MigrationResult(0, latest) + assert result.applied == latest + assert conn.execute("PRAGMA user_version").fetchone()[0] == latest + assert catalog.schema_version(conn) == latest + finally: + conn.close() + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_reapply_changes_nothing(tmp_path: Path, series: str) -> None: + path = tmp_path / "db.sqlite" + conn = catalog.connect(path, create=True) + catalog.apply_migrations(conn, series) + schema_before = _schema(conn) + conn.close() + + # 別の接続から再適用しても何も変わらない + conn = catalog.connect(path) + try: + result = catalog.apply_migrations(conn, series) + latest = catalog.latest_version(series) + assert result == MigrationResult(latest, latest) + assert result.applied == 0 + assert _schema(conn) == schema_before + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + finally: + conn.close() + + +def test_reapply_keeps_kinds_rows_and_data(catalog_conn: sqlite3.Connection) -> None: + catalog_conn.execute( + "INSERT INTO subjects (subject_id, created_at) VALUES ('S000001', '2026-10-06T00:00:00Z')" + ) + catalog_conn.commit() + catalog.apply_migrations(catalog_conn, "catalog") + assert catalog_conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5 + assert catalog_conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1 + + +def test_catalog_and_identity_are_separate_series(tmp_path: Path) -> None: + cat = catalog.connect(tmp_path / "catalog.sqlite", create=True) + ident = catalog.connect(tmp_path / "identity.sqlite", create=True) + try: + catalog.apply_migrations(cat, "catalog") + catalog.apply_migrations(ident, "identity") + # 目録に対応表の表は無く,対応表に目録の表は無い + assert "identity_map" not in _tables(cat) + assert _tables(ident) == {"identity_map"} + finally: + cat.close() + ident.close() + + +def test_db_newer_than_library_is_rejected(tmp_path: Path) -> None: + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + catalog.apply_migrations(conn, "catalog") + newer = catalog.latest_version("catalog") + 1 + conn.execute(f"PRAGMA user_version = {newer}") + with pytest.raises(ValueError): + catalog.apply_migrations(conn, "catalog") + # 版は書き換わらない + assert catalog.schema_version(conn) == newer + finally: + conn.close() + + +def test_failed_migration_leaves_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # 1 ファイル目は正常,2 ファイル目は途中で失敗するマイグレーション + good = Migration(1, "0001_good.sql", "CREATE TABLE a (x INTEGER);") + bad = Migration( + 2, + "0002_bad.sql", + "CREATE TABLE b (x INTEGER);\nINSERT INTO a VALUES (1);\nTHIS IS NOT SQL;", + ) + monkeypatch.setattr(migrate, "load_migrations", lambda series: [good, bad]) + + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + with pytest.raises(sqlite3.Error): + catalog.apply_migrations(conn, "catalog") + # 失敗したファイルの変更は残らず,前のファイルまでは適用済み + assert catalog.schema_version(conn) == 1 + assert _tables(conn) == {"a"} + assert conn.execute("SELECT COUNT(*) FROM a").fetchone()[0] == 0 + assert not conn.in_transaction + finally: + conn.close() + + # 別の接続から見ても同じ(確定した内容だけが残っている) + conn = catalog.connect(tmp_path / "db.sqlite") + try: + assert catalog.schema_version(conn) == 1 + assert _tables(conn) == {"a"} + finally: + conn.close() + + +def test_failed_first_migration_leaves_empty_db( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bad = Migration(1, "0001_bad.sql", "CREATE TABLE a (x INTEGER);\nTHIS IS NOT SQL;") + monkeypatch.setattr(migrate, "load_migrations", lambda series: [bad]) + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + with pytest.raises(sqlite3.Error): + catalog.apply_migrations(conn, "catalog") + assert catalog.schema_version(conn) == 0 + assert _tables(conn) == set() + finally: + conn.close() + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_bundled_migrations_are_numbered_from_one(series: str) -> None: + migrations = migrate.load_migrations(series) + assert [m.version for m in migrations] == list(range(1, len(migrations) + 1)) + assert catalog.latest_version(series) == len(migrations) + + +@pytest.mark.parametrize("series", ["catalog", "identity"]) +def test_bundled_migrations_do_not_manage_transaction_or_version(series: str) -> None: + # apply_migrations() が BEGIN・COMMIT・user_version を付けるため,ファイル側に書かない + for m in migrate.load_migrations(series): + code = "\n".join( + line for line in m.sql.splitlines() if not line.lstrip().startswith("--") + ).upper() + assert "USER_VERSION" not in code, m.name + # トリガ本体の BEGIN … END; は対象外(トランザクションの文だけを見る) + pattern = ( + r"^\s*(BEGIN(\s+(DEFERRED|IMMEDIATE|EXCLUSIVE))?(\s+TRANSACTION)?" + r"|COMMIT(\s+TRANSACTION)?|END\s+TRANSACTION|ROLLBACK)\s*;" + ) + assert re.search(pattern, code, re.M) is None, m.name diff --git a/tests/catalog/test_queries.py b/tests/catalog/test_queries.py new file mode 100644 index 0000000..4158592 --- /dev/null +++ b/tests/catalog/test_queries.py @@ -0,0 +1,10 @@ +"""目録の集計.""" + +import sqlite3 + +from tiasdata import catalog + + +def test_count_kinds_after_initial_migration(catalog_conn: sqlite3.Connection) -> None: + # SPEC_02「kinds」: 最初に用意する種類は asset 4 件・annotation 1 件 + assert catalog.count_kinds(catalog_conn) == {"annotation": 1, "asset": 4} diff --git a/tests/catalog/test_schema.py b/tests/catalog/test_schema.py new file mode 100644 index 0000000..2b5daf0 --- /dev/null +++ b/tests/catalog/test_schema.py @@ -0,0 +1,806 @@ +"""目録・対応表のスキーマ(SPEC_02 データモデル). + +マイグレーション適用後の DB に対して,表の構成と制約(外部キー・CHECK・トリガ)を確かめる. +""" + +import json +import sqlite3 + +import pytest + +TS = "2026-10-06T00:00:00Z" +SHA_A = "a" * 64 +SHA_B = "b" * 64 +SHA_M = "c" * 64 + +EXPECTED_COLUMNS = { + "kinds": {"kind", "category", "description", "value_schema_version"}, + "subjects": {"subject_id", "created_at", "note"}, + "captures": {"capture_id", "subject_id", "captured_on", "device", "note"}, + "assets": { + "sha256", + "kind", + "capture_id", + "ext", + "size_bytes", + "source", + "source_name", + "registered_at", + }, + "derivations": {"derivation_id", "tool", "tool_version", "mode", "performed_at", "note"}, + "derivation_inputs": {"derivation_id", "sha256", "role"}, + "derivation_outputs": {"derivation_id", "sha256", "role"}, + "annotations": { + "annotation_id", + "kind", + "capture_id", + "sha256", + "value", + "annotator", + "source", + "created_at", + "superseded_by", + }, + "datasets": {"name", "description", "created_at"}, + "dataset_versions": { + "name", + "version", + "definition", + "hub_commit", + "manifest_sha256", + "created_at", + "note", + }, + "dataset_members": {"name", "version", "sha256"}, + "dataset_member_annotations": {"name", "version", "sha256", "annotation_id"}, + "deliveries": { + "delivery_id", + "name", + "version", + "server", + "path", + "delivered_at", + "verified", + }, +} + +TONGUE_COLOR = json.dumps({"label": "red", "severity": "mild"}) + + +# --------------------------------------------------------------------------- +# ダミーの行を入れる補助 +# --------------------------------------------------------------------------- +def _subject(conn: sqlite3.Connection, subject_id: str = "S000001") -> None: + conn.execute("INSERT INTO subjects (subject_id, created_at) VALUES (?, ?)", (subject_id, TS)) + + +def _capture( + conn: sqlite3.Connection, capture_id: str = "C000001", subject_id: str = "S000001" +) -> None: + conn.execute( + "INSERT INTO captures (capture_id, subject_id) VALUES (?, ?)", (capture_id, subject_id) + ) + + +def _asset( + conn: sqlite3.Connection, + sha256: str = SHA_A, + kind: str = "tongue_crop_masked", + capture_id: str | None = "C000001", + ext: str = "png", + registered_at: str = TS, +) -> None: + conn.execute( + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source, source_name," + " registered_at) VALUES (?, ?, ?, ?, 10, 'dummy_source', 'dummy.png', ?)", + (sha256, kind, capture_id, ext, registered_at), + ) + + +def _annotation( + conn: sqlite3.Connection, + *, + capture_id: str | None = "C000001", + sha256: str | None = None, + kind: str = "tongue_color", + value: str = TONGUE_COLOR, + created_at: str = TS, +) -> int: + cur = conn.execute( + "INSERT INTO annotations (kind, capture_id, sha256, value, source, created_at)" + " VALUES (?, ?, ?, ?, 'dummy_source', ?)", + (kind, capture_id, sha256, value, created_at), + ) + return cur.lastrowid + + +def _base(conn: sqlite3.Connection) -> None: + """症例 1・撮影 1・ファイル 1 を入れる.""" + _subject(conn) + _capture(conn) + _asset(conn) + + +def _frozen_version(conn: sqlite3.Connection) -> int: + """データセットの版 1 を作り,ファイル 1 件と所見 1 件を構成に入れる.""" + _base(conn) + annotation_id = _annotation(conn) + conn.execute( + "INSERT INTO datasets (name, description, created_at) VALUES ('tongue_color', 'd', ?)", + (TS,), + ) + conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit, manifest_sha256," + " created_at) VALUES ('tongue_color', 1, 'name = \"tongue_color\"', 'abc123', ?, ?)", + (SHA_M, TS), + ) + conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('tongue_color', 1, ?)", + (SHA_A,), + ) + conn.execute( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES ('tongue_color', 1, ?, ?)", + (SHA_A, annotation_id), + ) + conn.commit() + return annotation_id + + +# --------------------------------------------------------------------------- +# 表の構成 +# --------------------------------------------------------------------------- +class TestTables: + def test_catalog_has_the_13_tables(self, catalog_conn: sqlite3.Connection) -> None: + rows = catalog_conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ) + assert {name for (name,) in rows} == set(EXPECTED_COLUMNS) + assert len(EXPECTED_COLUMNS) == 13 + + @pytest.mark.parametrize("table", sorted(EXPECTED_COLUMNS)) + def test_table_has_spec_columns(self, catalog_conn: sqlite3.Connection, table: str) -> None: + columns = {row[1] for row in catalog_conn.execute(f"PRAGMA table_info({table})")} + assert columns == EXPECTED_COLUMNS[table] + + def test_identity_map_columns(self, identity_conn: sqlite3.Connection) -> None: + columns = {row[1] for row in identity_conn.execute("PRAGMA table_info(identity_map)")} + assert columns == {"patient_key", "subject_id", "created_at"} + + def test_catalog_has_no_patient_key_column(self, catalog_conn: sqlite3.Connection) -> None: + # 実際の患者 ID は目録に入れない(対応表にだけ置く) + for table in EXPECTED_COLUMNS: + columns = {row[1] for row in catalog_conn.execute(f"PRAGMA table_info({table})")} + assert "patient_key" not in columns + + def test_dataset_versions_primary_key_is_name_and_version( + self, catalog_conn: sqlite3.Connection + ) -> None: + pk = { + row[1]: row[5] + for row in catalog_conn.execute("PRAGMA table_info(dataset_versions)") + if row[5] > 0 + } + assert set(pk) == {"name", "version"} + + +# --------------------------------------------------------------------------- +# kinds +# --------------------------------------------------------------------------- +class TestKinds: + def test_initial_kinds(self, catalog_conn: sqlite3.Connection) -> None: + rows = catalog_conn.execute( + "SELECT kind, category, value_schema_version FROM kinds" + ).fetchall() + assert {(k, c) for k, c, _ in rows} == { + ("tongue_raw", "asset"), + ("color_chart", "asset"), + ("tongue_crop_masked", "asset"), + ("tongue_mask", "asset"), + ("tongue_color", "annotation"), + } + + def test_only_annotation_kinds_have_value_schema_version( + self, catalog_conn: sqlite3.Connection + ) -> None: + for _kind, category, version in catalog_conn.execute( + "SELECT kind, category, value_schema_version FROM kinds" + ): + if category == "annotation": + assert version is not None and version >= 1 + else: + assert version is None + + def test_unknown_category_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES ('x', 'other', 'd')" + ) + + def test_asset_kind_with_value_schema_version_is_rejected( + self, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description, value_schema_version)" + " VALUES ('x', 'asset', 'd', 1)" + ) + + def test_annotation_kind_without_value_schema_version_is_rejected( + self, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES ('x', 'annotation', 'd')" + ) + + @pytest.mark.parametrize("kind", ["Tongue", "1abc", "tongue-color", "_x", ""]) + def test_invalid_kind_name_is_rejected( + self, catalog_conn: sqlite3.Connection, kind: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES (?, 'asset', 'd')", + (kind,), + ) + + def test_new_kind_can_be_added_as_row(self, catalog_conn: sqlite3.Connection) -> None: + # 拡張は行の追加で行う(SPEC_02「拡張の例」) + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description, value_schema_version)" + " VALUES ('coating_color', 'annotation', 'd', 1)" + ) + catalog_conn.execute( + "INSERT INTO kinds (kind, category, description) VALUES ('analysis_csv', 'asset', 'd')" + ) + + +# --------------------------------------------------------------------------- +# ID・値の書式 +# --------------------------------------------------------------------------- +class TestFormats: + def test_valid_rows_are_accepted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn) + catalog_conn.execute( + "UPDATE captures SET captured_on = '2026-10-01', device = 'TIAS'" + " WHERE capture_id = 'C000001'" + ) + + @pytest.mark.parametrize("subject_id", ["S12345", "S0000001", "s000001", "X000001", "S00000a"]) + def test_invalid_subject_id_is_rejected( + self, catalog_conn: sqlite3.Connection, subject_id: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + _subject(catalog_conn, subject_id) + + @pytest.mark.parametrize("capture_id", ["C12345", "C0000001", "c000001", "S000001"]) + def test_invalid_capture_id_is_rejected( + self, catalog_conn: sqlite3.Connection, capture_id: str + ) -> None: + _subject(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _capture(catalog_conn, capture_id) + + @pytest.mark.parametrize( + "sha256", ["A" * 64, "a" * 63, "a" * 65, "g" * 64, ""], ids=["upper", "63", "65", "g", "e"] + ) + def test_invalid_sha256_is_rejected( + self, catalog_conn: sqlite3.Connection, sha256: str + ) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, sha256=sha256) + + @pytest.mark.parametrize("ext", ["PNG", ".png", "", "p g"]) + def test_invalid_ext_is_rejected(self, catalog_conn: sqlite3.Connection, ext: str) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, ext=ext) + + @pytest.mark.parametrize( + "created_at", + [ + "2026-10-06 00:00:00", + "2026-10-06T00:00:00", + "2026-10-06T00:00:00+09:00", + "2026-10-06T00:00:00.123Z", + "2026-10-06", + ], + ) + def test_invalid_datetime_is_rejected( + self, catalog_conn: sqlite3.Connection, created_at: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO subjects (subject_id, created_at) VALUES ('S000001', ?)", + (created_at,), + ) + + @pytest.mark.parametrize("captured_on", ["2026/10/01", "2026-10-01T00:00:00Z", "20261001"]) + def test_invalid_captured_on_is_rejected( + self, catalog_conn: sqlite3.Connection, captured_on: str + ) -> None: + _subject(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO captures (capture_id, subject_id, captured_on)" + " VALUES ('C000001', 'S000001', ?)", + (captured_on,), + ) + + def test_captured_on_may_be_null(self, catalog_conn: sqlite3.Connection) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + row = catalog_conn.execute("SELECT captured_on FROM captures").fetchone() + assert row[0] is None + + @pytest.mark.parametrize("name", ["Tongue", "1tongue", "tongue-color", "_tongue", ""]) + def test_invalid_dataset_name_is_rejected( + self, catalog_conn: sqlite3.Connection, name: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO datasets (name, created_at) VALUES (?, ?)", (name, TS) + ) + + def test_dataset_version_starts_from_one(self, catalog_conn: sqlite3.Connection) -> None: + catalog_conn.execute("INSERT INTO datasets (name, created_at) VALUES ('ds', ?)", (TS,)) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit," + " manifest_sha256, created_at) VALUES ('ds', 0, '', 'c', ?, ?)", + (SHA_M, TS), + ) + + @pytest.mark.parametrize("verified", [2, -1]) + def test_delivery_verified_must_be_boolean( + self, catalog_conn: sqlite3.Connection, verified: int + ) -> None: + _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO deliveries (name, version, server, path, delivered_at, verified)" + " VALUES ('tongue_color', 1, 'newton', '~/data/tiasdata/tongue_color/v1', ?, ?)", + (TS, verified), + ) + + def test_delivery_is_recorded(self, catalog_conn: sqlite3.Connection) -> None: + _frozen_version(catalog_conn) + catalog_conn.execute( + "INSERT INTO deliveries (name, version, server, path, delivered_at, verified)" + " VALUES ('tongue_color', 1, 'newton', '~/data/tiasdata/tongue_color/v1', ?, 1)", + (TS,), + ) + + +# --------------------------------------------------------------------------- +# ID・名前の列は NULL を受け入れない +# (SQLite では INTEGER 以外の PRIMARY KEY が NULL を受け入れるため,NOT NULL が要る) +# --------------------------------------------------------------------------- +class TestPrimaryKeyNotNull: + @pytest.mark.parametrize( + ("needs_subject", "sql", "params"), + [ + ( + False, + "INSERT INTO kinds (kind, category, description) VALUES (?, 'asset', 'd')", + (None,), + ), + (False, "INSERT INTO subjects (subject_id, created_at) VALUES (?, ?)", (None, TS)), + ( + True, + "INSERT INTO captures (capture_id, subject_id) VALUES (?, 'S000001')", + (None,), + ), + ( + False, + "INSERT INTO assets (sha256, kind, capture_id, ext, size_bytes, source," + " source_name, registered_at) VALUES (?, 'color_chart', NULL, 'png', 10," + " 'dummy_source', 'dummy.png', ?)", + (None, TS), + ), + (False, "INSERT INTO datasets (name, created_at) VALUES (?, ?)", (None, TS)), + ], + ids=[ + "kinds.kind", + "subjects.subject_id", + "captures.capture_id", + "assets.sha256", + "datasets.name", + ], + ) + def test_null_key_is_rejected( + self, + catalog_conn: sqlite3.Connection, + needs_subject: bool, + sql: str, + params: tuple, + ) -> None: + if needs_subject: + _subject(catalog_conn) + table = sql.split()[2] + before = catalog_conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): + catalog_conn.execute(sql, params) + assert catalog_conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == before + + def test_null_patient_key_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): + identity_conn.execute( + "INSERT INTO identity_map (patient_key, subject_id, created_at) VALUES (?, ?, ?)", + (None, "S000001", TS), + ) + assert identity_conn.execute("SELECT COUNT(*) FROM identity_map").fetchone()[0] == 0 + + +# --------------------------------------------------------------------------- +# 外部キー +# --------------------------------------------------------------------------- +class TestForeignKeys: + def test_capture_requires_existing_subject(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _capture(catalog_conn, subject_id="S999999") + + def test_asset_requires_existing_capture(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, capture_id="C999999") + + def test_asset_without_capture_is_allowed(self, catalog_conn: sqlite3.Connection) -> None: + _asset(catalog_conn, capture_id=None, kind="color_chart") + + def test_asset_requires_existing_kind(self, catalog_conn: sqlite3.Connection) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, kind="no_such_kind") + + def test_annotation_requires_existing_capture(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id="C999999") + + def test_annotation_requires_existing_asset(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id=None, sha256=SHA_B) + + def test_derivation_links_require_existing_rows(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + cur = catalog_conn.execute( + "INSERT INTO derivations (tool, tool_version, mode) VALUES" + " ('DeepTIAS', 'unknown', 'Image&Calib')" + ) + derivation_id = cur.lastrowid + catalog_conn.execute( + "INSERT INTO derivation_outputs (derivation_id, sha256, role) VALUES (?, ?, 'cropped')", + (derivation_id, SHA_A), + ) + for table in ("derivation_inputs", "derivation_outputs"): + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + f"INSERT INTO {table} (derivation_id, sha256, role) VALUES (?, ?, 'x')", + (derivation_id, SHA_B), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + f"INSERT INTO {table} (derivation_id, sha256, role) VALUES (9999, ?, 'x')", + (SHA_A,), + ) + + def test_dataset_version_requires_existing_dataset( + self, catalog_conn: sqlite3.Connection + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit," + " manifest_sha256, created_at) VALUES ('nope', 1, '', 'c', ?, ?)", + (SHA_M, TS), + ) + + def test_dataset_member_requires_existing_version_and_asset( + self, catalog_conn: sqlite3.Connection + ) -> None: + _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('tongue_color', 2, ?)", + (SHA_A,), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_members (name, version, sha256) VALUES ('tongue_color', 1, ?)", + (SHA_B,), + ) + + def test_member_annotation_requires_member_and_annotation( + self, catalog_conn: sqlite3.Connection + ) -> None: + annotation_id = _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES ('tongue_color', 1, ?, ?)", + (SHA_B, annotation_id), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_member_annotations (name, version, sha256, annotation_id)" + " VALUES ('tongue_color', 1, ?, 9999)", + (SHA_A,), + ) + + def test_delivery_requires_existing_version(self, catalog_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO deliveries (name, version, server, path, delivered_at, verified)" + " VALUES ('nope', 1, 'newton', 'p', ?, 0)", + (TS,), + ) + + def test_subject_with_captures_cannot_be_deleted( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute("DELETE FROM subjects WHERE subject_id = 'S000001'") + + +# --------------------------------------------------------------------------- +# 種類の分類の整合 +# --------------------------------------------------------------------------- +class TestKindCategory: + def test_asset_with_annotation_kind_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _subject(catalog_conn) + _capture(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _asset(catalog_conn, kind="tongue_color") + + def test_asset_kind_cannot_be_changed_to_annotation_kind( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute("UPDATE assets SET kind = 'tongue_color'") + + def test_annotation_with_asset_kind_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, kind="tongue_crop_masked") + + +# --------------------------------------------------------------------------- +# 所見(撮影かファイルのどちらか一方・書き換えない・訂正は superseded_by) +# --------------------------------------------------------------------------- +class TestAnnotations: + def test_annotation_on_capture_is_accepted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn, capture_id="C000001") + + def test_annotation_on_asset_is_accepted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn, capture_id=None, sha256=SHA_A) + + def test_annotation_on_both_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id="C000001", sha256=SHA_A) + + def test_annotation_on_neither_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, capture_id=None, sha256=None) + + def test_annotation_value_must_be_json(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, value="{label: red") + + def test_annotation_created_at_format(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + _annotation(catalog_conn, created_at="2026-10-06 00:00:00") + + @pytest.mark.parametrize( + "assignment", + [ + 'value = \'{"label": "pale", "severity": "mild"}\'', + "annotator = 'A'", + "source = 'other'", + "created_at = '2026-10-07T00:00:00Z'", + "kind = 'tongue_color'", + "capture_id = NULL, sha256 = '" + SHA_A + "'", + ], + ids=["value", "annotator", "source", "created_at", "same_kind", "target"], + ) + def test_annotation_cannot_be_updated( + self, catalog_conn: sqlite3.Connection, assignment: str + ) -> None: + _base(catalog_conn) + annotation_id = _annotation(catalog_conn) + catalog_conn.commit() + before = catalog_conn.execute("SELECT * FROM annotations").fetchall() + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + f"UPDATE annotations SET {assignment} WHERE annotation_id = ?", (annotation_id,) + ) + assert catalog_conn.execute("SELECT * FROM annotations").fetchall() == before + + def test_annotation_cannot_be_deleted(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute("DELETE FROM annotations") + assert catalog_conn.execute("SELECT COUNT(*) FROM annotations").fetchone()[0] == 1 + + def test_correction_sets_superseded_by_once(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + old_id = _annotation(catalog_conn) + new_value = json.dumps({"label": "pale", "severity": "strong"}) + new_id = _annotation(catalog_conn, value=new_value) + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", (new_id, old_id) + ) + old = catalog_conn.execute( + "SELECT value, superseded_by FROM annotations WHERE annotation_id = ?", (old_id,) + ).fetchone() + # 古い行は値を保ったまま残る + assert old == (TONGUE_COLOR, new_id) + + # 2 度目(別の行への付け替え・NULL に戻す)は拒否する + third_id = _annotation(catalog_conn, value=new_value) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (third_id, old_id), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = NULL WHERE annotation_id = ?", (old_id,) + ) + + def test_superseded_by_must_point_to_later_row(self, catalog_conn: sqlite3.Connection) -> None: + _base(catalog_conn) + first = _annotation(catalog_conn) + second = _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (first, second), + ) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (second, second), + ) + + def test_superseded_by_must_point_to_existing_row( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + annotation_id = _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = 9999 WHERE annotation_id = ?", + (annotation_id,), + ) + + def test_correction_and_value_change_together_is_rejected( + self, catalog_conn: sqlite3.Connection + ) -> None: + _base(catalog_conn) + old_id = _annotation(catalog_conn) + new_id = _annotation(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ?, value = '{}' WHERE annotation_id = ?", + (new_id, old_id), + ) + + +# --------------------------------------------------------------------------- +# 凍結した版 +# --------------------------------------------------------------------------- +class TestFrozenVersions: + @pytest.mark.parametrize( + "statement", + [ + "UPDATE dataset_versions SET note = 'changed'", + "UPDATE dataset_versions SET manifest_sha256 = '" + "d" * 64 + "'", + "DELETE FROM dataset_versions", + "UPDATE dataset_members SET sha256 = '" + SHA_B + "'", + "DELETE FROM dataset_members", + "UPDATE dataset_member_annotations SET annotation_id = annotation_id", + "DELETE FROM dataset_member_annotations", + ], + ) + def test_frozen_rows_cannot_be_changed( + self, catalog_conn: sqlite3.Connection, statement: str + ) -> None: + _frozen_version(catalog_conn) + tables = ("dataset_versions", "dataset_members", "dataset_member_annotations") + before = {t: catalog_conn.execute(f"SELECT * FROM {t}").fetchall() for t in tables} + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute(statement) + after = {t: catalog_conn.execute(f"SELECT * FROM {t}").fetchall() for t in tables} + assert after == before + + def test_new_version_can_be_added(self, catalog_conn: sqlite3.Connection) -> None: + _frozen_version(catalog_conn) + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit, manifest_sha256," + " created_at) VALUES ('tongue_color', 2, 'def', 'abc124', ?, ?)", + (SHA_M, TS), + ) + + def test_duplicate_version_is_rejected(self, catalog_conn: sqlite3.Connection) -> None: + _frozen_version(catalog_conn) + with pytest.raises(sqlite3.IntegrityError): + catalog_conn.execute( + "INSERT INTO dataset_versions (name, version, definition, hub_commit," + " manifest_sha256, created_at) VALUES ('tongue_color', 1, 'x', 'y', ?, ?)", + (SHA_M, TS), + ) + + def test_annotation_used_by_version_survives_correction( + self, catalog_conn: sqlite3.Connection + ) -> None: + # 訂正しても過去の版が古い行(古い値)を参照し続けられる + old_id = _frozen_version(catalog_conn) + new_id = _annotation( + catalog_conn, value=json.dumps({"label": "pale", "severity": "strong"}) + ) + catalog_conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", (new_id, old_id) + ) + row = catalog_conn.execute( + "SELECT a.value FROM dataset_member_annotations m" + " JOIN annotations a ON a.annotation_id = m.annotation_id" + " WHERE m.name = 'tongue_color' AND m.version = 1" + ).fetchone() + assert row == (TONGUE_COLOR,) + + +# --------------------------------------------------------------------------- +# 対応表 +# --------------------------------------------------------------------------- +class TestIdentityMap: + def _insert(self, conn: sqlite3.Connection, patient_key: str, subject_id: str) -> None: + conn.execute( + "INSERT INTO identity_map (patient_key, subject_id, created_at) VALUES (?, ?, ?)", + (patient_key, subject_id, TS), + ) + + def test_mapping_is_accepted(self, identity_conn: sqlite3.Connection) -> None: + self._insert(identity_conn, "DUMMY-P1", "S000001") + self._insert(identity_conn, "DUMMY-P2", "S000002") + + def test_same_patient_twice_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + self._insert(identity_conn, "DUMMY-P1", "S000001") + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "DUMMY-P1", "S000002") + + def test_subject_shared_by_two_patients_is_rejected( + self, identity_conn: sqlite3.Connection + ) -> None: + self._insert(identity_conn, "DUMMY-P1", "S000001") + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "DUMMY-P2", "S000001") + + @pytest.mark.parametrize("subject_id", ["S1", "C000001", "s000001"]) + def test_invalid_subject_id_is_rejected( + self, identity_conn: sqlite3.Connection, subject_id: str + ) -> None: + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "DUMMY-P1", subject_id) + + def test_empty_patient_key_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + self._insert(identity_conn, "", "S000001") + + def test_invalid_created_at_is_rejected(self, identity_conn: sqlite3.Connection) -> None: + with pytest.raises(sqlite3.IntegrityError): + identity_conn.execute( + "INSERT INTO identity_map (patient_key, subject_id, created_at)" + " VALUES ('DUMMY-P1', 'S000001', '2026-10-06')" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cb3298a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +"""テスト共通の fixture(ダミーデータのみ.臨床データは使わない).""" + +import sqlite3 +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from tiasdata import catalog + + +@pytest.fixture(autouse=True) +def _isolate_config_env(monkeypatch: pytest.MonkeyPatch) -> None: + # 開発者の環境変数が混ざらないようにする(テストごとに必要なら設定する) + monkeypatch.delenv("TIASDATA_CONFIG", raising=False) + + +@pytest.fixture +def catalog_conn(tmp_path: Path) -> Iterator[sqlite3.Connection]: + """マイグレーション適用済みの目録(tmp_path に作る).""" + conn = catalog.connect(tmp_path / "catalog.sqlite", create=True) + catalog.apply_migrations(conn, "catalog") + try: + yield conn + finally: + conn.close() + + +@pytest.fixture +def identity_conn(tmp_path: Path) -> Iterator[sqlite3.Connection]: + """マイグレーション適用済みの対応表(tmp_path に作る).""" + conn = catalog.connect(tmp_path / "identity.sqlite", create=True) + catalog.apply_migrations(conn, "identity") + try: + yield conn + finally: + conn.close() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8b09d88 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,265 @@ +"""CLI(SPEC_03 の `tiasdata init`・「出力の規則」,error-handling.md の終了コード).""" + +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest + +from tiasdata import catalog +from tiasdata.cli import main + + +def _write_config(tmp_path: Path, extra: str = "") -> Path: + # 相対パスで書き,設定ファイルのあるフォルダ基準で解決させる + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + '[catalog]\nroot = "../data/tiasdata"\nidentity = "../data/identity/identity.sqlite"\n' + + extra, + encoding="utf-8", + ) + return config_path + + +def _paths(tmp_path: Path) -> dict[str, Path]: + root = tmp_path / "data" / "tiasdata" + return { + "root": root, + "catalog": root / "catalog.sqlite", + "store": root / "store", + "bundles": root / "bundles", + "backups": root / "backups", + "identity": tmp_path / "data" / "identity" / "identity.sqlite", + } + + +def _assert_no_paths(text: str, tmp_path: Path) -> None: + # 出力にデータ・設定ファイルの場所を出さない(SPEC_03「出力の規則」) + for form in (str(tmp_path), tmp_path.as_posix(), str(tmp_path.resolve())): + assert form not in text + assert "catalog.sqlite" not in text + assert "identity.sqlite" not in text + assert "config.toml" not in text + + +@pytest.fixture +def config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + config_path = _write_config(tmp_path) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + return config_path + + +# --------------------------------------------------------------------------- +# init +# --------------------------------------------------------------------------- +class TestInit: + def test_init_creates_catalog_store_and_identity( + self, tmp_path: Path, config_env: Path + ) -> None: + assert main(["init"]) == 0 + p = _paths(tmp_path) + assert p["catalog"].is_file() + assert p["store"].is_dir() + assert p["bundles"].is_dir() + assert p["backups"].is_dir() + assert p["identity"].is_file() + + def test_init_applies_latest_migrations(self, tmp_path: Path, config_env: Path) -> None: + assert main(["init"]) == 0 + p = _paths(tmp_path) + with sqlite3.connect(p["catalog"]) as conn: + assert catalog.schema_version(conn) == catalog.latest_version("catalog") + assert conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5 + with sqlite3.connect(p["identity"]) as conn: + assert catalog.schema_version(conn) == catalog.latest_version("identity") + + def test_init_twice_gives_same_result( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + p = _paths(tmp_path) + assert main(["init"]) == 0 + capsys.readouterr() + with sqlite3.connect(p["catalog"]) as conn: + schema = conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall() + kinds = conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall() + # 何度実行しても同じ(既存の行を消さない) + with sqlite3.connect(p["catalog"]) as conn: + conn.execute( + "INSERT INTO subjects (subject_id, created_at)" + " VALUES ('S000001', '2026-10-06T00:00:00Z')" + ) + + assert main(["init"]) == 0 + out = capsys.readouterr().out + with sqlite3.connect(p["catalog"]) as conn: + assert conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall() == schema + assert conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall() == kinds + assert conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1 + assert catalog.schema_version(conn) == catalog.latest_version("catalog") + assert "種類" in out + + def test_init_reports_versions_and_kind_counts( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + out = capsys.readouterr().out + latest = catalog.latest_version("catalog") + assert f"{latest}" in out + assert "asset 4" in out + assert "annotation 1" in out + + def test_init_output_contains_no_paths( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["init"]) == 0 + captured = capsys.readouterr() + _assert_no_paths(captured.out + captured.err, tmp_path) + + def test_init_rejects_catalog_newer_than_library( + self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + p = _paths(tmp_path) + newer = catalog.latest_version("catalog") + 1 + with sqlite3.connect(p["catalog"]) as conn: + conn.execute(f"PRAGMA user_version = {newer}") + capsys.readouterr() + + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + _assert_no_paths(captured.err, tmp_path) + + +# --------------------------------------------------------------------------- +# 入力の問題(終了コード 1・標準エラーに 1 行) +# --------------------------------------------------------------------------- +class TestInputErrors: + def test_missing_config_exits_1_with_one_line( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + monkeypatch.setenv("TIASDATA_CONFIG", str(tmp_path / "missing.toml")) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + # 何も作らない + assert not (tmp_path / "data").exists() + + def test_broken_toml_exits_1_with_one_line( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text("[catalog\nroot = ", encoding="utf-8") + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + + def test_unknown_key_exits_1_without_input_values( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + secret = "DUMMY-SECRET-VALUE-12345" + config_path = _write_config(tmp_path, f'stroe = "{secret}"\n') + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + # キーの位置は出すが,入力値・パスは出さない + assert "stroe" in captured.err + assert secret not in captured.err + _assert_no_paths(captured.err, tmp_path) + assert not (tmp_path / "data").exists() + + def test_missing_required_key_exits_1( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir() + config_path.write_text('[catalog]\nroot = "../data/tiasdata"\n', encoding="utf-8") + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + assert "identity" in captured.err + _assert_no_paths(captured.err, tmp_path) + + def test_multiple_validation_errors_fit_in_one_line( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + config_path = tmp_path / "conf" / "config.toml" + config_path.parent.mkdir() + config_path.write_text( + 'x = 1\n[catalog]\nfoo = "a"\n[servers.s]\nhost = "h"\n', encoding="utf-8" + ) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert main(["init"]) == 1 + assert len(capsys.readouterr().err.strip().splitlines()) == 1 + + +# --------------------------------------------------------------------------- +# 引数 +# --------------------------------------------------------------------------- +class TestArguments: + def test_unknown_command_exits_2(self) -> None: + with pytest.raises(SystemExit) as exc: + main(["no-such-command"]) + assert exc.value.code == 2 + + def test_unknown_option_exits_2(self, config_env: Path) -> None: + with pytest.raises(SystemExit) as exc: + main(["init", "--no-such-option"]) + assert exc.value.code == 2 + + def test_no_command_prints_help_and_exits_0(self, capsys: pytest.CaptureFixture[str]) -> None: + assert main([]) == 0 + out = capsys.readouterr().out + assert "init" in out + + +# --------------------------------------------------------------------------- +# 別プロセスでの実行(出力の文字コード) +# --------------------------------------------------------------------------- +def _run_cli(args: list[str], config_path: Path, cwd: Path) -> subprocess.CompletedProcess[bytes]: + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")} + env["TIASDATA_CONFIG"] = str(config_path) + # パイプにつないだときの既定の文字コード(Windows では cp932)に任せる + return subprocess.run( + [sys.executable, "-m", "tiasdata.cli", *args], + capture_output=True, + env=env, + cwd=cwd, + check=False, + timeout=60, + ) + + +class TestSubprocess: + def test_init_stdout_is_utf8(self, tmp_path: Path) -> None: + config_path = _write_config(tmp_path) + result = _run_cli(["init"], config_path, tmp_path) + assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace") + out = result.stdout.decode("utf-8") # UTF-8 でなければ例外 + assert "種類" in out + _assert_no_paths(out, tmp_path) + + def test_init_twice_in_separate_processes(self, tmp_path: Path) -> None: + config_path = _write_config(tmp_path) + assert _run_cli(["init"], config_path, tmp_path).returncode == 0 + second = _run_cli(["init"], config_path, tmp_path) + assert second.returncode == 0 + assert second.stderr == b"" + + def test_error_stderr_is_utf8_one_line(self, tmp_path: Path) -> None: + config_path = _write_config(tmp_path, 'stroe = "x"\n') + result = _run_cli(["init"], config_path, tmp_path) + assert result.returncode == 1 + err = result.stderr.decode("utf-8") # UTF-8 でなければ例外 + assert len(err.strip().splitlines()) == 1 + assert "Traceback" not in err diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..861b4ae --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,238 @@ +"""設定ファイルの読み込み・検証(SPEC_01「設定ファイル」,testing.md 必須対象 7).""" + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from tiasdata.config import default_config_path, load_config + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _minimal(tmp_path: Path, extra: str = "") -> str: + root = (tmp_path / "data" / "tiasdata").as_posix() + identity = (tmp_path / "data" / "identity" / "identity.sqlite").as_posix() + return f'[catalog]\nroot = "{root}"\nidentity = "{identity}"\n{extra}' + + +FULL_EXTRA = """ +[sources.tiasjudgemodel] +path = "src_a" +importer = "tongue_color_folders" +patient_map = "map.xlsx" + +[servers.newton] +host = "user@example.invalid" +key = "keys/id_ed25519" +root = "~/data/tiasdata" +""" + + +class TestLoadValid: + def test_loads_minimal_config(self, tmp_path: Path) -> None: + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path))) + assert config.catalog.root == tmp_path / "data" / "tiasdata" + assert config.catalog.identity == tmp_path / "data" / "identity" / "identity.sqlite" + assert config.sources == {} + assert config.servers == {} + + def test_loads_sources_and_servers_by_name(self, tmp_path: Path) -> None: + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, FULL_EXTRA))) + assert set(config.sources) == {"tiasjudgemodel"} + assert config.sources["tiasjudgemodel"].importer == "tongue_color_folders" + assert set(config.servers) == {"newton"} + assert config.servers["newton"].host == "user@example.invalid" + + def test_patient_map_is_optional(self, tmp_path: Path) -> None: + extra = '[sources.a]\npath = "x"\nimporter = "tongue_color_folders"\n' + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, extra))) + assert config.sources["a"].patient_map is None + + def test_catalog_layout_under_root(self, tmp_path: Path) -> None: + catalog = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path))).catalog + assert catalog.catalog_path == catalog.root / "catalog.sqlite" + assert catalog.store_dir == catalog.root / "store" + assert catalog.bundles_dir == catalog.root / "bundles" + assert catalog.backups_dir == catalog.root / "backups" + + +class TestPathResolution: + def test_relative_paths_are_relative_to_config_folder( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + conf_dir = tmp_path / "conf" + text = '[catalog]\nroot = "data/tiasdata"\nidentity = "identity/identity.sqlite"\n' + config_path = _write(conf_dir / "config.toml", text + FULL_EXTRA) + # 作業フォルダに依存しないこと + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + config = load_config(config_path) + + base = conf_dir.resolve() + assert config.catalog.root == base / "data" / "tiasdata" + assert config.catalog.identity == base / "identity" / "identity.sqlite" + assert config.sources["tiasjudgemodel"].path == base / "src_a" + assert config.sources["tiasjudgemodel"].patient_map == base / "map.xlsx" + assert config.servers["newton"].key == base / "keys" / "id_ed25519" + + def test_tilde_is_expanded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + text = ( + '[catalog]\nroot = "~/tiasdata"\nidentity = "~/identity/identity.sqlite"\n' + '[sources.a]\npath = "~/src"\nimporter = "tongue_color_folders"\n' + 'patient_map = "~/map.xlsx"\n' + '[servers.s]\nhost = "u@h"\nkey = "~/.ssh/key"\nroot = "~/data/tiasdata"\n' + ) + config = load_config(_write(tmp_path / "conf" / "config.toml", text)) + assert config.catalog.root == home / "tiasdata" + assert config.catalog.identity == home / "identity" / "identity.sqlite" + assert config.sources["a"].path == home / "src" + assert config.sources["a"].patient_map == home / "map.xlsx" + assert config.servers["s"].key == home / ".ssh" / "key" + + def test_server_root_is_not_expanded(self, tmp_path: Path) -> None: + # サーバ上のパスはサーバ側で解釈するため,手元で展開・解決しない + config = load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, FULL_EXTRA))) + assert config.servers["newton"].root == "~/data/tiasdata" + + def test_resolved_paths_are_absolute(self, tmp_path: Path) -> None: + text = '[catalog]\nroot = "r"\nidentity = "i/identity.sqlite"\n' + config = load_config(_write(tmp_path / "config.toml", text)) + assert config.catalog.root.is_absolute() + assert config.catalog.identity.is_absolute() + + +class TestValidationErrors: + @pytest.mark.parametrize( + "extra", + [ + "unknown_top = 1\n", + "[unknown_section]\nx = 1\n", + '[sources.a]\npath = "x"\nimporter = "i"\ntypo = "y"\n', + '[servers.s]\nhost = "h"\nkey = "k"\nroot = "r"\nport = 22\n', + ], + ids=["top_level", "section", "source", "server"], + ) + def test_unknown_key_is_rejected(self, tmp_path: Path, extra: str) -> None: + # 未知のキーはトップレベルに置くと [catalog] の中に入ってしまうので, + # トップレベル用は [catalog] より前に書く + if extra.startswith("unknown_top"): + text = extra + _minimal(tmp_path) + else: + text = _minimal(tmp_path, extra) + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + def test_unknown_key_in_catalog_is_rejected(self, tmp_path: Path) -> None: + text = _minimal(tmp_path) + 'stroe = "typo"\n' + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + def test_missing_catalog_section_is_rejected(self, tmp_path: Path) -> None: + text = '[sources.a]\npath = "x"\nimporter = "i"\n' + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + @pytest.mark.parametrize("missing", ["root", "identity"]) + def test_missing_catalog_key_is_rejected(self, tmp_path: Path, missing: str) -> None: + values = {"root": '"r"', "identity": '"i/identity.sqlite"'} + lines = [f"{k} = {v}" for k, v in values.items() if k != missing] + text = "[catalog]\n" + "\n".join(lines) + "\n" + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + @pytest.mark.parametrize("missing", ["path", "importer"]) + def test_missing_source_key_is_rejected(self, tmp_path: Path, missing: str) -> None: + values = {"path": '"x"', "importer": '"tongue_color_folders"'} + lines = [f"{k} = {v}" for k, v in values.items() if k != missing] + extra = "[sources.a]\n" + "\n".join(lines) + "\n" + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, extra))) + + @pytest.mark.parametrize("missing", ["host", "key", "root"]) + def test_missing_server_key_is_rejected(self, tmp_path: Path, missing: str) -> None: + values = {"host": '"u@h"', "key": '"k"', "root": '"~/data"'} + lines = [f"{k} = {v}" for k, v in values.items() if k != missing] + extra = "[servers.s]\n" + "\n".join(lines) + "\n" + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", _minimal(tmp_path, extra))) + + @pytest.mark.parametrize( + "identity", + ["data/tiasdata/identity.sqlite", "data/tiasdata/sub/identity.sqlite"], + ids=["directly_in_root", "under_root"], + ) + def test_identity_inside_catalog_root_is_rejected(self, tmp_path: Path, identity: str) -> None: + # 対応表は目録とは別のフォルダに置く(SPEC_01「構成要素」) + text = f'[catalog]\nroot = "data/tiasdata"\nidentity = "{identity}"\n' + with pytest.raises(ValidationError): + load_config(_write(tmp_path / "config.toml", text)) + + +class TestFileErrors: + def test_missing_file_raises_value_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="設定ファイル"): + load_config(tmp_path / "nope.toml") + + def test_missing_file_is_not_validation_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError) as exc: + load_config(tmp_path / "nope.toml") + assert not isinstance(exc.value, ValidationError) + + def test_broken_toml_raises_value_error(self, tmp_path: Path) -> None: + path = _write(tmp_path / "config.toml", "[catalog\nroot = \n") + with pytest.raises(ValueError) as exc: + load_config(path) + assert not isinstance(exc.value, ValidationError) + + def test_non_utf8_file_raises_value_error(self, tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_bytes('[catalog]\nroot = "日本語"\n'.encode("cp932")) + with pytest.raises(ValueError): + load_config(path) + + +class TestLocation: + def test_env_var_takes_precedence( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config_path = _write(tmp_path / "custom" / "my.toml", _minimal(tmp_path)) + monkeypatch.setenv("TIASDATA_CONFIG", str(config_path)) + assert default_config_path() == config_path + assert load_config().catalog.root == tmp_path / "data" / "tiasdata" + + def test_default_location_without_env_var( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + assert default_config_path() == home / ".config" / "tiasdata" / "config.toml" + + def test_load_without_path_uses_default_location( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + _write(home / ".config" / "tiasdata" / "config.toml", _minimal(tmp_path)) + assert load_config().catalog.root == tmp_path / "data" / "tiasdata" + + def test_env_var_with_tilde_is_expanded( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("TIASDATA_CONFIG", "~/conf/config.toml") + assert default_config_path() == home / "conf" / "config.toml"