Przejdź do treści

INW005 pure-domain

Co robi

Zgłasza import, w pliku w warstwie, biblioteki, której warstwa nie może używać. Każdy import trafia do jednej z kategorii: własny (należy do warstwy albo projektu; tym zajmują się INW001 i INW006), biblioteka standardowa (dołączona lista nazw modułów CPythona od 3.11 do 3.14) albo biblioteka zewnętrzna. Import biblioteki jest potem sprawdzany względem trzech opcjonalnych kluczy warstwy:

  • allow-libraries: gdy jest ustawiony, warstwa może importować tylko te biblioteki zewnętrzne. Biblioteka standardowa pozostaje dozwolona.
  • deny-libraries: warstwa nie może importować tych modułów, także z biblioteki standardowej. W najbardziej wewnętrznej warstwie zastępuje domyślną listę zakazów.
  • extend-deny-libraries: dopisuje do listy zakazów warstwy, także do domyślnej.

[tool.inwards.rules.pure-domain] może też zakazać bibliotek prefiksom modułów, które nie są całymi warstwami, kluczem deny (#219): moduły, do których pasuje wpis, nie mogą importować jego bibliotek, w warstwie albo poza wszystkimi warstwami, cokolwiek mówi allow-libraries warstwy. Komunikat podaje wtedy prefiks: Module "mypackage.one.views" imports "django.db" from library "django", which [tool.inwards.rules.pure-domain] denies to "mypackage.one".

Bez żadnego z nich najbardziej wewnętrzna warstwa w konfiguracji z co najmniej dwiema warstwami zakazuje domyślnej listy: frameworków (django, fastapi, flask...), baz danych i ORM-ów (sqlalchemy, psycopg, redis, sqlite3...), klientów sieciowych (requests, httpx, boto3...) i operacji wejścia-wyjścia z biblioteki standardowej (socket, subprocess, http.client, urllib.request...). Pozostałe warstwy mogą importować dowolną bibliotekę. Import jest sprawdzany wszędzie, gdzie się znajduje, jak w INW001, łącznie z dosłownymi importami dynamicznymi (importlib.import_module("requests")). Pełną listę i zasady dopasowania opisuje Biblioteki w warstwach.

Dlaczego to źle

INW001 patrzy tylko na twój własny kod, więc from sqlalchemy.orm import Session w domenie ją przechodzi, choć wiąże domenę z bazą danych równie mocno jak import shop.infrastructure.db. Dla agenta, któremu zabroniono importować warstwę infrastruktury, zaimportowanie biblioteki wprost to oczywisty następny ruch. To także najczęstszy wyciek w warstwowym kodzie Pythona pisanym przez ludzi.

Przykład

Z warstwami z INW001 domena używa sesji ORM:

shop/domain/order.py
from sqlalchemy.orm import Session


class Order:
    def __init__(self, session: Session, order_id: int) -> None:
        self.session = session
        self.order_id = order_id
inwards check
shop/domain/order.py:1:28: INW005 Layer "domain" imports "sqlalchemy.orm.Session" from library "sqlalchemy", which "domain" may not use.
  fix: Use "sqlalchemy" in an outer layer, behind a port owned by "domain".
    1. Delete `from sqlalchemy.orm import Session`. Do not move the import into a function, behind TYPE_CHECKING or into importlib; Inwards checks those too.
    2. Declare a typing.Protocol in `shop.domain` (for example `shop.domain.ports`) that describes only what this module needs from "sqlalchemy".
    3. Type this module against that Protocol and receive the implementation through a constructor or function parameter.
    4. Implement the Protocol with "sqlalchemy" in the outer layer that holds adapters (allowed: "application", "infrastructure"), and wire it in the outermost layer (the composition root).
    5. If "domain" should be allowed to use "sqlalchemy", ask the user to add "sqlalchemy" to that layer's allow-libraries in [tool.inwards]. Don't edit [tool.inwards] yourself.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW005/

Po poprawce domena deklaruje, czego potrzebuje, a infrastruktura implementuje to z użyciem SQLAlchemy.

shop/domain/ports.py
from typing import Protocol


class Orders(Protocol):
    def total(self, order_id: int) -> int: ...
shop/domain/order.py
from shop.domain.ports import Orders


def total(orders: Orders, order_id: int) -> int:
    return orders.total(order_id)
shop/infrastructure/db.py
from sqlalchemy.orm import Session


class SqlOrders:
    def __init__(self, session: Session) -> None:
        self.session = session

    def total(self, order_id: int) -> int:
        return 0
inwards check

Jak naprawić

  1. Usuń import. Przeniesienie go do funkcji, za TYPE_CHECKING albo do importlib tylko przenosi diagnostykę.
  2. Zadeklaruj w warstwie typing.Protocol, który opisuje tylko to, czego moduł potrzebuje od biblioteki.
  3. Otypuj moduł względem tego Protocolu i przyjmuj implementację jako parametr.
  4. Zaimplementuj Protocol z użyciem biblioteki w warstwie zewnętrznej, która może jej używać. Poprawka wymienia wszystkie takie warstwy; wybierz tę z adapterami, która w układzie heksagonalnym nie zawsze jest następną warstwą na zewnątrz.
  5. Jeśli warstwa powinna móc używać biblioteki, decyduje o tym użytkownik: dopisuje ją do allow-libraries. Gdy żadna warstwa zewnętrzna też nie może jej używać, poprawka każe agentowi zapytać użytkownika, gdzie biblioteka powinna trafić.

Pakiety workspace'u

W workspace (uv) z osobną konfiguracją dla każdego członka pakiet sąsiedniego członka leży poza root konfiguracji, więc INW005 sprawdza go jak bibliotekę. Gdy pakiet najwyższego poziomu importu należy do członka najbliższego [tool.uv.workspace] w katalogu konfiguracji lub wyżej, komunikat mówi "workspace package" zamiast "library": to własny kod zespołu, a nie zależność do usunięcia.

src/qv_datasources/core/x.py:1:8: INW005 Layer "core" imports "qv_core.models" from workspace package "qv_core", which "core" may not use.
  fix: "core" may not use workspace package "qv_core": use a package it may use, or ask the user to allow "qv_core".

Poprawka nie każe pisać portu: użyj pakietu albo członka workspace'u, którego warstwa może używać, albo poproś użytkownika o dopisanie pakietu do allow-libraries, które obejmuje też pakiety workspace'u. Pakiety członka to jego [project] name zamieniona na nazwę importu, katalogi w jego src/ oraz, bez src/, katalogi obok jego pyproject.toml zawierające __init__.py. Wzorce członków dopasowują * tylko w obrębie jednego segmentu ścieżki (packages/*); ** nie jest obsługiwane. Rozszerzenie VS Code jeszcze nie szuka workspace'u i nadal mówi "library".

Bezpieczeństwo poprawki

Brak automatycznej poprawki (autofix: false). Przeniesienie biblioteki za port zmienia projekt kodu i wymaga przeglądu.

Konfiguracja

Poziom: błąd. Dla każdej warstwy, w [tool.inwards]:

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain",         modules = ["shop.domain"], allow-libraries = ["attrs"], extend-deny-libraries = ["os"] },
  { name = "application",    modules = ["shop.application"], deny-libraries = ["sqlalchemy", "requests"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]

Żeby zakazać biblioteki części warstwy albo pakietowi, który nie należy do żadnej warstwy:

[tool.inwards.rules.pure-domain]
deny = [{ modules = ["shop.domain.pricing"], libraries = ["numpy"] }]

Wpisy to nazwy importu (yaml, nie PyYAML), które obejmują też podmoduły. Gdy pasują i lista dozwolonych, i lista zakazów, wygrywa dłuższy wpis, a przy remisie allow. deny-libraries = [] w najbardziej wewnętrznej warstwie wyłącza domyślną listę. [tool.inwards.rules] może wyłączyć regułę albo zmienić ją w ostrzeżenie (severity = { INW005 = "warning" }), zobacz Konfiguracja reguł. Jeden import można wyciszyć w jego linii przez # inwards: ignore[INW005] reason="...".

Znane ograniczenia

  • Nazwy dystrybucji różne od nazwy importu nie są mapowane: wpisz yaml, nie PyYAML.
  • Lista biblioteki standardowej jest ustalana przy budowaniu, więc moduł dodany w późniejszym CPythonie liczy się jako zewnętrzny, dopóki Inwards nie zaktualizuje listy.
  • Włączenie reguły w istniejącej konfiguracji z co najmniej dwiema warstwami może zgłosić biblioteki, których domena używa celowo. Akceptuje je deny-libraries = [], wpis w allow-libraries albo inwards baseline.

Źródła