Przejdź do treści

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 bloku if TYPE_CHECKING: (albo if 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() po from store.adapters import SqlOrderRepo, gdy store/adapters/__init__.py ją reeksportuje, albo store.adapters.outbound.sql.SqlOrderRepo() po import 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

pyproject.toml
[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"]
store/application/ports.py
from typing import Protocol


class OrderRepo(Protocol):
    def save(self, order_id: int) -> None: ...
store/adapters/outbound/sql.py
class SqlOrderRepo:
    def save(self, order_id: int) -> None:
        print("insert", order_id)
store/adapters/inbound/rest.py
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/main.py
from store.adapters.inbound.rest import OrderController

controller = OrderController()
inwards check store
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.

store/adapters/inbound/rest.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)
store/main.py
from store.adapters.inbound.rest import OrderController
from store.adapters.outbound.sql import SqlOrderRepo

controller = OrderController(SqlOrderRepo())
inwards check store

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:

store/adapters/inbound/rest.py
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: albo elif TYPE_CHECKING:. Gałąź else się 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 class z najwyższego poziomu. Jeśli ta klasa jest zdefiniowana w module roli, wywołanie jest zgłaszane. Czytane są tylko moduły z roli, z allowed-in i pakiety nad rolą (których __init__.py moż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ć

  1. Zależ od portu, który adapter implementuje, a nie od adaptera.
  2. 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.
  3. 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):

pyproject.toml
[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 w modules warstwy, wskazujące moduły, które może importować i budować tylko korzeń kompozycji. Prefiks obejmuje pakiet i wszystko pod nim. Tabela bez role to 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 jest role.
  • check-tests: true sprawia, ż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 do Depends(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-in i 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 SqlOrderRepo w __init__.py własnego pakietu (store/adapters/__init__.py importujące z store/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ę na Error, Exception albo Warning) 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 role ustawionym na app.outbound w fastapi-clean-example i pakiety infrastructure w 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: False w czasie działania, a dla narzędzi sprawdzających typy True.
  • FastAPI: Dependencies: Depends() i funkcje dostarczające zależności.