Skip to content

Commit a59d2fd

Browse files
committed
Increase test-coverage
Signed-off-by: Marc Romeyn <[email protected]>
1 parent cb2b6f6 commit a59d2fd

File tree

2 files changed

+701
-41
lines changed

2 files changed

+701
-41
lines changed

test/cli/test_api.py

Lines changed: 324 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# limitations under the License.
1515

1616
import os
17+
import sys
1718
from configparser import ConfigParser
1819
from dataclasses import dataclass, field
1920
from typing import Annotated, List, Optional, Union
@@ -24,12 +25,26 @@
2425
import typer
2526
from importlib_metadata import EntryPoint, EntryPoints
2627
from typer.testing import CliRunner
28+
from rich.console import Console
2729

2830
import nemo_run as run
2931
from nemo_run import cli, config
3032
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+
)
3246
from test.dummy_factory import DummyModel, dummy_entrypoint
47+
import nemo_run.cli.cli_parser # Import the module to mock its function
3348

3449
_RUN_FACTORIES_ENTRYPOINT: str = """
3550
[nemo_run.cli]
@@ -239,6 +254,120 @@ def sample_function(a, b):
239254
ctx.cli_execute(sample_function, ["a=10", "b=hello", "run.detach=False"])
240255
assert not ctx.detach
241256

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+
242371

243372
@dataclass
244373
class SomeObject:
@@ -1139,3 +1268,197 @@ def my_model(hidden_size: int = 1000) -> Model:
11391268
config,
11401269
to_yaml=f"{yaml_path}:invalid",
11411270
)
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

Comments
 (0)