Newer
Older
TIASDataHub / src / tiasdata / cli.py
"""tiasdata コマンドのエントリポイント."""

import argparse
import sys
from contextlib import closing

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:
    """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__":
    raise SystemExit(main())