"""CLI(SPEC_03 の `tiasdata init`・「出力の規則」,error-handling.md の終了コード)."""
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
from tiasdata import catalog
from tiasdata.cli import main
def _write_config(tmp_path: Path, extra: str = "") -> Path:
# 相対パスで書き,設定ファイルのあるフォルダ基準で解決させる
config_path = tmp_path / "conf" / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
'[catalog]\nroot = "../data/tiasdata"\nidentity = "../data/identity/identity.sqlite"\n'
+ extra,
encoding="utf-8",
)
return config_path
def _paths(tmp_path: Path) -> dict[str, Path]:
root = tmp_path / "data" / "tiasdata"
return {
"root": root,
"catalog": root / "catalog.sqlite",
"store": root / "store",
"bundles": root / "bundles",
"backups": root / "backups",
"identity": tmp_path / "data" / "identity" / "identity.sqlite",
}
def _assert_no_paths(text: str, tmp_path: Path) -> None:
# 出力にデータ・設定ファイルの場所を出さない(SPEC_03「出力の規則」)
for form in (str(tmp_path), tmp_path.as_posix(), str(tmp_path.resolve())):
assert form not in text
assert "catalog.sqlite" not in text
assert "identity.sqlite" not in text
assert "config.toml" not in text
@pytest.fixture
def config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
config_path = _write_config(tmp_path)
monkeypatch.setenv("TIASDATA_CONFIG", str(config_path))
return config_path
# ---------------------------------------------------------------------------
# init
# ---------------------------------------------------------------------------
class TestInit:
def test_init_creates_catalog_store_and_identity(
self, tmp_path: Path, config_env: Path
) -> None:
assert main(["init"]) == 0
p = _paths(tmp_path)
assert p["catalog"].is_file()
assert p["store"].is_dir()
assert p["bundles"].is_dir()
assert p["backups"].is_dir()
assert p["identity"].is_file()
def test_init_applies_latest_migrations(self, tmp_path: Path, config_env: Path) -> None:
assert main(["init"]) == 0
p = _paths(tmp_path)
with sqlite3.connect(p["catalog"]) as conn:
assert catalog.schema_version(conn) == catalog.latest_version("catalog")
assert conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5
with sqlite3.connect(p["identity"]) as conn:
assert catalog.schema_version(conn) == catalog.latest_version("identity")
def test_init_twice_gives_same_result(
self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str]
) -> None:
p = _paths(tmp_path)
assert main(["init"]) == 0
capsys.readouterr()
with sqlite3.connect(p["catalog"]) as conn:
schema = conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall()
kinds = conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall()
# 何度実行しても同じ(既存の行を消さない)
with sqlite3.connect(p["catalog"]) as conn:
conn.execute(
"INSERT INTO subjects (subject_id, created_at)"
" VALUES ('S000001', '2026-10-06T00:00:00Z')"
)
assert main(["init"]) == 0
out = capsys.readouterr().out
with sqlite3.connect(p["catalog"]) as conn:
assert conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall() == schema
assert conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall() == kinds
assert conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1
assert catalog.schema_version(conn) == catalog.latest_version("catalog")
assert "種類" in out
def test_init_reports_versions_and_kind_counts(
self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["init"]) == 0
out = capsys.readouterr().out
latest = catalog.latest_version("catalog")
assert f"{latest}" in out
assert "asset 4" in out
assert "annotation 1" in out
def test_init_output_contains_no_paths(
self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["init"]) == 0
assert main(["init"]) == 0
captured = capsys.readouterr()
_assert_no_paths(captured.out + captured.err, tmp_path)
def test_init_rejects_catalog_newer_than_library(
self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["init"]) == 0
p = _paths(tmp_path)
newer = catalog.latest_version("catalog") + 1
with sqlite3.connect(p["catalog"]) as conn:
conn.execute(f"PRAGMA user_version = {newer}")
capsys.readouterr()
assert main(["init"]) == 1
captured = capsys.readouterr()
assert len(captured.err.strip().splitlines()) == 1
_assert_no_paths(captured.err, tmp_path)
# ---------------------------------------------------------------------------
# 入力の問題(終了コード 1・標準エラーに 1 行)
# ---------------------------------------------------------------------------
class TestInputErrors:
def test_missing_config_exits_1_with_one_line(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("TIASDATA_CONFIG", str(tmp_path / "missing.toml"))
assert main(["init"]) == 1
captured = capsys.readouterr()
assert captured.out == ""
assert len(captured.err.strip().splitlines()) == 1
assert "Traceback" not in captured.err
# 何も作らない
assert not (tmp_path / "data").exists()
def test_broken_toml_exits_1_with_one_line(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("[catalog\nroot = ", encoding="utf-8")
monkeypatch.setenv("TIASDATA_CONFIG", str(config_path))
assert main(["init"]) == 1
captured = capsys.readouterr()
assert len(captured.err.strip().splitlines()) == 1
assert "Traceback" not in captured.err
def test_unknown_key_exits_1_without_input_values(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
secret = "DUMMY-SECRET-VALUE-12345"
config_path = _write_config(tmp_path, f'stroe = "{secret}"\n')
monkeypatch.setenv("TIASDATA_CONFIG", str(config_path))
assert main(["init"]) == 1
captured = capsys.readouterr()
assert len(captured.err.strip().splitlines()) == 1
# キーの位置は出すが,入力値・パスは出さない
assert "stroe" in captured.err
assert secret not in captured.err
_assert_no_paths(captured.err, tmp_path)
assert not (tmp_path / "data").exists()
def test_missing_required_key_exits_1(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
config_path = tmp_path / "conf" / "config.toml"
config_path.parent.mkdir()
config_path.write_text('[catalog]\nroot = "../data/tiasdata"\n', encoding="utf-8")
monkeypatch.setenv("TIASDATA_CONFIG", str(config_path))
assert main(["init"]) == 1
captured = capsys.readouterr()
assert len(captured.err.strip().splitlines()) == 1
assert "identity" in captured.err
_assert_no_paths(captured.err, tmp_path)
def test_multiple_validation_errors_fit_in_one_line(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
config_path = tmp_path / "conf" / "config.toml"
config_path.parent.mkdir()
config_path.write_text(
'x = 1\n[catalog]\nfoo = "a"\n[servers.s]\nhost = "h"\n', encoding="utf-8"
)
monkeypatch.setenv("TIASDATA_CONFIG", str(config_path))
assert main(["init"]) == 1
assert len(capsys.readouterr().err.strip().splitlines()) == 1
# ---------------------------------------------------------------------------
# 引数
# ---------------------------------------------------------------------------
class TestArguments:
def test_unknown_command_exits_2(self) -> None:
with pytest.raises(SystemExit) as exc:
main(["no-such-command"])
assert exc.value.code == 2
def test_unknown_option_exits_2(self, config_env: Path) -> None:
with pytest.raises(SystemExit) as exc:
main(["init", "--no-such-option"])
assert exc.value.code == 2
def test_no_command_prints_help_and_exits_0(self, capsys: pytest.CaptureFixture[str]) -> None:
assert main([]) == 0
out = capsys.readouterr().out
assert "init" in out
# ---------------------------------------------------------------------------
# 別プロセスでの実行(出力の文字コード)
# ---------------------------------------------------------------------------
def _run_cli(args: list[str], config_path: Path, cwd: Path) -> subprocess.CompletedProcess[bytes]:
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")}
env["TIASDATA_CONFIG"] = str(config_path)
# パイプにつないだときの既定の文字コード(Windows では cp932)に任せる
return subprocess.run(
[sys.executable, "-m", "tiasdata.cli", *args],
capture_output=True,
env=env,
cwd=cwd,
check=False,
timeout=60,
)
class TestSubprocess:
def test_init_stdout_is_utf8(self, tmp_path: Path) -> None:
config_path = _write_config(tmp_path)
result = _run_cli(["init"], config_path, tmp_path)
assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace")
out = result.stdout.decode("utf-8") # UTF-8 でなければ例外
assert "種類" in out
_assert_no_paths(out, tmp_path)
def test_init_twice_in_separate_processes(self, tmp_path: Path) -> None:
config_path = _write_config(tmp_path)
assert _run_cli(["init"], config_path, tmp_path).returncode == 0
second = _run_cli(["init"], config_path, tmp_path)
assert second.returncode == 0
assert second.stderr == b""
def test_error_stderr_is_utf8_one_line(self, tmp_path: Path) -> None:
config_path = _write_config(tmp_path, 'stroe = "x"\n')
result = _run_cli(["init"], config_path, tmp_path)
assert result.returncode == 1
err = result.stderr.decode("utf-8") # UTF-8 でなければ例外
assert len(err.strip().splitlines()) == 1
assert "Traceback" not in err