← Back to Blog

The zero-codegen workflow: save, wait 300ms, types are fresh

·8 min read

There is no codegen command in the happy path. You run pyrpc dev (orpyrpc watch) once, and from then on every save of a Python file ends with fresh TypeScript declarations a few hundred milliseconds later. The whole loop lives incli.py, and it has four stages: pick the directories to watch, batch file events, debounce them, and regenerate. This post walks each stage and the constants that tune it, _DEBOUNCE_SECONDS = 0.3, the 200ms watchfiles batch, and theschedule()_do_regen handoff.

Four stages of a save

The pipeline is deliberately small. _find_python_dirs answers “where do we listen?”; watchfiles’ watch withdebounce=200 and yield_on_timeout=True answers “when has a save settled?”; schedule() owns the 300ms resettingthreading.Timer; and _do_regen actually regenerates, reloading your module so the output reflects the edit. Each stage is a few lines, and none of them involve you typing a command.

Which directories get watched

_find_python_dirs(root) returns the project root plus its immediate subdirectories, deliberately not a recursive walk:

_skip = {"node_modules", "__pycache__", ".venv", "venv", "env",
 "dist", "build", ".git", ".next"}

def _find_python_dirs(root: str) -> list[str]:
 _skip = {"node_modules", "__pycache__", ".venv", "venv", "env",
 "dist", "build", ".git", ".next"}
 dirs = [root]
 try:
 for entry in os.scandir(root):
 if entry.is_dir() and entry.name not in _skip and not entry.name.startswith("."):
 dirs.append(entry.path)
 except PermissionError:
 pass
 return dirs

Why not recurse into every nested directory? Two reasons. First, the type pipeline only cares about the entry module and the modules it imports, the schema is rebuilt by reloading the module, not by scanning files, so watching every nested package directory buys nothing. Second, a flat one-level scan with an explicit skip list is cheap and predictable: no walking into node_modules, and no accidental watching of build output that churns every time types are regenerated. The skip set matches_find_frontend_projects, so the same noise directories are excluded everywhere.

The PermissionError guard matters in real monorepos: a sibling directory can be owned by another user or mounted from a container, and one unwatchable path shouldn’t take down the whole watcher. The root is always included, so even amain.py at the top level is covered.

watchfiles: batching bursts of changes

With the directory list in hand, a daemon thread feeds it to watchfiles:

def _py_watcher():
 for changes in watch(
 *_find_python_dirs(cwd),
 stop_event=stop,
 yield_on_timeout=True,
 debounce=200,
 ):
 if stop.is_set():
 break
 if any(f.endswith(".py") for _, f in changes):
 schedule()

Two parameters deserve attention. debounce=200 tells watchfiles to coalesce the burst of events a single editor save produces, editors typically create, truncate, write, and rename files, generating several events in a few milliseconds. The 200ms window folds them into one batch. yield_on_timeout=True changes the semantics of the generator: when nothing has changed, it yields an empty list periodically instead of blocking forever, which is what lets the stop event interrupt the loop cleanly on Ctrl+C.

The filter is explicit about what counts: any(f.endswith(".py") ...). A.pyc file, a stray JSON write, or the __pyrpc.d.ts file itself being rewritten does not trigger regeneration. Only Python source changes do. That last one is subtle and important, the client directories often live inside the watched tree, and without the .py filter the watcher would loop forever regenerating itself.

The 300ms debounce timer

The watchfiles batch is still not the final debounce. A save has two debounce layers: watchfiles’ 200ms batching, then a threading.Timer of_DEBOUNCE_SECONDS = 0.3 inside _make_regen_callback. The second layer exists because regeneration itself, importing the module, building the schema, writing files, should not start until the file has settled. The pattern matches webpack’s aggregateTimeout and nodemon’s --delay: regenerate once, after the last change stops.

_DEBOUNCE_SECONDS = 0.3

def _make_regen_callback(module: str, client_dirs: list[str]):
 _lock = threading.Lock()
 _timer: list[threading.Timer | None] = [None]
 _timer_lock = threading.Lock()

 def _do_regen():
 if not _lock.acquire(blocking=False):
 return
 try:
 n = _regenerate_clients(module, client_dirs, reload=True)
 console.print(
 f"[dim]{time.strftime('%H:%M:%S')} types regenerated "
 f"({n} procs) for {len(client_dirs)} clients[/dim]"
 )
 except Exception as e:
 console.print(f"[red]Error regenerating types:[/red] {e}")
 finally:
 _lock.release()

 def schedule():
 with _timer_lock:
 if _timer[0] is not None:
 _timer[0].cancel()
 t = threading.Timer(_DEBOUNCE_SECONDS, _do_regen)
 t.daemon = True
 t.start()
 _timer[0] = t

 return _do_regen, schedule

schedule() is resettable: each new event cancels the pending timer and starts a fresh one. Save three times in quick succession and the timer keeps restarting, so regeneration fires once, 300ms after the last save, not three times. The regeneration itself is guarded by a non-blocking lock: if a regen is already running when the timer fires, the new call returns immediately rather than stacking a second concurrent regen. Types are never generated twice for the same burst, and two overlapping runs can never race each other on the output file.

Regeneration that reflects the edit

The timer calls _do_regen, which calls_regenerate_clients(module, client_dirs, reload=True). Thereload=True flag routes through _run_codegen intodefault_router.reload_module. A plain re-import would return the already-cached module and regenerate stale types; reload_module clears the router, calls importlib.reload (which re-fires the @rpcdecorators and re-registers procedures), and, critically, restores the old procedure set if the reload fails or the module exports no procedures. A broken edit does not wipe your types; you keep the last good set until the next successful save. This reload-on-regen behavior shipped in v0.11.1.

The same reload=True path is what makes the workflow genuinely zero-manual: because the watcher reloads the module in-process, newly added, removed, or renamed procedures show up in __pyrpc.d.ts without any step on your side.

The full timeline

14:22:30.000 you save app/main.py
14:22:30.002 editor writes the file → watchfiles sees create/write/rename events
14:22:30.202 watchfiles debounce (200ms) folds them into one batch
14:22:30.203 .py filter matches → schedule() starts a 300ms timer
14:22:30.504 (the timer keeps resetting if you save again before it fires)
14:22:30.505 _do_regen → reload_module → schema → __pyrpc.d.ts written
14:22:30.510 "14:22:30 types regenerated (4 procs) for 1 clients"

From save to fresh types is on the order of half a second, and it is entirely automatic. The manual codegen command still exists for CI and one-off generation from a schema file or URL, but in the development loop you never type it, the watcheris the workflow.

Why zero-codegen is the right default

Explicit codegen commands fail in exactly the situation they claim to solve: you forget to run them. The generated file silently goes stale, and the first signal is a type error in your editor pointing at code you didn’t change. Tying generation to the save event removes that failure mode entirely. It is the same reasoning that moved webpack and Vite from manual build steps to watch mode by default: the thing that must always happen should not depend on the developer remembering to do it. The debounce constants (_DEBOUNCE_SECONDS = 0.3, the 200ms batch) are the tuning knobs that keep the loop fast without wasting work, fresh types within half a second, exactly one regeneration per settled save.

Read the full changelogfor the complete list of changes.