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.
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.
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.
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.
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.
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 packageX; both.pyfiles and subpackages qualify. - subpackage of
X— a submodule ofXthat is itself a package. Every subpackage is a submodule. - descendant of
X— a module nested underXat any depth.a.b.cis a descendant ofabut only a submodule ofa.b.
Rules like must_import('a.b') and must_not_import('a.b') apply to
a.b and all its descendants.
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')).
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.
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).
Both imports from inside your project and from external packages (standard library or installed packages) are supported.
This plugin uses a simple heuristic to determine the source root of your project:
- If
imports_project_pathsis set in the pytest config, use that. - Otherwise, walk up from pytest's rootpath looking for
pyproject.toml,setup.cfg, orsetup.py. - If a
src/directory exists next to that config file, usesrc/— this excludes a siblingtest/ortests/directory from the model, soproject()covers source code only. - Otherwise fall back to the directory containing the config file — which in a flat layout typically includes
test/ortests/in the model, and therefore inproject().
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.
- Add and finetune the available rule building blocks.
- Optimize the implementation with regards to speed.
Licensed under the Apache License, Version 2.0 - see LICENSE.md in project root directory.
- https://pypi.org/project/import-linter
- https://pypi.org/project/pytestarch
- https://pypi.org/project/pytest-archon
- https://github.com/jwbargsten/pytest-importson
- https://pypi.org/project/findimports
- https://pypi.org/project/pydeps (based on bytecode, not AST)
- https://docs.python.org/3/library/modulefinder.html (part of standard library, looks at runtime)
- https://pypi.org/project/archunitpython