Skip to content
Closed
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
115 changes: 115 additions & 0 deletions hydra/config_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Config schema — Pydantic models for hydra/config.json validation.

Validates the full framework configuration at startup so misconfigurations
are caught immediately rather than at runtime when a .get() path is first hit.
"""

from __future__ import annotations

from typing import Annotated, Literal, Union

from pydantic import BaseModel, Field, field_validator


# ---------------------------------------------------------------------------
# Sub-section models
# ---------------------------------------------------------------------------

class FrameworkConfig(BaseModel):
name: str = "Hydra"
version: str = "0.1.0"
debug: bool = False
log_level: str = "INFO"

@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
valid = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
upper = v.upper()
if upper not in valid:
raise ValueError(f"log_level must be one of {valid}, got '{v}'")
return upper
Comment on lines +24 to +31
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FrameworkConfig.validate_log_level() hard-codes a limited set of level names. This will reject valid logging level aliases like WARN/FATAL (and NOTSET), which were previously accepted by setup_logging() and are part of Python’s logging level name mapping. Consider validating against logging._nameToLevel (or a curated list derived from it) and make the error message deterministic (avoid embedding a set, whose ordering is not stable).

Copilot uses AI. Check for mistakes.


class DatabaseConfig(BaseModel):
url: str = "sqlite+aiosqlite:///hydra.db"


class DashboardConfig(BaseModel):
host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
enabled: bool = True


class ContextBusConfig(BaseModel):
backend: str = "memory"
max_queue_size: int = Field(default=10000, ge=1)
overflow_policy: str = "drop_oldest"
default_ttl: float | None = None
redis_url: str = "redis://localhost:6379/0"


class ProfileConfig(BaseModel):
id: str
name: str
avatar_url: str = ""
banner_url: str = ""
status: str = ""
theme: str = "default"
adapters: list[str] = []
plugins: list[str] = []
workers: list[str] = []


# ---------------------------------------------------------------------------
# Adapter config models (discriminated union on "type")
# ---------------------------------------------------------------------------

class MockAdapterConfig(BaseModel):
type: Literal["mock"]
interval: float = 15


class DiscordAdapterConfig(BaseModel):
type: Literal["discord"]
token: str = ""
intents: int = 513


class OpenAIAdapterConfig(BaseModel):
type: Literal["openai"]
base_url: str = "https://api.openai.com/v1"
api_key: str = ""
model: str = "gpt-4"
temperature: float = Field(default=0.7, ge=0.0, le=2.0) # OpenAI API accepted range


class MCPAdapterConfig(BaseModel):
type: Literal["mcp"]
command: str = ""
args: list[str] = []
env: dict[str, str] = {}


AdapterConfig = Annotated[
Union[
MockAdapterConfig,
DiscordAdapterConfig,
OpenAIAdapterConfig,
MCPAdapterConfig,
],
Field(discriminator="type"),
]


# ---------------------------------------------------------------------------
# Top-level config model
# ---------------------------------------------------------------------------

class HydraConfig(BaseModel):
framework: FrameworkConfig = FrameworkConfig()
database: DatabaseConfig = DatabaseConfig()
dashboard: DashboardConfig = DashboardConfig()
context_bus: ContextBusConfig = ContextBusConfig()
profiles: list[ProfileConfig] = []
adapters: dict[str, AdapterConfig] = {}
Comment on lines +109 to +115
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new schema models don’t forbid unknown/extra keys, so typos in config.json (e.g., dashbaord) will be silently ignored by Pydantic’s default extra handling, which undermines the goal of surfacing misconfiguration early. Consider setting model_config = ConfigDict(extra="forbid") on HydraConfig (and/or the section models) so unexpected keys produce a ValidationError.

Copilot uses AI. Check for mistakes.
43 changes: 21 additions & 22 deletions hydra/core/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from enum import Enum
from typing import Any

from hydra.config_schema import HydraConfig
from hydra.core.context_bus import ContextBus
from hydra.core.plugin_manager import PluginManager
from hydra.core.registry import Registry
Expand Down Expand Up @@ -50,18 +51,17 @@ class HydraRuntime:
and graceful shutdown. Supports hot-reload of plugins and themes.
"""

def __init__(self, config: dict[str, Any]) -> None:
def __init__(self, config: HydraConfig) -> None:
self.config = config
self.state = RuntimeState.INITIALIZING

# Context bus with pluggable backend
bus_config = config.get("context_bus", {})
self.context_bus = ContextBus(
max_queue_size=bus_config.get("max_queue_size", 10000),
overflow_policy=bus_config.get("overflow_policy", "drop_oldest"),
default_ttl=bus_config.get("default_ttl"),
backend_type=bus_config.get("backend", "memory"),
redis_url=bus_config.get("redis_url", "redis://localhost:6379/0"),
max_queue_size=config.context_bus.max_queue_size,
overflow_policy=config.context_bus.overflow_policy,
default_ttl=config.context_bus.default_ttl,
backend_type=config.context_bus.backend,
redis_url=config.context_bus.redis_url,
)
self.registry = Registry()
self.plugin_manager = PluginManager()
Expand All @@ -80,7 +80,7 @@ async def initialize(self) -> None:
# Initialize database
try:
from hydra.database import init_database
db_url = self.config.get("database", {}).get("url", "sqlite+aiosqlite:///hydra.db")
db_url = self.config.database.url
await init_database(db_url)
self._db_available = True
except Exception:
Expand Down Expand Up @@ -128,10 +128,9 @@ async def initialize(self) -> None:
logger.error("Plugin dependency resolution failed: %s", e)

# Create adapters from config
adapters_config = self.config.get("adapters", {})
for adapter_id, adapter_conf in adapters_config.items():
adapter_type = adapter_conf.get("type", "")
adapter = self._create_adapter(adapter_id, adapter_type, adapter_conf)
for adapter_id, adapter_conf in self.config.adapters.items():
adapter_type = adapter_conf.type
adapter = self._create_adapter(adapter_id, adapter_type, adapter_conf.model_dump())
if adapter:
self.registry.register_adapter(adapter_id, adapter)

Expand All @@ -140,17 +139,17 @@ async def initialize(self) -> None:
await self._load_profiles_from_db()

# Create profiles from config (overrides DB if same ID)
for profile_conf in self.config.get("profiles", []):
for profile_conf in self.config.profiles:
profile = Profile(
id=profile_conf["id"],
name=profile_conf["name"],
avatar_url=profile_conf.get("avatar_url", ""),
banner_url=profile_conf.get("banner_url", ""),
status=profile_conf.get("status", ""),
theme=profile_conf.get("theme", "default"),
adapters=profile_conf.get("adapters", []),
plugins=profile_conf.get("plugins", []),
workers=profile_conf.get("workers", []),
id=profile_conf.id,
name=profile_conf.name,
avatar_url=profile_conf.avatar_url,
banner_url=profile_conf.banner_url,
status=profile_conf.status,
theme=profile_conf.theme,
adapters=list(profile_conf.adapters),
plugins=list(profile_conf.plugins),
workers=list(profile_conf.workers),
)
self._profiles[profile.id] = profile
self.registry.register_profile(profile.id, profile)
Expand Down
26 changes: 17 additions & 9 deletions hydra/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
if _project_root not in sys.path:
sys.path.insert(0, _project_root)

from pydantic import ValidationError

from hydra.config_schema import HydraConfig


def setup_logging(level: str = "INFO") -> None:
"""Configure structured logging for the framework."""
Expand All @@ -28,17 +32,22 @@ def setup_logging(level: str = "INFO") -> None:
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)


def load_config(config_path: str) -> dict:
"""Load framework configuration from JSON file."""
def load_config(config_path: str) -> HydraConfig:
"""Load and validate framework configuration from JSON file."""
path = Path(config_path)
if not path.exists():
logging.error("Config file not found: %s", config_path)
sys.exit(1)
with open(path) as f:
return json.load(f)
raw = json.load(f)
try:
return HydraConfig.model_validate(raw)
except ValidationError as exc:
logging.error("Invalid configuration: %s", exc)
sys.exit(1)
Comment on lines 41 to +47
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_config() now catches ValidationError, but invalid JSON (e.g., a trailing comma) will still raise json.JSONDecodeError and crash with a stack trace rather than a clear startup error. Consider catching json.JSONDecodeError (and potentially OSError) to log a concise message including the config path before exiting.

Copilot uses AI. Check for mistakes.


async def run(config: dict, dashboard_host: str, dashboard_port: int) -> None:
async def run(config: HydraConfig, dashboard_host: str, dashboard_port: int) -> None:
"""Main async entry point — boots runtime and dashboard."""
from hydra.core.runtime import HydraRuntime
from hydra.web_dashboard.app import create_app
Expand All @@ -55,7 +64,7 @@ async def run(config: dict, dashboard_host: str, dashboard_port: int) -> None:

# Create and start dashboard
dashboard_task = None
if config.get("dashboard", {}).get("enabled", True):
if config.dashboard.enabled:
app = create_app(runtime)
import uvicorn
uvi_config = uvicorn.Config(
Expand Down Expand Up @@ -161,13 +170,12 @@ def main() -> None:
config = load_config(args.config)

# Setup logging
log_level = "DEBUG" if args.debug else config.get("framework", {}).get("log_level", "INFO")
log_level = "DEBUG" if args.debug else config.framework.log_level
setup_logging(log_level)

# Resolve dashboard host/port
dashboard_config = config.get("dashboard", {})
host = args.host or dashboard_config.get("host", "0.0.0.0")
port = args.port or dashboard_config.get("port", 8080)
host = args.host or config.dashboard.host
port = args.port or config.dashboard.port

# Run
try:
Expand Down
Loading