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, datasets
from tiasdata.check import CheckResult, run_check
from tiasdata.config import load_config
from tiasdata.importers import ImportResult, run_import
from tiasdata.stats import CatalogStats, collect_stats


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 _cmd_import(args: argparse.Namespace) -> int:
    config = load_config()
    result = run_import(config, args.source, show_source_names=args.show_source_names)
    _print_import(result, show_source_names=args.show_source_names)
    return 0


def _print_import(result: ImportResult, *, show_source_names: bool = False) -> None:
    # 件数と設定で付けた名前だけを出す(患者 ID・元のファイル名・パスは出さない)
    print(f"取り込み元: {result.source_name}({result.importer})")
    print(f"取り込み元の画像: {result.found} 件")
    print(f"保留で飛ばした: {result.held} 件(保留リスト {result.hold_filename})")
    # 対応表の行番号は位置だが,番号と同じく既定では出さない(--show-source-names で出す)
    unused_rows = result.unused_map_rows
    unused_note = ""
    if unused_rows and show_source_names:
        unused_note = f"(行: {', '.join(str(row) for row in unused_rows)})"
    elif unused_rows:
        unused_note = "(--show-source-names で行番号を表示する)"
    print(f"対応表で番号が数字でない行(使わない): {len(unused_rows)} 件{unused_note}")
    print(f"既に登録済みで飛ばした: {result.skipped_existing} 件")
    print(
        f"登録: 症例 {result.subjects_added} 件・撮影 {result.captures_added} 件・"
        f"ファイル {result.assets_added} 件・所見 {result.annotations_added} 件・"
        f"由来 {result.derivations_added} 件"
    )
    print(f"ストアにコピー: {result.copied_to_store} 件")


def _cmd_stats(args: argparse.Namespace) -> int:
    config = load_config()
    with closing(catalog.connect_latest(config.catalog.catalog_path)) as conn:
        stats = collect_stats(conn)
    _print_stats(stats)
    return 0


def _print_stats(stats: CatalogStats) -> None:
    print(
        f"症例: {stats.subjects} 件(撮影が 2 つ以上: {stats.subjects_with_multiple_captures} 件)"
    )
    print(f"撮影: {stats.captures} 件")
    print("ファイル(種類ごと):")
    for kind, count in stats.assets_by_kind.items():
        print(f"  {kind}: {count} 件")
    print("所見(訂正されていないもの):")
    if not stats.annotations:
        print("  なし")
    for kind, summary in stats.annotations.items():
        print(f"  {kind}: {summary.total} 件")
        for field, counts in summary.by_field.items():
            values = "・".join(f"{value} {count}" for value, count in counts.items())
            print(f"    {field}: {values}")
        print("    組み合わせ:")
        for value, count in summary.by_value.items():
            print(f"      {value}: {count} 件")
    print(f"訂正された所見: {stats.superseded_annotations} 件")


def _cmd_check(args: argparse.Namespace) -> int:
    config = load_config()
    with closing(catalog.connect_latest(config.catalog.catalog_path)) as conn:
        result = run_check(conn, config.catalog.store_dir)
    _print_check(result)
    if result.problems:
        # 問題は止めずに数え,最後にまとめて報告して 0 以外で終わる(error-handling.md).
        # 件数の表と同じ標準出力に書く(パイプ越しでも表の最後に並ぶように)
        print(f"問題: {result.problems} 件")
        return 1
    print("問題: 0 件")
    return 0


def _print_check(result: CheckResult) -> None:
    print(
        f"ハッシュの照合(目録の全ファイル {result.catalog_files} 件): "
        f"一致 {result.hash_ok} 件・不一致 {result.hash_mismatch} 件"
    )
    print(f"目録にあってストアに無いファイル: {result.missing_in_store} 件")
    print(
        f"ストアにあって目録に無いファイル: {result.not_in_catalog} 件"
        f"(ストアのファイル {result.store_files} 件)"
    )
    print(f"読み取り専用が外れたストアのファイル: {result.writable} 件")
    print(f"書きかけの一時ファイル: {result.temp_files} 件")
    print(f"ストアの想定外のファイル・フォルダ: {result.unexpected_in_store} 件")
    print(f"所見の参照切れ: {result.dangling_annotations} 件")
    print(f"凍結した版の構成の欠け: {result.broken_dataset_members} 件")
    print(
        f"凍結した版の manifest の不一致: {result.manifest_mismatch} 件"
        f"(版 {result.dataset_versions} 件)"
    )


# manifest の SHA-256 は一覧では先頭だけを出す(照合には `show` の全桁を使う)
_SHORT_SHA256 = 12


def _print_version_line(summary: datasets.VersionSummary, *, full: bool) -> None:
    record = summary.record
    digest = record.manifest_sha256 if full else record.manifest_sha256[:_SHORT_SHA256]
    line = (
        f"  v{record.version}: ファイル {summary.members} 件・所見 {summary.member_annotations} 件"
        f"・作成 {record.created_at}・hub_commit {record.hub_commit[:_SHORT_SHA256]}"
        f"・manifest {digest}"
    )
    if full and record.note:
        line += f"・備考 {' '.join(record.note.splitlines())}"
    print(line)


def _cmd_dataset_list(args: argparse.Namespace) -> int:
    config = load_config()
    overviews = datasets.run_list(config)
    print(f"データセット: {len(overviews)} 件")
    for overview in overviews:
        defined = "" if overview.defined else "(定義ファイルなし)"
        if not overview.versions:
            print(f"{overview.name}{defined}: 版なし")
            continue
        latest = overview.versions[-1]
        print(
            f"{overview.name}{defined}: 版 {len(overview.versions)} 件"
            f"(最新 v{latest.record.version}・ファイル {latest.members} 件)"
        )
    return 0


def _cmd_dataset_show(args: argparse.Namespace) -> int:
    config = load_config()
    overview = datasets.run_show(config, args.name)
    defined = "" if overview.defined else "(定義ファイルなし)"
    print(f"データセット: {overview.name}{defined}")
    if overview.description:
        print(f"説明: {overview.description}")
    print(f"版: {len(overview.versions)} 件")
    for summary in overview.versions:
        _print_version_line(summary, full=True)
    return 0


def _cmd_dataset_freeze(args: argparse.Namespace) -> int:
    config = load_config()
    result = datasets.run_freeze(config, args.name, note=args.note)
    if result.created:
        print(f"データセット {result.name} の版 {result.version} を作った")
    else:
        print(
            f"データセット {result.name}: 直前の版 {result.version} と構成・定義が同じなので"
            "版を作らなかった"
        )
    print(
        f"ファイル {result.members} 件・所見 {result.member_annotations} 件"
        f"(種類が一致したファイル {result.candidates} 件・必須の所見が無く除いた "
        f"{result.candidates - result.members} 件)"
    )
    print(f"manifest の SHA-256: {result.manifest_sha256}")
    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)

    import_ = subparsers.add_parser(
        "import",
        help="取り込み元を読み,ストアにコピーして目録に登録する",
        description="設定ファイルで名前を付けた取り込み元を読み,ファイルをストアにコピーして"
        "症例・撮影・ファイル・所見・由来を登録する.保留リスト(holds/<名前>.toml)の番号は飛ばす."
        "既に登録済みのファイルは飛ばす(何度実行しても同じ結果になる)",
    )
    import_.add_argument("source", metavar="<取り込み元>", help="設定ファイルの sources.<名前>")
    import_.add_argument(
        "--show-source-names",
        action="store_true",
        help=(
            "エラーメッセージに取り込み元での番号・名前を,結果に対応表の使わない行の行番号を出す"
            "(原因の特定に必要なときだけ使う)"
        ),
    )
    import_.set_defaults(handler=_cmd_import)

    stats = subparsers.add_parser(
        "stats",
        help="種類・所見の値ごとの件数を表示する",
        description="ファイルの種類ごとの数,所見の値ごとの件数,症例・撮影の数を表示する",
    )
    stats.set_defaults(handler=_cmd_stats)

    check = subparsers.add_parser(
        "check",
        help="目録とストアを点検する(問題があれば終了コード 1)",
        description="ストアのファイルのハッシュの照合,目録とストアの食い違い,読み取り専用が外れた"
        "ファイル,所見の参照切れ,凍結した版の構成の欠けを数える.件数だけを表示する",
    )
    check.set_defaults(handler=_cmd_check)

    dataset = subparsers.add_parser(
        "dataset",
        help="データセットと版の一覧・版の作成",
        description="データセット(datasets/<名前>.toml の定義)の版の一覧を表示し,新しい版を作る",
    )
    dataset_commands = dataset.add_subparsers(dest="dataset_command", metavar="<操作>")
    dataset.set_defaults(handler=lambda _args: _print_help(dataset))

    dataset_list = dataset_commands.add_parser(
        "list",
        help="データセットと版の一覧・件数",
        description="目録のデータセットと定義ファイルのあるデータセットの,版の数・最新の版の件数を表示する",
    )
    dataset_list.set_defaults(handler=_cmd_dataset_list)

    dataset_show = dataset_commands.add_parser(
        "show",
        help="データセットの版ごとの件数・作成日時・hub_commit・manifest の SHA-256",
        description="データセットの版ごとの件数・作成日時・hub_commit・manifest の SHA-256・"
        "備考を表示する",
    )
    dataset_show.add_argument("name", metavar="<名前>", help="データセット名")
    dataset_show.set_defaults(handler=_cmd_dataset_show)

    dataset_freeze = dataset_commands.add_parser(
        "freeze",
        help="新しい版を作る(構成と定義が直前の版と同じなら作らない)",
        description="datasets/<名前>.toml の定義を目録に当てはめて新しい版を作る."
        "構成(ファイルと採用した所見)と定義が直前の版と同じなら作らない."
        "本リポジトリに未コミットの変更があると停止する",
    )
    dataset_freeze.add_argument("name", metavar="<名前>", help="データセット名")
    dataset_freeze.add_argument("--note", metavar="TEXT", help="版の備考")
    dataset_freeze.set_defaults(handler=_cmd_dataset_freeze)
    return parser


def _print_help(parser: argparse.ArgumentParser) -> int:
    parser.print_help()
    return 0


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())