feat: add config framework
This commit is contained in:
parent
e12d65f53c
commit
fab4176ba9
1 changed files with 369 additions and 0 deletions
369
src/byteb4rb1e/utils/config.py
Normal file
369
src/byteb4rb1e/utils/config.py
Normal file
|
|
@ -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 ``--<prefix>-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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue