pyRPC
← Back to Blog

Dev console vs shell: two tools, one job, and the line between them

·8 min read

One of the most debated design decisions in pyRPC's CLI was how the interactive development console and the shell REPL relate to each other. Both let you call RPC procedures interactively. Both provide introspection. Both are used during development. So why are they separate tools?

The short answer: they serve different connection models. The dev console reads from the parent process (in-process Python object access), while the shell connects over HTTP to a remote server. They share the same user interface (a Rich-based REPL) but have completely different plumbing underneath.

The dev console: in-process introspection

When you run pyrpc dev app.main, pyRPC starts a uvicorn server in a subprocess and opens a _DevConsole in the parent process. The console gets the procedure registry directly from the parent'sdefault_router via get_registry_schema(default_router).

# Parent process (pyrpc dev):
# 1. Imports the user's module (registers @rpc procedures on default_router)
# 2. Starts uvicorn in a subprocess
# 3. Opens _DevConsole in the parent process
# 4. Console reads default_router directly (in-process, no network)
> add(1, 2)
3
> greet(name="World")
"Hello World"

The key design constraint: the dev console reads from the parent process's registry, not from the running uvicorn server over HTTP. This means:

  • No HTTP call overhead — calling a procedure through the console is a direct Python function call, not a round-trip through the network stack.
  • Works even if the server fails to start — the registry was populated during the import phase, which happens before the server starts. If uvicorn crashes, the console still works.
  • No serialization-deserialization round-trip — arguments are passed as Python objects, not JSON strings that get parsed again.
  • No HTTP stack dependency — the console does not need to import httpx or any HTTP client.

This also means the dev console does not need a callcommand. There is no HTTP endpoint to call — it calls the Python function directly. The console is purely a debugging surface for the parent process.

The shell: HTTP-based remote debugging

pyrpc shell http://localhost:8000 is the opposite: it connects to a running pyrpc server over HTTP, fetches the schema from GET /rpc, and lets you call procedures by POSTing to /rpc.

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

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

The shell is designed for docker exec-style debugging: the server keeps running while you interact with it. You can connect from a different machine, from CI, or from a container. The server does not know about the shell, and the shell does not need to be on the same machine.

Why the dev console does not call HTTP

There was a real temptation to simplify by having the dev console call its own server over HTTP. The flow would be: start uvicorn, wait for it to be ready, then issue HTTP calls to localhost:PORT/rpc. The shell and the console would share one code path.

We decided against this for three reasons:

  1. Boot order race condition. The console starts before the server is ready. If the console waits for the server, it adds latency and complexity. Worse, if the server fails to start, the console is useless — you have lost your debugging surface because the server crashed.
  2. Serialization overhead for every call. Every console command would serialize Python objects to JSON, POST them to localhost, have the server deserialize them, call the function, serialize the result, and return JSON. For a local debugging tool, this is wasteful. Direct Python function calls are instant.
  3. The port problem. The console would need to know which port the uvicorn server is listening on. In --types-only mode, there is no server at all. The console would need special-case handling for the no-server scenario, which defeats the purpose of a unified code path.

Instead, the dev console reads the registry from the parent process. It is simpler, faster, and works even when the server is down. The shell handles the remote case. Two tools, one job, different connection models.

The shared UI layer

Despite different plumbing, both tools share the same user interface. The REPL is built on Python's code.InteractiveConsole with Rich rendering for tables and output. Both support:

  • Positional and keyword argument syntax (add(1, 2) and greet(name="World")).
  • ast.literal_eval-based argument parsing for safe evaluation of strings, numbers, booleans, lists, and dicts.
  • inspect() command that prints a Rich table of all available procedures.
  • Tab completion via readline.

The separation is at the data-fetching layer, not the UI layer. The_DevConsole._schemas() method is the single point of divergence:

class _DevConsole:
    def _schemas(self) -> dict:
        # Dev console: read from parent process registry
        return get_registry_schema(default_router)

class _ShellConsole:
    def _schemas(self) -> dict:
        # Shell: fetch from HTTP
        resp = httpx.get(f"{self.base_url}/rpc")
        return resp.json()

Both classes return the same dict format. The rest of the REPL logic (argument parsing, procedure dispatch, result formatting, error handling) is shared. The hasattr checks in _cmd_procedures and_cmd_inspect handle both object-format (from get_registry_schema) and dict-format (from HTTP JSON) transparently.

The config story: pyproject.toml, not pyrpc.json

Another recurring design debate was where to store pyRPC configuration. Options included:

  • A dedicated pyrpc.json — clean separation, follows the ESLint/Prettier pattern.
  • A pyproject.toml [tool.pyrpc] section — follows the Black/ruff/FastAPI pattern, no extra files.
  • Environment variables only — minimal, but poor discoverability.

We chose [tool.pyrpc] in pyproject.toml. The reasoning:

  • No new files. The project already has a pyproject.toml. Adding a section is less invasive than creating a new file.
  • Tool convention. Python developers expect tool config in pyproject.toml. Black uses [tool.black], ruff uses [tool.ruff], FastAPI uses [tool.fastapi]. No one reads READMEs to find config locations anymore — they look in pyproject.toml.
  • Amending, not replacing. We read the existing file, merge our section, write it back. We do not overwrite or reformat the user's existing config.

The config schema is minimal:

[tool.pyrpc]
framework = "fastapi"       # "fastapi" | "flask" | "asgi"
entry = "app/main.py:app"   # Python module path with optional app instance

framework tells pyrpc which adapter to use (and which to install).entry tells it where to find the user's application for import and introspection. On first run, pyrpc dev prompts for both values, writes them, and proceeds. On subsequent runs, it reads from config and skips the prompt. The --reconfigure flag re-prompts and overwrites.

Framework detection: ask, do not guess

On first run, we could attempt to auto-detect the framework by scanning the project's dependencies or looking for from fastapi importin Python files. We chose not to.

Auto-detection is fragile. What if the project has both FastAPI and Flask installed? What if the framework is imported in a non-standard way? What if the framework is behind a compatibility shim? These edge cases lead to wrong guesses, which lead to cryptic errors, which lead to frustrated developers who delete the config file and give up.

Instead, we ask. The first-run prompt presents three choices:

$ pyrpc dev
We need to know a few things about your project to get started.

? Framework: (fastapi / flask / asgi)
? Entry point (e.g. app/main.py:app):
> app.main:app

Writing [tool.pyrpc] to pyproject.toml...
Installing pyrpc-core[fastapi]...
Starting dev server...

The prompt is interactive (using Rich's Prompt.ask) with defaults and validation. The framework choice determines which adapter to install. The entry point determines the importlib.import_module path for schema extraction.

The --reconfigure flag forces re-prompting even if config exists. This is useful when switching from FastAPI to Flask, or when the entry point changes during refactoring. The old adapter is left in place with a hint to uninstall it manually — we do not auto-uninstall because pip might remove a package the user needs elsewhere.

What this enabled

The combination of in-process dev console, HTTP-based shell, and pyproject.toml config gives us a development workflow that is:

  • Zero-config on the happy path — answer three prompts, done.
  • Explicit and predictable — no auto-detection magic, no guessing.
  • Framework-agnostic — the same CLI commands work for FastAPI, Flask, or raw ASGI.
  • Config-as-code — pyproject.toml is checked into version control, so the whole team gets the same setup.

The line between the dev console and the shell is clear. The dev console is a local debugging surface for the parent process. The shell is a remote debugging tool for running servers. They share a REPL UI but have different plumbing. Developers who only use pyrpc dev may never need the shell. Developers debugging production-like environments may only use the shell. Neither tool compromises the other.

All 45 tests pass. The repo is at github.com/pyrpc/pyrpc.