Package shape¶
Layer rules (INW001) look at imports. They can't see where code lives: a new helpers.py next to service.py, a services/ package beside service.py, or a test_orders.py inside the app all pass them. Package shape closes that gap. You say which members a package may, must and must not hold, and Inwards checks it on every edit (the hook), in the editor (inwards server) and in CI (inwards check).
| Rule | Reports | Where |
|---|---|---|
INW007 package-shape |
a member the shape doesn't allow (extra, error by default) or forbids; a name outside its only-in packages; a shape selector matching no package (warning) |
on the file, line 1; a dead selector in pyproject.toml |
INW008 missing-member |
a member the shape requires is absent | on the package's __init__.py, or its first file without one |
Configure it¶
[[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.**"]
- Selectors (
packages,only-in) match packages, in import-linter's grammar:a.bis exact,a.*is one segment belowa,a.**is any depth belowa(notaitself). A new domain such asapp/payments/is covered byapp.*the moment it exists. - The first matching entry wins. Put exact entries before the globs that would also match them. An entry whose selector an earlier one already covers could never apply, so it is a config error:
app.ordersafterapp.*, a repeated selector, orapp.*afterapp.**. A glob that only overlaps an earlier one (app.**afterapp.*) is fine. Selector segments are Python identifiers,*or**. - Member patterns are fnmatch globs (
*,?,[seq],[!seq]), matched as Python'sfnmatch.fnmatchcasematches them: inside[...],*and?are literal. A[that is never closed, or a reversed range such as[z-a], is a config error.nameis a module or a subpackage,name/a subpackage only,name.pya module only (.pyistubs count as modules).__init__is always allowed, and so is everything inrequire. Withoutallow, any member is allowed and onlyrequireandforbidapply. - A shape covers a package's direct members.
app/orders/services/x.pyis memberservices/ofapp.orders; the files insideservices/answer to a shape forapp.orders.services, if one exists. Members are named from the file system:utils.helpers.pyis one module member, notutils/, so it matches neitherutilsnorhelpers. Hidden files and directories are skipped, as every Inwards file walk skips them. - Names rules apply at every level: a member matching
patternanywhere outside theonly-inpackages is an INW007 error.
Unknown keys, malformed selectors or patterns, and shadowed entries are config errors (exit 2). The config guard treats shape and names like the rest of [tool.inwards]: an agent's edit to them is denied.
What the agent sees¶
The message names the member and the package, never the allowed list, so a baseline entry survives a change to allow. The fix steps list the allowed members and name the one the code most likely belongs in, found through built-in synonyms (helpers → utils, services → service, test_* → tests), a suffix match (order_service → service) or a small edit distance (rooter → router; 2 edits for names of 6 characters or more, 1 for 3 to 5, none below that, so db.py isn't sent to 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.
- Per edit (PostToolUse). INW007 blocks (exit 2) when the edited file is new this session. For a file that already existed when the session started, it goes back as context only, so legacy layout never blocks an unrelated edit. INW008 for the edited package is always context: the agent creating
app/payments/router.pyhears thatservice.pyis still missing, and can add it next. - Stop gate. INW008 findings that are new since the session started block the turn, so deleting a required
service.pythrough Bash is caught. A package that already lacked it at session start doesn't block. INW007 on files that predate the session doesn't block either. - Editor.
inwards serverreports INW007 on the file you type in as you type. Its whole pass, which isinwards checkin each workspace folder, puts INW007 on files you haven't opened and INW008 on the package's__init__.py; it runs when the editor starts the server, on every save, and once per burst of file events when files are created or deleted. - CI. A whole-project
inwards checkreports INW007 on every file, INW008 for every shaped package, and a warning for a selector that matches no package.
Example: fastapi-best-practices¶
The fastapi-best-practices layout gives each domain the same modules: router, schemas, models, service, dependencies, config, constants, exceptions and utils. The guide names its top package src; here it is app. Tests live in a top-level tests/ package.
[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.**"]
Example: clean architecture¶
The clean-architecture tree splits the code into domain (model/), application (ports/ and use_cases/) and infrastructure (adapters/ and the composition root di.py). Each layer package gets an exact shape; an empty allow means only the required members and __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.**"]
Both configs are test fixtures (src/cli/test/support/fixtures/shapes/): each passes inwards check with 0 findings, and the tests plant helpers.py, services/x.py and test_x.py to check that each fails with the fix above.
Templates¶
When every domain has the same shape, the same import order between its modules and the same public modules, a template says it once: template = "<name>" on a shape entry supplies allow, require, forbid, extra and hints, and the same template can expand into layers for the roles and into the public list of each domain's context. hints adds project advice to the INW007 fix steps. The configuration reference shows fastapi-best-practices as one template.
Shapes from a preset¶
inwards init --style NAME --scaffold writes shapes that fit its example package. In layered, clean, hexagonal and vertical-slices, the package itself holds only its layer packages, bootstrap.py, __main__.py and _version.py (which hatch-vcs and setuptools-scm write), and adapters/ in hexagonal holds only inbound/ and outbound/; any other member there is an error, because no layer would hold it. In clean and hexagonal, application/ must hold ports/ and use_cases/, and any other member there is a warning. The layer packages have no shape, so they grow freely. Install lists the presets; inwards init --list-styles prints their shapes.
The presets with contexts also shape their context packages, through a * selector, so a new one is covered the moment it exists:
| Preset | Package | Shape |
|---|---|---|
vertical-slices |
app.features |
slice packages only (allow = ["*/"]) |
vertical-slices |
app.features.* |
requires __init__ and api; allows any other member |
bounded-contexts |
app |
context packages, bootstrap.py, __main__.py, _version.py |
bounded-contexts |
app.* |
requires domain/, application/, infrastructure/, api; anything else is a warning |
django |
app |
requires settings.py and urls.py; allows apps, asgi.py, wsgi.py, __main__.py, _version.py |
django |
app.* |
requires models, services, views, urls; allows admin, apps, migrations/, tests; anything else is a warning |
fastapi |
app |
requires main.py; allows domains, config.py, database.py, exceptions.py, models.py, pagination.py, __main__.py, _version.py |
fastapi |
app.* |
requires __init__, router, service; allows the other roles of the fastapi-domain template; anything else is an error |
Not covered yet¶
Non-Python files, and what a file contains.