"""cli/remote.py(sm-remote CLI)の仕様ベーステスト.
仕様出典:
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」「環境変数」
- docs/02_ENV/ENV_02_環境構築手順.md「研究室サーバ編」
- 実装サマリー 仕様 1〜6(push / run / status・logs・attach・kill / pull / 設定の解決 /
サーバ上での実行検出)
テスト方針:
- ssh・tmux・実サーバには依存しない.subprocess.Popen を偽物に差し替え,
「ssh にどんな引数・標準入力を渡したか」と「ssh の終了コードをどう扱うか」を検証する
- ホスト名はテストごとに固定し(dev-pc),CWD はリポジトリ外にする
(このテストを研究室サーバ上で回してもサーバ上検出が誤作動しないように)
"""
from __future__ import annotations
import gzip
import io
import os
import re
import shlex
import subprocess
import tarfile
import types
from pathlib import Path
import pytest
import sm_tias_lightsim.cli.remote as remote_cli
from sm_tias_lightsim.cli.remote import (
DEFAULT_HOST,
DEFAULT_REMOTE_DIR,
DEFAULT_UV,
build_job_env,
default_ssh_executable,
detect_running_on_server,
main,
resolve_settings,
)
# ---------------------------------------------------------------------------
# 偽の ssh(subprocess.Popen)
# ---------------------------------------------------------------------------
class _CapturingPipe(io.BytesIO):
"""ssh の標準入力の代わり.close 時点の内容を captured に残す."""
captured: bytes | None = None
def close(self) -> None:
if not self.closed:
self.captured = self.getvalue()
super().close()
class _InterruptingStdout(io.BytesIO):
"""読むと KeyboardInterrupt(手元の Ctrl+C)を起こす標準出力."""
def read1(self, size: int = -1) -> bytes:
raise KeyboardInterrupt
class FakeProc:
def __init__(
self,
argv: list[str],
kwargs: dict,
*,
rc: int = 0,
stdout: bytes = b"",
finished: bool = False,
interrupt: bool = False,
) -> None:
self.argv = argv
self.kwargs = kwargs
self._rc = rc
self.returncode = rc if finished else None
self.stdin = _CapturingPipe() if kwargs.get("stdin") == subprocess.PIPE else None
if kwargs.get("stdout") == subprocess.PIPE:
self.stdout = _InterruptingStdout(stdout) if interrupt else io.BytesIO(stdout)
else:
self.stdout = None
self.input: bytes | None = None
self.killed = False
def communicate(self, input: bytes | None = None): # noqa: A002
self.input = input
self.returncode = self._rc
return (self.stdout.read() if self.stdout is not None else None), None
def wait(self, timeout: float | None = None) -> int:
self.returncode = self._rc
return self._rc
def poll(self) -> int | None:
return self.returncode
def kill(self) -> None:
self.killed = True
@pytest.fixture
def ssh(monkeypatch):
"""subprocess.Popen を偽物にする.responses に積んだ応答を呼ばれた順に返す."""
state = types.SimpleNamespace(calls=[], responses=[])
def fake_popen(argv, **kwargs):
spec = state.responses.pop(0) if state.responses else {}
proc = FakeProc(list(argv), kwargs, **spec)
state.calls.append(proc)
return proc
monkeypatch.setattr(remote_cli.subprocess, "Popen", fake_popen)
return state
@pytest.fixture(autouse=True)
def _isolated_environment(monkeypatch, tmp_path):
for key in (
"SM_REMOTE_HOST",
"SM_REMOTE_DIR",
"SM_REMOTE_UV",
"SM_REMOTE_VARIANT",
"SM_REMOTE_ALLOW_SELF",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("SM_REMOTE_SSH", "fake-ssh")
monkeypatch.setattr(remote_cli.socket, "gethostname", lambda: "dev-pc")
cwd = tmp_path / "cwd"
cwd.mkdir()
monkeypatch.chdir(cwd)
# ---------------------------------------------------------------------------
# シェルの単語分割の再現
# ---------------------------------------------------------------------------
def _shell_words(text: str) -> list[str]:
lex = shlex.shlex(text, posix=True, punctuation_chars=";&|")
lex.whitespace_split = True
return list(lex)
def _statements(text: str) -> list[list[str]]:
result: list[list[str]] = [[]]
for word in _shell_words(text):
if word == ";":
result.append([])
else:
result[-1].append(word)
return [s for s in result if s]
def _has_sequence(words: list[str], seq: list[str]) -> bool:
n = len(seq)
return any(words[i : i + n] == seq for i in range(len(words) - n + 1))
def _job_script(run_command: str) -> str:
"""build_run_command の出力から,tmux → bash -c に渡るジョブ本体を取り出す."""
words = _shell_words(run_command)
stmt = words[words.index("new-session") :]
if ";" in stmt:
stmt = stmt[: stmt.index(";")]
inner = shlex.split(stmt[-1])
assert inner[:2] == ["bash", "-c"]
return inner[2]
def _exports(script: str) -> dict[str, str]:
result = {}
for stmt in _statements(script):
if stmt[0] == "export" and len(stmt) == 2 and not stmt[1].startswith("PATH="):
key, _, value = stmt[1].partition("=")
result[key] = value
return result
def _run_statement(script: str) -> list[str]:
return next(s for s in _statements(script) if "/dev/null" in s and "tee" in s)
def _tar_bytes(members: dict[str, bytes], *, gz: bool = False) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz" if gz else "w", format=tarfile.PAX_FORMAT) as tar:
for name, data in members.items():
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = 0o644
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
# ---------------------------------------------------------------------------
# 疑似プロジェクト(push 用)
# ---------------------------------------------------------------------------
_PROJECT = {
"pyproject.toml": "[project]\nname='x'\n",
"src/sm_tias_lightsim/__init__.py": "",
"src/sm_tias_lightsim/a.py": "x = 1\n",
"src/sm_tias_lightsim/__pycache__/a.cpython-312.pyc": "pyc",
"tests/test_a.py": "",
"scripts/s.py": "",
".venv/Scripts/python.exe": "exe",
".git/HEAD": "ref",
"output/r/luminance.npy": "npy",
"configs/scene.yaml": "geometry:\n meshes:\n - id: a\n path: meshes/a.stl\n",
"configs/meshes/前側.stl": "solid",
"configs/_tmp_x/s.yaml": "geometry:\n meshes:\n - id: a\n path: C:\\Users\\r\\a.stl\n",
}
_EXPECTED_PUSH = {
"pyproject.toml",
"src/sm_tias_lightsim/__init__.py",
"src/sm_tias_lightsim/a.py",
"tests/test_a.py",
"scripts/s.py",
"configs/scene.yaml",
"configs/meshes/前側.stl",
}
@pytest.fixture
def project(tmp_path) -> Path:
root = tmp_path / "proj"
for rel, content in _PROJECT.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return root
# ---------------------------------------------------------------------------
# 設定の解決(仕様 5)
# ---------------------------------------------------------------------------
class TestResolveSettings:
def test_default_values(self):
assert DEFAULT_HOST == "Newton"
assert DEFAULT_REMOTE_DIR == "~/lab/SmTIAS-LightSim"
assert DEFAULT_UV == "~/.local/bin/uv"
settings = resolve_settings(None, None, None, {})
assert settings.host == "Newton"
assert settings.remote_dir == "~/lab/SmTIAS-LightSim"
assert settings.uv_path == "~/.local/bin/uv"
assert settings.ssh == default_ssh_executable({})
def test_env_overrides_defaults(self):
env = {
"SM_REMOTE_HOST": "Einstein",
"SM_REMOTE_DIR": "/srv/proj",
"SM_REMOTE_UV": "/opt/uv/uv",
"SM_REMOTE_SSH": "/usr/bin/ssh",
}
settings = resolve_settings(None, None, None, env)
assert (settings.host, settings.remote_dir, settings.uv_path, settings.ssh) == (
"Einstein",
"/srv/proj",
"/opt/uv/uv",
"/usr/bin/ssh",
)
def test_args_override_env(self):
env = {"SM_REMOTE_HOST": "Einstein", "SM_REMOTE_DIR": "/srv/a", "SM_REMOTE_UV": "/a/uv"}
settings = resolve_settings("Archimedes", "~/b", "/b/uv", env)
assert (settings.host, settings.remote_dir, settings.uv_path) == (
"Archimedes",
"~/b",
"/b/uv",
)
def test_blank_values_fall_back(self):
env = {"SM_REMOTE_HOST": " ", "SM_REMOTE_DIR": "", "SM_REMOTE_UV": " "}
settings = resolve_settings("", " ", None, env)
assert (settings.host, settings.remote_dir, settings.uv_path) == (
"Newton",
"~/lab/SmTIAS-LightSim",
"~/.local/bin/uv",
)
def test_main_uses_env_then_args_for_host_and_dir(self, ssh, monkeypatch):
monkeypatch.setenv("SM_REMOTE_HOST", "Einstein")
monkeypatch.setenv("SM_REMOTE_DIR", "/srv/env dir")
assert main(["status"]) == 0
argv = ssh.calls[0].argv
assert argv[:2] == ["fake-ssh", "Einstein"]
assert _statements(argv[-1])[0][:2] == ["cd", "/srv/env dir"]
assert main(["status", "--host", "Archimedes", "--remote-dir", "~/x"]) == 0
argv = ssh.calls[1].argv
assert argv[:2] == ["fake-ssh", "Archimedes"]
assert _statements(argv[-1])[0][:2] == ["cd", "$HOME/x"]
class TestDefaultSshExecutable:
def _make_ssh(self, root: Path) -> Path:
exe = root / "System32" / "OpenSSH" / "ssh.exe"
exe.parent.mkdir(parents=True)
exe.write_bytes(b"")
return exe
def test_windows_prefers_system32_openssh(self, tmp_path):
exe = self._make_ssh(tmp_path)
assert default_ssh_executable({"SystemRoot": str(tmp_path)}, "nt") == str(exe)
def test_windows_uppercase_systemroot(self, tmp_path):
exe = self._make_ssh(tmp_path)
assert default_ssh_executable({"SYSTEMROOT": str(tmp_path)}, "nt") == str(exe)
def test_windows_without_openssh_falls_back_to_path(self, tmp_path):
assert default_ssh_executable({"SystemRoot": str(tmp_path)}, "nt") == "ssh"
def test_posix_uses_ssh_on_path(self, tmp_path):
self._make_ssh(tmp_path)
assert default_ssh_executable({"SystemRoot": str(tmp_path)}, "posix") == "ssh"
@pytest.mark.skipif(
os.name != "nt" or not Path(r"C:\Windows\System32\OpenSSH\ssh.exe").is_file(),
reason="Windows 標準の OpenSSH がある環境のみ",
)
def test_real_windows_default(self):
result = default_ssh_executable(os.environ)
assert result.lower() == r"c:\windows\system32\openssh\ssh.exe"
# ---------------------------------------------------------------------------
# サーバ上での実行検出(仕様 6)
# ---------------------------------------------------------------------------
def _detect(**overrides):
kwargs = {
"host": "Newton",
"remote_dir": "~/lab/SmTIAS-LightSim",
"environ": {},
"local_hostname": "dev-pc",
"project_root": None,
"home": None,
"os_name": "posix",
}
kwargs.update(overrides)
host = kwargs.pop("host")
remote_dir = kwargs.pop("remote_dir")
return detect_running_on_server(host, remote_dir, **kwargs)
class TestDetectRunningOnServer:
@pytest.mark.parametrize(
("local", "host"),
[
("Newton", "Newton"),
("newton", "Newton"),
("NEWTON", "Newton"),
("newton.local", "Newton"),
("Newton", "Newton.local"),
("newton.lab.example.ac.jp", "Newton.local"),
(" newton ", "Newton"),
("192.168.23.13", "192.168.23.13"),
],
)
def test_hostname_match(self, local, host):
"""ホスト名は小文字化・ドメイン部除去で比べる(IPv4 はそのまま)."""
reason = _detect(host=host, local_hostname=local)
assert isinstance(reason, str) and reason
@pytest.mark.parametrize(
("local", "host"),
[
("dev-pc", "Newton"),
("Newton2", "Newton"),
("newt", "Newton"),
("192.168.23.13", "192.168.23.15"),
("192", "192.168.23.13"), # IPv4 はドメイン部除去をしない
("192.168.23.13", "192"),
("", "Newton"),
],
)
def test_hostname_mismatch(self, local, host):
assert _detect(host=host, local_hostname=local) is None
def test_hostname_match_applies_on_windows_too(self):
assert _detect(local_hostname="newton", os_name="nt") is not None
def _server_layout(self, tmp_path: Path) -> tuple[Path, Path]:
home = tmp_path / "home"
root = home / "lab" / "SmTIAS-LightSim"
root.mkdir(parents=True)
return home, root
@pytest.mark.parametrize("remote_dir", ["~/lab/SmTIAS-LightSim", "lab/SmTIAS-LightSim"])
def test_posix_repo_root_match(self, tmp_path, remote_dir):
"""POSIX でリポジトリのルートが remote_dir(~ 展開・ホーム基準)と一致すれば検出."""
home, root = self._server_layout(tmp_path)
assert _detect(remote_dir=remote_dir, project_root=root, home=home) is not None
def test_posix_absolute_remote_dir_match(self, tmp_path):
home, root = self._server_layout(tmp_path)
assert _detect(remote_dir=str(root), project_root=root, home=home) is not None
def test_posix_repo_root_mismatch(self, tmp_path):
home, _ = self._server_layout(tmp_path)
other = tmp_path / "work" / "SmTIAS-LightSim"
other.mkdir(parents=True)
assert _detect(project_root=other, home=home) is None
def test_windows_does_not_compare_directories(self, tmp_path):
"""Windows ではディレクトリを比べない(手元の PC を誤検出しない)."""
home, root = self._server_layout(tmp_path)
assert _detect(project_root=root, home=home, os_name="nt") is None
def test_unknown_root_or_home_is_not_detected(self, tmp_path):
home, root = self._server_layout(tmp_path)
assert _detect(project_root=None, home=home) is None
assert _detect(project_root=root, home=None) is None
def test_tilde_user_remote_dir_does_not_crash(self, tmp_path):
home, root = self._server_layout(tmp_path)
assert _detect(remote_dir="~other/lab", project_root=root, home=home) is None
@pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE", " Yes "])
def test_allow_self_disables_detection(self, tmp_path, value):
home, root = self._server_layout(tmp_path)
environ = {"SM_REMOTE_ALLOW_SELF": value}
assert (
_detect(environ=environ, local_hostname="newton", project_root=root, home=home) is None
)
@pytest.mark.parametrize("value", ["", "0", "no", "false", "off"])
def test_other_allow_self_values_keep_detection(self, value):
environ = {"SM_REMOTE_ALLOW_SELF": value}
assert _detect(environ=environ, local_hostname="newton") is not None
class TestMainStopsOnServer:
@pytest.mark.parametrize(
"argv",
[
["status"],
["logs", "job1"],
["logs", "job1", "-f"],
["attach", "job1"],
["kill", "job1"],
["run", "--name", "job1", "--", "echo", "hi"],
["push", "--dry-run", "--local-dir", "{project}"],
["pull", "foo", "--dest", "{dest}"],
],
)
def test_exits_1_before_starting_ssh(self, argv, ssh, monkeypatch, capsys, project, tmp_path):
"""サーバ上なら ssh を起動する前に案内を出して終了コード 1 で止まること."""
monkeypatch.setattr(remote_cli.socket, "gethostname", lambda: "newton.local")
argv = [a.format(project=project, dest=tmp_path / "dest") for a in argv]
assert main(argv) == 1
assert ssh.calls == []
captured = capsys.readouterr()
assert "SM_REMOTE_ALLOW_SELF" in captured.err
assert "送信対象" not in captured.out
def test_allow_self_lets_command_run(self, ssh, monkeypatch):
monkeypatch.setattr(remote_cli.socket, "gethostname", lambda: "newton")
monkeypatch.setenv("SM_REMOTE_ALLOW_SELF", "1")
assert main(["status"]) == 0
assert len(ssh.calls) == 1
# ---------------------------------------------------------------------------
# run(仕様 2)
# ---------------------------------------------------------------------------
class TestBuildJobEnv:
def test_minimal_env_is_unbuffered_python(self):
assert build_job_env(gpu=None, variant=None, threads=None, extra=[]) == {
"PYTHONUNBUFFERED": "1"
}
def test_all_options(self):
env = build_job_env(gpu="0,2", variant="cuda_ad_rgb", threads=16, extra=["FOO=bar"])
assert env == {
"PYTHONUNBUFFERED": "1",
"CUDA_VISIBLE_DEVICES": "0,2",
"SM_RENDER_VARIANT": "cuda_ad_rgb",
"SM_DRJIT_THREADS": "16",
"FOO": "bar",
}
def test_extra_is_last_wins(self):
env = build_job_env(gpu=None, variant=None, threads=None, extra=["A=1", "A=2"])
assert env["A"] == "2"
@pytest.mark.parametrize(
"kwargs",
[
{"gpu": "0;rm"},
{"gpu": ""},
{"threads": 0},
{"threads": -4},
{"extra": ["NOEQUALS"]},
{"extra": ["1BAD=x"]},
],
)
def test_invalid_values_raise(self, kwargs):
base = {"gpu": None, "variant": None, "threads": None, "extra": []}
base.update(kwargs)
with pytest.raises(ValueError):
build_job_env(**base)
def _dry_run_script(capsys, *args: str) -> tuple[str, str]:
"""run --dry-run を実行し,(表示されたリモートコマンド, ジョブ本体) を返す."""
argv = ["run", "--name", "offaxis", "--dry-run", *args]
if "--" not in args:
argv += ["--", "python", "scripts/sweep_offaxis_led.py"]
assert main(argv) == 0
remote_command = capsys.readouterr().out.strip()
return remote_command, _job_script(remote_command)
class TestRunCommand:
def test_dry_run_does_not_start_ssh(self, ssh, capsys):
remote_command, _ = _dry_run_script(capsys)
assert ssh.calls == []
assert "new-session" in remote_command
def test_default_env(self, capsys):
"""既定で SM_RENDER_VARIANT=cuda_ad_rgb と PYTHONUNBUFFERED=1 を渡すこと."""
_, script = _dry_run_script(capsys)
env = _exports(script)
assert env["SM_RENDER_VARIANT"] == "cuda_ad_rgb"
assert env["PYTHONUNBUFFERED"] == "1"
assert "CUDA_VISIBLE_DEVICES" not in env
assert "SM_DRJIT_THREADS" not in env
def test_yaml_variant_does_not_set_variant(self, capsys):
remote_command, script = _dry_run_script(capsys, "--yaml-variant")
assert "SM_RENDER_VARIANT" not in _exports(script)
assert "SM_RENDER_VARIANT" not in remote_command
def test_gpu_and_threads(self, capsys):
_, script = _dry_run_script(capsys, "--gpu", "0,2", "--threads", "16")
env = _exports(script)
assert env["CUDA_VISIBLE_DEVICES"] == "0,2"
assert env["SM_DRJIT_THREADS"] == "16"
def test_variant_precedence(self, capsys, monkeypatch):
monkeypatch.setenv("SM_REMOTE_VARIANT", "llvm_ad_rgb")
_, script = _dry_run_script(capsys)
assert _exports(script)["SM_RENDER_VARIANT"] == "llvm_ad_rgb"
_, script = _dry_run_script(capsys, "--variant", "scalar_rgb")
assert _exports(script)["SM_RENDER_VARIANT"] == "scalar_rgb"
_, script = _dry_run_script(capsys, "--variant", "scalar_rgb", "--yaml-variant")
assert "SM_RENDER_VARIANT" not in _exports(script)
def test_extra_env(self, capsys):
_, script = _dry_run_script(capsys, "--env", "FOO=a b", "--env", "BAR=")
env = _exports(script)
assert env["FOO"] == "a b"
assert env["BAR"] == ""
def test_command_after_double_dash_survives_quoting(self, capsys):
command = ["python", "-c", "print('a b'); import os", "--x=$(id)", "it's"]
_, script = _dry_run_script(capsys, "--", *command)
run = _run_statement(script)
assert run[: 2 + len(command)] == ["$HOME/.local/bin/uv", "run", *command]
def test_uv_path_from_env_and_arg(self, capsys, monkeypatch):
monkeypatch.setenv("SM_REMOTE_UV", "/opt/uv/uv")
_, script = _dry_run_script(capsys)
assert _run_statement(script)[:2] == ["/opt/uv/uv", "run"]
_, script = _dry_run_script(capsys, "--uv", "/usr/local/bin/uv")
assert _run_statement(script)[:2] == ["/usr/local/bin/uv", "run"]
def test_no_uv(self, capsys):
_, script = _dry_run_script(capsys, "--no-uv", "--", "nvidia-smi")
assert _run_statement(script)[0] == "nvidia-smi"
def test_log_and_remote_dir(self, capsys):
remote_command, script = _dry_run_script(capsys, "--remote-dir", "~/lab/X")
statements = _statements(script)
assert statements[0][:2] == ["cd", "$HOME/lab/X"]
assert ["log=output/_remote_logs/offaxis.log"] in statements
assert _has_sequence(_run_statement(script), ["<", "/dev/null"])
assert _statements(remote_command)[0][:2] == ["cd", "$HOME/lab/X"]
def test_default_job_name_is_timestamp(self, capsys):
assert main(["run", "--dry-run", "--", "echo"]) == 0
script = _job_script(capsys.readouterr().out.strip())
logs = [s[0] for s in _statements(script) if s[0].startswith("log=")]
assert re.fullmatch(r"log=output/_remote_logs/job-\d{8}-\d{6}\.log", logs[0])
@pytest.mark.parametrize(
"argv",
[
["run", "--name", "x"], # コマンドなし
["run", "--name", "x", "--"],
["run", "--name", "bad name", "--", "echo"],
["run", "--name", "a.b", "--", "echo"],
["run", "--name", "x", "--gpu", "0;rm", "--", "echo"],
["run", "--name", "x", "--gpu", "gpu0", "--", "echo"],
["run", "--name", "x", "--threads", "0", "--", "echo"],
["run", "--name", "x", "--env", "BAD", "--", "echo"],
],
)
def test_invalid_input_exits_1_without_ssh(self, argv, ssh, capsys):
assert main(argv) == 1
assert ssh.calls == []
assert "エラー" in capsys.readouterr().err
def test_launch_success(self, ssh, capsys):
ssh.responses.append({"rc": 0})
rc = main(["run", "--name", "offaxis", "--gpu", "0", "--", "python", "a.py"])
assert rc == 0
proc = ssh.calls[0]
assert proc.argv[:2] == ["fake-ssh", "Newton"]
assert len(proc.argv) == 3
assert proc.kwargs["stdin"] == subprocess.DEVNULL
assert _exports(_job_script(proc.argv[-1]))["CUDA_VISIBLE_DEVICES"] == "0"
out = capsys.readouterr().out
assert "offaxis" in out
assert "output/_remote_logs/offaxis.log" in out
def test_existing_session_returns_91(self, ssh, capsys):
ssh.responses.append({"rc": 91})
assert main(["run", "--name", "offaxis", "--", "echo"]) == 91
err = capsys.readouterr().err
assert "offaxis" in err
assert "実行中" in err
def test_ssh_failure_is_reported(self, ssh, capsys):
ssh.responses.append({"rc": 255})
assert main(["run", "--name", "offaxis", "--", "echo"]) == 255
assert "ssh" in capsys.readouterr().err
def test_missing_remote_dir_is_reported(self, ssh, capsys):
ssh.responses.append({"rc": 90})
assert main(["run", "--name", "offaxis", "--", "echo"]) == 90
assert "push" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# status / logs / attach / kill(仕様 3)
# ---------------------------------------------------------------------------
class TestStatus:
def test_prints_remote_output(self, ssh, capsys):
ssh.responses.append({"rc": 0, "stdout": "== tmux sessions ==\n(none)\n日本語\n".encode()})
assert main(["status"]) == 0
assert "(none)" in capsys.readouterr().out
words = _shell_words(ssh.calls[0].argv[-1])
assert "nvidia-smi" in words
assert ssh.calls[0].kwargs["stdin"] == subprocess.DEVNULL
def test_missing_remote_dir(self, ssh, capsys):
ssh.responses.append({"rc": 90})
assert main(["status"]) == 90
assert "push" in capsys.readouterr().err
class TestLogs:
def test_tail_n_lines(self, ssh, capsys):
ssh.responses.append({"rc": 0, "stdout": b"line1\nline2\n"})
assert main(["logs", "offaxis", "-n", "7"]) == 0
assert _statements(ssh.calls[0].argv[-1])[-1] == [
"tail",
"-n",
"7",
"output/_remote_logs/offaxis.log",
]
assert "line2" in capsys.readouterr().out
def test_missing_log_returns_92(self, ssh, capsys):
ssh.responses.append({"rc": 92})
assert main(["logs", "offaxis"]) == 92
assert "offaxis" in capsys.readouterr().err
@pytest.mark.parametrize("argv", [["logs", "offaxis", "-n", "-1"], ["logs", "a;b"]])
def test_invalid_input_exits_1(self, argv, ssh):
assert main(argv) == 1
assert ssh.calls == []
def test_follow_streams_and_closes_stdin(self, ssh, capsys):
"""-f はリモートの出力を逐次表示し,終了時に ssh の標準入力を閉じる(tail を止める)."""
ssh.responses.append({"rc": 0, "stdout": b"start\n 50%\r100%\n"})
assert main(["logs", "offaxis", "-f"]) == 0
proc = ssh.calls[0]
assert proc.kwargs["stdin"] == subprocess.PIPE
assert proc.stdin.closed
assert "100%" in capsys.readouterr().out
assert "-F" in _shell_words(proc.argv[-1])
def test_follow_ctrl_c_returns_0_and_stops_remote_tail(self, ssh, capsys):
"""Ctrl+C は追従の正常な終わらせ方(0).標準入力を閉じてリモートの tail を止める."""
ssh.responses.append({"rc": 255, "interrupt": True})
assert main(["logs", "offaxis", "-f"]) == 0
assert ssh.calls[0].stdin.closed
assert "ジョブは継続" in capsys.readouterr().err
class TestAttach:
def test_ended_job_returns_93_with_last_line(self, ssh, capsys):
ssh.responses.append({"rc": 93, "stdout": b"=== [2026-09-13 12:00:00] exit 0\n"})
assert main(["attach", "offaxis"]) == 93
assert len(ssh.calls) == 1 # tmux には接続しない
out = capsys.readouterr().out
assert "既に終了しています" in out
assert "exit 0" in out
def test_unknown_job_returns_92(self, ssh, capsys):
ssh.responses.append({"rc": 92})
assert main(["attach", "offaxis"]) == 92
assert len(ssh.calls) == 1
assert "offaxis" in capsys.readouterr().err
def test_missing_remote_dir_returns_90(self, ssh, capsys):
ssh.responses.append({"rc": 90})
assert main(["attach", "offaxis"]) == 90
assert len(ssh.calls) == 1
assert "push" in capsys.readouterr().err
def test_running_job_attaches_with_tty(self, ssh):
ssh.responses += [{"rc": 0}, {"rc": 0}]
assert main(["attach", "offaxis"]) == 0
assert len(ssh.calls) == 2
attach = ssh.calls[1].argv
assert attach[:3] == ["fake-ssh", "-t", "Newton"]
assert shlex.split(attach[-1]) == ["tmux", "attach-session", "-t", "=offaxis"]
def test_invalid_name(self, ssh):
assert main(["attach", "a b"]) == 1
assert ssh.calls == []
class TestKill:
def test_success(self, ssh, capsys):
ssh.responses.append({"rc": 0})
assert main(["kill", "offaxis"]) == 0
assert "offaxis" in capsys.readouterr().out
assert _has_sequence(
_shell_words(ssh.calls[0].argv[-1]), ["kill-session", "-t", "=offaxis"]
)
def test_not_running_returns_93(self, ssh, capsys):
ssh.responses.append({"rc": 93})
assert main(["kill", "offaxis"]) == 93
assert "offaxis" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# pull(仕様 4)
# ---------------------------------------------------------------------------
class TestPull:
@pytest.mark.parametrize("spec", ["foo", "output/foo", "output\\foo"])
def test_accepts_all_spellings_and_extracts(self, spec, ssh, tmp_path):
dest = tmp_path / "dest"
ssh.responses.append({"rc": 0, "stdout": _tar_bytes({"output/foo/a.txt": b"hello"})})
assert main(["pull", spec, "--dest", str(dest)]) == 0
assert (dest / "output/foo/a.txt").read_bytes() == b"hello"
proc = ssh.calls[0]
assert proc.kwargs["stdin"] == subprocess.DEVNULL
assert _statements(proc.argv[-1])[-1] == ["tar", "-cf", "-", "output/foo"]
def test_compress(self, ssh, tmp_path):
dest = tmp_path / "dest"
payload = _tar_bytes({"output/foo/前側.npy": b"\x00" * 100}, gz=True)
ssh.responses.append({"rc": 0, "stdout": payload})
assert main(["pull", "foo", "--compress", "--dest", str(dest)]) == 0
assert (dest / "output/foo/前側.npy").read_bytes() == b"\x00" * 100
assert _statements(ssh.calls[0].argv[-1])[-1] == ["tar", "-czf", "-", "output/foo"]
def test_multiple_paths_are_deduplicated(self, ssh, tmp_path):
payload = _tar_bytes({"output/foo/a": b"1", "output/bar/b": b"2"})
ssh.responses.append({"rc": 0, "stdout": payload})
assert main(["pull", "foo", "output/foo", "bar", "--dest", str(tmp_path / "d")]) == 0
assert _statements(ssh.calls[0].argv[-1])[-1] == [
"tar",
"-cf",
"-",
"output/foo",
"output/bar",
]
@pytest.mark.parametrize("spec", ["../src", "output", "/etc", "C:\\x", "x/../../y"])
def test_rejects_paths_outside_output(self, spec, ssh, tmp_path, capsys):
assert main(["pull", spec, "--dest", str(tmp_path / "d")]) == 1
assert ssh.calls == []
assert "エラー" in capsys.readouterr().err
def test_unrequested_member_is_rejected(self, ssh, tmp_path, capsys):
"""要求したパス以外のメンバーが届いたら展開を中止し,失敗を返すこと."""
dest = tmp_path / "dest"
payload = _tar_bytes({"output/foo/a.txt": b"ok", "src/sm_tias_lightsim/evil.py": b"x"})
ssh.responses.append({"rc": 0, "stdout": payload})
assert main(["pull", "foo", "--dest", str(dest)]) == 1
assert not (dest / "src").exists()
assert "エラー" in capsys.readouterr().err
def test_remote_path_missing_returns_92(self, ssh, tmp_path, capsys):
ssh.responses.append({"rc": 92, "stdout": b"", "finished": True})
assert main(["pull", "nope", "--dest", str(tmp_path / "d")]) == 92
assert "サーバにありません" in capsys.readouterr().err
def test_ssh_failure_returns_255(self, ssh, tmp_path, capsys):
ssh.responses.append({"rc": 255, "stdout": b"", "finished": True})
assert main(["pull", "foo", "--dest", str(tmp_path / "d")]) == 255
assert "ssh" in capsys.readouterr().err
def test_tar_warning_rc1_still_succeeds(self, ssh, tmp_path, capsys):
"""GNU tar の終了コード 1(読み取り中に変化)は警告にとどめ,取得は成功とする."""
dest = tmp_path / "dest"
ssh.responses.append({"rc": 1, "stdout": _tar_bytes({"output/foo/a": b"x"})})
assert main(["pull", "foo", "--dest", str(dest)]) == 0
assert (dest / "output/foo/a").is_file()
assert "警告" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# push(仕様 1)
# ---------------------------------------------------------------------------
def _tar_names(data: bytes) -> list[str]:
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
with tarfile.open(fileobj=io.BytesIO(data), mode="r:", encoding="utf-8") as tar:
assert tar.format == tarfile.PAX_FORMAT
return tar.getnames()
class TestPush:
def test_dry_run_lists_without_ssh(self, ssh, capsys, project):
assert main(["push", "--dry-run", "--local-dir", str(project)]) == 0
assert ssh.calls == []
out = capsys.readouterr().out
assert "送信対象: 7 ファイル" in out
assert "src/sm_tias_lightsim/a.py" in out
assert ".venv" not in out
assert "output/r" not in out
assert "--dry-run" in out
def test_windows_path_yaml_is_warned(self, ssh, capsys, project):
assert (
main(["push", "--dry-run", "--local-dir", str(project), "--include", "configs/_tmp_x"])
== 0
)
out = capsys.readouterr().out
assert "警告" in out
assert "configs/_tmp_x/s.yaml" in out
def test_no_warning_for_portable_yaml(self, ssh, capsys, project):
assert main(["push", "--dry-run", "--local-dir", str(project)]) == 0
assert "警告" not in capsys.readouterr().out
@pytest.mark.parametrize(
"extra",
[
["--include", "configs/_tmp_x", "--only", "configs/_tmp_x"],
["--prune", "--only", "configs/_tmp_x"],
["--only", "output/r"],
["--include", "output"],
["--only", "configs/_tmp_missing"],
],
)
def test_invalid_options_exit_1(self, extra, ssh, capsys, project):
assert main(["push", "--local-dir", str(project), *extra]) == 1
assert ssh.calls == []
assert "エラー" in capsys.readouterr().err
def test_streams_tar_to_remote_tar_x(self, ssh, capsys, project):
ssh.responses.append({"rc": 0})
assert main(["push", "--local-dir", str(project)]) == 0
proc = ssh.calls[0]
assert proc.argv[:2] == ["fake-ssh", "Newton"]
assert proc.kwargs["stdin"] == subprocess.PIPE
assert proc.stdin.captured is not None
assert set(_tar_names(proc.stdin.captured)) == _EXPECTED_PUSH
words = _shell_words(proc.argv[-1])
assert _has_sequence(words, ["tar", "-xf", "-", "-C", "$HOME/lab/SmTIAS-LightSim"])
assert "送信完了" in capsys.readouterr().out
def test_only_streams_just_the_path(self, ssh, project):
ssh.responses.append({"rc": 0})
assert main(["push", "--local-dir", str(project), "--only", "configs/_tmp_x"]) == 0
assert _tar_names(ssh.calls[0].stdin.captured) == ["configs/_tmp_x/s.yaml"]
def test_compress(self, ssh, project):
ssh.responses.append({"rc": 0})
assert main(["push", "--local-dir", str(project), "--compress"]) == 0
proc = ssh.calls[0]
assert proc.stdin.captured[:2] == b"\x1f\x8b"
assert set(_tar_names(proc.stdin.captured)) == _EXPECTED_PUSH
assert "-xzf" in _shell_words(proc.argv[-1])
def test_ssh_failure_is_returned(self, ssh, capsys, project):
ssh.responses.append({"rc": 255})
assert main(["push", "--local-dir", str(project)]) == 255
assert "ssh" in capsys.readouterr().err
def test_prune_removes_only_stale_code_files(self, ssh, project):
"""--prune はサーバの src/ tests/ scripts/ にあって手元に無いファイルだけを消す."""
remote_files = [
"src/sm_tias_lightsim/__init__.py",
"src/sm_tias_lightsim/a.py",
"src/sm_tias_lightsim/old.py",
"src/sm_tias_lightsim/__pycache__/old.cpython-312.pyc",
"tests/test_old.py",
"scripts/s.py",
"scripts/gone.py",
]
ssh.responses += [
{"rc": 0},
{"rc": 0, "stdout": b"".join(f.encode() + b"\0" for f in remote_files)},
{"rc": 0},
]
assert main(["push", "--local-dir", str(project), "--prune"]) == 0
assert len(ssh.calls) == 3
listing = _shell_words(ssh.calls[1].argv[-1])
assert _has_sequence(listing, ["in", "src", "tests", "scripts"])
assert "output" not in listing
assert "configs" not in listing
remove = ssh.calls[2]
assert _has_sequence(_shell_words(remove.argv[-1]), ["xargs", "-0", "rm", "-f", "--"])
assert remove.input == (
b"scripts/gone.py\0src/sm_tias_lightsim/old.py\0tests/test_old.py\0"
)
def test_prune_with_nothing_stale(self, ssh, capsys, project):
ssh.responses += [{"rc": 0}, {"rc": 0, "stdout": b"src/sm_tias_lightsim/a.py\0"}]
assert main(["push", "--local-dir", str(project), "--prune"]) == 0
assert len(ssh.calls) == 2
assert "削除対象はありません" in capsys.readouterr().out
def test_prune_dry_run_lists_targets_without_deleting(self, ssh, capsys, project):
ssh.responses.append({"rc": 0, "stdout": b"tests/test_old.py\0"})
assert main(["push", "--local-dir", str(project), "--prune", "--dry-run"]) == 0
assert len(ssh.calls) == 1 # 一覧の取得だけ(送信・削除はしない)
assert "tests/test_old.py" in capsys.readouterr().out
@pytest.mark.parametrize(
"listing",
[
b"src/x.py\0output/r/luminance.npy\0",
b"src/x.py\0configs/_tmp_x/s.yaml\0",
b"configs/scene.yaml\0output/_remote_logs/a.log\0",
b"src/../output/x\0",
b"/etc/passwd\0",
],
)
def test_prune_aborts_cleanly_on_unexpected_remote_paths(self, ssh, capsys, project, listing):
"""一覧に output/・configs/ 等のパスが紛れたら,削除コマンドを送らずに中止すること.
compute_prune_targets が RuntimeError を出す状況でも,トレースバックにせず
「削除を中止しました」旨を stderr に出して終了コード 1 で終わる.
(main が例外を送出した場合はこのテスト自体がエラーになる=不合格)
"""
ssh.responses += [{"rc": 0}, {"rc": 0, "stdout": listing}, {"rc": 0}]
rc = main(["push", "--local-dir", str(project), "--prune"])
assert rc == 1
assert len(ssh.calls) == 2 # 送信と一覧取得だけ.削除(xargs rm)は送らない
assert all("xargs" not in _shell_words(c.argv[-1]) for c in ssh.calls)
assert "削除を中止" in capsys.readouterr().err
def test_prune_dry_run_aborts_cleanly_on_unexpected_remote_paths(self, ssh, capsys, project):
"""--dry-run --prune でも同じく,トレースバックにせず中止を伝えて 1 を返すこと."""
ssh.responses.append({"rc": 0, "stdout": b"src/x.py\0output/r/luminance.npy\0"})
rc = main(["push", "--local-dir", str(project), "--prune", "--dry-run"])
assert rc == 1
assert len(ssh.calls) == 1
assert "削除を中止" in capsys.readouterr().err