Newer
Older
TIASJudgeModel / src / tias_judge_model / remote / files.py
"""push で送るファイルの選別と,手元のプロジェクトルートの探索.

リポジトリ内のコード一式を送り,キャッシュ・仮想環境・データ・実験結果・Claude の設定は
送らない(SPEC_03「push」).
"""

from __future__ import annotations

import os
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

# どの階層にあっても送らないディレクトリ名(.venv は Windows 用でサーバでは使えない)
EXCLUDED_DIR_NAMES = frozenset({".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"})
# プロジェクト直下でだけ除外するエントリ.data/ は臨床データ・合成データの置き場,runs/ は
# サーバ側の実験結果を上書きしないため,.claude/ は手元の Claude Code 設定のため送らない.
# 直下に限るのは src/tias_judge_model/data/(パッケージ)・tests/data/ を送るため
EXCLUDED_ROOT_ENTRIES = frozenset({"data", "runs", ".claude"})

_PACKAGE_DIR = Path("src") / "tias_judge_model"


@dataclass(frozen=True)
class PushEntry:
    """送信するファイル 1 件.

    Attributes:
        path: 手元の実ファイル.
        arcname: リモートのプロジェクトルート基準の相対パス(POSIX 区切り).
        size: バイト数.
    """

    path: Path
    arcname: str
    size: int


def find_project_root(start: Path) -> Path:
    """start から上方向にたどり,このリポジトリのルートを探す.

    Args:
        start: 探索を始めるディレクトリ(通常はカレントディレクトリ).

    Returns:
        `pyproject.toml` と `src/tias_judge_model/` を持つディレクトリ.

    Raises:
        ValueError: 見つからない場合.
    """
    start = start.resolve()
    for directory in (start, *start.parents):
        if (directory / "pyproject.toml").is_file() and (directory / _PACKAGE_DIR).is_dir():
            return directory
    raise ValueError(
        f"{start} から上にプロジェクトルート(pyproject.toml と src/tias_judge_model/)が"
        "見つからない.リポジトリ内で実行する"
    )


def is_excluded(rel_path: str) -> bool:
    """プロジェクトルート基準の相対パスが push から除外されるかを返す.

    Args:
        rel_path: POSIX 区切りの相対パス(ファイルでもディレクトリでもよい).

    Returns:
        除外対象なら True.
    """
    parts = PurePosixPath(rel_path).parts
    if not parts:
        return False
    return parts[0] in EXCLUDED_ROOT_ENTRIES or any(part in EXCLUDED_DIR_NAMES for part in parts)


def _walk_files(root: Path) -> Iterator[PushEntry]:
    """root 配下のファイルを,除外規則を適用しつつ決定的な順で列挙する."""
    for dirpath, dirnames, filenames in os.walk(root):
        dir_rel = Path(dirpath).relative_to(root).as_posix()
        prefix = "" if dir_rel == "." else f"{dir_rel}/"
        dirnames[:] = sorted(d for d in dirnames if not is_excluded(prefix + d))
        for filename in sorted(filenames):
            rel = prefix + filename
            path = Path(dirpath) / filename
            if is_excluded(rel) or not path.is_file():
                continue
            yield PushEntry(path=path, arcname=rel, size=path.stat().st_size)


def collect_push_entries(root: Path) -> list[PushEntry]:
    """送信するファイルの一覧を作る.

    Args:
        root: プロジェクトルート.

    Returns:
        arcname 順に並べた送信対象.
    """
    return sorted(_walk_files(root.resolve()), key=lambda entry: entry.arcname)