Files
hermes-agent/tui_gateway/method_ctx.py

164 lines
6.6 KiB
Python

"""Seam for the server.py handler/helper split.
server.py's JSON-RPC handlers and many helpers close over its module globals
(``_sessions``, ``_ok``, ``_err``, config helpers, ...). To move them out
without rewriting a single body, each split module defines its code normally
and server.py calls :func:`bind_module` at the end of its own import, once
every global the code closes over exists. Function bodies are re-created with
``types.FunctionType`` against server.py's namespace, so they stay
byte-identical and ``global X`` statements keep mutating server.py state.
No import cycle: split modules never import server at module level — server
imports them and passes itself in.
"""
import contextlib
import types
# contextlib.contextmanager wraps the generator; rebind the generator (found via
# __wrapped__) and re-wrap, otherwise only the wrapper would see server globals.
_CM_HELPER_CODE = contextlib.contextmanager(lambda: (yield)).__code__
def rebind(fn, g: dict, _seen=None):
"""Copy ``fn`` with globals ``g``.
Closure cells holding functions from the same module are rebound too, so
handlers produced by import-time decorator factories keep working.
"""
_seen = {} if _seen is None else _seen
if id(fn) in _seen:
return _seen[id(fn)]
wrapped = getattr(fn, "__wrapped__", None)
if wrapped is not None and fn.__code__ is _CM_HELPER_CODE:
return contextlib.contextmanager(rebind(wrapped, g, _seen))
closure = fn.__closure__
if closure:
cells = []
for cell in closure:
try:
val = cell.cell_contents
except ValueError: # empty cell
cells.append(cell)
continue
if isinstance(val, types.FunctionType) and val.__module__ == fn.__module__:
cells.append(types.CellType(rebind(val, g, _seen)))
else:
cells.append(cell)
closure = tuple(cells)
real = types.FunctionType(fn.__code__, g, fn.__name__, fn.__defaults__, closure)
real.__kwdefaults__ = fn.__kwdefaults__
real.__doc__ = fn.__doc__
real.__dict__.update(fn.__dict__)
_seen[id(fn)] = real
return real
class HandlerRegistry:
"""Deferred @method registrar used by the split modules."""
def __init__(self) -> None:
self._pending: list[tuple[str, types.FunctionType]] = []
def method(self, name: str):
"""Drop-in for server.py's ``@method`` decorator (defers registration)."""
def dec(fn):
self._pending.append((name, fn))
return fn
return dec
def profile_scoped(self, fn):
"""Drop-in for server.py's ``@_profile_scoped`` (applied at install)."""
fn._hermes_profile_scoped = True
return fn
def install(self, server) -> None:
"""Rebind pending handlers onto ``server``'s globals and register them."""
g = vars(server)
for name, fn in self._pending:
real = rebind(fn, g)
if getattr(fn, "_hermes_profile_scoped", False):
real = server._profile_scoped(real)
server._methods[name] = real
_PLUMBING = {"HandlerRegistry", "method", "_profile_scoped", "register", "rebind", "logger"}
def bind_module(module_globals: dict, server, *, skip=()) -> None:
"""Publish everything a split module defines onto ``server``, rebound to its globals.
``module_globals`` is the caller's ``globals()`` (not ``sys.modules[__name__]``:
tests that ``patch.dict(sys.modules)`` around the server import drop the
submodule entries while the package attribute survives, so a re-import would
KeyError). Functions are rebound; classes get their methods rebound in place;
other values (constants, ``global``-mutated state seeds) are copied as-is.
Imported modules/functions, dunders and registry plumbing are skipped, so a
split module needs no hand-maintained export list. Dispatch tables (dicts
whose values are this module's functions) get their values rebound too.
Finally the module's ``_registry`` (if any) installs its @method handlers.
"""
g = vars(server)
mod_name = module_globals["__name__"]
seen: dict = {}
def _own_fn(v):
return isinstance(v, types.FunctionType) and v.__module__ == mod_name
def _rebind_in(v):
"""Rebind own functions nested in dict/tuple/list constants (dispatch tables)."""
if _own_fn(v):
return rebind(v, g, seen)
if isinstance(v, dict):
return {k: _rebind_in(x) for k, x in v.items()}
if isinstance(v, (tuple, list)):
return type(v)(_rebind_in(x) for x in v)
return v
def _has_own_fn(v):
if _own_fn(v):
return True
if isinstance(v, dict):
return any(_has_own_fn(x) for x in v.values())
if isinstance(v, (tuple, list)):
return any(_has_own_fn(x) for x in v)
return False
for name, obj in list(module_globals.items()):
if name.startswith("__") or name in _PLUMBING or name in skip:
continue
if isinstance(obj, (types.ModuleType, HandlerRegistry)):
continue
if isinstance(obj, types.FunctionType):
if obj.__module__ != mod_name:
if name == obj.__name__:
continue # plain import; server already has its own
# ``_alias = other_module.fn`` — publish as-is, no rebind
else:
obj = rebind(obj, g, seen)
elif isinstance(obj, (dict, tuple, list)) and _has_own_fn(obj):
obj = _rebind_in(obj)
module_globals[name] = obj # keep the split module's own view consistent
elif isinstance(obj, type):
if obj.__module__ != mod_name:
continue
for attr, val in list(vars(obj).items()):
if isinstance(val, types.FunctionType):
setattr(obj, attr, rebind(val, g))
elif isinstance(val, (staticmethod, classmethod)):
setattr(obj, attr, type(val)(rebind(val.__func__, g)))
prev = g.get(name)
if isinstance(prev, types.FunctionType) and isinstance(obj, types.FunctionType):
owner = getattr(prev, "_hermes_split_module", None)
if owner and owner != mod_name:
raise RuntimeError(
f"split-module name collision: {mod_name}.{name} would overwrite {owner}.{name}"
)
obj._hermes_split_module = mod_name
setattr(server, name, obj)
registry = module_globals.get("_registry")
if isinstance(registry, HandlerRegistry):
registry.install(server)