Przejdź do treści

INW011 dynamic-import

Co robi

Zgłasza import dynamiczny, w pliku w warstwie, który sięga do warstwy zewnętrznej: importlib.import_module, importlib.util.find_spec, __import__ (także jako builtins.__import__ i importlib.__import__), runpy.run_module, pkgutil.resolve_name, loadery plików importlib.util.spec_from_file_location i importlib.machinery.SourceFileLoader oraz instrukcje importu wewnątrz dosłownego kodu przekazanego do exec, eval albo compile. Ścieżka loadera plików liczy się, gdy jest względną ścieżką do pliku .py, czytaną od korzenia projektu.

Loadery są śledzone przez aliasy importu (from importlib import import_module as im), przypisania (zwykłe, łańcuchowe, krotek, z gwiazdką i operator morsa), atrybuty klasy i instancji (load = importlib.import_module w ciele klasy, self.load = ...), functools.partial(loader, ...), nazwy związane wewnątrz dosłownego kodu exec, getattr(m, "name"), m.__dict__["name"], vars(m)["name"], sys.modules["importlib"], globals()["name"] i __globals__ funkcji oraz __self__ funkcji wbudowanej (print.__self__.exec).

Stały cel jest zwijany do wartości: literały połączone +, % z %s, * przez liczbę całkowitą, str.join, str.format, wycinki, f-stringi, których pola są stałymi (z !s i tekstową specyfikacją formatu, taką jak :>4), oraz nazwa na poziomie modułu przypisana stałej dokładnie raz (TARGET = "shop.infrastructure.db").

W każdej warstwie poza najbardziej zewnętrzną wywołanie, którego celu Inwards nie umie odczytać, jest zgłaszane jako niesprawdzalne: zmienna, pole f-stringa, escape \N{...}, argument za *args albo **kwargs, względny import_module, którego package nie jest znany, albo exec lub eval z niedosłownym kodem. Najbardziej zewnętrzna warstwa może sięgać do każdej, więc nie ma tam czego sprawdzać, i to tam należą loadery wtyczek i korzenie kompozycji. Bajty przekazane do exec albo compile z zadeklarowanym kodowaniem, którego Inwards nie umie odczytać, są zgłaszane jako niesprawdzone, w każdej warstwie.

Dlaczego to źle

Gdy INW001 wyłapuje importy w funkcjach i za TYPE_CHECKING, następnym najtańszym obejściem jest wywołanie: importlib.import_module("shop.infrastructure.db") to ta sama zależność, tylko bez instrukcji importu, którą checker mógłby zobaczyć. Gdy i to zostanie zablokowane, następnym krokiem jest wstawienie nazwy modułu do zmiennej. INW011 zgłasza oba przypadki, a poprawka wskazuje loader jako problem, bo składanie nazwy w czasie działania albo zmiana loadera tylko ukrywa zależność (ADR-015, ADR-026).

Przykład

Z warstwami z INW001 domena ładuje infrastrukturę w czasie działania:

shop/domain/order.py
import importlib


def repository():
    return importlib.import_module("shop.infrastructure.db")
inwards check
shop/domain/order.py:5:12: INW011 Layer "domain" imports "shop.infrastructure.db" from outer layer "infrastructure" through a dynamic import (importlib.import_module). Allowed direction: domain <- application <- infrastructure.
  fix: Remove the dynamic import and depend on an abstraction owned by "domain" instead of "shop.infrastructure.db".
    1. Delete `importlib.import_module("shop.infrastructure.db")`. A dynamic import is still a dependency: building the module name at runtime or moving it to another loader hides it instead of removing it.
    2. Declare a typing.Protocol in `shop.domain` (for example `shop.domain.ports`) that describes only what this module needs from `db`.
    3. Type this module against that Protocol and receive the implementation through a constructor or function parameter.
    4. Make the class in "infrastructure" satisfy the Protocol, and wire it in the outermost layer (the composition root).
  docs: https://sircypkowskyy.github.io/inwards/rules/INW011/

Z nazwą modułu w zmiennej celu nie da się sprawdzić:

shop/domain/order.py
import importlib


def load(name: str):
    return importlib.import_module(name)
inwards check
shop/domain/order.py:5:12: INW011 Layer "domain" makes a dynamic import (importlib.import_module) with an argument Inwards can't read, such as a variable, an f-string field or *args, so Inwards can't verify that it points toward inner layers.
  fix: Name the target with string literals, or move the dynamic import to the outermost layer "infrastructure".
    1. If the module is fixed, replace `importlib.import_module(name)` with an import statement, or pass the loader only string literals (no variables, f-string fields, \N{...} escapes or *args) so Inwards can check it.
    2. If the module is chosen at runtime (plugins, settings), move the loader to the outermost layer "infrastructure" (the composition root) and pass what it loads into this module as a parameter.
    3. Type that parameter against a typing.Protocol declared in `shop.domain` (for example `shop.domain.ports`).
  docs: https://sircypkowskyy.github.io/inwards/rules/INW011/

Po poprawce loader trafia do najbardziej zewnętrznej warstwy, a domena przyjmuje to, co on ładuje, jako parametr otypowany jej własnym portem.

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/plugins.py
import importlib


def load(name: str):
    return importlib.import_module(name)
inwards check

Jak naprawić

  1. Jeśli moduł jest stały, zastąp wywołanie instrukcją importu albo przekazuj loaderowi tylko literały napisów, żeby Inwards mógł go sprawdzić. Instrukcja importu wskazująca na zewnątrz staje się wtedy diagnostyką INW001, ze zwykłą poprawką: portem w warstwie wewnętrznej.
  2. Jeśli moduł jest wybierany w czasie działania (wtyczki, ustawienia), przenieś loader do najbardziej zewnętrznej warstwy i przekazuj to, co ładuje, do modułu wewnętrznego jako parametr.
  3. Otypuj ten parametr względem typing.Protocol zadeklarowanego w warstwie wewnętrznej.

Bezpieczeństwo poprawki

Brak automatycznej poprawki (autofix: false). Zastąpienie loadera działającego w czasie wykonania zmienia sposób łączenia programu, co wymaga przeglądu.

Konfiguracja

Poziom: błąd, także dla niesprawdzalnych celów: ostrzeżenie przeszłoby przez hook i Stop gate, a obejście by się udało. [tool.inwards.rules] może wyłączyć regułę albo zmienić ją w ostrzeżenie (Konfiguracja reguł). Przejrzany loader można wyciszyć w pierwszej linii wywołania:

importlib.import_module(  # inwards: ignore[INW011] reason="plugin loader, reviewed in #230"
    "shop.infrastructure.plugins",
)

Znane ograniczenia

  • Nazwa na poziomie modułu jest stałą tylko wtedy, gdy cały plik wiąże ją raz, zwykłym przypisaniem w ciele modułu, a gdzie indziej tylko ją odczytuje. Plik, który wywołuje exec albo eval, zapisuje przestrzeń nazw (globals(), setattr, sys.modules) albo ma import z gwiazdką, nie odczytuje żadnych stałych, bo każda z tych rzeczy może ponownie związać nazwę, więc jego wywołania loaderów z nazwanym celem pozostają niesprawdzalne. Dotyczy to też exec(CODE) ze stałą CODE.
  • Zwijanie zatrzymuje się na dyrektywach % innych niż %s, liczbowych specyfikacjach formatu, !r, !a i polach format z atrybutem albo indeksem ({0.name}); taki cel jest niesprawdzalny.
  • Zakresy są pomijane. Nazwa atrybutu, której gdziekolwiek w pliku przypisano loader, liczy się na każdym obiekcie, więc po self.load = importlib.import_module wywołanie other.load(name) w tym samym pliku jest zgłaszane w warstwie wewnętrznej jako niesprawdzalne.
  • functools.partial jest czytane tam, gdzie wiąże argumenty. Partial, który wiąże tylko część z nich (package= bez nazwy), jest tam niesprawdzalny, a jego późniejsze wywołania nie są czytane.
  • Loader plików ze ścieżką bezwzględną, względną do katalogu nadrzędnego albo wyliczaną jest niesprawdzalny, a SourcelessFileLoader, ExtensionFileLoader i importlib.util.spec_from_loader nie są czytane.
  • Loader zapisany w kontenerze (loaders["x"] = importlib.import_module), osiągnięty przez operator.attrgetter albo przekazany jako argument jest pomijany.
  • W najbardziej zewnętrznej warstwie niesprawdzalne wywołanie nie jest zgłaszane, więc może niezauważone sięgnąć do własnego kodu spoza wszystkich warstw (INW006).
  • exec, eval, compile i __import__ z dosłownym kodem zawsze liczą się jako wbudowane, więc po from re import compile wywołanie compile("from shop.infrastructure import x") jest zgłaszane. Udoskonalenie mogłoby uwzględniać przesłonięcie tylko przez def, class na poziomie modułu i importy z modułów zewnętrznych; pierwsza próba przeoczyła prawdziwe importy, takie jak from shop.compat import exec, który ponownie eksportuje funkcję wbudowaną.
  • Bajty przekazane do exec albo compile, które CPython odrzuca przed wykonaniem (UTF-8 BOM z innym zadeklarowanym kodowaniem albo deklaracja utf-16 lub rot13), są nadal czytane albo zgłaszane jako niesprawdzone. Oba przypadki to fałszywie pozytywne wyniki.
  • Samo exec albo eval z wyliczanym kodem jest pomijane tylko wtedy, gdy kod na pewno przesłania tę nazwę w miejscu wywołania, więc def eval(model, loader) w kodzie treningowym nie jest zgłaszane. Funkcja wbudowana przekazana jako argument jest pomijana, a niektóre przesłonięcia (zmienna w wyrażeniu listowym, przechwycenie w match) są nadal zgłaszane.

Pełną listę podaje sekcja Znane ograniczenia w rozdziale 3.

Źródła