Coverage for ml_workbench/cli_experiment.py: 9%
150 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-01-06 16:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2026-01-06 16:09 +0200
1"""CLI tool for running YAML-defined ML experiments.
3This tool loads a YAML configuration file, selects one or more experiments,
4and executes them using the Runner class. It also provides inspection options
5to view configuration and dataset information without running experiments.
6"""
8from __future__ import annotations
10import argparse
11import json
12from pathlib import Path
13import sys
14import traceback
15from typing import Any
17from dotenv import load_dotenv
18import yaml
20from .config import YamlConfig
21from .dataset import Dataset
22from .experiment import Experiment
23from .runner import Runner
25load_dotenv()
27# Constants
28MAX_COLUMNS_TO_DISPLAY = 10
31def parse_kv_pairs(kv_list: list[str]) -> dict[str, str]:
32 """Parse key=value pairs from command line arguments.
34 Parameters
35 ----------
36 kv_list : list[str]
37 List of "key=value" strings
39 Returns
40 -------
41 dict[str, str]
42 Dictionary of parsed key-value pairs
44 Raises
45 ------
46 argparse.ArgumentTypeError
47 If a pair is not in "key=value" format
48 """
49 variables: dict[str, str] = {}
50 for item in kv_list:
51 if "=" not in item:
52 raise argparse.ArgumentTypeError( # noqa: TRY003
53 f"Invalid variable format {item!r}, expected key=value"
54 )
55 key, value = item.split("=", 1)
56 variables[key.strip()] = value.strip()
57 return variables
60def _basic_stats_for_dataset(name: str, cfg: YamlConfig) -> dict[str, Any]:
61 """Get basic statistics for a dataset using the Dataset class.
63 Parameters
64 ----------
65 name : str
66 Dataset name
67 cfg : YamlConfig
68 Configuration object
70 Returns
71 -------
72 dict[str, Any]
73 Dictionary with dataset statistics including name, description, format,
74 type, path, num_columns, num_rows, column_names, and is_combined flag
75 """
76 try:
77 ds = Dataset(name, cfg)
79 result: dict[str, Any] = {
80 "name": name,
81 "description": ds.description,
82 "format": ds.format,
83 "type": ds.type,
84 "path": ds.path,
85 "is_combined": ds.is_combined,
86 }
88 # Try to read and get statistics
89 try:
90 stats = ds.get_statistics()
91 result.update({
92 "num_columns": stats["num_columns"],
93 "num_rows": stats["num_rows"],
94 "column_names": stats["column_names"],
95 })
96 except Exception as exc:
97 # If reading fails, still return basic info with error
98 result.update({
99 "num_columns": None,
100 "num_rows": None,
101 "column_names": None,
102 "error": str(exc),
103 })
105 except Exception as exc:
106 # If dataset creation fails, return minimal info
107 return {
108 "name": name,
109 "error": f"Failed to create dataset: {exc}",
110 }
111 else:
112 return result
115def filter_sections(data: dict, sections: list[str]) -> dict:
116 """Filter configuration to include only specified sections.
118 Parameters
119 ----------
120 data : dict
121 Full configuration dictionary
122 sections : list[str]
123 List of section names to include
125 Returns
126 -------
127 dict
128 Filtered configuration with only specified sections
129 """
130 result = {}
131 for section in sections:
132 if section in data:
133 result[section] = data[section]
134 else:
135 print(f"Warning: Section '{section}' not found", file=sys.stderr) # noqa: T201
136 return result
139def build_arg_parser() -> argparse.ArgumentParser:
140 """Build the argument parser for the CLI.
142 Returns
143 -------
144 argparse.ArgumentParser
145 Configured argument parser
146 """
147 parser = argparse.ArgumentParser(
148 prog="cli-experiment",
149 description="Run YAML-defined ML experiments or inspect configuration and datasets",
150 formatter_class=argparse.RawDescriptionHelpFormatter,
151 epilog="""
152Examples:
153 # Run all experiments defined in the YAML file
154 %(prog)s experiment.yaml
156 # Run specific experiment(s)
157 %(prog)s experiment.yaml --experiments house_prices_prediction_simple
159 # Run multiple specific experiments
160 %(prog)s experiment.yaml --experiments exp1 exp2 exp3
162 # Run with variable substitution
163 %(prog)s experiment.yaml --var path=/data --var env=production
165 # Inspect configuration
166 %(prog)s experiment.yaml --show-config
168 # Inspect configuration as JSON
169 %(prog)s experiment.yaml --show-config --json
171 # Inspect specific sections
172 %(prog)s experiment.yaml --show-config --section experiments --section datasets
174 # Inspect datasets
175 %(prog)s experiment.yaml --show-datasets
177 # Inspect datasets as JSON
178 %(prog)s experiment.yaml --show-datasets --json
179 """,
180 )
182 parser.add_argument(
183 "yaml",
184 type=Path,
185 help="Path to the YAML configuration file containing experiment definitions",
186 )
188 parser.add_argument(
189 "--experiments",
190 action="append",
191 dest="experiments",
192 metavar="EXPERIMENT",
193 help="Name(s) of experiment(s) to run (can be specified multiple times). "
194 "If not specified, all experiments in the YAML will be run. "
195 "Ignored if --show-config or --show-datasets is used.",
196 )
198 parser.add_argument(
199 "--var",
200 action="append",
201 dest="vars",
202 metavar="KEY=VALUE",
203 help="Variable for interpolation (can be specified multiple times)",
204 )
206 parser.add_argument(
207 "--quiet",
208 action="store_true",
209 help="Suppress verbose output from Runner",
210 )
212 # Inspection options
213 parser.add_argument(
214 "--show-config",
215 action="store_true",
216 help="Show processed configuration . When used, experiments are not run.",
217 )
219 parser.add_argument(
220 "--show-datasets",
221 action="store_true",
222 help="Show dataset statistics . When used, experiments are not run.",
223 )
225 parser.add_argument(
226 "--json",
227 action="store_true",
228 help="Output as JSON instead of YAML/text (used with --show-config or --show-datasets)",
229 )
231 parser.add_argument(
232 "--section",
233 action="append",
234 dest="sections",
235 metavar="SECTION",
236 help="Output only specific section(s) when using --show-config "
237 "(can be specified multiple times)",
238 )
240 parser.add_argument(
241 "--show-variables",
242 action="store_true",
243 help="Show resolved variables when using --show-config",
244 )
246 return parser
249def main(argv: list[str] | None = None) -> int:
250 """Main entry point for the CLI.
252 Parameters
253 ----------
254 argv : Optional[list[str]]
255 Command line arguments (default: sys.argv)
257 Returns
258 -------
259 int
260 Exit code (0 for success, non-zero for error)
261 """
262 parser = build_arg_parser()
263 args = parser.parse_args(argv)
265 exit_code = 0 # Initialize exit code
266 try:
267 # Parse variable substitutions
268 variables = parse_kv_pairs(list(args.vars)) if args.vars else {}
270 # Load configuration
271 if not args.yaml.exists():
272 print(f"YAML file not found: {args.yaml}", file=sys.stderr) # noqa: T201
273 return 1
275 config = YamlConfig(args.yaml, **variables)
277 # Handle inspection modes (these don't run experiments)
278 if args.show_config:
279 # Show configuration
280 data = config.to_dict()
282 # Filter sections if specified
283 if args.sections:
284 data = filter_sections(data, args.sections)
286 # Show variables if requested
287 if args.show_variables:
288 print("# Resolved Variables:", file=sys.stderr) # noqa: T201
289 for key, value in sorted(config.variables.items()):
290 print(f" {key}={value}", file=sys.stderr) # noqa: T201
292 # Format output
293 if args.json:
294 print(json.dumps(data, indent=2)) # noqa: T201
295 else:
296 print(yaml.dump( # noqa: T201
297 data,
298 default_flow_style=False,
299 sort_keys=False,
300 allow_unicode=True,
301 ))
303 return 0
305 if args.show_datasets:
306 # Show dataset statistics
307 dataset_names = config.get_datasets_list()
308 results = [_basic_stats_for_dataset(name, config) for name in dataset_names]
310 if args.json:
311 print(json.dumps({"datasets": results}, indent=2)) # noqa: T201
312 else:
313 for item in results:
314 if "error" in item:
315 continue
316 print(f"\nDataset: {item['name']}") # noqa: T201
317 if item.get("is_combined"):
318 print(" Type: Combined") # noqa: T201
319 if item.get("description"):
320 print(f" Description: {item['description']}") # noqa: T201
321 if item.get("format"):
322 print(f" format: {item['format']}") # noqa: T201
323 if item.get("num_rows") is not None:
324 print(f" Rows: {item['num_rows']}") # noqa: T201
325 if item.get("num_columns") is not None:
326 print(f" Columns: {item['num_columns']}") # noqa: T201
327 # Print column names if available
328 column_names = item.get("column_names")
329 if column_names:
330 print(f" Column names: {', '.join(column_names[:MAX_COLUMNS_TO_DISPLAY])}") # noqa: T201
331 if len(column_names) > MAX_COLUMNS_TO_DISPLAY:
332 print(f" ... and {len(column_names) - MAX_COLUMNS_TO_DISPLAY} more") # noqa: T201
334 return 0
336 # Normal mode: run experiments
337 # Get list of available experiments
338 all_experiment_names = Experiment.list_experiment_names(config)
340 if not all_experiment_names:
341 print("No experiments found in configuration", file=sys.stderr) # noqa: T201
342 return 1
344 # Determine which experiments to run
345 if args.experiments:
346 # User specified experiments to run
347 experiments_to_run = args.experiments
348 # Check if all specified experiments exist
349 missing_experiments = [
350 exp for exp in experiments_to_run if exp not in all_experiment_names
351 ]
352 if missing_experiments:
353 print(f"Experiment(s) not found: {', '.join(missing_experiments)}", file=sys.stderr) # noqa: T201
354 return 1
355 else:
356 # Run all experiments by default
357 experiments_to_run = all_experiment_names
359 # Verify configuration before running
360 try:
361 Experiment.verify_config(config)
362 except Exception as e:
363 if not args.quiet:
364 print(f"Configuration validation failed: {e}", file=sys.stderr) # noqa: T201
365 return 1
367 # Run each experiment
368 verbose = not args.quiet
369 exit_code = 0
371 for exp_name in experiments_to_run:
372 try:
373 if verbose:
374 pass
376 # Create experiment object
377 experiment = Experiment(config, exp_name)
379 # Create and run runner
380 runner = Runner(experiment, verbose=verbose)
381 results = runner.run()
383 if verbose:
384 pass
386 except KeyboardInterrupt:
387 return 130
388 except Exception as e:
389 print(f"Error running experiment: {e}", file=sys.stderr) # noqa: T201
390 if verbose:
391 traceback.print_exc()
392 exit_code = 1
394 if verbose and len(experiments_to_run) > 1:
395 pass
397 except FileNotFoundError as e:
398 print(f"YAML file not found: {e}", file=sys.stderr) # noqa: T201
399 return 1
400 except ValueError as e:
401 print(f"Error: {e}", file=sys.stderr) # noqa: T201
402 return 1
403 except Exception as e:
404 if "--debug" in (argv or sys.argv):
405 raise # noqa: TRY003
406 print(f"Error: {e}", file=sys.stderr) # noqa: T201
407 return 1
408 else:
409 return exit_code
412if __name__ == "__main__":
413 sys.exit(main())