"""`manifest.csv`(バンドルの 1 行 1 ファイルの表)の組み立て(SPEC_03「manifest.csv の列」).
版の作成(`datasets`)とバンドルの書き出し・読み込み(`bundle`)が同じ形を使うため,
目録(`catalog`)・ストア・設定に依存しない純関数だけを置く(計算サーバで import しても
目録を引き込まないようにするため.SPEC_01「依存の向き」).
"""
import csv
import hashlib
import io
import json
from collections.abc import Iterable, Mapping, Sequence
from typing import Any
from pydantic import BaseModel, ConfigDict
# 先頭の固定の列(この後に `<所見>.<項目>` の列が定義の `[columns]` の順で並ぶ)
BASE_COLUMNS: tuple[str, ...] = ("path", "sha256", "kind", "subject_id", "capture_id")
FILES_DIRNAME = "files"
MANIFEST_ENCODING = "utf-8"
class ManifestEntry(BaseModel):
"""manifest の 1 行の元になる情報(版に含まれる 1 ファイル).
Attributes:
sha256: ファイルの SHA-256.
ext: 拡張子(小文字.`.` なし).
kind: ファイルの種類.
subject_id: 研究用の症例 ID.
capture_id: 撮影 ID.
annotations: 採用した所見(所見の種類 → 値の辞書).無い種類は含めない.
"""
model_config = ConfigDict(frozen=True)
sha256: str
ext: str
kind: str
subject_id: str
capture_id: str
annotations: dict[str, dict[str, Any]] = {}
def bundle_file_path(sha256: str, ext: str) -> str:
"""バンドルのルートからのファイルの相対パス(`files/<sha256>.<ext>`.区切りは `/`)."""
return f"{FILES_DIRNAME}/{sha256}.{ext}"
def manifest_columns(columns: Mapping[str, Sequence[str]]) -> list[str]:
"""manifest の列名を返す.
Args:
columns: 所見の種類 → 出す項目(データセットの定義の `[columns]`.この順に並べる).
Returns:
固定の列のあとに `<所見>.<項目>` を並べた列名.
"""
return [
*BASE_COLUMNS,
*(f"{kind}.{field}" for kind, fields in columns.items() for field in fields),
]
def _cell(value: object) -> str:
# 文字列はそのまま,無い値は空,それ以外(数値・真偽値等)は JSON の表記にする
if value is None:
return ""
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _sort_key(entry: ManifestEntry) -> tuple[str, str, str]:
return (entry.subject_id, entry.capture_id, entry.sha256)
def build_manifest(entries: Iterable[ManifestEntry], columns: Mapping[str, Sequence[str]]) -> bytes:
"""manifest.csv のバイト列を作る.
行は `subject_id`・`capture_id`・`sha256` の順に並べ,UTF-8(BOM なし)・改行 LF で書く
(同じ構成なら同じバイト列になるようにするため.SPEC_03「版の作成」).
患者 ID・元のファイル名は入力に含まれないので出ない.
Args:
entries: 版に含まれるファイル.
columns: 所見の種類 → 出す項目(定義の `[columns]`).
Returns:
manifest.csv の中身.
"""
buffer = io.StringIO(newline="")
writer = csv.writer(buffer, lineterminator="\n")
writer.writerow(manifest_columns(columns))
for entry in sorted(entries, key=_sort_key):
values = [
_cell(entry.annotations.get(kind, {}).get(field))
for kind, fields in columns.items()
for field in fields
]
writer.writerow(
[
bundle_file_path(entry.sha256, entry.ext),
entry.sha256,
entry.kind,
entry.subject_id,
entry.capture_id,
*values,
]
)
return buffer.getvalue().encode(MANIFEST_ENCODING)
def manifest_sha256(data: bytes) -> str:
"""manifest.csv のバイト列の SHA-256(版の指紋.16 進 64 文字の小文字)."""
return hashlib.sha256(data).hexdigest()