"""設定ファイル(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})