Linter for Python architecture
Tags: python, django, architecture
Published: 2021-02-23 07:51:05

_How do you enforce architecture for your Python and Django projects other than in code reviews or guidelines?_

The goal of the architecture is to organize and constrain the dependencies between the components of your project. For example, in a regular Django project, views depend on models, and not vise versa. If you have utility functions, they shouldn't depend on any specificities of your project.

I am playing with [import-linter](https://import-linter.readthedocs.io/en/stable/readme.html), and it looks promising.

## What import-linter does

In Python projects, we define dependencies by imports. If module A imports module B, then A depends on B. The import-linter lets you specify the rules that declaratively constrain that dependency flow. In a config file, you define so-called contracts. For example, one contract can say, "in my projects, models.py must not have any imports from the views.py." 

```ini
[importlinter]
root_package = myproject

[importlinter:contract:models_views]
name = Models don't import views
type = forbidden
source_modules =
    myproject.views
forbidden_modules =
    myproject.models
```

If your models.py contains something like `from . import views`, the linter raises an error.

![import-linter error message](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tzi8iwism19f36qr54u5.png)

At the moment, there are three types of contracts:

- Forbidden modules: checks that one set of modules are not imported by another set of modules.
- Independence: checks that a set of modules do not depend on each other.
- Layers: enforces a layered architecture, where higher layers may depend on lower layers, but not the other way around.

If you can't express your architecture with these contracts, or you find yourself writing too many similar rules, you can write yours, and it's straightforward.

## How import-linter works

I love the elegance of the model behind the import-linter. To analyze the project, it builds an import graph. Your contract takes it and checks if any edges violate the contact rules. Is there an edge between module X and module Y? Are there any loops? Is there a path from X to Y, etc.?

Behind the scenes, it uses the library [grimp](https://grimp.readthedocs.io/en/stable/readme.html) that, in its turn, is based upon [NetworkX](https://networkx.org/).

## Kind of summary

**Things that I like:**

- There is no magic in the project, and the graph-based model is both transparent and powerful.
- It's quite fast even on large projects.
- It doesn't enforce defining the architecture for the entire application. On a legacy project, you can start small.
- You can define your own architecture rules with a Python class, and it's straightforward.
- Works with pre-commit.

**Things that left me confused or slightly disappointed:**

- It looks like a limited set of contract types makes you write different contracts for something that logically should be one contract.
- Documentation uses generic like "foo" or "project_one". Opinionated examples for a simple CRUD application or a typical Django or Flask project would help a lot.

I am still at the early stage of adoption but already added it to my [Python cookiecutter](https://github.com/imankulov/cookiecutter-python-project/commit/4ea55cdc1ee07753c5e865b0305acf06de678754). Let's see if it sticks.

## References

- [import-linter documentation](https://import-linter.readthedocs.io/en/stable/)
- [import-linter on GitHub](https://github.com/seddonym/import-linter)
- [a blog post with the announcement](https://seddonym.me/2019/05/20/meet-import-linter/)
- [grimp](https://grimp.readthedocs.io/en/stable/readme.html)
- [NetworkX](https://networkx.org/).