Skip to content

INW010 unknown-first-party

What it does

Reports a static import, in a file in a layer, of a first-party module that doesn't exist, and a relative import that climbs above the top-level package, which Python always refuses. An import is first-party when a prefix of it is a module of the project.

Only the module part is checked: X of from X import name, and the whole name of import X or from X import *. Existence is probed on disk the way Python imports, so namespace packages, stubs (.pyi) and compiled extensions (.so, .pyd, .pyx, .pyc) count. The fix lists the three modules of the same package closest to the missing name. Packages whose __init__.py extends __path__ (pkgutil.extend_path, declare_namespace) are skipped. Under an implicit namespace package (no __init__.py from the top-level package down), a module that another uv workspace member or the project's virtualenv holds counts as existing, since Python merges the package from every directory that has it (Monorepos and uv workspaces, installed namespace packages). A module that a generated pattern covers counts as existing, since a build step writes it.

An import INW010 reports, or accepts because another portion of a namespace package holds it, gets no INW006 as well, and an outward import INW001 reports gets no INW010: INW001's fix deletes the import, while INW010's would create the module.

Why is this bad

Agents invent plausible modules: from shop.domain.pricing import DiscountPolicy, where pricing doesn't exist. Without a check it surfaces as an ImportError at test time, if a test covers the file. INW010 flags it in the same hook run, before any test is written, and points at the near miss the agent probably meant.

Example

With the layers from INW001, and prices.py and money.py in the domain:

touch shop/domain/prices.py shop/domain/money.py
shop/domain/order.py
from shop.domain.pricing import DiscountPolicy
inwards check
shop/domain/order.py:1:33: INW010 "shop.domain.pricing" is not a module of this project: "shop.domain" has no "pricing".
  fix: Import a module that exists, or create "shop.domain.pricing" first.
    1. Check the name. The closest modules in "shop.domain": `shop.domain.prices`, `shop.domain.money`.
    2. If the code isn't written yet, create "shop.domain.pricing" before importing it.
    3. Don't wrap the import in try/except ImportError; Inwards checks those too.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW010/

Fixed: the policy lives in prices.py.

shop/domain/prices.py
class DiscountPolicy:
    pass
shop/domain/order.py
from shop.domain.prices import DiscountPolicy
inwards check

How to fix

  1. Check the name against the closest modules the fix lists, and import the one that exists.
  2. If the code isn't written yet, create the module first, then import it.
  3. Don't wrap the import in try/except ImportError: the rule checks it there too, and the code would still fail at runtime.

Fix safety

No automatic fix (autofix: false). The closest name by edit distance isn't always the module the code meant, so a person or the agent has to pick.

Configuration

Severity error. [tool.inwards.rules] can turn the rule off or make it a warning (Configure rules).

Generated modules

Some modules exist only after a build step: orders_pb2.py and orders_pb2_grpc.py from protoc, _version.py from setuptools-scm or hatch-vcs. A developer's checkout has them and a fresh CI checkout doesn't. INW010 treats a module that generated in [tool.inwards] covers as existing, file or no file (ADR-029):

pyproject.toml
[tool.inwards]
generated = ["*_pb2", "*_pb2_grpc", "_version", "shop.api.gen"]
  • A pattern is a dotted module name, and each segment may use * and ?. It matches whole segments anywhere in the resolved module name, as ignore does: *_pb2 covers shop.api.orders_pb2, _version covers shop._version, shop.api.gen covers everything under shop/api/gen/. A wildcard never crosses a dot.
  • There are no bracket sets. An empty segment, a character that can't be in a module name ([ and ] included) or a pattern made only of wildcards and dots (*, *.*) is a config error (exit 2). To turn INW010 off, use ignore = ["INW010"] in [tool.inwards.rules].
  • Without the key the list is ["*_pb2", "*_pb2_grpc", "_version"]. Setting the key replaces that default, so list the default patterns too if you still want them, and generated = [] turns it off.
  • Only INW010 reads the key. The other rules still see a generated module that isn't on disk as missing: an outward import of one is still INW001, and INW006 names the nearest package that exists.

With the default, an import of a protoc module that isn't generated yet passes:

shop/domain/order.py
from shop.domain.orders_pb2 import Order
inwards check

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

from shop.domain.legacy_rates import RATES  # inwards: ignore[INW010] reason="copied in by the deploy script, see #230"

Namespace packages shared with installed distributions

An implicit namespace package (no __init__.py) can be split between the project and an installed distribution. Take a local acme/platform/billing/, with no __init__.py in acme/ or acme/platform/, and an installed acme-platform-auth that provides acme/platform/auth/. import acme.platform.auth.tokens works when the code runs, although auth isn't in the project.

INW010 doesn't skip every missing module under a namespace package: a made-up one would then pass. It needs evidence that the module lives elsewhere:

  • The project's virtualenv. The CLI and the hooks look in the site-packages of the .venv next to pyproject.toml and, in a uv workspace, at the workspace root (.venv/lib/python3.*/site-packages, .venv/Lib/site-packages on Windows). A module found there counts as existing. Nothing is run: no Python, no network.
  • namespace-packages in [tool.inwards], for runs without a virtualenv, such as CI before uv sync. A module directly inside a listed package that isn't in the project counts as existing, whatever its name.
pyproject.toml
[tool.inwards]
namespace-packages = ["acme.platform"]

Either way, the package the module would live in must be a namespace package all the way down from the top-level package, since Python merges only those. A missing module inside a subpackage the project has is still reported: acme.platform.billing.pricing is an error when acme/platform/billing/ has no pricing, with or without the key. A regular package (with __init__.py) isn't merged, so a missing module in one is reported too.

Known limitations

  • from shop.domain import pricing passes when shop/domain is a package, since pricing could be a name its __init__.py defines (ADR-025).
  • A made-up import whose name a generated pattern covers passes INW010, by default any *_pb2, *_pb2_grpc or _version module (shop.api.payments_pb2 with no payments.proto). It still fails when the code runs. To catch those, set generated = [] and run the generator before the check (ADR-029).
  • An optional import behind try/except ImportError is reported.
  • Only a .venv next to the config or at the uv workspace root is searched. A virtualenv elsewhere (VIRTUAL_ENV, Poetry's cache, conda) and editable installs through .pth files aren't; list the package in namespace-packages instead.
  • namespace-packages accepts any name directly inside a listed package, so a made-up sibling (acme.platform.authz) passes. Deeper names under a local subpackage are still checked.
  • Dynamic imports aren't checked for existence.

References