v0.11.1 fixes a bug that quietly undermined the entire zero-codegen promise. The watcher regenerated types on every save, but the types were sometimes stale, reflecting theprevious version of your procedures. The root cause was a single word:import vs reload.
The cached-module trap
Python’s importlib.import_module does not re-execute a module that is already in sys.modules. It returns the cached object. For a one-shot CLI likepyrpc codegen that is exactly right, but the watcher runs inside a long-lived process, and it needs to regenerate against the newest version of the entry module after every edit.
Before the fix, the regen path did:
# wrong: re-importing returns the cached module _import_module(module) schemas = get_registry_schema(default_router)
The first run imported main, its @rpc decorators fired, anddefault_router filled up. Every save after that re-imported the same cached module: the decorators never re-fired, the router kept its original procedure list, and the regenerated __pyrpc.d.ts never saw your edits. Save, wait 300ms, types regenerate, identical to the last time.
reload_module: a transactional swap
The fix routes the watcher through Router.reload_module, which is designed for exactly this case:
def reload_module(self, module_path: str) -> bool: import importlib mod = importlib.import_module(module_path) with self._lock: old = dict(self._procedures) self._procedures.clear() try: importlib.reload(mod) except BaseException: with self._lock: self._procedures.update(old) raise with self._lock: if not self._procedures: self._procedures.update(old) return False return True
Three properties make it safe for a live process:
- Atomic swap, old procedures are snapshotted and the router is cleared before
importlib.reloadre-runs the module, which re-fires every@rpcdecorator and repopulates the router. - Rollback on failure, if the module raises during reload (a syntax error, a missing import, a runtime error at module scope), the old procedures are restored and the exception propagates. A broken save never leaves you with an empty router.
- Empty-guard, if the reloaded module exports no procedures (you deleted the last
@rpc, or the module’s decorator is bound to a different router), the old set is restored andFalseis returned.
Note the decorator-binding caveat in the docstring: this only works when the module’s@rpc is the global from pyrpc_core import rpc, which is bound todefault_router. A module that constructs its own Router() instance registers into that router, and reloading it leaves default_routerempty, hence the guard.
The reload flag in _run_codegen
_run_codegen now takes a reload flag that picks the right import strategy per call site:
def _run_codegen(module: str, output_path: str, *, reload: bool = False) -> int:
_lazy_core()
from pyrpc_core import default_router, get_registry_schema
if reload:
if not default_router.reload_module(module):
console.print(" [yellow]⚠[/yellow] no procedures after reload")
return 0
else:
_import_module(module)
schemas = get_registry_schema(default_router)
save = _lazy_codegen()
save(schemas, output_path)
return len(schemas)reload=False, used by dev and watch at startup, and by one-shot codegen, does a normal import: the process is fresh, nothing is cached, an import is correct. reload=True, used by the debounced regen callback after a save, goes through reload_module, with a friendly warning (and a skip) when the reloaded module yields no procedures.
Why this was worth a release
A watcher that regenerates stale types is worse than no watcher: it looks like it works until the day a rename doesn’t show up and you start chasing ghosts in a__pyrpc.d.ts that was never updated. The distinction between import and reload is a one-word fix with a big contract, save, wait 300ms, types reflect exactly what you wrote, and it restores the core promise of the zero-codegen workflow.
Read the full changelogfor the complete list of changes.

pyRPC