Newer
Older
TIASDataHub / src / tiasdata / config.py
"""設定ファイル(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})