Przejdź do treści

INW014 ports-abstract

Co robi

Opt-in, a po włączeniu błąd. Sprawdza klasy najwyższego poziomu w module portu, czyli module, który deklaruje, co implementują adaptery (shop/application/ports/orders.py). Zgłasza trzy rzeczy:

  • Klasę konkretną, na jej nazwie: klasę, która nie jest ani ABC, ani Protocol i nie rozszerza klasy z modułu portu. Klasa jest ABC, gdy wśród jej baz jest abc.ABC albo jej metaklasą jest abc.ABCMeta, a Protocol, gdy wśród baz jest typing.Protocol (albo typing_extensions.Protocol), także w postaci Protocol[T] i class P[T](Protocol).
  • Metodę, która wykonuje pracę, na nazwie metody: metodę ABC albo Protocol, której ciało zawiera coś innego niż docstring, ..., pass albo raise NotImplementedError (z komunikatem albo bez). Liczą się też metody z dekoratorami, w tym @property i @abstractmethod.
  • Implementację, na nazwie klasy: klasę, która rozszerza port z modułu portu, sama nie wymienia ABC ani Protocol i ma metody wykonujące pracę. Zgłoszenie jest jedno i wymienia wszystkie takie metody.

Podklasa portu, której wszystkie metody są abstrakcyjne, jest węższym portem i przechodzi. Przechodzą też wyjątki, modele Pydantic, named tuple, typed dict, enumy i dataclassy: zobacz Konfiguracja.

Dwie opcje zaostrzają regułę w projektach, w których porty to klasy typing.Protocol, a adaptery pasują do nich kształtem (ADR-046):

  • interface = "protocol" zgłasza też, na nazwie klasy, klasę ABC w module portu oraz węższy port, który rozszerza port, ale sam nie wymienia Protocol wśród swoich baz. Specyfikacja typowania traktuje taką klasę jak zwykłą klasę, a nie protokół (Merging and extending protocols).
  • allow-subclassing = false zgłasza, w każdym module poza modułami portów, każdą bazę klasy najwyższego poziomu, która wskazuje klasę z modułu portu, na tej bazie. Adapter ma pasować do portu kształtem i mówić to linią zgodności _: Port = Adapter(...) pod if TYPE_CHECKING:, którą sprawdza type checker.

Bez modules reguła sprawdza każdy moduł, który ma w nazwie segment ports: shop.application.ports, shop.application.ports.orders, shop.billing.ports.payments. Ustaw modules, żeby sprawdzać inne moduły.

Dlaczego to źle

W architekturze czystej i heksagonalnej warstwa aplikacji deklaruje porty, klasy abstrakcyjne albo Protocol, a adaptery z zewnętrznej warstwy je implementują. Agenci często dopisują w module portu implementację „domyślną”: metodę, która zapisuje do bazy, print, repozytorium w pamięci obok interfejsu. Warstwa aplikacji zawiera wtedy I/O albo logikę adaptera, reguła zależności obowiązuje tylko na papierze, a następny agent rozbudowuje implementację domyślną zamiast napisać adapter.

Python pozwala na oba skróty. Metoda z @abstractmethod może mieć ciało, które podklasy wywołują przez super() (dokumentacja abc), a metody Protocol mogą mieć domyślne ciała, które dziedziczą jawne podklasy (specyfikacja typowania, Protocols). B024 i B027 w Ruff sprawdzają tylko klasy ABC, w każdym module, i nic nie mówią o klasie konkretnej ani o ciele metody w porcie. INW014 sprawdza moduły portów i nic więcej.

Przykład

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 przechodzi, bo jest wyjątkiem, a Clock, bo jest Protocol z ciałem metody .... Po poprawce moduł portu zostawia same interfejsy, a repozytorium w pamięci trafia do warstwy infrastruktury.

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

Jak działa

Plik jest parsowany tylko wtedy, gdy INW014 jest włączona dla jego modułu, moduł jest modułem portu, a tekst zawiera słowo class. Przy allow-subclassing = false parsowany jest też plik spoza modułów portów, jeśli jego tekst zawiera class, a jego importy, odczytane ze szkieletu importów bez pełnego parsowania, wskazują moduł portu. Jedno parsowanie na taki plik; żaden inny moduł nie jest czytany.

  • Nazwy są kwalifikowane przez importy pliku, więc liczą się import abc z abc.ABC, from typing_extensions import Protocol i import z aliasem. Nazwa, której plik nie importuje i nie definiuje jako klasy, jest wbudowana i zapisana bez prefiksu (Exception, ValueError).
  • Bazy z modułu portu. Baza zdefiniowana w tym samym pliku albo zaimportowana z innego modułu portu (z segmentem ports albo pasującego do modules) czyni klasę węższym portem albo implementacją. Jej własny moduł jest sprawdzany osobno.
  • Kolejność. Dozwolony dekorator albo baza zwalnia klasę bez względu na jej pozostałe bazy. W przeciwnym razie Protocol albo ABC wśród jej własnych baz czyni ją interfejsem, także gdy rozszerza ona inny port.
  • Ciała. Docstring, każdy inny napis, ..., pass oraz raise NotImplementedError albo raise NotImplementedError("...") są abstrakcyjne; komentarze się nie liczą. Komunikat cytuje pierwszą inną instrukcję.
  • Ciała klas. Sprawdzane są tylko metody. Atrybuty, adnotacje i stałe w ciele klasy przechodzą, podobnie jak klasy zagnieżdżone.
  • Dziedziczenie po porcie (allow-subclassing = false). Baza się liczy, gdy przez importy pliku wskazuje nazwę w module portu: from shop.application.ports.orders import OrderRepository, import shop.application.ports.orders z pełną nazwą i alias modułu. Klasa z dozwolonym dekoratorem albo bazą jest zwolniona, a baza pasująca do allow-bases jest pomijana, więc class MissingOrder(OrderNotFoundError) przechodzi. Każda baza to jedno zgłoszenie.
  • Poprawka wskazuje warstwę adapterów: pierwszą warstwę, której nazwa mówi outbound, driven albo secondary, w drugiej kolejności infrastructure albo persistence, w trzeciej adapter, i jej pierwszy wpis w modules. Bez takiej warstwy mówi o module adaptera poza pakietem portów.

Jak naprawić

  1. Zostaw interfejs w module portu: ABC z metodami @abstractmethod albo Protocol, każde ciało to ... albo docstring. Przy interface = "protocol" zamień ABC na Protocol wśród baz i usuń @abstractmethod, który ma znaczenie tylko dla podklas; węższy port sam wymienia Protocol: class OrderReader(OrderRepository, Protocol):.
  2. Przenieś kod do klasy w warstwie adapterów, która dziedziczy po ABC albo ma metody Protocol.
  3. Przy allow-subclassing = false usuń port z baz adaptera i dodaj _: OrderRepository = SqlOrderRepository(...) pod if TYPE_CHECKING: na końcu modułu adaptera, żeby type checker nadal sprawdzał adapter względem portu.
  4. Przenieś dubel testowy (fake w pamięci) obok testów albo do modułu adaptera.
  5. Jeśli klasa to dane, a nie interfejs (wyjątek, DTO, obiekt wartości), daj jej bazę albo dekorator, który to mówi, albo poproś właściciela konfiguracji o dodanie jej bazy do extend-allow-bases.

Bezpieczeństwo poprawki

Brak automatycznej poprawki (autofix: false). Przeniesienie klasy zmienia jej ścieżkę importu u każdego wywołującego, a adapter, który polegał na domyślnym ciele metody, musi ją zaimplementować.

Konfiguracja

Ważność: błąd. Opt-in: włącz ją przez extend-select. Presety clean i hexagonal polecenia inwards init włączają INW014 jako ostrzeżenie, z domyślnym zakresem, który obejmuje ich pakiet application/ports/, i ustawiają interface = "protocol" oraz allow-subclassing = false. Opcje są w [tool.inwards.rules.ports-abstract] (tabele opcji):

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: prefiksy modułów albo selektory, jak w modules warstwy. Gdy jest ustawione, INW014 sprawdza pasujące moduły zamiast tych z segmentem ports, a baza zaimportowana z jednego z nich liczy się jako port.
  • allow-bases: kwalifikowane nazwy, z * i ? jako wildcardami, baz, które zwalniają klasę. Nazwy wbudowane pisze się bez prefiksu. Ustawienie zastępuje domyślną listę: BaseException, Exception, *Error, *Exception, *Warning, pydantic.BaseModel, typing.NamedTuple, typing.TypedDict, ich odpowiedniki z typing_extensions i enum.*.
  • extend-allow-bases: bazy dodane do allow-bases albo do jej domyślnej listy.
  • allow-decorators: kwalifikowane nazwy, z wildcardami, dekoratorów klas, które zwalniają klasę. Ustawienie zastępuje domyślną listę: dataclasses.dataclass, pydantic.dataclasses.dataclass, attrs.define, attrs.frozen, attrs.mutable, attr.s, attr.attrs, attr.define i attr.frozen.
  • interface: "any" (domyślnie) przyjmuje jako porty klasy ABC i Protocol; "protocol" zgłasza też klasę ABC w module portu oraz port, który rozszerza port, nie wymieniając Protocol.
  • allow-subclassing: domyślnie true. false zgłasza każdą bazę klasy spoza modułów portów, która wskazuje klasę z modułu portu.
  • check-tests: true sprawia, że INW014 zgłasza też w modułach testowych, które domyślnie pomija (tabele opcji).

Żeby zaakceptować jedno zgłoszenie, wycisz je w linii, na którą wskazuje: # inwards: ignore[INW014] reason="..." w linii class albo def.

Szablon może włączyć INW014 dla jednej ze swoich ról: ports = { ports-abstract = true } ustawia modules na moduły roli.

Znane ograniczenia

  • Tylko klasy najwyższego poziomu. Klasa wewnątrz funkcji albo pod if TYPE_CHECKING:, klasa zagnieżdżona i funkcje na poziomie modułu nie są sprawdzane. Moduł portu z konkretną funkcją przechodzi.
  • Bazy po nazwie. Klasa, która dostaje ABCMeta przez bazę spoza modułów portów (class Repo(SomeLibraryBase)), jest zgłaszana jako konkretna; dodaj bazę do extend-allow-bases albo wymień też ABC. Baza z modułu portu liczy się bez czytania, więc konkretna klasa w tamtym module jest zgłaszana w swoim module, nie przez podklasy.
  • Klasy zwolnione nie są sprawdzane dalej. Wyjątek albo dataclass w module portu może mieć dowolne metody; allow-bases decyduje tylko o tym, czy klasa to dane.
  • Ciała klas. Atrybut klasy, który buduje obiekt (session = Session()), nie jest zgłaszany.
  • Domyślne ciała, na które pozwala język. Metoda @abstractmethod z ciałem dla super() albo metoda Protocol z domyślnym ciałem jest zgłaszana: przenieś wspólny kod do funkcji pomocniczej poza portem albo wycisz linię z podaniem powodu.
  • Dziedziczenie odczytywane po nazwie. allow-subclassing = false zgłasza bazę z modułu portu, chyba że pasuje do allow-bases, i nie czyta modułu portu: wyjątek z tamtego modułu, którego nazwa nie kończy się na Error ani Exception (class Missing(OrderNotFound)), jest zgłaszany. Dodaj go do extend-allow-bases. Klasa zagnieżdżona albo wewnątrz funkcji nie jest sprawdzana.
  • Zmierzona na korpusie (#182, rozdział 6): brak zgłoszeń. fastapi-clean-example to jedyne repozytorium z modułami ports (5 plików, każda klasa to Protocol), więc przebieg nie pokazuje fałszywych alarmów, ale mało mówi o precyzji. Przy allow-subclassing = false dostaje 9 zgłoszeń, czyli 9 baz-portów jego 8 klas adapterów (jedyna zaślepka testowa leży w module testowym i jest pomijana); przy interface = "protocol" nie dostaje żadnego.

Źródła