Przejdź do treści

INW004 import-cycles

Co robi

Zgłasza moduły, które importują się nawzajem w cyklu, i konteksty, które to robią. cycles wybiera, które:

  • "contexts", domyślnie: cykl między kontekstami, złożony z importów z jednego kontekstu do innego. depends-on może pozwalać na oba kierunki, a INW004 i tak go zgłasza. Bez kontekstów niczego nie znajduje.
  • "modules": cykl między modułami, na przykład order importuje pricing, pricing importuje discounts, a discounts importuje order.
  • [] wyłącza regułę.

Każda silnie spójna grupa modułów albo kontekstów jest zgłaszana raz: najkrótszy cykl przez jej pierwszy element, z całą ścieżką, przy imporcie, który robi pierwszy krok, razem z liczbą elementów i powiązań w grupie oraz skrótem (hashem) tych powiązań. Poprawka wymienia import stojący za każdym krokiem.

Liczą się importy w funkcjach, a także importy dynamiczne, których cel Inwards potrafi odczytać. Import w treści if TYPE_CHECKING: albo elif TYPE_CHECKING: (także typing.TYPE_CHECKING) się nie liczy: TYPE_CHECKING ma w czasie działania wartość False, więc ten import nigdy się nie wykonuje, a tak właśnie kod w Pythonie zachowuje podpowiedź typu bez cyklu w czasie działania. Gałąź else takiego bloku się wykonuje, więc import w niej się liczy, tak samo jak import pod if not TYPE_CHECKING: albo pod warunkiem w rodzaju TYPE_CHECKING and DEBUG. Pozostałe reguły nadal sprawdzają importy tylko dla typów: taki import do zewnętrznej warstwy to nadal INW001. Udział biorą tylko pliki, które sprawdzenie parsuje: te w warstwie, a gdy zadeklarowano konteksty, każdy plik.

Cykl wymaga całego projektu, więc INW004 działa tylko przy sprawdzaniu całego projektu: inwards check bez ścieżek, inwards baseline i bramka Stop z stop-gate = "project". Hook po każdej edycji i edytor sprawdzają jeden plik i nigdy go nie zgłaszają.

Dlaczego to źle

Modułów w cyklu nie da się zrozumieć, przetestować ani przenieść pojedynczo; zmiana jednego może zepsuć pozostałe. Python może nawet zawieść przy imporcie, zależnie od tego, który moduł zostanie zaimportowany pierwszy. Agent dodaje import, którego potrzebuje, tam, gdzie go potrzebuje, a do zamknięcia cyklu brakuje jednego importu. Między kontekstami cykl oznacza, że żaden z nich nie jest już niezależny, cokolwiek mówi depends-on.

Przykład

Z warstwami z INW001 i włączonymi cyklami modułów:

pyproject.toml
[tool.inwards]
cycles = ["modules"]
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]
shop/domain/order.py
from shop.domain.pricing import price


class Order:
    pass
shop/domain/pricing.py
from shop.domain.discounts import discount


def price(order_id: int) -> int:
    return 100 - discount(order_id)
shop/domain/discounts.py
from shop.domain.order import Order


def discount(order_id: int) -> int:
    return 0
inwards check
shop/domain/discounts.py:1:31: INW004 Modules import each other in a cycle: shop.domain.discounts -> shop.domain.order -> shop.domain.pricing -> shop.domain.discounts. The group holds 3 modules and 3 links between them (link hash 2c11a00a0c2cf439).
  fix: Break the cycle: one of these modules must stop importing the next.
    1. The imports that make the cycle: `from shop.domain.order import Order` in shop.domain.discounts; `from shop.domain.pricing import price` in shop.domain.order; `from shop.domain.discounts import discount` in shop.domain.pricing.
    2. Pick the import that points the wrong way, usually from the lower-level module back up, and remove it.
    3. Move what both modules need into a third module they can both import, or define a Protocol in the lower-level module and pass the implementation in.
    4. If a module needs the other only for type annotations, move that import under `if TYPE_CHECKING:` (before Python 3.14, also quote those annotations or add `from __future__ import annotations`); Inwards doesn't count it, since it never runs. Don't do that for a name the code uses at runtime, and don't move the import into a function: Inwards counts that one.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW004/

Po poprawce: discounts nigdy nie potrzebował Order.

shop/domain/discounts.py
def discount(order_id: int) -> int:
    return 0
inwards check

Jak naprawić

  1. Przeczytaj ścieżkę: każdy krok to import, a poprawka je wymienia.
  2. Usuń import, który wskazuje w złą stronę, zwykle z modułu niższego poziomu z powrotem do tego, który go używa.
  3. Jeśli oba moduły potrzebują tego samego kodu, przenieś go do trzeciego modułu, który oba mogą importować. Jeśli moduł niższego poziomu potrzebuje zachowania z wyższego, daj mu typing.Protocol i przekaż implementację.
  4. Jeśli jeden moduł potrzebuje drugiego tylko w adnotacjach typów, zaimportuj go pod if TYPE_CHECKING:. Przed Pythonem 3.14 adnotacje, które go używają, muszą też być napisami albo moduł zaczyna się od from __future__ import annotations. Nigdy nie rób tego z nazwą, której kod używa w czasie działania: Python zgłosi NameError, gdy do niej dojdzie.
  5. Między kontekstami zdecyduj, który zależy od którego, i usuń importy idące w drugą stronę. Jeśli depends-on pozwala na usunięty kierunek, poproś użytkownika o jego usunięcie; hooki nie pozwalają agentowi edytować [tool.inwards].

Bezpieczeństwo poprawki

Bez automatycznej poprawki (autofix: false). Który import jest zły, to decyzja projektowa.

Konfiguracja

Poziom: błąd. cycles w [tool.inwards] wybiera rodzaje cykli, domyślnie ["contexts"]. Każdy preset inwards init --style zapisuje cycles = ["modules", "contexts"], więc projekt założony przez init dostaje też cykle modułów. [tool.inwards.rules] może regułę wyłączyć albo zmienić w ostrzeżenie (Konfiguracja reguł).

INW004 nie da się wyciszyć w linii: sprawdzenie jednego pliku nie wie, czy wyciszony cykl nadal istnieje, a komentarz siedziałby przy jednym imporcie z wielu. Żeby przyjąć cykle, które kod już ma, uruchom inwards baseline. Nowa grupa i tak nie przejdzie, podobnie jak przyjęta grupa, której powiązania zmienią się w jakikolwiek sposób, bo komunikat podaje ich liczbę i ich skrót. Dotyczy to także grupy, która straciła powiązanie albo element: uruchom inwards baseline ponownie, żeby przyjąć mniejszą grupę.

Znane ograniczenia

  • Pliki poza wszystkimi warstwami biorą udział dopiero wtedy, gdy zadeklarowano konteksty; wcześniej sprawdzenie ich nie parsuje, więc cykl przez nie nie jest widoczny.
  • Import shop.domain.order uruchamia też shop/domain/__init__.py. Ten niejawny krok nie jest krawędzią, więc cykl, który zamyka się tylko przez __init__ pakietu, nie jest zgłaszany.
  • Jeden cykl na grupę: usunięcie go może przy następnym uruchomieniu odsłonić kolejny w tej samej grupie.
  • Żadne ustawienie nie przywraca importów spod TYPE_CHECKING do grafu. Cykl, za którym idzie tylko narzędzie do sprawdzania typów, nie wywraca się przy imporcie, a reguły warstw i kontekstów nadal widzą taki import.
  • Inwards patrzy na nazwę w warunku. Strażnik zapisany inaczej liczy się jak kod wykonywany w czasie działania (if TC: po from typing import TYPE_CHECKING as TC), a projekt, który sam ustawia TYPE_CHECKING = True, ukryłby prawdziwy cykl.

Źródła

  • ADR-032: dlaczego cykle są sprawdzane tylko dla całego projektu, jak powstaje graf, dlaczego importy spod TYPE_CHECKING nie dają krawędzi i skąd wartość domyślna.
  • INW002: które konteksty mogą importować które.