Skip to content

INW004 import-cycles

What it does

Reports modules that import each other in a cycle, and contexts that do. cycles picks which:

  • "contexts", the default: a cycle between contexts, made of imports from one context into another. depends-on may allow both directions, and INW004 still reports it. Without contexts, it finds nothing.
  • "modules": a cycle between modules, such as order importing pricing, pricing importing discounts and discounts importing order.
  • [] turns the rule off.

Each strongly connected group of modules or contexts is reported once: the shortest cycle through its first member, with the whole path, on the import that makes the first step, how many members and links the group holds, and a hash of those links. The fix lists the import behind every step.

Imports inside functions count, and so do dynamic imports whose target Inwards can read. An import in the body of if TYPE_CHECKING: or elif TYPE_CHECKING: (typing.TYPE_CHECKING too) doesn't: TYPE_CHECKING is False at runtime, so the import never runs, and it is how Python code keeps a type hint without a runtime cycle. The else branch of such a block runs, so an import there counts, and so does one under if not TYPE_CHECKING: or a condition like TYPE_CHECKING and DEBUG. The other rules still check type-only imports: one into an outer layer is still INW001. Only the files a check parses take part: those in a layer, and, once contexts are declared, every file.

A cycle needs the whole project, so INW004 runs on whole-project checks only: inwards check without paths, inwards baseline, and the Stop gate with stop-gate = "project". The per-edit hook and the editor check one file and never report it.

Why is this bad

Modules in a cycle can't be understood, tested or moved one at a time; changing one can break all the others. Python may even fail at import time, depending on which module is imported first. An agent adds the import it needs where it needs it, and closing a cycle is one import away. Between contexts, a cycle means neither context is independent any more, whatever depends-on says.

Example

With the layers from INW001, and module cycles turned on:

pyproject.toml
[tool.inwards]
cycles = ["modules"]
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]
shop/domain/order.py
from shop.domain.pricing import price


class Order:
    pass
shop/domain/pricing.py
from shop.domain.discounts import discount


def price(order_id: int) -> int:
    return 100 - discount(order_id)
shop/domain/discounts.py
from shop.domain.order import Order


def discount(order_id: int) -> int:
    return 0
inwards check
shop/domain/discounts.py:1:31: INW004 Modules import each other in a cycle: shop.domain.discounts -> shop.domain.order -> shop.domain.pricing -> shop.domain.discounts. The group holds 3 modules and 3 links between them (link hash 2c11a00a0c2cf439).
  fix: Break the cycle: one of these modules must stop importing the next.
    1. The imports that make the cycle: `from shop.domain.order import Order` in shop.domain.discounts; `from shop.domain.pricing import price` in shop.domain.order; `from shop.domain.discounts import discount` in shop.domain.pricing.
    2. Pick the import that points the wrong way, usually from the lower-level module back up, and remove it.
    3. Move what both modules need into a third module they can both import, or define a Protocol in the lower-level module and pass the implementation in.
    4. If a module needs the other only for type annotations, move that import under `if TYPE_CHECKING:` (before Python 3.14, also quote those annotations or add `from __future__ import annotations`); Inwards doesn't count it, since it never runs. Don't do that for a name the code uses at runtime, and don't move the import into a function: Inwards counts that one.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW004/

Fixed: discounts never needed Order.

shop/domain/discounts.py
def discount(order_id: int) -> int:
    return 0
inwards check

How to fix

  1. Read the path: every step is an import, and the fix lists them.
  2. Remove the import that points the wrong way, usually from a lower-level module back to one that uses it.
  3. If both modules need the same code, move it into a third module both can import. If the lower-level one needs behaviour from the higher-level one, give it a typing.Protocol and pass the implementation in.
  4. If one module needs the other only in type annotations, import it under if TYPE_CHECKING:. Before Python 3.14 the annotations that use it must also be strings, or the module starts with from __future__ import annotations. Never do this for a name the code uses at runtime: Python raises NameError when it gets there.
  5. Between contexts, decide which one depends on which and remove the imports going the other way. If depends-on allows the direction you removed, ask the user to drop it; the hooks don't let an agent edit [tool.inwards].

Fix safety

No automatic fix (autofix: false). Which import is the wrong one is a design decision.

Configuration

Severity error. cycles in [tool.inwards] picks the kinds of cycle, ["contexts"] by default. Every inwards init --style preset writes cycles = ["modules", "contexts"], so a project set up with init gets module cycles too. [tool.inwards.rules] can turn the rule off or make it a warning (Configure rules).

INW004 can't be suppressed inline: a one-file check can't tell whether a suppressed cycle is still there, and the comment would sit on one import of many. To accept cycles a codebase already has, run inwards baseline. A new group still fails, and so does an accepted group whose links change in any way, since the message carries their number and a hash of them. That includes a group that lost a link or a member: run inwards baseline again to accept the smaller group.

Known limitations

  • Files outside every layer take part only once contexts are declared; before that, the check doesn't parse them, so a cycle through them isn't seen.
  • Importing shop.domain.order also runs shop/domain/__init__.py. That implicit step isn't an edge, so a cycle that only closes through a package's __init__ isn't reported.
  • One cycle per group: breaking it may reveal another in the same group on the next run.
  • No setting brings TYPE_CHECKING imports back into the graph. A cycle that only a type checker follows doesn't fail at import time, and the layer and context rules still see the import.
  • Inwards goes by the name in the condition. A guard spelled another way counts as runtime code (if TC: after from typing import TYPE_CHECKING as TC), and a project that sets its own TYPE_CHECKING = True would hide a real cycle.

References

  • ADR-032: why cycles are whole-project only, how the graph is built, why TYPE_CHECKING imports make no edge, and the default.
  • INW002: which contexts may import which.