Everything pyRPC needs to know about your project, which module to scan, which frontends to feed, lives in one small JSON file. Tracing that file through the system is the fastest way to understand the whole CLI, because every command either reads it, writes it, or watches it.
Born in the wizard
The file is created on the first run of pyrpc dev, when no config exists. The wizard produces a dict and it is written with a stable shape:
def _write_config(config: dict, path: Path | None = None) -> Path:
if path is None:
path = Path.cwd() / CONFIG_FILE
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
return pathTwo details: indent=2 for human-readable diffs, and an explicit trailing newline , details that keep the file pleasant in pull requests. The result looks like:
{
"module": "server",
"framework": "FastAPI",
"clients": ["./frontend", "./admin"]
}Found from anywhere
You will usually run pyrpc from the project root, but not always. The lookup walks up the directory tree until it finds a pyrpc.json:
def _find_config() -> Path | None: """Walk up from cwd to find pyrpc.json.""" p = Path.cwd() for parent in [p] + list(p.parents): candidate = parent / CONFIG_FILE if candidate.is_file(): return candidate return None
That means pyrpc dev from app/ behaves identically to running it from the repo root, as long as a config exists somewhere above. The config is a property of the project, not of your terminal location.
Read by every command
The read is centralized and tolerant:
def _read_config() -> dict | None: path = _find_config() if not path: return None try: with open(path) as f: return json.load(f) except Exception: return None
A malformed config returns None rather than crashing; callers fall back to the wizard or to explicit flags. Consumers:
- dev, reads
moduleandclient/clients, imports the module, generates types, starts the server. - watch, reads the same fields; the whole command is “read config, then do the dev type-loop without a server.”
- codegen, uses
--client(or the config’s clients when invoked through dev), so a CI regenerate honors the same layout. - _get_clients, normalizes
clientorclientsinto one list, so downstream code never branches.
Watched while dev is running
The interesting part of the file’s life is that it is live. A dedicated watcher thread monitors the config’s parent directory and reacts to changes whiledev runs:
new_module = new_cfg.get("module", module)
new_client_dirs = _get_clients(new_cfg)
module_changed = new_module != module
output_changed = new_client_dirs != client_dirs
if not module_changed and not output_changed:
continue
console.print(" [blue]pyrpc.json changed, reloading...[/blue]")
# re-wire regen callback, restart uvicorn if module changed and owned, regen nowAdd a client, switch modules, and the running session adapts, no Ctrl+C, no restart. When the module changes and pyRPC owns the server, uvicorn is restarted with the new module; when only clients change, the regen callback is re-pointed and types regenerate immediately.
Why a file, not flags
Config-as-file wins for three reasons:
- Repeatability, the same project behaves the same way across
dev,watch, CI, and every teammate’s machine. - Reviewability, moving a client root is a one-line diff that shows up in a PR, not a secret flag you carry in a Makefile.
- Live behavior, a file can be watched; a command-line invocation cannot. The watcher turns config edits into running-system changes.
From a wizard prompt to a file being hot-reloaded by a running watcher, pyrpc.jsonis the spine of the CLI. Every feature, multi-client, zero-codegen, non-interactive setup, is really a rule about how this file is born, read, and kept in sync.
Read the full changelogfor the complete list of changes.

pyRPC