Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 227 additions & 53 deletions libby/config.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,238 @@
from __future__ import annotations
from typing import Any, Dict, Mapping
import json, os, pathlib

import copy
import json
import os
import pathlib
from typing import Any, Dict, List, Mapping, Optional, Union

try:
import yaml
except Exception:
yaml = None

def _load_json(p: pathlib.Path) -> Dict[str, Any]:
return json.loads(p.read_text())

def _load_yaml(p: pathlib.Path) -> Dict[str, Any]:
PathLike = Union[str, os.PathLike]


class ConfigError(ValueError):
"""Raised when daemon configuration cannot be selected or validated."""


def _load_json(path: pathlib.Path) -> Dict[str, Any]:
parsed = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(parsed, dict):
raise ConfigError(f"configuration root must be a mapping: {path}")
return parsed


def _load_yaml(path: pathlib.Path) -> Dict[str, Any]:
if yaml is None:
raise RuntimeError("YAML requested but PyYAML not installed. `pip install pyyaml`")
return yaml.safe_load(p.read_text()) or {}

def load_config(path: str | os.PathLike[str]) -> Dict[str, Any]:
"""
Load config from .json or .yml/.yaml.
If the extension is missing/unknown, attempt JSON → YAML.
"""
p = pathlib.Path(path)
if not p.exists():
raise FileNotFoundError(f"Config file not found: {p}")
ext = p.suffix.lower()
if ext == ".json":
return _load_json(p) or {}
if ext in (".yml", ".yaml"):
return _load_yaml(p) or {}
# Auto-detect
for fn in (_load_json, _load_yaml):
raise RuntimeError(
"YAML requested but PyYAML is not installed; "
"install it with `pip install pyyaml`"
)

parsed = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(parsed, dict):
raise ConfigError(f"configuration root must be a mapping: {path}")
return parsed


def load_config(path: PathLike) -> Dict[str, Any]:
"""Load a JSON or YAML configuration file."""

config_path = pathlib.Path(path)
if not config_path.exists():
raise FileNotFoundError(f"config file not found: {config_path}")

extension = config_path.suffix.lower()
if extension == ".json":
return _load_json(config_path)
if extension in (".yml", ".yaml"):
return _load_yaml(config_path)

errors: List[Exception] = []
for loader in (_load_json, _load_yaml):
try:
return fn(p) or {}
except Exception:
pass
raise ValueError(f"Could not parse config file as JSON or YAML: {p}")

def with_env_overrides(cfg: Mapping[str, Any], prefix: str = "LIBBY_") -> Dict[str, Any]:
"""
Uppercase, underscore keys: LIBBY_PEER_ID, LIBBY_BIND, etc.
Booleans: '1','true','yes' => True ; '0','false','no' => False
Lists: comma-separated.
"""
out: Dict[str, Any] = dict(cfg)

def coerce(v: str) -> Any:
s = v.strip()
ls = s.lower()
if ls in ("true","1","yes","on"): return True
if ls in ("false","0","no","off"): return False
if "," in s: return [x.strip() for x in s.split(",")]
return loader(config_path)
except Exception as exc:
errors.append(exc)

raise ConfigError(
f"could not parse config file as JSON or YAML: {config_path}"
) from errors[-1]


def with_env_overrides(
config: Mapping[str, Any],
prefix: str = "LIBBY_",
) -> Dict[str, Any]:
"""Apply top-level environment-variable overrides."""

result = dict(config)

def coerce(value: str) -> Any:
stripped = value.strip()
lowered = stripped.lower()

if lowered in ("true", "1", "yes", "on"):
return True
if lowered in ("false", "0", "no", "off"):
return False
if "," in stripped:
return [item.strip() for item in stripped.split(",")]

try:
if "." in s: return float(s)
return int(s)
except Exception:
return s

for k, v in os.environ.items():
if not k.startswith(prefix):
continue
key = k[len(prefix):].lower()
out[key] = coerce(v)
return out
if "." in stripped:
return float(stripped)
return int(stripped)
except ValueError:
return stripped

for env_name, env_value in os.environ.items():
if env_name.startswith(prefix):
key = env_name[len(prefix):].lower()
result[key] = coerce(env_value)

return result


def deep_merge(
base: Mapping[str, Any],
override: Mapping[str, Any],
) -> Dict[str, Any]:
"""Return a recursive merge without mutating either input."""

result: Dict[str, Any] = copy.deepcopy(dict(base))

for key, value in override.items():
existing = result.get(key)
if isinstance(existing, Mapping) and isinstance(value, Mapping):
result[key] = deep_merge(existing, value)
else:
result[key] = copy.deepcopy(value)

return result


def is_subsystem_config(
config: Mapping[str, Any],
*,
daemon_section: str = "daemons",
) -> bool:
"""Return whether a config contains a daemon collection."""

return daemon_section in config


def list_daemons(
config: Mapping[str, Any],
*,
daemon_section: str = "daemons",
) -> List[str]:
"""List daemon IDs in a subsystem config."""

daemons = config.get(daemon_section, {})
if not isinstance(daemons, Mapping):
raise ConfigError(
f"{daemon_section!r} must contain a mapping of daemon IDs"
)
return list(daemons)


def extract_daemon_config(
full_config: Mapping[str, Any],
daemon_id: str,
*,
daemon_section: str = "daemons",
) -> Dict[str, Any]:
"""Merge subsystem defaults with one daemon's overrides."""

daemons = full_config.get(daemon_section, {})
if not isinstance(daemons, Mapping):
raise ConfigError(
f"{daemon_section!r} must contain a mapping of daemon IDs"
)

if daemon_id not in daemons:
raise ConfigError(
f"daemon {daemon_id!r} is not defined; "
f"available daemons: {list(daemons)}"
)

daemon_override = daemons[daemon_id] or {}
if not isinstance(daemon_override, Mapping):
raise ConfigError(
f"configuration for daemon {daemon_id!r} must be a mapping"
)

defaults = {
key: value
for key, value in full_config.items()
if key != daemon_section
}
result = deep_merge(defaults, daemon_override)
result.setdefault("peer_id", daemon_id)
return result


class DaemonConfigLoader:
"""Load a single-daemon or multi-daemon subsystem config."""

def __init__(
self,
path: PathLike,
*,
daemon_section: str = "daemons",
) -> None:
self.path = pathlib.Path(path)
self.daemon_section = daemon_section
self._config: Optional[Dict[str, Any]] = None

@property
def config(self) -> Dict[str, Any]:
if self._config is None:
self._config = load_config(self.path)
return self._config

@property
def is_subsystem(self) -> bool:
return is_subsystem_config(
self.config,
daemon_section=self.daemon_section,
)

@property
def subsystem(self) -> Optional[str]:
raw = self.config.get("subsystem")
return str(raw) if raw is not None else None

@property
def daemon_ids(self) -> List[str]:
if self.is_subsystem:
return list_daemons(
self.config,
daemon_section=self.daemon_section,
)

peer_id = self.config.get("peer_id", self.path.stem)
return [str(peer_id)]

def get_daemon_config(
self,
daemon_id: Optional[str] = None,
) -> Dict[str, Any]:
if self.is_subsystem:
if daemon_id is None:
raise ConfigError(
"daemon_id is required for a subsystem config; "
f"available daemons: {self.daemon_ids}"
)
return extract_daemon_config(
self.config,
daemon_id,
daemon_section=self.daemon_section,
)

return copy.deepcopy(self.config)
Loading