pyRPC
← Back to Blog

How to break a circular dependency in Python packaging

·11 min read

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-codegen

When 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 here

Step 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, watchfiles

Step 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

StrategyEffortProduction deps clean?Clear responsibilities?
MergeLowNoNo
Remove backward depMediumYesNo (broken CLI)
Invert + shared utilsHighYesPartial
Extract intermediaryMediumYesYes

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.