← Back to Blog

Reloading modules in the watcher: import vs reload

·9 min read

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.reload re-runs the module, which re-fires every @rpc decorator 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 and False is 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.