172 lines
5.4 KiB
Python
172 lines
5.4 KiB
Python
"""
|
|
registry.py - Discovery and registration for framework feature modules
|
|
|
|
Each package directly under ``modules`` may expose ``register(registry)``.
|
|
The same registry is loaded by the API, Discord bot, and scheduler so a
|
|
feature can keep its routes, commands, prompts, and jobs together.
|
|
"""
|
|
|
|
import importlib
|
|
import pkgutil
|
|
|
|
|
|
class FrameworkRegistry:
|
|
def __init__(self):
|
|
self.modules = {}
|
|
self.commands = {}
|
|
self.route_registrars = []
|
|
self.job_handlers = {}
|
|
self._loading_module = None
|
|
|
|
def clear(self):
|
|
self.modules.clear()
|
|
self.commands.clear()
|
|
self.route_registrars.clear()
|
|
self.job_handlers.clear()
|
|
self._loading_module = None
|
|
|
|
def begin_module(self, name, package):
|
|
if name in self.modules:
|
|
raise ValueError(f"Duplicate module name: {name}")
|
|
self.modules[name] = {
|
|
"name": name,
|
|
"package": package,
|
|
"description": "",
|
|
}
|
|
self._loading_module = name
|
|
|
|
def finish_module(self):
|
|
self._loading_module = None
|
|
|
|
def describe(self, description):
|
|
module_name = self._require_loading_module()
|
|
self.modules[module_name]["description"] = description.strip()
|
|
|
|
def register_command(
|
|
self,
|
|
interaction_type,
|
|
handler,
|
|
prompt,
|
|
validator=None,
|
|
help_text=None,
|
|
description="",
|
|
):
|
|
module_name = self._require_loading_module()
|
|
if interaction_type in self.commands:
|
|
raise ValueError(f"Duplicate command type: {interaction_type}")
|
|
if not callable(handler):
|
|
raise TypeError(f"Handler for {interaction_type} must be callable")
|
|
if validator is not None and not callable(validator):
|
|
raise TypeError(f"Validator for {interaction_type} must be callable")
|
|
if not isinstance(prompt, dict) or not prompt.get("system") or not prompt.get(
|
|
"user_template"
|
|
):
|
|
raise ValueError(
|
|
f"Command {interaction_type} must provide system and user_template prompts"
|
|
)
|
|
|
|
self.commands[interaction_type] = {
|
|
"module": module_name,
|
|
"handler": handler,
|
|
"prompt": prompt,
|
|
"validator": validator,
|
|
"help_text": list(help_text or []),
|
|
"description": description.strip(),
|
|
}
|
|
|
|
def register_routes(self, registrar):
|
|
module_name = self._require_loading_module()
|
|
if not callable(registrar):
|
|
raise TypeError(f"Route registrar for {module_name} must be callable")
|
|
self.route_registrars.append((module_name, registrar))
|
|
|
|
def register_job(self, job_type, handler):
|
|
module_name = self._require_loading_module()
|
|
if job_type in self.job_handlers:
|
|
raise ValueError(f"Duplicate job type: {job_type}")
|
|
if not callable(handler):
|
|
raise TypeError(f"Job handler for {job_type} must be callable")
|
|
self.job_handlers[job_type] = {
|
|
"module": module_name,
|
|
"handler": handler,
|
|
}
|
|
|
|
def get_command(self, interaction_type):
|
|
return self.commands.get(interaction_type)
|
|
|
|
def get_job_handler(self, job_type):
|
|
registration = self.job_handlers.get(job_type)
|
|
return registration["handler"] if registration else None
|
|
|
|
def list_commands(self):
|
|
return list(self.commands.keys())
|
|
|
|
def router_context(self):
|
|
lines = []
|
|
for name, command in sorted(self.commands.items()):
|
|
description = command["description"] or "No description provided"
|
|
lines.append(f"- {name}: {description}")
|
|
return "\n".join(lines) if lines else "No modules are available"
|
|
|
|
def help_lines(self):
|
|
lines = []
|
|
for name, command in sorted(self.commands.items()):
|
|
if command["help_text"]:
|
|
lines.extend(f"- {item}" for item in command["help_text"])
|
|
else:
|
|
lines.append(f"- {name}: {command['description']}")
|
|
return lines
|
|
|
|
def _require_loading_module(self):
|
|
if not self._loading_module:
|
|
raise RuntimeError("Registration must happen inside a module register() call")
|
|
return self._loading_module
|
|
|
|
|
|
registry = FrameworkRegistry()
|
|
_loaded = False
|
|
|
|
|
|
def discover_modules(force=False):
|
|
"""Discover and register feature packages exactly once per process."""
|
|
global _loaded
|
|
if _loaded and not force:
|
|
return registry
|
|
|
|
_loaded = False
|
|
|
|
package = importlib.import_module("modules")
|
|
discovered = sorted(
|
|
item.name
|
|
for item in pkgutil.iter_modules(package.__path__)
|
|
if item.ispkg and not item.name.startswith("_")
|
|
)
|
|
|
|
registry.clear()
|
|
try:
|
|
for module_name in discovered:
|
|
qualified_name = f"modules.{module_name}"
|
|
feature_module = importlib.import_module(qualified_name)
|
|
register_fn = getattr(feature_module, "register", None)
|
|
if not callable(register_fn):
|
|
raise RuntimeError(f"{qualified_name} must expose register(registry)")
|
|
|
|
registry.begin_module(module_name, qualified_name)
|
|
try:
|
|
register_fn(registry)
|
|
finally:
|
|
registry.finish_module()
|
|
except Exception:
|
|
registry.clear()
|
|
raise
|
|
|
|
_loaded = True
|
|
return registry
|
|
|
|
|
|
def reset_registry():
|
|
"""Reset discovery state for tests."""
|
|
global _loaded
|
|
registry.clear()
|
|
_loaded = False
|