Skip to content

Commit 4a9b41c

Browse files
authored
Merge pull request #62 from VectorInstitute/retire_launch_script
- Retire launch_server script, the launch command now takes care of setting environment variables and runs the sbatch command directly - Updated default behaviour for launching a model that's absent from config, only send a warning msg if the model weights exist - Replaced all os.path methods with pathlib - Moved singularity containers to model-weights and added a cache config - Added a json file to keep track of launch params and server address for each job - Updated logging file structure - Updated CLI integration tests to accommodate the changes, split the launch_command_model_not_found test into two - Minor updates to various variable names - Fixed Qwen2.5-Math model config issues
2 parents 8eecdca + 4cb0552 commit 4a9b41c

10 files changed

Lines changed: 871 additions & 556 deletions

File tree

tests/vec_inf/cli/test_cli.py

Lines changed: 299 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Tests for the CLI module of the vec_inf package."""
22

33
import json
4-
from unittest.mock import patch
4+
import traceback
5+
from contextlib import ExitStack
6+
from pathlib import Path
7+
from unittest.mock import mock_open, patch
58

69
import pytest
10+
import yaml
711
from click.testing import CliRunner
812

913
from vec_inf.cli._cli import cli
@@ -20,25 +24,7 @@ def mock_launch_output():
2024
"""Fixture providing consistent mock output structure."""
2125

2226
def _output(job_id):
23-
return f"""
24-
Job Name: Meta-Llama-3.1-8B
25-
Partition: a40
26-
Num Nodes: 1
27-
GPUs per Node: 1
28-
QOS: llm
29-
Walltime: 08:00:00
30-
Model Type: LLM
31-
Task: generate
32-
Data Type: auto
33-
Max Model Length: 131072
34-
Max Num Seqs: 256
35-
Vocabulary Size: 128256
36-
Pipeline Parallelism: True
37-
Enforce Eager: False
38-
Log Directory: /h/llm/.vec-inf-logs/Meta-Llama-3.1
39-
Model Weights Parent Directory: /model-weights
40-
Submitted batch job {job_id}
41-
""".strip()
27+
return (f"Submitted batch job {job_id}", "")
4228

4329
return _output
4430

@@ -55,36 +41,317 @@ def _output(job_id, job_state):
5541
return _output
5642

5743

58-
def test_launch_command_success(runner, mock_launch_output):
44+
@pytest.fixture
45+
def mock_exists():
46+
"""Fixture providing path existence checks."""
47+
48+
def _exists(path):
49+
# Return False for CACHED_CONFIG to fall back to default config
50+
return not str(path).endswith("vec-inf-shared/models.yaml")
51+
52+
return _exists
53+
54+
55+
@pytest.fixture
56+
def test_config_dir():
57+
"""Fixture providing the path to the test config directory."""
58+
# Go up to project root, then into vec_inf/config
59+
return Path(__file__).resolve().parent.parent.parent.parent / "vec_inf" / "config"
60+
61+
62+
@pytest.fixture
63+
def path_exists(mock_exists, test_config_dir):
64+
"""Fixture providing path existence checks."""
65+
66+
def _exists(p):
67+
# Allow access to the default config file
68+
if str(p).endswith("config/models.yaml"):
69+
return True
70+
# Use mock_exists for other paths
71+
return mock_exists(p)
72+
73+
return _exists
74+
75+
76+
@pytest.fixture
77+
def debug_helper(test_config_dir):
78+
"""Fixture providing debug helper functions and tracked file operations."""
79+
80+
class DebugHelper:
81+
def __init__(self):
82+
self.open_calls = []
83+
self.config_file = test_config_dir / "models.yaml"
84+
with open(self.config_file, "r") as f:
85+
self.config_content = f.read()
86+
self.yaml_content = yaml.safe_load(self.config_content)
87+
88+
def print_debug_info(self, result):
89+
"""Print debug information for test results."""
90+
print("\n=== TEST ERROR DETAILS ===")
91+
print(f"Config file path: {self.config_file}")
92+
print(f"Config file exists: {self.config_file.exists()}")
93+
print(f"Config dir contents: {list(test_config_dir.iterdir())}")
94+
print(f"Exit Code: {result.exit_code}")
95+
print(f"Exception: {result.exception}")
96+
print(f"Output:\n{result.output}")
97+
98+
print("\n=== FILE OPEN CALLS ===")
99+
for args, kwargs in self.open_calls:
100+
print(f"Open called with: args={args}, kwargs={kwargs}")
101+
102+
if hasattr(result.exception, "__traceback__"):
103+
print("\n=== STACK TRACE ===")
104+
print("".join(traceback.format_tb(result.exception.__traceback__)))
105+
106+
# Try to parse and print JSON output if present
107+
try:
108+
if result.output:
109+
print("\n=== PARSED OUTPUT ===")
110+
# Try direct JSON parsing first
111+
try:
112+
parsed = json.loads(result.output)
113+
except json.JSONDecodeError:
114+
# Fall back to ast.literal_eval for Python dict format
115+
import ast
116+
117+
parsed = ast.literal_eval(result.output)
118+
print("Keys found:", list(parsed.keys()))
119+
print("Full parsed output:", parsed)
120+
except Exception as e:
121+
print(f"Failed to parse output: {e}")
122+
123+
def tracked_mock_open(self, *args, **kwargs):
124+
"""Track file open operations and return mock."""
125+
self.open_calls.append((args, kwargs))
126+
return mock_open(read_data=self.config_content)(*args, **kwargs)
127+
128+
return DebugHelper()
129+
130+
131+
@pytest.fixture
132+
def test_paths():
133+
"""Fixture providing common test paths."""
134+
return {
135+
"log_dir": Path("/tmp/test_vec_inf_logs"),
136+
"weights_dir": Path("/model-weights"),
137+
"unknown_model": Path("/model-weights/unknown-model"),
138+
}
139+
140+
141+
@pytest.fixture
142+
def mock_truediv(test_paths):
143+
"""Fixture providing path joining mock."""
144+
145+
def _mock_truediv(self, other):
146+
if str(self) == str(test_paths["weights_dir"]) and other == "unknown-model":
147+
return test_paths["unknown_model"]
148+
if str(self) == str(test_paths["log_dir"]):
149+
return test_paths["log_dir"] / other
150+
if str(self) == str(test_paths["log_dir"] / "model_family_placeholder"):
151+
return test_paths["log_dir"] / "model_family_placeholder" / other
152+
return Path(str(self)) / str(other)
153+
154+
return _mock_truediv
155+
156+
157+
def create_path_exists(test_paths, path_exists, exists_paths=None):
158+
"""Create a path existence checker.
159+
160+
Args:
161+
test_paths: Dictionary containing test paths
162+
path_exists: Default path existence checker
163+
exists_paths: Optional list of paths that should exist
164+
"""
165+
166+
def _custom_path_exists(p):
167+
str_path = str(p)
168+
# First check if path should explicitly exist
169+
if exists_paths is not None:
170+
for path in exists_paths:
171+
if str_path == str(path):
172+
return True
173+
# Special handling for model weights paths
174+
if str_path == str(test_paths["unknown_model"]):
175+
# Model weights path existence depends on exists_paths
176+
return (
177+
exists_paths is not None and test_paths["unknown_model"] in exists_paths
178+
)
179+
if str_path == str(test_paths["weights_dir"]):
180+
# Model weights directory existence depends on exists_paths
181+
return (
182+
exists_paths is not None and test_paths["weights_dir"] in exists_paths
183+
)
184+
# Fall back to default path_exists for other paths
185+
return path_exists(p)
186+
187+
return _custom_path_exists
188+
189+
190+
@pytest.fixture
191+
def base_patches(test_paths, mock_truediv, debug_helper):
192+
"""Fixture providing common patches for tests."""
193+
return [
194+
patch("pathlib.Path.mkdir"),
195+
patch("builtins.open", debug_helper.tracked_mock_open),
196+
patch("pathlib.Path.open", debug_helper.tracked_mock_open),
197+
patch("pathlib.Path.expanduser", return_value=test_paths["log_dir"]),
198+
patch("pathlib.Path.resolve", return_value=debug_helper.config_file.parent),
199+
patch(
200+
"pathlib.Path.parent", return_value=debug_helper.config_file.parent.parent
201+
),
202+
patch("pathlib.Path.__truediv__", side_effect=mock_truediv),
203+
patch("json.dump"),
204+
patch("pathlib.Path.touch"),
205+
patch("vec_inf.cli._helper.Path", return_value=test_paths["weights_dir"]),
206+
]
207+
208+
209+
def test_launch_command_success(runner, mock_launch_output, path_exists, debug_helper):
59210
"""Test successful model launch with minimal required arguments."""
60-
with patch("vec_inf.cli._utils.run_bash_command") as mock_run:
211+
test_log_dir = Path("/tmp/test_vec_inf_logs")
212+
213+
with (
214+
patch("vec_inf.cli._utils.run_bash_command") as mock_run,
215+
patch("pathlib.Path.mkdir"),
216+
patch("builtins.open", debug_helper.tracked_mock_open),
217+
patch("pathlib.Path.open", debug_helper.tracked_mock_open),
218+
patch("pathlib.Path.exists", new=path_exists),
219+
patch("pathlib.Path.expanduser", return_value=test_log_dir),
220+
patch("pathlib.Path.resolve", return_value=debug_helper.config_file.parent),
221+
patch(
222+
"pathlib.Path.parent", return_value=debug_helper.config_file.parent.parent
223+
),
224+
patch("json.dump"),
225+
patch("pathlib.Path.touch"),
226+
patch("pathlib.Path.__truediv__", return_value=test_log_dir),
227+
):
61228
expected_job_id = "14933053"
62229
mock_run.return_value = mock_launch_output(expected_job_id)
230+
63231
result = runner.invoke(cli, ["launch", "Meta-Llama-3.1-8B"])
232+
debug_helper.print_debug_info(result)
233+
64234
assert result.exit_code == 0
65235
assert expected_job_id in result.output
66236
mock_run.assert_called_once()
67237

68238

69-
def test_launch_command_with_json_output(runner, mock_launch_output):
239+
def test_launch_command_with_json_output(
240+
runner, mock_launch_output, path_exists, debug_helper
241+
):
70242
"""Test JSON output format for launch command."""
71-
with patch("vec_inf.cli._utils.run_bash_command") as mock_run:
243+
test_log_dir = Path("/tmp/test_vec_inf_logs")
244+
with (
245+
patch("vec_inf.cli._utils.run_bash_command") as mock_run,
246+
patch("pathlib.Path.mkdir"),
247+
patch("builtins.open", debug_helper.tracked_mock_open),
248+
patch("pathlib.Path.open", debug_helper.tracked_mock_open),
249+
patch("pathlib.Path.exists", new=path_exists),
250+
patch("pathlib.Path.expanduser", return_value=test_log_dir),
251+
patch("pathlib.Path.resolve", return_value=debug_helper.config_file.parent),
252+
patch(
253+
"pathlib.Path.parent", return_value=debug_helper.config_file.parent.parent
254+
),
255+
patch("json.dump"),
256+
patch("pathlib.Path.touch"),
257+
patch("pathlib.Path.__truediv__", return_value=test_log_dir),
258+
):
72259
expected_job_id = "14933051"
73260
mock_run.return_value = mock_launch_output(expected_job_id)
261+
74262
result = runner.invoke(cli, ["launch", "Meta-Llama-3.1-8B", "--json-mode"])
263+
debug_helper.print_debug_info(result)
264+
75265
assert result.exit_code == 0
76-
output = json.loads(result.output)
266+
267+
# Try to fix single quotes to double quotes if needed
268+
try:
269+
output = json.loads(result.output)
270+
except json.JSONDecodeError:
271+
# If direct parsing fails, try to fix the format
272+
import ast
273+
274+
# First convert string to dict using ast.literal_eval
275+
output_dict = ast.literal_eval(result.output)
276+
# Then convert back to proper JSON string
277+
output = json.loads(json.dumps(output_dict))
278+
77279
assert output.get("slurm_job_id") == expected_job_id
78-
assert output.get("job_name") == "Meta-Llama-3.1-8B"
280+
assert output.get("model_name") == "Meta-Llama-3.1-8B"
79281
assert output.get("model_type") == "LLM"
80-
assert output.get("log_directory") == "/h/llm/.vec-inf-logs/Meta-Llama-3.1"
282+
assert str(test_log_dir) in output.get("log_dir", "")
283+
284+
285+
def test_launch_command_model_not_in_config_with_weights(
286+
runner, mock_launch_output, path_exists, debug_helper, test_paths, base_patches
287+
):
288+
"""Test handling of a model that's not in config but has weights."""
289+
custom_path_exists = create_path_exists(
290+
test_paths,
291+
path_exists,
292+
exists_paths=[
293+
test_paths["unknown_model"],
294+
test_paths["weights_dir"],
295+
], # Ensure both paths exist
296+
)
297+
298+
with ExitStack() as stack:
299+
# Apply all base patches
300+
for patch_obj in base_patches:
301+
stack.enter_context(patch_obj)
302+
# Apply specific patches for this test
303+
mock_run = stack.enter_context(patch("vec_inf.cli._utils.run_bash_command"))
304+
stack.enter_context(patch("pathlib.Path.exists", new=custom_path_exists))
305+
306+
expected_job_id = "14933051"
307+
mock_run.return_value = mock_launch_output(expected_job_id)
308+
309+
result = runner.invoke(cli, ["launch", "unknown-model"])
310+
debug_helper.print_debug_info(result)
311+
312+
assert result.exit_code == 0
313+
assert (
314+
"Warning: 'unknown-model' configuration not found in config"
315+
in result.output
316+
)
317+
318+
319+
def test_launch_command_model_not_found(
320+
runner, path_exists, debug_helper, test_paths, base_patches
321+
):
322+
"""Test handling of a model that's neither in config nor has weights."""
323+
324+
def custom_path_exists(p):
325+
str_path = str(p)
326+
# Always return False for model weights paths
327+
if str_path == str(test_paths["unknown_model"]) or str_path == str(
328+
test_paths["weights_dir"]
329+
):
330+
return False
331+
# Allow access to the default config file
332+
return str_path.endswith("config/models.yaml")
333+
334+
with ExitStack() as stack:
335+
# Apply all base patches except the Path mock
336+
for patch_obj in base_patches[:-1]: # Skip the last patch which is Path mock
337+
stack.enter_context(patch_obj)
338+
339+
# Apply specific patches for this test
340+
stack.enter_context(patch("pathlib.Path.exists", new=custom_path_exists))
341+
342+
# Mock Path to return the weights dir path
343+
stack.enter_context(
344+
patch("vec_inf.cli._helper.Path", return_value=test_paths["weights_dir"])
345+
)
81346

347+
result = runner.invoke(cli, ["launch", "unknown-model"])
348+
debug_helper.print_debug_info(result)
82349

83-
def test_launch_command_invalid_model(runner):
84-
"""Test error handling for unknown model."""
85-
result = runner.invoke(cli, ["launch", "unknown-model"])
86-
assert result.exit_code == 1
87-
assert "Model 'unknown-model' not found in configuration" in result.output
350+
assert result.exit_code == 1
351+
assert (
352+
"'unknown-model' not found in configuration and model weights not found"
353+
in result.output
354+
)
88355

89356

90357
def test_list_all_models(runner):

0 commit comments

Comments
 (0)