Skip to content

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.ABC is among its bases or its metaclass is abc.ABCMeta, and a Protocol when typing.Protocol (or typing_extensions.Protocol) is, Protocol[T] and class 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, ..., pass or raise NotImplementedError (with or without a message). Decorated methods count, @property and @abstractmethod included.
  • An implementation, on the class's name: a class that extends a port from a port module, without listing ABC or Protocol itself, 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 listing Protocol among its own bases. The typing spec treats such a class as a regular class, not a protocol (Merging and extending protocols).
  • allow-subclassing = false reports, 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(...) under if 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

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]

[tool.inwards.rules]
extend-select = ["INW014"]
shop/application/ports/orders.py
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)
inwards check shop
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.

shop/application/ports/orders.py
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: ...
shop/infrastructure/orders.py
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
inwards check shop

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 abc with abc.ABC, from typing_extensions import Protocol and 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 ports segment, or matched by modules), 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 Protocol or ABC among its own bases makes it an interface, even when it also extends another port.
  • Bodies. A docstring, any other string, ..., pass and raise NotImplementedError or raise 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.orders with the full name, and a module alias all do. A class with an allowed decorator or base is exempt, and a base allow-bases matches is skipped, so class 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 modules entry. Without such a layer it says "an adapter module outside the ports package".

How to fix

  1. Leave the interface in the port module: an ABC with @abstractmethod methods or a Protocol, each body ... or a docstring. With interface = "protocol", replace ABC with Protocol among the bases and drop @abstractmethod, which matters only to subclasses; a narrower port lists Protocol itself: class OrderReader(OrderRepository, Protocol):.
  2. Move the code to a class in the adapter layer that subclasses the ABC, or has the Protocol's methods.
  3. With allow-subclassing = false, remove the port from the adapter's bases and add _: OrderRepository = SqlOrderRepository(...) under if TYPE_CHECKING: at the end of the adapter module, so the type checker still checks the adapter against the port.
  4. Move a test double (an in-memory fake) next to the tests, or into an adapter module.
  5. 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):

pyproject.toml
[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's modules. Set, INW014 checks the modules they match instead of the ones with a ports segment, 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, their typing_extensions twins, and enum.*.
  • extend-allow-bases: bases added to allow-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.define and attr.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 listing Protocol.
  • allow-subclassing: true by default. false reports each base, on a class outside the port modules, that names a class from a port module.
  • check-tests: true makes 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 ABCMeta through a base outside the port modules (class Repo(SomeLibraryBase)) is reported as concrete; add the base to extend-allow-bases, or list ABC too. 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-bases decides 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 @abstractmethod with a body meant for super(), 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 = false reports a base from a port module unless allow-bases matches it, without reading the port module: an exception there whose name doesn't end in Error or Exception (class Missing(OrderNotFound)) is reported. Add it to extend-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 ports modules (5 files, every class a Protocol), so the run shows no false positives but says little about precision. With allow-subclassing = false it gets 9 findings, the 9 port bases of its 8 adapter classes (its one test stub is in a test module and skipped); with interface = "protocol" it gets none.

References