|
14 | 14 | # limitations under the License. |
15 | 15 |
|
16 | 16 | import os |
| 17 | +import sys |
17 | 18 | from configparser import ConfigParser |
18 | 19 | from dataclasses import dataclass, field |
19 | 20 | from typing import Annotated, List, Optional, Union |
|
24 | 25 | import typer |
25 | 26 | from importlib_metadata import EntryPoint, EntryPoints |
26 | 27 | from typer.testing import CliRunner |
| 28 | +from rich.console import Console |
27 | 29 |
|
28 | 30 | import nemo_run as run |
29 | 31 | from nemo_run import cli, config |
30 | 32 | from nemo_run.cli import api as cli_api |
31 | | -from nemo_run.cli.api import Entrypoint, RunContext, add_global_options, create_cli |
| 33 | +from nemo_run.config import Config, Partial, get_nemorun_home |
| 34 | +from nemo_run.cli.lazy import LazyEntrypoint |
| 35 | +from nemo_run.cli.api import ( |
| 36 | + Entrypoint, |
| 37 | + RunContext, |
| 38 | + add_global_options, |
| 39 | + create_cli, |
| 40 | + _search_workspace_file, |
| 41 | + _load_workspace_file, |
| 42 | + _load_workspace, |
| 43 | + RunContextError, |
| 44 | + _get_return_type, |
| 45 | +) |
32 | 46 | from test.dummy_factory import DummyModel, dummy_entrypoint |
| 47 | +import nemo_run.cli.cli_parser # Import the module to mock its function |
33 | 48 |
|
34 | 49 | _RUN_FACTORIES_ENTRYPOINT: str = """ |
35 | 50 | [nemo_run.cli] |
@@ -239,6 +254,120 @@ def sample_function(a, b): |
239 | 254 | ctx.cli_execute(sample_function, ["a=10", "b=hello", "run.detach=False"]) |
240 | 255 | assert not ctx.detach |
241 | 256 |
|
| 257 | + def test_run_context_cli_execute_load_not_implemented(self, sample_function): |
| 258 | + ctx = RunContext(name="test_run", load="some_dir") |
| 259 | + with pytest.raises(NotImplementedError, match="Load is not implemented yet"): |
| 260 | + ctx.cli_execute(sample_function, []) |
| 261 | + |
| 262 | + @patch("nemo_run.cli.api._serialize_configuration") |
| 263 | + def test_run_context_execute_task_export(self, mock_serialize, sample_function): |
| 264 | + ctx = RunContext(name="test_run", to_yaml="config.yaml", skip_confirmation=True) |
| 265 | + with patch("nemo_run.dryrun_fn"): # Mock dryrun as it's called before export check |
| 266 | + ctx.cli_execute(sample_function, ["a=10"]) |
| 267 | + mock_serialize.assert_called_once() |
| 268 | + assert mock_serialize.call_args[0][1] == "config.yaml" # Check to_yaml path |
| 269 | + |
| 270 | + @patch("nemo_run.run") |
| 271 | + @patch("nemo_run.cli.api._serialize_configuration") |
| 272 | + def test_execute_lazy_export(self, mock_serialize, mock_run): |
| 273 | + # Mock sys.argv for lazy execution context |
| 274 | + original_argv = sys.argv |
| 275 | + sys.argv = ["nemo_run", "--lazy", "lazy_test", "arg1=1", "--to-yaml", "output.yaml"] |
| 276 | + os.environ["LAZY_CLI"] = "true" # Ensure lazy mode is active |
| 277 | + |
| 278 | + # Create a dummy entrypoint for LazyEntrypoint |
| 279 | + @cli.entrypoint(namespace="test_lazy") |
| 280 | + def lazy_test_fn(arg1: int): |
| 281 | + pass |
| 282 | + |
| 283 | + # Directly test the execute_lazy method's export behavior |
| 284 | + ctx = RunContext(name="lazy_test", to_yaml="output.yaml", skip_confirmation=True) |
| 285 | + # We need executor and plugins initialized even if None, as execute_lazy accesses them |
| 286 | + ctx.executor = None |
| 287 | + ctx.plugins = [] |
| 288 | + lazy_entry = LazyEntrypoint("test_lazy.lazy_test_fn arg1=1") |
| 289 | + |
| 290 | + # Mock parse_args as it's called within execute_lazy |
| 291 | + with patch("nemo_run.cli.api.RunContext.parse_args", return_value=["arg1=1"]) as mock_parse_args: |
| 292 | + # Mock _should_continue to avoid interaction/torchrun checks |
| 293 | + with patch("nemo_run.cli.api.RunContext._should_continue", return_value=True): |
| 294 | + ctx.execute_lazy(lazy_entry, sys.argv, "lazy_test") |
| 295 | + |
| 296 | + mock_serialize.assert_called_once() |
| 297 | + # Check arguments passed to _serialize_configuration |
| 298 | + assert isinstance(mock_serialize.call_args[0][0], LazyEntrypoint) # Check config object |
| 299 | + assert mock_serialize.call_args[0][1] == "output.yaml" # Check to_yaml path |
| 300 | + assert mock_serialize.call_args[1].get("is_lazy") is True # Check is_lazy kwarg |
| 301 | + mock_run.assert_not_called() # Should not run if exporting |
| 302 | + |
| 303 | + del os.environ["LAZY_CLI"] |
| 304 | + sys.argv = original_argv # Restore original argv |
| 305 | + |
| 306 | + def test_execute_lazy_error_cases(self): |
| 307 | + lazy_entry = LazyEntrypoint("dummy") |
| 308 | + # Dry run |
| 309 | + ctx_dry = RunContext(name="lazy_test", dryrun=True) |
| 310 | + with pytest.raises(ValueError, match="Dry run is not supported for lazy execution"): |
| 311 | + ctx_dry.execute_lazy(lazy_entry, [], "lazy_test") |
| 312 | + # REPL |
| 313 | + ctx_repl = RunContext(name="lazy_test", repl=True) |
| 314 | + with pytest.raises(ValueError, match="Interactive mode is not supported for lazy execution"): |
| 315 | + ctx_repl.execute_lazy(lazy_entry, [], "lazy_test") |
| 316 | + # Direct |
| 317 | + ctx_direct = RunContext(name="lazy_test", direct=True) |
| 318 | + with pytest.raises(ValueError, match="Direct execution is not supported for lazy execution"): |
| 319 | + ctx_direct.execute_lazy(lazy_entry, [], "lazy_test") |
| 320 | + |
| 321 | + @patch("nemo_run.cli.api._serialize_configuration") |
| 322 | + @patch("fiddle.build") |
| 323 | + def test_execute_experiment_export(self, mock_build, mock_serialize, sample_experiment): |
| 324 | + ctx = RunContext(name="test_exp", to_json="exp_config.json", skip_confirmation=True) |
| 325 | + with patch("nemo_run.dryrun_fn"): # Mock dryrun |
| 326 | + ctx.cli_execute(sample_experiment, ["a=5"], entrypoint_type="experiment") |
| 327 | + mock_serialize.assert_called_once() |
| 328 | + assert mock_serialize.call_args[0][3] == "exp_config.json" # Check to_json path |
| 329 | + assert "is_lazy" in mock_serialize.call_args[1] |
| 330 | + assert mock_serialize.call_args[1]["is_lazy"] is False |
| 331 | + mock_build.assert_not_called() # Should not build if exporting |
| 332 | + |
| 333 | + @patch("fiddle.build") |
| 334 | + def test_execute_experiment_normal(self, mock_build, sample_experiment): |
| 335 | + ctx = RunContext(name="test_exp", skip_confirmation=True) |
| 336 | + # Mock the build process to avoid actual execution |
| 337 | + mock_partial = Mock() |
| 338 | + mock_build.return_value = mock_partial |
| 339 | + with patch("nemo_run.dryrun_fn"), \ |
| 340 | + patch("typer.confirm", return_value=True): # Mock dryrun and confirmation |
| 341 | + ctx.cli_execute(sample_experiment, ["a=5", "b='exp'"], entrypoint_type="experiment") |
| 342 | + mock_build.assert_called_once() |
| 343 | + mock_partial.assert_called_once_with() # Check that the built object is called |
| 344 | + |
| 345 | + def test_run_context_get_help(self): |
| 346 | + help_text = RunContext.get_help() |
| 347 | + assert "Represents the context for executing a run" in help_text |
| 348 | + |
| 349 | + def test_run_context_cli_command_defaults(self): |
| 350 | + app = typer.Typer() |
| 351 | + defaults = {"dryrun": True, "verbose": True} |
| 352 | + |
| 353 | + # Mock the actual execution logic inside the command |
| 354 | + with patch.object(RunContext, "cli_execute") as mock_cli_execute: |
| 355 | + # Create the command with defaults |
| 356 | + RunContext.cli_command(app, "testcmd", lambda: None, cmd_defaults=defaults) |
| 357 | + |
| 358 | + # Simulate calling the command with no overrides |
| 359 | + runner = CliRunner() |
| 360 | + runner.invoke(app, ["testcmd"]) |
| 361 | + |
| 362 | + # Check that cli_execute was called with the context reflecting defaults |
| 363 | + mock_cli_execute.assert_called_once() |
| 364 | + ctx_instance = mock_cli_execute.call_args[0][0] # First arg is self (RunContext instance) |
| 365 | + # Can't directly check ctx_instance attributes as it's created inside the closure, |
| 366 | + # but we can check if the options passed to _configure_global_options reflect defaults |
| 367 | + with patch("nemo_run.cli.api._configure_global_options") as mock_configure: |
| 368 | + runner.invoke(app, ["testcmd"]) |
| 369 | + mock_configure.assert_called_with(app, False, True, True, None, True) # verbose=True expected |
| 370 | + |
242 | 371 |
|
243 | 372 | @dataclass |
244 | 373 | class SomeObject: |
@@ -1139,3 +1268,197 @@ def my_model(hidden_size: int = 1000) -> Model: |
1139 | 1268 | config, |
1140 | 1269 | to_yaml=f"{yaml_path}:invalid", |
1141 | 1270 | ) |
| 1271 | + |
| 1272 | + def test_export_verbose(self, temp_dir): |
| 1273 | + @run.autoconvert |
| 1274 | + def my_model() -> Model: |
| 1275 | + return Model(hidden_size=10, num_layers=1, activation="test") |
| 1276 | + |
| 1277 | + config = my_model() |
| 1278 | + yaml_path = temp_dir / "verbose.yaml" |
| 1279 | + json_path = temp_dir / "verbose.json" |
| 1280 | + |
| 1281 | + from nemo_run.cli.api import _serialize_configuration |
| 1282 | + mock_console = Mock(spec=Console) |
| 1283 | + |
| 1284 | + _serialize_configuration( |
| 1285 | + config, |
| 1286 | + to_yaml=str(yaml_path), |
| 1287 | + to_json=str(json_path), |
| 1288 | + console=mock_console, |
| 1289 | + verbose=True |
| 1290 | + ) |
| 1291 | + |
| 1292 | + # Check that console print was called multiple times for verbose output |
| 1293 | + assert mock_console.print.call_count > 2 |
| 1294 | + mock_console.print.assert_any_call(f"[bold green]Configuration exported to YAML:[/bold green] {yaml_path}") |
| 1295 | + mock_console.print.assert_any_call("[bold cyan]File contents:[/bold cyan]") |
| 1296 | + mock_console.print.assert_any_call(f"[bold green]Configuration exported to JSON:[/bold green] {json_path}") |
| 1297 | + |
| 1298 | + @patch("nemo_run.cli.lazy.LazyEntrypoint.resolve") |
| 1299 | + def test_export_section_lazy(self, mock_resolve, temp_dir): |
| 1300 | + # Define local classes for clarity in this test |
| 1301 | + @dataclass |
| 1302 | + class SectionExportModel: |
| 1303 | + hidden_size: int = 0 |
| 1304 | + |
| 1305 | + @dataclass |
| 1306 | + class SectionExportTrainer: |
| 1307 | + model: SectionExportModel |
| 1308 | + learning_rate: float = 0.001 |
| 1309 | + |
| 1310 | + lazy_config = LazyEntrypoint("test.cli.test_api.SectionExportTrainer") |
| 1311 | + lazy_config.model = LazyEntrypoint("test.cli.test_api.SectionExportModel") |
| 1312 | + lazy_config.model.hidden_size = 500 |
| 1313 | + lazy_config.learning_rate = 0.05 # Set on parent |
| 1314 | + |
| 1315 | + # Configure the mock resolve for the 'model' LazyEntrypoint |
| 1316 | + # It should return the equivalent resolved Config object that the serializer expects |
| 1317 | + resolved_model_config = Config(SectionExportModel, hidden_size=500) |
| 1318 | + |
| 1319 | + # Simpler mocking: Assume the relevant resolve call in this path |
| 1320 | + # will be the one on the model section, and return the expected config. |
| 1321 | + mock_resolve.return_value = resolved_model_config |
| 1322 | + |
| 1323 | + |
| 1324 | + yaml_path = temp_dir / "lazy_section.yaml" |
| 1325 | + |
| 1326 | + from nemo_run.cli.api import _serialize_configuration |
| 1327 | + |
| 1328 | + with patch("rich.console.Console") as mock_console: |
| 1329 | + _serialize_configuration( |
| 1330 | + lazy_config, to_yaml=f"{yaml_path}:model", is_lazy=True, console=mock_console |
| 1331 | + ) |
| 1332 | + |
| 1333 | + # Verify the mock was called (meaning the serializer tried to resolve the section) |
| 1334 | + mock_resolve.assert_called() |
| 1335 | + |
| 1336 | + # Verify only the model section was exported based on the mock resolved config |
| 1337 | + assert yaml_path.exists() |
| 1338 | + content = yaml_path.read_text() |
| 1339 | + |
| 1340 | + # Check content based on the 'resolved_model_config' the mock returned |
| 1341 | + assert "hidden_size: 500" in content |
| 1342 | + assert "_target_: test.cli.test_api.TestConfigExport.test_export_section_lazy.<locals>.SectionExportModel" in content |
| 1343 | + assert "learning_rate" not in content |
| 1344 | + assert "_factory_" not in content |
| 1345 | + |
| 1346 | + def test_export_error_handling(self, temp_dir): |
| 1347 | + config = Config(Model, hidden_size=100) |
| 1348 | + non_existent_path = temp_dir / "non_existent_dir" / "config.yaml" |
| 1349 | + |
| 1350 | + from nemo_run.cli.api import _serialize_configuration |
| 1351 | + mock_console = Mock(spec=Console) |
| 1352 | + |
| 1353 | + with pytest.raises(Exception): # Expecting FileNotFoundError or similar |
| 1354 | + _serialize_configuration( |
| 1355 | + config, |
| 1356 | + to_yaml=str(non_existent_path), |
| 1357 | + console=mock_console, |
| 1358 | + verbose=True # Test error printing in verbose mode |
| 1359 | + ) |
| 1360 | + |
| 1361 | + # Check that error message was printed |
| 1362 | + expected_error_msg = str(FileNotFoundError(f"[Errno 2] No such file or directory: '{str(non_existent_path)}'")) |
| 1363 | + mock_console.print.assert_called_with( |
| 1364 | + f"[bold red]Failed to export configuration to YAML:[/bold red] {expected_error_msg}" |
| 1365 | + ) |
| 1366 | + |
| 1367 | + def test_export_no_format_error(self): |
| 1368 | + from nemo_run.cli.api import _serialize_configuration |
| 1369 | + with pytest.raises(ValueError, match="At least one output format must be provided"): |
| 1370 | + _serialize_configuration(Config(int)) # Dummy config |
| 1371 | + |
| 1372 | + |
| 1373 | +class TestWorkspaceLoading: |
| 1374 | + @pytest.fixture(autouse=True) |
| 1375 | + def _setup_teardown(self, tmp_path, monkeypatch): |
| 1376 | + # Setup |
| 1377 | + original_cwd = os.getcwd() |
| 1378 | + nemorun_home_path = tmp_path / ".nemorun_home" |
| 1379 | + nemorun_home_path.mkdir() |
| 1380 | + nemorun_home_str = str(nemorun_home_path) |
| 1381 | + |
| 1382 | + monkeypatch.setenv("INCLUDE_WORKSPACE_FILE", "true") |
| 1383 | + monkeypatch.setattr(cli_api, "get_nemorun_home", lambda: nemorun_home_str) |
| 1384 | + |
| 1385 | + # Change directory for test file creation AND for the function under test |
| 1386 | + os.chdir(str(tmp_path)) |
| 1387 | + |
| 1388 | + # Clear cache *before* test runs |
| 1389 | + cli_api._load_workspace.cache_clear() |
| 1390 | + |
| 1391 | + yield tmp_path, nemorun_home_path |
| 1392 | + |
| 1393 | + # Teardown |
| 1394 | + os.chdir(original_cwd) # Restore original CWD |
| 1395 | + cli_api._load_workspace.cache_clear() |
| 1396 | + |
| 1397 | + def test_search_workspace_file_not_found(self, _setup_teardown): |
| 1398 | + # Fixture ensures CWD is tmp_path, no files created here |
| 1399 | + assert _search_workspace_file() is None |
| 1400 | + |
| 1401 | + def test_search_workspace_file_disabled(self, _setup_teardown, monkeypatch): |
| 1402 | + tmp_path, _ = _setup_teardown |
| 1403 | + monkeypatch.setenv("INCLUDE_WORKSPACE_FILE", "false") |
| 1404 | + ws_path = tmp_path / "workspace.py" |
| 1405 | + ws_path.touch() # Create a file that *would* be found otherwise |
| 1406 | + assert _search_workspace_file() is None # Should return None due to env var |
| 1407 | + |
| 1408 | + @patch("importlib.util.spec_from_file_location") |
| 1409 | + @patch("importlib.util.module_from_spec") |
| 1410 | + def test_load_workspace_file(self, mock_module_from_spec, mock_spec_from_file, _setup_teardown): |
| 1411 | + tmp_path, _ = _setup_teardown |
| 1412 | + mock_spec = Mock() |
| 1413 | + mock_spec.loader = Mock() |
| 1414 | + mock_spec_from_file.return_value = mock_spec |
| 1415 | + mock_module = Mock() |
| 1416 | + mock_module_from_spec.return_value = mock_module |
| 1417 | + ws_path = tmp_path / "dummy_ws.py" |
| 1418 | + ws_path.touch() |
| 1419 | + _load_workspace_file(str(ws_path)) |
| 1420 | + mock_spec_from_file.assert_called_once_with("workspace", str(ws_path)) |
| 1421 | + mock_module_from_spec.assert_called_once_with(mock_spec) |
| 1422 | + mock_spec.loader.exec_module.assert_called_once_with(mock_module) |
| 1423 | + |
| 1424 | + @patch("nemo_run.cli.api._search_workspace_file") |
| 1425 | + @patch("nemo_run.cli.api._load_workspace_file") |
| 1426 | + def test_load_workspace(self, mock_load_file, mock_search_file, _setup_teardown): |
| 1427 | + # Case 1: File found |
| 1428 | + ws_path = "/fake/path/workspace.py" |
| 1429 | + mock_search_file.return_value = ws_path |
| 1430 | + _load_workspace() # Call 1 |
| 1431 | + mock_search_file.assert_called_once() |
| 1432 | + mock_load_file.assert_called_once_with(ws_path) |
| 1433 | + |
| 1434 | + # Reset mocks and *explicitly clear cache* before second call |
| 1435 | + mock_search_file.reset_mock() |
| 1436 | + mock_load_file.reset_mock() |
| 1437 | + cli_api._load_workspace.cache_clear() # Reset cache |
| 1438 | + |
| 1439 | + # Case 2: File not found |
| 1440 | + mock_search_file.return_value = None |
| 1441 | + _load_workspace() # Call 2 |
| 1442 | + mock_search_file.assert_called_once() # Should call search again |
| 1443 | + mock_load_file.assert_not_called() |
| 1444 | + |
| 1445 | + @patch("nemo_run.cli.api._search_workspace_file") |
| 1446 | + @patch("nemo_run.cli.api._load_workspace_file") |
| 1447 | + def test_load_workspace_cached(self, mock_load_file, mock_search_file, _setup_teardown): |
| 1448 | + # Test caching behavior |
| 1449 | + ws_path = "/fake/path/workspace.py" |
| 1450 | + mock_search_file.return_value = ws_path |
| 1451 | + |
| 1452 | + # First call |
| 1453 | + _load_workspace() # Call 1 |
| 1454 | + mock_search_file.assert_called_once() |
| 1455 | + mock_load_file.assert_called_once_with(ws_path) |
| 1456 | + |
| 1457 | + # Reset counts but *not* the cache |
| 1458 | + mock_search_file.reset_mock() |
| 1459 | + mock_load_file.reset_mock() |
| 1460 | + |
| 1461 | + # Second call - should use cache |
| 1462 | + _load_workspace() # Call 2 |
| 1463 | + mock_search_file.assert_not_called() # Should not call search |
| 1464 | + mock_load_file.assert_not_called() # Should not call load |
0 commit comments