pyRPC
← Back to Blog

CLI overhaul, model interfaces, and the dev tools we built

·12 min read

After the last release we took a hard look at the CLI and developer tooling. The codegen worked, the client worked, but there were sharp edges everywhere: a broken serve command, a confusing split between pull andcodegen, no model interface generation, and the client.rpc.add() syntax silently returned undefined instead of a helpful error.

Over the last week we shipped six improvements that reshape the developer experience. This post documents the decisions behind each one.

1. The .rpc prefix now throws a clear error

The old createClient proxy returned undefined when someone accessedclient.rpc. This was an intentional decision to prevent the oldclient.rpc.method() pattern (which we removed for a cleaner API). Butundefined is a terrible error signal — it produces a confusingTypeError: Cannot read properties of undefined with no indication of what went wrong or how to fix it.

The fix is a redirecting proxy that catches every property access onclient.rpc and throws a message that tells you exactly what to do:

Use client.add() instead of client.rpc.add().
The .rpc prefix was removed for a cleaner API.

This pattern — a proxy that throws helpful errors instead of silently failing — is something we are applying across the client surface area. Every breaking change should produce an error message that tells you the new way to do it.

2. Model interfaces via jsonschema-ts

The biggest gap in the old codegen was Pydantic model support. If you defined a @model class, the generated TypeScript would only output the class name (e.g. User) — no interface definition, no property types, no autocompletion on the frontend. The user had to manually maintain the TypeScript definitions.

We evaluated three approaches:

  • Ship a Python reimplementation of JSON Schema to TypeScript — months of work to handle allOf, anyOf, oneOf, $ref, $defs, enums, tuples, circular refs.
  • Inline Jinja2 templates to walk schema properties — fragile, would need constant updates as JSON Schema evolves.
  • Delegate to json-schema-to-typescript (npm, 3.3k stars, 705 dependents, used by Microsoft/Amazon/Expo/Webpack) — mature, tested, handles every edge case.

We chose option three and built jsonschema-ts, a thin Python orchestration layer that calls npx json2ts under the hood. It is zero-dependency Python stdlib — the only requirement is Node.js 18+ with npx available.

The API is three functions:

  • collect_defs(*schemas) — extracts and merges $defs from Pydantic JSON Schema output.
  • convert_all(defs) — converts all definitions to TypeScript interfaces in a single npx call.
  • assemble(models, procedures) — combines model and procedure types into the final output file.

The generated output now has two sections:

// ── Models ──────────────────────────────────────
export interface User {
  id: number;
  name: string;
  email: string;
}

// ── Procedures ──────────────────────────────────
export interface Types {
  createUser(user: User): Promise<User>;
}

One important design constraint: jsonschema-ts is a standalone package on PyPI. It does not depend on pyrpc-core, pyrpc-codegen, or any other pyrpc package. You can use it directly with any Pydantic project:

pip install jsonschema-ts

This separation means the JSON Schema to TypeScript conversion is useful beyond pyrpc. Any Python project with Pydantic models can generate TypeScript interfaces without adopting the full pyrpc stack.

3. Merging pull into codegen

The old CLI had two separate commands: pyrpc pull app.main extracted schemas to a JSON file, and pyrpc codegen pyrpc-schema.json read the file and generated TypeScript. The two-step workflow was confusing — developers expected one command to go from Python module to TypeScript types.

We merged pull into codegen. Now:

  • pyrpc codegen app.main — import a Python module, extract schemas, generate TypeScript. One command end-to-end.
  • pyrpc codegen pyrpc-schema.json — read a schema file, generate TypeScript. Same behavior as before.
  • pyrpc codegen http://localhost:8000 — fetch schema from a running server, generate TypeScript. Same behavior as before.

pyrpc pull still exists as a standalone command for CI workflows where you want to save the intermediate schema file. The internal_extract_schema_from_module() function now includes the Pydantic schema data (schema_ and return_schema) that was previously dropped during serialization — the serialization gap that made model interfaces impossible.

4. Fixing pyrpc serve

The pyrpc serve command had a subtle bug: it started uvicorn withuvicorn.run("pyrpc:asgi_app"), which tells uvicorn to import thepyrpc package and use the module-level asgi_app instance. Butpyrpc was not a real pip package — the ASGI app lived inpyrpc_core.transport.asgi, and the module-level app instance was created with router=None. Importing the user’s module registered@rpc procedures on default_router, but the ASGI app never received it. Every GET /rpc call would crash with a NoneType error because self.router was None.

The fix is straightforward: import PyRPCAsgiApp directly and create the app instance with default_router:

from pyrpc_core.transport.asgi import PyRPCAsgiApp
app_instance = PyRPCAsgiApp(default_router)
uvicorn.run(app_instance, host=host, port=port)

For --reload mode, uvicorn requires a string module path, not an instance. We generate a temporary Python file that creates the app with the correct router and pass that path to uvicorn.

5. pyrpc dev: the watcher

One of the most common development patterns is editing a Python backend file and wanting the TypeScript types to update automatically. Thepyrpc dev app.main command combines a file watcher, a dev server, and automatic type regeneration into a single terminal session.

Behind the scenes it uses watchfiles (a Rust-backed file watcher with zero Python dependencies) to monitor project directories for .pychanges. When a change is detected:

  1. Re-import the user’s module (using importlib.reload with registry clearing).
  2. Re-extract the schema from get_registry_schema().
  3. Regenerate the TypeScript types in node_modules/@pyrpc/types/src/index.ts.

The TypeScript file is written to node_modules/@pyrpc/types/src/index.ts, which means any bundler (Vite, webpack, Next.js with Turbopack) that watchesnode_modules will automatically hot-reload the new types. No browser refresh needed.

The --types-only flag skips the server startup entirely, which is useful when you are running the server separately (in a debugger, inside a Docker container, or on a remote machine) and just want the type regeneration.

We chose watchfiles over watchdog because it uses the OS-native file notification APIs (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows) through a Rust binding, which means no polling overhead and near-instant change detection even on large projects.

6. pyrpc shell: debugging like docker exec

pyrpc shell http://localhost:8000 opens an interactive REPL connected to a running pyrpc server. It fetches the schema from GET /rpc and lets you call any procedure as if it were a local function:

$ pyrpc shell http://localhost:8000
Connected to http://localhost:8000/rpc
Available procedures: 5

[5 procs] >>> add(1, 2)
3
[5 procs] >>> greet(name="World")
"Hello World"
[5 procs] >>> inspect()
┌──────────────────────────────────────────┐
│ Method          Params        Returns     │
├──────────────────────────────────────────┤
│ add             a: int, b: int  int      │
│ greet           name: str       str      │
└──────────────────────────────────────────┘
[5 procs] >>> exit

The design was inspired by docker exec — the server keeps running while you interact with it. But the closer analogy is redis-clior psql: you connect to a running server and issue commands through an application-aware REPL. The server does not need to know about the shell, and the shell does not need to be on the same machine.

For the argument parsing we use Python’s ast.literal_eval to safely evaluate argument literals. This means you can pass strings ("hello"), numbers (42), booleans (True/False), lists ([1, 2, 3]), and dicts ({"key": "value"}) with full safety — no code execution risk. Both positional and keyword argument syntax are supported.

We consider this a debugging tool, not a production monitoring interface. The shell should not be exposed on production servers or used in automated pipelines. It is meant for the same use case as docker exec: a developer debugging a running service during development.

Why this approach vs alternatives

We evaluated how tRPC, better-auth, Prisma, and Docker handle their CLI experiences:

  • tRPC has no CLI at all — types flow through import type at compile time. This works because tRPC is single-language (TypeScript). pyrpc bridges Python and TypeScript, which is a different problem.
  • better-auth has a CLI for codegen and configuration, but no interactive mode. It generates types from server definitions, similar to our codegen command.
  • Prisma has prisma studio (GUI) and prisma generate, but no interactive query REPL. The pull / generate split inspired our original two-step workflow before we merged it.
  • Docker exec is a system-level shell inside a container, not an application REPL. But the UX pattern — server runs in background, exec into it for debugging — directly inspired pyrpc shell.

The closest analogue is redis-cli or psql: you have a running server, you connect a CLI client, and you issue commands through an application-aware REPL. This pattern is well-established and developers already know how to use it. The difference is that pyrpc commands are your own RPC procedures, not a fixed set of database operations.

We are considering making pyrpc (no subcommand) default to the shell when a PYRPC_URL environment variable is set or a server is detected atlocalhost:8000. This would make the workflow even tighter: install pyrpc, start a server, and pyrpc drops you into a shell automatically — no subcommand, no flags, no learning curve.

What is next

The immediate roadmap:

  1. pyrpc init — framework-aware scaffolding that detects FastAPI, Flask, or raw pyRPC and generates project structure.
  2. pyrpc shell as default — running pyrpc with no args opens the shell if a server is detected.
  3. Tab completion in the shell for procedure names and parameter hints.
  4. Streaming responses in the shell for async generator endpoints.

All 12 CLI tests pass. The client tests pass. The repo is atgithub.com/pyrpc/pyrpc.