nemo-evaluator-launcher 0.1.0rc6__py3-none-any.whl → 0.1.41__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. nemo_evaluator_launcher/__init__.py +15 -1
  2. nemo_evaluator_launcher/api/functional.py +188 -27
  3. nemo_evaluator_launcher/api/types.py +9 -0
  4. nemo_evaluator_launcher/cli/export.py +131 -12
  5. nemo_evaluator_launcher/cli/info.py +477 -82
  6. nemo_evaluator_launcher/cli/kill.py +5 -3
  7. nemo_evaluator_launcher/cli/logs.py +102 -0
  8. nemo_evaluator_launcher/cli/ls_runs.py +31 -10
  9. nemo_evaluator_launcher/cli/ls_tasks.py +105 -3
  10. nemo_evaluator_launcher/cli/main.py +101 -5
  11. nemo_evaluator_launcher/cli/run.py +153 -30
  12. nemo_evaluator_launcher/cli/status.py +49 -5
  13. nemo_evaluator_launcher/cli/version.py +26 -23
  14. nemo_evaluator_launcher/common/execdb.py +121 -27
  15. nemo_evaluator_launcher/common/helpers.py +213 -33
  16. nemo_evaluator_launcher/common/logging_utils.py +16 -5
  17. nemo_evaluator_launcher/common/printing_utils.py +100 -0
  18. nemo_evaluator_launcher/configs/deployment/generic.yaml +33 -0
  19. nemo_evaluator_launcher/configs/deployment/sglang.yaml +4 -2
  20. nemo_evaluator_launcher/configs/deployment/trtllm.yaml +23 -0
  21. nemo_evaluator_launcher/configs/deployment/vllm.yaml +2 -2
  22. nemo_evaluator_launcher/configs/execution/local.yaml +2 -0
  23. nemo_evaluator_launcher/configs/execution/slurm/default.yaml +19 -4
  24. nemo_evaluator_launcher/executors/base.py +54 -1
  25. nemo_evaluator_launcher/executors/lepton/deployment_helpers.py +60 -5
  26. nemo_evaluator_launcher/executors/lepton/executor.py +240 -101
  27. nemo_evaluator_launcher/executors/lepton/job_helpers.py +15 -11
  28. nemo_evaluator_launcher/executors/local/executor.py +492 -56
  29. nemo_evaluator_launcher/executors/local/run.template.sh +76 -9
  30. nemo_evaluator_launcher/executors/slurm/executor.py +571 -98
  31. nemo_evaluator_launcher/executors/slurm/proxy.cfg.template +26 -0
  32. nemo_evaluator_launcher/exporters/base.py +9 -0
  33. nemo_evaluator_launcher/exporters/gsheets.py +27 -9
  34. nemo_evaluator_launcher/exporters/local.py +30 -16
  35. nemo_evaluator_launcher/exporters/mlflow.py +245 -74
  36. nemo_evaluator_launcher/exporters/utils.py +139 -184
  37. nemo_evaluator_launcher/exporters/wandb.py +157 -43
  38. nemo_evaluator_launcher/package_info.py +6 -3
  39. nemo_evaluator_launcher/resources/mapping.toml +56 -15
  40. nemo_evaluator_launcher-0.1.41.dist-info/METADATA +494 -0
  41. nemo_evaluator_launcher-0.1.41.dist-info/RECORD +62 -0
  42. {nemo_evaluator_launcher-0.1.0rc6.dist-info → nemo_evaluator_launcher-0.1.41.dist-info}/entry_points.txt +1 -0
  43. nemo_evaluator_launcher-0.1.0rc6.dist-info/METADATA +0 -35
  44. nemo_evaluator_launcher-0.1.0rc6.dist-info/RECORD +0 -57
  45. {nemo_evaluator_launcher-0.1.0rc6.dist-info → nemo_evaluator_launcher-0.1.41.dist-info}/WHEEL +0 -0
  46. {nemo_evaluator_launcher-0.1.0rc6.dist-info → nemo_evaluator_launcher-0.1.41.dist-info}/licenses/LICENSE +0 -0
  47. {nemo_evaluator_launcher-0.1.0rc6.dist-info → nemo_evaluator_launcher-0.1.41.dist-info}/top_level.txt +0 -0
@@ -20,6 +20,7 @@ It automatically initializes logging and conditionally loads internal components
20
20
  """
21
21
 
22
22
  import importlib
23
+ import warnings
23
24
 
24
25
  from nemo_evaluator_launcher.common.logging_utils import logger
25
26
  from nemo_evaluator_launcher.package_info import (
@@ -32,9 +33,22 @@ from nemo_evaluator_launcher.package_info import (
32
33
  __version__,
33
34
  )
34
35
 
35
- logger.info("Version info", pkg=__package_name__, ver=__version__)
36
+ # Suppress pydantic warnings from third-party libraries (e.g., wandb) that are not
37
+ # compatible with Pydantic 2.x field metadata on Python 3.13+
38
+ warnings.filterwarnings(
39
+ "ignore",
40
+ message=r"The 'repr' attribute.*Field\(\).*",
41
+ category=Warning,
42
+ )
43
+ warnings.filterwarnings(
44
+ "ignore",
45
+ message=r"The 'frozen' attribute.*Field\(\).*",
46
+ category=Warning,
47
+ )
36
48
 
37
49
 
50
+ logger.info("Version info", pkg=__package_name__, ver=__version__)
51
+
38
52
  try:
39
53
  importlib.import_module("nemo_evaluator_launcher_internal")
40
54
  logger.debug(
@@ -18,8 +18,10 @@
18
18
  This module provides the main functional entry points for running evaluations, querying job status, and listing available tasks. These functions are intended to be used by CLI commands and external integrations.
19
19
  """
20
20
 
21
- from typing import Any, List, Optional, Union
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
22
23
 
24
+ import yaml
23
25
  from omegaconf import DictConfig, OmegaConf
24
26
 
25
27
  from nemo_evaluator_launcher.api.types import RunConfig
@@ -97,11 +99,13 @@ def run_eval(cfg: RunConfig, dry_run: bool = False) -> Optional[str]:
97
99
  return get_executor(cfg.execution.type).execute_eval(cfg, dry_run)
98
100
 
99
101
 
100
- def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
102
+ def get_status(ids_or_prefixes: list[str]) -> list[dict[str, Any]]:
101
103
  """Get status of jobs by their IDs or invocation IDs.
102
104
 
103
105
  Args:
104
- job_ids: List of job IDs or invocation IDs to check status for.
106
+ job_ids: List of job IDs or invocation IDs to check status for. Short ones are allowed,
107
+ we would try to match the full ones from prefixes if no collisions are
108
+ present.
105
109
 
106
110
  Returns:
107
111
  list[dict[str, Any]]: List of status dictionaries for each job or invocation.
@@ -112,14 +116,15 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
112
116
  db = ExecutionDB()
113
117
  results: List[dict[str, Any]] = []
114
118
 
115
- for job_id in job_ids:
116
- # If id looks like an invocation_id (8 hex digits, no dot), get all jobs for it
117
- if len(job_id) == 8 and "." not in job_id:
118
- jobs = db.get_jobs(job_id)
119
+ # TODO(agronskiy): refactor the `.`-checking job in all the functions.
120
+ for id_or_prefix in ids_or_prefixes:
121
+ # If id looks like an invocation_id (no dot), get all jobs for it
122
+ if "." not in id_or_prefix:
123
+ jobs = db.get_jobs(id_or_prefix)
119
124
  if not jobs:
120
125
  results.append(
121
126
  {
122
- "invocation": job_id,
127
+ "invocation": id_or_prefix,
123
128
  "job_id": None,
124
129
  "status": "not_found",
125
130
  "data": {},
@@ -134,7 +139,7 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
134
139
  except ValueError as e:
135
140
  results.append(
136
141
  {
137
- "invocation": job_id,
142
+ "invocation": id_or_prefix,
138
143
  "job_id": None,
139
144
  "status": "error",
140
145
  "data": {"error": str(e)},
@@ -144,7 +149,7 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
144
149
 
145
150
  # Get status from the executor for all jobs in the invocation
146
151
  try:
147
- status_list = executor_cls.get_status(job_id)
152
+ status_list = executor_cls.get_status(id_or_prefix)
148
153
 
149
154
  # Create a result for each job in the invocation
150
155
  for job_id_in_invocation, job_data in jobs.items():
@@ -159,7 +164,7 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
159
164
 
160
165
  results.append(
161
166
  {
162
- "invocation": job_id,
167
+ "invocation": job_data.invocation_id,
163
168
  "job_id": job_id_in_invocation,
164
169
  "status": (
165
170
  job_status if job_status is not None else "unknown"
@@ -174,7 +179,7 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
174
179
  except Exception as e:
175
180
  results.append(
176
181
  {
177
- "invocation": job_id,
182
+ "invocation": id_or_prefix,
178
183
  "job_id": None,
179
184
  "status": "error",
180
185
  "data": {"error": str(e)},
@@ -182,13 +187,13 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
182
187
  )
183
188
  else:
184
189
  # Otherwise, treat as job_id
185
- single_job_data: Optional[JobData] = db.get_job(job_id)
190
+ single_job_data: Optional[JobData] = db.get_job(id_or_prefix)
186
191
 
187
192
  if single_job_data is None:
188
193
  results.append(
189
194
  {
190
195
  "invocation": None,
191
- "job_id": job_id,
196
+ "job_id": id_or_prefix,
192
197
  "status": "not_found",
193
198
  "data": {},
194
199
  }
@@ -202,7 +207,7 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
202
207
  results.append(
203
208
  {
204
209
  "invocation": None,
205
- "job_id": job_id,
210
+ "job_id": id_or_prefix,
206
211
  "status": "error",
207
212
  "data": {"error": str(e)},
208
213
  }
@@ -211,13 +216,13 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
211
216
 
212
217
  # Get status from the executor
213
218
  try:
214
- status_list = executor_cls.get_status(job_id)
219
+ status_list = executor_cls.get_status(id_or_prefix)
215
220
 
216
221
  if not status_list:
217
222
  results.append(
218
223
  {
219
224
  "invocation": single_job_data.invocation_id,
220
- "job_id": job_id,
225
+ "job_id": single_job_data.job_id,
221
226
  "status": "unknown",
222
227
  "data": single_job_data.data,
223
228
  }
@@ -227,7 +232,7 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
227
232
  results.append(
228
233
  {
229
234
  "invocation": single_job_data.invocation_id,
230
- "job_id": job_id,
235
+ "job_id": single_job_data.job_id,
231
236
  "status": (
232
237
  status_list[0].state.value if status_list else "unknown"
233
238
  ),
@@ -244,7 +249,9 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
244
249
  "invocation": (
245
250
  single_job_data.invocation_id if single_job_data else None
246
251
  ),
247
- "job_id": job_id,
252
+ "job_id": (
253
+ single_job_data.job_id if single_job_data else id_or_prefix
254
+ ),
248
255
  "status": "error",
249
256
  "data": {"error": str(e)},
250
257
  }
@@ -253,6 +260,108 @@ def get_status(job_ids: list[str]) -> list[dict[str, Any]]:
253
260
  return results
254
261
 
255
262
 
263
+ def stream_logs(
264
+ ids_or_prefixes: Union[str, list[str]],
265
+ ) -> Iterator[Tuple[str, str, str]]:
266
+ """Stream logs from jobs or invocations by their IDs or invocation IDs.
267
+
268
+ Args:
269
+ ids_or_prefixes: Single ID/prefix or list of job IDs or invocation IDs to stream logs from.
270
+ Short prefixes are allowed, we would try to match the full ones from
271
+ prefixes if no collisions are present.
272
+
273
+ Yields:
274
+ Tuple[str, str, str]: Tuples of (job_id, task_name, log_line) for each log line.
275
+ Empty lines are yielded as empty strings.
276
+
277
+ Raises:
278
+ ValueError: If the executor doesn't support log streaming.
279
+ """
280
+ db = ExecutionDB()
281
+
282
+ # Normalize to list for consistent processing
283
+ if isinstance(ids_or_prefixes, str):
284
+ ids_or_prefixes = [ids_or_prefixes]
285
+
286
+ # Collect all jobs from all IDs, grouped by executor
287
+ executor_to_jobs: Dict[str, Dict[str, JobData]] = {}
288
+ executor_to_invocations: Dict[str, List[str]] = {}
289
+
290
+ # TODO(agronskiy): refactor the `.`-checking job in all the functions.
291
+ for id_or_prefix in ids_or_prefixes:
292
+ # Determine if this is a job ID or invocation ID
293
+ if "." in id_or_prefix:
294
+ # This is a job ID
295
+ job_data = db.get_job(id_or_prefix)
296
+ if job_data is None:
297
+ continue
298
+
299
+ executor = job_data.executor
300
+ if executor not in executor_to_jobs:
301
+ executor_to_jobs[executor] = {}
302
+ executor_to_jobs[executor][id_or_prefix] = job_data
303
+ else:
304
+ # This is an invocation ID
305
+ jobs = db.get_jobs(id_or_prefix)
306
+ if not jobs:
307
+ continue
308
+
309
+ # Get the executor class from the first job
310
+ first_job_data = next(iter(jobs.values()))
311
+ executor = first_job_data.executor
312
+ if executor not in executor_to_invocations:
313
+ executor_to_invocations[executor] = []
314
+ executor_to_invocations[executor].append(id_or_prefix)
315
+
316
+ # Stream logs from each executor simultaneously
317
+ # For each executor, collect all job IDs and stream them together
318
+ for executor, jobs_dict in executor_to_jobs.items():
319
+ try:
320
+ executor_cls = get_executor(executor)
321
+ except ValueError:
322
+ continue
323
+
324
+ # For local executor with multiple jobs, pass list to stream simultaneously
325
+ # For other executors or single jobs, pass individual job IDs
326
+ if executor == "local" and len(jobs_dict) > 1:
327
+ # Pass all job IDs as a list to stream simultaneously
328
+ try:
329
+ yield from executor_cls.stream_logs(
330
+ list(jobs_dict.keys()), executor_name=executor
331
+ )
332
+ except NotImplementedError:
333
+ raise ValueError(
334
+ f"Log streaming is not yet implemented for executor '{executor}'"
335
+ )
336
+ else:
337
+ # Single job or non-local executor
338
+ for job_id in jobs_dict.keys():
339
+ try:
340
+ yield from executor_cls.stream_logs(job_id, executor_name=executor)
341
+ except NotImplementedError:
342
+ raise ValueError(
343
+ f"Log streaming is not yet implemented for executor '{executor}'"
344
+ )
345
+
346
+ # Stream logs from invocation IDs
347
+ for executor, invocation_ids in executor_to_invocations.items():
348
+ try:
349
+ executor_cls = get_executor(executor)
350
+ except ValueError:
351
+ continue
352
+
353
+ # Stream each invocation (each invocation already handles multiple jobs internally)
354
+ for invocation_id in invocation_ids:
355
+ try:
356
+ yield from executor_cls.stream_logs(
357
+ invocation_id, executor_name=executor
358
+ )
359
+ except NotImplementedError:
360
+ raise ValueError(
361
+ f"Log streaming is not yet implemented for executor '{executor}'"
362
+ )
363
+
364
+
256
365
  def list_all_invocations_summary() -> list[dict[str, Any]]:
257
366
  """Return a concise per-invocation summary from the exec DB.
258
367
 
@@ -372,6 +481,7 @@ def kill_job_or_invocation(id: str) -> list[dict[str, Any]]:
372
481
  "data": {"error": f"Unexpected error: {str(e)}"},
373
482
  }
374
483
 
484
+ # TODO(agronskiy): refactor the `.`-checking job in all the functions.
375
485
  # Determine if this is a job ID or invocation ID
376
486
  if "." in id:
377
487
  # This is a job ID - kill single job
@@ -434,14 +544,66 @@ def export_results(
434
544
  single_id = invocation_ids[0]
435
545
 
436
546
  if "." in single_id: # job_id
437
- job_data = ExecutionDB().get_job(single_id)
547
+ # Try reading config from artifacts working dir (auto-export on remote node)
548
+ cfg_file = None
549
+ for name in ("run_config.yml", "config.yml"):
550
+ p = Path(name)
551
+ if p.exists():
552
+ cfg_file = p
553
+ break
554
+
555
+ md_job_data = None
556
+ if cfg_file:
557
+ try:
558
+ cfg_yaml = (
559
+ yaml.safe_load(cfg_file.read_text(encoding="utf-8")) or {}
560
+ )
561
+
562
+ # Merge exporter override file if present
563
+ ypath_export = Path("export_config.yml")
564
+ if ypath_export.exists():
565
+ exp_yaml = (
566
+ yaml.safe_load(ypath_export.read_text(encoding="utf-8"))
567
+ or {}
568
+ )
569
+ exec_cfg = cfg_yaml.get("execution") or {}
570
+ auto_exp = (exp_yaml.get("execution") or {}).get(
571
+ "auto_export"
572
+ )
573
+ if auto_exp is not None:
574
+ exec_cfg["auto_export"] = auto_exp
575
+ cfg_yaml["execution"] = exec_cfg
576
+ if "export" in exp_yaml:
577
+ cfg_yaml["export"] = exp_yaml["export"]
578
+ if "evaluation" in exp_yaml and exp_yaml["evaluation"]:
579
+ eval_cfg = cfg_yaml.get("evaluation") or {}
580
+ eval_cfg.update(exp_yaml["evaluation"])
581
+ cfg_yaml["evaluation"] = eval_cfg
582
+
583
+ executor_name = (cfg_yaml.get("execution") or {}).get(
584
+ "type", "local"
585
+ )
586
+ md_job_data = JobData(
587
+ invocation_id=single_id.split(".")[0],
588
+ job_id=single_id,
589
+ timestamp=0.0,
590
+ executor=executor_name, # ensures slurm tag is preserved
591
+ data={
592
+ "output_dir": str(Path.cwd().parent),
593
+ "storage_type": "remote_local", # no SSH in auto-export path
594
+ },
595
+ config=cfg_yaml,
596
+ )
597
+ except Exception:
598
+ md_job_data = None
599
+
600
+ job_data = md_job_data or ExecutionDB().get_job(single_id)
438
601
  if job_data is None:
439
602
  return {
440
603
  "success": False,
441
604
  "error": f"Job {single_id} not found in ExecutionDB",
442
605
  }
443
606
 
444
- # Convert single job result to invocation-like structure
445
607
  job_result = exporter.export_job(job_data)
446
608
  return {
447
609
  "success": job_result.success,
@@ -451,14 +613,14 @@ def export_results(
451
613
  "success": job_result.success,
452
614
  "message": job_result.message,
453
615
  "metadata": job_result.metadata or {},
616
+ "dest": getattr(job_result, "dest", None),
454
617
  }
455
618
  },
456
619
  "metadata": job_result.metadata or {},
457
620
  }
621
+
458
622
  elif single_id.isdigit(): # pipeline_id
459
- # Find job by pipeline_id
460
623
  db = ExecutionDB()
461
- # Search all jobs for matching pipeline_id
462
624
  for job_id, job_data in db._jobs.items():
463
625
  if job_data.data.get("pipeline_id") == int(single_id):
464
626
  job_result = exporter.export_job(job_data)
@@ -475,14 +637,13 @@ def export_results(
475
637
  "metadata": job_result.metadata or {},
476
638
  }
477
639
  return {"success": False, "error": f"Pipeline {single_id} not found"}
640
+
478
641
  else: # invocation_id
479
642
  result = exporter.export_invocation(single_id)
480
- # Ensure metadata is present in job results to prevent KeyError
481
643
  if "jobs" in result:
482
644
  for job_id, job_result in result["jobs"].items():
483
- if "metadata" not in job_result:
484
- job_result["metadata"] = {}
485
- return result # type: ignore[no-any-return]
645
+ job_result.setdefault("metadata", {})
646
+ return result
486
647
  else:
487
648
  # Multiple IDs - parse and group
488
649
  db = ExecutionDB()
@@ -19,9 +19,18 @@ This module defines data structures and helpers for configuration and type safet
19
19
  """
20
20
 
21
21
  import os
22
+ import warnings
22
23
  from dataclasses import dataclass
23
24
  from typing import cast
24
25
 
26
+ # ruff: noqa: E402
27
+ # Later when adding optional module to hydra, since the internal package is optional,
28
+ # will generate a hydra warning. We suppress it as distraction and bad UX, before hydra gets invoked.
29
+ warnings.filterwarnings(
30
+ "ignore",
31
+ message="provider=hydra.searchpath.*path=nemo_evaluator_launcher_internal.*is not available\\.",
32
+ )
33
+
25
34
  import hydra
26
35
  from hydra.core.global_hydra import GlobalHydra
27
36
  from omegaconf import DictConfig, OmegaConf
@@ -20,8 +20,6 @@ from typing import Any, List, Optional
20
20
 
21
21
  from simple_parsing import field
22
22
 
23
- from nemo_evaluator_launcher.api.functional import export_results
24
-
25
23
 
26
24
  @dataclass
27
25
  class ExportCmd:
@@ -29,8 +27,8 @@ class ExportCmd:
29
27
 
30
28
  # Short usage examples will show up in -h as the class docstring:
31
29
  # Examples:
32
- # nemo-evaluator-launcher export 8abcd123 --dest local --format json -o .
33
- # nemo-evaluator-launcher export 8abcd123.0 9ef01234 --dest local --format csv -o results/ -fname processed_results.csv
30
+ # nemo-evaluator-launcher export 8abcd123 --dest local --format json --out .
31
+ # nemo-evaluator-launcher export 8abcd123.0 9ef01234 --dest local --format csv --out results/ -fname processed_results.csv
34
32
  # nemo-evaluator-launcher export 8abcd123 --dest jet
35
33
 
36
34
  invocation_ids: List[str] = field(
@@ -43,9 +41,17 @@ class ExportCmd:
43
41
  choices=["local", "wandb", "mlflow", "gsheets", "jet"],
44
42
  help="Export destination.",
45
43
  )
44
+ # overrides for exporter config; use -o similar to run command
45
+ override: List[str] = field(
46
+ default_factory=list,
47
+ action="append",
48
+ nargs="?",
49
+ alias=["-o", "--override"],
50
+ help="Hydra-style overrides for exporter config. Use `export.<dest>.key=value` (e.g., -o export.wandb.entity=org-name).",
51
+ )
46
52
  output_dir: Optional[str] = field(
47
53
  default=".",
48
- alias=["--output-dir", "-o"],
54
+ alias=["--output-dir", "-out"],
49
55
  help="Output directory (default: current directory).",
50
56
  )
51
57
  output_filename: Optional[str] = field(
@@ -69,17 +75,29 @@ class ExportCmd:
69
75
  alias=["--log-metrics"],
70
76
  help="Filter metrics by name (repeatable). Examples: score, f1, mmlu_score_micro.",
71
77
  )
72
- only_required: bool = field(
73
- default=True,
78
+ only_required: Optional[bool] = field(
79
+ default=None,
74
80
  alias=["--only-required"],
75
81
  help="Copy only required+optional artifacts (default: True). Set to False to copy all available artifacts.",
76
82
  )
77
83
 
78
84
  def execute(self) -> None:
79
85
  """Execute export."""
86
+ # Import heavy dependencies only when needed
87
+ from omegaconf import OmegaConf
88
+
89
+ from nemo_evaluator_launcher.api.functional import export_results
90
+
91
+ # Validation: ensure IDs are provided
92
+ if not self.invocation_ids:
93
+ print("Error: No IDs provided. Specify one or more invocation or job IDs.")
94
+ print(
95
+ "Usage: nemo-evaluator-launcher export <id> [<id>...] --dest <destination>"
96
+ )
97
+ return
98
+
80
99
  config: dict[str, Any] = {
81
100
  "copy_logs": self.copy_logs,
82
- "only_required": self.only_required,
83
101
  }
84
102
 
85
103
  # Output handling
@@ -94,20 +112,90 @@ class ExportCmd:
94
112
  if self.log_metrics:
95
113
  config["log_metrics"] = self.log_metrics
96
114
 
115
+ # Add only_required if explicitly passed via CLI
116
+ if self.only_required is not None:
117
+ config["only_required"] = self.only_required
118
+
119
+ # Parse and validate overrides
120
+ if self.override:
121
+ # Flatten possible list-of-lists from parser
122
+ flat_overrides: list[str] = []
123
+ for item in self.override:
124
+ if isinstance(item, list):
125
+ flat_overrides.extend(str(x) for x in item)
126
+ else:
127
+ flat_overrides.append(str(item))
128
+
129
+ try:
130
+ self._validate_overrides(flat_overrides, self.dest)
131
+ except ValueError as e:
132
+ print(f"Error: {e}")
133
+ return
134
+
135
+ # Expand env vars in override vals ($VAR / ${VAR})
136
+ import os
137
+
138
+ from omegaconf import OmegaConf
139
+
140
+ expanded_overrides: list[str] = []
141
+ for ov in flat_overrides:
142
+ if "=" in ov:
143
+ k, v = ov.split("=", 1)
144
+ expanded_overrides.append(f"{k}={os.path.expandvars(v)}")
145
+ else:
146
+ expanded_overrides.append(os.path.expandvars(ov))
147
+
148
+ dot_cfg = OmegaConf.from_dotlist(expanded_overrides)
149
+ as_dict = OmegaConf.to_container(dot_cfg, resolve=True) or {}
150
+ if isinstance(as_dict, dict) and "export" in as_dict:
151
+ export_map = as_dict.get("export") or {}
152
+ if isinstance(export_map, dict) and self.dest in export_map:
153
+ config.update(export_map[self.dest] or {})
154
+ else:
155
+ config.update(as_dict)
156
+ else:
157
+ config.update(as_dict)
158
+
97
159
  if self.format and self.dest != "local":
98
160
  print(
99
161
  "Note: --format is only used by --dest local. It will be ignored for other destinations."
100
162
  )
101
163
 
102
- # Execute
164
+ if "only_required" in config and self.only_required is True:
165
+ config.pop("only_required", None)
166
+
103
167
  print(
104
168
  f"Exporting {len(self.invocation_ids)} {'invocations' if len(self.invocation_ids) > 1 else 'invocation'} to {self.dest}..."
105
169
  )
106
170
 
107
171
  result = export_results(self.invocation_ids, self.dest, config)
108
172
 
109
- if not result["success"]:
110
- print(f"Export failed: {result.get('error', 'Unknown error')}")
173
+ if not result.get("success", False):
174
+ err = result.get("error", "Unknown error")
175
+ print(f"\nExport failed: {err}")
176
+ # Provide actionable guidance for common configuration issues
177
+ if self.dest == "mlflow":
178
+ if "tracking_uri" in str(err).lower():
179
+ print("\nMLflow requires 'tracking_uri' to be configured.")
180
+ print(
181
+ "Set it via: -o export.mlflow.tracking_uri=http://mlflow-server:5000"
182
+ )
183
+ elif "not installed" in str(err).lower():
184
+ print("\nMLflow package not installed.")
185
+ print("Install via: pip install nemo-evaluator-launcher[mlflow]")
186
+ elif self.dest == "wandb":
187
+ if "entity" in str(err).lower() or "project" in str(err).lower():
188
+ print("\nW&B requires 'entity' and 'project' to be configured.")
189
+ print(
190
+ "Set via: -o export.wandb.entity=my-org -o export.wandb.project=my-proj"
191
+ )
192
+ elif "not installed" in str(err).lower():
193
+ print("\nW&B package not installed.")
194
+ print("Install via: pip install nemo-evaluator-launcher[wandb]")
195
+ elif self.dest == "gsheets":
196
+ if "not installed" in str(err).lower():
197
+ print("\nGoogle Sheets package not installed.")
198
+ print("Install via: pip install nemo-evaluator-launcher[gsheets]")
111
199
  return
112
200
 
113
201
  # Success path
@@ -124,6 +212,9 @@ class ExportCmd:
124
212
  print(f" URL: {metadata['run_url']}")
125
213
  if metadata.get("summary_path"):
126
214
  print(f" Summary: {metadata['summary_path']}")
215
+ path_hint = job_result.get("dest") or metadata.get("output_dir")
216
+ if self.dest == "local" and path_hint:
217
+ print(f" Path: {path_hint}")
127
218
  else:
128
219
  print(f" {job_id} failed: {job_result.get('message', '')}")
129
220
  else:
@@ -136,7 +227,6 @@ class ExportCmd:
136
227
  # Show summary path if available
137
228
  if metadata.get("summary_path"):
138
229
  print(f"Summary: {metadata['summary_path']}")
139
-
140
230
  # Show per-invocation status
141
231
  for invocation_id, inv_result in result["invocations"].items():
142
232
  if inv_result.get("success"):
@@ -146,3 +236,32 @@ class ExportCmd:
146
236
  print(
147
237
  f" {invocation_id}: failed, {inv_result.get('error', 'Unknown error')}"
148
238
  )
239
+
240
+ def _validate_overrides(self, overrides: List[str], dest: str) -> None:
241
+ """Validate override list for destination consistency.
242
+
243
+ Raises:
244
+ ValueError: If overrides specify wrong destination or have other issues.
245
+ """
246
+ if not overrides:
247
+ return # nothing to validate
248
+
249
+ # Check each override for destination mismatch
250
+ for override_str in overrides:
251
+ if override_str.startswith(
252
+ "export."
253
+ ): # check if override starts with export.
254
+ # Extract destination from override path
255
+ try:
256
+ key_part = override_str.split("=")[0] # Get left side before =
257
+ parts = key_part.split(".")
258
+ if len(parts) >= 2:
259
+ override_dest = parts[1]
260
+ if override_dest != dest:
261
+ raise ValueError(
262
+ f"Override destination mismatch: override specifies 'export.{override_dest}' but --dest is '{dest}'. "
263
+ f"Either change --dest to '{override_dest}' or use 'export.{dest}' in overrides."
264
+ )
265
+ except (IndexError, AttributeError):
266
+ # miconstructed override -> OmegaConf handles this
267
+ pass