pyRPC
← Back to Blog

The circular dependency problem and how pyrpc-cli solved it

·10 min read

Package architecture is one of those problems you do not think about until it blocks your deploy. For the first few months of pyRPC we had a simple two-package layout: pyrpc-core for the runtime (Router, RPC handler, ASGI adapter, decorators) and pyrpc-codegen for the CLI and TypeScript generation. The CLI commands needed pyrpc-core to import modules and introspect schemas, so pyrpc-codegen declaredpyrpc-core as a dependency. It worked.

Then we wanted pip install pyrpc-core to give users everything they needed — the runtime, the CLI, and the codegen — in one command. That meant pyrpc-core needed to depend onpyrpc-codegen. But pyrpc-codegen already depended onpyrpc-core. That is a circular dependency. Python packaging tools (pip, uv, poetry) cannot resolve cycles. You get ResolutionImpossibleor silently broken installs.

The original layout

Here is what we started with:

pyrpc-codegen
  ├── depends on: pyrpc-core   (for module introspection)
  ├── ships: CLI (typer, rich), codegen (jinja2)
  └── installed via: pip install pyrpc-codegen

pyrpc-core
  ├── depends on: nothing pyRPC-specific
  ├── ships: Router, decorators, ASGI/Flask/FastAPI adapters
  └── installed via: pip install pyrpc-core

This worked if you knew to install both packages. But new users installpyrpc-core first (it is the main package, the runtime), then discover they also need the CLI. The two-package story was confusing: "Do I install pyrpc-core or pyrpc-codegen? Both? What is the difference?"

We wanted one install command. Every major framework works this way:pip install fastapi gives you the runtime, the CLI (uvicorn), and everything needed to run. pip install django gives you the runtime, the admin panel, the ORM, and the management CLI. One package, one install, one version to track.

The three options we evaluated

We went back and forth across three approaches before landing on the solution:

Option A: Merge everything into one package

The simplest option: put everything in pyrpc-core — runtime, CLI, codegen, all in one package. No circular dependency because there is only one package.

Problem: pyrpc-codegen has heavy transitive dependencies.typer, rich, uvicorn, watchfiles, httpx — these are CLI dependencies that should not be required if you are just using pyRPC as an ASGI middleware in production. A user running gunicorn in production does not need typer installed.

We rejected this because it violates the principle of minimal dependencies. Your production server should not install developer tooling.

Option B: Make pyrpc-core depend on pyrpc-codegen, break the cycle by removing pyrpc-core from pyrpc-codegen

If we could remove pyrpc-core from pyrpc-codegen's dependencies, the cycle would disappear. The CLI would still need pyrpc-core at runtime (for module introspection), but it could lazy-import it instead of declaring it as a hard dependency.

Problem: This makes pyrpc-codegen a partially-functional package. If you install it alone and try pyrpc pull (which needspyrpc-core), it crashes with ModuleNotFoundError. That is a bad user experience — your CLI installs successfully but half the commands fail at runtime.

It also blurs the packaging boundary: what is pyrpc-codegen? Is it a CLI package or a library package? The answer depends on which command you run, which changes based on what else you have installed.

Option C: Extract the CLI into its own package (the winner)

Create a third package pyrpc-cli that owns the CLI and depends on both pyrpc-core and pyrpc-codegen. Then pyrpc-coredepends on pyrpc-cli, which depends on pyrpc-codegen. Chain: pyrpc-core → pyrpc-cli → pyrpc-codegen (no cycles).

pyrpc-codegen becomes a pure library — no CLI, no typer, no uvicorn. Just jinja2 + jsonschema-ts, taking a schema dict and returning a TypeScript string. It no longer depends on pyrpc-coreat all.

pyrpc-cli owns all CLI commands: dev, serve, inspect,codegen, pull, shell. It depends on pyrpc-codegen(for TypeScript generation) and lazily imports pyrpc-core at runtime only for commands that need module introspection (pull,serve, inspect, dev).

pyrpc-core declares pyrpc-cli as a dependency. When a user runspip install pyrpc-core, pip resolves the chain and installs all three packages. The CLI entry point (pyrpc) is registered bypyrpc-cli, so it is available immediately.

How the extraction worked

The actual migration was mechanical but had to be done carefully:

  1. Create packages/pyrpc-cli/ with its own pyproject.toml. Dependencies: pyrpc-codegen, typer, rich, uvicorn,httpx, watchfiles.
  2. Move main.py from pyrpc-codegen to pyrpc-cli, along with the [project.scripts] entry point.
  3. Strip pyrpc-codegen: remove the CLI code, remove thepyrpc-core dependency, remove typer/rich/uvicorn/watchfiles from its dependencies. It now only has jinja2 and jsonschema-ts.
  4. Update pyrpc-core: add pyrpc-cli as a dependency.
  5. Update the workspace: add packages/pyrpc-cli to the workspace members in the root pyproject.toml.
  6. Fix imports: update 13 test files and inline imports across the CLI code to point to pyrpc_cli.main instead ofpyrpc_codegen.main.

The most delicate part was the lazy import pattern. Commands likepyrpc codegen --url do not need pyrpc-core at all — they fetch the schema over HTTP and pass it to pyrpc-codegen's template engine. Commands like pyrpc pull app.main need pyrpc-core to import the user's module and call get_registry_schema(). The lazy import keeps the fast path fast and only pays the cost when needed.

The dependency graph after

pyrpc-core (runtime)
  └── depends on: pyrpc-cli

pyrpc-cli (CLI)
  ├── depends on: pyrpc-codegen (hard dep)
  ├── depends on: pyrpc-core (lazy import at runtime)
  ├── depends on: typer, rich, uvicorn, httpx, watchfiles
  └── ships: [project.scripts] pyrpc → pyrpc_cli.main:app

pyrpc-codegen (pure library)
  ├── depends on: jinja2, jsonschema-ts
  └── NO pyrpc deps at all

pip install pyrpc-core
  → installs: pyrpc-core + pyrpc-cli + pyrpc-codegen
  → 1 command, everything works

No circular dependency. Every package has a clear responsibility. The dependency direction is always downward: core → cli → codegen.

Why this matters beyond pyrpc

Circular dependencies between packages are a smell. They indicate that two concepts that should be separate have become entangled. In pyrpc's case, the entanglement was between the thing that provides the API (core) and the thing that consumes the API to produce developer tooling (CLI).

The fix was to introduce a third package that owns the consumer role explicitly, leaving the core and the codegen library free of each other. This is the same pattern you see in well-structured monorepos: acli package that depends on core + utils, never the other way.

If you are designing a multi-package Python project and hit aResolutionImpossible error from pip, step back and ask: "What is each package's single responsibility? Are these responsibilities truly distinct, or have I accidentally merged two concerns into one package boundary?" The answer is usually to extract the overlapping concern into its own package.

What it enabled

With the clean dependency chain in place, we can now add features that were blocked before:

  • First-run setup prompts in pyrpc dev — detect framework, prompt for entry point, write [tool.pyrpc] to pyproject.toml, install the adapter. All the CLI logic lives in pyrpc-cli, no circular deps.
  • Framework adapter auto-installpip install fastapi on first run if the user chooses FastAPI.
  • --reconfigure — re-run the setup prompts and overwrite [tool.pyrpc].
  • Optional pyrpc shell removal — the shell lives in pyrpc-cli and can be removed without touching core or codegen.

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