"""Application configuration: layered defaults < config file < env vars <
CLI overrides, all validated by the same pydantic models used everywhere
else in shinobi. No OmegaConf/scabha/munch/benedict stack -- just
pydantic-settings, reusing the validation library the cab schemas already
depend on.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, ClassVar
import yaml
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
DEFAULT_CONFIG_FILE = Path.home() / ".shinobi" / "config.yml"
[docs]
class BackendConfig(BaseModel):
"""Settings controlling which execution backend cabs run under."""
default: str = "native"
# Run docker/podman containers as the host UID/GID (`--user uid:gid`,
# HOME=workdir) instead of root, so bind-mounted outputs come out
# host-owned. Defaults to True -- unlike this file's other options,
# this one is opt-OUT: root-by-default is Docker's own footgun, not
# behavior worth preserving silently. Set to False for images that
# specifically require running as root. No-op for apptainer, which
# already runs as the host user.
run_as_host_user: bool = True
[docs]
class ExecutionConfig(BaseModel):
"""Settings controlling recipe step scheduling."""
# How many recipe steps may run concurrently. Default 1 (sequential) --
# parallelism is opt-in: at 1 the scheduler reproduces exact
# declaration-order execution, and no MUTABLE input can be shared across
# concurrently-running steps (see AGENTS.md's recipe-execution note).
max_workers: int = 1
[docs]
class CacheConfig(BaseModel):
"""Settings controlling step-level skip-if-unchanged caching."""
# Step-level skip-if-unchanged caching (shinobi.cache). Disabled by
# default -- same "opt-in, zero cost for existing users" shape as
# `backend.default`/`execution.max_workers`.
enabled: bool = False
dir: str = ".shinobi/cache"
[docs]
class LogConfig(BaseModel):
"""Settings controlling logging and live output streaming."""
dir: str = "."
level: str = "INFO"
# Live-echo a running cab's stdout/stderr to the terminal as it runs
# (native/container backends only -- see shinobi.backends._stream).
# Default on: `ninja run --quiet` opts out for one invocation.
stream: bool = True
class _YamlFileSource(PydanticBaseSettingsSource):
"""Reads a YAML file (if it exists) as a settings source."""
def __init__(self, settings_cls: type[BaseSettings], yaml_file: Path):
"""Load `yaml_file` (if it exists) into the source's data.
Args:
settings_cls: The `BaseSettings` subclass this source feeds.
yaml_file: Path to the YAML config file. Missing files are
treated as empty config, not an error.
"""
super().__init__(settings_cls)
self._data: dict[str, Any] = {}
if yaml_file.exists():
self._data = yaml.safe_load(yaml_file.read_text()) or {}
def get_field_value(self, field, field_name: str) -> tuple[Any, str, bool]:
"""Look up a single field's value, per `PydanticBaseSettingsSource`.
Args:
field: The pydantic field metadata (unused; required by the
base class interface).
field_name: Name of the top-level settings field to look up.
Returns:
A `(value, field_name, is_complex)` tuple, `is_complex` always
False.
"""
return self._data.get(field_name), field_name, False
def __call__(self) -> dict[str, Any]:
"""Return the full parsed YAML data as this source's settings dict."""
return self._data
[docs]
class AppConfig(BaseSettings):
"""Precedence, highest to lowest: CLI overrides > env vars (SHINOBI_*)
> config file > built-in defaults.
"""
model_config = SettingsConfigDict(env_prefix="SHINOBI_", env_nested_delimiter="__")
_config_file: ClassVar[Path] = DEFAULT_CONFIG_FILE
backend: BackendConfig = Field(default_factory=BackendConfig)
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
log: LogConfig = Field(default_factory=LogConfig)
cache: CacheConfig = Field(default_factory=CacheConfig)
[docs]
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
):
"""Set the settings source precedence: init > env vars > YAML file.
Args:
settings_cls: The `BaseSettings` subclass being configured.
init_settings: Source for values passed directly to `__init__`.
env_settings: Source for `SHINOBI_*` environment variables.
dotenv_settings: Unused; `.env` files are not supported.
file_secret_settings: Unused; Docker/Kubernetes secret files
are not supported.
Returns:
The ordered tuple of settings sources pydantic-settings should
consult, highest precedence first.
"""
yaml_source = _YamlFileSource(settings_cls, cls._config_file)
return (init_settings, env_settings, yaml_source)
[docs]
@classmethod
def load(cls, config_file: str | Path | None = None, **cli_overrides: Any) -> "AppConfig":
"""Build an `AppConfig`, layering defaults, config file, env, and overrides.
Args:
config_file: Path to a YAML config file. Defaults to
`DEFAULT_CONFIG_FILE` (`~/.shinobi/config.yml`) if not given.
**cli_overrides: Explicit values that take precedence over the
config file and environment variables.
Returns:
A fully-resolved `AppConfig` instance.
"""
cls._config_file = Path(config_file) if config_file else DEFAULT_CONFIG_FILE
return cls(**cli_overrides)