INW015 construct-only-in¶
Co robi¶
Opt-in, a po włączeniu błąd. Wskazujesz rolę, czyli moduły, które może budować tylko korzeń kompozycji, na przykład adaptery wyjściowe, oraz moduły, którym wolno je budować. INW015 zgłasza wtedy dwie rzeczy w każdym innym module, który należy do warstwy:
- Import modułu roli, który się wykonuje. Liczy się zwykły import, import
from, import względny, import z gwiazdką i import wewnątrz funkcji; zgłoszenie stoi na importowanej nazwie. Import w blokuif TYPE_CHECKING:(alboif typing.TYPE_CHECKING:) się nie liczy: nigdy się nie wykonuje, więc niczego nie buduje. - Wywołanie, które buduje klasę roli osiągniętą inną drogą.
SqlOrderRepo()pofrom store.adapters import SqlOrderRepo, gdystore/adapters/__init__.pyją reeksportuje, albostore.adapters.outbound.sql.SqlOrderRepo()poimport store.adapters. Zgłoszenie stoi na wywołaniu. Wywołanie, którego nazwa pochodzi z importu już zgłoszonego przez INW015, nie jest zgłaszane drugi raz.
Moduły roli mogą importować siebie nawzajem, podobnie jak moduły wymienione w allowed-in. Moduł, który nie należy do żadnej warstwy, na przykład test albo skrypt, nie jest sprawdzany: testy budują adaptery celowo.
Dlaczego to źle¶
W architekturze portów i adapterów warstwy wewnętrzne mają port (klasę abstrakcyjną albo Protocol), adapter wyjściowy go implementuje, a korzeń kompozycji (main.py, di.py) buduje adapter i przekazuje go temu, kto potrzebuje portu. Gdy adapter wejściowy, na przykład kontroler REST, sam buduje SqlOrderRepo(), tego połączenia już nie ma: kontroler jest związany z SQL, test nie podstawi atrapy, a kolejny agent skopiuje ten wzorzec do każdej trasy.
INW001 tego nie widzi. Adaptery wejściowe i wyjściowe zwykle leżą w tej samej warstwie, więc import nie jest skierowany na zewnątrz. Moduły publiczne z INW003 dotyczą kontekstów ograniczonych, a nie tego, kto może co budować.
Przykład¶
[tool.inwards]
layers = [
{ name = "application", modules = ["store.application"] },
{ name = "adapters", modules = ["store.adapters"] },
{ name = "bootstrap", modules = ["store.main"] },
]
[tool.inwards.rules]
extend-select = ["INW015"]
[tool.inwards.rules.construct-only-in]
role = ["store.adapters.outbound"]
allowed-in = ["store.main"]
from typing import Protocol
class OrderRepo(Protocol):
def save(self, order_id: int) -> None: ...
class SqlOrderRepo:
def save(self, order_id: int) -> None:
print("insert", order_id)
from store.adapters.outbound.sql import SqlOrderRepo
from store.application.ports import OrderRepo
class OrderController:
def __init__(self) -> None:
self.repo: OrderRepo = SqlOrderRepo()
def place(self, order_id: int) -> None:
self.repo.save(order_id)
store/adapters/inbound/rest.py:1:41: INW015 `store.adapters.inbound.rest` imports `SqlOrderRepo` from `store.adapters.outbound.sql`, a module of the role `store.adapters.outbound`. A module that uses the adapter directly is tied to it and bypasses the port it implements.
fix: Take the port as a parameter instead of importing `store.adapters.outbound.sql`, and let `store.main` build the adapter.
1. Delete `from store.adapters.outbound.sql import SqlOrderRepo`.
2. Type this module against the port the adapter implements (the abstract class or Protocol the inner layer owns), not against the adapter.
3. Receive the implementation through a constructor or function parameter; in a FastAPI route or dependency, through `Depends()` on a provider whose return type is the port.
4. Build the adapter in `store.main` and pass it in from there.
Po poprawce kontroler dostaje port przez konstruktor, a adapter buduje store/main.py.
from store.application.ports import OrderRepo
class OrderController:
def __init__(self, repo: OrderRepo) -> None:
self.repo = repo
def place(self, order_id: int) -> None:
self.repo.save(order_id)
from store.adapters.inbound.rest import OrderController
from store.adapters.outbound.sql import SqlOrderRepo
controller = OrderController(SqlOrderRepo())
Import w TYPE_CHECKING też przechodzi, bo nigdy się nie wykonuje. Nadal jednak wiąże moduł z nazwą adaptera, więc w adnotacjach lepiej używać portu:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from store.adapters.outbound.sql import SqlOrderRepo
Jak działa¶
INW015 sprawdza moduł, gdy reguła jest dla niego włączona (modules), moduł należy do warstwy i nie jest ani w role, ani w allowed-in. Plik bez słowa import nigdy nie jest parsowany; każdy inny jest parsowany raz, osobno.
- Importy. Każda instrukcja importu, także zagnieżdżona, jest rozwiązywana tak jak w INW001, a indeks modułów projektu mówi, w którym module z własnego kodu ląduje. Zgłaszany jest import, który ląduje w module roli i nie leży w ciele gałęzi
if TYPE_CHECKING:alboelif TYPE_CHECKING:. Gałąźelsesię wykonuje, więc się liczy. - Wywołania. Wywołanie, w którym ostatnia nazwa wywoływanego obiektu zaczyna się wielką literą, tak jak Python nazywa klasy, jest kwalifikowane przez importy pliku i śledzone przez indeks modułów, przez najwyżej cztery reeksporty, aż do
classz najwyższego poziomu. Jeśli ta klasa jest zdefiniowana w module roli, wywołanie jest zgłaszane. Czytane są tylko moduły z roli, zallowed-ini pakiety nad rolą (których__init__.pymoże ją reeksportować), każdy raz na sprawdzany plik. - Najpierw INW001. Import skierowany na zewnątrz, do innej warstwy, dostaje tylko INW001, którego poprawka już każe zależeć od portu. Gdy INW001 jest wyłączona dla modułu, zgłasza go INW015.
Jak naprawić¶
- Zależ od portu, który adapter implementuje, a nie od adaptera.
- Przyjmij implementację jako parametr konstruktora albo funkcji. W trasie albo zależności FastAPI użyj
Depends()z funkcją dostarczającą, której typ zwracany to port. - Zbuduj adapter w korzeniu kompozycji i przekaż go stamtąd.
Bezpieczeństwo poprawki¶
Brak automatycznej poprawki (autofix: false). Przeniesienie budowania do korzenia kompozycji zmienia konstruktory i miejsca wywołań w więcej niż jednym module.
Konfiguracja¶
Ważność: błąd. Opt-in: włączasz ją przez extend-select. Presety clean i hexagonal polecenia inwards init włączają INW015 jako ostrzeżenie, z adapterami wyjściowymi (app.infrastructure albo app.adapters.outbound) jako role i app.bootstrap jako allowed-in. Opcje trafiają do [tool.inwards.rules.construct-only-in] (tabele opcji):
[tool.inwards.rules]
extend-select = ["INW015"]
[tool.inwards.rules.construct-only-in]
role = ["app.adapters.outbound", "app.*.gateways"] # required: the guarded modules
allowed-in = ["app.di", "app.main"] # the composition root
modules = ["app.adapters"] # optional: only report here
role(wymagane): prefiksy modułów albo selektory, zapisane jak wmoduleswarstwy, wskazujące moduły, które może importować i budować tylko korzeń kompozycji. Prefiks obejmuje pakiet i wszystko pod nim. Tabela bezroleto błąd konfiguracji.allowed-in: prefiksy modułów albo selektory korzenia kompozycji, który może importować rolę i budować jej klasy. Bez tej opcji mogą to robić tylko moduły samej roli.modules: jak w każdej regule, moduły, w których INW015 zgłasza. Inaczej niż w większości reguł nie wskazuje roli: od tego jestrole.check-tests:truesprawia, że INW015 zgłasza też w modułach testowych, które domyślnie pomija (tabele opcji).
Pojedynczy import można wyciszyć z uzasadnieniem w jego linii: # inwards: ignore[INW015] reason="...".
Szablon może włączyć INW015 dla jednej ze swoich ról: outbound = { construct-only-in = { allowed-in = ["app.di"] } } wpisuje moduły roli do role, a nie do modules, więc to rola jest tym, co reguła chroni.
Znane ograniczenia¶
- Tylko klasy, po nazwie. Forma wywołań śledzi wywoływany obiekt, którego nazwa zaczyna się wielką literą, więc funkcja fabrykująca (
make_repo()) albo klasa z nazwą małą literą osiągnięta przez reeksport nie jest śledzona. Jej import, gdy importuje moduł roli wprost, nadal jest zgłaszany. - Co ukrywa klasę. Klasa przypisana pod inną nazwę (
Repo = SqlOrderRepo), przekazywana jako wartość albo podana doDepends(SqlOrderRepo)przez reeksport nie jest śledzona. Jej bezpośredni import i tak jest zgłaszany. - Reeksporty. Śledzone są najwyżej cztery reeksporty, i tylko przez rolę,
allowed-ini pakiety nad rolą. - Moduły spoza warstw nie są sprawdzane wcale, więc kod poza twoimi warstwami może budować adaptery bez ograniczeń. Moduły testowe też nie, chyba że
check-tests = true. - Fasada pakietu nie jest zgłaszana za re-eksport.
from <podmoduł> import SqlOrderRepow__init__.pywłasnego pakietu (store/adapters/__init__.pyimportujące zstore/adapters/outbound/sql.py) niczego nie buduje, a INW003 tego wymaga, gdy pakiet jest jedynym publicznym wejściem. Wywołanie budujące klasę w samej fasadzie i każdy, kto buduje ją przez fasadę, nadal są zgłaszani. - Jedno zgłoszenie na import, a nie na użycie. Poprawienie importu poprawia każde jego użycie.
- Wyjątki z roli nie są zgłaszane.
from <moduł roli> import StorageError(nazwa kończąca się naError,ExceptionalboWarning) i wywołanie, które buduje taką klasę przez reeksport, nie budują adaptera: kod spoza roli łapie błąd albo mapuje go na status HTTP. Przechodzi też nazwa, która tylko wygląda na wyjątek. - Zmierzona na korpusie (#182, rozdział 6) z
roleustawionym naapp.outboundw fastapi-clean-example i pakietyinfrastructurew python-ddd: 48 zgłoszeń przed regułą o wyjątkach, 17 po niej, każde obejrzane. 15 z 17 jest trafnych (zapytanie aplikacji importuje model ORM z infrastruktury, handler wejściowy importuje klasę handlera wyjściowego). Pozostałe 2: stała importowana z adaptera (STAGED_COOKIE) i moduł testów, który ma warstwa. Od #362 moduły testowe są pomijane, więc zostaje 16 zgłoszeń, z czego 15 trafnych.
Źródła¶
- #296: ta reguła; #98: reguły treści ograniczone do ról, z których pochodzi.
typing.TYPE_CHECKING:Falsew czasie działania, a dla narzędzi sprawdzających typyTrue.- FastAPI: Dependencies:
Depends()i funkcje dostarczające zależności.