Skip to content

nwilbert/pytest-imports

Repository files navigation

pytest-imports

A pythonic derivative of ArchUnit, in the form of a pytest plugin.

The idea is to write automated tests for the architecture aspects of your Python project. This plugin specifically covers import statements in your Python code, enabling you to check the dependencies in your project.

Simple example

from pytest_imports import must_import, must_not_import, scope

def test_imports(imports):
    imports.check({
        scope('foo'): must_import('bar'),
        scope('baz'): must_not_import('qux'),
    })

This checks that module foo imports bar, and that module baz does not import qux.

Both must_import and must_not_import are inclusive with regards to descendants (i.e., if there is an import of foo.foo2 in a descendant bar.bar2 then the rule is satisfied). See Terminology for what we mean by descendant, submodule, and subpackage.

Installation & use

Install pytest-imports via the Python package manager of your choice (e.g., pip or uv).

If your project structure is "normal" then you can simply start using the imports fixture in your tests right away, as seen above.

Complex examples

Dot paths in rules are always specified as fully qualified absolute paths (using . as separator). See Terminology and GLOSSARY.md for the project's vocabulary.

Quick reference of the building blocks used below:

Name Kind Purpose
scope(path) scope Restrict a rule to path and its descendants.
scope(path, without=...) scope Same, but exclude named submodules or subpackages.
project() scope All modules under the configured source roots.
must_import(target) predicate Require an import of target (or a descendant) in scope.
must_not_import(target) predicate Forbid imports of target (or a descendant) in scope.
must_not_import_private() predicate Forbid imports of any private (_-prefixed) name.
descendants(path) target Match descendants of path but not path itself.
internal() target Match any import resolving inside the source roots.
via='absolute' / via='relative' option Restrict a predicate to one import style.

from pytest_imports import must_import, must_not_import, scope

def test_layered_architecture(imports):
    imports.check({
        scope('myapp', without='api'): must_not_import('myapp.api'),
        scope('myapp.api'): must_import('myapp.core'),
    })

scope('myapp', without='api') covers all of myapp except myapp.api and its descendants. The excluded name can be a subpackage (api/) or a .py module file (plugin.py) — anything that appears as a direct or nested name in the tree. Pass a list to exclude multiple paths: without=['api', 'adapters']. Each entry can also be a dotted path into a deeper subtree, e.g. without='db.migrations' excludes only myapp.db.migrations (and its descendants) while leaving the rest of myapp.db in scope.

def test_no_relative_imports_in_public_api(imports):
    imports.check({
        scope('myapp.api'): must_not_import('myapp', via='relative'),
    })

Via the via argument you can restrict a rule to only absolute (via='absolute') or only relative (via='relative') imports. Omitting via matches both.

def test_multiple_rules_per_scope(imports):
    imports.check({
        scope('myapp', without=['adapters']): [
            must_not_import('sqlalchemy'),
            must_not_import('flask'),
        ],
    })

A list of predicates can be used to apply multiple rules to the same scope. All failures are reported together rather than stopping at the first violation.

from pytest_imports import must_not_import_private, project

def test_no_private_imports(imports):
    imports.check({
        project(): must_not_import_private(),
    })

must_not_import_private() checks that no module imports a private name — any dotted-path part starting with _ or __, except the standard __future__ module. project() is a special scope covering all modules under the configured source root — see Configuration for which paths that includes (notably, with a src/ layout project() does not include test folders, but with a flat layout it does). You can restrict to a specific package with must_not_import_private('myapp').

from pytest_imports import descendants, must_not_import, scope

def test_capture_internals_are_encapsulated(imports):
    imports.check({
        scope('myapp', without='capture'):
            must_not_import(descendants('myapp.capture')),
    })

descendants('myapp.capture') is a target helper that matches the descendants of myapp.capture (myapp.capture.parser, myapp.capture.config, …) but not myapp.capture itself. This lets the rest of myapp use the myapp.capture public surface (import myapp.capture) while keeping its internals private. A plain string target like 'myapp.capture' would also flag import myapp.capture, which is usually not what you want here.

from pytest_imports import internal, must_not_import, project

def test_internal_imports_are_relative(imports):
    imports.check({
        project(): must_not_import(internal(), via='absolute'),
    })

internal() is a target helper that matches every import whose target resolves to a module under the configured source roots. Combined with via='absolute' this enforces project-wide that all internal imports are written as relative imports — e.g. from .aaa import ... rather than from myapp.core.aaa import .... Unlike a parent-package-only check, this also flags an absolute import of myapp.other from myapp.core.bbb.

Note: This is similar to ruff's TID252 (relative-imports) rule, but works in the opposite direction — TID252 bans relative imports in favor of absolute ones, while must_not_import(internal(), via='absolute') bans absolute internal imports in favor of relative ones.

Reporting violations without failing

For dashboards, ratchets, or benchmarks, use imports.violations(rules) instead of imports.check(rules). It accepts the same rules dictionary and returns the list of violation messages without raising:

def test_track_legacy_couplings(imports):
    failures = imports.violations({
        scope('myapp.api'): must_not_import('myapp.legacy'),
    })
    print(f'{len(failures)} legacy coupling(s) remain')

check() is violations() plus an AssertionError on non-empty output, so both report the same messages.

Details

Terminology

This project keeps a deliberate, consistent vocabulary — see GLOSSARY.md for the full list. The most important distinctions:

  • submodule of X — a module that is a direct child of package X; both .py files and subpackages qualify.
  • subpackage of X — a submodule of X that is itself a package. Every subpackage is a submodule.
  • descendant of X — a module nested under X at any depth. a.b.c is a descendant of a but only a submodule of a.b.

Rules like must_import('a.b') and must_not_import('a.b') apply to a.b and all its descendants.

How it works

This plugin uses the ast module from the standard library to analyze the abstract syntax tree of your project. Import statements are collected and normalized when the imports fixture is first used in a test session.

The analysis is superficial, so there are limitations. Due to the dynamic nature of Python it is easy to circumvent tests if you want to. So we assume that this plugin is used in a "friendly" context.

Note that we don't track how the imported symbols are used. For example, in the case of

import a
...
a.b()

you will not be able to check that a.b is used (e.g., via must_import('a.b')).

Performance

The model is built once per test session (the imports fixture is session-scoped), so per-test cost is essentially the cost of evaluating the rules — well under a millisecond for most rules. Building the model is linear in the size of the source tree: as a reference point, the in-repo benchmark against the full Django 5.2 source tree (~2,800 modules, ~18,000 import statements) builds the model in ~2.7 s on a modern laptop, and even the most expensive project-wide rule (must_not_import(internal(), via='absolute'), scanning every import in the project) completes in ~45 ms. See benchmark/ and uv run nox -s benchmark for the full suite.

Absolute vs. relative imports

Dot paths in rules are always specified as fully qualified absolute paths, regardless of whether relative imports are used in the source. You can optionally use the via argument to distinguish between absolute and relative imports.

Note that relative imports from outside the configured project source directory are not supported (because we can't normalize those).

Internal vs. external imports

Both imports from inside your project and from external packages (standard library or installed packages) are supported.

Configuration

This plugin uses a simple heuristic to determine the source root of your project:

  1. If imports_project_paths is set in the pytest config, use that.
  2. Otherwise, walk up from pytest's rootpath looking for pyproject.toml, setup.cfg, or setup.py.
  3. If a src/ directory exists next to that config file, use src/ — this excludes a sibling test/ or tests/ directory from the model, so project() covers source code only.
  4. Otherwise fall back to the directory containing the config file — which in a flat layout typically includes test/ or tests/ in the model, and therefore in project().

You can check the resolved source root via the imports_project_paths fixture in a test. If the auto-detected scope is not what you want — for example, you have a flat layout but want to exclude tests, or your project uses src/ but you also want to apply rules to test/ — set imports_project_paths explicitly, or use a narrower scope such as scope('myapp') instead of project().

To specify the source root in the pytest configuration, if you use a pyproject.toml then this looks like:

[tool.pytest.ini_options]
    imports_project_paths = [
        "foo/bar",
    ]

With pytest 9.0+ you can also use the native TOML table:

[tool.pytest]
    imports_project_paths = [
        "foo/bar",
    ]

Other config formats are supported as well, as long as they are supported by pytest.

Future plans

  • Add and finetune the available rule building blocks.
  • Optimize the implementation with regards to speed.

License

Licensed under the Apache License, Version 2.0 - see LICENSE.md in project root directory.

Related Python libraries

About

A pythonic derivative of ArchUnit, in the form of a pytest plugin.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages