Skip to content

Commit eed1942

Browse files
authored
feat(cli): add 'gym env validate' pre-flight config check (#1205 friction #12) (#1599)
## What Adds **`gym env validate`** (+ `ng_validate` / `nemo_gym_validate` deprecated shims) — runs the full config parse with **no Ray and no server subprocesses**, then exits **0 (valid) / 1 (invalid)** with a clean, rich-escaped message (**no traceback**). Returns in well under a second instead of after a ~30–60s Ray bootstrap. ```bash gym env validate --config resources_servers/<env>/configs/<env>.yaml --config responses_api_models/<model>/configs/<model>.yaml gym env validate --benchmark gsm8k --model-type openai_model ``` ## How `validate()` lives in `cli/env.py` and is registered as `env validate` in the `gym` router (`cli/main.py` COMMANDS) with the same config-selection flags as `env start` (`--config`, `--benchmark`, `--environment`, `--resources-server`, `--model-type`, `--search-dir`, `--model*`). It reuses the same `get_global_config_dict()` parse path the other commands use, so the validation checks stay in sync: - **config_paths** resolution — missing/typo'd ([#1488](#1488)) and malformed ([#1490](#1490)) - **server cross-references** — unknown `name:` refs ([#1561](#1561)) - **mandatory `???`** values ([#1575](#1575)) - **schema** (`BaseNeMoGymCLIConfig`) Wrapped in `exit_cleanly_on_config_error` (from #1609) so any `ConfigError` becomes a clean message + `exit 1`. A dummy `policy_model` is injected (the `NO_MODEL` parser config, as in `gym list` / `env compose`) so model interpolations like `${policy_base_url}` resolve without real creds — validation is about config **well-formedness**; the real model is supplied by the `--model*` flags at run time. ## Targets `main` Originally drafted on the unified-CLI epic branch; rebuilt directly on `main` now that [#1630](#1630) (and #1637/#1609/#1635/#1671) have merged. The old branch contents (a snapshot of the CLI refactor + unrelated CI commits) were superseded and replaced. ## Scope note The zero-server check ([#1489](#1489), "nothing configured to run") is intentionally **not** part of `validate`: `NO_MODEL` injects a dummy model server (which would defeat the check), and "is anything configured to run" is a *start*-time concern already enforced by `gym env start` before Ray init. `validate` focuses on config well-formedness. ## Why Epic [#1205](#1205) friction #12 (no config validation tooling) — the M1 "fast failure triage" deliverable. Config errors otherwise only surface after Ray starts (~30–60s). ## Tests - `test_cli_main.py`: `gym env validate --config X` routes to `nemo_gym.cli.env:validate` with `+config_paths=[X]` (added to the parametrized config-command matrix). - `test_cli.py`: `validate()` prints OK on a valid config; a raised `ConfigError` becomes `exit 1` (no traceback). - All `test_cli` + `test_cli_main` + `test_cli_legacy` pass (the only failures are the pre-existing Python-3.12 `TestDidYouMean` argparse issue on `main`); ruff + pre-commit clean. Smoke-tested end-to-end: `✓ Config is valid.` on a real benchmark, clean error + `exit 1` on a bad path, and the `ng_validate` deprecation shim. --------- Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
1 parent 180960b commit eed1942

7 files changed

Lines changed: 106 additions & 4 deletions

File tree

nemo_gym/cli/env.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,39 @@ def dump_config(): # pragma: no cover
864864
print(OmegaConf.to_yaml(global_config_dict, resolve=True))
865865

866866

867+
@exit_cleanly_on_config_error
868+
def validate():
869+
"""Validate a config without starting Ray or any server subprocess.
870+
871+
Runs the full config parse — config_paths resolution (missing/malformed), server cross-reference
872+
validation, mandatory `???` values, and schema — then exits 0 (valid) or, via
873+
`exit_cleanly_on_config_error`, 1 with a clean traceback-free message. No Ray, no servers, so it
874+
returns in well under a second instead of after Ray bootstrap.
875+
876+
No model config is required: a dummy `policy_model` is injected (the `NO_MODEL` parser config, as
877+
in `gym list` / `env compose`) so model interpolations (e.g. `${policy_base_url}`) resolve —
878+
validation is about config well-formedness, not the model. Pass a model config / `--model-type`
879+
as well if you want it validated too.
880+
881+
Examples:
882+
883+
```bash
884+
gym env validate --environment <env>
885+
gym env validate --benchmark <benchmark>
886+
# or by explicit config path(s):
887+
gym env validate --config resources_servers/<env>/configs/<env>.yaml
888+
```
889+
"""
890+
global_config_dict = get_global_config_dict(
891+
global_config_dict_parser_config=GlobalConfigDictParserConfig(
892+
initial_global_config_dict=GlobalConfigDictParserConfig.NO_MODEL_GLOBAL_CONFIG_DICT,
893+
),
894+
)
895+
BaseNeMoGymCLIConfig.model_validate(global_config_dict)
896+
897+
rich.print("[green]✓[/green] Config is valid.")
898+
899+
867900
def list_environments() -> None:
868901
"""List the environments available under environments/, by short name.
869902

nemo_gym/cli/legacy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"gitlab_to_hf_dataset": ["dataset", "migrate"],
4949
"delete_dataset_from_gitlab": ["dataset", "rm"],
5050
"dump_config": ["env", "resolve"],
51+
"validate": ["env", "validate"],
5152
"help": ["--help"],
5253
"status": ["env", "status"],
5354
"pip_list": ["env", "packages"],

nemo_gym/cli/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,21 @@ def _dataset_download(args: argparse.Namespace, overrides: list[str]) -> None:
397397
summary="Resolve the final config from configs, flags, and overrides.",
398398
flags=(CONFIG,),
399399
),
400+
"env validate": Command(
401+
target="nemo_gym.cli.env:validate",
402+
summary="Validate a config (paths, cross-refs, ??? values, servers) fast — no Ray, no servers.",
403+
flags=(
404+
CONFIG,
405+
BENCHMARK,
406+
ENVIRONMENT,
407+
RESOURCES_SERVER_CONFIG,
408+
MODEL_TYPE,
409+
SEARCH_DIR,
410+
MODEL,
411+
MODEL_URL,
412+
MODEL_API_KEY,
413+
),
414+
),
400415
"env packages": Command(
401416
target="nemo_gym.cli.env:pip_list",
402417
summary="Print pip packages for the selected resources server.",

nemo_gym/config_types.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,6 @@ def is_server_ref(config_dict: DictConfig) -> Optional[ServerRef]:
137137
return None
138138

139139

140-
class ServerRefNotFoundError(ValueError):
141-
"""A server cross-reference points to an instance that is not defined in the merged config."""
142-
143-
144140
class ConfigError(Exception):
145141
"""Base for user-facing configuration errors.
146142
@@ -167,6 +163,10 @@ class ConfigMissingValuesError(ConfigError, ValueError):
167163
"""One or more required config values are still unset (OmegaConf '???') after merging."""
168164

169165

166+
class ServerRefNotFoundError(ConfigError, ValueError):
167+
"""A server cross-reference points to an instance that is not defined in the merged config."""
168+
169+
170170
########################################
171171
# Dataset configs for handling and upload/download
172172
########################################

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,9 @@ ng_delete_dataset_from_gitlab = "nemo_gym.cli.legacy:main"
406406
nemo_gym_dump_config = "nemo_gym.cli.legacy:main"
407407
ng_dump_config = "nemo_gym.cli.legacy:main"
408408

409+
nemo_gym_validate = "nemo_gym.cli.legacy:main"
410+
ng_validate = "nemo_gym.cli.legacy:main"
411+
409412
# Display help
410413
nemo_gym_help = "nemo_gym.cli.legacy:main"
411414
ng_help = "nemo_gym.cli.legacy:main"

tests/unit_tests/test_cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# limitations under the License.
1515
import json
1616
import shutil
17+
import sys
1718
import tomllib
1819
from importlib import import_module
1920
from pathlib import Path
@@ -35,6 +36,7 @@
3536
exit_cleanly_on_config_error,
3637
init_resources_server,
3738
list_environments,
39+
validate,
3840
)
3941
from nemo_gym.config_types import ConfigError, NoServerInstancesError, ResourcesServerInstanceConfig
4042
from nemo_gym.registry import EnvironmentEntry
@@ -290,6 +292,53 @@ def test_config_error_base_catches_subclasses(self) -> None:
290292
assert issubclass(NoServerInstancesError, ConfigError)
291293

292294

295+
class TestValidate:
296+
def _validate_config(self, monkeypatch: MonkeyPatch, tmp_path: Path, config_yaml: str) -> None:
297+
"""Run the real `validate()` against a config file (no mocking of the parse path)."""
298+
config_file = tmp_path / "config.yaml"
299+
config_file.write_text(config_yaml)
300+
# chdir to a clean dir so a repo-local env.yaml isn't picked up; clear the parse cache.
301+
monkeypatch.chdir(tmp_path)
302+
monkeypatch.setattr(sys, "argv", ["gym", f"+config_paths=[{config_file}]"])
303+
monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)
304+
validate()
305+
306+
def test_valid_config_passes(self, monkeypatch: MonkeyPatch, tmp_path, capsys) -> None:
307+
# A well-formed config (real parse, real checks) -> "valid".
308+
self._validate_config(
309+
monkeypatch,
310+
tmp_path,
311+
"my_server:\n resources_servers:\n my_server:\n entrypoint: app.py\n domain: other\n",
312+
)
313+
assert "valid" in capsys.readouterr().out.lower()
314+
315+
def test_unknown_cross_reference_exits_nonzero(self, monkeypatch: MonkeyPatch, tmp_path) -> None:
316+
# An agent referencing a resources server that isn't defined -> ServerRefNotFoundError ->
317+
# clean exit(1) (real cross-reference validation, not a mock).
318+
bad = (
319+
"my_agent:\n"
320+
" responses_api_agents:\n"
321+
" a:\n"
322+
" entrypoint: app.py\n"
323+
" resources_server:\n type: resources_servers\n name: does_not_exist\n"
324+
" model_server:\n type: responses_api_models\n name: policy_model\n"
325+
)
326+
with raises(SystemExit) as exc_info:
327+
self._validate_config(monkeypatch, tmp_path, bad)
328+
assert exc_info.value.code == 1
329+
330+
def test_config_error_becomes_clean_exit(self, monkeypatch: MonkeyPatch) -> None:
331+
# Fast unit check of the decorator path: any ConfigError -> exit(1), no traceback.
332+
def _raise(**kwargs):
333+
raise NoServerInstancesError("nothing configured to run")
334+
335+
monkeypatch.setattr(nemo_gym.cli.env, "get_global_config_dict", _raise)
336+
337+
with raises(SystemExit) as exc_info:
338+
validate()
339+
assert exc_info.value.code == 1
340+
341+
293342
class TestListEnvironments:
294343
_ALPHA = EnvironmentEntry(
295344
name="alpha",

tests/unit_tests/test_cli_main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def _split_overrides(overrides: list[str]) -> tuple[set[str], set[str]]:
5454
CONFIG_COMMANDS = [
5555
(["env", "start"], "nemo_gym.cli.env:run"),
5656
(["env", "resolve"], "nemo_gym.cli.env:dump_config"),
57+
(["env", "validate"], "nemo_gym.cli.env:validate"),
5758
(["eval", "prepare"], "nemo_gym.cli.eval:prepare_benchmark"),
5859
(["eval", "aggregate"], "nemo_gym.cli.eval:aggregate_rollouts"),
5960
(["eval", "run"], "nemo_gym.cli.eval:e2e_rollout_collection"),

0 commit comments

Comments
 (0)