Roman Imankulov

Roman Imankulov

Python Developer from Porto

23 Feb 2021

Linter for Python Architecture

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 was playing with import-linter, 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.”

[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.

An import-linter error message

An import-linter error message

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 that, in its turn, is based upon NetworkX.

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. Let’s see if it sticks.

References