mud/src/mudlib/content.py
Jared Miller d159a88ca4
Add TOML content loader for declarative command definitions
Scan content/commands/ for .toml files at startup and register them
as commands alongside Python-defined ones. Two flavors: handler-based
(points to a Python callable via module:function) and message-based
(auto-generates a handler from inline text). Includes example MOTD
command, type validation, error logging, and full test coverage.
2026-02-07 20:27:29 -05:00

141 lines
3.7 KiB
Python

"""Content loading from TOML files."""
import importlib
import logging
import tomllib
from pathlib import Path
from typing import cast
from mudlib.commands import CommandDefinition, CommandHandler
from mudlib.player import Player
log = logging.getLogger(__name__)
def _resolve_handler(handler_ref: str) -> CommandHandler:
"""Resolve a handler reference string to a callable.
Args:
handler_ref: String in format "module.path:function_name"
Returns:
The resolved callable
Raises:
ImportError: If the module cannot be imported
AttributeError: If the function doesn't exist in the module
"""
if ":" not in handler_ref:
msg = (
f"Handler reference must be in format 'module:function', got: {handler_ref}"
)
raise ValueError(msg)
module_path, func_name = handler_ref.split(":", 1)
module = importlib.import_module(module_path)
handler = getattr(module, func_name)
if not callable(handler):
msg = f"Handler reference {handler_ref} is not callable"
raise TypeError(msg)
return cast(CommandHandler, handler)
def _make_message_handler(message: str) -> CommandHandler:
"""Create a handler that sends a fixed message to the player.
Args:
message: The message text to send
Returns:
An async handler function
"""
async def handler(player: Player, args: str) -> None:
player.writer.write(message.strip() + "\r\n")
await player.writer.drain()
return handler
def load_command(path: Path) -> CommandDefinition:
"""Load a command definition from a TOML file.
Args:
path: Path to the .toml file
Returns:
CommandDefinition instance
Raises:
KeyError: If required fields are missing
ValueError: If neither handler nor message is specified
TypeError: If handler or message fields have wrong types
ImportError: If handler reference cannot be resolved
"""
with open(path, "rb") as f:
data = tomllib.load(f)
name = data["name"]
# Determine the handler
handler_ref = data.get("handler")
message = data.get("message")
if handler_ref and message:
msg = "Command definition cannot specify both 'handler' and 'message'"
raise ValueError(msg)
if not handler_ref and not message:
msg = "Command definition must specify either 'handler' or 'message'"
raise ValueError(msg)
# Validate types
if handler_ref and not isinstance(handler_ref, str):
msg = f"'handler' must be a string in {path}"
raise TypeError(msg)
if message and not isinstance(message, str):
msg = f"'message' must be a string in {path}"
raise TypeError(msg)
handler: CommandHandler
if handler_ref:
handler = _resolve_handler(handler_ref)
else:
# message must be a string here since we checked it's not None
handler = _make_message_handler(cast(str, message))
# Optional fields with defaults
aliases = data.get("aliases", [])
mode = data.get("mode", "normal")
help_text = data.get("help", "")
return CommandDefinition(
name=name,
handler=handler,
aliases=aliases,
mode=mode,
help=help_text,
)
def load_commands(directory: Path) -> list[CommandDefinition]:
"""Load all command definitions from a directory.
Args:
directory: Path to directory containing .toml files
Returns:
List of CommandDefinition instances
"""
commands = []
for path in directory.glob("*.toml"):
try:
commands.append(load_command(path))
except Exception as e:
log.warning("failed to load command from %s: %s", path, e)
return commands