"""tiasdata コマンドのエントリポイント."""
import argparse
import sys
from contextlib import closing
from pydantic import ValidationError
from tiasdata import __version__, backup, catalog, datasets, export
from tiasdata.check import CheckResult, run_check
from tiasdata.config import load_config
from tiasdata.importers import ImportResult, run_import
from tiasdata.kinds import REVIEW_KIND
from tiasdata.manifest import validation_error_details
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.review_numbers} 件(要確認リスト {result.review_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.reviewed_added} 件(うち撮影にひも付けないファイル "
f"{result.unlinked_files_added} 件・番号 {result.unlinked_numbers_added} 個)"
)
print(f"既存の撮影・ファイルに要確認の印を足した: {result.review_marks_added} 件")
print(f"要確認リストから外れたが印が残っている: {result.review_marks_stale} 件")
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(f"撮影にひも付かないファイル: {stats.assets_without_capture} 件")
print("所見(訂正されていないもの):")
review_marks = stats.review_marks_on_captures + stats.review_marks_on_files
if not stats.annotations and not review_marks:
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} 件")
if review_marks:
# 要確認の印は件数だけを出す(値は理由の全文で,値ごとの内訳にならない)
print(
f" {REVIEW_KIND}: {review_marks} 件"
f"(撮影に付いたもの {stats.review_marks_on_captures} 件・"
f"ファイルに付いたもの {stats.review_marks_on_files} 件)"
)
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} 件)"
)
print(f"撮影にひも付かないファイル(問題には数えない): {result.unlinked_files} 件")
# 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 - result.unlinked} 件)"
)
print(f"撮影にひも付かないため除いた: {result.unlinked} 件")
print(f"manifest の SHA-256: {result.manifest_sha256}")
return 0
def _cmd_export(args: argparse.Namespace) -> int:
config = load_config()
result = export.run_export(config, args.name, args.dataset_version)
# パスは出さない(SPEC_03「出力の規則」).書き出したか,既にあったものを照合して使ったか
if result.reused:
print(
f"データセット {result.name} の版 {result.version}: 既にあったバンドルを照合して使った"
"(上書きしていない)"
)
else:
print(f"データセット {result.name} の版 {result.version} のバンドルを書き出した")
print(f"ファイル {result.files} 件(合計 {result.size_bytes} バイト)")
print(f"manifest の SHA-256: {result.manifest_sha256}")
return 0
def _cmd_backup(args: argparse.Namespace) -> int:
config = load_config()
result = backup.run_backup(config)
# パスは出さない(SPEC_03「出力の規則」).ファイル名(日時)と件数だけ
counts = result.catalog.counts
print(f"目録を複製した: {result.catalog.filename}(目録のフォルダの backups/.整合性 ok)")
print(
f" 症例 {counts['subjects']} 件・撮影 {counts['captures']} 件・"
f"ファイル {counts['assets']} 件・所見 {counts['annotations']} 件・"
f"版 {counts['dataset_versions']} 件"
)
print(f"対応表を複製した: {result.identity.filename}(対応表のフォルダの backups/.整合性 ok)")
print(f" 行 {result.identity.counts['identity_map']} 件")
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 行にまとめる
return f"設定ファイルの検証に失敗した({e.error_count()} 件): {validation_error_details(e)}"
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="設定ファイルで名前を付けた取り込み元を読み,ファイルをストアにコピーして"
"症例・撮影・ファイル・所見・由来を登録する.要確認リスト(reviews/<名前>.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)
export_ = subparsers.add_parser(
"export",
help="凍結した版のバンドルを bundles/ に書き出す",
description="凍結した版の manifest.csv・dataset.json・SHA256SUMS・files/ を "
"bundles/<名前>/v<版>/ に書き出す.構成から作り直した manifest が版の記録と一致しなければ"
"停止する.既にあれば上書きせず,照合して使う(食い違えば停止する)",
)
export_.add_argument("name", metavar="<名前>", help="データセット名")
export_.add_argument(
"--version",
dest="dataset_version",
metavar="<版>",
type=int,
required=True,
help="書き出す版(必須)",
)
export_.set_defaults(handler=_cmd_export)
backup_ = subparsers.add_parser(
"backup",
help="目録と対応表をそれぞれの backups/ に複製して点検する",
description="目録を目録のフォルダの backups/catalog-<UTC の日時>.sqlite に,対応表を"
"対応表のフォルダの backups/identity-<同じ日時>.sqlite に,SQLite のバックアップ API で"
"複製する.複製に integrity_check をかけて ok を確かめ,件数を表示する."
"同じ名前があれば上書きせずに停止する",
)
backup_.set_defaults(handler=_cmd_backup)
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())