Debugging a Circular Import Chain Across 6 Python Packages
Traced and resolved a deep circular dependency chain spanning oauth_service, integration_service, mcp_tools_store, and 3 other packages -- restructuring imports across 14 files without breaking any existing functionality.
The error appeared on startup: ImportError: cannot import name 'OAuthCredentials' from partially initialized module 'services.oauth_service'. Circular import. In a large Python codebase with years of accumulated dependencies, these can be trivial (move one import inside a function) or deeply structural. This one was structural.
Mapping the dependency graph
The agent’s first step was to not touch any code and instead map the complete import graph. What Python’s error message showed was the immediate cycle: oauth_service → mcp_tools_store → mcp/__init__ → oauth_service. But that was just the surface. Following every import in the chain revealed a second, independent cycle interleaved with the first.
The full graph: oauth_service imports integration_service (to check if an integration is connected before attempting OAuth). integration_service imports mcp_tools_store (to load the user’s available tools). mcp_tools_store imports mcp/__init__ (the package’s own init file that re-exports convenience types). mcp/__init__ imports subagent_service (to expose subagent tool types). subagent_service imports oauth_service (to authenticate subagent MCP connections). Cycle one.
Simultaneously: mcp_tools_store also imports mcp/tool_executor directly. mcp/tool_executor imports integration_service to resolve integration configs at tool execution time. integration_service imports mcp_tools_store. Cycle two. Both cycles needed to be broken, and they shared modules, so fixing one naively could strengthen the other.
Why the coupling existed
The agent traced the business logic reason for each dependency before deciding how to break any of them. oauth_service needed integration_service because checking if a user’s integration was connected was genuinely needed before attempting token refresh — you don’t want to refresh a token for an integration that’s been disconnected. integration_service needed oauth_service because loading an integration’s tools required a valid OAuth token. mcp_tools_store needed both because it assembled the complete tool manifest for a user session, which required knowing which integrations were active and that their tokens were fresh. The coupling wasn’t accidental — it reflected real logical dependencies that couldn’t be deleted. They could only be restructured.
The architectural fix
The lazy approach would have been to move the import statements inside the functions that needed them — import integration_service at the call site rather than at module level. This works but it hides dependencies, makes the code harder to reason about, and doesn’t address the underlying design problem.
Instead, the agent created user_integration_status.py — a new lightweight module containing only the shared types and status-checking logic that oauth_service and integration_service both needed. Neither module imported the other anymore; both imported the shared module. This broke the primary cycle without requiring either module to know about the other’s internals.
The second fix was emptying four __init__.py barrel files that had been creating transitive import chains. The mcp/__init__.py that imported from subagent_service was the linchpin of cycle one — it existed as a convenience re-export, not because anything structurally required it. Removing those re-exports and updating the fourteen consumer files across the codebase to import directly from the source files (from services.mcp.tool_executor import X instead of from services.mcp import X) eliminated the transitive dependency that completed the cycle.
The application booted cleanly. All forty-seven existing tests passed. Two interleaved circular import cycles across six packages, resolved with 103 lines added, 73 removed, and zero functional regressions.