"""目録と対応表のバックアップ(`tiasdata backup`.2026-10-08 のユーザー判断).
- 目録は `<catalog.root>/backups/catalog-<UTC YYYYmmddTHHMMSSZ>.sqlite`,対応表は
**対応表のフォルダの** `backups/identity-<同じ日時>.sqlite` に,SQLite のバックアップ API で
複製する
- 複製に `integrity_check` が ok・スキーマの版が最新であることを確かめ,件数を表示する
(パスは出さない)
- 書き込む前に 2 つとも同じ名前が無いことを確かめ,あれば何も書かずに停止する(上書きしない)
- 一時ファイル → 点検 → 正式な名前.失敗時は一時ファイルを消す
- init 前・スキーマが古いときは 1 行のエラー
- `run_backup(config, *, now=None)` で日時を固定できる
"""
import sqlite3
from contextlib import closing
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
import pytest
from conftest import ImportEnv, write_reviews
from tiasdata import backup, catalog
from tiasdata.backup import backup_timestamp, run_backup
from tiasdata.catalog import backup as catalog_backup
from tiasdata.cli import main
from tiasdata.config import load_config
from tiasdata.importers import run_import
NOW = datetime(2026, 10, 8, 9, 0, 0, tzinfo=UTC)
STAMP = "20261008T090000Z"
DUMMY_KEYS = [str(990001 + i) for i in range(7)]
def _catalog_backups(env: ImportEnv) -> Path:
return env.catalog_root / "backups"
def _identity_backups(env: ImportEnv) -> Path:
return env.identity_path.parent / "backups"
def _files(directory: Path) -> list[str]:
return sorted(p.name for p in directory.iterdir()) if directory.is_dir() else []
def _dump(path: Path) -> list[str]:
with closing(sqlite3.connect(path)) as conn:
return list(conn.iterdump())
def _version(path: Path) -> int:
with closing(sqlite3.connect(path)) as conn:
return conn.execute("PRAGMA user_version").fetchone()[0]
def _init() -> None:
assert main(["init"]) == 0
def _assert_nothing_written(env: ImportEnv) -> None:
assert _files(_catalog_backups(env)) == []
assert _files(_identity_backups(env)) == []
def _assert_no_paths(text: str, env: ImportEnv) -> None:
root = env.config_path.parents[1]
for form in (str(root), root.as_posix(), str(root.resolve())):
assert form not in text
assert "catalog.sqlite" not in text
assert "identity.sqlite" not in text
for key in DUMMY_KEYS:
assert key not in text
class TestTimestamp:
def test_utc_format(self) -> None:
assert backup_timestamp(NOW) == STAMP
def test_other_timezone_is_converted_to_utc(self) -> None:
jst = timezone(timedelta(hours=9))
assert backup_timestamp(datetime(2026, 10, 8, 18, 0, 0, tzinfo=jst)) == STAMP
def test_naive_is_treated_as_utc(self) -> None:
assert backup_timestamp(datetime(2026, 10, 8, 9, 0, 0)) == STAMP
def test_default_is_now(self) -> None:
before = datetime.now(UTC).replace(microsecond=0)
stamp = backup_timestamp()
after = datetime.now(UTC)
moment = datetime.strptime(stamp, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
assert before <= moment <= after
class TestRunBackup:
def test_copies_to_each_backups_folder(self, import_env: ImportEnv) -> None:
write_reviews(import_env.reviews_dir, "dummy_tongue", [3])
_init()
run_import(load_config(import_env.config_path), import_env.source_name)
catalog_before = _dump(import_env.catalog_root / "catalog.sqlite")
identity_before = import_env.identity_path.read_bytes()
result = run_backup(load_config(import_env.config_path), now=NOW)
assert result.timestamp == STAMP
assert result.catalog.filename == f"catalog-{STAMP}.sqlite"
assert result.identity.filename == f"identity-{STAMP}.sqlite"
# 目録は目録のフォルダの backups/,対応表は対応表のフォルダの backups/(別の場所)
assert _files(_catalog_backups(import_env)) == [f"catalog-{STAMP}.sqlite"]
assert _files(_identity_backups(import_env)) == [f"identity-{STAMP}.sqlite"]
assert _catalog_backups(import_env) != _identity_backups(import_env)
catalog_copy = _catalog_backups(import_env) / result.catalog.filename
identity_copy = _identity_backups(import_env) / result.identity.filename
# 中身は元と同じ.スキーマの版は最新
assert _dump(catalog_copy) == catalog_before
assert _dump(identity_copy) == _dump(import_env.identity_path)
assert _version(catalog_copy) == catalog.latest_version("catalog")
assert _version(identity_copy) == catalog.latest_version("identity")
assert catalog_backup.integrity_problems(catalog_copy) == 0
assert catalog_backup.integrity_problems(identity_copy) == 0
# 複製元は変わらない
assert _dump(import_env.catalog_root / "catalog.sqlite") == catalog_before
assert import_env.identity_path.read_bytes() == identity_before
# 件数(撮影 18・ファイル 18・所見 18 + 要確認の印 1・症例 7・対応表 7)
assert result.catalog.counts == {
"subjects": 7,
"captures": 18,
"assets": 18,
"annotations": 19,
"dataset_versions": 0,
}
assert result.identity.counts == {"identity_map": 7}
def test_copy_can_be_opened_as_latest_catalog(self, import_env: ImportEnv) -> None:
_init()
result = run_backup(load_config(import_env.config_path), now=NOW)
copy = _catalog_backups(import_env) / result.catalog.filename
with closing(catalog.connect_latest(copy)) as conn:
assert catalog.count_captures(conn) == 0
def test_empty_catalog_counts_are_zero(self, import_env: ImportEnv) -> None:
_init()
result = run_backup(load_config(import_env.config_path), now=NOW)
assert set(result.catalog.counts.values()) == {0}
assert result.identity.counts == {"identity_map": 0}
def test_different_times_make_separate_copies(self, import_env: ImportEnv) -> None:
_init()
config = load_config(import_env.config_path)
run_backup(config, now=NOW)
run_backup(config, now=NOW + timedelta(seconds=1))
assert _files(_catalog_backups(import_env)) == [
f"catalog-{STAMP}.sqlite",
"catalog-20261008T090001Z.sqlite",
]
assert len(_files(_identity_backups(import_env))) == 2
def test_no_temporary_files_remain(self, import_env: ImportEnv) -> None:
_init()
run_backup(load_config(import_env.config_path), now=NOW)
for directory in (_catalog_backups(import_env), _identity_backups(import_env)):
assert not [n for n in _files(directory) if n.startswith(backup.TEMP_PREFIX)]
class TestDoesNotOverwrite:
@pytest.mark.parametrize("which", ["catalog", "identity"])
def test_existing_name_stops_without_writing(self, import_env: ImportEnv, which: str) -> None:
_init()
if which == "catalog":
existing = _catalog_backups(import_env) / f"catalog-{STAMP}.sqlite"
other = _identity_backups(import_env)
else:
existing = _identity_backups(import_env) / f"identity-{STAMP}.sqlite"
other = _catalog_backups(import_env)
existing.parent.mkdir(parents=True, exist_ok=True)
existing.write_bytes(b"existing-backup")
with pytest.raises(ValueError) as excinfo:
run_backup(load_config(import_env.config_path), now=NOW)
message = str(excinfo.value)
assert "\n" not in message
_assert_no_paths(message, import_env)
# 既存のファイルは変わらず,もう一方も書かない(一時ファイルも残らない)
assert existing.read_bytes() == b"existing-backup"
assert _files(existing.parent) == [existing.name]
assert _files(other) == []
def test_second_run_at_same_time_stops(self, import_env: ImportEnv) -> None:
_init()
config = load_config(import_env.config_path)
run_backup(config, now=NOW)
catalog_copy = _catalog_backups(import_env) / f"catalog-{STAMP}.sqlite"
before = catalog_copy.read_bytes()
with pytest.raises(ValueError):
run_backup(config, now=NOW)
assert catalog_copy.read_bytes() == before
assert _files(_catalog_backups(import_env)) == [catalog_copy.name]
assert _files(_identity_backups(import_env)) == [f"identity-{STAMP}.sqlite"]
class TestFailures:
def test_before_init_stops(self, import_env: ImportEnv) -> None:
with pytest.raises(ValueError) as excinfo:
run_backup(load_config(import_env.config_path), now=NOW)
assert "\n" not in str(excinfo.value)
_assert_no_paths(str(excinfo.value), import_env)
_assert_nothing_written(import_env)
def test_missing_identity_stops(self, import_env: ImportEnv) -> None:
_init()
import_env.identity_path.unlink()
with pytest.raises(ValueError) as excinfo:
run_backup(load_config(import_env.config_path), now=NOW)
_assert_no_paths(str(excinfo.value), import_env)
_assert_nothing_written(import_env)
@pytest.mark.parametrize("which", ["catalog", "identity"])
def test_old_schema_stops(self, import_env: ImportEnv, which: str) -> None:
_init()
path = import_env.catalog_root / "catalog.sqlite" if which == "catalog" else None
path = path or import_env.identity_path
with closing(sqlite3.connect(path)) as conn:
conn.execute("PRAGMA user_version = 0")
with pytest.raises(ValueError, match="init") as excinfo:
run_backup(load_config(import_env.config_path), now=NOW)
assert "\n" not in str(excinfo.value)
_assert_no_paths(str(excinfo.value), import_env)
_assert_nothing_written(import_env)
def test_integrity_problem_leaves_nothing(
self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
) -> None:
_init()
monkeypatch.setattr(catalog_backup, "integrity_problems", lambda path: 2)
with pytest.raises(ValueError, match="integrity_check"):
run_backup(load_config(import_env.config_path), now=NOW)
_assert_nothing_written(import_env)
def test_integrity_problem_in_identity_copy_removes_catalog_copy(
self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
) -> None:
_init()
original = catalog_backup.integrity_problems
calls = {"n": 0}
def second_fails(path: Path) -> int:
calls["n"] += 1
return 1 if calls["n"] == 2 else original(path)
monkeypatch.setattr(catalog_backup, "integrity_problems", second_fails)
with pytest.raises(ValueError):
run_backup(load_config(import_env.config_path), now=NOW)
assert calls["n"] == 2
_assert_nothing_written(import_env)
def test_copy_with_old_version_leaves_nothing(
self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
) -> None:
_init()
monkeypatch.setattr(catalog_backup, "user_version", lambda path: 0)
with pytest.raises(ValueError):
run_backup(load_config(import_env.config_path), now=NOW)
_assert_nothing_written(import_env)
def test_sqlite_error_during_copy_leaves_nothing(
self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch
) -> None:
_init()
original = catalog_backup.copy_database
calls = {"n": 0}
def second_fails(source: Path, target: Path) -> None:
calls["n"] += 1
if calls["n"] == 2:
raise sqlite3.OperationalError("database is locked")
original(source, target)
monkeypatch.setattr(catalog_backup, "copy_database", second_fails)
with pytest.raises(ValueError) as excinfo:
run_backup(load_config(import_env.config_path), now=NOW)
assert "\n" not in str(excinfo.value)
_assert_no_paths(str(excinfo.value), import_env)
_assert_nothing_written(import_env)
class TestBackupCommand:
def test_prints_names_and_counts_without_paths(
self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str]
) -> None:
_init()
run_import(load_config(import_env.config_path), import_env.source_name)
capsys.readouterr()
assert main(["backup"]) == 0
captured = capsys.readouterr()
assert captured.err == ""
out = captured.out
catalog_names = _files(_catalog_backups(import_env))
identity_names = _files(_identity_backups(import_env))
assert len(catalog_names) == len(identity_names) == 1
assert catalog_names[0] in out
assert identity_names[0] in out
# 2 つの複製の日時は同じ
assert catalog_names[0].removeprefix("catalog-") == identity_names[0].removeprefix(
"identity-"
)
assert "撮影 18 件" in out
assert "行 7 件" in out
assert "ok" in out
_assert_no_paths(out, import_env)
def test_same_name_exits_1_with_one_line(
self,
import_env: ImportEnv,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
_init()
monkeypatch.setattr(backup, "backup_timestamp", lambda now=None: STAMP)
assert main(["backup"]) == 0
capsys.readouterr()
assert main(["backup"]) == 1
captured = capsys.readouterr()
assert captured.out == ""
assert len(captured.err.strip().splitlines()) == 1
assert captured.err.startswith("エラー: ")
assert "Traceback" not in captured.err
_assert_no_paths(captured.err, import_env)
def test_before_init_exits_1_with_one_line(
self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["backup"]) == 1
captured = capsys.readouterr()
assert len(captured.err.strip().splitlines()) == 1
assert "Traceback" not in captured.err
_assert_no_paths(captured.err, import_env)
def test_unknown_argument_exits_2(self, import_env: ImportEnv) -> None:
with pytest.raises(SystemExit) as excinfo:
main(["backup", "--to", "x"])
assert excinfo.value.code == 2