From fab4176ba924ad62349180bb2856732712723dcc Mon Sep 17 00:00:00 2001 From: Tiara Rodney Date: Sat, 6 Jun 2026 14:35:02 +0200 Subject: [PATCH] feat: add config framework --- src/byteb4rb1e/utils/config.py | 369 +++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 src/byteb4rb1e/utils/config.py diff --git a/src/byteb4rb1e/utils/config.py b/src/byteb4rb1e/utils/config.py new file mode 100644 index 0000000..8bece72 --- /dev/null +++ b/src/byteb4rb1e/utils/config.py @@ -0,0 +1,369 @@ +"""Config framework — INI-backed dataclasses with CLI integration. + +A config dataclass is the single source of truth for settings. Values +come from three layers (later wins): + + 1. Dataclass field defaults + 2. INI file sections + 3. CLI overrides (via argparse flags or ``--config KEY=VALUE``) + +Two CLI integration styles: + +- ``add_config_arguments`` — generates one ``--flag`` per field. +- ``apply_overrides`` — accepts a ``dict[str, str]`` of dotted-path + overrides from a unified ``--config KEY=VALUE`` flag. +""" + +import configparser +from argparse import ArgumentParser, Namespace +from dataclasses import MISSING, fields +from pathlib import Path +from typing import Any, Type, TypeVar, get_type_hints + +T = TypeVar("T") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _parse_bool(value: str) -> bool: + """Parse a boolean from INI/CLI string.""" + return value.lower() in ("true", "yes", "1", "on") + + +_TYPE_MAP = { + int: int, + float: float, + str: str, + bool: _parse_bool, +} + + +def resolve_hints(cls: Type) -> dict[str, type]: + """Resolve type hints for a dataclass, handling both evaluated + and string annotations. + + :param cls: a dataclass class. + :returns: dict mapping field names to resolved types. + """ + try: + return get_type_hints(cls) + except Exception: + return { + f.name: f.type if isinstance(f.type, type) + else str + for f in fields(cls) + } + + +def _section_name(cls: Type, section: str | None = None) -> str: + """Derive INI section name from class name if not provided.""" + if section is not None: + return section + name = cls.__name__ + if name.endswith("Config"): + name = name[: -len("Config")] + return name.lower() + + +# --------------------------------------------------------------------------- +# INI loading +# --------------------------------------------------------------------------- + +def load_ini( + cls: Type[T], + path: Path, + section: str | None = None, +) -> T: + """Load a config dataclass from an INI file. + + If *section* is not given, the dataclass name (lowercased, + without trailing "Config") is used. + + Unknown keys in the INI file raise ValueError. Missing keys + use the dataclass default. + """ + section = _section_name(cls, section) + + parser = configparser.ConfigParser( + comment_prefixes=("#", ";"), + inline_comment_prefixes=("#", ";"), + ) + parser.read(path) + + if not parser.has_section(section): + return cls() # type: ignore[call-arg] + + hints = resolve_hints(cls) + field_names = {f.name for f in fields(cls) if f.init} + kwargs: dict[str, Any] = {} + + for key, raw_value in parser.items(section): + if key not in field_names: + raise ValueError( + f"Unknown config key '{key}' in" + f" [{section}]. Valid keys:" + f" {sorted(field_names)}" + ) + + field_type = hints.get(key, str) + coerce = _TYPE_MAP.get(field_type, field_type) + kwargs[key] = coerce(raw_value) + + return cls(**kwargs) # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# INI writing +# --------------------------------------------------------------------------- + +def format_section(cls: Type, section: str | None = None) -> str: + """Format a config dataclass as an INI section string. + + Returns the section header and all fields with their defaults + as commented key-value pairs. + + :param cls: a dataclass class. + :param section: section name (derived from class name if None). + :returns: INI section string. + """ + section = _section_name(cls, section) + hints = resolve_hints(cls) + lines = [f"[{section}]"] + + for f in fields(cls): + if not f.init: + continue + field_type = hints.get(f.name, str) + type_name = getattr(field_type, "__name__", str(field_type)) + + if f.default is not MISSING: + default = f.default + elif f.default_factory is not MISSING: # type: ignore[arg-type] + default = f.default_factory() # type: ignore[misc] + else: + continue + + lines.append(f"# {f.name} ({type_name})") + lines.append(f"{f.name} = {default}") + lines.append("") + + return "\n".join(lines) + + +def ensure_ini( + cls: Type[T], + path: Path, + section: str | None = None, +) -> T: + """Load config from INI, creating the file with defaults if + it does not exist. + + On first run, writes a commented INI file with all fields and + their default values. On subsequent runs, reads the existing + file. Never writes back CLI overrides. + """ + section = _section_name(cls, section) + + if not path.exists(): + _write_default_ini(cls, path, section) + + return load_ini(cls, path, section) + + +def ensure_ini_multi( + configs: list[tuple[Type, str | None]], + path: Path, +) -> None: + """Create an INI file with multiple sections if it does not exist. + + Each entry is a (dataclass_cls, section_name) tuple. If + section_name is None, it is derived from the class name. + + Does not overwrite an existing file. + + :param configs: list of (cls, section) tuples. + :param path: path to the INI file. + """ + if path.exists(): + return + + sections = [format_section(cls, section) for cls, section in configs] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(sections) + "\n") + + +def _write_default_ini( + cls: Type, + path: Path, + section: str, +) -> None: + """Write an INI file with all fields as commented defaults.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(format_section(cls, section) + "\n") + + +# --------------------------------------------------------------------------- +# CLI: per-flag style (add_config_arguments / apply_cli_overrides) +# --------------------------------------------------------------------------- + +def add_config_arguments( + cls: Type[T], + parser: ArgumentParser, + prefix: str = "", +) -> None: + """Add CLI arguments for each field in a config dataclass. + + Field names are converted to CLI flags: ``heart_rate_resolution`` + becomes ``--heart-rate-resolution`` (or ``---heart-rate-resolution`` + if a prefix is given). + """ + hints = resolve_hints(cls) + + for f in fields(cls): + if not f.init: + continue + flag_name = f.name.replace("_", "-") + if prefix: + flag_name = f"{prefix}-{flag_name}" + + field_type = hints.get(f.name, str) + + kwargs: dict[str, Any] = { + "dest": f.name, + } + + if field_type is bool: + kwargs["action"] = ( + "store_false" + if f.default is True + else "store_true" + ) + kwargs["default"] = None + else: + kwargs["type"] = _TYPE_MAP.get( + field_type, field_type + ) + kwargs["default"] = None + kwargs["metavar"] = field_type.__name__.upper() + + parser.add_argument(f"--{flag_name}", **kwargs) + + +def apply_cli_overrides( + config: T, + args: Namespace, +) -> T: + """Apply CLI argument values to a config instance. + + Only overrides fields that were explicitly set on the command + line (not None). Returns a new instance. + """ + overrides = {} + for f in fields(config): # type: ignore[arg-type] + if not f.init: + continue + cli_value = getattr(args, f.name, None) + if cli_value is not None: + overrides[f.name] = cli_value + + if not overrides: + return config + + from dataclasses import asdict + merged = asdict(config) # type: ignore[arg-type] + merged.update(overrides) + return type(config)(**merged) # type: ignore[return-value] + + +# --------------------------------------------------------------------------- +# CLI: dotted-path style (apply_overrides) +# --------------------------------------------------------------------------- + +def apply_overrides( + config: T, + overrides: dict[str, str], + prefix: str = "", +) -> T: + """Apply dotted-path string overrides to a config dataclass. + + Used with a unified ``--config KEY=VALUE`` CLI flag. Each key + is a dotted path relative to the prefix. + + Example:: + + overrides = { + "provider.base_url": "http://localhost:4000", + "provider.model": "qwen2.5:7b", + } + config = apply_overrides(config, overrides, prefix="provider") + # config.base_url == "http://localhost:4000" + # config.model == "qwen2.5:7b" + + :param config: a dataclass instance. + :param overrides: dict of dotted keys to string values. + :param prefix: only apply keys starting with this prefix. + :returns: new config instance with overrides applied. + """ + hints = resolve_hints(type(config)) + kwargs: dict[str, Any] = {} + changed = False + + for f in fields(config): + if not f.init: + continue + full_key = f"{prefix}.{f.name}" if prefix else f.name + if full_key in overrides: + raw = overrides[full_key] + field_type = hints.get(f.name, str) + coerce = _TYPE_MAP.get(field_type, field_type) + kwargs[f.name] = coerce(raw) + changed = True + else: + kwargs[f.name] = getattr(config, f.name) + + if not changed: + return config + + return type(config)(**kwargs) # type: ignore[return-value] + + +def format_help(cls: Type, prefix: str = "") -> list[str]: + """Generate help lines for a config dataclass. + + Each line shows the dotted key path, type, and default value. + Suitable for CLI epilog text. + + :param cls: a dataclass class. + :param prefix: prepended to each key path. + :returns: list of formatted help strings. + """ + hints = resolve_hints(cls) + lines = [] + + for f in fields(cls): + if not f.init: + continue + field_type = hints.get(f.name, str) + type_name = getattr(field_type, "__name__", str(field_type)) + key = f"{prefix}.{f.name}" if prefix else f.name + + if f.default is not MISSING: + default = f.default + elif f.default_factory is not MISSING: # type: ignore[arg-type] + default = repr(f.default_factory()) # type: ignore[misc] + else: + default = "(required)" + + lines.append(f" {key} ({type_name}, default: {default})") + + return lines + + +# --------------------------------------------------------------------------- +# Backwards compat +# --------------------------------------------------------------------------- + +# keep the old private name working for existing callers +_resolve_hints = resolve_hints