Source code for zencfg.from_commandline

"""Create configs with overrides from keyword arguments or the command line."""

import sys
from pathlib import Path
from typing import Optional, Type, Union

from .config import ConfigBase
from .from_dict import _deep_update, flat_dict_to_nested, make_config_from_flat_dict, make_config_from_nested_dict


[docs] def make_config(source: Union[Type[ConfigBase], ConfigBase, str, Path], name: Optional[str] = None, /, **overrides) -> ConfigBase: """Create a config instance from any source, with overrides. Parameters ---------- source : Union[Type[ConfigBase], ConfigBase, str, Path] Source to create the config from: - ConfigBase class: instantiate it with the overrides - ConfigBase instance: apply the overrides to a copy - str/Path: path of a Python file to load the config from (requires name) name : str, optional Name of the class or instance to load, when source is a file path. **overrides Values to override in the config. Nested fields use dotted keys, passed via unpacking: ``make_config(cfg, **{"model.layers": 24})``. Returns ------- ConfigBase Config instance with overrides applied. Examples -------- >>> # From a class >>> config = make_config(TrainingConfig, batch_size=32) >>> >>> # From a file (name of the class or instance to load is required) >>> config = make_config("configs/experiment.py", "TrainingConfig", epochs=100) >>> >>> # From an instance >>> config = make_config(TrainingConfig(), learning_rate=0.001) """ if isinstance(source, type) and issubclass(source, ConfigBase): return make_config_from_flat_dict(source, overrides) elif isinstance(source, ConfigBase): if not overrides: return source # No changes needed merged = _deep_update(source.to_dict(), flat_dict_to_nested(dict(overrides))) return make_config_from_nested_dict(type(source), merged) elif isinstance(source, (str, Path)): if name is None: raise ValueError("name parameter is required when loading from a file") from .from_file import load_config_from_file source_path = Path(source).resolve() if not source_path.is_file(): raise FileNotFoundError(f"Config file not found: {source}") loaded_item = load_config_from_file(source_path.parent, source_path.name, config_name=name) return make_config(loaded_item, **overrides) else: raise TypeError(f"Unsupported source type: {type(source)}. Expected ConfigBase class, instance, or file path.")
def _parse_cli_arguments(args) -> dict: """Parse ["--key", "value", "--key2=value2", ...] into {"key": value, ...}.""" arg_dict = {} i = 0 while i < len(args): arg = args[i] key = arg.lstrip('-') if isinstance(arg, str) else arg if not isinstance(arg, str) or not arg.startswith('-'): raise ValueError( f"Expected an argument name like --key, got '{arg}'. " "Arguments must be in pairs like: --model.layers 24 (or --model.layers=24)") if '=' in key: key, _, value = key.partition('=') i += 1 else: if i + 1 >= len(args): raise ValueError( f"Missing value for argument '--{key}'. " "Arguments must be in pairs like: --model.layers 24 (or --model.layers=24)") value = args[i + 1] i += 2 arg_dict[key] = value return arg_dict def _cli_help_lines(config, prefix: str = "", seen: frozenset = frozenset()) -> list: """One help line per field (dotted paths for nested configs). `config` is a ConfigBase class or instance; with an instance, its current values are shown as the defaults (so `--model cnnconfig --help` shows the CNN fields). `seen` guards against self-referential configs. """ from .from_dict import extract_configbase_member config_cls = config if isinstance(config, type) else type(config) lines = [] for field_name, field in config_cls.model_fields.items(): path = f"{prefix}{field_name}" if isinstance(config, type): value = field.default if not field.is_required() else None else: value = getattr(config, field_name) config_type = extract_configbase_member(field.annotation) if isinstance(config_type, type) and issubclass(config_type, ConfigBase): choices = config_type._config_name_choices() if isinstance(value, ConfigBase): default = value._config_name else: default = "required" if field.is_required() else repr(value) lines.append(f" --{path} {{{', '.join(choices)}}} (default: {default})") if config_type not in seen: lines.extend(_cli_help_lines(value if isinstance(value, ConfigBase) else config_type, prefix=f"{path}.", seen=seen | {config_type})) else: type_name = getattr(field.annotation, "__name__", str(field.annotation)) default = repr(value) if not field.is_required() else "required" lines.append(f" --{path} <{type_name}> (default: {default})") return lines def _print_cli_help(source, args) -> None: """Print available fields; selections already on the command line are applied.""" config_cls = source if isinstance(source, type) else type(source) try: arg_dict = _parse_cli_arguments([a for a in args if a not in ("-h", "--help")]) if isinstance(source, ConfigBase): merged = _deep_update(source.to_dict(), flat_dict_to_nested(arg_dict)) resolved = make_config_from_nested_dict(config_cls, merged) else: resolved = make_config_from_flat_dict(config_cls, arg_dict) except Exception: resolved = source # help must never fail: fall back to the defaults print(f"Usage: {Path(sys.argv[0]).name} [--field value ...]") print(f"\nFields of {config_cls.__name__} (nested fields use dots, " f"subclasses are selected by name):\n") print("\n".join(_cli_help_lines(resolved)))
[docs] def make_config_from_cli( config_or_path: Union[Type[ConfigBase], ConfigBase, str, Path], config_file: Optional[Union[str, Path]] = None, config_name: Optional[str] = None, *, strict: bool = False ) -> ConfigBase: """Create a config instance with command-line argument overrides. Arguments are read from sys.argv as ``--key value`` or ``--key=value`` pairs, where nested fields use dots (``--model.layers 24``) and subclasses are selected by name (``--model cnnconfig``). Passing ``--help`` prints the available fields and exits. This function supports two usage patterns: 1. Pass a ConfigBase class or instance directly 2. Load from a file, using the same API as load_config_from_file Parameters ---------- config_or_path : Union[Type[ConfigBase], ConfigBase, str, Path] Either: - A ConfigBase class or instance to apply CLI overrides to - A config_path root directory (when config_file is also provided) - A single file path (when config_file is None) config_file : Union[str, Path], optional If provided, config_or_path is treated as the root directory and this is the file's path relative to it (matching load_config_from_file). config_name : str, optional Name of the class or instance to load. Required when loading from file. strict : bool, default=False Kept for backward compatibility; validation is always on. Returns ------- ConfigBase Config instance with command-line overrides applied. Examples -------- >>> # From a class >>> config = make_config_from_cli(TrainingConfig) >>> >>> # From a file, relative to a config root directory >>> config = make_config_from_cli( ... "configs/", ... config_file="experiments/main.py", ... config_name="MainConfig", ... ) >>> >>> # From a single file path >>> config = make_config_from_cli("configs/experiment.py", config_name="TrainingConfig") """ args = sys.argv[1:] # Skip the script name help_requested = any(arg in ("-h", "--help") for arg in args if isinstance(arg, str)) if not help_requested: arg_dict = _parse_cli_arguments(args) # File given relative to a config root directory if config_file is not None: if config_name is None: raise ValueError("config_name is required when loading from file") from .from_file import load_config_from_file loaded_item = load_config_from_file(config_or_path, config_file, config_name=config_name) return make_config_from_cli(loaded_item, strict=strict) source = config_or_path if isinstance(source, (str, Path)): if config_name is None: raise ValueError("config_name is required when loading from a file") from .from_file import load_config_from_file source_path = Path(source).resolve() if not source_path.is_file(): raise FileNotFoundError(f"Config file not found: {source}") loaded_item = load_config_from_file(source_path.parent, source_path.name, config_name=config_name) return make_config_from_cli(loaded_item, strict=strict) if not (isinstance(source, ConfigBase) or (isinstance(source, type) and issubclass(source, ConfigBase))): raise TypeError(f"Unsupported config_or_path type: {type(source)}. " "Expected ConfigBase class, instance, or file path.") if help_requested: _print_cli_help(source, args) sys.exit(0) if isinstance(source, ConfigBase): # Merge CLI overrides into the instance's current values merged = _deep_update(source.to_dict(), flat_dict_to_nested(arg_dict)) return make_config_from_nested_dict(type(source), merged, strict=strict) return make_config_from_flat_dict(source, arg_dict, strict=strict)