Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / remote / files.py
"""push で送るファイルの選定(除外規則・追加指定・サーバ側の削除対象の算出).

既定では,Windows 専用の仮想環境・計算結果・キャッシュ・スイープの一時生成物を除いた
プロジェクト全体を送る.gitignore 対象でも実測参照データ(lightsim/・reference_measurement/・
REFERENCE/)はサーバでの評価に要るので送る.
"""

from __future__ import annotations

import fnmatch
import os
import re
from collections.abc import Callable, Collection, Iterable, Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

# どの階層にあっても送らないディレクトリ名(.venv は Windows 用でサーバでは使えない)
EXCLUDED_DIR_NAMES = frozenset({".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"})
# プロジェクト直下でだけ除外するエントリ(サーバ側の計算結果を上書きしないため output/ は送らない)
EXCLUDED_ROOT_ENTRIES = frozenset({"output"})
# プロジェクトルート基準のパターン(スイープ生成物.約 450 MB あり必要時だけ --include で送る)
EXCLUDED_GLOBS = ("configs/_tmp_*",)
# push --prune でサーバ側の古いファイルを消してよいディレクトリ(コードのみ.output/・configs/ は
# サーバ側で生成されたファイルがありうるので対象外)
PRUNE_DIRS = ("src", "tests", "scripts")

# シーン YAML の `path: C:\...` / `- path: 'D:/...'` のような Windows 絶対パス
_WINDOWS_ABS_PATH_RE = re.compile(r"^\s*(?:-\s*)?path:\s*['\"]?[A-Za-z]:[\\/]", re.MULTILINE)


@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 から上方向にたどり,SmTIAS-LightSim のプロジェクトルートを探す.

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

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

    Raises:
        ValueError: 見つからない場合.
    """
    start = start.resolve()
    for directory in (start, *start.parents):
        if (directory / "pyproject.toml").is_file() and (
            directory / "src" / "sm_tias_lightsim"
        ).is_dir():
            return directory
    msg = (
        f"{start} から上にプロジェクトルート(pyproject.toml と src/sm_tias_lightsim/)が"
        "見つかりません.リポジトリ内で実行するか --local-dir / --dest で指定してください."
    )
    raise ValueError(msg)


def _is_cache_path(rel_path: str) -> bool:
    """パスのどこかに除外ディレクトリ名(キャッシュ・.git 等)を含むか."""
    return any(part in EXCLUDED_DIR_NAMES for part in PurePosixPath(rel_path).parts)


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

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

    Returns:
        除外対象なら True.
    """
    parts = PurePosixPath(rel_path).parts
    if not parts:
        return False
    if _is_cache_path(rel_path) or parts[0] in EXCLUDED_ROOT_ENTRIES:
        return True
    prefixes = ("/".join(parts[:i]) for i in range(1, len(parts) + 1))
    return any(
        fnmatch.fnmatchcase(prefix, pattern) for prefix in prefixes for pattern in EXCLUDED_GLOBS
    )


def _walk_files(root: Path, start: Path, skip: Callable[[str], bool]) -> Iterator[PushEntry]:
    """start 配下のファイルを,skip で除外しつつ決定的な順で列挙する."""
    for dirpath, dirnames, filenames in os.walk(start):
        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 skip(prefix + d))
        for filename in sorted(filenames):
            rel = prefix + filename
            path = Path(dirpath) / filename
            if skip(rel) or not path.is_file():
                continue
            yield PushEntry(path=path, arcname=rel, size=path.stat().st_size)


def resolve_extra_path(root: Path, text: str) -> tuple[Path, str]:
    """--include / --only で指定されたパスを検証し,実体と相対パスを返す.

    Args:
        root: プロジェクトルート(解決済み).
        text: 指定されたパス(相対ならプロジェクトルート基準).

    Returns:
        (実体の絶対パス, プロジェクトルート基準の POSIX 相対パス).

    Raises:
        ValueError: プロジェクト外・プロジェクト全体・output/ 配下・存在しない場合.
    """
    candidate = Path(text)
    if not candidate.is_absolute():
        candidate = root / candidate
    resolved = candidate.resolve()
    try:
        rel = resolved.relative_to(root)
    except ValueError as exc:
        raise ValueError(f"プロジェクト外のパスは送れません: {text}") from exc
    if not rel.parts:
        msg = "プロジェクト全体は --include / --only ではなく通常の push で送ってください."
        raise ValueError(msg)
    if rel.parts[0] in EXCLUDED_ROOT_ENTRIES:
        msg = f"output/ 配下は push できません(サーバ側の計算結果を上書きしうるため): {text}"
        raise ValueError(msg)
    if not resolved.exists():
        raise ValueError(f"パスが存在しません: {text}")
    return resolved, rel.as_posix()


def collect_push_entries(
    root: Path, *, include_base: bool, extra_paths: Sequence[str] = ()
) -> list[PushEntry]:
    """送信するファイルの一覧を作る.

    Args:
        root: プロジェクトルート.
        include_base: True なら既定の除外規則を適用したプロジェクト全体を含める.
        extra_paths: 追加で送るパス(configs/_tmp_* 等).既定の除外規則のうち
            グロブ(configs/_tmp_*)は適用しないが,キャッシュ類は除く.

    Returns:
        arcname 順に並べた送信対象(同じ arcname は 1 件にまとめる).

    Raises:
        ValueError: extra_paths に不正なパスがある場合.
    """
    root = root.resolve()
    entries: dict[str, PushEntry] = {}
    if include_base:
        for entry in _walk_files(root, root, is_excluded):
            entries[entry.arcname] = entry
    for text in extra_paths:
        path, rel = resolve_extra_path(root, text)
        if path.is_file():
            entries[rel] = PushEntry(path=path, arcname=rel, size=path.stat().st_size)
            continue
        for entry in _walk_files(root, path, _is_cache_path):
            entries[entry.arcname] = entry
    return [entries[name] for name in sorted(entries)]


def find_yaml_with_windows_paths(entries: Iterable[PushEntry]) -> list[str]:
    """メッシュ等の path に Windows の絶対パスを含む YAML を返す(サーバでは読めない).

    configs/_tmp_*/ の古い生成物は絶対パスで書かれているため,送る前に警告する目的.
    読めないファイルは判定対象外にする(警告用の補助チェックで,送信自体は妨げない).

    Args:
        entries: 送信対象.

    Returns:
        該当する YAML の arcname 一覧.
    """
    hits: list[str] = []
    for entry in entries:
        if not entry.arcname.endswith((".yaml", ".yml")):
            continue
        try:
            text = entry.path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        if _WINDOWS_ABS_PATH_RE.search(text):
            hits.append(entry.arcname)
    return hits


def compute_prune_targets(
    remote_files: Iterable[str],
    local_arcnames: Collection[str],
    prune_dirs: Sequence[str] = PRUNE_DIRS,
) -> list[str]:
    """サーバにあって手元に無い(=削除・改名された)ファイルを求める.

    Args:
        remote_files: サーバの prune_dirs 配下のファイル一覧(プロジェクトルート基準).
        local_arcnames: 手元から送ったファイルの arcname 集合.
        prune_dirs: 削除してよいディレクトリ.

    Returns:
        削除対象の相対パス(ソート済み).キャッシュ類は含めない.

    Raises:
        RuntimeError: prune_dirs 外・絶対パス・`..` を含むパスを受け取った場合
            (リモートの一覧コマンドの不変条件違反.誤削除を防ぐため中断する).
    """
    local = set(local_arcnames)
    targets: list[str] = []
    for rel in remote_files:
        path = PurePosixPath(rel)
        if (
            path.is_absolute()
            or ".." in path.parts
            or len(path.parts) < 2
            or path.parts[0] not in prune_dirs
        ):
            raise RuntimeError(f"想定外のリモートパスを受信しました(削除を中止): {rel!r}")
        normalized = path.as_posix()
        if _is_cache_path(normalized) or normalized in local:
            continue
        targets.append(normalized)
    return sorted(targets)