A pythonic derivative of ArchUnit, in the form of a pytest plugin.
pytest-imports lets you write tests for the architecture of your Python project by checking its import statements.
Install pytest-imports with the package manager of your choice (e.g. pip or uv). It works out of the box for src/ and flat layouts (see Configuration), so you can use the imports fixture right away:
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.
Rules include descendants on both sides: an import of bar.x anywhere in foo or its descendants satisfies scope('foo'): must_import('bar'). Dot paths in rules are always fully qualified, even where the source uses relative imports. See Terminology for what we mean by descendant, submodule and subpackage.
| 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(targets) |
predicate | Require an import of the target (or a descendant) in scope. With a list, every target is required. |
must_not_import(targets) |
predicate | Forbid imports of the target (or a descendant) in scope. With a list, any target is forbidden. |
must_only_import(allowed, among=internal()) |
predicate | Within among, allow only the listed targets. |
must_not_import_private(targets) |
predicate | Forbid imports of private (_-prefixed) names; the optional targets narrow which ones are flagged. |
must_alias(path, alias) |
predicate | Require that path enters a namespace only under alias (e.g. numpy as np). |
descendants(path, without=...) |
target | Match descendants of path but not path itself. |
internal() |
target | Match any import resolving inside the source roots. |
stdlib() |
target | Match any non-internal import of a standard library module. |
third_party() |
target | Match any import that is neither internal nor stdlib. |
via='absolute' / via='relative' |
option | Restrict a predicate to one import style. |
A target is either a dotted-path string or one of the target helpers above.
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 module file (plugin.py). Pass a list to exclude several (without=['api', 'adapters']), or a dotted path to exclude a deeper subtree (without='db.migrations' leaves the rest of myapp.db in scope).
def test_multiple_rules_per_scope(imports):
imports.check({
scope('myapp', without=['adapters']): [
must_not_import(['sqlalchemy', 'flask']),
must_import('myapp.core'),
],
})- A list of targets in
must_not_importis disjunctive: an import of either is a violation, and the failure message names which one matched. - A list of targets in
must_importis conjunctive: every target must be imported somewhere in scope. - A list of predicates applies several rules to one scope. All failures are reported together.
def test_no_relative_imports_in_public_api(imports):
imports.check({
scope('myapp.api'): must_not_import('myapp', via='relative'),
})via='absolute' or via='relative' restricts a rule to that import style; without via, both match.
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') matches myapp.capture.parser, myapp.capture.config, … but not myapp.capture itself. So the rest of myapp may use the public surface (import myapp.capture) but not the internals. The plain string 'myapp.capture' would flag both.
without= carves subtrees out of the match, relative to the path: descendants('myapp.contrib', without='admin') matches everything under myapp.contrib except myapp.contrib.admin and its descendants. It takes the same shapes as scope(without=...), and pairs well with must_only_import to say "allow everything under X except Y".
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() matches every import that resolves to a module under the configured source roots. With via='absolute', this rule requires all internal imports to be relative: from .aaa import ... rather than from myapp.core.aaa import ..., including across packages (e.g. myapp.other imported from myapp.core.bbb).
This is the opposite of ruff's TID252 (relative-imports), which bans relative imports in favor of absolute ones.
from pytest_imports import must_only_import, scope
def test_api_layer_imports(imports):
imports.check({
scope('myapp.api'): must_only_import(['myapp.core', 'myapp.schemas']),
})must_only_import is the allowlist counterpart of must_not_import: within a universe of imports, only the listed targets are allowed.
- The universe is
among, which defaults tointernal(). So the rule above says "myapp.apimay only reach intomyapp.coreandmyapp.schemas", and leaves stdlib and third-party imports alone. - Change
amongto narrow or shift the universe, e.g.among=descendants('myapp')to police onlymyapp.*, oramong=third_party()to allowlist external dependencies (see below). - An empty allowlist,
must_only_import([]), forbids every import withinamong: a guardrail for a leaf module that must not reach back into the project.
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() flags any import with a dotted-path part starting with _ (except __future__). project() covers every module under the source roots. Optional targets narrow which private imports are flagged:
must_not_import_private('myapp'): only those frommyapp.must_not_import_private(descendants('myapp.capture')): only those from that subtree.must_not_import_private(internal()): only those of project modules.must_not_import_private([internal(), third_party()]): all except the stdlib.
from pytest_imports import must_alias, project
def test_numpy_alias(imports):
imports.check({
project(): must_alias('numpy', 'np'),
})must_alias('numpy', 'np') flags any import that brings numpy into a module's namespace under another name:
- Rejected:
import numpy(bare name),import numpy as foo(wrong alias),import numpy.linalg(binds the barenumpy),import numpy.linalg as np(npbound to a submodule), andfrom numpy import *. - Accepted:
import numpy as np,import numpy.linalg as nl, and from-imports of members or submodules (from numpy import array), since they don't bindnumpyitself.
Unlike must_import, it does not require numpy to be imported at all.
from pytest_imports import (
internal, must_not_import, must_not_import_private, must_only_import,
project, scope, stdlib, third_party,
)
def test_external_dependencies(imports):
imports.check({
# Pure core: stdlib and internal imports only.
scope('myapp.domain'): must_not_import(third_party()),
# The only third-party dependency the API layer may use is fastapi.
scope('myapp.api'): must_only_import('fastapi', among=third_party()),
# Ignore private names reached through the stdlib (_thread, os._exit, …).
project(): must_not_import_private([internal(), third_party()]),
# The sandboxed plugin layer may only use these stdlib modules.
scope('myapp.plugins'): must_only_import(
['__future__', 'json', 'pathlib'], among=stdlib()
),
})stdlib() and third_party() let you say "no third-party dependencies here" without listing every installed package, so the rule doesn't go stale when a dependency is added. How imports are classified, and the caveats, are described under Internal, stdlib and third-party imports.
For dashboards, ratchets or benchmarks, use imports.violations(rules) instead of imports.check(rules). It takes the same rules and returns the violation messages instead of 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 when the list is non-empty.
This project keeps a consistent vocabulary; see GLOSSARY.md for the full list. The key distinctions:
- submodule of
X: a direct child of packageX, either a.pyfile or a subpackage. - subpackage of
X: a submodule ofXthat is itself a package. - descendant of
X: a module nested underXat any depth.a.b.cis a descendant ofa, but a submodule only ofa.b.
The plugin parses your source files with the standard library's ast module and collects their import statements the first time a test uses the imports fixture. The analysis is static and superficial:
- Python is dynamic, so the rules are easy to circumvent on purpose. The plugin assumes a "friendly" codebase.
- It does not track how imported names are used. After
import a, a call toa.b()does not count as importinga.b. - Only files under the source roots are analyzed.
- Relative imports that Python itself rejects (one in a top-level module, or one that climbs beyond the top-level package) are skipped with a warning.
Every import falls into exactly one of three classes:
| Target | Matches |
|---|---|
internal() |
The import resolves to a module under the configured source roots. |
stdlib() |
Not internal, and the top-level name is in sys.stdlib_module_names. |
third_party() |
Neither internal nor stdlib. |
- Internal wins. If your project has its own top-level
loggingpackage, imports of it matchinternal()only. Relative imports are always internal. __future__is stdlib, so a stdlib allowlist must name it to permitfrom __future__ import annotations.- Typeshed-only modules such as
_typeshedare not insys.stdlib_module_names, so they count as third-party. - Stdlib membership depends on the Python version running pytest, but not on the platform (
winregis stdlib everywhere). For example,annotationlibis third-party before 3.14, anddistutilsis third-party on 3.12+. For a version-dependent fallback such astry: from compression import zstd/except ImportError: from backports import zstd, use an allowlist rather than a ban, so the rule passes on every version:must_only_import(['compression', 'backports.zstd'], among=third_party()).
The model is built once per test session, so each test only pays for evaluating its rules, which takes well under a millisecond for most rules. Building the model is linear in the size of the source tree. For reference, the in-repo benchmark against Django 5.2 (~2,800 modules, ~18,000 import statements) builds the model in ~2.7 s on a modern laptop. Even the most expensive project-wide rule, must_not_import(internal(), via='absolute'), which scans every import, takes ~45 ms. See benchmark/ and uv run nox -s benchmark.
The plugin determines the source roots as follows:
- If
imports_project_pathsis set in the pytest config, use that. - Otherwise, walk up from pytest's rootpath to find
pyproject.toml,setup.cfgorsetup.py. - If a
src/directory exists next to that file, usesrc/. A siblingtest/ortests/directory is then not part of the model or ofproject(). - Otherwise, use the directory containing that file. In a flat layout this typically includes
test/ortests/.
The imports_project_paths fixture shows the resolved source roots. If they are not what you want (say, a flat layout without tests, or a src/ layout plus test/), set imports_project_paths explicitly, or use a narrower scope such as scope('myapp') instead of project():
[tool.pytest.ini_options] # or [tool.pytest] with pytest 9.0+
imports_project_paths = ["src", "test"]Any other config format that pytest supports works too.
Each entry should normally be an import root: the directory you would put on sys.path, such as src/. You may also point at a package directory (one with an __init__.py), such as src/myapp. Only that package is then analyzed, but its modules keep their full names (myapp.core, not core). Imports of sibling packages outside it then count as external rather than internal().
- Namespace packages: a directory without
__init__.pyis always treated as an import root, because it is indistinguishable from a namespace package. So don't point at a namespace package directly. - Duplicate or nested entries: an entry that repeats another, or lies inside another, is skipped with a warning.
- import-linter
- pytestarch
- pytest-archon
- pytest-importson
- findimports
- pydeps (based on bytecode, not AST)
- modulefinder (standard library, looks at runtime)
- archunitpython
Licensed under the Apache License, Version 2.0; see LICENSE.md in the project root directory.