Skip to content

← Capstone Projects step 3 of 6

Hard End-to-End

Capstone: Check a Package's Layer and Import Rules

Build a checker for the import rules of a Python package. It reads the package's import graph and a list of rules, called contracts, and reports each chain of imports that breaks a rule.

The import graph is a dictionary that maps each module to the modules it imports. A chain is the list of modules along one import path, from the importing module to the imported one.

graph: Graph = {
    "pricing.domain": ["pricing.adapter"],
    "pricing.adapter": ["sqlite3"],
}
contract = parse_contract({
    "name": "domain stays off the database",
    "kind": "forbidden",
    "source_modules": ["pricing.domain"],
    "forbidden_modules": ["sqlite3"],
})
reports = check_contracts(graph, [contract])
print(reports[0]["violations"])
# [['pricing.domain', 'pricing.adapter', 'sqlite3']]

pricing.domain never imports sqlite3 itself, but it reaches it through pricing.adapter. The report shows the whole chain, so you can see which import to remove.

What to write

Fill in these two functions. The starter supplies the Graph, Contract and Report types and ContractError. Make the file pass mypy --strict.

def parse_contract(raw: Mapping[str, object]) -> Contract: ...
def check_contracts(
    graph: Graph, contracts: Sequence[Contract]
) -> list[Report]: ...

You get the graph as a dictionary, so you don't read source files or import any modules.

Parse each contract

A contract is a dictionary with a string name and a kind. The kind says which lists of module names the contract needs, and what breaks the rule. "Reaches" means imports directly or through other modules.

Kind Lists it needs The rule breaks when
"layers" layers A lower layer reaches a higher one
"forbidden" source_modules, forbidden_modules A source module reaches a forbidden one
"independence" modules One of the modules reaches another

parse_contract turns one raw dictionary into the matching contract type.

  • Check name first, then kind, then the lists in the order the table gives them.
  • If name is missing or is not a string, raise ContractError("name").
  • If kind is not one of the three kinds, raise ContractError("kind").
  • Each list must be a Python list that holds only strings. If a list is missing, is not a list, or holds a non-string, raise ContractError with that list's field name, such as ContractError("layers").
  • Accept empty lists, repeated strings and empty strings as they are.
  • Ignore extra fields. Return a new dictionary with only name, kind and the lists that kind needs.

Match modules by name

Each string in a contract's lists is an entry. The exercise on finding forbidden import chains matches package names the same way.

  • An entry covers the module with exactly that name and every module below it. "pricing.domain" covers "pricing.domain.rules" but not "pricing.domain_extra".
  • The graph's modules are its keys plus every name in its import lists. A module that only appears in an import list, such as sqlite3, imports nothing.
  • An entry that covers no module in the graph matches nothing. It is not an error.

Check every pair of entries

Each contract checks pairs of entries. In each pair, one entry is the source and the other is the target. The layering exercise checked direct imports only. Here a chain through other modules breaks the rule too.

  • In layers, the list goes from the highest layer to the lowest. A layer is one level of the design, such as the API, the services or the domain.
  • A higher layer may import a lower one. So pair each layer, as the source, with every layer above it, as the target.
  • A repeated entry in layers is still its own layer. ["a", "a"] pairs the second "a" with the first.
  • In forbidden, pair every source entry with every forbidden entry, even when the two strings are equal.
  • In independence, pair every entry with every other entry, in both directions. Two copies of the same string are not a pair.

Find the shortest chain for each pair

  • Follow imports any number of steps, through modules that no contract names.
  • The graph can contain cycles. Make sure your search still ends.
  • For each pair, find the shortest chain from any module the source covers to any module the target covers.
  • If several shortest chains tie, keep the one that sorts first when you compare the lists with <.
  • If the source and the target both cover a module, the chain is that module alone, such as ["pricing.api"]. If they share several modules, use the one whose name sorts first.
  • A pair with no chain adds nothing to the report.

Build one report per contract

  • Put each pair's chain in the contract's violations. Drop duplicate chains, then sort the rest with Python's ordinary list ordering. It compares names, not lengths, so ["b", "c", "d"] comes before ["c", "b"].
  • Return one Report per contract, in the same order as contracts. Each holds the contract's name, its kind and its violations.
  • Include a report even when its violations list is empty.

Let mypy tell the three kinds apart

The TypedDict exercise and the assert_never exercise cover these tools.

  • Contract is a union of three TypedDict types. Each has a kind field with one literal value, such as Literal["layers"].
  • In check_contracts, check contract["kind"] before you read a kind's lists. mypy then knows which of the three types you have.
  • End the branches with else: assert_never(contract). If someone adds a fourth kind to the union and does not handle it, mypy reports an error on that line.

What the driver checks

Keep solve as it is. It parses every contract first, then checks them.

  • ok is False when a contract fails to parse. The driver stops at the first ContractError, and error holds that error's field.
  • ok is True when every contract parsed. It says nothing about the rules, so the reports can still hold violations.
  • reports holds your reports, one per contract.

Going further

  • The three kinds of contract come from Import Linter, a tool that builds the graph from your source code and runs in CI.
  • A clean report covers only the imports in the graph. A graph built from import statements misses modules loaded while the program runs, for example with importlib.import_module.
  • The article on testing module boundaries shows two checks that go well with this one. You can block imports in a fresh process, and you can run two adapters on the same test cases.