Circular dependencies between Python packages are one of those problems that sounds theoretical until pip hands you a ResolutionImpossibleerror and you realize you cannot deploy.
This post is a practical guide to breaking circular dependencies in Python packaging, using pyRPC's real-world restructuring as a case study. We will cover the four strategies we evaluated, the one we chose, and the step-by-step process for extracting a package from an existing monolith.
The problem
You have two packages that each need the other. In our case:
pyrpc-codegen (CLI + TypeScript codegen) ΓööΓöÇΓöÇ depends on: pyrpc-core (for module introspection) Want: pip install pyrpc-core to give everything ΓööΓöÇΓöÇ pyrpc-core now needs to depend on pyrpc-codegen ΓööΓöÇΓöÇ CIRCULAR DEPENDENCY detected by pip ΓööΓöÇΓöÇ ResolutionImpossible
Pip (and uv, poetry, and every other Python package manager) builds a directed acyclic graph of dependencies. If package A requires B and B requires A, the resolver cannot determine which to install first. It fails with ResolutionImpossible.
Strategy 1: Merge the packages
Put everything in one package. No cycle because there is only one node in the graph.
When it works: When the two packages are conceptually inseparable and have compatible dependency requirements. If both packages are always installed together and never used independently, merging is the simplest fix.
When it fails: When the packages have different dependency profiles. pyrpc-codegen needs typer, rich, uvicorn,watchfiles, and httpx. pyrpc-core should not require any of those in production. Merging would force every production deployment to install developer tooling. That is a violation of the principle of minimal production dependencies.
Verdict: Rejected. Merging solves the packaging problem but creates a dependency hygiene problem. Production servers should not install typer.
Strategy 2: Remove the backward dependency
Make pyrpc-codegen not depend on pyrpc-core. Use lazy imports inside pyrpc-codegen instead of declaring the dependency inpyproject.toml.
# pyrpc-codegen: remove pyrpc-core from [project.dependencies]
# Use lazy import inside commands that need it
def _cmd_pull(module_path):
from pyrpc_core import get_registry_schema # lazy import
...When it works: When the backward dependency is narrow (one function call) and the dependent package is meaningful without it. For example, a CLI tool that can generate types from a URL without ever touching the runtime.
When it fails: When the backward dependency is broad (many imports across many modules) or when the dependent package's identity is unclear. Is pyrpc-codegen a CLI package or a library package? If half its commands crash without pyrpc-core, the user experience is terrible — the package installs successfully, but most commands fail at runtime with ModuleNotFoundError.
Verdict: Rejected. pyrpc-codegen was both a CLI and a library. Making it partially functional depending on what else was installed would be confusing. A package should either work or not work, not work-sometimes.
Strategy 3: Invert the dependency direction
Instead of pyrpc-codegen depending on pyrpc-core, make pyrpc-core depend on pyrpc-codegen. Then extract the overlapping code (module introspection) into a shared utility package that neither depends on.
pyrpc-core → pyrpc-codegen
Γåÿ
pyrpc-utils (shared introspection code)
Γåù
pyrpc-codegenWhen it works: When the overlapping code is a clean, standalone concern that can be extracted into its own package. Utility functions, type definitions, and shared constants are good candidates.
When it fails: When the overlapping code is deeply entangled with the runtime. Introspection in pyRPC is not a utility function — it calls get_registry_schema(default_router), which imports Router, which imports the procedure registry, which is the core of pyrpc-core. Extracting this would mean copying half of pyrpc-core into pyrpc-utils, which defeats the purpose.
Verdict: Rejected. The introspection code is too entangled with pyrpc-core to extract cleanly. A pyrpc-utils package would be pyrpc-core with a different name.
Strategy 4: Introduce an intermediary package (the winner)
Create a third package that owns the overlapping concern. In our case, the overlapping concern was the CLI itself. The CLI is the thing that needs both pyrpc-core (for introspection) and pyrpc-codegen (for TypeScript generation). Extract it into its own package.
Before: pyrpc-codegen → pyrpc-core (cycle when pyrpc-core ← pyrpc-codegen) After: pyrpc-core → pyrpc-cli → pyrpc-codegen (no cycles, each has one responsibility)
When it works: When the overlapping concern is a distinct architectural layer. In pyRPC's case, the CLI is a distinct layer — it is developer tooling that orchestrates the runtime and the codegen. It is not part of the runtime (pyrpc-core) and not part of the code generation library (pyrpc-codegen). It is its own thing.
When it fails: When there is no clean separation between the overlapping concern and the existing packages. If you extract a package that is just a thin facade over two existing packages, you have added complexity without solving the underlying entanglement.
Verdict: Chosen. The CLI is a clear architectural boundary. It has its own dependencies (typer, rich, uvicorn, watchfiles, httpx), its own entry point ([project.scripts]), and its own lifecycle (only used during development, never in production).
Step-by-step: extracting a package
Here is the exact process we followed to extract pyrpc-cli from pyrpc-codegen. The same steps apply to any package extraction:
Step 1: Create the new package structure
packages/pyrpc-cli/
pyproject.toml
src/
pyrpc_cli/
__init__.py # empty or with version
main.py # all CLI commands move hereStep 2: Write pyproject.toml with the right dependencies
[project]
name = "pyrpc-cli"
dependencies = [
"pyrpc-codegen>=1.0.0", # hard dep for codegen
"typer>=0.12.0",
"rich>=13.0.0",
"uvicorn>=0.29.0",
"httpx>=0.27.0",
"watchfiles>=0.21.0",
]
# Note: pyrpc-core is NOT a declared dependency
# It is lazy-imported at runtime
[project.scripts]
pyrpc = "pyrpc_cli.main:app"Step 3: Move the code
Copy the existing CLI code from the old package to the new one. For pyRPC, this meant moving the entire main.py (581 lines) and its [project.scripts] entry point.
Step 4: Strip the old package
Remove the CLI code from the old package. Remove its CLI-related dependencies. Remove its [project.scripts] entry point. The old package is now a pure library.
# pyrpc-codegen/pyproject.toml (after stripping)
[project]
name = "pyrpc-codegen"
dependencies = [
"jinja2>=3.0.0",
"jsonschema-ts>=0.1.0",
]
# No [project.scripts] section
# No pyrpc-core dependency
# No typer, rich, uvicorn, httpx, watchfilesStep 5: Update the production package
# pyrpc-core/pyproject.toml (updated)
[project]
name = "pyrpc-core"
dependencies = [
"pyrpc-cli>=1.0.0", # new: brings CLI on pip install
...existing deps...
]Step 6: Update the workspace
# Root pyproject.toml
[tool.uv.sources]
pyrpc-cli = { workspace = true }
pyrpc-codegen = { workspace = true }
pyrpc-core = { workspace = true }
[tool.uv.workspace]
members = [
"packages/pyrpc-core",
"packages/pyrpc-codegen",
"packages/pyrpc-cli", # new
]Step 7: Fix imports
Every import that referred to the old package's CLI module needs to point to the new one. For pyRPC, this was 13 import paths across tests and internal code:
# Before from pyrpc_codegen.main import _load_schema, _DevConsole # After from pyrpc_cli.main import _load_schema, _DevConsole
Step 8: Add lazy imports
The new package needs pyrpc-core for some commands. Instead of declaring it as a hard dependency, use lazy imports inside the command handlers:
# pyrpc_cli/main.py
@app.command()
def pull(module_path: str):
from pyrpc_core import get_registry_schema # lazy
...
@app.command()
def serve(module_path: str, host: str, port: int):
from pyrpc_core import default_router # lazy
from pyrpc_core.transport.asgi import PyRPCAsgiApp # lazy
...Step 9: Run the tests
Run the full test suite. Fix any remaining import paths or mock targets. In pyRPC's case, one test file (test_codegen.py) imported_load_schema from the old path. One line change, all 45 tests pass.
The decision matrix
| Strategy | Effort | Production deps clean? | Clear responsibilities? |
|---|---|---|---|
| Merge | Low | No | No |
| Remove backward dep | Medium | Yes | No (broken CLI) |
| Invert + shared utils | High | Yes | Partial |
| Extract intermediary | Medium | Yes | Yes |
When to reach for each strategy
- Merge — when the packages are never used independently and have similar dependency profiles. Example: a library and its type stubs.
- Remove backward dep — when the backward dependency is a single function call and the package is meaningful without it. Example: a CLI tool that has a "use local" vs "use remote" mode.
- Invert + shared utils — when the overlapping code is a clean, extractable concern. Example: shared validation logic between a server and a client package.
- Extract intermediary — when the overlapping concern is a distinct architectural layer. Example: a CLI that orchestrates a runtime and a code generator.
The key insight: circular dependencies in packaging are almost always a sign that a third concept has been hiding inside an existing package. Find that concept, extract it, and the cycle disappears.
All 45 tests pass. The repo is at github.com/pyrpc/pyrpc.

pyRPC