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:
import importlib
def repository():
return importlib.import_module("shop.infrastructure.db")
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: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.
from typing import Protocol
class Orders(Protocol):
def total(self, order_id: int) -> int: ...
from shop.domain.ports import Orders
def total(orders: Orders, order_id: int) -> int:
return orders.total(order_id)
import importlib
def load(name: str):
return importlib.import_module(name)
Jak naprawić¶
- 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.
- 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.
- Otypuj ten parametr względem
typing.Protocolzadeklarowanego 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
execalboeval, 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,!ai polachformatz 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_modulewywołanieother.load(name)w tym samym pliku jest zgłaszane w warstwie wewnętrznej jako niesprawdzalne. functools.partialjest 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,ExtensionFileLoaderiimportlib.util.spec_from_loadernie są czytane. - Loader zapisany w kontenerze (
loaders["x"] = importlib.import_module), osiągnięty przezoperator.attrgetteralbo 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,compilei__import__z dosłownym kodem zawsze liczą się jako wbudowane, więc pofrom re import compilewywołaniecompile("from shop.infrastructure import x")jest zgłaszane. Udoskonalenie mogłoby uwzględniać przesłonięcie tylko przezdef,classna poziomie modułu i importy z modułów zewnętrznych; pierwsza próba przeoczyła prawdziwe importy, takie jakfrom shop.compat import exec, który ponownie eksportuje funkcję wbudowaną.- Bajty przekazane do
execalbocompile, które CPython odrzuca przed wykonaniem (UTF-8 BOM z innym zadeklarowanym kodowaniem albo deklaracjautf-16lubrot13), są nadal czytane albo zgłaszane jako niesprawdzone. Oba przypadki to fałszywie pozytywne wyniki. - Samo
execalboevalz wyliczanym kodem jest pomijane tylko wtedy, gdy kod na pewno przesłania tę nazwę w miejscu wywołania, więcdef 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 wmatch) są nadal zgłaszane.
Pełną listę podaje sekcja Znane ograniczenia w rozdziale 3.
Źródła¶
- ADR-015: Sprawdzaj dosłowne importy dynamiczne jako INW011
- ADR-026: Zgłaszaj nieczytelne cele importów dynamicznych w warstwach wewnętrznych