INW014 ports-abstract¶
What it does¶
Opt-in, and an error when on. Checks the top-level classes of a port module, the module that declares what adapters implement (shop/application/ports/orders.py). Three things are reported:
- A concrete class, on its name: a class that is neither an ABC nor a Protocol and extends no class from a port module. A class is an ABC when
abc.ABCis among its bases or its metaclass isabc.ABCMeta, and a Protocol whentyping.Protocol(ortyping_extensions.Protocol) is,Protocol[T]andclass P[T](Protocol)included. - A method that does work, on the method's name: a method of an ABC or a Protocol whose body holds anything but a docstring,
...,passorraise NotImplementedError(with or without a message). Decorated methods count,@propertyand@abstractmethodincluded. - An implementation, on the class's name: a class that extends a port from a port module, without listing
ABCorProtocolitself, and has methods that do work. It is reported once, with every such method listed.
A subclass of a port whose methods are all abstract is a narrower port and passes.
Two options make the rule stricter, for projects whose ports are typing.Protocol classes that adapters match by shape (ADR-046):
interface = "protocol"also reports, on the class's name, an ABC in a port module, and a narrower port that extends a port without listingProtocolamong its own bases. The typing spec treats such a class as a regular class, not a protocol (Merging and extending protocols).allow-subclassing = falsereports, in any module outside the port modules, each base of a top-level class that names a class from a port module, on that base. The adapter should match the port by shape and say so with_: Port = Adapter(...)underif TYPE_CHECKING:, which the type checker checks. Exceptions, Pydantic models, named tuples, typed dicts, enums and dataclasses pass too: see Configuration.
Without modules, the rule checks every module with a ports segment in its name: shop.application.ports, shop.application.ports.orders, shop.billing.ports.payments. Set modules to check other modules instead.
Why is this bad¶
In clean and hexagonal architectures the application layer declares ports, abstract classes or Protocols, and adapters in an outer layer implement them. Agents often add a "default" implementation in the port module: a method that writes to the database, a print, an in-memory repository next to the interface. The application layer then holds I/O or adapter logic, the dependency rule only holds on paper, and the next agent extends the default instead of writing an adapter.
Python allows both shortcuts. An @abstractmethod may have a body that subclasses call through super() (abc docs), and a Protocol's methods may have default bodies that explicit subclasses inherit (typing spec, Protocols). Ruff's B024 and B027 check ABCs only, in every module, and say nothing about a concrete class or a method body in a port. INW014 checks the port modules and nothing else.
Example¶
[tool.inwards]
layers = [
{ name = "domain", modules = ["shop.domain"] },
{ name = "application", modules = ["shop.application"] },
{ name = "infrastructure", modules = ["shop.infrastructure"] },
]
[tool.inwards.rules]
extend-select = ["INW014"]
from abc import ABC, abstractmethod
from typing import Protocol
class OrderNotFound(Exception):
pass
class Clock(Protocol):
def now(self) -> float: ...
class OrderRepository(ABC):
@abstractmethod
def get(self, order_id: int) -> dict:
"""Loads one order, or raises OrderNotFound."""
def save(self, order: dict) -> None:
print("saving order", order["id"])
class InMemoryOrders(OrderRepository):
def __init__(self) -> None:
self.rows: dict[int, dict] = {}
def get(self, order_id: int) -> dict:
return self.rows[order_id]
class SmtpMailer:
def send(self, to: str, body: str) -> None:
print("mail to", to, body)
shop/application/ports/orders.py:18:9: INW014 `OrderRepository.save` in port module `shop.application.ports.orders` has a body that does work: line 19 runs `print("saving order", order["id"])`. A port declares what adapters implement; its methods hold only a docstring, `...`, `pass` or `raise NotImplementedError`.
fix: Move the body of `OrderRepository.save` to an implementation in `shop.infrastructure` (layer `infrastructure`), and leave `...` here.
1. Replace the body of `OrderRepository.save` with `...`, keeping its docstring, and mark it `@abstractmethod`.
2. Put the code in a class in `shop.infrastructure` (layer `infrastructure`) that subclasses `OrderRepository`, and implement `save` there.
3. Code every adapter shares belongs in a helper in the adapter layer or a domain service, not in the port.
docs: https://sircypkowskyy.github.io/inwards/rules/INW014/
shop/application/ports/orders.py:22:7: INW014 `InMemoryOrders` in port module `shop.application.ports.orders` extends the port `OrderRepository` and implements it: `__init__` (line 23) and `get` (line 26) do work. An implementation is an adapter; a port module holds only the interfaces adapters implement.
fix: Move `InMemoryOrders` to `shop.infrastructure` (layer `infrastructure`).
1. Move `InMemoryOrders` to `shop.infrastructure` (layer `infrastructure`), importing `OrderRepository` from `shop.application.ports.orders` there.
2. If `InMemoryOrders` is a test double, such as an in-memory fake, move it next to the tests instead.
3. If `InMemoryOrders` is meant to be a narrower port, list `ABC` or `Protocol` among its bases and give its methods the body `...`.
docs: https://sircypkowskyy.github.io/inwards/rules/INW014/
shop/application/ports/orders.py:30:7: INW014 `SmtpMailer` in port module `shop.application.ports.orders` is neither an ABC nor a Protocol, and extends no class from a port module. A port module holds only the interfaces adapters implement.
fix: Move `SmtpMailer` to `shop.infrastructure` (layer `infrastructure`), and leave an ABC or Protocol here.
OrderNotFound passes because it is an exception, and Clock because it is a Protocol whose method body is .... Fixed: the port module keeps the interfaces, and the in-memory repository moves to the infrastructure layer.
from abc import ABC, abstractmethod
from typing import Protocol
class OrderNotFound(Exception):
pass
class Clock(Protocol):
def now(self) -> float: ...
class OrderRepository(ABC):
@abstractmethod
def get(self, order_id: int) -> dict:
"""Loads one order, or raises OrderNotFound."""
@abstractmethod
def save(self, order: dict) -> None: ...
from shop.application.ports.orders import OrderNotFound, OrderRepository
class InMemoryOrders(OrderRepository):
def __init__(self) -> None:
self.rows: dict[int, dict] = {}
def get(self, order_id: int) -> dict:
if order_id not in self.rows:
raise OrderNotFound(order_id)
return self.rows[order_id]
def save(self, order: dict) -> None:
self.rows[order["id"]] = order
How it works¶
A file is parsed only when INW014 is on for its module, the module is a port module, and its text has the word class. With allow-subclassing = false, a file outside the port modules is parsed too when its text has class and its imports, read from the import skeleton without a full parse, name a port module. One parse per such file; no other module is read.
- Names are qualified through the file's imports, so
import abcwithabc.ABC,from typing_extensions import Protocoland an aliased import all count. A name the file doesn't import and doesn't define as a class is a builtin, spelled bare (Exception,ValueError). - Bases from a port module. A base defined in the same file, or imported from another module that is a port module (with a
portssegment, or matched bymodules), makes the class a narrower port or an implementation. Its own module is checked on its own. - Order. An allowed decorator or base makes the class exempt, whatever its other bases are. Otherwise
ProtocolorABCamong its own bases makes it an interface, even when it also extends another port. - Bodies. A docstring, any other string,
...,passandraise NotImplementedErrororraise NotImplementedError("...")are abstract; comments don't count. The first other statement is the one the message quotes. - Class bodies. Only methods are checked. Attributes, annotations and constants in a class body pass, and so do nested classes.
- Subclassing a port (
allow-subclassing = false). A base counts when it resolves through the file's imports to a name in a port module:from shop.application.ports.orders import OrderRepository,import shop.application.ports.orderswith the full name, and a module alias all do. A class with an allowed decorator or base is exempt, and a baseallow-basesmatches is skipped, soclass MissingOrder(OrderNotFoundError)passes. Each base is one finding. - The fix names the adapter layer: the first layer whose name says outbound, driven or secondary, else infrastructure or persistence, else adapter, and its first
modulesentry. Without such a layer it says "an adapter module outside the ports package".
How to fix¶
- Leave the interface in the port module: an ABC with
@abstractmethodmethods or a Protocol, each body...or a docstring. Withinterface = "protocol", replaceABCwithProtocolamong the bases and drop@abstractmethod, which matters only to subclasses; a narrower port listsProtocolitself:class OrderReader(OrderRepository, Protocol):. - Move the code to a class in the adapter layer that subclasses the ABC, or has the Protocol's methods.
- With
allow-subclassing = false, remove the port from the adapter's bases and add_: OrderRepository = SqlOrderRepository(...)underif TYPE_CHECKING:at the end of the adapter module, so the type checker still checks the adapter against the port. - Move a test double (an in-memory fake) next to the tests, or into an adapter module.
- If a class is data rather than an interface (an exception, a DTO, a value object), give it the base or decorator that says so, or ask whoever owns the config to add its base to
extend-allow-bases.
Fix safety¶
No automatic fix (autofix: false). Moving a class changes its import path for every caller, and an adapter that relied on a default method body has to implement it.
Configuration¶
Severity error. Opt-in: turn it on with extend-select. The clean and hexagonal presets of inwards init turn INW014 on as a warning, with the default scope, which covers their application/ports/ package, and set interface = "protocol" and allow-subclassing = false. The options live in [tool.inwards.rules.ports-abstract] (options tables):
[tool.inwards.rules]
extend-select = ["INW014"]
[tool.inwards.rules.ports-abstract]
modules = ["shop.application.ports", "shop.*.ports"] # optional: the port modules
extend-allow-bases = ["shop.domain.ValueObject"] # more bases that are exempt
allow-decorators = ["dataclasses.dataclass", "attrs.*"]
interface = "protocol" # ports are typing.Protocol classes
allow-subclassing = false # adapters never subclass a port
modules: module prefixes or selectors, as in a layer'smodules. Set, INW014 checks the modules they match instead of the ones with aportssegment, and a base imported from one of them counts as a port.allow-bases: qualified names, with*and?as wildcards, of bases that make a class exempt. Builtins are spelled bare. Setting it replaces the default:BaseException,Exception,*Error,*Exception,*Warning,pydantic.BaseModel,typing.NamedTuple,typing.TypedDict, theirtyping_extensionstwins, andenum.*.extend-allow-bases: bases added toallow-bases, or to its default.allow-decorators: qualified names, with wildcards, of class decorators that make a class exempt. Setting it replaces the default:dataclasses.dataclass,pydantic.dataclasses.dataclass,attrs.define,attrs.frozen,attrs.mutable,attr.s,attr.attrs,attr.defineandattr.frozen.interface:"any"(the default) accepts ABCs and Protocols as ports;"protocol"also reports an ABC in a port module, and a port that extends a port without listingProtocol.allow-subclassing:trueby default.falsereports each base, on a class outside the port modules, that names a class from a port module.check-tests:truemakes INW014 report in test modules too, which it skips by default (options tables).
To accept one finding, suppress it on the line it points at: # inwards: ignore[INW014] reason="..." on the class or def line.
A template can turn INW014 on for one of its roles: ports = { ports-abstract = true } sets modules to the role's modules.
Known limitations¶
- Top-level classes only. A class inside a function or under
if TYPE_CHECKING:, a nested class, and module-level functions are not checked. A port module that defines a concrete function passes. - Bases by name. A class that gets
ABCMetathrough a base outside the port modules (class Repo(SomeLibraryBase)) is reported as concrete; add the base toextend-allow-bases, or listABCtoo. A base from a port module counts without being read, so a concrete class there is reported in its own module, not through its subclasses. - Exempt classes aren't checked further. An exception or a dataclass in a port module may hold any method;
allow-basesdecides only whether a class is data. - Class bodies. A class attribute that builds an object (
session = Session()) isn't reported. - Default bodies the language allows. An
@abstractmethodwith a body meant forsuper(), or a Protocol method with a default, is reported: move the shared code to a helper outside the port, or suppress the line with a reason. - Subclassing is read by name.
allow-subclassing = falsereports a base from a port module unlessallow-basesmatches it, without reading the port module: an exception there whose name doesn't end inErrororException(class Missing(OrderNotFound)) is reported. Add it toextend-allow-bases. A nested class, or one inside a function, isn't checked. - Measured on the corpus (#182, chapter 6): no findings. fastapi-clean-example is the one repo with
portsmodules (5 files, every class aProtocol), so the run shows no false positives but says little about precision. Withallow-subclassing = falseit gets 9 findings, the 9 port bases of its 8 adapter classes (its one test stub is in a test module and skipped); withinterface = "protocol"it gets none.
References¶
- #295: the rule; #432:
interfaceandallow-subclassing; #98: the role-scoped content rules it comes from. abc: Abstract Base Classes:ABC,ABCMetaandabstractmethod.- Typing spec: Protocols: what makes a class a protocol, default method bodies, and why explicit subclassing isn't needed for type checking.
- Ruff B024 and B027: the ABC checks Ruff has.