greetings
 
You are here:

bbsengine6.module — module framework

Status: canonical. Single source of truth for the four-function module shape, the registry, and the module.run() lifecycle. The previous per-function reference (handbook/specs/modules.md, 1678 lines) is superseded; the design sketch in module_registration.md is historical context only.

bbsengine6.module is the runtime plugin loader, validator, access controller, and execution framework for bbsengine6. Every BBS package (the engine itself, casino, empyre, murdermotel, bed, …) is a module from this package's perspective: a Python object exposing init, access, buildargs, and main.

Contents

Module shape

Every bbsengine6 module exposes exactly four callables. They are called in order by module.run():

def init(args, **kwargs) -> bool:
    """Initialize the module. Called once when the module is loaded."""

def access(args, op: str, **kwargs) -> bool:
    """Check whether the current session is permitted to perform ``op``.

    ``op`` is a domain verb (the operation the caller wants to perform),
    not a wire-protocol message type. Examples: ``"run"``, ``"edit"``,
    ``"init"``, ``"buildargs"``, ``"subscribe"``, ``"list_pending"``.
    """

def buildargs(args, **kwargs) -> argparse.ArgumentParser | None:
    """Build and validate arguments for ``main()``.

    Returns a configured ``ArgumentParser``; ``None`` if the module has
    no CLI flags. The first line of the module's docstring becomes the
    parser description.
    """

def main(args, **kwargs) -> Any:
    """Execute the module."""

All four must accept **kwargs — the module loader threads context (pool, conn, kwargs from the caller) through every call.

Module discovery

Modules are discovered via importlib.import_module() against a fully-qualified Python module name (e.g. "bbsengine6.console.member" or "bbsengine6.console.checkroles"). There is no bbsengine6/modules/ directory and no plugin manifest — packages are added to PYTHONPATH and discovered by Python's standard import system.

The registry (register_module / unregister_module / is_module_registered / get_module / get_module_api) layers a name-keyed API index on top of importlib so callers can discover which API a module exports (its access policy, its init callable, etc.) without doing another importlib.import_module hop. The registry is opt-in via set_require_registration(True); without it, module.check accepts any importable module.

Loading and reloading

load(args, modulepath, package=None) -> ModuleType

Loads a module via importlib.import_module(). When args.debug is True, the module is importlib.reload-ed on every call so edits to the source are picked up without restarting the process.

package= resolves bare names and relative dotted names:

modulepath package Resolves to
"checkfunctions" None importlib.import_module("checkfunctions")
"checkfunctions" "bbsengine6.backend" bbsengine6.backend.checkfunctions
"checkfunctions" ".backend" bbsengine6.backend.checkfunctions (caller's package is bbsengine6)
".stage_one" ".backend" bbsengine6.backend.stage_one (PEP 328, caller is bbsengine6.backend)
"bbsengine6.console.member" None bbsengine6.console.member (absolute; package ignored)

Relative package= values are resolved against the calling frame's __package__ via _caller_package() so cross-package calls work without the caller hard-coding the anchor.

is_importable(modulepath) -> bool — returns True iff import_module(modulepath) succeeds, without leaving the module on sys.modules.

get(module_input, args=None, package=None) -> ModuleType — accepts either a dotted name (string) or an already-imported module object; returns the module object.

files(module_ref) -> pathlib.Path — returns the module's on-disk directory (like importlib.files). folder(module_ref, name) -> pathlib.Path | None returns the named subdirectory. file(module_ref, subdir, name) -> pathlib.Path | None returns a specific file inside a subdirectory.

Signature validation

check(args, modulename, op="run", *, package=None, **kwargs) -> bool

check is the entry point used by module.run. It runs the following gates, in order:

  1. Registration check. If get_require_registration() is True, the module must already be in the registry. If not, check returns False.

  2. Import. get(actual_modpath, args, package=package) — resolves the registered module_path (or the input modulename if unregistered) and imports it.

  3. Required callables. init, access, buildargs, main must all exist and be callable. Each is checked via _check_func_signature(func, _stub_X) against the matching stub from OP_TO_STUB:

    def _stub_access(args, op, **kwargs) -> bool | None: pass
    def _stub_init(args, **kwargs) -> bool | None: pass
    def _stub_buildargs(args, **kwargs) -> argparse.ArgumentParser | None: pass
    def _stub_main(args, **kwargs) -> bool | None: pass
    def _stub_version(args, **kwargs) -> str | None: return None

    version is optional; if present it must match _stub_version.

  4. Access. m.access(args, op, **kwargs) is invoked. A non-True return value fails the check.

_check_func_signature(func, stub, *, name=None, allow_extra=True, enforce_return=True) accepts the module's callable and the stub, then enforces:

_check_params(func_name, params, required, optional_kwargs=False) is the simpler parameter-name check used by older check() paths.

check_func(mod_ref, func_name, required_signature, *, allow_extra=True, enforce_return=True, silent=False) -> bool is the standalone validator exported for callers that want to check a specific function on a specific module. validate_function(*args, **kwargs) is a backward-compatible alias.

The registry

bbsengine6.module keeps a process-global registry of ModuleAPI records keyed by module name. ModuleAPI is a frozen dataclass:

@dataclass(frozen=True)
class ModuleAPI:
    version: str
    apis: dict[str, Callable]
    module_path: str
Function Signature Notes
register_module(name, module_path, version, apis) Thread-safe UPSERT Use in init() to expose a module's API
unregister_module(name) Thread-safe DELETE
is_module_registered(name) Thread-safe contains
get_module(name) -> ModuleAPI | None Thread-safe read
get_module_api(name, api_name) -> Callable | None Thread-safe dict lookup The hot-path accessor for bbsengine6.get_module_api
set_require_registration(bool) Thread-safe flag When True, module.check requires registration
get_require_registration() -> bool Thread-safe flag read
get_all_modules() -> list[str] Snapshot of registered names

The registry is created by _create_registry() which returns a tuple of eight operations bound to a fresh RegistryState and threading.RLock. The module-default registry is the first instance created at import time.

The registry is what makes bbsengine6.message.init callable from the engine startup path:

from bbsengine6 import register_module

register_module(
    name="bbsengine6.message",
    module_path="bbsengine6.message",
    version=__version__,
    apis={"access": access},
)

Execution lifecycle

module.run(args, modulename, **kwargs) -> Any

run is the canonical lifecycle orchestrator. The order is:

run(args, "modulename", **kwargs)
  ├─ actual_modpath = get_module(modulename).module_path or modulename
  ├─ get(actual_modpath, args, package=kwargs.pop("package", None))
  ├─ check(args, modulename, package=package, **kwargs)
  ├─ runcallback(args, m.init, **kwargs)               # module.init()
  ├─ argv = kwargs.get("argv", [])
  ├─ if _is_help_request(argv):
  │     runcallback(args, m.buildargs, **kwargs)
  │     print_help() and return True
  ├─ prgargparser = runcallback(args, m.buildargs, **kwargs)
  ├─ if prgargparser is not None:
  │     if argv empty and _has_subparser_info(args): use args directly
  │     elif argv and _has_subparser_info(args): warn + prgargparser.parse_args(argv)
  │     else:                                            prgargparser.parse_args(argv)
  │     return runcallback(prgargs, m.main, **kwargs)
  └─ return runcallback(args, m.main, **kwargs)

runmodule is an alias for run (kept for backward compat with older call sites).

runcallback(args, callback, optional=False, **kwargs):

Helper functions

Function Signature Purpose
check (args, modulename, op="run", *, package=None, **kwargs) Verify a module is valid and accessible
is_importable (modulepath) -> bool Import test that does not pollute sys.modules
load (args, modulepath, *, package=None) -> ModuleType Import (or reload in debug mode)
get (module_input, args=None, *, package=None) -> ModuleType Resolve a string-or-module reference
files (module_ref) -> pathlib.Path Module directory
folder (module_ref, name) -> pathlib.Path | None Subdirectory
file (module_ref, subdir, name) -> pathlib.Path | None Subdirectory + filename
get_op (module_ref, op, args=None) -> Callable | None Return the op function from the module if it matches its stub
runcallback (args, callback, optional=False, **kwargs) Invoke a callable or "module.func" string
run (args, modulename, **kwargs) Full lifecycle
runmodule alias for run
register_module / unregister_module / is_module_registered / get_module / get_module_api / get_all_modules / set_require_registration / get_require_registration registry surface See The registry
check_func / validate_function signature validators See Signature validation

SignatureError is a dataclass carrying func_name, expected, found, and optional reason fields. It is constructed by _check_func_signature and surfaced through io.echo(..., level="debug").

How the console loads modules

bbsengine6.console.lib.runmodule(args, submodule, *, package="bbsengine6.console", **kwargs) is a one-liner: module.runmodule(args, f"{package}.{submodule}", **kwargs). The console __main__.py builds a subcommand parser from CONSOLE_SUBCOMMANDS and BACKEND_SUBCOMMANDS, then routes the user's choice to handle_subcommand(args, subcommand). Backend subcommands dispatch with package="bbsengine6.backend"; console subcommands stay with the default package="bbsengine6.console".

This is the same module.run lifecycle every BBS package uses; the console is just a thin argparse wrapper around it.

How startup loads stages

bbsengine6.startup.lib.runmodule(args, submodule, **kwargs) delegates to bbsengine6.console.lib.runmodule(args, submodule, package="bbsengine6.startup", **kwargs). The startup main loop runs ("stage_zero", "stage_one", "engine", "bank") via this helper, threading conn=conn through so every stage shares the same transaction. See bbsengine6/TODO_BACKEND.md for the canonical bring-up sequence.

Error handling