Skip to content

INW002 context-independence

What it does

Reports an import from one context into another that the importing context doesn't list in depends-on. Contexts are declared in [[tool.inwards.contexts]]; the context whose prefix matches the most of a module owns it.

  • A declared dependency is direct. orders depending on billing lets orders import billing, not the other way round, and not whatever billing depends on.
  • A context nested in another is a context of its own. Depending on billing doesn't cover a tax context declared inside shop.billing, and tax itself needs depends-on = ["billing"] to import its parent.
  • Imports within a context, and imports where either end belongs to no context (a shared kernel, a library), are left alone.
  • Which of the other context's modules is imported doesn't matter here: a declared dependency allows any of them. Keeping callers to a context's public modules is INW003's job.

Every import counts, as for INW001: relative ones, from shop import shipping, imports inside functions or behind TYPE_CHECKING, and dynamic imports whose target Inwards can read. The rule applies to every file a context owns, whether or not a layer owns it too. A file outside every layer still gets its INW006 warning, which then says that only context rules check its imports. Contexts and layers add up, so one import can be both INW001 and INW002.

Why is this bad

A slice that reaches into another slice's code couples the two: a change in shipping breaks orders, and neither can be moved, split out or owned by another team on its own. An agent sees only the name it needs, finds it in the other slice and imports it, which is the fastest way to finish the task and the one that erodes the boundaries you drew. depends-on makes each allowed dependency a decision someone wrote down.

Example

Three slices in one layer. Orders may use billing; nothing may use orders, and shipping is on its own:

rm -r shop
pyproject.toml
[tool.inwards]
layers = [
  { name = "slices", modules = ["shop.orders", "shop.billing", "shop.shipping"] },
]

[[tool.inwards.contexts]]
name = "orders"
modules = ["shop.orders"]
depends-on = ["billing"]

[[tool.inwards.contexts]]
name = "billing"
modules = ["shop.billing"]
public = ["shop.billing.invoices"]

[[tool.inwards.contexts]]
name = "shipping"
modules = ["shop.shipping"]
shop/billing/invoices.py
def issue_invoice(order_id: int) -> None:
    pass
shop/shipping/labels.py
def print_label(order_id: int) -> None:
    pass
shop/orders/checkout.py
from shop.billing.invoices import issue_invoice
from shop.shipping.labels import print_label
inwards check
shop/orders/checkout.py:2:34: INW002 Context "orders" imports "shop.shipping.labels.print_label" from context "shipping", which it doesn't declare in depends-on.
  fix: Keep "orders" independent of "shipping", or ask the user whether it should depend on it.
    1. Delete `from shop.shipping.labels import print_label`. Do not move the import into a function or behind TYPE_CHECKING; Inwards checks those too.
    2. Define what "orders" needs as an interface (a Protocol) inside "orders", and let the composition root pass in the "shipping" implementation. "orders" may import from "billing".
    3. If "orders" should use "shipping" directly, ask the user to add "shipping" to its depends-on in [tool.inwards]. Don't edit [tool.inwards] yourself.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW002/

The import of billing passes: orders declares it. Fixed: checkout asks for a label printer instead of importing shipping's.

shop/orders/checkout.py
from typing import Protocol

from shop.billing.invoices import issue_invoice


class LabelPrinter(Protocol):
    def print_label(self, order_id: int) -> None: ...


def check_out(order_id: int, labels: LabelPrinter) -> None:
    issue_invoice(order_id)
    labels.print_label(order_id)
inwards check

How to fix

  1. Delete the import. Moving it into a function, behind TYPE_CHECKING or into importlib doesn't help: the rule checks those too.
  2. Declare what the importing context needs as a typing.Protocol of its own, and receive the implementation from the composition root. INW002 checks the root too: put it in a module no context owns (an entry point such as shop.main), or in a context whose depends-on lists both. The layer order still applies to it, and INW003 keeps it to each context's public modules.
  3. If the dependency is intended, ask the user to add the other context to depends-on. The hooks don't let an agent edit [tool.inwards].

Fix safety

No automatic fix (autofix: false). Whether a dependency between two contexts is a mistake or a decision not yet written down is the user's call.

Configuration

Severity error. The rule reads [[tool.inwards.contexts]] and does nothing without it; each context's depends-on lists the contexts it may import, and the configuration reference has every key. [tool.inwards.rules] can turn the rule off or make it a warning (Configure rules).

One import can be suppressed on its line, with a reason:

from shop.shipping.labels import print_label  # inwards: ignore[INW002] reason="until shipping publishes events, #88"

Known limitations

  • A context is a list of literal prefixes, so twenty slices mean twenty tables. Layers take selectors since #191; contexts don't yet.
  • A name re-exported through a module no context owns passes: from shop.shared import print_label is an import of shop.shared, whatever shop.shared imports in turn.
  • A dynamic import whose target Inwards can't read isn't reported here. In a layer, INW011 reports it.

References

  • INW001: the same idea for layers, which contexts add to.
  • ADR-030: how contexts are declared, and why ownership is the longest prefix.