""" 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