11"""Tests for the CLI module of the vec_inf package."""
22
33import 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
69import pytest
10+ import yaml
711from click .testing import CliRunner
812
913from 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
90357def test_list_all_models (runner ):
0 commit comments