"""保留リスト(`holds/<取り込み元の名前>.toml`)の読み込みと検証(SPEC_03「保留リスト」).
保留リストは個人情報を含まないためリポジトリで管理する(PR で変更する).
実行時は,リポジトリのルート(`repo.find_repo_root()`)の `holds/` を見つける.
パッケージとして別の場所に入れた場合は見つからないので,取り込みはリポジトリの clone から
実行する.
"""
import re
import tomllib
from datetime import date
from pathlib import Path
from typing import NamedTuple
from pydantic import BaseModel, ConfigDict, Field, StrictInt, ValidationError
from tiasdata import repo
HOLDS_DIRNAME = "holds"
# 取り込み元の名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない
_SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
class HoldEntry(BaseModel):
"""保留リストの 1 件(`[[hold]]`).
Attributes:
number: 取り込み元での番号.
reason: 保留にした理由.
resolve: 確かめる方法.
since: 保留にした日.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
number: StrictInt = Field(ge=1)
reason: str = Field(min_length=1)
resolve: str = Field(min_length=1)
since: date
class HoldFile(BaseModel):
"""保留リストのファイル全体."""
model_config = ConfigDict(extra="forbid", frozen=True)
hold: list[HoldEntry] = []
class Holds(NamedTuple):
"""取り込み元の保留の番号.
Attributes:
numbers: 保留にする番号.
filename: 保留リストのファイル名(リポジトリの `holds/` からの相対.表示用).
"""
numbers: frozenset[int]
filename: str
def default_holds_dir() -> Path:
"""リポジトリの `holds/` フォルダを返す.
リポジトリのルートは `repo.find_repo_root()` で見つける.
Returns:
`holds/` のパス.
Raises:
ValueError: リポジトリのルートが見つからない(パッケージとして別の場所に入れて
実行した等),またはルートに `holds/` が無い.
"""
try:
root = repo.find_repo_root()
except ValueError as e:
# 取り込みの文脈で対処が分かるように,保留リストの場所として言い直す
raise ValueError(
"保留リスト(holds/)の場所が分からない."
"取り込みは TIASDataHub のリポジトリの clone から実行する(uv run tiasdata import …)"
) from e
holds_dir = root / HOLDS_DIRNAME
if not holds_dir.is_dir():
raise ValueError(f"リポジトリに {HOLDS_DIRNAME}/ が無い(保留リストを置くフォルダ)")
return holds_dir
def load_holds(holds_dir: Path, source_name: str) -> Holds:
"""取り込み元の保留リストを読んで検証する.
ファイルが無ければ停止する(設定の取り込み元の名前を誤ると,保留が効かないまま取り込まれる
ため).保留の無い取り込み元には空の保留リスト(`[[hold]]` が 0 件,または空のファイル)を置く.
Args:
holds_dir: 保留リストのフォルダ.
source_name: 取り込み元の名前(設定ファイルの `sources.<名前>`).
Returns:
保留の番号.
Raises:
ValueError: 取り込み元の名前がファイル名に使えない,保留リストのファイルが無い,
TOML として読めない,
形が誤っている(未知のキー・必須キーの欠落・番号が 1 未満等),
または番号が重複している.
"""
if not _SOURCE_NAME_PATTERN.match(source_name):
raise ValueError(
f"取り込み元の名前 '{source_name}' は英数字・_・- だけにする"
"(保留リストのファイル名に使う)"
)
filename = f"{HOLDS_DIRNAME}/{source_name}.toml"
path = holds_dir / f"{source_name}.toml"
if not path.is_file():
raise ValueError(
f"保留リスト {filename} が無い.保留が無い取り込み元は空の保留リストを置く"
)
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
raise ValueError(f"保留リスト {filename} を TOML として読めない: {e}") from e
try:
hold_file = HoldFile.model_validate(data)
except ValidationError as e:
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)
)
raise ValueError(f"保留リスト {filename} の形が誤っている: {details}") from e
numbers = [entry.number for entry in hold_file.hold]
duplicated = sorted({n for n in numbers if numbers.count(n) > 1})
if duplicated:
# 保留リストの番号はファイル名の連番で個人をたどれないため,リストの誤りの特定に表示する
raise ValueError(f"保留リスト {filename} に同じ番号が 2 回以上ある: {duplicated}")
return Holds(numbers=frozenset(numbers), filename=filename)