Migrating from import-linter¶
import-linter contracts convert to a [tool.inwards] table in one command. inwards import-config reads the contracts, prints the table, and says for each contract whether it was carried over in full, in part, or not at all, and why.
Convert¶
Run it where lint-imports runs:
inwards import-config # print the table on stdout, the report on stderr
inwards import-config --write # append the table to pyproject.toml
inwards import-config setup.cfg # read this file instead of looking for one
Without a file it looks where lint-imports does, in the current directory and in this order: setup.cfg (its [importlinter] sections), .importlinter, then pyproject.toml ([tool.importlinter]). A file whose name ends in .toml is read as TOML, anything else as INI.
- Output. The table goes to stdout, so
inwards import-config > inwards.tomlkeeps only TOML. The report goes to stderr. --writeappends the table to thepyproject.tomlnext to the import-linter config. It refuses, with exit code 2 and nothing written, when that file has nopyproject.tomlnext to it, or whenpyproject.tomlalready has[tool.inwards]: it never overwrites or merges into an existing table. Print the table instead and merge it by hand.root. When the root package lives insrc/(src/<package>, or auv_buildproject), the table getsroot = "src", asinwards initwould pick it.- Exit codes. 0 when the table was printed or written, even if some contracts couldn't be converted (the report lists them). 2 when no import-linter config was found, the file doesn't parse, or
--writerefused.
The table is checked before it is printed: it must parse as an Inwards config, or the command stops with exit code 2.
An example¶
This .importlinter has a layers contract with two independent siblings, a forbidden contract with an ignored import, and a protected contract:
[importlinter]
root_package = shop
include_external_packages = True
[importlinter:contract:layers]
name = Web on top, the domain at the bottom
type = layers
layers =
shop.web
shop.orders | shop.billing
shop.domain
[importlinter:contract:pure-domain]
name = The domain stays pure
type = forbidden
source_modules = shop.domain
forbidden_modules = pydantic
ignore_imports = shop.domain.money -> pydantic
[importlinter:contract:db]
name = Only the repository imports the database
type = protected
protected_modules = shop.db
allowed_importers = shop.repository
inwards import-config prints:
# Converted from .importlinter by inwards import-config.
[tool.inwards]
layers = [
{ name = "domain", modules = ["shop.domain"], extend-deny-libraries = ["pydantic"] },
[
{ name = "orders", modules = ["shop.orders"] },
{ name = "billing", modules = ["shop.billing"] },
],
{ name = "web", modules = ["shop.web"] },
]
and reports:
inwards import-config: converted 2 of 3 contracts from .importlinter.
mapped layers ("Web on top, the domain at the bottom")
partial pure-domain ("The domain stays pure")
ignore_imports (shop.domain.money -> pydantic) has no equivalent: add an inline suppression with a reason at each import, or run `inwards baseline` once the config is in place.
skipped db ("Only the repository imports the database")
protected contracts have no equivalent: Inwards limits what a context exposes (`public`), not who may import it.
Inwards checks direct imports only: an import chain through a third module, which import-linter follows, isn't reported.
Inwards also runs rules import-linter doesn't have (INW006 for code outside every layer, INW005's default deny list on the innermost layer, INW010, INW011): run `inwards check`, then `inwards baseline` to accept what is there today.
How contracts map¶
| import-linter | Inwards | Rule |
|---|---|---|
layers, listed high to low |
layers, listed innermost first: the order is reversed |
INW001 |
a layer line a : b (siblings that may import each other) |
one layer holding both modules | INW001 |
a layer line a | b (independent siblings) |
sibling layers: a nested array in layers, one layer per module, in the line's place |
INW001 |
containers |
every layer repeated under each container, in the same Inwards layer | INW001 |
(optional) layers |
a plain layer; reported, because a prefix that matches no module gets an INW006 warning | INW006 |
exhaustive = true |
reported: a module outside every layer gets the INW006 warning, not an error | INW006 |
exhaustive_ignores |
ignore, one entry per container |
INW006 |
independence |
one context per module, none depending on another | INW002 |
forbidden, first-party forbidden_modules |
one context per source and forbidden module; each source's depends-on leaves out what it may not import |
INW002 |
forbidden, external forbidden_modules |
extend-deny-libraries on the layers that hold exactly the source modules; otherwise a deny entry in [tool.inwards.rules.pure-domain] for the source modules |
INW005 |
root_package, root_packages |
with no layers contract, one layer holding the root packages; they also decide which forbidden modules are external |
A forbidden pair covers everything below its two ends, as import-linter's packages do: when shop.a may not import shop.c, a context declared inside shop.a may not import shop.c either. Each generated context is public in full (public is its own module), so INW003 reports nothing extra, and cycles = [] keeps INW004 from reporting cycles between the new contexts, which import-linter didn't check.
When there are several layers contracts, the first one sets the layer order. A later one joins it when it has the same layers in other containers, or when every module it lists is already a layer in the same order. Anything else is reported: Inwards has one layer order per config.
What doesn't convert¶
Each of these is reported with its contract, never dropped silently:
ignore_imports. Inwards has no config-level exception for one import. After writing the table, runinwards check, then either add an inline suppression with a reason at each import (# inwards: ignore[INW002] reason="...", see ADR-028) or accept today's violations withinwards baseline(on an existing codebase).- Wildcards (
mypackage.*,mypackage.**.models) in any module list. Inwards takes literal module names; the contract is skipped, and you list the modules the wildcard matches by hand. as_packages = false. An Inwards module name always covers everything below it.protectedcontracts. Inwards limits what a context exposes to others (public, INW003), not which modules may import it.acyclic_siblingscontracts. INW004 reports cycles between modules or between contexts, not between sibling packages; setcycles = ["modules", "contexts"]by hand if that is close enough.- Custom contract types, and a layer line that mixes
|and:(import-linter rejects it too). broken_contract_guidance. Inwards writes its own fix steps for each violation.
What changes after the switch¶
- Direct imports only. import-linter follows import chains: in a layers contract,
lowmay not importutilswhenutilsimportshigh. Inwards checks each import on its own, so that chain passes unlessutilsis in a layer. - Containers are checked against each other. import-linter lets
foo.lowimportbar.highwhenfooandbarare different containers. Inwards has one layer order for the project, so it reports that import. - Inwards' own rules run too. INW006 reports a layer importing first-party code that belongs to no layer (an error) and a package outside every layer (a warning). The innermost of two or more layers gets INW005's default deny list of frameworks and I/O libraries. INW010 reports imports of first-party modules that don't exist, and INW011 dynamic imports. Run
inwards checkonce and decide: fix, add the missing packages to a layer or toignore, or accept what is there withinwards baseline.
A migration then looks like this: run inwards import-config --write, read the report, run inwards check, handle what it finds, and remove the lint-imports step from CI once inwards check passes there (GitHub Actions).