Kształt pakietu¶
Reguły warstw (INW001) patrzą na importy. Nie widzą, gdzie kod leży: nowy helpers.py obok service.py, pakiet services/ obok service.py albo test_orders.py wewnątrz aplikacji przechodzą je bez problemu. Kształt pakietu zamyka tę lukę. Mówisz, jakie elementy pakiet może, musi i nie może zawierać, a Inwards sprawdza to przy każdej edycji (hook), w edytorze (inwards server) i w CI (inwards check).
| Reguła | Zgłasza | Gdzie |
|---|---|---|
INW007 package-shape |
element, na który kształt nie pozwala (extra, domyślnie błąd) albo którego zabrania; nazwę poza jej pakietami only-in; selektor kształtu, który nie pasuje do żadnego pakietu (ostrzeżenie) |
w pliku, w linii 1; martwy selektor w pyproject.toml |
INW008 missing-member |
brakuje elementu, którego wymaga kształt | w __init__.py pakietu albo w jego pierwszym pliku, jeśli __init__.py nie ma |
Konfiguracja¶
[[tool.inwards.shape]]
packages = ["app.*"] # which packages: selectors, see below
allow = ["router", "schemas", "utils"] # what else they may hold
require = ["__init__", "router", "service"]
forbid = ["conftest"]
extra = "error" # or "warning" for members allow doesn't cover
[[tool.inwards.names]]
pattern = "test_*"
only-in = ["tests", "tests.**"]
- Selektory (
packages,only-in) dopasowują pakiety, w gramatyce import-lintera:a.bto dokładne dopasowanie,a.*to jeden segment poniżeja, aa.**to dowolna głębokość poniżeja(bez samegoa). Nową domenę, taką jakapp/payments/,app.*obejmuje w chwili, gdy powstaje. - Wygrywa pierwszy pasujący wpis. Dokładne wpisy umieszczaj przed globami, które też by do nich pasowały. Wpis, którego selektor jest już pokryty przez wcześniejszy, nigdy nie mógłby zadziałać, więc jest błędem konfiguracji:
app.orderspoapp.*, powtórzony selektor alboapp.*poapp.**. Glob, który tylko częściowo pokrywa się z wcześniejszym (app.**poapp.*), jest w porządku. Segmenty selektora to identyfikatory Pythona,*albo**. - Wzorce elementów to globy fnmatch (
*,?,[seq],[!seq]), dopasowywane tak jak w Pythonowymfnmatch.fnmatchcase: wewnątrz[...]znaki*i?są dosłowne. Niezamknięty[albo odwrócony zakres, taki jak[z-a], to błąd konfiguracji.nameto moduł albo podpakiet,name/tylko podpakiet,name.pytylko moduł (zaślepki.pyiliczą się jako moduły).__init__jest zawsze dozwolony, podobnie jak wszystko zrequire. Bezallowkażdy element jest dozwolony i działają tylkorequireorazforbid. - Kształt obejmuje bezpośrednie elementy pakietu.
app/orders/services/x.pyjest elementemservices/pakietuapp.orders; pliki wewnątrzservices/podlegają kształtowi dlaapp.orders.services, jeśli taki istnieje. Elementy są nazywane według systemu plików:utils.helpers.pyto jeden element-moduł, a nieutils/, więc nie pasuje ani doutils, ani dohelpers. Ukryte pliki i katalogi są pomijane, tak jak pomija je każde przeglądanie plików w Inwards. - Reguły nazw działają na każdym poziomie: element pasujący do
patternw dowolnym miejscu poza pakietamionly-into błąd INW007.
Nieznane klucze, źle zbudowane selektory albo wzorce oraz przesłonięte wpisy to błędy konfiguracji (kod wyjścia 2). Config guard traktuje shape i names tak jak resztę [tool.inwards]: edycja ich przez agenta jest odrzucana.
Co widzi agent¶
Komunikat podaje element i pakiet, nigdy listę dozwolonych elementów, więc wpis w baseline'ie przetrwa zmianę allow. Kroki naprawy wypisują dozwolone elementy i wskazują ten, do którego kod najpewniej należy, znaleziony przez wbudowane synonimy (helpers → utils, services → service, test_* → tests), dopasowanie końcówki (order_service → service) albo małą odległość edycyjną (rooter → router; 2 edycje dla nazw od 6 znaków, 1 dla nazw od 3 do 5, zero poniżej, więc db.py nie jest odsyłany do di.py):
app/orders/helpers.py:1:1: INW007 "helpers.py" is not an allowed member of package "app.orders".
fix: Move the code in "helpers.py" into utils.py.
1. Move the code into app/orders/utils.py and delete helpers.py.
2. Package "app.orders" may hold: router, schemas, models, service, dependencies, config, constants, exceptions, utils.
- Przy każdej edycji (PostToolUse). INW007 blokuje (kod wyjścia 2), gdy edytowany plik jest nowy w tej sesji. Dla pliku, który istniał już na początku sesji, wraca tylko jako kontekst, więc stary układ nigdy nie blokuje niezwiązanej edycji. INW008 dla edytowanego pakietu jest zawsze kontekstem: agent, który tworzy
app/payments/router.py, słyszy, że wciąż brakujeservice.py, i może go dodać w następnym kroku. - Stop gate. Wyniki INW008, które są nowe od początku sesji, blokują turę, więc usunięcie wymaganego
service.pyprzez Bash zostaje wyłapane. Pakiet, któremu tego pliku brakowało już na początku sesji, nie blokuje. INW007 w plikach starszych niż sesja też nie blokuje. - Edytor.
inwards serverzgłasza INW007 w pliku, w którym piszesz, na bieżąco. Jego przebieg całego projektu, czyliinwards checkw każdym folderze obszaru roboczego, umieszcza INW007 w plikach, których nie otworzyłeś, i INW008 w__init__.pypakietu; rusza, gdy edytor uruchamia serwer, przy każdym zapisie i raz na serię zdarzeń, gdy pliki powstają albo znikają. - CI. Sprawdzenie całego projektu przez
inwards checkzgłasza INW007 w każdym pliku, INW008 dla każdego pakietu z kształtem i ostrzeżenie dla selektora, który nie pasuje do żadnego pakietu.
Przykład: fastapi-best-practices¶
Układ fastapi-best-practices daje każdej domenie te same moduły: router, schemas, models, service, dependencies, config, constants, exceptions i utils. Przewodnik nazywa swój główny pakiet src; tutaj nazywa się app. Testy leżą w osobnym pakiecie tests/ na najwyższym poziomie.
[tool.inwards]
ignore = ["tests"]
layers = [{ name = "app", modules = ["app"] }]
# An integration package: a client, no routes. Exact entries come before the
# glob that would also match them.
[[tool.inwards.shape]]
packages = ["app.aws"]
allow = ["client", "schemas", "config", "constants", "exceptions", "utils"]
require = ["__init__", "client"]
# Every domain package, including the ones added later.
[[tool.inwards.shape]]
packages = ["app.*"]
allow = [
"router", "schemas", "models", "service", "dependencies",
"config", "constants", "exceptions", "utils",
]
require = ["__init__", "router", "service"]
# Tests live under tests/, never next to the code.
[[tool.inwards.names]]
pattern = "test_*"
only-in = ["tests", "tests.**"]
Przykład: czysta architektura¶
Drzewo czystej architektury dzieli kod na domenę (model/), aplikację (ports/ i use_cases/) oraz infrastrukturę (adapters/ i korzeń kompozycji di.py). Każdy pakiet warstwy dostaje dokładny kształt; puste allow oznacza tylko wymagane elementy i __init__.
[tool.inwards]
ignore = ["tests"]
layers = [
{ name = "domain", modules = ["shop.domain"] },
{ name = "application", modules = ["shop.application"] },
{ name = "infrastructure", modules = ["shop.infrastructure"] },
{ name = "main", modules = ["shop.main"] },
]
[[tool.inwards.shape]]
packages = ["shop"]
allow = ["domain/", "application/", "infrastructure/", "main.py"]
[[tool.inwards.shape]]
packages = ["shop.domain"]
require = ["__init__", "model/"]
allow = []
[[tool.inwards.shape]]
packages = ["shop.application"]
require = ["__init__", "ports/", "use_cases/"]
allow = []
# The composition root must exist, and only adapters sit beside it.
[[tool.inwards.shape]]
packages = ["shop.infrastructure"]
require = ["__init__", "adapters/", "di.py"]
allow = []
[[tool.inwards.names]]
pattern = "test_*"
only-in = ["tests", "tests.**"]
Obie konfiguracje są fixture'ami testów (src/cli/test/support/fixtures/shapes/): każda przechodzi inwards check z zerową liczbą wyników, a testy podkładają helpers.py, services/x.py i test_x.py, żeby sprawdzić, że każdy z nich oblewa sprawdzenie z krokami naprawy pokazanymi wyżej.
Szablony¶
Gdy każda domena ma ten sam kształt, tę samą kolejność importów między swoimi modułami i te same moduły publiczne, szablon mówi to raz: template = "<nazwa>" we wpisie kształtu dostarcza allow, require, forbid, extra i hints, a ten sam szablon może się rozwinąć w warstwy dla ról i w listę public kontekstu każdej domeny. hints dodaje wskazówki projektu do kroków naprawy INW007. Dokumentacja konfiguracji pokazuje fastapi-best-practices jako jeden szablon.
Kształty z presetu¶
inwards init --style NAME --scaffold zapisuje kształty dopasowane do przykładowego pakietu. W layered, clean, hexagonal i vertical-slices sam pakiet zawiera tylko swoje pakiety warstw, bootstrap.py, __main__.py i _version.py (który zapisują hatch-vcs i setuptools-scm), a adapters/ w hexagonal zawiera tylko inbound/ i outbound/; każdy inny element jest tam błędem, bo nie należałby do żadnej warstwy. W clean i hexagonal application/ musi zawierać ports/ i use_cases/, a każdy inny element jest tam ostrzeżeniem. Pakiety warstw nie mają kształtu, więc rosną swobodnie. Instalacja wymienia presety; inwards init --list-styles wypisuje ich kształty.
Presety z kontekstami nadają kształt także swoim pakietom kontekstów, przez selektor *, więc nowy pakiet jest objęty od chwili, gdy powstanie:
| Preset | Pakiet | Kształt |
|---|---|---|
vertical-slices |
app.features |
tylko pakiety wycinków (allow = ["*/"]) |
vertical-slices |
app.features.* |
wymaga __init__ i api; dopuszcza każdy inny element |
bounded-contexts |
app |
pakiety kontekstów, bootstrap.py, __main__.py, _version.py |
bounded-contexts |
app.* |
wymaga domain/, application/, infrastructure/, api; wszystko inne jest ostrzeżeniem |
django |
app |
wymaga settings.py i urls.py; dopuszcza aplikacje, asgi.py, wsgi.py, __main__.py, _version.py |
django |
app.* |
wymaga models, services, views, urls; dopuszcza admin, apps, migrations/, tests; wszystko inne jest ostrzeżeniem |
fastapi |
app |
wymaga main.py; dopuszcza domeny, config.py, database.py, exceptions.py, models.py, pagination.py, __main__.py, _version.py |
fastapi |
app.* |
wymaga __init__, router, service; dopuszcza pozostałe role szablonu fastapi-domain; wszystko inne jest błędem |
Czego jeszcze nie obejmuje¶
Kształt pakietu nie obejmuje jeszcze plików innych niż Python ani tego, co plik zawiera.