"""tiasdata コマンドのエントリポイント."""
import argparse
import sqlite3
import sys
from contextlib import closing
from pathlib import Path
from pydantic import ValidationError
from tiasdata import __version__, catalog
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 _open_catalog(catalog_path: Path) -> sqlite3.Connection:
conn = catalog.connect(catalog_path)
try:
catalog.require_latest(conn, "catalog")
except BaseException:
conn.close()
raise
return conn
def _cmd_stats(args: argparse.Namespace) -> int:
config = load_config()
with closing(_open_catalog(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(_open_catalog(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} 件")
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)
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())