Przejdź do treści

INW021 private-module

Co robi

Opt-in, a po włączeniu błąd. Własny moduł, którego nazwa poniżej pakietu najwyższego poziomu zaczyna się od jednego podkreślenia, jest prywatny dla pakietu, który go zawiera: app.users.services._user należy do app.users.services, a wszystko pod app.users._internal należy do app.users. INW021 zgłasza import takiego modułu z każdego modułu spoza tego pakietu. __init__.py pakietu, jego pozostałe moduły i jego podpakiety mogą go importować.

Nazwy dunder (__init__, __main__) nie są prywatne. Sam pakiet najwyższego poziomu nie jest brany pod uwagę, więc _vendor.x jest poza zakresem reguły.

Liczą się te same importy co w INW003:

  • import app.users.services._user, from app.users.services._user import UserService i from app.users.services import _user (gdy _user jest modułem);
  • importy względne i z gwiazdką, importy wewnątrz funkcji i importy pod if TYPE_CHECKING:, które nadal wiążą moduł importujący z prywatną ścieżką;
  • wywołanie importlib.import_module("...") z nazwą w literale.

Import, który INW003 zgłasza już w tej samej linii, nie jest zgłaszany drugi raz.

Dlaczego to źle

PEP 8 oznacza moduły wewnętrzne, tak jak wewnętrzne funkcje, jednym podkreśleniem na początku nazwy. Pakiet, który trzyma implementację w _user.py i reeksportuje nazwy publiczne z __init__.py, może przenieść, podzielić albo przemianować _user.py bez uprzedzania kogokolwiek. Kod, który importuje _user.py bezpośrednio, łamie tę obietnicę, a następny agent kopiuje ten import.

Reguła Ruffa PLC2701 import-private-name nie obejmuje tego przypadku. Jest w trybie preview i pomija każdy import, którego pakiet najwyższego poziomu jest pakietem pliku importującego, więc w obrębie jednego projektu from app.users.services._user import X w app.orders nigdy nie jest zgłaszany.

Przykład

pyproject.toml
[tool.inwards]
layers = [{ name = "store", modules = ["store"] }]

[tool.inwards.rules]
extend-select = ["INW021"]
store/users/services/_user.py
class UserService:
    def name(self, user_id: int) -> str:
        return f"user {user_id}"
store/users/services/__init__.py
"""User services: other packages import what this module re-exports."""
store/orders/api.py
from store.users.services._user import UserService


def greet(user_id: int) -> str:
    return UserService().name(user_id)
inwards check store
store/orders/api.py:1:40: INW021 `store.orders.api` imports `store.users.services._user`, a private module of `store.users.services`. A module whose name starts with `_` is for its own package; other code imports what the package's `__init__.py` re-exports.
  fix: Import `UserService` from `store.users.services`, not from its private module `store.users.services._user`.
    1. In `from store.users.services._user import UserService`, import `UserService` from `store.users.services` instead.
    2. If `store.users.services`'s `__init__.py` doesn't re-export it yet, add `from ._user import UserService` there (and to `__all__` if the package keeps one).
    3. If `store.users.services._user` is meant for other packages, ask the user whether to rename it without the leading underscore instead.

Po poprawce: pakiet reeksportuje klasę, a moduł wywołujący importuje ją z pakietu.

store/users/services/__init__.py
from ._user import UserService

__all__ = ["UserService"]
store/orders/api.py
from store.users.services import UserService


def greet(user_id: int) -> str:
    return UserService().name(user_id)
inwards check store

Jak działa

INW021 sprawdza każdy moduł, dla którego reguła jest włączona (modules, i nie moduł testowy, chyba że check-tests = true), niezależnie od tego, czy należy do warstwy, więc sprawdza też skrypty. Czyta te same importy co pozostałe reguły importów: linie importów ze wstępnego skanu albo pełne parsowanie, gdy plik go wymaga. Każdy import jest rozwiązywany do własnego modułu, do którego trafia, przez indeks modułów projektu; import modułu, który nie istnieje, należy do INW010, a moduł zewnętrzny nigdy nie jest sprawdzany.

Pakiet, dla którego moduł jest prywatny, to rodzic jego najgłębszego prywatnego segmentu: app._internal.services._user jest prywatny dla app._internal.services, a moduł wewnątrz tego pakietu jest też wewnątrz app.

Jak naprawić

  1. Importuj nazwę z pakietu (from app.users.services import UserService), nie z jego modułu prywatnego.
  2. Jeśli __init__.py pakietu jeszcze jej nie reeksportuje, dodaj tam from ._user import UserService, a także do __all__, jeśli pakiet je prowadzi.
  3. Jeśli moduł ma służyć innym pakietom, usuń podkreślenie z jego nazwy.

Bezpieczeństwo poprawki

Bez automatycznej poprawki (autofix: false). Reeksport nazwy zmienia __init__.py innego pakietu, a zmiana nazwy modułu zmienia każdy jego import.

Konfiguracja

Ważność: błąd. Opt-in: włączasz ją przez extend-select. Preset vertical-slices polecenia inwards init włącza INW021 jako ostrzeżenie, bo commands/ i queries/ z jego scaffoldu reeksportują moduły prywatne ze swoich __init__.py. Opcje trafiają do [tool.inwards.rules.private-module] (tabele opcji):

pyproject.toml
[tool.inwards.rules]
extend-select = ["INW021"]

[tool.inwards.rules.private-module]
modules = ["app"]  # optional: only report in these modules
check-tests = true # optional: report in test modules too
  • modules: jak w każdej regule, moduły, w których INW021 zgłasza, czyli moduły importujące, nie moduły prywatne.
  • check-tests: true sprawia, że INW021 zgłasza też w modułach testowych. Domyślnie je pomija, bo testy często sięgają do modułów, które testują.

Pojedynczy import można wyciszyć z powodem w jego linii: # inwards: ignore[INW021] reason="...".

Znane ograniczenia

  • Nazwy prywatne nie są objęte. from app.users.services import _helper, gdy _helper jest funkcją, należy do Ruffa (PLC2701 zgłasza to między pakietami najwyższego poziomu); Inwards nie czyta nazw modułu docelowego.
  • Prywatne moduły zewnętrzne też należą do Ruffa.
  • Moduł prywatny w pakiecie przestrzeni nazw bez __init__.py nie ma skąd być reeksportowany; dodaj pakietowi __init__.py albo zmień nazwę modułu.
  • Pomiar na korpusie (ADR-046): moduły prywatne ma tylko polar. Dostaje 10 zgłoszeń przy pominiętych testach (8 w kodzie źródłowym, 2 w skryptach), jedno na instrukcję importu. Z check-tests = true dostaje 67: 57 w testach, wiele z nich to importy prywatnych modułów workera wewnątrz funkcji, między 41 parami modułu importującego i modułu prywatnego. Pozostałe cztery repozytoria nie dostają żadnego.

Źródła