Newer
Older
TIASDataHub / src / tiasdata / manifest.py
"""`manifest.csv`(バンドルの 1 行 1 ファイルの表)の組み立て(SPEC_03「manifest.csv の列」).

版の作成(`datasets`)とバンドルの書き出し・読み込み(`bundle`)が同じ形を使うため,
目録(`catalog`)・ストア・設定に依存しない純関数だけを置く(計算サーバで import しても
目録を引き込まないようにするため.SPEC_01「依存の向き」).

バンドルの読み込み(`bundle`)は目録側のモジュールを import できないため,読み込み側と
目録側(`datasets`・`store`・`cli`)が共に使う小さな純関数(データセット名の形・
ファイルの SHA-256・pydantic の検証エラーの整形)もここに置く.
"""

import csv
import hashlib
import io
import json
import re
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path
from typing import Any

from pydantic import BaseModel, ConfigDict, ValidationError

# 先頭の固定の列(この後に `<所見>.<項目>` の列が定義の `[columns]` の順で並ぶ)
BASE_COLUMNS: tuple[str, ...] = ("path", "sha256", "kind", "subject_id", "capture_id")
FILES_DIRNAME = "files"
MANIFEST_ENCODING = "utf-8"
# データセット名(SPEC_02「ID の書式」.スキーマの CHECK と同じ).定義ファイル名・
# バンドルのパスにも使うため,区切り等を含む名前を拒否する
DATASET_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
# 大きいファイルでもメモリを使い切らないよう分割して読む
CHUNK_SIZE = 1024 * 1024


class ManifestEntry(BaseModel):
    """manifest の 1 行の元になる情報(版に含まれる 1 ファイル).

    Attributes:
        sha256: ファイルの SHA-256.
        ext: 拡張子(小文字.`.` なし).
        kind: ファイルの種類.
        subject_id: 研究用の症例 ID.
        capture_id: 撮影 ID.
        annotations: 採用した所見(所見の種類 → 値の辞書).無い種類は含めない.
    """

    model_config = ConfigDict(frozen=True)

    sha256: str
    ext: str
    kind: str
    subject_id: str
    capture_id: str
    annotations: dict[str, dict[str, Any]] = {}


def bundle_file_path(sha256: str, ext: str) -> str:
    """バンドルのルートからのファイルの相対パス(`files/<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()


def sha256_file(path: Path) -> str:
    """ファイルの SHA-256 を計算する(読み取り専用で開き,分割して読む).

    Args:
        path: 対象のファイル.

    Returns:
        SHA-256(小文字の 16 進 64 文字).
    """
    digest = hashlib.sha256()
    with path.open("rb") as f:
        while chunk := f.read(CHUNK_SIZE):
            digest.update(chunk)
    return digest.hexdigest()


def validation_error_details(e: ValidationError) -> str:
    """pydantic の検証エラーを 1 行にまとめる(`<キーの位置>: <理由>` を `; ` でつなぐ).

    入力値(パス・患者 ID 等を含みうる)は出さない(SPEC_03「出力の規則」).

    Args:
        e: 検証エラー.

    Returns:
        キーの位置と理由だけを並べた 1 行.
    """
    return "; ".join(
        f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}"
        for error in e.errors(include_input=False, include_url=False)
    )