"""remote/commands.py の仕様ベーステスト.
仕様出典:
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
- docs/02_ENV/ENV_02_環境構築手順.md「研究室サーバ編」
- 実装サマリー(sm-remote 仕様 1〜5)
テスト方針:
- ssh・tmux・実サーバには依存しない(コマンド文字列の組み立てだけを検証する)
- 「リモートの POSIX シェルがどう単語分割するか」を shlex(posix モード)で再現し,
ユーザー由来の値(パス・コマンド引数・環境変数値)が 1 語のまま届くことを確かめる
"""
from __future__ import annotations
import shlex
import pytest
from sm_tias_lightsim.remote import commands as cmd
REMOTE_DIR = "~/lab/SmTIAS-LightSim"
REMOTE_DIR_EXPANDED = "$HOME/lab/SmTIAS-LightSim"
UV = "~/.local/bin/uv"
# ---------------------------------------------------------------------------
# ヘルパ(シェルの単語分割の再現)
# ---------------------------------------------------------------------------
def _shell_words(text: str) -> list[str]:
"""POSIX シェル相当の単語分割(; & | は区切り記号として別トークンにする)."""
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:
"""words に seq が連続して現れるか."""
n = len(seq)
return any(words[i : i + n] == seq for i in range(len(words) - n + 1))
def _index_of_sequence(words: list[str], seq: list[str]) -> int:
n = len(seq)
for i in range(len(words) - n + 1):
if words[i : i + n] == seq:
return i
raise AssertionError(f"{seq} が見つかりません: {words}")
def _job_script(**overrides) -> str:
kwargs = {
"remote_dir": REMOTE_DIR,
"name": "offaxis",
"command": ["python", "scripts/sweep_offaxis_led.py"],
"uv_path": UV,
"use_uv": True,
"env": {"PYTHONUNBUFFERED": "1", "SM_RENDER_VARIANT": "cuda_ad_rgb"},
}
kwargs.update(overrides)
return cmd.build_job_script(
kwargs["remote_dir"],
kwargs["name"],
kwargs["command"],
uv_path=kwargs["uv_path"],
use_uv=kwargs["use_uv"],
env=kwargs["env"],
)
def _run_statement(script: str) -> list[str]:
"""ジョブ本体(標準入力を /dev/null にしてコマンドを実行する文)を返す."""
candidates = [s for s in _statements(script) if "/dev/null" in s and "tee" in s]
assert len(candidates) == 1, f"ジョブ本体の文が 1 つに特定できません: {candidates}"
return candidates[0]
# ---------------------------------------------------------------------------
# 終了コード(仕様 2・3)
# ---------------------------------------------------------------------------
def test_exit_codes_match_spec():
"""リモートスクリプトの終了コードが仕様の値であること(90/91/92/93)."""
assert cmd.EXIT_REMOTE_DIR_MISSING == 90
assert cmd.EXIT_SESSION_EXISTS == 91
assert cmd.EXIT_NOT_FOUND == 92
assert cmd.EXIT_NO_SESSION == 93
def test_log_dir_is_under_output():
"""ジョブログの置き場所が output/_remote_logs であること(仕様 2)."""
assert cmd.REMOTE_LOG_DIR == "output/_remote_logs"
# ---------------------------------------------------------------------------
# quote_remote_path(仕様 5: ~ はリモートで展開させる)
# ---------------------------------------------------------------------------
class TestQuoteRemotePath:
def test_tilde_path_expands_home_on_remote(self):
"""`~/...` は `"$HOME"/...` になり,リモートのシェルで $HOME が展開される形であること."""
quoted = cmd.quote_remote_path(REMOTE_DIR)
assert quoted.startswith('"$HOME"/')
assert shlex.split(quoted) == [REMOTE_DIR_EXPANDED]
@pytest.mark.parametrize("path", ["~", "~/"])
def test_bare_tilde_is_home(self, path):
assert cmd.quote_remote_path(path) == '"$HOME"'
def test_tilde_remainder_is_quoted_against_expansion(self):
"""`~/` 以降はシェルに解釈させない($(...) 等を含んでもそのまま 1 語)."""
quoted = cmd.quote_remote_path("~/$(id)")
assert quoted == '"$HOME"/' + shlex.quote("$(id)")
@pytest.mark.parametrize(
("path", "expected"),
[
("~/my dir/it's", "$HOME/my dir/it's"),
("~/x; rm -rf /", "$HOME/x; rm -rf /"),
("/srv/data dir/proj", "/srv/data dir/proj"),
("lab/proj", "lab/proj"),
],
)
def test_special_characters_stay_single_word(self, path, expected):
"""空白・引用符・; を含むパスもシェルで 1 語として扱われること."""
assert shlex.split(cmd.quote_remote_path(path)) == [expected]
@pytest.mark.parametrize("path", ["", " "])
def test_empty_path_raises(self, path):
with pytest.raises(ValueError):
cmd.quote_remote_path(path)
def test_tilde_user_form_is_rejected(self):
"""`~ユーザー名/...` 形式は対応しない(ValueError)."""
with pytest.raises(ValueError):
cmd.quote_remote_path("~other/lab")
# ---------------------------------------------------------------------------
# 入力の検証(仕様 2: ジョブ名と GPU 指定を検証する)
# ---------------------------------------------------------------------------
class TestValidateHost:
@pytest.mark.parametrize("host", ["Newton", "Newton.local", "192.168.23.13", "user@host"])
def test_valid_hosts_pass_through(self, host):
assert cmd.validate_host(host) == host
@pytest.mark.parametrize(
"host", ["", "-oProxyCommand=calc", "-t", "New ton", "Newton\n", "\tNewton"]
)
def test_invalid_hosts_raise(self, host):
"""空・`-` 始まり(ssh のオプション注入)・空白入りは拒否すること."""
with pytest.raises(ValueError):
cmd.validate_host(host)
class TestValidateJobName:
@pytest.mark.parametrize(
"name", ["offaxis", "r100", "pytest-all", "job-20260913-120000", "A_b-9", "x" * 64]
)
def test_valid_names(self, name):
assert cmd.validate_job_name(name) == name
@pytest.mark.parametrize(
"name",
[
"",
"-job", # オプションと紛らわしい
"_job",
"a.b", # tmux のセッション名に使えない
"a:b",
"a b",
"a;b",
"a/b",
"../x",
"$(id)",
"ジョブ",
"x" * 65, # 64 文字超
],
)
def test_invalid_names_raise(self, name):
with pytest.raises(ValueError):
cmd.validate_job_name(name)
class TestValidateGpuSpec:
@pytest.mark.parametrize("spec", ["0", "1", "0,2", "0,1,2", "10"])
def test_valid_specs(self, spec):
assert cmd.validate_gpu_spec(spec) == spec
@pytest.mark.parametrize("spec", ["", "a", "0,", ",0", "0, 1", "-1", "0;rm -rf ~", "0 1"])
def test_invalid_specs_raise(self, spec):
with pytest.raises(ValueError):
cmd.validate_gpu_spec(spec)
class TestParseEnvAssignment:
@pytest.mark.parametrize(
("text", "expected"),
[
("KEY=VALUE", ("KEY", "VALUE")),
("KEY=", ("KEY", "")),
("KEY=a=b", ("KEY", "a=b")),
("_K1=with space", ("_K1", "with space")),
],
)
def test_valid_assignments(self, text, expected):
assert cmd.parse_env_assignment(text) == expected
@pytest.mark.parametrize("text", ["KEY", "=v", "1KEY=v", "K-1=v", "K 1=v", ""])
def test_invalid_assignments_raise(self, text):
with pytest.raises(ValueError):
cmd.parse_env_assignment(text)
# ---------------------------------------------------------------------------
# build_ssh_argv
# ---------------------------------------------------------------------------
class TestBuildSshArgv:
def test_remote_command_is_single_argument(self):
argv = cmd.build_ssh_argv("ssh", "Newton", "echo a b; ls")
assert argv == ["ssh", "Newton", "echo a b; ls"]
def test_tty_adds_t_option_before_host(self):
argv = cmd.build_ssh_argv("ssh", "Newton", "tmux attach", tty=True)
assert argv == ["ssh", "-t", "Newton", "tmux attach"]
def test_invalid_host_raises(self):
with pytest.raises(ValueError):
cmd.build_ssh_argv("ssh", "-oProxyCommand=calc", "echo")
# ---------------------------------------------------------------------------
# push(仕様 1)
# ---------------------------------------------------------------------------
class TestBuildPushCommand:
def test_extracts_stdin_tar_into_remote_dir(self):
"""標準入力の tar をリモートのプロジェクトルートへ展開すること."""
statements = _statements(cmd.build_push_command(REMOTE_DIR, compress=False))
last = statements[-1]
assert _has_sequence(last, ["mkdir", "-p", REMOTE_DIR_EXPANDED])
assert _has_sequence(last, ["tar", "-xf", "-", "-C", REMOTE_DIR_EXPANDED])
def test_compress_uses_gzip_extraction(self):
words = _shell_words(cmd.build_push_command(REMOTE_DIR, compress=True))
assert _has_sequence(words, ["tar", "-xzf", "-", "-C", REMOTE_DIR_EXPANDED])
def test_tar_is_the_last_command(self):
"""ssh の終了コード=tar の終了コードになるよう tar が最後に実行されること."""
last = _statements(cmd.build_push_command(REMOTE_DIR, compress=False))[-1]
assert last[last.index("tar") :] == ["tar", "-xf", "-", "-C", REMOTE_DIR_EXPANDED]
def test_warns_when_tmux_sessions_are_running(self):
"""ジョブ実行中の push に警告を出すため tmux ls を確認すること(ENV_04 注意)."""
words = _shell_words(cmd.build_push_command(REMOTE_DIR, compress=False))
assert _has_sequence(words, ["tmux", "ls"])
def test_remote_dir_with_space_is_single_word(self):
words = _shell_words(cmd.build_push_command("~/my lab/proj", compress=False))
assert _has_sequence(words, ["-C", "$HOME/my lab/proj"])
class TestBuildListAndRemoveCommands:
def test_list_targets_only_given_dirs(self):
"""--prune の一覧は指定ディレクトリ(src/tests/scripts)だけを対象にすること."""
text = cmd.build_list_files_command(
REMOTE_DIR, ["src", "tests", "scripts"], ["__pycache__", ".venv"]
)
words = _shell_words(text)
assert _has_sequence(words, ["in", "src", "tests", "scripts"])
assert "output" not in words
assert "configs" not in words
assert "-print0" in words
def test_list_skips_cache_dirs(self):
words = _shell_words(
cmd.build_list_files_command(REMOTE_DIR, ["src"], ["__pycache__", ".pytest_cache"])
)
assert _has_sequence(words, ["-name", "__pycache__"])
assert _has_sequence(words, ["-name", ".pytest_cache"])
assert "-prune" in words
def test_list_exits_90_when_remote_dir_missing(self):
words = _shell_words(cmd.build_list_files_command(REMOTE_DIR, ["src"], ["__pycache__"]))
assert words[:2] == ["cd", REMOTE_DIR_EXPANDED]
assert _has_sequence(words, ["exit", "90"])
def test_remove_reads_nul_separated_paths(self):
words = _shell_words(cmd.build_remove_files_command(REMOTE_DIR))
assert words[:2] == ["cd", REMOTE_DIR_EXPANDED]
assert _has_sequence(words, ["exit", "90"])
assert _has_sequence(words, ["xargs", "-0", "rm", "-f", "--"])
# ---------------------------------------------------------------------------
# run(仕様 2)
# ---------------------------------------------------------------------------
class TestBuildJobScript:
def test_runs_in_remote_project_root(self):
first = _statements(_job_script())[0]
assert first[:2] == ["cd", REMOTE_DIR_EXPANDED]
assert _has_sequence(_shell_words(_job_script()), ["exit", "90"])
def test_log_file_is_named_after_job(self):
"""ログは output/_remote_logs/<NAME>.log であること."""
statements = _statements(_job_script(name="r100"))
assert ["log=output/_remote_logs/r100.log"] in statements
def test_writes_start_and_exit_lines_to_log(self):
"""開始行と `exit <終了コード>` 行をログに追記すること."""
statements = _statements(_job_script())
start = [s for s in statements if s[0] == "printf" and "start" in s[1]]
exit_line = [s for s in statements if s[0] == "printf" and "exit" in s[1]]
assert len(start) == 1
assert len(exit_line) == 1
assert start[0][-2:] == [">>", "$log"]
assert exit_line[0][-2:] == [">>", "$log"]
assert "$rc" in exit_line[0]
# 開始行 → ジョブ本体 → 終了コード行 の順
run_idx = statements.index(_run_statement(_job_script()))
assert statements.index(start[0]) < run_idx < statements.index(exit_line[0])
def test_start_line_shows_the_command(self):
statements = _statements(_job_script(command=["sm-render", "--scene", "a.yaml"]))
start = next(s for s in statements if s[0] == "printf" and "start" in s[1])
assert "sm-render --scene a.yaml" in start[-3]
def test_exit_code_of_command_is_propagated(self):
"""パイプ(tee)越しでもコマンド自身の終了コードで終わること."""
statements = _statements(_job_script())
assert ["rc=${PIPESTATUS[0]}"] in statements
assert statements[-1] == ["exit", "$rc"]
def test_output_is_also_tee_to_log(self):
run = _run_statement(_job_script())
assert _has_sequence(run, ["tee", "-a", "$log"])
def test_stdin_is_dev_null(self):
"""入力待ちで止まらないよう標準入力を /dev/null にすること."""
run = _run_statement(_job_script())
assert _has_sequence(run, ["<", "/dev/null"])
def test_runs_command_with_uv_run(self):
run = _run_statement(_job_script(command=["python", "scripts/sweep_offaxis_led.py"]))
assert run[:4] == ["$HOME/.local/bin/uv", "run", "python", "scripts/sweep_offaxis_led.py"]
def test_no_uv_runs_command_directly(self):
run = _run_statement(_job_script(use_uv=False, command=["nvidia-smi"]))
assert run[0] == "nvidia-smi"
assert "run" not in run[:2]
def test_arguments_are_quoted_and_survive_intact(self):
"""引数は shlex で正しくクォートされ,リモートのシェルでそのまま 1 語ずつ届くこと."""
command = [
"python",
"-c",
"import sys; print('a b', \"$HOME\")",
"--label=two words",
"it's",
"$(rm -rf ~)",
"`id`",
"a|b&c",
"",
"日本語 引数",
]
run = _run_statement(_job_script(command=command))
assert run[: 2 + len(command)] == ["$HOME/.local/bin/uv", "run", *command]
def test_uv_directory_is_added_to_path(self):
"""スクリプトが `uv run sm-render` を名前で呼べるよう uv のディレクトリを PATH に足す."""
statements = _statements(_job_script())
path_exports = [s for s in statements if s[0] == "export" and s[1].startswith("PATH=")]
assert len(path_exports) == 1
value = path_exports[0][1]
assert "dirname" in value
assert ".local/bin/uv" in value
assert value.endswith(":$PATH")
def test_custom_uv_path_is_used(self):
run = _run_statement(_job_script(uv_path="/opt/uv tools/uv"))
assert run[:2] == ["/opt/uv tools/uv", "run"]
def test_env_values_are_exported_and_quoted(self):
env = {
"PYTHONUNBUFFERED": "1",
"SM_RENDER_VARIANT": "cuda_ad_rgb",
"CUDA_VISIBLE_DEVICES": "0,2",
"WEIRD": "a b $(id); x",
}
statements = _statements(_job_script(env=env))
for key, value in env.items():
assert ["export", f"{key}={value}"] in statements
def test_env_is_exported_before_the_command(self):
statements = _statements(_job_script(env={"SM_DRJIT_THREADS": "16"}))
export_idx = statements.index(["export", "SM_DRJIT_THREADS=16"])
assert export_idx < statements.index(_run_statement(_job_script(env={"X": "1"})))
def test_empty_command_raises(self):
with pytest.raises(ValueError):
_job_script(command=[])
@pytest.mark.parametrize("name", ["a b", "a.b", "-x", ""])
def test_invalid_job_name_raises(self, name):
with pytest.raises(ValueError):
_job_script(name=name)
@pytest.mark.parametrize("key", ["1X", "A-B", "A B", "X;rm", ""])
def test_invalid_env_key_raises(self, key):
with pytest.raises(ValueError):
_job_script(env={key: "1"})
def _tmux_new_session(run_command: str) -> list[str]:
words = _shell_words(run_command)
start = words.index("new-session")
stmt = words[start:]
return stmt[: stmt.index(";")] if ";" in stmt else stmt
class TestBuildRunCommand:
def test_job_script_survives_nested_quoting(self):
"""tmux → bash -c と 2 段に渡してもジョブ本体が 1 文字も崩れないこと."""
script = _job_script(command=["python", "-c", "print('x; y')", "$(id)"])
stmt = _tmux_new_session(cmd.build_run_command(REMOTE_DIR, "offaxis", script))
assert shlex.split(stmt[-1]) == ["bash", "-c", script]
def test_starts_detached_session_named_after_job(self):
script = _job_script()
stmt = _tmux_new_session(cmd.build_run_command(REMOTE_DIR, "offaxis", script))
assert stmt[:4] == ["new-session", "-d", "-s", "offaxis"]
assert _has_sequence(stmt, ["-c", "$PWD"])
def test_existing_session_exits_91(self):
"""同名セッションが実行中なら起動せず 91 で終わること(tmux の完全一致指定 =NAME)."""
words = _shell_words(cmd.build_run_command(REMOTE_DIR, "off", _job_script(name="off")))
check = _index_of_sequence(words, ["has-session", "-t", "=off"])
exit91 = _index_of_sequence(words, ["exit", "91"])
new = words.index("new-session")
assert check < exit91 < new
def test_creates_log_dir_and_enters_remote_dir(self):
statements = _statements(cmd.build_run_command(REMOTE_DIR, "offaxis", _job_script()))
assert statements[0][:2] == ["cd", REMOTE_DIR_EXPANDED]
assert any(_has_sequence(s, ["mkdir", "-p", "output/_remote_logs"]) for s in statements)
@pytest.mark.parametrize("name", ["a b", "a:b", "-t"])
def test_invalid_name_raises(self, name):
with pytest.raises(ValueError):
cmd.build_run_command(REMOTE_DIR, name, "echo")
# ---------------------------------------------------------------------------
# status / logs / attach / kill(仕様 3)
# ---------------------------------------------------------------------------
class TestBuildStatusCommand:
def test_shows_sessions_logs_gpu_and_load(self):
words = _shell_words(cmd.build_status_command(REMOTE_DIR))
assert words[:2] == ["cd", REMOTE_DIR_EXPANDED]
assert _has_sequence(words, ["exit", "90"])
assert _has_sequence(words, ["tmux", "ls"])
assert any("output/_remote_logs/*.log" in w for w in words)
assert "nvidia-smi" in words
assert "uptime" in words
class TestBuildLogsCommand:
def test_tail_last_n_lines(self):
statements = _statements(cmd.build_logs_command(REMOTE_DIR, "job1", lines=7, follow=False))
assert statements[-1] == ["tail", "-n", "7", "output/_remote_logs/job1.log"]
def test_missing_log_exits_92(self):
words = _shell_words(cmd.build_logs_command(REMOTE_DIR, "job1", lines=40, follow=False))
assert _has_sequence(words, ["exit", "92"])
assert _has_sequence(words, ["exit", "90"])
def test_follow_stops_tail_when_stdin_closes(self):
"""-f は tail -F で追従し,手元が切れて標準入力が閉じたら tail を止めること."""
words = _shell_words(cmd.build_logs_command(REMOTE_DIR, "job1", lines=5, follow=True))
assert _has_sequence(words, ["tail", "-n", "5", "-F", "output/_remote_logs/job1.log"])
cat = _index_of_sequence(words, ["cat", ">/dev/null"])
kill = words.index("kill")
assert cat < kill
def test_invalid_name_raises(self):
with pytest.raises(ValueError):
cmd.build_logs_command(REMOTE_DIR, "../etc/passwd", lines=1, follow=False)
class TestAttachCommands:
def test_attach_uses_exact_session_match(self):
assert shlex.split(cmd.build_attach_command("offaxis")) == [
"tmux",
"attach-session",
"-t",
"=offaxis",
]
def test_attach_invalid_name_raises(self):
with pytest.raises(ValueError):
cmd.build_attach_command("a;b")
def test_check_exit_codes_in_order(self):
"""セッションあり→0,無くてログあり→93,ログも無し→92,ディレクトリ無し→90."""
words = _shell_words(cmd.build_attach_check_command(REMOTE_DIR, "offaxis"))
has = _index_of_sequence(words, ["has-session", "-t", "=offaxis"])
exit0 = _index_of_sequence(words, ["exit", "0"])
cd = _index_of_sequence(words, ["cd", REMOTE_DIR_EXPANDED])
exit90 = _index_of_sequence(words, ["exit", "90"])
log_check = _index_of_sequence(words, ["-f", "output/_remote_logs/offaxis.log"])
exit93 = _index_of_sequence(words, ["exit", "93"])
exit92 = _index_of_sequence(words, ["exit", "92"])
assert has < exit0 < cd < exit90 < log_check < exit93 < exit92
def test_check_prints_last_log_line_when_ended(self):
words = _shell_words(cmd.build_attach_check_command(REMOTE_DIR, "offaxis"))
assert _has_sequence(words, ["tail", "-n", "1", "output/_remote_logs/offaxis.log"])
class TestBuildKillCommand:
def test_missing_session_exits_93(self):
words = _shell_words(cmd.build_kill_command(REMOTE_DIR, "offaxis"))
has = _index_of_sequence(words, ["has-session", "-t", "=offaxis"])
assert has < _index_of_sequence(words, ["exit", "93"])
def test_appends_note_to_log_then_kills_session(self):
words = _shell_words(cmd.build_kill_command(REMOTE_DIR, "offaxis"))
append = _index_of_sequence(words, [">>", "output/_remote_logs/offaxis.log"])
kill = _index_of_sequence(words, ["kill-session", "-t", "=offaxis"])
assert append < kill
assert any("killed" in w for w in words)
def test_invalid_name_raises(self):
with pytest.raises(ValueError):
cmd.build_kill_command(REMOTE_DIR, "a b")
# ---------------------------------------------------------------------------
# pull(仕様 4)
# ---------------------------------------------------------------------------
class TestNormalizeOutputSubpath:
@pytest.mark.parametrize(
"text",
[
"offaxis_led_sweep",
"output/offaxis_led_sweep",
"output\\offaxis_led_sweep",
"./offaxis_led_sweep",
"offaxis_led_sweep/",
" offaxis_led_sweep ",
"output//offaxis_led_sweep",
],
)
def test_accepts_all_spellings(self, text):
"""`foo` / `output/foo` / `output\\foo` のどれでも output/foo になること."""
assert cmd.normalize_output_subpath(text) == "output/offaxis_led_sweep"
def test_nested_path(self):
assert cmd.normalize_output_subpath("sweep\\r100\\figures") == "output/sweep/r100/figures"
assert cmd.normalize_output_subpath("_remote_logs") == "output/_remote_logs"
def test_similar_prefix_is_not_stripped(self):
"""`outputs/...` は output/ と見なさず,output/ 配下のサブパスとして扱う."""
assert cmd.normalize_output_subpath("outputs/x") == "output/outputs/x"
@pytest.mark.parametrize(
"text",
[
"",
" ",
"/etc/passwd",
"/output/x",
"C:\\Users\\x",
"c:/x",
"C:x",
"../x",
"x/../../y",
"output/../src",
"..\\src",
"output",
"output/",
"output\\",
".",
"./",
],
)
def test_rejects_outside_output_and_whole_output(self, text):
"""output/ の外・絶対パス・`..`・output/ 全体は拒否すること."""
with pytest.raises(ValueError):
cmd.normalize_output_subpath(text)
class TestBuildPullCommand:
def test_tars_requested_paths_to_stdout(self):
statements = _statements(
cmd.build_pull_command(REMOTE_DIR, ["output/a", "output/b c"], compress=False)
)
assert statements[0][:2] == ["cd", REMOTE_DIR_EXPANDED]
assert statements[-1] == ["tar", "-cf", "-", "output/a", "output/b c"]
def test_compress_uses_gzip(self):
last = _statements(cmd.build_pull_command(REMOTE_DIR, ["output/a"], compress=True))[-1]
assert last == ["tar", "-czf", "-", "output/a"]
def test_missing_path_exits_92_before_tar(self):
words = _shell_words(cmd.build_pull_command(REMOTE_DIR, ["output/a"], compress=False))
assert _index_of_sequence(words, ["exit", "92"]) < words.index("tar")
assert _has_sequence(words, ["exit", "90"])
def test_empty_paths_raise(self):
with pytest.raises(ValueError):
cmd.build_pull_command(REMOTE_DIR, [], compress=False)