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.ABCalbo jej metaklasą jestabc.ABCMeta, a Protocol, gdy wśród baz jesttyping.Protocol(albotyping_extensions.Protocol), także w postaciProtocol[T]iclass P[T](Protocol). - Metodę, która wykonuje pracę, na nazwie metody: metodę ABC albo Protocol, której ciało zawiera coś innego niż docstring,
...,passalboraise NotImplementedError(z komunikatem albo bez). Liczą się też metody z dekoratorami, w tym@propertyi@abstractmethod. - Implementację, na nazwie klasy: klasę, która rozszerza port z modułu portu, sama nie wymienia
ABCaniProtocoli 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 wymieniaProtocolwśród swoich baz. Specyfikacja typowania traktuje taką klasę jak zwykłą klasę, a nie protokół (Merging and extending protocols).allow-subclassing = falsezgł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(...)podif 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¶
[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 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.
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
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 abczabc.ABC,from typing_extensions import Protocoli 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
portsalbo pasującego domodules) 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
ProtocolalboABCwśród jej własnych baz czyni ją interfejsem, także gdy rozszerza ona inny port. - Ciała. Docstring, każdy inny napis,
...,passorazraise NotImplementedErroralboraise 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.ordersz pełną nazwą i alias modułu. Klasa z dozwolonym dekoratorem albo bazą jest zwolniona, a baza pasująca doallow-basesjest pomijana, więcclass 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ć¶
- Zostaw interfejs w module portu: ABC z metodami
@abstractmethodalbo Protocol, każde ciało to...albo docstring. Przyinterface = "protocol"zamieńABCnaProtocolwśród baz i usuń@abstractmethod, który ma znaczenie tylko dla podklas; węższy port sam wymieniaProtocol:class OrderReader(OrderRepository, Protocol):. - Przenieś kod do klasy w warstwie adapterów, która dziedziczy po ABC albo ma metody Protocol.
- Przy
allow-subclassing = falseusuń port z baz adaptera i dodaj_: OrderRepository = SqlOrderRepository(...)podif TYPE_CHECKING:na końcu modułu adaptera, żeby type checker nadal sprawdzał adapter względem portu. - Przenieś dubel testowy (fake w pamięci) obok testów albo do modułu adaptera.
- 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):
[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 wmoduleswarstwy. Gdy jest ustawione, INW014 sprawdza pasujące moduły zamiast tych z segmentemports, 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 ztyping_extensionsienum.*.extend-allow-bases: bazy dodane doallow-basesalbo 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.defineiattr.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ącProtocol.allow-subclassing: domyślnietrue.falsezgłasza każdą bazę klasy spoza modułów portów, która wskazuje klasę z modułu portu.check-tests:truesprawia, ż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
ABCMetaprzez bazę spoza modułów portów (class Repo(SomeLibraryBase)), jest zgłaszana jako konkretna; dodaj bazę doextend-allow-basesalbo 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-basesdecyduje 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
@abstractmethodz ciałem dlasuper()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 = falsezgłasza bazę z modułu portu, chyba że pasuje doallow-bases, i nie czyta modułu portu: wyjątek z tamtego modułu, którego nazwa nie kończy się naErroraniException(class Missing(OrderNotFound)), jest zgłaszany. Dodaj go doextend-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 toProtocol), więc przebieg nie pokazuje fałszywych alarmów, ale mało mówi o precyzji. Przyallow-subclassing = falsedostaje 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); przyinterface = "protocol"nie dostaje żadnego.
Źródła¶
- #295: reguła; #432:
interfaceiallow-subclassing; #98: reguły treści modułów według ról, z których pochodzi. abc: Abstract Base Classes:ABC,ABCMetaiabstractmethod.- Specyfikacja typowania: Protocols: co czyni klasę protokołem, domyślne ciała metod i dlaczego jawne dziedziczenie nie jest potrzebne do sprawdzania typów.
- Ruff B024 i B027: kontrole ABC, które ma Ruff.