Skip to content

INW003 public-api-only

What it does

Reports an import, from outside a context, of one of the context's modules that isn't public. A module is public when it lies at or under one of the context's public prefixes: public = ["shop.billing.api"] opens shop.billing.api and everything under it that this context owns, and nothing else in shop.billing.

  • The caller can be in another context or in no context at all, such as an entry point or a script, with or without a layer: once contexts are declared, every file's imports are checked. Only code inside the context itself may import its internals.
  • While INW002 is on, an import from a context that doesn't declare the target in depends-on is INW002's, and gets no INW003 as well: the dependency itself has to go, whichever module it goes through. With INW002 turned off, INW003 reports it.
  • An imported name counts as the module it lives in: from shop.billing.invoices import issue_invoice imports shop.billing.invoices.
  • The fix names the public module that already exposes the imported name, by defining it or importing it, when one does.

Every import counts, as for INW002: relative ones, imports inside functions or behind TYPE_CHECKING, and dynamic imports whose target Inwards can read. The rule applies whether or not a layer owns the file.

Why is this bad

A context's public modules are the promise it makes to the rest of the code; everything else can change without notice. A caller that imports an internal module ties itself to how the context is built today, so a rename or a split inside billing breaks orders. An agent looks for the name it needs and imports it from wherever it is defined, which is usually an internal module. INW003 points it at the public one.

Example

Orders may use billing, and billing's public module is shop.billing.api, which re-exports the invoice function:

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

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

[[tool.inwards.contexts]]
name = "billing"
modules = ["shop.billing"]
public = ["shop.billing.api"]
shop/billing/invoices.py
def issue_invoice(order_id: int) -> None:
    pass
shop/billing/api.py
from shop.billing.invoices import issue_invoice

__all__ = ["issue_invoice"]
shop/orders/checkout.py
from shop.billing.invoices import issue_invoice
inwards check
shop/orders/checkout.py:1:35: INW003 Context "orders" imports "shop.billing.invoices.issue_invoice" from context "billing", but "shop.billing.invoices" isn't one of its public modules.
  fix: Import "issue_invoice" from "shop.billing.api", the public module of "billing" that exposes it.
    1. Replace `from shop.billing.invoices import issue_invoice` with `from shop.billing.api import issue_invoice`.
    2. Do not reach the module another way (through a function-level import, TYPE_CHECKING or importlib); Inwards checks those too.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW003/

Fixed: checkout imports the function from billing's public module.

shop/orders/checkout.py
from shop.billing.api import issue_invoice
inwards check

How to fix

  1. If the fix names a public module, import the name from there instead.
  2. Otherwise, re-export what you need from one of the context's public modules (from shop.billing.invoices import issue_invoice in shop/billing/api.py), then import it from there. That is a code change, not a config change.
  3. If the context declares no public modules, ask the user which of its modules other code may import. The hooks don't let an agent edit [tool.inwards].

Fix safety

No automatic fix (autofix: false). A public module that exposes the same name usually exposes the same object, but it doesn't have to, and re-exporting a name from a public module widens the context's promise. Both are decisions to check.

Configuration

Severity error. The rule reads public in [[tool.inwards.contexts]] and does nothing without contexts; the configuration reference has every key. A context without public keeps all of its modules private. [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.billing.invoices import issue_invoice  # inwards: ignore[INW003] reason="api.py lands in #91"

Known limitations

  • public entries are prefixes (ADR-030), so public = ["shop.billing"] opens every module under the package. To make a package facade public without its _private submodules, write the entry with a leading =: public = ["=shop.billing", "shop.billing.models"] opens shop.billing itself and shop.billing.models, and from shop.billing._invoices import issue_invoice stays an INW003. Inwards doesn't check that the facade re-exports anything, or treat _-prefixed names specially.
  • To name a public module, the fix reads the names the module binds at its top level, from its syntax tree: def, class, an assignment with a value, an import under its as name. A name listed only in __all__, or bound inside an if or try block, isn't found; the import is still reported, with the general advice. The language server reads a public module again when it is saved.
  • A dynamic import whose target Inwards can't read isn't reported here. In a layer, INW011 reports it.

References

  • INW002: which contexts may import which; INW003 decides which of their modules.
  • ADR-030: how contexts and their public modules are declared.