We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Orientation and the Gate step 13 of 13
Placement 6/6: import-cycle detection
Placement diagnostic, 6 of 6. About seven minutes. If this one is opaque, T12 (Seams, Modules, Packaging and Tooling) is where you start.
An import cycle is not a style problem. It is a load-order problem: at the
moment models imports core, core is a half-initialised module object
whose class definitions may not exist yet, so you get
ImportError: cannot import name 'X' from partially initialized module —
and, worse, you get it only in the order the first importer happens to run,
so it reproduces in production and not in your test suite.
A module’s imports are a directed graph, so “does this package have a cycle” is a graph question with a machine answer. That is exactly what tools like import-linter enforce in CI, and building the check yourself is the fastest way to understand what they are asserting.
def solve(imports: dict[str, list[str]]) -> frozenset[str]:
imports maps a module name to the modules it imports directly. Return the
frozenset of modules that lie on at least one cycle. Specifically:
- a module is on a cycle iff it can reach itself by following one or more edges;
-
a self-import (
{"a": ["a"]}) counts; - names that appear only as import targets and never as keys are third-party or stdlib modules — they have no outgoing edges recorded, so they can never be on a cycle;
-
a module that merely imports into a cycle without being reachable from it
is not on the cycle. In
{"a": ["b"], "b": ["c"], "c": ["a"], "d": ["a"]}the answer is{"a", "b", "c"}, not{"a", "b", "c", "d"}.
Return a frozenset, and mean it
Returning a list here fails, and the failure is deliberate. This answer has
no meaningful order — any order you pick is an implementation detail of your
traversal, and the first person to switch a set for a dict in your code
will change it. A frozenset says “unordered, and you may not mutate my
answer” in the signature, so a caller cannot rely on either. Under the
strict gate, frozenset on its own is also not enough: with
--disallow-any-generics you must write frozenset[str].
Production consequence
The cycles that hurt are rarely two modules long. They are eight modules
long, they pass through a utils that everybody imports, and they appear
when someone adds one convenience import to a __init__.py. That is why the
contract worth enforcing in CI is not “no cycles between these two files” but
a layering one: “nothing in domain may import anything in adapters“,
checked as a reachability query over exactly this graph. If you can write
this function, you can write that contract.
A cycle you cannot remove has three standard exits, in descending order of
honesty: extract the shared piece into a third module that both import; move
one import under if TYPE_CHECKING: when it exists only for annotations; or
defer it into the function body that needs it. Note that PEP 810’s lazy
imports, landing in 3.15, do not fix cycles in the general case — the
cycle is still there, you have only moved when it detonates.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.