INW011 dynamic-import¶
What it does¶
Reports a dynamic import, in a file in a layer, that reaches an outer layer: importlib.import_module, importlib.util.find_spec, __import__ (also as builtins.__import__ and importlib.__import__), runpy.run_module, pkgutil.resolve_name, the file loaders importlib.util.spec_from_file_location and importlib.machinery.SourceFileLoader, and import statements inside literal exec, eval or compile source. A file loader's path counts when it is a relative .py path, read from the project root.
Loaders are followed through import aliases (from importlib import import_module as im), assignments (plain, chained, tuple, starred and the walrus), class and instance attributes (load = importlib.import_module in a class body, self.load = ...), functools.partial(loader, ...), names bound inside a literal exec source, getattr(m, "name"), m.__dict__["name"], vars(m)["name"], sys.modules["importlib"], globals()["name"] and a function's __globals__, and a builtin function's __self__ (print.__self__.exec).
A constant target is folded to its value: literals joined with +, % with %s, * by an integer, str.join, str.format, slicing, f-strings whose fields are constants (with !s and a string format spec such as :>4), and a module-level name assigned a constant exactly once (TARGET = "shop.infrastructure.db").
In every layer but the outermost, a call whose target Inwards can't read is reported as unverifiable: a variable, an f-string field, a \N{...} escape, an argument behind *args or **kwargs, a relative import_module whose package isn't known, or exec or eval of a non-literal source. The outermost layer may reach every layer, so there is nothing to verify there, and that is where plugin loaders and composition roots belong. Bytes passed to exec or compile under a declared encoding Inwards can't read are reported as unchecked, in any layer.
Why is this bad¶
Once INW001 catches imports in functions and behind TYPE_CHECKING, the next cheapest dodge is a call: importlib.import_module("shop.infrastructure.db") is the same dependency with no import statement for a checker to see. When that is blocked too, putting the module name in a variable is the next step. INW011 reports both, and the fix names the loader as the problem, because rebuilding the name at runtime or switching to another loader only hides the dependency (ADR-015, ADR-026).
Example¶
With the layers from INW001, the domain loads the infrastructure at runtime:
import importlib
def repository():
return importlib.import_module("shop.infrastructure.db")
shop/domain/order.py:5:12: INW011 Layer "domain" imports "shop.infrastructure.db" from outer layer "infrastructure" through a dynamic import (importlib.import_module). Allowed direction: domain <- application <- infrastructure.
fix: Remove the dynamic import and depend on an abstraction owned by "domain" instead of "shop.infrastructure.db".
1. Delete `importlib.import_module("shop.infrastructure.db")`. A dynamic import is still a dependency: building the module name at runtime or moving it to another loader hides it instead of removing it.
2. Declare a typing.Protocol in `shop.domain` (for example `shop.domain.ports`) that describes only what this module needs from `db`.
3. Type this module against that Protocol and receive the implementation through a constructor or function parameter.
4. Make the class in "infrastructure" satisfy the Protocol, and wire it in the outermost layer (the composition root).
docs: https://sircypkowskyy.github.io/inwards/rules/INW011/
With the module name in a variable, the target can't be checked:
shop/domain/order.py:5:12: INW011 Layer "domain" makes a dynamic import (importlib.import_module) with an argument Inwards can't read, such as a variable, an f-string field or *args, so Inwards can't verify that it points toward inner layers.
fix: Name the target with string literals, or move the dynamic import to the outermost layer "infrastructure".
1. If the module is fixed, replace `importlib.import_module(name)` with an import statement, or pass the loader only string literals (no variables, f-string fields, \N{...} escapes or *args) so Inwards can check it.
2. If the module is chosen at runtime (plugins, settings), move the loader to the outermost layer "infrastructure" (the composition root) and pass what it loads into this module as a parameter.
3. Type that parameter against a typing.Protocol declared in `shop.domain` (for example `shop.domain.ports`).
docs: https://sircypkowskyy.github.io/inwards/rules/INW011/
Fixed: the loader moves to the outermost layer, and the domain takes what it loads as a parameter typed against its own port.
from typing import Protocol
class Orders(Protocol):
def total(self, order_id: int) -> int: ...
from shop.domain.ports import Orders
def total(orders: Orders, order_id: int) -> int:
return orders.total(order_id)
import importlib
def load(name: str):
return importlib.import_module(name)
How to fix¶
- If the module is fixed, replace the call with an import statement, or pass the loader only string literals so Inwards can check it. An import statement that points outward is then INW001's finding, with the usual fix: a port in the inner layer.
- If the module is chosen at runtime (plugins, settings), move the loader to the outermost layer and pass what it loads into the inner module as a parameter.
- Type that parameter against a
typing.Protocoldeclared in the inner layer.
Fix safety¶
No automatic fix (autofix: false). Replacing a runtime loader changes how the program is wired, which needs review.
Configuration¶
Severity error, for unverifiable targets too: a warning would pass the hook and the Stop gate, and the dodge would land. [tool.inwards.rules] can turn the rule off or make it a warning (Configure rules). A reviewed loader can be suppressed on the call's first line:
importlib.import_module( # inwards: ignore[INW011] reason="plugin loader, reviewed in #230"
"shop.infrastructure.plugins",
)
Known limitations¶
- A module-level name is a constant only when the whole file binds it once, with a plain assignment in the module body, and only reads it elsewhere. A file that calls
execoreval, writes a namespace (globals(),setattr,sys.modules) or has a wildcard import reads no constants, since each of these can rebind a name, so its loader calls with named targets stay unverifiable. That includesexec(CODE)with a constantCODE. - Folding stops at
%directives other than%s, numeric format specs,!r,!a, andformatfields with an attribute or an index ({0.name}); such a target is unverifiable. - Scopes are ignored. An attribute name assigned a loader anywhere in the file counts on every object, so after
self.load = importlib.import_module,other.load(name)in the same file is reported as unverifiable in an inner layer. functools.partialis read where its arguments are bound. A partial that binds only some of them (package=without the name) is unverifiable there, and its later calls aren't read.- A file loader with an absolute, parent-relative or computed path is unverifiable, and
SourcelessFileLoader,ExtensionFileLoaderandimportlib.util.spec_from_loaderaren't read. - A loader stored in a container (
loaders["x"] = importlib.import_module), reached throughoperator.attrgetter, or passed in as an argument is missed. - In the outermost layer an unverifiable call isn't reported, so it can reach first-party code outside every layer (INW006) unseen.
exec,eval,compileand__import__with a literal source always count as the builtins, so afterfrom re import compile,compile("from shop.infrastructure import x")is reported. A refinement could honour rebinding by module-leveldef,classand third-party imports only; the first attempt missed real imports such asfrom shop.compat import exec, which re-exports the builtin.- Bytes passed to
execorcompilethat CPython rejects before running them (a UTF-8 BOM with another declared encoding, or autf-16orrot13declaration) are still read, or reported as unchecked. Both are false positives. - A bare
execorevalwith a computed source is skipped only when the code surely rebinds the name at the call, sodef eval(model, loader)in training code isn't reported. The builtin passed in as an argument is missed, and some rebindings (a comprehension variable, amatchcapture) are still reported.
Known limitations in chapter 3 has the full list.
References¶
- ADR-015: Check literal dynamic imports as INW011
- ADR-026: Report unreadable dynamic-import targets in inner layers