We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Capstone Projects step 3 of 6
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
namefirst, thenkind, then the lists in the order the table gives them. - If
nameis missing or is not a string, raiseContractError("name"). - If
kindis not one of the three kinds, raiseContractError("kind"). - Each list must be a Python
listthat holds only strings. If a list is missing, is not alist, or holds a non-string, raiseContractErrorwith that list's field name, such asContractError("layers"). - Accept empty lists, repeated strings and empty strings as they are.
- Ignore extra fields. Return a new dictionary with only
name,kindand 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
layersis 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
Reportper contract, in the same order ascontracts. Each holds the contract'sname, itskindand itsviolations. - Include a report even when its
violationslist is empty.
Let mypy tell the three kinds apart
The TypedDict exercise and the assert_never exercise cover these tools.
Contractis a union of threeTypedDicttypes. Each has akindfield with one literal value, such asLiteral["layers"].- In
check_contracts, checkcontract["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.
okisFalsewhen a contract fails to parse. The driver stops at the firstContractError, anderrorholds that error'sfield.okisTruewhen every contract parsed. It says nothing about the rules, so the reports can still hold violations.reportsholds 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
importstatements misses modules loaded while the program runs, for example withimportlib.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.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Python reference solution
Sign in to track your attempts toward unlocking the reference solution.