"""ssh 引数とサーバ上で実行するコマンドの組み立てのテスト(SPEC_03「接続」「取得」「run」)."""
from __future__ import annotations
import pytest
from tias_judge_model.remote.commands import (
build_job_env,
build_job_script,
build_probe_command,
build_ssh_argv,
format_git_commit,
validate_job_name,
)
def _argv(jump: str | None, timeout: int = 15) -> list[str]:
return build_ssh_argv(
"ssh",
user="hasegawa",
address="192.168.23.14",
jump_host=jump,
connect_timeout_sec=timeout,
remote_command="echo hi",
)
def _has_option(argv: list[str], value: str) -> bool:
return any(argv[i] == "-o" and argv[i + 1] == value for i in range(len(argv) - 1))
class TestBuildSshArgv:
def test_with_jump_host(self) -> None:
argv = _argv("labpc")
assert argv[0] == "ssh"
j = argv.index("-J")
assert argv[j + 1] == "labpc"
assert "hasegawa@192.168.23.14" in argv
assert argv[-1] == "echo hi"
# 接続先より前にオプションがある
assert j < argv.index("hasegawa@192.168.23.14")
def test_without_jump_host(self) -> None:
argv = _argv(None)
assert "-J" not in argv
assert "hasegawa@192.168.23.14" in argv
@pytest.mark.parametrize("jump", ["labpc", None])
def test_batch_mode_and_timeout_always(self, jump: str | None) -> None:
argv = _argv(jump, timeout=7)
assert _has_option(argv, "BatchMode=yes")
assert _has_option(argv, "ConnectTimeout=7")
def test_remote_command_is_single_argument(self) -> None:
argv = build_ssh_argv(
"ssh",
user="u",
address="h",
jump_host=None,
connect_timeout_sec=5,
remote_command="cd ~/x; echo a b",
)
assert argv[-1] == "cd ~/x; echo a b"
@pytest.mark.parametrize("bad", ["-oProxyCommand=evil", "a b", ""])
def test_rejects_option_like_destination(self, bad: str) -> None:
with pytest.raises(ValueError):
build_ssh_argv(
"ssh",
user="u",
address=bad,
jump_host=None,
connect_timeout_sec=5,
remote_command="true",
)
class TestBuildProbeCommand:
def test_queries_listed_in_spec(self) -> None:
command = build_probe_command("~/work/x", observe_sec=10, sample_interval_ms=500)
assert "--query-gpu=index,uuid,name,memory.used,memory.total,utilization.gpu" in command
assert "--query-compute-apps=gpu_uuid,pid,used_memory" in command
assert "--format=csv,noheader,nounits" in command
def test_observation_uses_interval_and_duration(self) -> None:
command = build_probe_command("~/work/x", observe_sec=10, sample_interval_ms=500)
assert "-lms 500" in command
assert "timeout 10" in command
assert "pmon -s u" in command
def test_observe_zero_does_not_observe(self) -> None:
command = build_probe_command("~/work/x", observe_sec=0, sample_interval_ms=500)
assert "-lms" not in command
assert "pmon" not in command
assert "--query-gpu=index,uuid,name" in command
def test_is_single_line(self) -> None:
command = build_probe_command("~/work/x", observe_sec=10, sample_interval_ms=500)
assert "\n" not in command
class TestJobSettings:
@pytest.mark.parametrize("name", ["a", "exp1", "Exp_1-b", "x" * 64])
def test_valid_job_names(self, name: str) -> None:
assert validate_job_name(name) == name
@pytest.mark.parametrize("name", ["", "_a", "-a", "a b", "a.b", "a:b", "x" * 65, "a/b"])
def test_invalid_job_names(self, name: str) -> None:
with pytest.raises(ValueError):
validate_job_name(name)
def test_job_env_contains_selected_gpu_and_extra(self) -> None:
env = build_job_env(2, ["FOO=bar", "EMPTY="])
assert env["CUDA_VISIBLE_DEVICES"] == "2"
assert env["FOO"] == "bar"
assert env["EMPTY"] == ""
class TestGitCommit:
"""`TIAS_GIT_COMMIT`(SPEC_03「run」・SPEC_01「実験の出力」)."""
def test_clean_tree_passes_short_hash(self) -> None:
# git rev-parse --short HEAD の出力(末尾改行付き)
assert format_git_commit("abc1234\n", dirty=False) == "abc1234"
def test_dirty_tree_appends_suffix(self) -> None:
assert format_git_commit("abc1234\n", dirty=True) == "abc1234-dirty"
def test_job_env_contains_git_commit(self) -> None:
env = build_job_env(0, [], git_commit="abc1234-dirty")
assert env["TIAS_GIT_COMMIT"] == "abc1234-dirty"
def test_job_env_without_git_commit_does_not_set_it(self) -> None:
env = build_job_env(0, [])
assert "TIAS_GIT_COMMIT" not in env
@pytest.mark.parametrize("git_commit", ["abc1234", None])
def test_env_option_cannot_override_git_commit(self, git_commit: str | None) -> None:
# git が使えず渡さないときも --env で指定させない(実験の記録を迂回させないため)
with pytest.raises(ValueError):
build_job_env(0, ["TIAS_GIT_COMMIT=fake"], git_commit=git_commit)
class TestReservedEnv:
@pytest.mark.parametrize(
"key",
[
"CUDA_VISIBLE_DEVICES",
"TIAS_DATA_ROOT",
"CUDA_DEVICE_ORDER",
"PYTHONUNBUFFERED",
"TIAS_GIT_COMMIT",
],
)
@pytest.mark.parametrize("gpu_index", [0, None])
def test_reserved_keys_cannot_be_overridden(self, key: str, gpu_index: int | None) -> None:
with pytest.raises(ValueError):
build_job_env(gpu_index, [f"{key}=x"], git_commit="abc1234")
def test_fixed_values(self) -> None:
env = build_job_env(3, [], git_commit="abc1234")
assert env["CUDA_DEVICE_ORDER"] == "PCI_BUS_ID"
assert env["PYTHONUNBUFFERED"] == "1"
assert env["CUDA_VISIBLE_DEVICES"] == "3"
class TestCpuJob:
"""`run --cpu`(SPEC_03「run」).
CUDA_VISIBLE_DEVICES を空文字で渡し,ログの開始行の GPU は「なし(CPU)」とする.
"""
def test_cpu_job_env_hides_gpus(self) -> None:
env = build_job_env(None, ["FOO=bar"], git_commit="abc1234")
assert "CUDA_VISIBLE_DEVICES" in env
assert env["CUDA_VISIBLE_DEVICES"] == ""
assert env["CUDA_DEVICE_ORDER"] == "PCI_BUS_ID"
assert env["TIAS_GIT_COMMIT"] == "abc1234"
assert env["FOO"] == "bar"
def _script(self, gpu_index: int | None) -> str:
return build_job_script(
"~/work/TIASJudgeModel",
"survey",
["tias-dataset", "survey", "--config", "configs/newton.yaml"],
uv_path="~/.local/bin/uv",
server_name="Newton",
gpu_index=gpu_index,
env=build_job_env(gpu_index, [], git_commit="abc1234"),
path_env={"TIAS_DATA_ROOT": "~/data/TIASJudgeModel"},
)
def test_cpu_job_log_header_says_no_gpu(self) -> None:
script = self._script(None)
assert "gpu=なし(CPU)" in script
assert "gpu=None" not in script
# 空文字として export する(未設定ではなく空にして GPU を見えなくする)
assert "export CUDA_VISIBLE_DEVICES=''" in script
def test_gpu_job_log_header_has_index(self) -> None:
script = self._script(1)
assert "gpu=1" in script
assert "なし(CPU)" not in script
assert "export CUDA_VISIBLE_DEVICES=1" in script
assert "export TIAS_GIT_COMMIT=abc1234" in script