Source code for neurocore.config.loader
"""Configuration loader for NeuroCore.
Loads configuration with the following priority (highest wins):
1. Environment variables (``NEUROCORE_`` prefix, double underscore for nesting)
2. .env file (project root)
3. neurocore.yaml
4. Built-in defaults
Usage::
from neurocore.config import load_config
# Auto-detect project root (walks up from cwd)
config = load_config()
# Explicit project root
config = load_config(project_root=Path("/path/to/project"))
# Explicit config file
config = load_config(config_path=Path("custom-config.yaml"))
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from dotenv import load_dotenv
from neurocore.config.defaults import CONFIG_FILE_NAME, ENV_FILE_NAME, ENV_PREFIX
from neurocore.config.schema import NeuroCoreConfig
from neurocore.errors import ConfigError
[docs]
def find_project_root(start: Path | None = None) -> Path | None:
"""Walk up from `start` looking for neurocore.yaml.
Args:
start: Directory to begin searching from. Defaults to cwd.
Returns:
The directory containing neurocore.yaml, or None if not found.
"""
current = (start or Path.cwd()).resolve()
while True:
if (current / CONFIG_FILE_NAME).is_file():
return current
parent = current.parent
if parent == current:
# Reached filesystem root
return None
current = parent
def _load_yaml(path: Path) -> dict[str, Any]:
"""Load and parse a YAML file.
Args:
path: Path to the YAML file.
Returns:
Parsed dict (empty dict if file is empty).
Raises:
ConfigError: If the file cannot be read or parsed.
"""
try:
with open(path) as f:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else {}
except FileNotFoundError:
raise ConfigError(f"Config file not found: {path}")
except yaml.YAMLError as e:
raise ConfigError(f"Invalid YAML in {path}: {e}")
def _apply_env_overrides(data: dict[str, Any]) -> dict[str, Any]:
"""Apply ``NEUROCORE_`` environment variable overrides into the config dict.
Supports nested keys via double underscore:
NEUROCORE_LOGGING__LEVEL=DEBUG → data["logging"]["level"] = "DEBUG"
NEUROCORE_PROJECT__NAME=foo → data["project"]["name"] = "foo"
Only applies to known top-level sections (project, paths, logging).
Skills env vars are handled separately.
Args:
data: The config dict to update (mutated in place).
Returns:
The updated config dict.
"""
prefix = ENV_PREFIX.upper()
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
# Strip prefix and split on double underscore
remainder = key[len(prefix):]
parts = [p.lower() for p in remainder.split("__")]
if not parts:
continue
# Walk into the nested dict, creating intermediate dicts as needed
target = data
for part in parts[:-1]:
if part not in target:
target[part] = {}
if isinstance(target[part], dict):
target = target[part]
else:
# Can't nest into a non-dict value
break
else:
target[parts[-1]] = value
return data
[docs]
def load_config(
project_root: Path | None = None,
config_path: Path | None = None,
) -> NeuroCoreConfig:
"""Load NeuroCore configuration.
Priority (highest wins):
1. Environment variables (NEUROCORE_*)
2. .env file
3. neurocore.yaml
4. Built-in defaults (in schema.py)
Args:
project_root: Explicit project root directory.
If not provided, walks up from cwd looking for neurocore.yaml.
config_path: Explicit path to a config YAML file.
If provided, project_root defaults to its parent directory.
Returns:
Fully resolved NeuroCoreConfig.
Raises:
ConfigError: If config file is specified but invalid.
"""
# Determine project root and config path
if config_path is not None:
config_path = Path(config_path).resolve()
root = project_root or config_path.parent
elif project_root is not None:
root = Path(project_root).resolve()
config_path = root / CONFIG_FILE_NAME
else:
root = find_project_root()
if root is not None:
config_path = root / CONFIG_FILE_NAME
else:
# No neurocore.yaml found — use defaults with cwd as root
root = Path.cwd().resolve()
config_path = None
root = Path(root).resolve()
# Load .env file (if it exists)
env_path = root / ENV_FILE_NAME
if env_path.is_file():
load_dotenv(env_path, override=True)
# Load YAML config (or start with empty dict for defaults)
if config_path is not None and config_path.is_file():
data = _load_yaml(config_path)
else:
data = {}
# Apply environment variable overrides
_apply_env_overrides(data)
# Set project_root in the data
data["project_root"] = root
# Build and return the config model (pydantic handles defaults)
try:
return NeuroCoreConfig(**data)
except Exception as e:
raise ConfigError(f"Invalid configuration: {e}")